fix(auth) coordinate token refresh across concurrent requests and tabs #63

Merged
araemer merged 1 commit from fix-concurrent-token-refresh into stage 2026-10-24 06:23:42 +00:00
Owner

Fixes #49: concurrent 401s each called /auth/refresh with the same refresh-token cookie. The backend rotates that cookie on every refresh, so a losing refresh got 401 and logged the user out even when another had just succeeded. The same race exists between tabs, which share the cookie and the localStorage session.

Changes

  • src/utils/concurrencyUtils.ts (new):
    • createSingleFlight: concurrent calls share one run.
    • runWithCrossTabLock: runs an operation under a Web Lock (navigator.locks, built into the browser, no dependency). The wait for the lock is capped (10 s for the refresh), so a frozen background tab can't block the others. On timeout, or without lock support, the operation runs without the lock.
  • AuthRestResource.refresh(rejectedToken):
    • If the stored token is no longer the one the failed request used, another refresh already replaced it, and the request just retries.
    • Otherwise it joins the tab's shared refresh. Once it holds the cross-tab lock, it checks again whether another tab got there first, and only then calls /auth/refresh.
  • apiClient: passes the request's token to refresh(). The session is still cleared only when the refresh fails or the retry gets another 401.
  • Tests in src/utils/tests/concurrencyUtils.test.ts (8 cases).
  • ADR docs/adr/2026-10-24-coordinated-token-refresh.md. It covers the rejected alternatives, including a backend grace window for the rotated token. docs/architecture.md is updated.

Verification

  • npm run test, npm run build, npm run lint: pass (the lint warnings are the existing two in MealPlanDayProvider.tsx).
  • Not yet verified in the running app: the dev backend wasn't reachable from the session. To check:
    1. Log in.
    2. Edit the token in localStorage so the backend rejects it.
    3. Open /shopping-list/<id>. Expect exactly one /auth/refresh and the user stays logged in.
    4. For the cross-tab case, repeat with two tabs and reload both quickly.

🤖 Generated with Claude Code

Fixes #49: concurrent 401s each called `/auth/refresh` with the same refresh-token cookie. The backend rotates that cookie on every refresh, so a losing refresh got 401 and logged the user out even when another had just succeeded. The same race exists between tabs, which share the cookie and the localStorage session. ## Changes - **`src/utils/concurrencyUtils.ts`** (new): - `createSingleFlight`: concurrent calls share one run. - `runWithCrossTabLock`: runs an operation under a Web Lock (`navigator.locks`, built into the browser, no dependency). The wait for the lock is capped (10 s for the refresh), so a frozen background tab can't block the others. On timeout, or without lock support, the operation runs without the lock. - **`AuthRestResource.refresh(rejectedToken)`**: - If the stored token is no longer the one the failed request used, another refresh already replaced it, and the request just retries. - Otherwise it joins the tab's shared refresh. Once it holds the cross-tab lock, it checks again whether another tab got there first, and only then calls `/auth/refresh`. - **`apiClient`**: passes the request's token to `refresh()`. The session is still cleared only when the refresh fails or the retry gets another 401. - Tests in `src/utils/tests/concurrencyUtils.test.ts` (8 cases). - ADR `docs/adr/2026-10-24-coordinated-token-refresh.md`. It covers the rejected alternatives, including a backend grace window for the rotated token. `docs/architecture.md` is updated. ## Verification - `npm run test`, `npm run build`, `npm run lint`: pass (the lint warnings are the existing two in `MealPlanDayProvider.tsx`). - **Not yet verified in the running app**: the dev backend wasn't reachable from the session. To check: 1. Log in. 2. Edit the token in localStorage so the backend rejects it. 3. Open `/shopping-list/<id>`. Expect exactly one `/auth/refresh` and the user stays logged in. 4. For the cross-tab case, repeat with two tabs and reload both quickly. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
fix(auth) coordinate token refresh across concurrent requests and tabs
All checks were successful
/ test (pull_request) Successful in 1m4s
/ contract_pin (pull_request) Successful in 2s
7e2073108c
Concurrent 401s each called /auth/refresh with the same refresh-token
cookie. The backend rotates that cookie on every refresh, so a losing
refresh got 401 and logged the user out even when another had succeeded.

Concurrent 401s in a tab now share one refresh. A request whose token was
already replaced retries without refreshing. Tabs refresh one after
another under a Web Lock, with a bounded wait and an unlocked fallback.

Closes #49

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
araemer merged commit 2e57f1f2a2 into stage 2026-10-24 06:23:42 +00:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
araemer/recipe-app!63
No description provided.