Repository navigation
feat(sso): OIDC authorize + callback + linking handlers (V2-272 chunk B) - #32
Merged
Merged
Conversation
…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>
2 of 3 tasks
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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)
/api/v2/auth/oidc/providers{id, name, display_name}. Deliberately omitsclient_idand the encrypted secret./api/v2/auth/oidc/authorize/{providerId}HttpOnly+Secure+SameSite=Lax+ scopedPath=/api/v2/auth/oidc) and 302's to the IdP./api/v2/auth/oidc/callbackHandleCallback, issues a session JWT via the same pathLoginuses, 302's to/on success or/login?error=<code>on failure. IdP-side errors (?error=access_deniedetc.) pass through.Authenticated
/api/v2/auth/oidc/link/{providerId}LinkToUserIDset. Returns{authorize_url}as JSON so the frontend can dowindow.location.href(POST→302 doesn't play nicely with fetch)./api/v2/auth/oidc/identities/{identityId}UnlinkIdentity. 409 +last_login_methodcode when it would leave the user with no way to log in./api/v2/me/oidc/identitiesprovider_nameresolved for the profile "connected accounts" view.Admin
/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=0clears the group assignment.Error code mapping (
/login?error=...)ErrOIDCNoAccountno_accountErrOIDCEmailCollisionemail_existssession_expiredErrOIDCMissingEmailmissing_emailErrOIDCProviderDisabledprovider_disabledinternaloidcCallbackURLpreferscfg.BaseURLwhen set (production), falls back to deriving scheme + host from the incoming request.Test coverage — 10 in
internal/handlers/oidc_auth_test.goListOIDCProvidersonly shows enabled, doesn't leakclient_id/authorize→ callback with pre-linked identity → session cookie set, 302 to/auto_provision=off+ unknown sub →/login?error=no_account?error=access_denied→/login?error=access_deniedlast_login_methodcode/me/oidc/identitieslists user's identities withprovider_nameresolvedauto_provisionpersistsThe handler tests reuse a fake IdP (httptest + go-jose RS256 JWKS) that auto-redirects
/authorizeback 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 greengo build ./...cleanNext
🤖 Generated with Claude Code