Skip to content

fix(frontend): sequence auth refresh against session invalidation (P3-4) - #82

Merged
tyler-rich merged 1 commit into
devfrom
claude/auth-refresh-logout-race-8cs4h6
Jul 24, 2026
Merged

fix(frontend): sequence auth refresh against session invalidation (P3-4)#82
tyler-rich merged 1 commit into
devfrom
claude/auth-refresh-logout-race-8cs4h6

Conversation

@tyler-rich

@tyler-rich tyler-rich commented Jul 24, 2026

Copy link
Copy Markdown
Owner

Summary

Closes P3-4, the last open finding in the frontend-review Priority-3 batch (docs/reviews/frontend-review.md § Priority 3, tracked in docs/reviews/STATUS.md § 1).

AuthContext.refresh() wrote the fetchAuthStatus result into auth state unconditionally, with no sequencing against the scrye:auth-invalidated event (dispatched by api/client.ts on any 401) or against logout(). A status request answered by the backend before the credential was revoked, but resolving after the invalidation, wrote its stale user back — flashing the authenticated shell over the login screen until the next 401.

Invariant now enforced: a logged-out session is never restored by a late-arriving refresh. Once the session is invalidated, no auth-state write from a request that was already in flight at that moment may restore user; the session can only be re-entered by a fresh authentication.

What changed

  • frontend/src/auth/AuthContext.tsx
    • A sessionGeneration ref is bumped by every invalidation: the scrye:auth-invalidated handler, and logout() — the latter before awaiting the request, so a refresh already in flight when sign-out starts is covered too.
    • refresh() captures the generation before fetching and re-checks it after the await. If it changed, the session-independent facts (needs_setup, oidc, loading: false) are still applied but user is forced to null. Keeping those fields is what prevents an invalidation during the initial load from stranding the app on the loading spinner.
    • refresh() additionally takes a token from the existing createLatestGuard() helper (lib/latest.ts — the same latest-wins guard the history/findings fetches use), so two overlapping refreshes cannot resolve out of order and clobber each other.
  • frontend/src/auth/AuthContext.test.tsx (new, jsdom) — holds the status request open and drives resolution order: (1) invalidation event fires mid-refresh → stays signed out; (2) logout() lands mid-refresh → stays signed out; (3) no invalidation → the refresh applies normally. Both race tests were confirmed to fail against the pre-fix provider and pass with it.
  • frontend/src/test/render.tsx — re-exports act so tests keep importing the whole Testing Library surface from one place.
  • Docs — dated docs/ARCHIVE.md § 14 entry; docs/reviews/STATUS.md moves P3-4 from open to Resolved (the "LOW with a correctness/security edge" bucket is now empty; only P3-8's deferred TS-strictness flags remain).

A generation counter was chosen over an AbortController because the invalidation must also invalidate a response that has already been received but not yet applied, which aborting does not cover — and it reuses the guard idiom already in the codebase rather than adding a new one.

Verification

  • npm test — 36 tests / 11 files pass (3 new).
  • npm run lint, npx prettier --check src, npm run build (tsc -b && vite build) — all clean.
  • No backend changes.

See docs/ARCHIVE.md § Deviations for the dated entry.

`refresh()` wrote the `fetchAuthStatus` result into auth state
unconditionally, with no sequencing against the `scrye:auth-invalidated`
event or `logout()`. A status request answered before the credential was
revoked but resolving after the invalidation restored the stale `user`,
flashing the authenticated shell back over the login screen.

Enforce the invariant that a logged-out session is never restored by a
late-arriving refresh: a `sessionGeneration` ref is bumped by every
invalidation (the 401 event handler, and `logout()` before it awaits the
request), and `refresh()` compares the generation across its `await` —
applying the session-independent facts (`needs_setup`, `oidc`, loading
cleared) but forcing `user: null` when the session died mid-flight.
`refresh()` also takes a `createLatestGuard()` token so two overlapping
refreshes cannot resolve out of order.

Adds a jsdom test covering both races and the happy path.

See `docs/ARCHIVE.md` § Deviations for the dated entry.
@tyler-rich
tyler-rich merged commit 8d41e7d into dev Jul 24, 2026
4 checks passed
@tyler-rich
tyler-rich deleted the claude/auth-refresh-logout-race-8cs4h6 branch July 31, 2026 04:59
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