Skip to content

feat(web): session-expiry polish — atomic logout, expiry notices, proactive keepalive, idle warning - #3086

Open
ToddHebebrand wants to merge 7 commits into
mainfrom
ToddHebebrand/session-timeout-shape
Open

feat(web): session-expiry polish — atomic logout, expiry notices, proactive keepalive, idle warning#3086
ToddHebebrand wants to merge 7 commits into
mainfrom
ToddHebebrand/session-timeout-shape

Conversation

@ToddHebebrand

Copy link
Copy Markdown
Collaborator

What

Polishes how the web app handles session death. Previously an expired session gutted the sidebar in place (44/56 nav items are permission-gated) while the redirect to /login — if it happened at all — raced asynchronously in a different island, with no explanation to the user.

Four changes, all in apps/web:

  1. Atomic, explicit session expiry. New handleSessionExpired(reason) in the auth store: sets a non-persisted sessionExpiredReason, calls logout(), and immediately window.location.replaces to /login?next=…&reason=… (idempotent across concurrent 401s; no-op if already on /login). Both fetchWithAuth give-up paths now route through it — including the 401-refresh-failed path that previously just mutated state and returned the stale response. AuthOverlay renders a full-screen "session expired" mask the instant the reason is set, so the gutted UI is never visible.
  2. Deep links survive re-login. The old path emitted ?returnTo=, which login.astro never read. Redirects now use loginPathWithNext() (?next=, validated by getSafeNext), and the login page shows an informational notice explaining the sign-out (reason=session-expired / reason=idle; ?error= SSO copy takes precedence when both resolve).
  3. Keepalive detects a dead session proactively. The 5-minute heartbeat refresh previously discarded its result, so a revoked/expired refresh cookie went unnoticed until the next user click 401'd. It now distinguishes restored / auth-failed / transient: hard auth failure logs out cleanly right away; transient network/5xx failures change nothing (an offline user is never evicted). Refresh internals (retry counts, backoff, Web Locks, rotation/reuse-detection handling) are untouched — the outcome the code already computed is just propagated.
  4. Idle-timeout warning. Instead of a silent idle logout, a countdown dialog appears min(2 min, budget/2) before the org/partner-configurable idle deadline. Passive activity (mousemove/scroll/focus/visibilitychange) neither extends nor dismisses it; deliberate input (mousedown/keydown/touchstart) or the "Stay signed in" button dismisses it and refreshes the token. At 0:00 it performs the durable apiLogout() (server-side family revocation) and routes through handleSessionExpired('idle'), landing on the login page with the inactivity notice.

Notes for review

  • Behavior change: the idle warning now arms while the tab is hidden (deliberate — a returning user sees the countdown rather than being silently evicted; expiry in a hidden tab is owned by the 1s tick, with the 30s heartbeat as a fail-safe for budget-shrinking scope switches; a shared in-flight ref makes the two paths mutually exclusive).
  • ?reason= on /login carries exactly session-expired | idle from these paths; the pre-existing reason=registration-disabled on the same param is consumed by a disjoint code path and unaffected.
  • New i18n keys added to all seven locales (key-parity suite green).
  • New testids: session-expired-overlay, login-session-expired-notice, idle-warning-stay, idle-warning-body.

Testing

  • Targeted suites green: auth.test.ts, AdminSessionManager.test.tsx, AuthOverlay.test.tsx, LoginPage.test.tsx, authNext/authScope tests (114 tests in final run), plus i18n key-parity, literal-key, and no-silent-mutations contract suites.
  • astro check / eslint clean on touched files.
  • Each task went through an independent spec+quality review; a whole-branch review (cross-task lifecycle, heartbeat/modal interplay, open-redirect and refresh-security checks) found nothing above nice-to-have, and its three minors (helper dedup, ref-reset hardening, dialog autoFocus+aria-describedby) are fixed in the final commit.

🤖 Generated with Claude Code

Todd Hebebrand and others added 6 commits August 3, 2026 19:49
…hWithAuth expiry paths

Adds a single idempotent handleSessionExpired(reason) entry point in
stores/auth.ts that sets sessionExpiredReason (unpersisted), calls logout(),
and redirects via loginPathWithNext() + window.location.replace() unless
already on /login. Both fetchWithAuth expiry paths (dead refresh cookie on
bootstrap, and a 401 that survives refresh-and-retry) now funnel through it
instead of hand-rolling their own logout/redirect, so later work (expiry
overlay, login-page notice, idle logout) has one shape to hook into.

loginPathWithNext is duplicated locally (not imported) since lib/authScope.ts
imports useAuthStore from this module and importing it back would cycle.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Task 1 gave the auth store a `sessionExpiredReason`; nothing rendered it.
An expiring session therefore showed the user a gutted sidebar and blank
widgets for the length of the redirect, then dropped them on a bare sign-in
form with no explanation of what happened.

AuthOverlay now renders a full-screen mask whenever `sessionExpiredReason`
is set. The branch is deliberately placed BEFORE the `fadeState === 'hidden'`
early return: by the time a session expires the overlay has long since faded
out and returns null forever, so a check after that return would never fire.
The mask is purely cosmetic — `handleSessionExpired()` already owns the
`window.location.replace`, and the existing `!isAuthenticated →
redirectToLogin()` effect is left untouched.

LoginPage reads `?reason=` the same way it already reads the SSO `?error=`
param and renders an informational notice (`session-expired`, `idle`;
unknown codes render nothing). When an SSO error notice is also present it
wins — its copy is more specific and more actionable — so the two never
stack.

Three new keys land in all seven locale catalogs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…k 3)

Threads the hard-vs-transient outcome refreshFetchOnce already computes
through requestTokenRefresh/requestTokenRefreshShared instead of collapsing
it to Tokens | null. Adds restoreAccessTokenFromCookieDetailed() returning
'restored' | 'auth-failed' | 'transient'; restoreAccessTokenFromCookie()
becomes a thin boolean wrapper so its other call sites are untouched.

AdminSessionManager's 5-minute heartbeat now reacts to the outcome instead
of discarding it: 'restored' stamps lastRefreshAtRef, 'auth-failed' calls
handleSessionExpired and stands the heartbeat down, 'transient' does
nothing so an offline user isn't logged out and the next 30s tick retries.
The idle budget now ends with a warning modal: the 30s heartbeat raises it at
`idleTimeoutMs - min(2min, idleTimeoutMs / 2)` and a 1s tick drives the visible
m:ss countdown, so the logout lands on the second the countdown shows 0:00
rather than up to a heartbeat later.

While the modal is up, passive signals (mousemove, scroll, focus,
visibilitychange) no longer extend the session — a drifting mouse must not
answer the warning on the user's behalf. Deliberate input (mousedown, keydown,
touchstart) or the "Stay signed in" button dismisses it, marks activity and
refreshes the access token immediately.

On expiry the durable logout is unchanged in order: apiLogout() first (it needs
the Bearer/localStorage state), then handleSessionExpired('idle'), which lands
the user on /login?reason=idle with the inactivity notice. The bare
navigateTo('/login') is gone.

Copy lives in the auth namespace across all seven locales.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Deduplicate loginPathWithNext: canonical impl now lives in
  lib/authNext.ts (import-cycle-free), authScope.ts re-exports it so
  every existing settings-component importer and test mock keeps
  working unchanged, and stores/auth.ts imports it instead of
  carrying a byte-identical copy.
- AdminSessionManager: reset idleLogoutInFlightRef in the
  !isAuthenticated cleanup alongside its sibling refs (hardening;
  currently unreachable since every eviction ends in a full-page
  location.replace).
- IdleWarningDialog: autoFocus on the "Stay signed in" button and
  aria-describedby wiring to the countdown body paragraph.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Aug 4, 2026

Copy link
Copy Markdown

Deploying breeze with  Cloudflare Pages  Cloudflare Pages

Latest commit: 59f881b
Status: ✅  Deploy successful!
Preview URL: https://4e8f8096.breeze-9te.pages.dev
Branch Preview URL: https://toddhebebrand-session-timeou.breeze-9te.pages.dev

View logs

…race, log silent catches, comment + test gaps

- apiLogout now aborts its /auth/logout request after 8s (matching
  refreshFetchOnce). A hung revoke previously stranded the idle-logout flow:
  AdminSessionManager awaits apiLogout() before handleSessionExpired('idle'),
  so the modal sat on "Signing you out…" forever and idleLogoutInFlightRef
  permanently gated both the heartbeat and the countdown tick.
- AuthOverlay's `!isAuthenticated → redirectToLogin()` branch is now gated on
  `!sessionExpiredReason`. handleSessionExpired flips isAuthenticated before its
  hard window.location.replace('/login?next=…&reason=…'); the ungated soft nav
  raced it and could land the user on a bare /login with no notice/deep link.
- console.warn added to the previously silent catches in apiLogout and
  restoreAccessTokenFromCookieDetailed.
- Comment fixes: transient-eviction policy split (foreground fetch evicts,
  background heartbeat waits), RefreshOutcome collapse claim, logout()'s
  deliberate retention of sessionExpiredReason, IdleWarningDialog's false
  "no shared Dialog primitive" claim + signingOut doc, dismissWarning rationale.
- Tests: apiLogout resolves on its own timeout when the request never settles;
  warning-lead clamp on a 2-minute budget; hidden-tab keepalive skip with idle
  eviction still firing; visibilitychange does not dismiss the warning. The
  AuthOverlay "does not navigate" test now exercises the real post-logout state.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@ToddHebebrand

Copy link
Copy Markdown
Collaborator Author

Post-open review round (4 parallel reviewers: code quality, test coverage, comment accuracy, silent failures) surfaced two real defects the earlier per-task and whole-branch reviews missed; both fixed in 59f881b:

  • apiLogout() had an unbounded fetch, and the idle-logout path awaited it before handleSessionExpired('idle') — a hung POST /auth/logout would strand the "Signing you out…" modal and permanently gate the keepalive. Now bounded by an 8s abort (same pattern as refreshFetchOnce), and client-side eviction proceeds regardless.
  • Both of AuthOverlay's redirect paths (main effect + 10s safety net) could soft-navigate to bare /login racing handleSessionExpired's hard redirect, dropping ?next=/?reason=. Both are now gated on sessionExpiredReason being unset; covered in both directions (fires for a plain unauthenticated visitor, dormant during expiry), mutation-verified.

Also in that commit: console.warn in the two previously-silent auth catches, comment corrections (including documenting the deliberate transient-eviction policy split between foreground fetches and the background heartbeat), and new tests for the idleTimeoutMs/2 warning-lead clamp and hidden-tab/visibilitychange behavior.

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