Skip to content

fix(signin): two independent causes of failed Ministry Platform sign-in - #87

Merged
chriskehayias merged 2 commits into
mainfrom
fix/signin-double-oauth-flow
Sep 13, 2026
Merged

chriskehayias merged 2 commits into
mainfrom
fix/signin-double-oauth-flow

Conversation

@chriskehayias

@chriskehayias chriskehayias commented Sep 13, 2026

Copy link
Copy Markdown
Contributor

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 blocker

better-auth 1.7 enables nonce binding 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 back. nonceMatches() returns false when the claim is absent. MP omits it, so verification failed deterministically:

Provider "ministry-platform": id_token failed verification against the
discovery JWKS or expected nonce

Confirmed by decoding a real MP id_token rather than by inference — kid, alg, iss, aud all matched the discovered values exactly; only nonce was 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 the state cookie 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. /signin started two OAuth flows per attempt

Every attempt fired two POST /api/auth/sign-in/social. The guard was a useState flag read inside the getSession() callback with the state in the effect's dep array; StrictMode double-invokes effects in dev, both runs reached the async callback having captured false, and both called signIn.social(). A state flag cannot close that window.

Each call mints its own state + nonce and overwrites the single oauth_state cookie the callback validates against, so the flows raced. Fix: a ref, checked and set synchronously before the first await.

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

  • JWKS rotation / multi-node signer — six probes returned one key, same kid
  • Signature, issuer, audience, algorithm — all confirmed matching from the decoded token
  • Clock skew — measured ~85–160 ms. The id_token nbf is 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

  • New StrictMode test asserting exactly one signIn.social() and one getSession(). Verified it fails against the old implementation, so it 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 correct.
  • 979 tests / 56 files passing; lint and tsc --noEmit clean.

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

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

codecov Bot commented Sep 13, 2026

Copy link
Copy Markdown

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>
@chriskehayias chriskehayias changed the title fix(signin): start exactly one OAuth flow per page load fix(signin): two independent causes of failed Ministry Platform sign-in Sep 13, 2026
@chriskehayias
chriskehayias merged commit 65e9417 into main Sep 13, 2026
3 checks passed
@chriskehayias
chriskehayias deleted the fix/signin-double-oauth-flow branch September 13, 2026 01:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant