Skip to content

feat(auth): SPA session-refresh driver — cross-tab /auth/refresh timer (#1854) - #218

Merged
cyberantonz merged 2 commits into
constructorfabric:mainfrom
cyberantonz:feat/session-refresh-driver
Jul 24, 2026
Merged

feat(auth): SPA session-refresh driver — cross-tab /auth/refresh timer (#1854)#218
cyberantonz merged 2 commits into
constructorfabric:mainfrom
cyberantonz:feat/session-refresh-driver

Conversation

@cyberantonz

@cyberantonz cyberantonz commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

Closes constructorfabric/insight#1854 — the FE half of the nginx+auth session model (EPIC constructorfabric/insight#1583). Backend POST /auth/refresh landed in step 10 (insight#1593); without this driver every user is force-logged-out ~10 min after login because the session is deliberately non-sliding (PRD 5.3/5.4).

What it does

  • Session gains expiresAt/refreshAt (unix seconds), read from GET /auth/me at boot (loadSession).
  • src/auth/refresh.ts — the driver, started from main.tsx once boot resolves authenticated:
    • Timer fires POST /auth/refresh at the server-supplied refresh_at; on success the next timer is armed from the response's fresh refresh_at (never client-computed — the server re-jitters it every time).
    • Single refresher across tabs (PRD 5.4 SPA contract): leadership is a heartbeat-renewed localStorage lease — stale leases are stolen, pagehide releases early — and results fan out over BroadcastChannel with a localStorage storage-event fallback. Occasional double-refresh races are tolerated by the backend's rotation grace + CAS.
    • X-CSRF-Token on the POST (pairs with feat(auth): send X-CSRF-Token on state-changing /auth/* (nginx+auth step 10.5) #212), plus an abort timeout so a hung request can't wedge the driver/lease.
    • 401 → clear store, fan out expired, bounce to /auth/login. 429 → honor Retry-After. 403 → re-prime csrf + schedule via /auth/me with a 15 s retry floor (a persistent 403 cannot become a zero-delay loop), or expire if the re-probe fails closed. Transient errors retry every 15 s.
    • visibilitychange re-evaluates on wake, so a refresh that came due during laptop sleep fires immediately and a dead leader is replaced without waiting out the heartbeat.
    • Fan-out messages are shape-checked; unknown kinds are ignored (a newer deploy in another tab can't force-logout old tabs).
  • MSW: MOCK_SESSION now carries csrf_tokenloadSession's fail-closed guard (from feat(auth): send X-CSRF-Token on state-changing /auth/* (nginx+auth step 10.5) #212) was rejecting mock sessions in VITE_ENABLE_MOCKS runs — plus timestamps and an /auth/refresh handler.
  • Test fixtures now build sessions via a shared makeSession() factory, so the next Session field is a one-line change instead of the ten-literal sweep that broke main after feat(auth): send X-CSRF-Token on state-changing /auth/* (nginx+auth step 10.5) #212 (fixed by test(auth): add csrfToken to Session fixtures (fix main build) #216).

Known trade-offs (deliberate)

  • Hidden-tab timer throttling (~1/min in Chrome) can make the 15 s lease look stale across all-hidden tabs → occasional rotating leadership. Bounded and harmless (grace + CAS + per-session rate bucket); a longer lease would instead delay crashed-leader takeover past the refresh safety margin.
  • While offline, the store stays authenticated between retries; the first round-trip that reaches the backend after expiry lands on the 401 → login path.
  • A single tab whose refresh POST hangs past the rotation grace while another tab rotates successfully will, per spec, treat its eventual 401 as expiry (all-tab re-login). Rare: requires a >10 s hang plus a concurrent rotation.

Testing

  • 13 unit tests for the driver (fake timers): schedule/reschedule from asymmetric server timestamps (a client-computed refresh_at fails the suite), immediate overdue refresh, 401/429/403/network paths, 403 hot-loop floor, lease takeover, follower fan-out (both transports), malformed-message hardening, signOut mid-flight, stop-on-unauthenticated.
  • pnpm test: 663 passed (main: 654). tsc -b, eslint clean on touched files (the eslint-plugin-local/dist failure pre-exists on main).
  • Independent security + QA review passes were run on the diff; their two real findings (persistent-403 hot loop, 403-dead-session stranding the user on the AuthGate overlay) are fixed and regression-tested.

EPIC: constructorfabric/insight#1583

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added automatic session refresh based on server-provided timing.
    • Coordinated session refresh across browser tabs.
    • Added handling for expired sessions, temporary failures, and CSRF recovery.
    • Exposed session refresh controls for application startup.
  • Enhancements

    • Session data now includes expiration and refresh timing.
    • Authentication session loading and mock responses include timing details.
  • Tests

    • Added comprehensive coverage for refresh scheduling, errors, multi-tab coordination, and session expiry.
    • Standardized authenticated test session setup.

The session model is deliberately non-sliding: session_ttl (default 600 s)
is extended only by an explicit POST /auth/refresh, so without a driver
every user is force-logged-out ~10 min after login regardless of activity
(constructorfabric/insight#1854, PRD 5.4 "SPA contract").

- Session gains expires_at/refresh_at (unix seconds), read from /auth/me
  at boot; the driver always schedules from the server-supplied refresh_at
  (re-jittered on every response), never a client-computed one.
- Single refresher across tabs: leadership is a heartbeat-renewed
  localStorage lease (stale leases are stolen; pagehide releases early);
  results fan out over BroadcastChannel with a localStorage storage-event
  fallback. Races are tolerated by the backend's rotation grace + CAS.
- POST carries X-CSRF-Token (fail-closed server middleware, #212) and an
  abort timeout so a hung request can't wedge the driver.
- 401 → clear store, fan out "expired", bounce to /auth/login.
  429 → honor Retry-After. 403 → re-prime csrf/schedule via /auth/me with
  a retry floor (no zero-delay loop if the 403 persists), or expire if the
  re-probe fails closed. Transient errors retry on a 15 s cadence.
- visibilitychange re-evaluates on wake so a sleep-overdue refresh fires
  (or a dead leader is replaced) immediately.
- MSW: MOCK_SESSION now carries csrf_token (loadSession's fail-closed
  guard was rejecting mock sessions) + timestamps and a /auth/refresh
  handler.
- Tests build sessions via a shared makeSession() factory so the next
  Session field lands in one place instead of ten fixture literals.

EPIC: constructorfabric/insight#1583
Signed-off-by: Anton Zelenov <antonz@constructor.tech>
@cyberantonz
cyberantonz requested a review from a team as a code owner July 24, 2026 08:01
@coderabbitai

coderabbitai Bot commented Jul 24, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@cyberantonz, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 37 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 3b0237b2-5c4d-4ea7-86e7-dfeda780a5b3

📥 Commits

Reviewing files that changed from the base of the PR and between a764f95 and ff76a5d.

📒 Files selected for processing (4)
  • src/auth/refresh.test.ts
  • src/auth/refresh.ts
  • src/auth/session.test.ts
  • src/auth/session.ts
📝 Walkthrough

Walkthrough

Changes

The PR adds server-provided session timing fields, a cross-tab SPA refresh driver with retry and expiration handling, startup integration, refreshed authentication mocks, and shared authenticated test fixtures.

Session refresh lifecycle

Layer / File(s) Summary
Session timing contract and fixtures
src/auth/types.ts, src/auth/session.ts, src/mocks/handlers.ts, src/test/session.ts, src/auth/session.test.ts
Authenticated sessions now store expiresAt and refreshAt; mocks and tests provide and validate these fields.
Refresh driver implementation
src/auth/refresh.ts, src/auth/index.ts
RefreshDriver schedules /auth/refresh from server timestamps, handles retries and expiration, coordinates a localStorage lease, and broadcasts updates through BroadcastChannel or storage events.
Application bootstrap wiring
src/main.tsx
The refresh driver starts after loadSession() reports an authenticated session.
Refresh driver behavior tests
src/auth/refresh.test.ts
Tests cover scheduling, retries, CSRF re-priming, cross-tab leadership, fan-out, lifecycle cleanup, and stale responses.
Authenticated test setup migration
src/api/*.test.*, src/auth/use-*.test.*, src/components/auth-gate.test.tsx, src/components/widgets/v2/*.test.tsx
Existing authenticated tests now use the shared makeSession() fixture.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant main.tsx
  participant loadSession
  participant RefreshDriver
  participant AuthAPI
  main.tsx->>loadSession: load initial session
  loadSession-->>main.tsx: authenticated status and refreshAt
  main.tsx->>RefreshDriver: startSessionRefresh()
  RefreshDriver->>AuthAPI: POST /auth/refresh with CSRF token
  AuthAPI-->>RefreshDriver: expires_at and refresh_at
  RefreshDriver->>RefreshDriver: update store and schedule next refresh
Loading

Possibly related issues

Possibly related PRs

Suggested reviewers: aleksdotbar

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 71.43% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly describes the SPA auth session-refresh driver and cross-tab /auth/refresh timer, matching the main change.
Linked Issues check ✅ Passed The changes implement boot-time refresh_at/expires_at loading, scheduled /auth/refresh, cross-tab leadership, CSRF, and 401 logout handling.
Out of Scope Changes check ✅ Passed The diff stays focused on auth refresh plumbing, tests, and fixtures; no unrelated feature work stands out.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (1)
src/auth/refresh.test.ts (1)

33-349: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Cover request timeout aborts and visibility-change re-ticks in this test suite.

The test file does not exercise AbortSignal.timeout(REQUEST_TIMEOUT_MS) or the visibilitychange re-tick path. For request timeouts, Vitest fake timers do not mock AbortSignal.timeout, so the test should mock the signal itself/inject it or run with real timers/worker setup. Add assertions that a 10s fetch abort is retried instead of leaving the driver on the stale refresh path.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/auth/refresh.test.ts` around lines 33 - 349, The session refresh driver
tests need coverage for request-timeout retries and visibilitychange re-ticking.
Extend the “session refresh driver” suite with a timeout case that controls or
injects the request abort signal (since fake timers do not advance
AbortSignal.timeout), verifies the 10-second fetch is aborted, and confirms the
driver retries rather than remaining on the stale refresh schedule. Add a
visibilitychange test that dispatches the event and asserts the refresh
scheduling is re-ticked.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/auth/refresh.ts`:
- Around line 173-246: In the 403 branch of refresh(), when loadSession()
returns a non-authenticated result, broadcast the expired event before calling
expire(), matching the ordering in the 401 branch so other tabs receive the
session-death notification before the channel closes. Update the corresponding
refresh test to verify the expired fan-out.

In `@src/auth/session.test.ts`:
- Around line 28-29: Update the session test’s mock response and expectation
timestamps for expires_at and refresh_at to use non-expiring relative values or
shared far-future constants, ensuring the refresh driver does not schedule an
immediate refresh.

In `@src/auth/session.ts`:
- Around line 27-28: Validate expires_at and refresh_at at the session-ingestion
boundary before storing or using them; do not rely on the JSON cast. Accept
finite numeric timestamps, preserve 0 only when a field is missing, and reject
or disable the session when a supplied value is non-numeric or non-finite,
covering the assignments around the session field definitions and the related
lines 48-51.

---

Nitpick comments:
In `@src/auth/refresh.test.ts`:
- Around line 33-349: The session refresh driver tests need coverage for
request-timeout retries and visibilitychange re-ticking. Extend the “session
refresh driver” suite with a timeout case that controls or injects the request
abort signal (since fake timers do not advance AbortSignal.timeout), verifies
the 10-second fetch is aborted, and confirms the driver retries rather than
remaining on the stale refresh schedule. Add a visibilitychange test that
dispatches the event and asserts the refresh scheduling is re-ticked.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: dbffcb89-1871-4f95-bea3-fefc9c9af108

📥 Commits

Reviewing files that changed from the base of the PR and between 99d52ce and a764f95.

📒 Files selected for processing (18)
  • src/api/fetch-with-auth.test.ts
  • src/api/use-catalog.test.tsx
  • src/api/view-configs.test.tsx
  • src/auth/index.ts
  • src/auth/refresh.test.ts
  • src/auth/refresh.ts
  • src/auth/session.test.ts
  • src/auth/session.ts
  • src/auth/types.ts
  • src/auth/use-auth.test.ts
  • src/auth/use-viewer.test.ts
  • src/components/auth-gate.test.tsx
  • src/components/widgets/v2/counters-block.test.tsx
  • src/components/widgets/v2/distribution-strip.test.tsx
  • src/components/widgets/v2/section-card.test.tsx
  • src/main.tsx
  • src/mocks/handlers.ts
  • src/test/session.ts

Comment thread src/auth/refresh.ts
Comment thread src/auth/session.test.ts
Comment thread src/auth/session.ts
…s (review)

- The 403 -> dead-session path now broadcasts "expired" like the 401 path.
  stop() has already closed the BroadcastChannel by then, but broadcast()
  falls through to the localStorage transport, which every tab listens to
  regardless of its own transport - the earlier comment claiming fan-out
  was impossible was wrong.
- loadSession() validates expires_at/refresh_at off the wire (finite
  positive number, else 0 = "never schedule"), matching the validation
  refresh() and onMessage() already do; the JSON cast is compile-time only.

Signed-off-by: Anton Zelenov <antonz@constructor.tech>
@cyberantonz
cyberantonz merged commit d13f31b into constructorfabric:main Jul 24, 2026
6 checks passed
@cyberantonz
cyberantonz deleted the feat/session-refresh-driver branch July 28, 2026 07:56
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.

feat(auth): SPA-side /auth/refresh driver — session renewal timer (nginx+auth #1583)

2 participants