feat(auth): surface OIDC callback errors with distinct codes + log cause (#100) #101

Merged
james merged 1 commit from 100-oauth-callback-diagnostics into main 2026-06-18 03:51:01 +00:00
Owner

Summary

The OAuth callback's catch block (added in #85) collapsed every error other than NoVerifiedEmailError into a generic 400 with "Failed to fetch profile" and no log line. For OIDC, that swallowed three distinct failure classes (IdTokenSignatureError, IdTokenClaimError, NonceMismatchError) and left a self-hoster with nothing to grep for. Hit while deploying #85 against Authentik on carol.int.wynning.tech — the only signal was "Failed to fetch profile."

Now the catch:

  • Logs the underlying error to console.error with the provider id, so the cause shows up in container logs.
  • Redirects to /login with a distinct ?error= code per failure class:
    • IdTokenSignatureErroroidc_signature
    • IdTokenClaimErroroidc_claim (iss / aud / exp / email / missing id_token)
    • NonceMismatchErroroidc_nonce_mismatch
    • NoVerifiedEmailErrorno_verified_email (unchanged)
    • other → oauth_profile

Either signal alone is enough to diagnose a misconfigured IdP.

Decision: 302 + log (not 400 + log)

The pre-existing NoVerifiedEmailError path already redirected to /login?error=…, so this lands all "we got a token, validation failed" cases on the same shape. Protocol-level failures (cookies missing, state mismatch, provider mismatch, RFC 9207 iss mismatch) stay 400 — those indicate browser or attack-shaped problems where landing on /login would be misleading.

Reviewer note: see the "Out of scope" section of #100 — I'm open to flipping these back to 400 if a reviewer prefers the cleaner security-review story.

Test plan

  • npm run typecheck — clean.
  • npm run lint — clean.
  • npm test — 214 / 38 skipped.
  • Three existing OIDC scenarios that asserted 400 now assert 302 + matching ?error= code: bad signature (oidc_signature), email_verified: false (oidc_claim), nonce mismatch (oidc_nonce_mismatch).
  • Manual re-probe against Authentik on carol.int.wynning.tech — confirm container logs show the specific error class.

Closes #100.

🤖 Generated with Claude Code

## Summary The OAuth callback's catch block (added in #85) collapsed every error other than `NoVerifiedEmailError` into a generic 400 with "Failed to fetch profile" and no log line. For OIDC, that swallowed three distinct failure classes (`IdTokenSignatureError`, `IdTokenClaimError`, `NonceMismatchError`) and left a self-hoster with nothing to grep for. Hit while deploying #85 against Authentik on `carol.int.wynning.tech` — the only signal was "Failed to fetch profile." Now the catch: - Logs the underlying error to `console.error` with the provider id, so the cause shows up in container logs. - Redirects to `/login` with a distinct `?error=` code per failure class: - `IdTokenSignatureError` → `oidc_signature` - `IdTokenClaimError` → `oidc_claim` (iss / aud / exp / email / missing id_token) - `NonceMismatchError` → `oidc_nonce_mismatch` - `NoVerifiedEmailError` → `no_verified_email` (unchanged) - other → `oauth_profile` Either signal alone is enough to diagnose a misconfigured IdP. ## Decision: 302 + log (not 400 + log) The pre-existing `NoVerifiedEmailError` path already redirected to `/login?error=…`, so this lands all "we got a token, validation failed" cases on the same shape. Protocol-level failures (cookies missing, state mismatch, provider mismatch, RFC 9207 iss mismatch) stay 400 — those indicate browser or attack-shaped problems where landing on `/login` would be misleading. Reviewer note: see the "Out of scope" section of #100 — I'm open to flipping these back to 400 if a reviewer prefers the cleaner security-review story. ## Test plan - [x] `npm run typecheck` — clean. - [x] `npm run lint` — clean. - [x] `npm test` — 214 / 38 skipped. - [x] Three existing OIDC scenarios that asserted 400 now assert 302 + matching `?error=` code: bad signature (`oidc_signature`), `email_verified: false` (`oidc_claim`), nonce mismatch (`oidc_nonce_mismatch`). - [ ] Manual re-probe against Authentik on `carol.int.wynning.tech` — confirm container logs show the specific error class. Closes #100. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
feat(auth): surface OIDC callback errors with distinct codes + log cause
All checks were successful
Commits / Conventional Commits (pull_request) Successful in 11s
PR / OSV-Scanner (pull_request) Successful in 30s
Secrets / gitleaks (pull_request) Successful in 29s
PR / Static analysis (pull_request) Successful in 45s
PR / Test (sqlite) (pull_request) Successful in 1m9s
PR / Trivy (image) (pull_request) Successful in 1m13s
PR / npm audit (pull_request) Successful in 1m14s
PR / Lint (pull_request) Successful in 1m22s
PR / Typecheck (pull_request) Successful in 1m32s
PR / Build (pull_request) Successful in 1m33s
PR / Test (postgres) (pull_request) Successful in 1m41s
f8ed6588e2
The previous catch in /api/auth/oauth/callback collapsed every error
other than NoVerifiedEmailError into a generic "Failed to fetch
profile" 400 with no log line. A self-hoster hitting an
IdTokenSignatureError / IdTokenClaimError / NonceMismatchError had no
way to tell which one.

Now: log the underlying cause to console.error (with provider id) and
redirect to /login with a distinct error code per failure class:

- IdTokenSignatureError  -> oidc_signature
- IdTokenClaimError      -> oidc_claim (iss/aud/exp/email/missing id_token)
- NonceMismatchError     -> oidc_nonce_mismatch
- NoVerifiedEmailError   -> no_verified_email (unchanged)
- other                  -> oauth_profile

Discovered while deploying #85 against Authentik.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
james force-pushed 100-oauth-callback-diagnostics from f8ed6588e2
All checks were successful
Commits / Conventional Commits (pull_request) Successful in 11s
PR / OSV-Scanner (pull_request) Successful in 30s
Secrets / gitleaks (pull_request) Successful in 29s
PR / Static analysis (pull_request) Successful in 45s
PR / Test (sqlite) (pull_request) Successful in 1m9s
PR / Trivy (image) (pull_request) Successful in 1m13s
PR / npm audit (pull_request) Successful in 1m14s
PR / Lint (pull_request) Successful in 1m22s
PR / Typecheck (pull_request) Successful in 1m32s
PR / Build (pull_request) Successful in 1m33s
PR / Test (postgres) (pull_request) Successful in 1m41s
to 6fd6509547
All checks were successful
Commits / Conventional Commits (pull_request) Successful in 23s
Secrets / gitleaks (pull_request) Successful in 18s
PR / OSV-Scanner (pull_request) Successful in 42s
PR / Trivy (image) (pull_request) Successful in 46s
PR / Static analysis (pull_request) Successful in 46s
PR / Lint (pull_request) Successful in 1m45s
PR / Typecheck (pull_request) Successful in 1m49s
PR / npm audit (pull_request) Successful in 2m10s
PR / Test (sqlite) (pull_request) Successful in 2m18s
PR / Test (postgres) (pull_request) Successful in 2m20s
PR / Build (pull_request) Successful in 2m33s
2026-06-18 03:49:54 +00:00
Compare
james merged commit 73ddb10ed5 into main 2026-06-18 03:51:01 +00:00
james deleted branch 100-oauth-callback-diagnostics 2026-06-18 03:51:02 +00:00
Sign in to join this conversation.
No description provided.