feat(auth): OIDC userinfo fallback for email / email_verified (#105) #106

Merged
james merged 1 commit from 105-oidc-userinfo-fallback into main 2026-06-18 12:21:57 +00:00
Owner

Summary

Authentik (and a few other IdPs) gate email / email_verified behind a per-provider "Include claims in id_token" toggle. When off, those claims live only at the userinfo endpoint and the id_token carries just standard claims. Carol previously rejected such users with ?error=oidc_claim — even for users the IdP WAS willing to attest as verified, just not on the id_token.

Carol now widens where it looks for the claim. The verified-email boundary doesn't move: email_verified === true is still required.

Flow

  1. Verify the id_token (signature + iss + aud + exp + nbf + nonce + sub). Unchanged trust anchor.
  2. If the id_token lacks email or email_verified, call the userinfo endpoint with the access_token from the same token-exchange.
  3. Require the userinfo sub to equal the id_token sub (OIDC §5.3.2 — token-substitution defence).
  4. Merge the claims; apply the strict email_verified === true check.
  5. If userinfo can't run (no access_token, no userinfo_endpoint configured) and the id_token lacks the claim, still ?error=oidc_claim.

Trust chain

  • id_token signature is anchored on the JWKS (unchanged).
  • userinfo is fetched over HTTPS with Bearer access_token — IdP authenticates Carol because the IdP just issued the access_token to Carol's client_id.
  • sub match binds the userinfo response to the same subject the id_token signed off on — a stolen access_token for a different user can't be substituted.
  • Strict email_verified === true still load-bearing (ADR-0015 §3 inherited).

Full rationale in ADR-0017 §4.5.

Test plan

  • npm run typecheck — clean.
  • npm run lint — clean.
  • npm test — 225 passed / 38 skipped (was 215 / 38 — 10 new tests).
    • verifyIdToken now returns {providerUserId, email?, emailVerified?}; 3 new tests cover claim-present-but-explicit-false, claim-missing-entirely, both-missing.
    • fetchUserinfo unit: returns email + verified on sub match; rejects on sub mismatch (token substitution); rejects on non-200; returns undefined on missing claims.
    • End-to-end tests/api/oauth.test.ts (+5): id_token lacks email_verified + userinfo provides it → session set; id_token lacks both + userinfo provides both → session set; userinfo sub mismatch → oidc_claim; userinfo also lacks claim → oidc_claim; no access_token to call userinfo → oidc_claim.
  • Manual re-probe on carol.int.wynning.tech after release — sign-in clears without changing Authentik's claims toggle.

Files

  • lib/auth/oidc-verify.tsverifyIdToken shape change + new fetchUserinfo helper.
  • lib/auth/oidc-providers.ts — orchestrated extractProfile.
  • docs/adr/0017-oidc-generic-provider.md — new §4.5; §3 threat #13 updated.
  • docs/oidc-self-hoster-guide.md — Troubleshooting entry.
  • tests/auth/oidc-verify.test.ts — updated + fetchUserinfo tests.
  • tests/api/oauth.test.ts — end-to-end fallback scenarios.

Closes #105.

🤖 Generated with Claude Code

## Summary Authentik (and a few other IdPs) gate `email` / `email_verified` behind a per-provider "Include claims in id_token" toggle. When off, those claims live only at the userinfo endpoint and the id_token carries just standard claims. Carol previously rejected such users with `?error=oidc_claim` — even for users the IdP WAS willing to attest as verified, just not on the id_token. Carol now widens *where it looks* for the claim. The verified-email *boundary* doesn't move: `email_verified === true` is still required. ### Flow 1. Verify the id_token (signature + iss + aud + exp + nbf + nonce + sub). **Unchanged trust anchor.** 2. If the id_token lacks `email` or `email_verified`, call the userinfo endpoint with the `access_token` from the same token-exchange. 3. Require the userinfo `sub` to equal the id_token `sub` (OIDC §5.3.2 — token-substitution defence). 4. Merge the claims; apply the strict `email_verified === true` check. 5. If userinfo can't run (no `access_token`, no `userinfo_endpoint` configured) and the id_token lacks the claim, still `?error=oidc_claim`. ### Trust chain - id_token signature is anchored on the JWKS (unchanged). - userinfo is fetched over HTTPS with Bearer `access_token` — IdP authenticates Carol because the IdP just issued the access_token to Carol's client_id. - `sub` match binds the userinfo response to the same subject the id_token signed off on — a stolen access_token for a different user can't be substituted. - Strict `email_verified === true` still load-bearing (ADR-0015 §3 inherited). Full rationale in ADR-0017 §4.5. ## Test plan - [x] `npm run typecheck` — clean. - [x] `npm run lint` — clean. - [x] `npm test` — 225 passed / 38 skipped (was 215 / 38 — 10 new tests). - `verifyIdToken` now returns `{providerUserId, email?, emailVerified?}`; 3 new tests cover claim-present-but-explicit-false, claim-missing-entirely, both-missing. - `fetchUserinfo` unit: returns email + verified on sub match; rejects on sub mismatch (token substitution); rejects on non-200; returns undefined on missing claims. - End-to-end `tests/api/oauth.test.ts` (+5): id_token lacks `email_verified` + userinfo provides it → session set; id_token lacks both + userinfo provides both → session set; userinfo sub mismatch → `oidc_claim`; userinfo also lacks claim → `oidc_claim`; no `access_token` to call userinfo → `oidc_claim`. - [ ] Manual re-probe on `carol.int.wynning.tech` after release — sign-in clears without changing Authentik's claims toggle. ## Files - `lib/auth/oidc-verify.ts` — `verifyIdToken` shape change + new `fetchUserinfo` helper. - `lib/auth/oidc-providers.ts` — orchestrated `extractProfile`. - `docs/adr/0017-oidc-generic-provider.md` — new §4.5; §3 threat #13 updated. - `docs/oidc-self-hoster-guide.md` — Troubleshooting entry. - `tests/auth/oidc-verify.test.ts` — updated + `fetchUserinfo` tests. - `tests/api/oauth.test.ts` — end-to-end fallback scenarios. Closes #105. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
feat(auth): OIDC userinfo fallback for email / email_verified
All checks were successful
Commits / Conventional Commits (pull_request) Successful in 9s
PR / OSV-Scanner (pull_request) Successful in 17s
Secrets / gitleaks (pull_request) Successful in 35s
PR / Static analysis (pull_request) Successful in 44s
PR / Typecheck (pull_request) Successful in 1m0s
PR / npm audit (pull_request) Successful in 1m4s
PR / Lint (pull_request) Successful in 1m8s
PR / Test (sqlite) (pull_request) Successful in 1m12s
PR / Test (postgres) (pull_request) Successful in 1m14s
PR / Build (pull_request) Successful in 1m19s
PR / Trivy (image) (pull_request) Successful in 1m27s
a694e940b8
Authentik (and a few other IdPs) gate `email` / `email_verified`
behind a per-provider "Include claims in id_token" toggle. When off,
those claims live only at the userinfo endpoint, and the id_token
carries just the standard claims. Carol previously read those claims
exclusively from the id_token and rejected with
"id_token email_verified is not true" — even for users the IdP
WAS willing to attest as verified, just not on the id_token.

Carol now:

1. Verifies the id_token (signature + iss + aud + exp + nbf + nonce
   + sub) — unchanged trust anchor.
2. If the id_token lacks `email` or `email_verified`, calls the
   userinfo endpoint with the access_token from the same exchange
   and merges in what's there.
3. Requires the userinfo `sub` to equal the id_token `sub`
   (OIDC §5.3.2 token-substitution defence).
4. Applies the strict `email_verified === true` check against the
   merged claims.

The verified-email boundary doesn't move — we only widened where
Carol looks. ADR-0017 §4.5 documents the trust chain; self-hoster
guide picks up a Troubleshooting entry for the new failure mode.

Discovered deploying #85 against Authentik on
carol.int.wynning.tech — the JWT preview Authentik showed for
the user had `email_verified: true`, but the live id_token didn't
carry the claim.

Closes #105.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
james merged commit 4de8b7c420 into main 2026-06-18 12:21:57 +00:00
james deleted branch 105-oidc-userinfo-fallback 2026-06-18 12:21:58 +00:00
Sign in to join this conversation.
No description provided.