Skip to content

feat(sso): OIDC authorize + callback + linking handlers (V2-272 chunk B) - #32

Merged
Nic-dorman merged 3 commits into
masterfrom
nic/v2-272-sso-handlers
May 19, 2026
Merged

Nic-dorman merged 3 commits into
masterfrom
nic/v2-272-sso-handlers

Conversation

@Nic-dorman

Copy link
Copy Markdown
Member

Summary

Second of four PRs for V2-272. Mounts the OIDC service from chunk A on the router and adds the public auth + authenticated linking endpoints.

Stacked on #31 — base is nic/v2-272-sso-foundation. Merge chunk A first.

Routes added

Public (unauthenticated)

Method Path Purpose
GET /api/v2/auth/oidc/providers List enabled providers as {id, name, display_name}. Deliberately omits client_id and the encrypted secret.
GET /api/v2/auth/oidc/authorize/{providerId} Sets the encrypted state cookie (HttpOnly + Secure + SameSite=Lax + scoped Path=/api/v2/auth/oidc) and 302's to the IdP.
GET /api/v2/auth/oidc/callback Clears the state cookie up-front, calls HandleCallback, issues a session JWT via the same path Login uses, 302's to / on success or /login?error=<code> on failure. IdP-side errors (?error=access_denied etc.) pass through.

Authenticated

Method Path Purpose
POST /api/v2/auth/oidc/link/{providerId} Same flow as authorize but with LinkToUserID set. Returns {authorize_url} as JSON so the frontend can do window.location.href (POST→302 doesn't play nicely with fetch).
DELETE /api/v2/auth/oidc/identities/{identityId} Calls UnlinkIdentity. 409 + last_login_method code when it would leave the user with no way to log in.
GET /api/v2/me/oidc/identities Returns the user's identities with provider_name resolved for the profile "connected accounts" view.

Admin

Method Path Purpose
PUT /api/v2/admin/oidc/providers/{id}/auto-provision {auto_provision, default_group_id}. Separate from the main Update endpoint so the UI toggle (chunk C) can flip it without re-supplying every other field. default_group_id=0 clears the group assignment.

Error code mapping (/login?error=...)

Sentinel Code
ErrOIDCNoAccount no_account
ErrOIDCEmailCollision email_exists
state / nonce / cookie variants session_expired
ErrOIDCMissingEmail missing_email
ErrOIDCProviderDisabled provider_disabled
(other) internal

oidcCallbackURL prefers cfg.BaseURL when set (production), falls back to deriving scheme + host from the incoming request.

Test coverage — 10 in internal/handlers/oidc_auth_test.go

  • ListOIDCProviders only shows enabled, doesn't leak client_id
  • Authorize sets HttpOnly+Lax cookie, 302's to IdP, URL includes PKCE S256 challenge
  • Authorize → 404 for unknown provider
  • Full round-trip: authorize → fake IdP /authorize → callback with pre-linked identity → session cookie set, 302 to /
  • Callback with auto_provision=off + unknown sub → /login?error=no_account
  • Callback with IdP ?error=access_denied → /login?error=access_denied
  • Unlink requires auth (401)
  • Unlink last-login-method returns 409 with last_login_method code
  • /me/oidc/identities lists user's identities with provider_name resolved
  • Admin set auto_provision persists

The handler tests reuse a fake IdP (httptest + go-jose RS256 JWKS) that auto-redirects /authorize back to the callback with code+state, so the full authorize → IdP → callback chain runs in-process. Chunk D adds Dex on top for browser-driven verification.

Test plan

  • go test ./internal/services/ ./internal/handlers/ ./internal/worker/ — all green
  • go build ./... clean
  • CI Postgres matrix green

Next

  • Chunk C — LoginView.vue SSO button, profile connected-accounts section, admin auto-provision UI in SettingsView.vue
  • Chunk D — Dex CI service container + Playwright E2E

🤖 Generated with Claude Code

Nic-dorman and others added 2 commits May 18, 2026 11:29
…w, identity matching (V2-272 chunk A)

First of four PRs implementing the SSO login flow. V2-272 was ~60% done
on master (oidc_providers/identities tables + admin CRUD); this lands
the missing service layer that handles authorize → IdP → callback +
identity matching. Chunk B will mount HTTP handlers on top.

Deps
- github.com/coreos/go-oidc/v3 — OIDC discovery + ID-token verification
- golang.org/x/oauth2 — code exchange with PKCE-S256

Schema (004_oidc_provisioning.sql for sqlite + postgres)
- oidc_providers.default_group_id (FK → groups, nullable)
- oidc_providers.auto_provision (bool, default false — off by default so
  a misconfigured provider can't silently grow the user table)
- Reflected on OIDCProvider struct + scanned in all SELECTs.

New service: internal/services/oidc_login.go
- BuildAuthorizeURL(providerID, opts) → returns the IdP redirect URL +
  the opaque encrypted-cookie value the caller must set on the response.
  Generates state (32 random bytes hex), nonce (same), PKCE verifier
  via oauth2.GenerateVerifier(), runs OIDC discovery, builds the
  authorize URL with S256 challenge.
- HandleCallback(cookieValue, queryState, code) → completes the
  exchange, validates the ID token (signature via JWKS, issuer,
  audience, nonce, exp), and resolves the identity. Returns a
  CallbackOutcome that distinguishes login vs linking flows.
- Identity matching tree (per V2-272 decisions):
    1. (provider_id, sub) hit → log in as that user
    2. users.external_id == sub → auto-link (SCIM correlation) + log in
    3. auto_provision=false → ErrOIDCNoAccount
    4. auto_provision=true + email already used → ErrOIDCEmailCollision
       (the explicit email-confusion guard — never auto-link by email)
    5. auto_provision=true + email free → create user via CreateFromSCIM
       + LinkIdentity + AddMember(default_group_id), log in
- LinkIdentity / UnlinkIdentity / ListIdentitiesForUser for the
  profile "connected accounts" flow. UnlinkIdentity refuses to leave
  the user with no login method (no password AND only one identity).

State cookie
- JSON payload {provider_id, state, nonce, code_verifier, redirect_url,
  link_to_user_id?, exp} AES-256-GCM encrypted via the existing
  internal/crypto package (reuses wallet_encryption_key — no separate
  secret to provision), then base64url-encoded for the cookie value.
- 10-minute TTL. Tampering and wrong-key decryption both fail closed
  via the GCM auth tag; both covered in tests.

OIDCProviderService.SetAutoProvision(id, enabled, groupID)
- Separate method so the admin UI toggle (Chunk C) doesn't need to
  re-supply every other field.

Tests (19 active + 1 skipped) in internal/services/oidc_login_test.go
- Cookie round-trip, tampered cookie rejected, wrong-key rejected
- splitScopes (auto-prepends openid), splitName fallbacks
- LinkIdentity idempotent + rejects-different-user
- UnlinkIdentity: rejects-last, allows-with-password,
  allows-multiple-identities, rejects-foreign
- HandleCallback against an in-memory fake IdP (httptest + go-jose
  RSA signer with JWKS): existing-identity login, SCIM auto-link by
  external_id, no_account when auto_provision off, email-collision
  guard, state mismatch, expired cookie, nonce mismatch, linking flow
- TestHandleCallback_AutoProvisionsNewUserWithDefaultGroup is t.Skip-ped
  with a pointer to V2-273 (PR #27). The auto-provision path calls
  groupSvc.AddMember(..., 0) and AddMember on master writes 0
  directly to a FK-to-users column. V2-273 maps 0 → SQL NULL via
  nullableAddedBy(); once it merges, drop the skip.

Chunks B/C/D
- B: GET /api/v2/auth/oidc/{providers,authorize/{id},callback} +
  link/unlink handlers
- C: LoginView.vue SSO buttons, profile connected accounts, admin
  auto-provision toggle
- D: Dex as CI service container, Playwright E2E

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Mounts the OIDC service from chunk A on the router and adds the public
auth + authenticated linking endpoints. Stack: this PR is on top of
nic/v2-272-sso-foundation (chunk A) — merge that first.

Public routes
- GET  /api/v2/auth/oidc/providers
    Lists enabled providers as {id, name, display_name}. Deliberately
    leaves out client_id and the encrypted secret — the login page just
    needs labels.
- GET  /api/v2/auth/oidc/authorize/{providerId}
    Calls BuildAuthorizeURL, sets the encrypted state cookie
    (HttpOnly + Secure + SameSite=Lax + Path=/api/v2/auth/oidc), and
    302's to the IdP. Cookie path is scoped so it doesn't leak to other
    routes.
- GET  /api/v2/auth/oidc/callback
    Clears the state cookie up-front (success OR failure), calls
    HandleCallback, issues a session JWT via the same path Login uses
    (jwt_expiry_hours setting, session cookie with the same flags), and
    302's to / on success or /login?error=<code> on failure. IdP-side
    errors (?error=access_denied etc.) pass through to /login.

Authenticated routes
- POST   /api/v2/auth/oidc/link/{providerId}
    Same flow as authorize but with LinkToUserID set to the current
    user. Returns {authorize_url} as JSON so the frontend can do
    window.location.href — POST→302 doesn't work cleanly with fetch.
- DELETE /api/v2/auth/oidc/identities/{identityId}
    Calls UnlinkIdentity. 409 + last_login_method code when removing
    would leave the user with no way to log in.
- GET    /api/v2/me/oidc/identities
    Returns the user's linked identities with provider_name resolved
    for the profile "connected accounts" view.

Admin route
- PUT /api/v2/admin/oidc/providers/{id}/auto-provision
    {auto_provision, default_group_id} — separate from the main Update
    endpoint so the UI toggle (chunk C) can flip it without re-supplying
    every other field. default_group_id=0 clears the group assignment.

Error code mapping (oidcErrorCode):
- ErrOIDCNoAccount       → no_account
- ErrOIDCEmailCollision  → email_exists
- state/nonce/cookie     → session_expired
- ErrOIDCMissingEmail    → missing_email
- ErrOIDCProviderDisabled→ provider_disabled
- everything else        → internal

oidcCallbackURL prefers cfg.BaseURL when set (production), falls back to
deriving scheme + host from the incoming request (dev / single-host).

Tests: 10 in internal/handlers/oidc_auth_test.go
- ListOIDCProviders only shows enabled, doesn't leak client_id
- Authorize sets HttpOnly+Lax cookie, 302's to IdP, URL includes PKCE
  S256 challenge
- Authorize → 404 for unknown provider
- Full round-trip: authorize → fake IdP /authorize → callback with
  pre-linked identity → session cookie set, 302 to /
- Callback with auto_provision=off + unknown sub → /login?error=no_account
- Callback with IdP ?error=access_denied → /login?error=access_denied
- Unlink requires auth (401)
- Unlink last-login-method returns 409 with last_login_method code
- /me/oidc/identities lists user's identities with provider_name resolved
- Admin set auto_provision persists

The handler tests reuse a fake IdP (httptest + go-jose RS256 JWKS) that
auto-redirects /authorize back to the callback with code+state, so the
full authorize → IdP → callback chain runs in-process. Chunk D adds Dex
on top of this for browser-driven verification.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@Nic-dorman
Nic-dorman changed the base branch from nic/v2-272-sso-foundation to master May 18, 2026 16:31
@Nic-dorman Nic-dorman closed this May 18, 2026
@Nic-dorman Nic-dorman reopened this May 18, 2026
Lint job on master enforces swagger freshness; the cumulative handler
changes on this branch hadn't been reflected. Generated with:

  swag init -g cmd/indelible/main.go -o docs/ --parseDependency

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@Nic-dorman
Nic-dorman merged commit d79deef into master May 19, 2026
8 checks passed
@Nic-dorman
Nic-dorman deleted the nic/v2-272-sso-handlers branch May 19, 2026 09:07
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