Skip to content

feat(sso): OIDC login service foundation — state cookie, callback flow, identity matching (V2-272 chunk A) - #31

Merged
Nic-dorman merged 1 commit into
masterfrom
nic/v2-272-sso-foundation
May 19, 2026
Merged

Nic-dorman merged 1 commit into
masterfrom
nic/v2-272-sso-foundation

Conversation

@Nic-dorman

Copy link
Copy Markdown
Member

Summary

First of four PRs implementing the SSO login flow (V2-272). V2-272 was ~60% done on master — oidc_providers / oidc_identities tables + admin CRUD exist — 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

Migration 004_oidc_provisioning.sql (sqlite + postgres):

  • oidc_providers.default_group_id — FK → groups, nullable
  • oidc_providers.auto_provision — bool, default false so a misconfigured provider can't silently grow the user table

Service layer (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 distinguishing 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

Linking flow: when LinkToUserID is set on the cookie, the callback creates an oidc_identities row for that user and skips the login decision entirely.

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 via existing internal/crypto (reuses wallet_encryption_key — no separate secret to provision) → base64url for the cookie value. 10-minute TTL. Tampering and wrong-key decryption both fail closed via the GCM auth tag.

Tests — 19 active + 1 skipped

Group Tests
Cookie round-trip, tampered rejected, wrong-key rejected
Helpers splitScopes (auto-prepends openid), splitName fallbacks
LinkIdentity idempotent, rejects-different-user
UnlinkIdentity rejects-last, allows-with-password, allows-multiple-identities, rejects-foreign
HandleCallback existing-identity login, SCIM auto-link by externalId, no_account when auto-provision off, email-collision guard, state mismatch, expired cookie, nonce mismatch, linking flow

The HandleCallback tests run against an in-memory fake IdP (httptest + go-jose RSA signer with JWKS) so they exercise the full discovery → exchange → verify pipeline without external deps. Chunk D layers a real Dex container on top of this for the handler+browser integration test.

TestHandleCallback_AutoProvisionsNewUserWithDefaultGroup is t.Skip-ped with a clear pointer to V2-273 (PR #27). The auto-provision path calls groupSvc.AddMember(..., 0) and on master that 0 trips a FK violation against users(id). V2-273 maps 0 → SQL NULL via nullableAddedBy(); once it merges, drop the skip.

Test plan

  • go test ./internal/services/ — all green (19 new + skipped + existing)
  • go test ./internal/handlers/ ./internal/worker/ — no regressions
  • go build ./... clean
  • CI Postgres matrix green (new migration applies cleanly on both dialects)

Next

  • Chunk B — GET /api/v2/auth/oidc/{providers,authorize/{id},callback} + link/unlink handlers
  • Chunk C — LoginView.vue SSO buttons, profile connected accounts, admin auto-provision toggle in SettingsView
  • Chunk D — Dex CI service container + Playwright E2E for the happy path

🤖 Generated with Claude Code

…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>
@Nic-dorman
Nic-dorman merged commit 87d870c into master May 19, 2026
8 checks passed
@Nic-dorman
Nic-dorman deleted the nic/v2-272-sso-foundation branch May 19, 2026 09:06
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