diff --git a/ROADMAP.md b/ROADMAP.md index 8eefe15..8ea17d7 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -886,9 +886,11 @@ phase plan when ready to commit. Coexists with root token + named tokens (`--no-auth` unchanged). Tested against a **fake IdP** (`oidc_test.go` — real go-oidc verify: happy path, highest-role-wins, deny-by-default, wrong-audience + expired - rejection). *Remaining:* a UI "Log in with SSO" button (frontend - follow-up — the backend is reachable directly at `/auth/oidc/login`) - and validation against a real IdP. + rejection). **UI "Log in with SSO" button SHIPPED:** the login page + probes an unauthenticated `/auth/oidc/enabled` (only mounted when OIDC + is configured) and, when present, renders a "Log in with SSO" button + linking to `/auth/oidc/login`. *Remaining:* validation against a real + IdP (the one step that needs an actual provider). - [x] **Terraform / OpenTofu provider host** *(Tier 1 — see [docs/direction.md](docs/direction.md))* — consume the existing Terraform provider ecosystem (AWS, GCP, Azure, Cloudflare, …) diff --git a/docs/oidc-design.md b/docs/oidc-design.md index 18dbce9..7bfcad6 100644 --- a/docs/oidc-design.md +++ b/docs/oidc-design.md @@ -3,8 +3,9 @@ **Status:** **backend IMPLEMENTED** (2026-07-07) — `auth.oidc` config block, `internal/controller/auth/oidc.go` (authenticator), `internal/controller/ server/oidc_http.go` (login+callback routes), wired into the gateway; tested -against a fake IdP (`oidc_test.go`). Remaining: a UI "Log in with SSO" button -and real-IdP validation. The design below is what shipped. +against a fake IdP (`oidc_test.go`). The UI "Log in with SSO" button is also +shipped (login page probes `/auth/oidc/enabled`). Remaining: real-IdP +validation. The design below is what shipped. **Author:** autonomous session, 2026-07-05. **Roadmap item:** "Multi-user auth → *Next — OIDC: external IdP → claims → role (the last big auth slice)*" (ROADMAP.md, Future goals). Direction: diff --git a/internal/controller/server/oidc_http.go b/internal/controller/server/oidc_http.go index dd337b5..680b93e 100644 --- a/internal/controller/server/oidc_http.go +++ b/internal/controller/server/oidc_http.go @@ -39,10 +39,19 @@ func NewOIDCHandler(authn *auth.OIDCAuthenticator, sessions *auth.SessionStore, // register mounts the login + callback routes on the mux. func (h *OIDCHandler) register(mux *http.ServeMux) { + mux.HandleFunc("/auth/oidc/enabled", h.enabled) mux.HandleFunc("/auth/oidc/login", h.login) mux.HandleFunc("/auth/oidc/callback", h.callback) } +// enabled is an unauthenticated probe the login page hits to decide whether to +// render the "Log in with SSO" button. It's only registered when OIDC is +// configured, so a 200 here means SSO is available; a 404 means it isn't. +func (h *OIDCHandler) enabled(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"enabled":true}`)) +} + // login starts the Authorization Code + PKCE flow: generate a state + PKCE // verifier, stash them in short-lived HttpOnly cookies, and redirect to the IdP. func (h *OIDCHandler) login(w http.ResponseWriter, r *http.Request) { diff --git a/internal/controller/server/oidc_http_test.go b/internal/controller/server/oidc_http_test.go new file mode 100644 index 0000000..538178d --- /dev/null +++ b/internal/controller/server/oidc_http_test.go @@ -0,0 +1,38 @@ +package server + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +// The enabled probe returns 200 + {"enabled":true}. It's registered only when +// OIDC is configured, so its mere presence signals SSO availability to the +// login page. +func TestOIDCEnabledProbe(t *testing.T) { + h := &OIDCHandler{} + rec := httptest.NewRecorder() + h.enabled(rec, httptest.NewRequest(http.MethodGet, "/auth/oidc/enabled", nil)) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } + if !strings.Contains(rec.Body.String(), `"enabled":true`) { + t.Errorf("body = %q, want it to report enabled:true", rec.Body.String()) + } + if ct := rec.Header().Get("Content-Type"); ct != "application/json" { + t.Errorf("content-type = %q, want application/json", ct) + } +} + +// register mounts the enabled route (so a login page can probe it). +func TestOIDCRegisterMountsEnabled(t *testing.T) { + mux := http.NewServeMux() + (&OIDCHandler{}).register(mux) + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/auth/oidc/enabled", nil)) + if rec.Code != http.StatusOK { + t.Errorf("GET /auth/oidc/enabled = %d, want 200", rec.Code) + } +} diff --git a/ui/src/routes/Login.svelte b/ui/src/routes/Login.svelte index ebd6fbf..608e046 100644 --- a/ui/src/routes/Login.svelte +++ b/ui/src/routes/Login.svelte @@ -8,13 +8,27 @@ let busy = false; let error = ''; let tokenInput: HTMLInputElement; + // Whether the controller has OIDC configured — probed on mount. The + // /auth/oidc/enabled route is only mounted when OIDC is on (200 → show the + // SSO button; 404/error → hide it). + let ssoEnabled = false; onMount(() => { // Programmatic focus instead of HTML autofocus — single input, clear // intent, and avoids Svelte's a11y_autofocus warning. tokenInput?.focus(); + void probeSSO(); }); + async function probeSSO() { + try { + const resp = await fetch('/auth/oidc/enabled', { credentials: 'same-origin' }); + ssoEnabled = resp.ok; + } catch { + ssoEnabled = false; + } + } + function defaultDeviceLabel(): string { const ua = navigator.userAgent; if (ua.includes('Macintosh')) return 'macOS browser'; @@ -79,6 +93,10 @@ {busy ? 'Signing in…' : 'Sign in'} + {#if ssoEnabled} +