fix(signin): two independent causes of failed Ministry Platform sign-in - #87
Merged
Merged
Conversation
Sign-in failed intermittently with `/auth-error?error=unable_to_get_user_info`, and the server log showed better-auth rejecting the id_token: Provider "ministry-platform": id_token failed verification against the discovery JWKS or expected nonce The cause was in this app, not MP. Every sign-in attempt fired TWO `POST /api/auth/sign-in/social` requests. The guard in the sign-in component was a `useState` flag read INSIDE the `getSession()` callback, with the state in the effect's own dep array. React StrictMode double-invokes effects in dev, so both runs reached the async callback before `setIsRedirecting(true)` had landed, both had captured `isRedirecting === false` in their closure, and both called `signIn.social()`. A state flag cannot close that window no matter where it is read — by the time the callback runs, both flows are already in flight. That is not cosmetic, because of two settings that interact. Nonce binding is on for this provider (`requiresIdTokenNonce` is true whenever discovery supplies an id_token config and `disableIdTokenNonceBinding` is unset), and `account.storeStateStrategy` is `"cookie"`. Each call therefore mints its own `state` and id_token `nonce` and OVERWRITES the single `oauth_state` cookie the callback validates against. The two flows raced; only the last cookie written could win; the loser's id_token carried the other flow's nonce and failed verification. Hence the intermittency, which made it look like an MP or network fault. The guard is now a ref, checked and set synchronously before the first await, so the second effect run returns before touching the network. A ref survives StrictMode's mount/unmount/remount, since the component instance is the same. Ruled out along the way, each with evidence rather than inspection: - MP's JWKS is stable: six probes returned one key with the same kid, so this was not key rotation or a multi-node signer. - Clock skew is sub-second against MP's clock, so it was not nbf/exp being rejected with jose's zero clock tolerance. - A lost state would log `state_mismatch`, which never appeared. - Our own `getUserInfo` was never reached: verification happens before it, and neither of its two failure logs appeared. Tests: - New StrictMode test asserting exactly one `signIn.social()` and one `getSession()`. Verified it FAILS against the old implementation, so it genuinely pins the regression rather than just passing. - Corrected `"does not start a second sign-in when the effect re-runs"`, which asserted `getSession` was called TWICE — it had encoded the broken behavior as if it were correct. - Full suite 975 passing in 56 files; lint and tsc clean. Documented in .claude/references/auth.md under the OAuth flow, because the next person to touch this will reach for a state flag. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
Sign-in failed with `/auth-error?error=unable_to_get_user_info`, logging: Provider "ministry-platform": id_token failed verification against the discovery JWKS or expected nonce Ministry Platform does not echo the `nonce` back in the id_token. better-auth 1.7 turns nonce binding on automatically for any provider whose discovery document yields an id_token config (`requiresIdTokenNonce`), sends a `nonce` on the authorize request, and then requires the claim to come back — `nonceMatches` returns false when the claim is absent (`typeof claimNonce !== "string"`). So verification failed deterministically and nobody could sign in. Confirmed by decoding a real MP id_token rather than by inference: `kid`, `alg`, `iss` and `aud` all matched the discovered values exactly; only `nonce` was missing. Sign-in succeeds with binding disabled and fails with it enabled, on the same build, minutes apart. This is the second root cause behind the same symptom, and it is NOT the one the previous commit fixed. The duplicate `sign-in/social` POST was real and worth fixing, but it cannot explain this: the failure reproduces in a production build, where React StrictMode does not double-invoke effects and only one flow is ever started. What made it look intermittent is inverted from the obvious reading: sign-in SUCCEEDED only when the boot-time discovery fetch had failed. A failed discovery leaves the id_token config undefined, which skips verification altogether. A working discovery meant a broken sign-in. (Worth a separate ticket: one transient discovery failure at boot disables the provider for the life of the process, with no retry — observed once during this investigation.) Ruled out with evidence before landing here: - JWKS rotation / multi-node signer: six probes returned one key, same kid. - Signature, issuer, audience, algorithm: all verified matching from the decoded token. - Clock skew: measured ~85-160ms, and the id_token `nbf` is checked with jose's zero clock tolerance. This bites occasionally at an integer-second boundary and is a real second-order fragility, but it cannot explain a deterministic failure. The dev machine's Windows Time service was stopped; syncing it removed that variable. What this gives up: binding the id_token to this particular authorization request. Signature, issuer and audience are still verified against MP's discovery JWKS. The residual replay/injection risk is mitigated by the OAuth `state` cookie check that still runs and by this being a confidential client that exchanges the code with a client secret. Enabling PKCE (F8) would narrow it further and is the natural follow-up. Verified end to end against a live Ministry Platform sign-in. Co-Authored-By: Claude Opus 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.
Sign-in was failing with
/auth-error?error=unable_to_get_user_info. There turned out to be two independent bugs behind that one symptom. Both are fixed here and verified against a live Ministry Platform sign-in.1. MP never echoes the id_token
nonce— the actual blockerbetter-auth 1.7 enables nonce binding automatically for any provider whose discovery document yields an id_token config (
requiresIdTokenNonce), sends anonceon the authorize request, and then requires the claim back.nonceMatches()returns false when the claim is absent. MP omits it, so verification failed deterministically:Confirmed by decoding a real MP id_token rather than by inference —
kid,alg,iss,audall matched the discovered values exactly; onlynoncewas missing.Fix:
disableIdTokenNonceBinding: true. Signature, issuer and audience are still verified against MP's JWKS. What's given up is binding the token to this particular authorization request; the residual risk is mitigated by thestatecookie check that still runs and by this being a confidential client exchanging the code with a secret. PKCE (F8) would narrow it further.Why it looked intermittent
Inverted from the obvious reading: sign-in succeeded only when the boot-time discovery fetch had failed, because that leaves the id_token config undefined and skips verification entirely. A working discovery meant a broken sign-in. That inversion is what made this hard to pin down.
2.
/signinstarted two OAuth flows per attemptEvery attempt fired two
POST /api/auth/sign-in/social. The guard was auseStateflag read inside thegetSession()callback with the state in the effect's dep array; StrictMode double-invokes effects in dev, both runs reached the async callback having capturedfalse, and both calledsignIn.social(). A state flag cannot close that window.Each call mints its own
state+nonceand overwrites the singleoauth_statecookie the callback validates against, so the flows raced. Fix: a ref, checked and set synchronously before the firstawait.This was a real bug but not the cause of the error above — the error reproduces in a production build, where StrictMode does not double-invoke and only one flow is ever started. Worth being explicit, because fixing it first made it look solved.
Ruled out with evidence
kidnbfis checked with jose's zero clock tolerance, so this does bite occasionally at an integer-second boundary and is a genuine second-order fragility — but it cannot explain a deterministic failure. The dev machine's Windows Time service was stopped; syncing it removed the variable.Tests
signIn.social()and onegetSession(). Verified it fails against the old implementation, so it pins the regression rather than just passing."does not start a second sign-in when the effect re-runs", which assertedgetSessionwas called twice — it had encoded the broken behavior as correct.tsc --noEmitclean.Follow-up (not in this PR)
One transient discovery failure at boot disables the provider for the life of the process, with no retry — observed once during this investigation. That's a production robustness risk worth its own ticket.
Documented in
.claude/references/auth.md.🤖 Generated with Claude Code