Repository navigation
fix(auth): verify the MP id_token lazily instead of fetching OIDC discovery at boot (#101) - #107
Merged
Merged
Conversation
…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>
Codecov Report❌ Patch coverage is
📢 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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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-processauth.api.*call (i.e. every page's session check), awaits thatinit./sign-in/socialreturned404 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.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,tokenUrlandendSessionEndpoint(without it, sign-out loses theid_token_hint);accountSubject: profile.sub.Building the instance,
next build, cold starts and session checks make no MP call, so the provider always registers.verifyMpIdTokeningetUserInfo: josejwtVerify(signature,iss,aud,exp/nbf), the same call better-auth made, with RS256 pinned (ID_TOKEN_ALGORITHMS). It runs before the existingexp-presence,azpandsub-binding checks, and replaces the hand-rolled unverified decoder.lazyIdTokenVerifier:issuerandjwks_uristill 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:redirect: "error";jose likewise doesn't cache a failed JWKS fetch. A document without a usable
issuer/jwks_urifails 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:
selfHealingAuthand 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
idTokenbranch itself (ID_TOKEN_NOT_SUPPORTED). The route filter andhooks.beforeguard remain the primary controls.Nothing lost on RFC 9207: losing
provider.issuerskips better-auth's callbackisscheck, but MP doesn't advertiseauthorization_response_iss_parameter_supported.New logs, identifiers only:
auth.oidc.discovery_failed(reason, status or errName, field);auth.userinfo.id_token_unverified(verifier_unavailable, orverification_failedwith josecode/claim).joseis now a direct dependency.scripts/check-lockfile.mjs --fixbug:deps:relockskipped writing when the tree shape was unchanged. Adding a dependency that's already hoisted only changes the lockfile's root entry, sojosenever reachedpackage-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):lazyIdTokenVerifierunit tests: relativejwks_uri, a hung request timing out, failures never cached, fail-closed on 9 kinds of bad document.Mutation check: re-adding
discoveryUrlfails 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, callinggetUserInfowith really signed tokens viastubUserinfo. 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-addingdiscoveryUrlswitches nonce binding back on.auth.id-token-sign-in.test.ts: better-auth refuses the branch without the hook, and thesubbinding is still proven withdiscoveryUrlre-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:fetchnow blocks every URL; the old stub fell through to the real network.src/test-utils/mock-oidc.tsgaineddiscovery/jwks/mpmodes (ok|reject|http_500|hang) andsignIdToken(claims).Verification
npx vitest run --coverage: 85 files, 2062 passed, 3 skipped; thresholds met (auth.ts100% statements).npx tsc --noEmit,npm run lint,npm run build,npm run build:check-prerenderandnpm run deps:verifyare all clean.next build+next startagainst its fake MP, adapted to https:failbefore start:POST /api/auth/sign-in/socialreturns200with an authorize URL, anddiscoveryRequestsstays at 0 (the issue saw404 PROVIDER_NOT_FOUND);hangon a fresh process:/api/auth/get-session,/sign-in/social,/signinand/answer in 10–40 ms (the issue measured ~301 s).auth.md§ smoke test).Docs
.claude/references/auth.md,testing.md(counts regenerated),deps-known-issues.md(the relock fix) andREADME.md.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.deps-known-issues.md: the open item "PKCE can likely be enabled" now records that MP doesn't support PKCE, sopkce: falseis required. That line contradictedauth.ts.Downstream
Forks that ported
selfHealingAuthfrom the 2026-09-28 playbook should port this instead..claude/playbooks/port-security-review-2026-09-28.mdPhase 4 now has the steps, including what to delete and the pitfalls (don't pin the JWKS URL;endSessionEndpointandaccountSubjectare required).🤖 Generated with Claude Code