Skip to content

Feature/implement sso connect providers - #519

Merged
pikann merged 8 commits into
masterfrom
feature/implement-sso-connect-providers
Sep 27, 2026
Merged

pikann merged 8 commits into
masterfrom
feature/implement-sso-connect-providers

Conversation

@pikann

@pikann pikann commented Sep 27, 2026 •

Copy link
Copy Markdown
Contributor

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

  • New Single sign-on (SSO) card under Administration → Settings. You can add, edit and delete providers, and the page shows the callback URL to register at the IdP, with a copy button.
  • When you save an enabled provider, the API fetches the issuer's discovery document right away, so a wrong issuer URL fails at save time instead of on a user's first sign-in.
  • Per-provider options:
    • Enabled
    • Create accounts automatically: new users get the default global role.
    • Link existing accounts by verified email
    • Allowed email domains: exact match; empty allows any domain.
    • Scopes: openid is always included.
  • The client secret is encrypted at rest with ENCRYPTION_KEY and never returned by the API; responses only report has_client_secret.
  • The Settings page title changes from "Workspace Branding" to "Workspace Settings".

User experience

  • The login page shows a "Continue with …" button for each enabled provider. With no provider configured, it looks the same as before.
  • When SSO sign-in fails, the user is sent back to the login page with ?sso_error=<code>, and a translated message explains why.

Security

  • Authorization-code flow with PKCE (S256). The state is single-use (Redis GETDEL, 10-minute TTL) and bound to the browser that started the sign-in through an sso_state cookie, which blocks login CSRF. The ID token's nonce is checked.
  • A returning user is recognised by the provider's stable sub claim, not by email. Unverified emails are never used for linking, creating accounts or the domain check.
  • If a verified email already belongs to an account and linking is off, sign-in is refused (account_exists) rather than creating a duplicate.
  • A deleted Paca user stays locked out: SSO does not recreate their account.
  • The post-sign-in redirect only accepts in-app paths, so the flow can't be used as an open redirect.
  • New permission settings.sso.write, held only by SUPER_ADMIN by 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 to TestDefaultGlobalRoles_OnlySuperAdminCanMintRoot and to docs/architecture/authorization.md.

Implementation

  • Migration 000062_add_oidc_sso.sql: oidc_providers table, plus user_identities with 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 through usersvc.Create, so the default role and user-created events apply unchanged.
  • authsvc.Service.IssueSession: extracted from Login, so SSO and password sign-in issue identical sessions and cookies.
  • Routes:
    • Public: GET /api/v1/auth/sso/providers, GET /api/v1/auth/sso/{slug}/login, GET /api/v1/auth/sso/{slug}/callback
    • Admin, gated by settings.sso.write: GET/POST /api/v1/admin/sso/providers, PUT/DELETE /api/v1/admin/sso/providers/{providerId}
  • Web: SsoSettings / SsoProviderDialog on the admin side, SsoSignIn on the login page, and translations for all 9 locales.
  • Docs: new admin guide docs/guides/sso-oidc.md covering setup, common issuer URLs, options and the sso_error codes.

Deployment notes

  • Set PUBLIC_URL: the callback URL is built from it. Without it, the API falls back to the request's Host / X-Forwarded-Host.
  • Set ENCRYPTION_KEY so client secrets are encrypted. Without it they're stored in plaintext, the same fallback every other secret uses.
  • The API server must be able to reach the IdP's issuer URL.

Test plan

  • Go unit tests (internal/service/sso), run against a fake IdP that signs real RS256 ID tokens. They cover:
    • provisioning and returning sign-in
    • wrong browser binding and replay
    • bad code
    • disabled provider
    • discovery check on save
    • input validation
    • secret kept on update
    • linking only with a verified email
    • account_exists
    • domain allow-list
    • deleted user stays out
    • username collisions
    • safe redirects
  • Go e2e TestSSOSignIn (PACA_E2E=1, real Postgres and Redis). It drives the full browser redirect chain through the real router and checks:
    • ADMIN gets 403 and SUPER_ADMIN can manage providers
    • the secret is never returned
    • the public provider list
    • account creation on first sign-in, and reuse on the next
    • open redirects are dropped
    • no_account when account creation is off
    • disabled providers can't be used
    • delete
  • go vet ./... and all API unit tests pass.
  • Web: tsc -b, biome check, and vitest run (924 tests, including locale parity) pass.
  • Manual check on the dev stack, with Playwright against a local fake IdP:
    • configured a provider through the admin UI
    • signed in as a new user and landed on /home with the right name, email and default role
    • checked the error notice and the mobile layout of the login page

🤖 Generated with Claude Code

…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.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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-side state/nonce/browser-binding in Redis (GetDel, single-use, 10-min TTL), constant-time nonce + binding comparison, provider-id match, SafeRedirect confining the post-login redirect to in-app paths, and account resolution (link by verified email → auto-provision → else refuse).
  • Persistence — 000062_add_oidc_sso.sql adds oidc_providers (unique slug) and user_identities (unique (provider_id, subject), ON DELETE CASCADE); sqlx repository stores client_secret encrypted via secret.Encryptor with plaintext fallback.
  • Admin API + permission — /admin/sso/providers CRUD behind the new root-equivalent settings.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) computes base[: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_id is ON DELETE CASCADE, so a future hard-delete path would let the same subject re-provision a fresh account.

Pullfrog  | Fix all ➔ | Fix 👍s ➔ | View workflow run | Using DeepSeek Flash (free via Pullfrog for OSS) | 𝕏

Comment thread services/api/internal/service/sso/resolve.go Outdated
Comment thread services/api/internal/service/sso/resolve.go
- 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>

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 usernameBase panic — a verified email without @ no longer indexes email[:-1]; it now uses strings.Cut.
  • Handled the user_identities unique-violation race — CreateIdentity maps the conflict to ErrIdentityExists, and ResolveUser signs in as the winner via a new signInLinked helper while deleting the account its own attempt provisioned.
  • Bounded withSuffix against 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.

Pullfrog  | Fix all ➔ | Fix 👍s ➔ | View workflow run | Using DeepSeek Flash (free via Pullfrog for OSS) | 𝕏

Comment thread services/api/internal/service/sso/resolve.go Outdated
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>

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ 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 a LinkByEmail winner 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 it drives exactly that interleaving through beforeCreateIdentity, asserting the adopted account survives and later sign-ins still resolve. TestResolveUser passes on 8732fe1f.

Pullfrog  | View workflow run | Using DeepSeek Flash (free via Pullfrog for OSS) | 𝕏

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ℹ️ 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 — Repository gains LockIdentity; ResolveUser now takes it after the fast-path FindIdentity miss, 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 dedicated db.Conn(ctx) with a 15s wait bound, released by unlock (or by Postgres if the connection dies), mirroring the existing runMigrations pattern.
  • Removed the ErrIdentityExists catch-and-delete fallback — CreateIdentity still 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 createDelay knob 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.

Pullfrog  | Fix all ➔ | Fix 👍s ➔ | View workflow run | Using DeepSeek Flash (free via Pullfrog for OSS) | 𝕏

Comment thread services/api/test/e2e/sso_test.go
…ts; enhance email linking logic to ignore case

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ℹ️ 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 /auth credential 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, 0 disables, negative/NaN rejected) through config, the new TOO_MANY_REQUESTS code and 429 presenter mapping, the web login-form error path, all nine locales, and the deploy/helm examples.
  • Hardened IdP discovery — providerHTTPClient dials through a Control hook that refuses link-local, multicast and unspecified addresses, and concurrent discovery misses for the same provider version share one fetch via singleflight.
  • Made email matching case-insensitive — FindByEmail compares lower(email) (exact case wins), backed by a new partial idx_users_email_lower_active index; checkEmailAvailable takes a self id so re-casing your own address is not a conflict; added e2e coverage for link-by-email across case.
  • Stopped trusting X-Forwarded-Host when deriving the SSO callback origin (uses PUBLIC_URL, else the request Host), and documented the multi-tenant-issuer caveat for link-by-email.

Pullfrog  | Fix all ➔ | Fix 👍s ➔ | View workflow run | Using DeepSeek Flash (free via Pullfrog for OSS) | 𝕏

Comment thread services/api/internal/service/sso/sso_service.go Outdated

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ 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 — providerHTTPClient now parses the dial address with netip.ParseAddr instead of net.ParseIP, and blockedProviderIP strips 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 that fe80::1%eth0 parses and is blocked, and 169.254.169.254 remains blocked.
  • Stopped calling t.Fatal from test goroutines — the concurrent e2e subtest now builds all browsers on the test goroutine and routes the sign-in through a new trySSOSignIn that returns an error; workers report with t.Error. The previously open thread on sso_test.go:263 is 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.

Pullfrog  | View workflow run | Using DeepSeek Flash (free via Pullfrog for OSS) | 𝕏

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ 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.yml gains mock-oidc (ghcr.io/navikt/mock-oauth2-server:2.1.10 with interactiveLogin), reachable by the API at http://mock-oidc:9090 and published on host port 9090 for the browser.
  • Added the SSO Playwright suite — apps/e2e/tests/admin/sso.spec.ts plus features/admin/sso.feature cover permission gating (settings.write vs settings.sso.write), provider create/edit/delete through the admin UI, first-time auto-provisioning, returning sign-in matched by sub, 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 the sso_state cookie.
  • Documented the mock provider — apps/e2e/README.md notes the provider, its internal/public URLs, and the E2E_MOCK_OIDC_URL override.

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.

Pullfrog  | View workflow run | Using DeepSeek Flash (free via Pullfrog for OSS) | 𝕏

@pikann
pikann merged commit c01728c into master Sep 27, 2026
12 checks passed
@pikann
pikann deleted the feature/implement-sso-connect-providers branch September 27, 2026 13:40
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