Skip to content

Harden session sync against false logouts and refresh races - #172

Merged
damianlegawiec merged 3 commits into
mainfrom
claude/lucid-clarke-s1p6gi
Jul 15, 2026
Merged

Harden session sync against false logouts and refresh races#172
damianlegawiec merged 3 commits into
mainfrom
claude/lucid-clarke-s1p6gi

Conversation

@damianlegawiec

@damianlegawiec damianlegawiec commented Jul 14, 2026

Copy link
Copy Markdown
Member

Summary

Follow-up to #171 (session sync with JWT refresh on tab focus), addressing review findings from that PR that landed alongside the merge.

Changes

  • Checkout email no longer locks mid-typing (AddressSection.tsx) — the field's disabled/helper condition now keys off the account email (user?.email) instead of the live input state. Previously an authenticated user with no email on file would have the field disable itself after the first keystroke, blocking checkout.
  • Transient fetch failures don't force a logout (customer.ts, AuthContext.tsx) — syncSession() now flags a transient customer-fetch failure as stale so the client keeps the current user rather than flashing to logged-out on a network blip / 5xx. The session is only cleared on a confirmed 401/403.
  • Concurrent token refreshes are coalesced (auth-helpers.ts) — tryRefresh() now shares a single in-flight request per rotated refresh token, so a losing race can no longer rotate a stale token and wipe the freshly persisted session. Keyed by token value so distinct sessions never share a refresh.
  • Deduplicated cookie clearingclearAuthCookies() is exported from auth-helpers and reused in customer.ts in place of the duplicate clearAuthTokens() helper.

Testing

  • vitest run — 106 tests pass, including new syncSession cases covering the transient-failure (stale) and auth-failure paths.
  • tsc --noEmit clean; Biome check clean.

🤖 Generated with Claude Code


Generated by Claude Code

Summary by CodeRabbit

  • Bug Fixes
    • Improved checkout email field behavior for authenticated customers by gating disable/notice on account email availability.
    • Prevented temporary session or network issues from unnecessarily signing customers out.
    • Authentication cookies are now cleared only after confirmed authentication failures.
    • Improved reliability when multiple session refreshes occur simultaneously, including “stale” handling for transient issues.
  • Tests
    • Expanded coverage for transient vs confirmed auth failures, “stale” session outcomes, and concurrent session refresh coalescing.

Note

Medium Risk
Touches authentication, cookie clearing, and token refresh—security-sensitive paths—but behavior is narrowed (fewer logouts) with added tests; checkout UX change is low scope.

Overview
Hardens JWT session handling so network blips and concurrent refreshes no longer sign users out or break checkout.

Session syncsyncSession() can return stale when refresh or customer fetch fails transiently; AuthContext keeps the in-memory user instead of clearing it. Cookies are cleared only on confirmed 401/403 via shared isAuthError / clearAuthCookies. ensureFreshSession() adds a stale state when refresh fails but the refresh token remains; tryRefresh() no longer wipes cookies on generic errors.

Refresh races — Concurrent refreshes for the same refresh token are coalesced so a losing race cannot rotate an already-invalidated token and wipe a good session.

Checkout — The email field locks only when user?.email is present (hasAccountEmail), not when isAuthenticated is true while user is still null (hydration or stale sync), avoiding a disabled blank email field.

Tests cover stale vs auth-failure paths and refresh coalescing.

Reviewed by Cursor Bugbot for commit 699feba. Bugbot is set up for automated code reviews on this repo. Configure here.

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
@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 15, 2026 11:39am

Request Review

@coderabbitai

coderabbitai Bot commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Authentication handling now coalesces refreshes, distinguishes transient failures from confirmed logout, preserves stale sessions, and updates checkout email behavior for authenticated users.

Changes

Authentication and session resilience

Layer / File(s) Summary
Coalesced authentication refreshes
src/lib/spree/auth-helpers.ts, src/lib/spree/index.ts, src/lib/spree/__tests__/auth-helpers.test.ts
Concurrent refreshes reuse the same in-flight promise, confirmed invalid tokens clear cookies, and refresh resilience is covered by tests.
Customer and session outcome handling
src/lib/data/customer.ts, src/lib/data/__tests__/customer.test.ts
Customer fetching centralizes auth-error detection; confirmed auth failures clear cookies, while transient failures return stale session results without clearing cookies.
Session and checkout consumers
src/contexts/AuthContext.tsx, src/components/checkout/AddressSection.tsx
Stale or unexpected refresh failures preserve the current user, and email editing depends on the authenticated account email.

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

Sequence Diagram(s)

sequenceDiagram
  participant Checkout
  participant AuthContext
  participant syncSession
  participant fetchCustomer
  participant SpreeAuth
  Checkout->>AuthContext: refresh user
  AuthContext->>syncSession: synchronize session
  syncSession->>fetchCustomer: fetch customer
  fetchCustomer->>SpreeAuth: use coalesced auth refresh
  SpreeAuth-->>fetchCustomer: customer or auth/transient error
  fetchCustomer-->>syncSession: result
  syncSession-->>AuthContext: customer, refreshed, stale
  AuthContext-->>Checkout: updated or preserved user state
Loading

Possibly related PRs

Poem

I’m a bunny guarding tokens bright,
Refreshes now share one hop tonight.
Stale sessions stay snug in their chair,
Auth cookies vanish only with care.
Checkout emails can wiggle free—
Hippity-hop, consistency!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 66.67% 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 accurately summarizes the main session-sync and refresh-race hardening changes in the PR.
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.
✨ 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.

Actionable comments posted: 3

Caution

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

⚠️ Outside diff range comments (1)
src/lib/data/customer.ts (1)

1-1: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Token rotation signal (refreshed) is lost on a transiently-stale follow-up fetch. syncSession() hardcodes refreshed: false in its stale branch even when the preceding refresh actually rotated tokens, and AuthContext.refreshUser() checks stale before refreshed. Together, a successful rotation immediately followed by a transient customer-fetch failure never triggers router.refresh(), so Server Components keep rendering under the pre-rotation session until the next throttled resync.

  • src/lib/data/customer.ts#L93-117: return refreshed: state === "refreshed" in the stale branch instead of hardcoding false (and update the matching assertion in src/lib/data/__tests__/customer.test.ts lines 172-190).
  • src/contexts/AuthContext.tsx#L70-79: check refreshed (and call router.refresh()) before the early stale return, e.g. if (refreshed) router.refresh(); if (stale) return;.
🔧 Proposed fix
--- a/src/lib/data/customer.ts
@@
     // Transient failure — signal the client to preserve its current session.
-    return { customer: null, refreshed: false, stale: true };
+    return { customer: null, refreshed: state === "refreshed", stale: true };
--- a/src/contexts/AuthContext.tsx
@@
       const { customer, refreshed, stale } = await syncSession();
-      // A transient fetch failure returns `stale` — keep the current session
-      // rather than flashing the user to logged-out on a blip.
-      if (stale) return;
-      setUser(customer ? toUser(customer) : null);
-      if (refreshed) {
-        router.refresh();
-      }
+      if (refreshed) {
+        router.refresh();
+      }
+      // A transient fetch failure returns `stale` — keep the current session
+      // rather than flashing the user to logged-out on a blip.
+      if (stale) return;
+      setUser(customer ? toUser(customer) : null);
🤖 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` at line 1, Preserve the token-rotation signal in
syncSession’s stale branch by returning refreshed based on state === "refreshed"
instead of false, and update the corresponding customer test assertion. In
AuthContext.refreshUser, handle refreshed and call router.refresh() before
returning early for stale results.
🧹 Nitpick comments (3)
src/lib/spree/auth-helpers.ts (2)

111-140: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consider a regression test for the coalescing behavior.

This is the PR's central resilience fix; a test asserting two concurrent tryRefresh()/withAuthRefresh() calls for the same refresh token result in a single call to the underlying auth.refresh() would give confidence this doesn't regress.

🤖 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 111 - 140, The refresh coalescing
behavior in tryRefresh should be covered by a regression test. Add a test that
invokes two concurrent tryRefresh or withAuthRefresh calls with the same refresh
token, mocks auth.refresh, and asserts both callers share the result while
auth.refresh is called exactly once.

95-140: 🩺 Stability & Availability | 🔵 Trivial

In-memory refresh coalescing is process-local. That still leaves the same rotated-token race across Vercel/serverless invocations or multiple replicas; if that runtime is in scope, use shared coordination instead of relying on this map.

🤖 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 - 140, Replace the
process-local refreshInFlight map used by tryRefresh with shared coordination
keyed by refreshToken, so concurrent refreshes across serverless invocations or
replicas coalesce around one refresh operation. Preserve the existing
cookie-persistence guard, token rotation persistence, failure cleanup, and
returned-token behavior.
src/components/checkout/AddressSection.tsx (1)

277-284: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Correct fix for the checkout email lockout.

Keying disabled/the hint off user?.email (account email) rather than the live email input value correctly lets an authenticated user without an account email keep typing, and avoids a stale session leaving the field both disabled and blank.

Optional: the predicate isAuthenticated && !!user?.email is duplicated at lines 281 and 284; extracting it to a local const hasAccountEmail = isAuthenticated && !!user?.email; would avoid the repetition.

🤖 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 - 284, In
AddressSection, extract the repeated isAuthenticated && !!user?.email predicate
into a local hasAccountEmail constant and reuse it for the email field’s
disabled prop and conditional hint rendering, preserving the current
account-email-based behavior.
🤖 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/contexts/AuthContext.tsx`:
- Around line 70-79: Update refreshUser in AuthContext so stale results preserve
the current user while still calling router.refresh() when refreshed is true;
avoid returning before the refresh check. Update syncSession’s stale branch to
propagate whether token rotation occurred instead of hardcoding refreshed:
false, preserving the existing stale-session behavior.

In `@src/lib/data/customer.ts`:
- Around line 93-117: Update the transient-failure return in syncSession so
refreshed reflects whether ensureFreshSession returned "refreshed", even when
fetchCustomer fails non-auth transiently. Preserve stale: true and the existing
auth-error handling, while ensuring AuthContext.refreshUser can observe the
successful rotation.

In `@src/lib/spree/auth-helpers.ts`:
- Around line 118-140: The catch in tryRefresh must distinguish confirmed
invalid refresh tokens from transient failures. Inspect the caught error and
call clearAuthCookies only for a SpreeError with code invalid_refresh_token or
status 401; return null for other refresh errors while preserving the existing
finally cleanup and in-flight deduplication.

---

Outside diff comments:
In `@src/lib/data/customer.ts`:
- Line 1: Preserve the token-rotation signal in syncSession’s stale branch by
returning refreshed based on state === "refreshed" instead of false, and update
the corresponding customer test assertion. In AuthContext.refreshUser, handle
refreshed and call router.refresh() before returning early for stale results.

---

Nitpick comments:
In `@src/components/checkout/AddressSection.tsx`:
- Around line 277-284: In AddressSection, extract the repeated isAuthenticated
&& !!user?.email predicate into a local hasAccountEmail constant and reuse it
for the email field’s disabled prop and conditional hint rendering, preserving
the current account-email-based behavior.

In `@src/lib/spree/auth-helpers.ts`:
- Around line 111-140: The refresh coalescing behavior in tryRefresh should be
covered by a regression test. Add a test that invokes two concurrent tryRefresh
or withAuthRefresh calls with the same refresh token, mocks auth.refresh, and
asserts both callers share the result while auth.refresh is called exactly once.
- Around line 95-140: Replace the process-local refreshInFlight map used by
tryRefresh with shared coordination keyed by refreshToken, so concurrent
refreshes across serverless invocations or replicas coalesce around one refresh
operation. Preserve the existing cookie-persistence guard, token rotation
persistence, failure cleanup, and returned-token behavior.
🪄 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: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: d275a8f8-e8dc-4fa3-a119-a3d69870feed

📥 Commits

Reviewing files that changed from the base of the PR and between f1bc5a3 and e378a92.

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

Comment thread src/contexts/AuthContext.tsx
Comment thread src/lib/data/customer.ts
Comment thread src/lib/spree/auth-helpers.ts
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
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

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

Actionable comments posted: 1

🤖 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/lib/spree/__tests__/auth-helpers.test.ts`:
- Around line 22-32: Update the ../cookies mock factory in auth-helpers.test.ts
to include the exported clearAuthCookies function, using a dedicated
mockClearAuthCookies spy or equivalent delegation to the existing clear-token
mocks. Review the affected auth error test assertions to verify clearAuthCookies
is invoked, while preserving the existing cookie mock behavior.
🪄 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: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 4360b01f-66d7-46ad-a4c5-68b85fd30ac4

📥 Commits

Reviewing files that changed from the base of the PR and between e378a92 and 699feba.

📒 Files selected for processing (7)
  • src/components/checkout/AddressSection.tsx
  • src/contexts/AuthContext.tsx
  • src/lib/data/__tests__/customer.test.ts
  • src/lib/data/customer.ts
  • src/lib/spree/__tests__/auth-helpers.test.ts
  • src/lib/spree/auth-helpers.ts
  • src/lib/spree/index.ts
🚧 Files skipped from review as they are similar to previous changes (4)
  • src/lib/spree/index.ts
  • src/components/checkout/AddressSection.tsx
  • src/contexts/AuthContext.tsx
  • src/lib/data/customer.ts

Comment thread src/lib/spree/__tests__/auth-helpers.test.ts
@damianlegawiec
damianlegawiec merged commit 649cc54 into main Jul 15, 2026
8 checks passed
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