fix(auth): close password-reset → login → /me race via cookie-commit sync - #80
Merged
Merged
Conversation
…sync
Root cause: a Chromium cookie-commit lag under Playwright. The login
(or MFA verify / verify-email / OAuth callback) response sets the auth
cookie via `Set-Cookie`, but the browser sometimes hasn't committed
the cookie to its jar by the time ProtectedRoute mounts and fires
GET /me. `/me` sees no cookie, returns `{user: null}`, the SPA treats
that as anonymous, and bounces back to /login.
Fix: every session-establishing flow now pre-fetches /me with short
retries (5 × 30 ms = max ~150 ms) BEFORE the consumer navigates. The
cookie window closes inside the retry budget and the post-navigation
useMe is a cache hit, not a refetch. Same helper used across:
- `useLogin` (Auth.session.mutations.ts)
- `useMfaVerifyLogin` + `useMfaVerifyRecovery` (Auth.mfa.challenge.mutations.ts)
- `OAuthCallbackPage.hooks.ts`
- `VerifyEmailPage.hooks.ts`
New helper `Auth.session.sync.ts` with sibling unit test covers the
three paths: authed-on-first-call, retry-until-authed, retry-budget-
exhausted. Test mocks updated across the four consumers.
password-reset.spec.ts `.fixme` removed. Commentary in the spec
records the diagnosis + the closure path.
Belt-and-suspenders against:
1. Chromium cookie-commit lag (the primary failure mode).
2. Cache propagation gaps in `jwtRevocationService` between
`revokeAllForUser` (called during password reset) and
`buildJWTPayload`'s `getUserRevokeCutoff` read on the immediate
login. The iat-lift in `buildJWTPayload` already covers the
common case; this retry budget covers the rare miss.
Gates: API 1049/1051 (2 DB-only skipped locally), UI 577/577, both
apps lint + lint-meta + typecheck + knip + format clean.
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.
Summary
Closes the password-reset login race that's been
.fixme-skipped since May 28. Root cause is a Chromium cookie-commit lag under Playwright; the fix pre-fetches /me with short retries on every session-establishing flow so the post-navigationuseMeis a cache hit, not a refetch.Root cause
The login (or MFA verify / verify-email / OAuth callback) response sets the auth cookie via
Set-Cookie, but the browser sometimes hasn't committed the cookie to its jar by the time ProtectedRoute mounts and firesGET /me./mesees no cookie, returns{user: null}, the SPA treats that as anonymous, and bounces back to/login.Two earlier partial fixes (
ad456b7JWT iat-lift, the ProtectedRouteisFetchingguard) reduced but didn't eliminate the failure. Codex Pass 4 surfaced it again at ~1/3 flake rate under CI-mimicking single-worker runs.Fix
New helper
apps/ui/src/features/auth/Auth.session.sync.ts—syncMeAfterSessionEstablished(qc)— calls/meup to 5 times at 30 ms apart (max ~150 ms budget) until it sees an authed user, then returns. The cachedmecarriesstaleTime: 60_000, so the value sticks past the navigation.Called from every session-establishing flow:
useLogin(Auth.session.mutations.ts)useMfaVerifyLogin+useMfaVerifyRecovery(Auth.mfa.challenge.mutations.ts)OAuthCallbackPage.hooks.tsVerifyEmailPage.hooks.tsWhy this is durable
Belt-and-suspenders against:
jwtRevocationServicebetweenrevokeAllForUser(called during password reset) andbuildJWTPayload'sgetUserRevokeCutoffread on the immediate login. The existing iat-lift covers the common case; this retry budget covers the rare miss.If
/mepersistently reports anonymous after 5 attempts, the helper returnsnulland the consumer proceeds with the navigation —ProtectedRoutewill redirect to/login, which is the correct behavior for a genuinely unauthenticated state.Files
Auth.session.sync.ts(helper) +Auth.session.sync.test.ts(4 unit tests)Auth.session.mutations.ts,Auth.mfa.challenge.mutations.ts,OAuthCallbackPage.hooks.ts,VerifyEmailPage.hooks.tsGET /mecallpassword-reset.spec.ts:.fixmeremoved; comment block records the diagnosis + closure pathTest plan
pre-push-smoke.shend-to-end against the password-reset spec to verify the fix in the actual browser context