Skip to content

V-3499 Implement session sync with JWT refresh on tab focus - #171

Merged
damianlegawiec merged 2 commits into
mainfrom
claude/lucid-clarke-s1p6gi
Jul 14, 2026
Merged

V-3499 Implement session sync with JWT refresh on tab focus#171
damianlegawiec merged 2 commits into
mainfrom
claude/lucid-clarke-s1p6gi

Conversation

@damianlegawiec

@damianlegawiec damianlegawiec commented Jul 14, 2026

Copy link
Copy Markdown
Member

Summary

Adds robust session management to the authentication system by implementing JWT expiry detection, transparent token refresh, and session re-sync when the browser tab regains focus. This ensures users remain authenticated across tab switches and prevents stale sessions from blocking checkout.

Key Changes

  • JWT utilities (src/lib/spree/jwt.ts): New module for decoding JWT exp claims and checking expiry with configurable skew windows, without signature verification.

  • Session refresh logic (src/lib/spree/auth-helpers.ts):

    • Extracted JWT expiry checking into reusable isJwtExpired() helper
    • Added ensureFreshSession() Server Action to refresh expired JWTs and return session state (valid, refreshed, expired, anonymous)
    • Enhanced tryRefresh() to skip token rotation when cookies can't be persisted (Server Component context), deferring to client-side re-sync
    • Added canPersistCookies() probe to detect writable execution contexts
  • Session sync endpoint (src/lib/data/customer.ts):

    • New syncSession() Server Action that reconciles client session state: refreshes expired JWTs and returns the current customer plus a refreshed flag
    • Consolidated auth cookie clearing into clearAuthTokens() helper with best-effort error handling
  • AuthContext enhancements (src/contexts/AuthContext.tsx):

    • Integrated syncSession() for initial auth state and transparent JWT refresh
    • Added focus-driven re-sync: listens to visibilitychange and focus events to refresh session when tab regains visibility
    • Throttled re-sync to 30-second intervals to avoid excessive server calls
    • Calls router.refresh() after transparent token refresh to re-render server components with renewed session
  • Cookie authentication (src/lib/data/cookies.ts):

    • Updated isAuthenticated() to check JWT expiry (30-second skew) in addition to token presence, preventing stale tokens from appearing authenticated
  • Checkout UX fix (src/components/checkout/AddressSection.tsx):

    • Pre-populate email field with authenticated user's email
    • Only disable email field when both authenticated and email is present, preventing impossible checkout states with stale sessions
  • Comprehensive test coverage:

    • AuthContext.test.tsx: Tests session sync on mount, focus-driven re-sync with throttling, transparent refresh triggering server re-render, and session expiry handling
    • jwt.test.ts: Tests JWT decoding, expiry detection with skew windows, and malformed token handling
    • customer.test.ts: Tests syncSession() behavior across all session states

Implementation Details

  • JWT expiry checks use a 5-minute proactive refresh window in API calls and 30-second skew in authentication checks, balancing security and UX
  • Token rotation is deferred to Server Actions (where cookies are writable) rather than attempted during Server Component renders, preventing lost tokens
  • Session re-sync on focus is throttled to prevent rapid tab switches from hammering the server while still catching JWT expiry during idle periods
  • Transparent refresh (when JWT is renewed without user action) triggers router.refresh() to ensure server components re-fetch data with the new session

https://claude.ai/code/session_01X9CjDU8j8MvtR2Wtxpobfa

Summary by CodeRabbit

  • Bug Fixes

    • Checkout now pre-fills the email address for signed-in customers when no email is already associated with the cart.
    • Signed-in customers can edit the checkout email when no saved email is available.
    • Expired sessions are now recognized and cleared more reliably.
    • Authentication stays synchronized when returning to or refocusing the app, with refreshes handled automatically.
  • Tests

    • Added coverage for session synchronization, token expiration, refresh behavior, and checkout authentication scenarios.

claude added 2 commits July 14, 2026 11:31
A logged-in customer whose Store API JWT expired (1h) kept an
authenticated-looking UI while every authenticated request silently
failed: My Account rendered but order history and addresses were blank,
and checkout showed a blank, non-interactable email field so no order
could be placed. Recovery required a manual logout/login.

Root cause was in the storefront's token lifecycle, not the SDK (a
stateless client that only attaches the token it is handed):

- "Authenticated" was inferred from cookie presence (7-day cookie) rather
  than JWT validity (1-hour token), so a dead session still looked logged
  in. isAuthenticated now treats an expired token as logged out.
- Token refresh persists via cookie writes, which Next.js forbids during a
  Server Component render, so account/checkout page fetches could never
  renew the session and fell back to empty data. Refresh now runs only
  where cookies are writable and skips rotating a refresh token it cannot
  store, avoiding the rotation-loss that wedged the session.
- The session is now transparently refreshed on the client (a Server
  Action on mount) and server components are re-rendered so their data
  reflects the renewed token; a genuinely expired session is treated as
  logged out.
- The checkout email field is never left both disabled and blank, and
  falls back to the account email.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X9CjDU8j8MvtR2Wtxpobfa
The transparent session refresh only fired when the auth provider mounted
(a fresh load or reload). A customer who left an already-open tab idle
past the JWT lifetime and then kept navigating in-app never triggered a
refresh, so pages could still render blank until a manual reload.

Re-run the session sync when the tab regains visibility/focus — the exact
"return to the tab" motion — so the expired JWT is renewed (or the session
is treated as logged out) without a reload. Throttled to avoid re-syncing
on ordinary focus churn.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X9CjDU8j8MvtR2Wtxpobfa
@vercel

vercel Bot commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
storefront Ready Ready Preview, Comment Jul 14, 2026 12:02pm

Request Review

@coderabbitai

coderabbitai Bot commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

JWT freshness and cookie persistence handling now support session reconciliation. Auth context synchronization responds to initial loading, visibility, and focus events. Checkout email state uses the authenticated user email when needed and conditionally enables editing.

Changes

Session synchronization

Layer / File(s) Summary
JWT expiry utilities
src/lib/spree/jwt.ts, src/lib/spree/__tests__/jwt.test.ts, src/lib/spree/index.ts
Adds JWT expiration decoding, skew-aware expiry checks, tests, and public exports.
Cookie-aware auth refresh
src/lib/spree/cookies.ts, src/lib/spree/auth-helpers.ts, src/lib/data/cookies.ts
Adds cookie-write detection, centralized best-effort clearing, and JWT freshness checks for authentication refresh flows.
Customer session reconciliation
src/lib/data/customer.ts, src/lib/data/__tests__/customer.test.ts
Adds syncSession() and handles anonymous, expired, valid, and refreshed session states.
AuthProvider synchronization
src/contexts/AuthContext.tsx, src/contexts/__tests__/AuthContext.test.tsx
Synchronizes sessions on mount, visibility, and focus with throttling and router refresh handling.
Checkout email behavior
src/components/checkout/AddressSection.tsx
Initializes missing checkout email from the authenticated user and conditionally disables the input.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Browser
  participant AuthProvider
  participant syncSession
  participant ensureFreshSession
  participant CustomerAPI
  Browser->>AuthProvider: mount or regain visibility
  AuthProvider->>syncSession: reconcile session
  syncSession->>ensureFreshSession: inspect or refresh tokens
  ensureFreshSession-->>syncSession: session state
  syncSession->>CustomerAPI: fetch customer when authenticated
  CustomerAPI-->>syncSession: customer
  syncSession-->>AuthProvider: customer and refreshed flag
  AuthProvider-->>Browser: update auth state and refresh route when renewed
Loading

Poem

I’m a rabbit with tokens tucked neat,
Fresh JWT hops make sessions complete.
Cookies probe, customers align,
Email fills in at checkout time.
Focus returns; the state springs bright!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 78.57% 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
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main change: session synchronization with JWT refresh and tab-focus re-sync.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch claude/lucid-clarke-s1p6gi

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
Contributor

Choose a reason for hiding this comment

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (3)
src/components/checkout/AddressSection.tsx (1)

277-287: 🎯 Functional Correctness | 🔴 Critical | ⚡ Quick win

Fix input disabling mid-typing.

Because email is a React state variable that updates on every keystroke, checking !!email here introduces a severe bug: if an authenticated user with a missing email starts typing, the field will become disabled immediately after the first character, locking them out.

To fix this and accurately match the intent of locking to the account email, derive the condition from the user prop instead of the live input state.

🐛 Proposed fix
           // Lock to the account email only when we actually have one — never
           // leave the field both disabled and blank (a stale session would
           // otherwise make checkout impossible).
-          disabled={isAuthenticated && !!email}
+          disabled={isAuthenticated && !!user?.email}
           placeholder={t("emailAddress")}
         />
-        {isAuthenticated && !!email && (
+        {isAuthenticated && !!user?.email && (
           <p className="text-xs text-gray-500 mt-1.5">
             {t("usingAccountEmail")}
           </p>
🤖 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/components/checkout/AddressSection.tsx` around lines 277 - 287, Update
the email field’s disabled condition and account-email helper visibility in
AddressSection to use the authenticated user’s account email from the user prop,
not the live email state. Preserve the behavior of leaving the field enabled
when the user lacks an account email, so typing does not disable it after the
first character.
src/lib/spree/auth-helpers.ts (1)

95-123: 🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

Serialize token refreshes before clearing cookies
src/lib/spree/auth-helpers.ts:104-121
tryRefresh() is reachable from getAuthOptions(), withAuthRefresh(), and ensureFreshSession(). In writable requests, two concurrent refreshes can race on the same rotated refresh token: the first persists the new pair, the second fails on the stale token and clearAuthCookies() wipes the newly valid session. Use a single-flight guard per session/request so only one refresh runs at a time.

🤖 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/lib/spree/auth-helpers.ts` around lines 95 - 123, The tryRefresh function
can concurrently rotate the same refresh token, allowing a losing request to
clear the newly refreshed cookies. Add a per-session or per-request
single-flight guard around tryRefresh so concurrent callers await the same
refresh operation, ensuring only one network refresh and cookie update occurs
while preserving the existing success and failure behavior.
src/lib/data/customer.ts (1)

65-101: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Don’t collapse transient customer fetch failures into a logged-out state

getCustomer() returns null for any non-auth error, and AuthContext.refreshUser() turns that into setUser(null) on mount/focus. A brief network/5xx failure can therefore clear a valid session in the UI; keep the existing user unless the error is a confirmed 401/403.

🤖 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/lib/data/customer.ts` around lines 65 - 101, Update getCustomer so
transient network or server failures are not converted to the same null result
as an unauthenticated customer. Preserve null for confirmed 401/403 or missing
tokens, but propagate or otherwise distinguish non-auth errors so
AuthContext.refreshUser does not call setUser(null); keep the existing
authenticated user on those failures.
🧹 Nitpick comments (3)
src/lib/spree/auth-helpers.ts (1)

125-132: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

clearAuthCookies() isn't exported, forcing a duplicate implementation in customer.ts.

See consolidated comment for the cross-file duplication with src/lib/data/customer.ts's clearAuthTokens().

🤖 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/lib/spree/auth-helpers.ts` around lines 125 - 132, Export
clearAuthCookies from auth-helpers.ts and update customer.ts to reuse this
shared function instead of maintaining the duplicate clearAuthTokens
implementation, preserving the existing best-effort cookie-clearing behavior.
src/lib/data/__tests__/customer.test.ts (1)

128-177: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consider adding a test for getCustomer() throwing a non-auth error after a successful refresh.

The current suite covers ensureFreshSession()'s four states well, but doesn't exercise the case where state is "valid"/"refreshed" and the subsequent getClient().customer.get(...) call throws a non-401/403 error. That's the scenario behind the reliability concern raised in src/lib/data/customer.ts (transient failure surfacing as a false logout); a regression test here would guard any future fix.

🤖 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/lib/data/__tests__/customer.test.ts` around lines 128 - 177, Extend the
syncSession tests to cover a non-authentication error from
mockClient.customer.get after ensureFreshSession returns both "valid" and
"refreshed" (or the relevant shared path). Assert the error is propagated or
handled according to syncSession’s intended behavior, and verify the result does
not incorrectly indicate an expired or anonymous session.
src/lib/data/customer.ts (1)

22-34: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Duplicates clearAuthCookies() from auth-helpers.ts; also missing an explicit return type.

This best-effort clear logic is identical to the private clearAuthCookies() in src/lib/spree/auth-helpers.ts — see consolidated comment for the cross-file fix. Separately, clearAuthTokens() is declared without an explicit return type (Promise<void>), unlike the other functions touched in this diff.

As per coding guidelines, "Use strict TypeScript type checking. Always define explicit return types for functions... and avoid 'any' (use 'unknown' instead)."

♻️ Proposed fix
-async function clearAuthTokens() {
+async function clearAuthTokens(): Promise<void> {
   try {
     await clearAccessToken();
     await clearRefreshToken();
   } catch {
     // Best-effort — not writable during a Server Component render.
   }
 }
🤖 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/lib/data/customer.ts` around lines 22 - 34, Remove the duplicated
clearAuthTokens implementation and reuse the existing clearAuthCookies helper
from auth-helpers.ts for best-effort token clearing. Ensure the affected
function has an explicit Promise<void> return type, preserving the current
behavior in read-only contexts.

Source: Coding guidelines

🤖 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.

Outside diff comments:
In `@src/components/checkout/AddressSection.tsx`:
- Around line 277-287: Update the email field’s disabled condition and
account-email helper visibility in AddressSection to use the authenticated
user’s account email from the user prop, not the live email state. Preserve the
behavior of leaving the field enabled when the user lacks an account email, so
typing does not disable it after the first character.

In `@src/lib/data/customer.ts`:
- Around line 65-101: Update getCustomer so transient network or server failures
are not converted to the same null result as an unauthenticated customer.
Preserve null for confirmed 401/403 or missing tokens, but propagate or
otherwise distinguish non-auth errors so AuthContext.refreshUser does not call
setUser(null); keep the existing authenticated user on those failures.

In `@src/lib/spree/auth-helpers.ts`:
- Around line 95-123: The tryRefresh function can concurrently rotate the same
refresh token, allowing a losing request to clear the newly refreshed cookies.
Add a per-session or per-request single-flight guard around tryRefresh so
concurrent callers await the same refresh operation, ensuring only one network
refresh and cookie update occurs while preserving the existing success and
failure behavior.

---

Nitpick comments:
In `@src/lib/data/__tests__/customer.test.ts`:
- Around line 128-177: Extend the syncSession tests to cover a
non-authentication error from mockClient.customer.get after ensureFreshSession
returns both "valid" and "refreshed" (or the relevant shared path). Assert the
error is propagated or handled according to syncSession’s intended behavior, and
verify the result does not incorrectly indicate an expired or anonymous session.

In `@src/lib/data/customer.ts`:
- Around line 22-34: Remove the duplicated clearAuthTokens implementation and
reuse the existing clearAuthCookies helper from auth-helpers.ts for best-effort
token clearing. Ensure the affected function has an explicit Promise<void>
return type, preserving the current behavior in read-only contexts.

In `@src/lib/spree/auth-helpers.ts`:
- Around line 125-132: Export clearAuthCookies from auth-helpers.ts and update
customer.ts to reuse this shared function instead of maintaining the duplicate
clearAuthTokens implementation, preserving the existing best-effort
cookie-clearing behavior.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 83e638b7-1a82-4cb7-b778-8959d6bbaa00

📥 Commits

Reviewing files that changed from the base of the PR and between 7ad5a07 and 3ef0057.

📒 Files selected for processing (11)
  • src/components/checkout/AddressSection.tsx
  • src/contexts/AuthContext.tsx
  • src/contexts/__tests__/AuthContext.test.tsx
  • src/lib/data/__tests__/customer.test.ts
  • src/lib/data/cookies.ts
  • src/lib/data/customer.ts
  • src/lib/spree/__tests__/jwt.test.ts
  • src/lib/spree/auth-helpers.ts
  • src/lib/spree/cookies.ts
  • src/lib/spree/index.ts
  • src/lib/spree/jwt.ts

@damianlegawiec damianlegawiec changed the title Implement session sync with JWT refresh on tab focus V-3499 Implement session sync with JWT refresh on tab focus Jul 14, 2026
@damianlegawiec
damianlegawiec merged commit f1bc5a3 into main Jul 14, 2026
7 checks passed
damianlegawiec pushed a commit that referenced this pull request Jul 14, 2026
Follow-up to the JWT session-sync work (#171), addressing review findings:

- Checkout email input is keyed off the account email rather than the
  live input value, so an authenticated user without an email on file
  can still type instead of being disabled after the first keystroke.
- Transient customer-fetch failures no longer collapse a valid session
  to logged-out: syncSession flags them as `stale` so the client keeps
  the current user, clearing only on a confirmed 401/403.
- Concurrent token refreshes sharing the same rotated refresh token are
  coalesced into a single in-flight request, so a losing race can no
  longer wipe the freshly persisted session.
- Deduplicate best-effort cookie clearing behind an exported
  clearAuthCookies helper.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X9CjDU8j8MvtR2Wtxpobfa
damianlegawiec added a commit that referenced this pull request Jul 15, 2026
* Harden session sync against false logouts and refresh races

Follow-up to the JWT session-sync work (#171), addressing review findings:

- Checkout email input is keyed off the account email rather than the
  live input value, so an authenticated user without an email on file
  can still type instead of being disabled after the first keystroke.
- Transient customer-fetch failures no longer collapse a valid session
  to logged-out: syncSession flags them as `stale` so the client keeps
  the current user, clearing only on a confirmed 401/403.
- Concurrent token refreshes sharing the same rotated refresh token are
  coalesced into a single in-flight request, so a losing race can no
  longer wipe the freshly persisted session.
- Deduplicate best-effort cookie clearing behind an exported
  clearAuthCookies helper.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X9CjDU8j8MvtR2Wtxpobfa

* Harden token refresh against transient failures

The session-sync work made the customer-fetch path resilient to transient
errors but left the token-refresh path clearing auth cookies on any failure,
so a network or 5xx blip during refresh logged the user out — the exact
failure this change set out to prevent. Only drop the session on a
confirmed-invalid refresh token; treat other refresh failures as stale and
preserve the session. Carry the rotation signal through a stale follow-up
fetch so server components still re-render under the renewed session.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X9CjDU8j8MvtR2Wtxpobfa

* Clarify email-lock guard comment in checkout AddressSection

The comment justified the `!!user?.email` guard with an impossible state (a
saved customer always has an email). The guard actually protects the window
where the server-derived `isAuthenticated` is true while the AuthContext `user`
is still null — during hydration or after a transient sync preserved as stale —
where keying off `isAuthenticated` alone would leave the email field disabled
and blank, blocking checkout.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X9CjDU8j8MvtR2Wtxpobfa

---------

Co-authored-by: Claude <noreply@anthropic.com>
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.

2 participants