Skip to content

fix(auth): verify the MP id_token lazily instead of fetching OIDC discovery at boot (#101) - #107

Merged
chriskehayias merged 2 commits into
mainfrom
fix/101-oidc-lazy-discovery
Sep 29, 2026
Merged

chriskehayias merged 2 commits into
mainfrom
fix/101-oidc-lazy-discovery

Conversation

@chriskehayias

Copy link
Copy Markdown
Contributor

Fixes #101.

Problem

genericOAuth fetched MP's OIDC discovery document once, inside the auth context's init, with no timeout and no retry. Every auth request, and every in-process auth.api.* call (i.e. every page's session check), awaits that init.

  • A failed fetch dropped the provider: /sign-in/social returned 404 PROVIDER_NOT_FOUND. selfHealingAuth (Security review 2026-09-28: close all open findings #99) rebuilt the instance on the next sign-in after a 30 s cooldown.
  • A hung fetch (MP accepts the connection and never answers) stalled the first sign-in and every session check for undici's ~300 s headers timeout. The facade's wait limit only covered rebuilds, not the first instance.

Fix

Stop depending on discovery at boot. Keep verifying the id_token against MP's JWKS.

  • Explicit endpoints, no discoveryUrl (src/lib/auth.ts, mpOidc):

    • authorizationUrl, tokenUrl and endSessionEndpoint (without it, sign-out loses the id_token_hint);
    • accountSubject: profile.sub.

    Building the instance, next build, cold starts and session checks make no MP call, so the provider always registers.

  • verifyMpIdToken in getUserInfo: jose jwtVerify (signature, iss, aud, exp/nbf), the same call better-auth made, with RS256 pinned (ID_TOKEN_ALGORITHMS). It runs before the existing exp-presence, azp and sub-binding checks, and replaces the hand-rolled unverified decoder.

  • lazyIdTokenVerifier: issuer and jwks_uri still come from discovery, because the JWKS path is not the same on every IdentityServer. The live MP serves /oauth/.well-known/jwks; the IdentityServer default is /openid-configuration/jwks. The fetch is:

    • made at the first callback, not at boot;
    • limited by a 5 s timeout, with redirect: "error";
    • shared by concurrent callbacks;
    • cached on success;
    • never cached on failure.

    jose likewise doesn't cache a failed JWKS fetch. A document without a usable issuer/jwks_uri fails closed.

  • What an MP blip looks like now: only the sign-ins that land during it are refused (/auth-error?error=unable_to_get_user_info, copy now says "please try again"). The next attempt works on the same process.

  • Removed:

    • selfHealingAuth and its Proxy facade;
    • requireIdTokenVerification (genericOAuth throws for it without discovery);
    • disableIdTokenNonceBinding (a no-op without an id_token config; the F8 accepted-risk note is kept).
  • F12: with no id_token config, better-auth now also refuses the idToken branch itself (ID_TOKEN_NOT_SUPPORTED). The route filter and hooks.before guard remain the primary controls.

  • Nothing lost on RFC 9207: losing provider.issuer skips better-auth's callback iss check, but MP doesn't advertise authorization_response_iss_parameter_supported.

  • New logs, identifiers only:

    • auth.oidc.discovery_failed (reason, status or errName, field);
    • auth.userinfo.id_token_unverified (verifier_unavailable, or verification_failed with jose code/claim).
  • jose is now a direct dependency.

  • scripts/check-lockfile.mjs --fix bug: deps:relock skipped writing when the tree shape was unchanged. Adding a dependency that's already hoisted only changes the lockfile's root entry, so jose never reached package-lock.json. It now writes metadata-only differences too. The resulting lockfile diff is exactly the one root line.

Tests

  • New src/auth.oidc-discovery.test.ts (23 tests):

    • MP rejecting, returning 500 or hanging at boot, with zero MP calls;
    • discovery failing at the callback: refused and logged, then the same instance signs in once MP recovers;
    • the same for a JWKS failure;
    • timeout and redirect refusal;
    • single-flight fetching and caching;
    • lazyIdTokenVerifier unit tests: relative jwks_uri, a hung request timing out, failures never cached, fail-closed on 9 kinds of bad document.

    Mutation check: re-adding discoveryUrl fails 11 of the 23.

  • Replaced auth.discovery-rebuild.test.ts, which tested the removed facade.

  • Updated suites:

    • auth.test.ts: now on the node environment, calling getUserInfo with really signed tokens via stubUserinfo. New cases for alg confusion (HS256 with the client secret, alg: none), malformed tokens and config pins.
    • auth.code-flow.test.ts: jose error codes; negative control that re-adding discoveryUrl switches nonce binding back on.
    • auth.id-token-sign-in.test.ts: better-auth refuses the branch without the hook, and the sub binding is still proven with discoveryUrl re-added.
    • auth.oidc-hardening.test.ts: partial discovery document leads to a refused callback.
    • auth.secret-guard.test.ts: no fetch while the instance is built.
    • route.test.ts: fetch now blocks every URL; the old stub fell through to the real network.
  • src/test-utils/mock-oidc.ts gained discovery/jwks/mp modes (ok|reject|http_500|hang) and signIdToken(claims).

Verification

  • npx vitest run --coverage: 85 files, 2062 passed, 3 skipped; thresholds met (auth.ts 100% statements).
  • npx tsc --noEmit, npm run lint, npm run build, npm run build:check-prerender and npm run deps:verify are all clean.
  • The issue's repro on next build + next start against its fake MP, adapted to https:
    • fake MP in fail before start: POST /api/auth/sign-in/social returns 200 with an authorize URL, and discoveryRequests stays at 0 (the issue saw 404 PROVIDER_NOT_FOUND);
    • fake MP in hang on a fresh process: /api/auth/get-session, /sign-in/social, /signin and / answer in 10–40 ms (the issue measured ~301 s).
  • A real code flow against a live MP was not exercised. Worth a manual sign-in / sign-out smoke test after deploy (auth.md § smoke test).

Docs

  • Current-state references rewritten: .claude/references/auth.md, testing.md (counts regenerated), deps-known-issues.md (the relock fix) and README.md.
  • Historical security notes (docs/security/2026-09-25-*, 2026-09-28-*): dated "Update 2026-09-29 (issue Sign-in stays broken until restart if Ministry Platform blips during the one OIDC discovery fetch #101)" notes added, history left as it was.
  • Downstream playbooks: updated, including their grep self-checks.
  • One unrelated correction in deps-known-issues.md: the open item "PKCE can likely be enabled" now records that MP doesn't support PKCE, so pkce: false is required. That line contradicted auth.ts.

Downstream

Forks that ported selfHealingAuth from the 2026-09-28 playbook should port this instead. .claude/playbooks/port-security-review-2026-09-28.md Phase 4 now has the steps, including what to delete and the pitfalls (don't pin the JWKS URL; endSessionEndpoint and accountSubject are required).

🤖 Generated with Claude Code

chriskehayias and others added 2 commits September 29, 2026 19:32
…y at boot (#101)

genericOAuth fetched MP's OIDC discovery document once, inside the auth
context's init, with no timeout or retry, and every auth request and
in-process auth.api call (every page's session check) awaited it. A failed
fetch dropped the provider (404 PROVIDER_NOT_FOUND until selfHealingAuth
rebuilt the instance after a 30 s cooldown); a hung one stalled the first
sign-in and every session check for undici's ~300 s headers timeout, which
the facade's wait cap did not cover.

The provider now has explicit endpoints and no discoveryUrl
(authorizationUrl, tokenUrl, endSessionEndpoint for the id_token_hint,
accountSubject reading profile.sub), so building the instance, next build,
cold starts and session checks make no MP call and the provider always
registers. The id_token is verified in getUserInfo by verifyMpIdToken: jose
jwtVerify with RS256 pinned, against the issuer and JWKS from discovery,
loaded by lazyIdTokenVerifier at the first callback (5 s timeout, no
redirects, single-flight, cached on success, never cached on failure; jose
does not cache a failed key-set fetch either). The JWKS URL is not pinned:
a live MP serves /oauth/.well-known/jwks, the IdentityServer default is
/openid-configuration/jwks. A document without a usable issuer or jwks_uri
fails closed. An MP blip now refuses only the sign-ins that land during it,
and the next one works on the same process.

Removed: selfHealingAuth and its facade, requireIdTokenVerification (throws
without discovery), disableIdTokenNonceBinding (a no-op without an id_token
config), the unverified hand-rolled decoder, and the now-unreachable
"expired"/"undecodable_id_token" reasons (jose covers them). With no
id_token config better-auth also refuses the /sign-in/social idToken branch
itself; the route filter and hooks.before guard stay primary. No
provider.issuer skips the RFC 9207 callback iss check, moot for MP, which
does not advertise authorization_response_iss_parameter_supported.

New log events (identifiers only): auth.oidc.discovery_failed and
auth.userinfo.id_token_unverified. The auth-error copy for
unable_to_get_user_info now invites a retry.

jose becomes a direct dependency. scripts/check-lockfile.mjs --fix skipped
writing when the tree shape was unchanged, so a dependency that is already
hoisted (only the root entry changes) never reached package-lock.json; it
now writes metadata-only differences too.

Tests: new src/auth.oidc-discovery.test.ts (MP rejecting, 500ing or hanging
at boot with zero MP calls; discovery or JWKS failing at the callback then
recovering on the same instance; timeout, redirects, single-flight, cache;
lazyIdTokenVerifier units) replaces auth.discovery-rebuild.test.ts;
re-adding discoveryUrl fails 11 of its 23 tests. auth.test.ts now uses
genuinely signed tokens (node environment, stubUserinfo) and covers alg
confusion; code-flow, id-token-sign-in, oidc-hardening, secret-guard and
route tests updated; mock-oidc gains discovery/jwks/mp modes and
signIdToken. The route test's fetch now blocks every URL instead of
falling through to the real network.

Verified with the issue's repro on next build + next start (https fake MP):
MP failing before start -> /sign-in/social 200 with zero discovery
requests; MP hanging on a fresh process -> every request answers in
10-40 ms.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… and playbooks

- auth.md: config table (no discoveryUrl; explicit endpoints, endSessionEndpoint,
  accountSubject), new "id_token verification (lazy discovery)" section, nonce
  section now "do not re-add discoveryUrl" (F8 kept), F12 layers, log events,
  PROVIDER_NOT_FOUND now means misconfiguration only, upgrade checklist.
- testing.md: auth.oidc-discovery.test.ts replaces auth.discovery-rebuild;
  mock-oidc modes and signIdToken; counts regenerated from a full run.
- deps-known-issues.md: deps:relock now writes metadata-only differences; the
  stale "PKCE can likely be enabled" item records that MP does not support it.
- README: the build makes no MP call; an outage fails only the sign-in that
  hits it.
- 2026-09-25 / 2026-09-28 security notes: dated "Update 2026-09-29 (issue #101)"
  notes; history unchanged.
- Downstream playbooks: Phase 4 port rewritten for the #101 approach (code
  sketch, pitfalls, what to delete if selfHealingAuth was ported, what to keep
  on a discoveryUrl fork); self-check greps updated; deferred "drop
  discoveryUrl" and F12-residue items marked done.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@chriskehayias chriskehayias added bug Something isn't working claude-code-assisted labels Sep 29, 2026
@chriskehayias
chriskehayias merged commit 8a1b161 into main Sep 29, 2026
4 checks passed
@chriskehayias
chriskehayias deleted the fix/101-oidc-lazy-discovery branch September 29, 2026 23:34
@codecov

codecov Bot commented Sep 29, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 97.10145% with 2 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
src/test-utils/mock-oidc.ts 85.71% 2 Missing ⚠️

📢 Thoughts on this report? Let us know!

chriskehayias added a commit that referenced this pull request Sep 30, 2026
port-security-review-2026-09-28.md was published (#103) telling forks to
port the selfHealingAuth rebuild facade; PR #107 replaced that with no
boot-time discovery a few hours later. A fork that already ran that
playbook has no reason to re-open it, and the #101 port was only a sketch
spread across three documents.

port-oidc-lazy-discovery.md is a self-contained delta playbook:
- triage into four fork states (discoveryUrl + facade, discoveryUrl only,
  explicit endpoints without id_token verification, done), with greps
  checked against main, the pre-#107 file and the pre-facade file;
- a read-only check of the fork's own MP discovery document (connect/*
  paths, RS256, jwks_uri not to be pinned, RFC 9207 flag);
- the full code (lazyIdTokenVerifier, verifyMpIdToken, provider config,
  getUserInfo), jose as a direct dependency and the relock-script fix;
- what to delete if the facade was ported, and which log events changed;
- the test changes (mock OIDC modes, the regression suite, signed tokens
  for direct getUserInfo tests, alg confusion, mutation check);
- verification: the issue's repro against next start with an https fake
  MP, and the real-MP sign-in/sign-out smoke test;
- PR callouts and a checklist.

The 2026-09-28 playbook's Phase 4, port-downstream-hardening.md and the
downstream hardening playbook now point to it as the full procedure.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working claude-code-assisted

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Sign-in stays broken until restart if Ministry Platform blips during the one OIDC discovery fetch

1 participant