Skip to content

feat(auth): OIDC login (browser SSO) — backend#110

Merged
astrojerms merged 1 commit into
mainfrom
feat/oidc
Jul 7, 2026
Merged

feat(auth): OIDC login (browser SSO) — backend#110
astrojerms merged 1 commit into
mainfrom
feat/oidc

Conversation

@astrojerms

Copy link
Copy Markdown
Owner

The last big auth slice. An external IdP mints openctl sessions via Authorization Code + PKCE, reusing the shipped session/cookie/RBAC machinery — OIDC only adds an identity source; everything downstream is unchanged. Implements docs/oidc-design.md.

Config

auth:
  oidc:
    enabled: true
    issuer: https://accounts.example.com
    clientID: openctl
    clientSecretFile: oidc-client-secret   # 0600, never inline
    redirectURL: https://controller.lan:9445/auth/oidc/callback
    roleClaim: groups
    roleMapping: { openctl-admins: admin, openctl-editors: editor }
    defaultRole: ""   # "" = deny (fail closed)

How

  • internal/controller/auth/oidc.goOIDCAuthenticator over go-oidc/v3 + oauth2: discovery, PKCE AuthCodeURL, code exchange + ID-token verification (signature/iss/aud/exp via the IdP JWKS), and claims→role mapping (highest matching group wins; deny-by-default).
  • internal/controller/server/oidc_http.go/auth/oidc/login (state + PKCE verifier in short-lived HttpOnly cookies → redirect to IdP) and /auth/oidc/callback (verify state, exchange, map principal, mint session via SessionStore.Create, set the openctl_session cookie, redirect /ui/). Denied user → clear 403, no session.
  • wiring — gateway mounts the routes only when auth.oidc.enabled; an unreachable issuer / unreadable secret is a fatal startup error. Coexists with root token + named tokens; --no-auth unchanged.

Tests — fake IdP, real go-oidc verification

oidc_test.go stands up a discovery + JWKS + token endpoint signing ID tokens with a test key (the OIDC analog of plugins/tf-fake): happy path + group→role, highest-role-wins, deny-by-default, default-role, wrong-audience + expired rejection, bad-role-mapping error. Full suite + -race, lint/staticcheck/modernize clean, make build green. New direct deps: go-oidc/v3, oauth2.

Remaining (follow-ups)

A UI "Log in with SSO" button (the backend is reachable directly at /auth/oidc/login) and validation against a real IdP.

The last big auth slice: an external IdP mints openctl sessions via
Authorization Code + PKCE, reusing the shipped session/cookie/RBAC
machinery. OIDC only adds an identity source; everything downstream
(session lookup, RBAC, cookie) is unchanged.

- config: auth.oidc block (issuer, clientID, clientSecretFile,
  redirectURL, roleClaim, roleMapping, defaultRole, usernameClaim);
  ResolveClientSecret reads the 0600 secret file.
- internal/controller/auth/oidc.go: OIDCAuthenticator over
  github.com/coreos/go-oidc/v3 + golang.org/x/oauth2 — discovery, PKCE
  AuthCodeURL, code exchange + ID-token verification (signature/iss/aud/
  exp via the IdP JWKS), and claims->role mapping (highest matching group
  wins; deny-by-default when nothing matches and no defaultRole).
- internal/controller/server/oidc_http.go: /auth/oidc/login (state +
  PKCE verifier in short-lived HttpOnly cookies -> redirect to IdP) and
  /auth/oidc/callback (verify state, exchange, map principal, mint
  session via SessionStore.Create, set the openctl_session cookie,
  redirect to /ui/). A denied user gets a clear 403, no session.
- wiring: gateway mounts the routes only when auth.oidc.enabled; an
  unreachable issuer / unreadable secret is a fatal startup error.
  Coexists with the root token + named tokens; --no-auth unchanged.

Adds deps go-oidc/v3 + oauth2 (now direct). Tests: fake IdP
(oidc_test.go, real go-oidc verification) — happy path + group->role,
highest-role-wins, deny-by-default, default-role, wrong-audience and
expired rejection, bad-role-mapping construction error.

Remaining (follow-ups): a UI "Log in with SSO" button (the backend is
reachable directly at /auth/oidc/login) and validation against a real
IdP.
@astrojerms astrojerms merged commit 462ef06 into main Jul 7, 2026
6 checks passed
astrojerms added a commit that referenced this pull request Jul 8, 2026
Finishes the OIDC feature (backend shipped in #110). The login page now
probes an unauthenticated /auth/oidc/enabled route (registered only when
OIDC is configured) and, when present, renders a "Log in with SSO"
button linking to /auth/oidc/login. When OIDC is off the route 404s and
the button stays hidden.

- server: OIDCHandler.register mounts /auth/oidc/enabled (200 +
  {"enabled":true}); it is only registered when auth.oidc.enabled, so
  its presence is the availability signal.
- ui: Login.svelte probes it on mount and conditionally shows the SSO
  button (with an "or" divider) alongside the bearer-token form.

Tests: the enabled probe returns 200 + enabled:true and is mounted by
register(). npm run check clean, 50 UI tests pass. Remaining OIDC work:
validation against a real IdP.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant