feat(auth): generic OIDC provider with discovery + per-endpoint overrides (#85) #96
No reviewers
Labels
No labels
area:auth
area:ci
area:db
area:infra
area:native
area:pwa
area:service
epic
feature
foundation
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
james/carol!96
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "85-oidc"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
Adds OpenID Connect support to Carol via a single generic provider type configured by
OIDC_<NAME>_*env vars. Self-hosters can wire Authentik, Keycloak, Zitadel, Google-as-OIDC, or any compliant IdP without code changes; multiple instances coexist on the same deployment.Inherits every ADR-0015 invariant (state, PKCE, mix-up via cookie binding, replay via same-response cookie clear, no-token-storage, verified-email-required, no-auto-merge-by-email) and adds the OIDC-specific surface:
jose.createRemoteJWKSet+jose.jwtVerifywith an explicit algorithm allowlist (RS/ES 256/384/512). HS256 rejected to close the alg-confusion attack class./startgenerates a nonce only whenkind === "oidc", setsOAUTH_NONCE_COOKIE, appends&nonce=…to the authorize URL; callback validates the id_token's nonce claim against the cookie.?iss=on the callback, callback compares it against the configured issuer (defence-in-depth on top of the cookie-bound mix-up check).issuerclaim must equal the configured issuer (RFC 8414 §3.3).Endpoint resolution (
OIDC_<NAME>_{AUTH,TOKEN,USERINFO,JWKS}_ENDPOINT): env override → discovery doc → error. Overrides must behttps://; the issuer is never overridable (trust anchor). Per-endpoint overrides ship on day one — real Authentik/Keycloak deployments routinely mis-publish discovery.Discovery is lazy + memoised: 24h TTL, 5s fetch timeout, serve-stale-revalidate. Not eager-at-startup because Next's
instrumentation.tsregister()is fire-and-forget — eager validation would be illusory.See ADR-0016 for the full threat model and design; docs/oidc-self-hoster-guide.md for Authentik/Keycloak/Google/Zitadel recipes.
Implementation
New files:
lib/auth/oidc-discovery.ts—resolveOIDCEndpoints, per-issuer cache.lib/auth/oidc-verify.ts—verifyIdTokenwith JWKS + claim + nonce + email_verified checks.lib/auth/oidc-providers.ts—getOIDCInstancesenv parsing + instance building.docs/adr/0016-oidc-generic-provider.md,docs/oidc-self-hoster-guide.md.Interface widening rippled across the OAuth callers (in one PR to avoid a half-migrated state):
OAuthProvider.fetchProfile({accessToken, fetch})→extractProfile({tokenResponse, nonce, fetch}). GitHub readstokenResponse.access_tokenas before and ignoresnonce.kind: "oauth2" | "oidc"field onOAuthProviderdrives the nonce + RFC 9207 branches in/startand/callback.getEnabledProviders/getProviderById/isProviderEnabledbecame async because OIDC instances resolve via discovery. All three callers (OAuthButtonsRSC,/start,/callback) were already in async contexts.OAUTH_NONCE_COOKIEadded tooauth-cookies.tsand included inOAUTH_COOKIE_NAMESsoclearOAuthCookiesclears it on every exit path.Test plan
npm run typecheck— clean.npm run lint— clean.npm test— 214 passed / 38 skipped (up from 175 / 38 on main).tests/auth/oidc-discovery.test.ts(12) — override precedence, discovery cache, issuer-mismatch rejection, missing-endpoint rejection, https-only overrides.tests/auth/oidc-verify.test.ts(8) — happy path, wrong-key signature, wrong iss/aud, expired exp, nonce mismatch,email_verified: false, missing email — each maps to a distinct error class.tests/auth/oidc-providers.test.ts(10) — env parsing, multi-instance, missing required envs, name validation, name-collision-with-static-provider skip, instance-disable-on-discovery-failure.tests/api/oauth.test.ts(+8 OIDC scenarios) — end-to-end: nonce cookie set + matches authorize URL, OAuth2 path stays nonce-free, happy callback signs the user in, nonce mismatch / wrong key / unverified email / missing nonce cookie / RFC 9207 iss mismatch all 400.npm run build— succeeds./and/offlinestill○ Static(ADR-0008 invariant preserved).OIDC_AUTHENTIK_ISSUER/_CLIENT_ID/_CLIENT_SECRETagainst a local Authentik with redirect URI<APP_URL>/api/auth/oauth/callback/authentik./login— "Sign in with Authentik" appears. Click → consent → land on/profile. Visit/account— Authentik identity listed.OIDC_AUTHENTIK_TOKEN_ENDPOINTto a wrong path → sign-in fails clearly. Set it correctly → sign-in succeeds.OIDC_KEYCLOAK_*alongsideOIDC_AUTHENTIK_*→ both buttons appear.Closes #85.
🤖 Generated with Claude Code
91c8d34afc72a5af9dc1