Feature/implement sso connect providers - #519
Conversation
…t providers - Add SSOProviderRequest and SSOProviderResponse DTOs for handling SSO provider data. - Create SSOHandler to manage SSO sign-in and provider management endpoints. - Implement routes for listing, creating, updating, and deleting SSO providers. - Add database migration for oidc_providers and user_identities tables to support SSO. - Introduce e2e tests for SSO sign-in flow, including provider management and user provisioning. - Enhance error handling for SSO-related operations in the presenter layer.
There was a problem hiding this comment.
Important
SSO sign-in can fail with a server panic: usernameBase slices a provider-supplied email at its first @ without checking that one exists, so a verified email with no @ crashes the callback. One inline fix below, plus two smaller notes.
Reviewed changes
- Backend SSO flow (
service/sso,domain/sso, HTTP handler/router) — authorization-code + PKCE (S256) login with server-sidestate/nonce/browser-binding in Redis (GetDel, single-use, 10-min TTL), constant-time nonce + binding comparison, provider-id match,SafeRedirectconfining the post-login redirect to in-app paths, and account resolution (link by verified email → auto-provision → else refuse). - Persistence —
000062_add_oidc_sso.sqladdsoidc_providers(unique slug) anduser_identities(unique(provider_id, subject),ON DELETE CASCADE); sqlx repository storesclient_secretencrypted viasecret.Encryptorwith plaintext fallback. - Admin API + permission —
/admin/sso/providersCRUD behind the new root-equivalentsettings.sso.write(SUPER_ADMIN only by default; deliberately not seeded on ADMIN), with the public login-page listing and callback routes left unauthenticated. - Web UI — admin Settings gains an SSO provider list + create/edit/delete dialog; the login page lists enabled providers and renders
?sso_error=codes. - i18n / docs — locale strings for all languages,
docs/guides/sso-oidc.md, authorization note, README/ROADMAP.
The OIDC flow itself holds up: I could not construct a sequence in which a party who is neither the IdP nor the account owner obtains a session. The state is consumed even on a binding mismatch, the nonce is checked in constant time against the server-side attempt, go-oidc verifies issuer/audience/signature, and deleted (soft-deleted) users are excluded by both FindByID and FindByEmail.
ℹ️ Auto-provisioned SSO accounts fire the same user_created event as admin-created accounts
provision creates the account through usersvc.Create, which unconditionally publishes TopicUserCreated (service/user/user_service.go:219). That topic's own doc comment says it is "meant to trigger the same downstream plugin behavior (e.g. emailing a 'set your password' link)" as an admin reset — but an SSO-provisioned user is deliberately given a random password and MustChangePassword=false, so the plugin premise ("both leave the account in the same must-change-password state") does not hold here. A plugin acting on the event could hand an SSO user a password-set path that bypasses the IdP, or email an invite to an account that was intentionally password-less. This may be fine, but it is a product decision worth making deliberately rather than by inheritance.
Technical details
# Auto-provision emits user_created with no must-change-password distinction
## Affected sites
- `services/api/internal/service/sso/resolve.go:145` — `provision` calls `s.creator.Create` with no `MustChangePassword`.
- `services/api/internal/service/user/user_service.go:219` — `Create` publishes `TopicUserCreated` for every new account.
## Required outcome
- Decide whether SSO provisioning should emit `TopicUserCreated` (and thus potentially trigger invite/set-password plugin flows) or a distinct signal, and make that explicit.
## Open questions for the human
- Should auto-provisioned SSO users be eligible for a "set your password" invite at all, given that would create a local password path around the IdP?ℹ️ Nitpicks
withSuffix(resolve.go:256) computesbase[:maxUsernameLen-len(suffix)]; both current suffixes are ≤ 7 chars so it is safe today, but a bounds check would keep it safe if a longer suffix is ever passed.- The guide says a deleted user's SSO link "stays attached" and is not re-provisioned; that holds only because user deletion is a soft delete.
user_identities.user_idisON DELETE CASCADE, so a future hard-delete path would let the same subject re-provision a fresh account.
DeepSeek Flash (free via Pullfrog for OSS) | 𝕏
- usernameBase: use strings.Cut so a verified email without '@' can't panic the callback (email[:-1]). - ResolveUser: when a concurrent first sign-in links the same (provider, subject) first, map the unique violation to ErrIdentityExists, sign in as the winner's account and soft-delete the account this attempt provisioned, instead of returning a 500. - withSuffix: guard against a suffix longer than the username limit. - docs: note that 'deleted users stay out' relies on soft delete. - lint: gofmt, and rename IdP -> IDP test types (revive var-naming). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
Important
The lost-race recovery can soft-delete the account the winning sign-in just linked, locking that user out permanently. One inline fix below.
Reviewed changes
- Fixed the
usernameBasepanic — a verified email without@no longer indexesemail[:-1]; it now usesstrings.Cut. - Handled the
user_identitiesunique-violation race —CreateIdentitymaps the conflict toErrIdentityExists, andResolveUsersigns in as the winner via a newsignInLinkedhelper while deleting the account its own attempt provisioned. - Bounded
withSuffixagainst an oversized suffix (not reachable with today's callers). - Clarified the soft-delete caveat in the SSO guide, and added tests for the malformed-email and lost-race paths.
DeepSeek Flash (free via Pullfrog for OSS) | 𝕏
On a lost (provider, subject) link race, ResolveUser soft-deleted the account it had just provisioned. With LinkByEmail on, the winning sign-in can adopt that very account (found by the email provision set) and link it, so the cleanup deleted the winner's account and locked the user out. Look up the winning identity first and delete only when it points at a different user. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
✅ No new issues found.
The new commit cleanly closes the lost-race finding from the previous review.
Reviewed changes
- Guarded the lost-race cleanup — on
ErrIdentityExists, the account this attempt provisioned is now soft-deleted only when the winning identity links a different user (provisioned && ident.UserID != u.ID), so aLinkByEmailwinner that adopted this very account is not deleted out from under itself. - Added a regression test —
losing to a winner that linked our provisioned account keeps itdrives exactly that interleaving throughbeforeCreateIdentity, asserting the adopted account survives and later sign-ins still resolve.TestResolveUserpasses on8732fe1f.
DeepSeek Flash (free via Pullfrog for OSS) | 𝕏
…ent duplicate accounts
There was a problem hiding this comment.
ℹ️ No critical issues — one minor test-hygiene suggestion inline.
Reviewed changes
The delta since the prior pullfrog review is 1e80eafd, which replaces the optimistic lost-race recovery with a pessimistic per-identity lock.
- Serialized first sign-ins with a per-
(provider, subject)lock —RepositorygainsLockIdentity;ResolveUsernow takes it after the fast-pathFindIdentitymiss, re-checks the identity under the lock, and only then links or provisions, so two concurrent callbacks for the same identity can no longer both create an account. - Postgres lock implementation — session-level
pg_advisory_lock(hashtextextended(key))held on a dedicateddb.Conn(ctx)with a 15s wait bound, released byunlock(or by Postgres if the connection dies), mirroring the existingrunMigrationspattern. - Removed the
ErrIdentityExistscatch-and-delete fallback —CreateIdentitystill maps the unique violation, but the lock key matches the unique constraint, making the branch unreachable. - Added concurrency coverage — a 10-goroutine unit subtest (gated by a
createDelayknob and a fake per-key lock) and an 8-goroutine e2e subtest that both assert exactly one account and one link are created.
Verified on 1e80eafd: go test ./internal/service/sso/ -run TestResolveUser (and -race) pass, and go vet is clean across the touched packages.
DeepSeek Flash (free via Pullfrog for OSS) | 𝕏
…ts; enhance email linking logic to ignore case
There was a problem hiding this comment.
ℹ️ No critical issues — one minor hardening note on the new SSRF guard.
Reviewed changes
The delta since the prior pullfrog review (1e80eafd) is dcf64a71.
- Rate-limited the public
/authcredential endpoints — a per-IP fixed-window limiter (middleware.RateLimit) now wraps password login, password set, and refresh/annotation-refresh (3× budget), plus SSO login/callback; over-limit requests get a JSON 429, or for the SSO browser navigations a redirect back to the login page with?sso_error=rate_limited. - Added
AUTH_RATE_LIMIT_PER_MINUTE(default 20,0disables, negative/NaN rejected) through config, the newTOO_MANY_REQUESTScode and 429 presenter mapping, the web login-form error path, all nine locales, and the deploy/helm examples. - Hardened IdP discovery —
providerHTTPClientdials through aControlhook that refuses link-local, multicast and unspecified addresses, and concurrent discovery misses for the same provider version share one fetch viasingleflight. - Made email matching case-insensitive —
FindByEmailcompareslower(email)(exact case wins), backed by a new partialidx_users_email_lower_activeindex;checkEmailAvailabletakes aselfid so re-casing your own address is not a conflict; added e2e coverage for link-by-email across case. - Stopped trusting
X-Forwarded-Hostwhen deriving the SSO callback origin (usesPUBLIC_URL, else the requestHost), and documented the multi-tenant-issuer caveat for link-by-email.
DeepSeek Flash (free via Pullfrog for OSS) | 𝕏
…ts for new behavior
There was a problem hiding this comment.
✅ No new issues found.
The delta since the prior pullfrog review (dcf64a71) is 160d3008, which closes the two open findings carried over from earlier runs.
Reviewed changes
- Closed the zoned-IPv6 SSRF bypass —
providerHTTPClientnow parses the dial address withnetip.ParseAddrinstead ofnet.ParseIP, andblockedProviderIPstrips the zone (.WithZone("").Unmap()) before the link-local check. A zoned IPv6 link-local issuer ([fe80::1%eth0]) is refused, as are unparseable hosts. Verified locally thatfe80::1%eth0parses and is blocked, and169.254.169.254remains blocked. - Stopped calling
t.Fatalfrom test goroutines — the concurrent e2e subtest now builds all browsers on the test goroutine and routes the sign-in through a newtrySSOSignInthat returns an error; workers report witht.Error. The previously open thread onsso_test.go:263is resolved by this, and it is already marked resolved on the PR.
go test ./internal/service/sso/ and ./internal/transport/http/middleware/, go vet on both packages, all pass on 160d3008. All prior pullfrog threads are now resolved.
DeepSeek Flash (free via Pullfrog for OSS) | 𝕏
There was a problem hiding this comment.
✅ No new issues found.
The delta since the prior pullfrog review (160d3008) is 0fd531e8, which adds the browser-level SSO end-to-end coverage and the mock identity provider it drives. It changes no production code.
Reviewed changes
- Added a mock OpenID Connect provider to the E2E stack —
deploy/docker-compose.e2e.ymlgainsmock-oidc(ghcr.io/navikt/mock-oauth2-server:2.1.10withinteractiveLogin), reachable by the API athttp://mock-oidc:9090and published on host port9090for the browser. - Added the SSO Playwright suite —
apps/e2e/tests/admin/sso.spec.tsplusfeatures/admin/sso.featurecover permission gating (settings.writevssettings.sso.write), provider create/edit/delete through the admin UI, first-time auto-provisioning, returning sign-in matched bysub, the domain allow-list refusal sending?sso_error=email_not_allowed, and provider deletion. Because the browser cannot resolve the compose service name, the web sign-in route rewrites the redirect to the published port while preserving thesso_statecookie. - Documented the mock provider —
apps/e2e/README.mdnotes the provider, its internal/public URLs, and theE2E_MOCK_OIDC_URLoverride.
I checked the suite's load-bearing assumptions against mock-oauth2-server: the 2.1.10 tag exists on GHCR, the interactive login form posts username and claims with a Sign-in submit input, and the ID-token iss is built from the token request's Host (HttpUrl.toIssuerUrl), so redeeming at mock-oidc:9090 produces an issuer matching the configured one. Every prior pullfrog thread is resolved.
DeepSeek Flash (free via Pullfrog for OSS) | 𝕏

Summary
Adds SSO / OpenID Connect sign-in. Admins configure identity providers (Google, Microsoft Entra ID, Okta, Keycloak, GitLab, …) in the UI, and users sign in with them from the login page. Password sign-in keeps working alongside it.
Admin experience
openidis always included.ENCRYPTION_KEYand never returned by the API; responses only reporthas_client_secret.User experience
?sso_error=<code>, and a translated message explains why.Security
stateis single-use (RedisGETDEL, 10-minute TTL) and bound to the browser that started the sign-in through ansso_statecookie, which blocks login CSRF. The ID token'snonceis checked.subclaim, not by email. Unverified emails are never used for linking, creating accounts or the domain check.account_exists) rather than creating a duplicate.settings.sso.write, held only bySUPER_ADMINby default. A provider with email linking on can sign in as any account with a matching email, so this permission is root-equivalent. It is added toTestDefaultGlobalRoles_OnlySuperAdminCanMintRootand todocs/architecture/authorization.md.Implementation
000062_add_oidc_sso.sql:oidc_providerstable, plususer_identitieswith a unique(provider_id, subject). Rows cascade when the provider or the user is deleted.services/api/internal/service/sso: provider management, the sign-in flow (coreos/go-oidc/v3+x/oauth2), a discovery cache, and account resolution (link → provision). Provisioning goes throughusersvc.Create, so the default role and user-created events apply unchanged.authsvc.Service.IssueSession: extracted fromLogin, so SSO and password sign-in issue identical sessions and cookies.GET /api/v1/auth/sso/providers,GET /api/v1/auth/sso/{slug}/login,GET /api/v1/auth/sso/{slug}/callbacksettings.sso.write:GET/POST /api/v1/admin/sso/providers,PUT/DELETE /api/v1/admin/sso/providers/{providerId}SsoSettings/SsoProviderDialogon the admin side,SsoSignInon the login page, and translations for all 9 locales.docs/guides/sso-oidc.mdcovering setup, common issuer URLs, options and thesso_errorcodes.Deployment notes
PUBLIC_URL: the callback URL is built from it. Without it, the API falls back to the request'sHost/X-Forwarded-Host.ENCRYPTION_KEYso client secrets are encrypted. Without it they're stored in plaintext, the same fallback every other secret uses.Test plan
internal/service/sso), run against a fake IdP that signs real RS256 ID tokens. They cover:account_existsTestSSOSignIn(PACA_E2E=1, real Postgres and Redis). It drives the full browser redirect chain through the real router and checks:no_accountwhen account creation is offgo vet ./...and all API unit tests pass.tsc -b,biome check, andvitest run(924 tests, including locale parity) pass./homewith the right name, email and default role🤖 Generated with Claude Code