From b48fcc8465273af6142286b0774b702a70fbdbde Mon Sep 17 00:00:00 2001 From: Kyle Isom Date: Thu, 11 Jun 2026 11:20:50 -0700 Subject: [PATCH] sso: public MCIAS authorize URL + docs Add [sso].public_url so the browser SSO authorize redirect uses the public MCIAS hostname while the code exchange stays on the internal address (mcdsl v1.9.0). Document the SSO URL split and the rootless-podman / unikernel-eligibility rules in CLAUDE.md. Co-Authored-By: Claude Opus 4.8 --- CLAUDE.md | 31 +++++++++++++++++++++++++++++++ cmd/mcq/server.go | 1 + go.mod | 2 +- go.sum | 4 ++-- internal/config/config.go | 10 +++++++++- internal/webserver/server.go | 11 +++++++++-- 6 files changed, 53 insertions(+), 6 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index d7aeeab..8946cab 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -67,3 +67,34 @@ template rendering). 3. **No test frameworks**: stdlib `testing` only, real SQLite in t.TempDir(). 4. **CSRF on all web mutations**: double-submit cookie pattern. 5. **Session cookies**: HttpOnly, Secure, SameSite=Strict. + +## SSO (public vs internal MCIAS URLs) + +MCQ is reachable publicly (`mcq.metacircular.net`), so its SSO uses **two** +MCIAS URLs (via `mcdsl/sso` ≥ v1.9.0): + +- `[mcias].server_url` — the **internal** address (`https://mcias.svc.mcp.metacircular.net:8443`) + used for the server-to-server authorization-code exchange. Efficient and + does not depend on the public edge. +- `[sso].public_url` — the **public, browser-facing** MCIAS base URL + (`https://mcias.metacircular.net`) used to build the authorize redirect, so + end-user browsers (which can't resolve the internal name) can reach it. +- `[sso].redirect_uri` must be the **public** callback + (`https://mcq.metacircular.net/sso/callback`) and must match the + `redirect_uri` registered for the `mcq` SSO client in MCIAS + (`mciasctl sso update --client-id mcq --redirect-uri ...`). + +If `public_url` is empty the authorize redirect falls back to `server_url` +(tailnet-only SSO). The startup log prints both `authorize_url` and +`exchange_url` so you can confirm the split. + +## Deployment / runtime + +- **Containers run rootless under MCP.** Dockerfiles must NOT declare + `VOLUME /srv/mcq`, pre-create/chown the data dir, or set `USER` — MCP + bind-mounts `/srv/mcq` and runs `--user 0:0`. See + `../engineering-standards.md` → Containerization. +- **Not unikernel-eligible (yet).** MCQ writes a SQLite DB to `/srv/mcq`; + the unikernel runtime currently bakes config/certs read-only and has no + writable host mount, so MCQ stays a container until 9p/virtio-blk storage + lands. See `docs/unikernels.md` in the workspace root. diff --git a/cmd/mcq/server.go b/cmd/mcq/server.go index 87d9296..eb70597 100644 --- a/cmd/mcq/server.go +++ b/cmd/mcq/server.go @@ -78,6 +78,7 @@ func runServer(configPath string) error { ServiceName: cfg.MCIAS.ServiceName, Tags: cfg.MCIAS.Tags, MciasURL: cfg.MCIAS.ServerURL, + PublicURL: cfg.SSO.PublicURL, CACert: cfg.MCIAS.CACert, RedirectURI: cfg.SSO.RedirectURI, } diff --git a/go.mod b/go.mod index 45b5b24..f0ecc80 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module git.wntrmute.dev/mc/mcq go 1.25.7 require ( - git.wntrmute.dev/mc/mcdsl v1.7.0 + git.wntrmute.dev/mc/mcdsl v1.9.0 github.com/alecthomas/chroma/v2 v2.18.0 github.com/go-chi/chi/v5 v5.2.5 github.com/mark3labs/mcp-go v0.46.0 diff --git a/go.sum b/go.sum index c842129..a37aeee 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,5 @@ -git.wntrmute.dev/mc/mcdsl v1.7.0 h1:dAh2SGdzjhz0H66i3KAMDm1eRYYgMaxqQ0Pj5NzF7fc= -git.wntrmute.dev/mc/mcdsl v1.7.0/go.mod h1:MhYahIu7Sg53lE2zpQ20nlrsoNRjQzOJBAlCmom2wJc= +git.wntrmute.dev/mc/mcdsl v1.9.0 h1:TGqVhf9uhhh5jpMhN+8eNtBPSi/wwNXQn/NFDAcU4wg= +git.wntrmute.dev/mc/mcdsl v1.9.0/go.mod h1:MhYahIu7Sg53lE2zpQ20nlrsoNRjQzOJBAlCmom2wJc= github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= github.com/alecthomas/chroma/v2 v2.2.0/go.mod h1:vf4zrexSH54oEjJ7EdB65tGNHmH3pGZmVkgTP5RHvAs= diff --git a/internal/config/config.go b/internal/config/config.go index 3c4c924..f50e985 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -24,8 +24,16 @@ type Config struct { // SSOConfig holds SSO redirect settings for the web UI. type SSOConfig struct { // RedirectURI is the callback URL that MCIAS redirects to after login. - // Must exactly match the redirect_uri registered in MCIAS config. + // Must exactly match the redirect_uri registered in MCIAS config. For + // public (non-Tailnet) browser access this must be the public hostname. RedirectURI string `toml:"redirect_uri"` + + // PublicURL is the browser-facing MCIAS base URL used to build the SSO + // authorize redirect (e.g. "https://mcias.metacircular.net"). When empty, + // the backend [mcias].server_url is used for the redirect too. Set this + // when browsers cannot resolve the internal MCIAS name; the + // server-to-server code exchange still uses [mcias].server_url. + PublicURL string `toml:"public_url"` } // ServerConfig holds HTTP/gRPC server settings. TLS fields are optional; diff --git a/internal/webserver/server.go b/internal/webserver/server.go index 29b8bfe..dcb0646 100644 --- a/internal/webserver/server.go +++ b/internal/webserver/server.go @@ -28,7 +28,8 @@ type Config struct { Tags []string // SSO fields — when RedirectURI is non-empty, the web UI uses SSO instead // of the direct username/password login form. - MciasURL string + MciasURL string // internal MCIAS URL for the server-to-server code exchange + PublicURL string // browser-facing MCIAS URL for the authorize redirect (optional) CACert string RedirectURI string } @@ -65,6 +66,7 @@ func New(cfg Config, database *db.DB, authenticator *auth.Authenticator, logger if cfg.RedirectURI != "" { ssoClient, err := mcdsso.New(mcdsso.Config{ MciasURL: cfg.MciasURL, + PublicURL: cfg.PublicURL, ClientID: "mcq", RedirectURI: cfg.RedirectURI, CACert: cfg.CACert, @@ -73,7 +75,12 @@ func New(cfg Config, database *db.DB, authenticator *auth.Authenticator, logger return nil, fmt.Errorf("create SSO client: %w", err) } s.ssoClient = ssoClient - logger.Info("SSO enabled: redirecting to MCIAS for login", "mcias_url", cfg.MciasURL) + authorizeURL := cfg.PublicURL + if authorizeURL == "" { + authorizeURL = cfg.MciasURL + } + logger.Info("SSO enabled: redirecting to MCIAS for login", + "authorize_url", authorizeURL, "exchange_url", cfg.MciasURL) } return s, nil