Repository navigation
feat(sso): OIDC login service foundation — state cookie, callback flow, identity matching (V2-272 chunk A) - #31
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>
2 of 3 tasks
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
First of four PRs implementing the SSO login flow (V2-272). V2-272 was ~60% done on master —
oidc_providers/oidc_identitiestables + 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 verificationgolang.org/x/oauth2— code exchange with PKCE-S256Schema
Migration
004_oidc_provisioning.sql(sqlite + postgres):oidc_providers.default_group_id— FK → groups, nullableoidc_providers.auto_provision— bool, default false so a misconfigured provider can't silently grow the user tableService 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 viaoauth2.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 aCallbackOutcomedistinguishing login vs linking flows.Identity matching tree (per V2-272 decisions):
(provider_id, sub)hit → log in as that userusers.external_id == sub→ auto-link (SCIM correlation) + log inauto_provision=false→ErrOIDCNoAccountauto_provision=true+ email already used →ErrOIDCEmailCollision(the explicit email-confusion guard — never auto-link by email)auto_provision=true+ email free → create user viaCreateFromSCIM+LinkIdentity+AddMember(default_group_id), log inLinking flow: when
LinkToUserIDis set on the cookie, the callback creates anoidc_identitiesrow for that user and skips the login decision entirely.UnlinkIdentityrefuses 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 existinginternal/crypto(reuseswallet_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
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_AutoProvisionsNewUserWithDefaultGroupist.Skip-ped with a clear pointer to V2-273 (PR #27). The auto-provision path callsgroupSvc.AddMember(..., 0)and on master that 0 trips a FK violation againstusers(id). V2-273 maps 0 → SQL NULL vianullableAddedBy(); 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 regressionsgo build ./...cleanNext
GET /api/v2/auth/oidc/{providers,authorize/{id},callback}+ link/unlink handlers🤖 Generated with Claude Code