feat(auth): generic OIDC provider with discovery + per-endpoint overrides (#85) #96

Merged
james merged 1 commit from 85-oidc into main 2026-06-18 02:32:42 +00:00
Owner

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:

  • id_token signature + claims: jose.createRemoteJWKSet + jose.jwtVerify with an explicit algorithm allowlist (RS/ES 256/384/512). HS256 rejected to close the alg-confusion attack class.
  • Nonce replay defence: /start generates a nonce only when kind === "oidc", sets OAUTH_NONCE_COOKIE, appends &nonce=… to the authorize URL; callback validates the id_token's nonce claim against the cookie.
  • RFC 9207 cross-IdP issuer check: when the IdP echoes ?iss= on the callback, callback compares it against the configured issuer (defence-in-depth on top of the cookie-bound mix-up check).
  • Discovery doc trust anchor: doc's issuer claim 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 be https://; 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.ts register() 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.tsresolveOIDCEndpoints, per-issuer cache.
  • lib/auth/oidc-verify.tsverifyIdToken with JWKS + claim + nonce + email_verified checks.
  • lib/auth/oidc-providers.tsgetOIDCInstances env parsing + instance building.
  • docs/adr/0016-oidc-generic-provider.md, docs/oidc-self-hoster-guide.md.
  • Three matching test files.

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 reads tokenResponse.access_token as before and ignores nonce.
  • New kind: "oauth2" | "oidc" field on OAuthProvider drives the nonce + RFC 9207 branches in /start and /callback.
  • getEnabledProviders / getProviderById / isProviderEnabled became async because OIDC instances resolve via discovery. All three callers (OAuthButtons RSC, /start, /callback) were already in async contexts.
  • OAUTH_NONCE_COOKIE added to oauth-cookies.ts and included in OAUTH_COOKIE_NAMES so clearOAuthCookies clears 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 /offline still ○ Static (ADR-0008 invariant preserved).
  • Manual probe against Authentik (deferred to reviewer):
    • Set OIDC_AUTHENTIK_ISSUER/_CLIENT_ID/_CLIENT_SECRET against a local Authentik with redirect URI <APP_URL>/api/auth/oauth/callback/authentik.
    • Visit /login — "Sign in with Authentik" appears. Click → consent → land on /profile. Visit /account — Authentik identity listed.
    • Mis-configure OIDC_AUTHENTIK_TOKEN_ENDPOINT to a wrong path → sign-in fails clearly. Set it correctly → sign-in succeeds.
  • Multi-instance probe: add OIDC_KEYCLOAK_* alongside OIDC_AUTHENTIK_* → both buttons appear.

Closes #85.

🤖 Generated with Claude Code

## 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: - **id_token signature + claims**: `jose.createRemoteJWKSet` + `jose.jwtVerify` with an explicit algorithm allowlist (RS/ES 256/384/512). **HS256 rejected** to close the alg-confusion attack class. - **Nonce replay defence**: `/start` generates a nonce only when `kind === "oidc"`, sets `OAUTH_NONCE_COOKIE`, appends `&nonce=…` to the authorize URL; callback validates the id_token's nonce claim against the cookie. - **RFC 9207 cross-IdP issuer check**: when the IdP echoes `?iss=` on the callback, callback compares it against the configured issuer (defence-in-depth on top of the cookie-bound mix-up check). - **Discovery doc trust anchor**: doc's `issuer` claim 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 be `https://`; 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.ts` `register()` is fire-and-forget — eager validation would be illusory. See [ADR-0016](docs/adr/0016-oidc-generic-provider.md) for the full threat model and design; [docs/oidc-self-hoster-guide.md](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` — `verifyIdToken` with JWKS + claim + nonce + email_verified checks. - `lib/auth/oidc-providers.ts` — `getOIDCInstances` env parsing + instance building. - `docs/adr/0016-oidc-generic-provider.md`, `docs/oidc-self-hoster-guide.md`. - Three matching test files. 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 reads `tokenResponse.access_token` as before and ignores `nonce`. - New `kind: "oauth2" | "oidc"` field on `OAuthProvider` drives the nonce + RFC 9207 branches in `/start` and `/callback`. - `getEnabledProviders` / `getProviderById` / `isProviderEnabled` became async because OIDC instances resolve via discovery. All three callers (`OAuthButtons` RSC, `/start`, `/callback`) were already in async contexts. - `OAUTH_NONCE_COOKIE` added to `oauth-cookies.ts` and included in `OAUTH_COOKIE_NAMES` so `clearOAuthCookies` clears it on every exit path. ## Test plan - [x] `npm run typecheck` — clean. - [x] `npm run lint` — clean. - [x] `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. - [x] `npm run build` — succeeds. `/` and `/offline` still `○ Static` (ADR-0008 invariant preserved). - [ ] **Manual probe against Authentik** (deferred to reviewer): - Set `OIDC_AUTHENTIK_ISSUER/_CLIENT_ID/_CLIENT_SECRET` against a local Authentik with redirect URI `<APP_URL>/api/auth/oauth/callback/authentik`. - Visit `/login` — "Sign in with Authentik" appears. Click → consent → land on `/profile`. Visit `/account` — Authentik identity listed. - Mis-configure `OIDC_AUTHENTIK_TOKEN_ENDPOINT` to a wrong path → sign-in fails clearly. Set it correctly → sign-in succeeds. - [ ] **Multi-instance probe**: add `OIDC_KEYCLOAK_*` alongside `OIDC_AUTHENTIK_*` → both buttons appear. Closes #85. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
feat(auth): generic OIDC provider with discovery + per-endpoint overrides
All checks were successful
PR / Static analysis (Semgrep) (pull_request) Successful in 26s
PR / OSV-Scanner (pull_request) Successful in 17s
Secrets / gitleaks (pull_request) Successful in 14s
PR / Typecheck (pull_request) Successful in 4m15s
PR / Lint (pull_request) Successful in 5m4s
PR / Test (postgres) (pull_request) Successful in 4m1s
PR / Build (pull_request) Successful in 6m42s
PR / npm audit (pull_request) Successful in 5m40s
PR / Test (sqlite) (pull_request) Successful in 5m47s
PR / Trivy (image) (pull_request) Successful in 6m32s
91c8d34afc
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. Inherits every ADR-0015 invariant
(state, PKCE, mix-up, replay, no-token-storage, no-auto-merge-by-email)
and adds id_token signature + claim validation, nonce replay defence,
and RFC 9207 cross-IdP issuer check.

Endpoint resolution: env override -> discovery doc -> error. Overrides
are required to be https://; the issuer itself is never overridable
(trust anchor). Discovery is lazy + memoised with a 24h TTL, 5s fetch
timeout, and serve-stale-revalidate.

id_token verification uses jose's createRemoteJWKSet + jwtVerify with
an explicit algorithm allowlist (RS/ES 256/384/512); HS256 is rejected
to close the alg-confusion attack class.

Closes #85.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
james force-pushed 85-oidc from 91c8d34afc
All checks were successful
PR / Static analysis (Semgrep) (pull_request) Successful in 26s
PR / OSV-Scanner (pull_request) Successful in 17s
Secrets / gitleaks (pull_request) Successful in 14s
PR / Typecheck (pull_request) Successful in 4m15s
PR / Lint (pull_request) Successful in 5m4s
PR / Test (postgres) (pull_request) Successful in 4m1s
PR / Build (pull_request) Successful in 6m42s
PR / npm audit (pull_request) Successful in 5m40s
PR / Test (sqlite) (pull_request) Successful in 5m47s
PR / Trivy (image) (pull_request) Successful in 6m32s
to 72a5af9dc1
All checks were successful
Commits / Conventional Commits (pull_request) Successful in 18s
PR / OSV-Scanner (pull_request) Successful in 28s
Secrets / gitleaks (pull_request) Successful in 16s
PR / Static analysis (pull_request) Successful in 40s
PR / Build (pull_request) Successful in 2m5s
PR / npm audit (pull_request) Successful in 2m10s
PR / Typecheck (pull_request) Successful in 2m13s
PR / Lint (pull_request) Successful in 2m15s
PR / Test (sqlite) (pull_request) Successful in 2m18s
PR / Test (postgres) (pull_request) Successful in 2m19s
PR / Trivy (image) (pull_request) Successful in 7m24s
2026-06-18 02:29:00 +00:00
Compare
james merged commit fbcd7051c0 into main 2026-06-18 02:32:42 +00:00
james deleted branch 85-oidc 2026-06-18 02:32:42 +00:00
Sign in to join this conversation.
No description provided.