diff --git a/docs/refresh-token-contract.md b/docs/refresh-token-contract.md
index 93c3f57..b45f1aa 100644
--- a/docs/refresh-token-contract.md
+++ b/docs/refresh-token-contract.md
@@ -143,6 +143,101 @@ and does not require any backend changes.
| `signed-in` | After a successful `/v1/auth/siwe/verify` | Write session to sessionStorage and authenticate |
| `refreshed` | After a successful `/v1/auth/siwe/refresh` | Update access token in sessionStorage |
| `signed-out` | After logout or 401-triggered expiry | Clear session and show re-auth prompt |
+| `request-current-session` | Sent by a tab that detects (via the localStorage marker below) that a peer refreshed but never received that peer's `refreshed` message | Any tab holding a valid session for that address re-broadcasts `refreshed` |
+
+Because each tab runs its own `SiweAuthProvider` instance, two tabs can
+independently enter the proactive renewal window (60 s before access-token
+expiry) and both attempt to redeem the same **one-time-use** refresh token
+before either observes the other's `BroadcastChannel` message. The refresh
+path (`lib/wallet/refresh-coordination.ts`, driven from
+`lib/wallet/providers.tsx`) adds three layers on top of `BroadcastChannel` to
+prevent that race:
+
+### Web Locks coordination
+
+The network call to `POST /v1/auth/siwe/refresh` is wrapped in
+`navigator.locks.request()`, scoped per wallet address
+(`guildpass:siwe-refresh:
`). At most one same-origin tab holds the
+lock for a given address at a time, so at most one tab is ever mid-flight on
+the refresh call for that address; any other tab that reaches its renewal
+window for the same address queues behind the lock instead of firing a
+concurrent request.
+
+### Adopting a peer's refreshed session
+
+A tab that was queued behind the lock does **not** blindly replay its refresh
+token once the lock is granted — that token may already have been rotated
+away by the peer that held the lock first. Before calling the API it:
+
+1. Re-reads the stored session and checks whether it is now newer than the
+ session snapshot this attempt started with (different `expiresAt` /
+ `refreshToken`, and not itself expired). If so, it adopts that session
+ directly — no network call.
+2. If storage hasn't caught up yet — a peer's `refreshed` message can be
+ **missed entirely**, not just delayed, if it was sent before this tab's
+ `BroadcastChannel` listener existed (e.g. the winner finishes in a
+ handful of milliseconds while this tab is still mid-navigation) — the tab
+ sends `request-current-session` and briefly polls storage for any peer's
+ response, rather than assuming it must perform its own call.
+3. Only if neither step yields a fresh session does the tab call
+ `siweRefresh()` itself, store the result, and broadcast `refreshed`.
+
+This adoption path also runs when Web Locks is unavailable (see below), so a
+tab that loses an unlocked race can still recover a peer's result instead of
+presenting an already-invalidated token.
+
+A small `localStorage` marker (timestamp of the last successful refresh per
+address — never the token itself) backs step 1/2 above: unlike
+`BroadcastChannel` messages, `localStorage` writes are synchronously visible
+to other same-origin tabs regardless of listener timing, so it reliably tells
+a queued tab *that* a peer refreshed even when it missed the message saying
+so.
+
+### Bounded fallback when the leader disappears
+
+The wait for a peer's response (`request-current-session` → `refreshed`) is
+bounded — by default a 2 second timeout, polling storage every 25 ms. If the
+tab that completed the refresh (or held the lock) closes or navigates away
+before a waiting tab observes the result — e.g. every peer holding the fresh
+session closed its tab — the wait times out and the queued tab falls back to
+performing its own `siweRefresh()` call. A stuck or vanished leader tab can
+therefore never wedge session renewal in the tabs that survive it.
+
+### Graceful degradation without Web Locks or BroadcastChannel
+
+Both coordination primitives are optional; their absence degrades safety
+without breaking functionality:
+
+- **No Web Locks** (`navigator.locks` undefined): `withRefreshLock` runs the
+ refresh operation directly instead of acquiring a lock. Same-tab exclusion
+ (the existing `isRefreshing` ref) still prevents duplicate calls within one
+ tab, and the peer-adoption check above still lets a tab that loses an
+ unlocked race adopt the winner's session — but multiple tabs can now
+ concurrently *attempt* the network call, relying on the backend's one-time-use
+ enforcement (see below) to make any redundant attempt fail safely as a 401
+ rather than a security issue.
+- **No `BroadcastChannel`** (checked via `"BroadcastChannel" in window`): the
+ provider skips wiring up the channel entirely. Cross-tab propagation of
+ `signed-in` / `refreshed` / `signed-out` and the `request-current-session`
+ handshake do not happen, so each tab manages its own session independently
+ and only learns of a peer's rotation the next time it reads
+ `sessionStorage` on its own schedule (e.g. its own renewal timer). Within a
+ single tab, refresh behaviour is unaffected.
+
+In both degraded cases, no tab presents a refresh token it already knows to
+be stale — the remaining risk is redundant network calls, not incorrect
+token reuse, and that risk is bounded by the backend contract below.
+
+### Backend contract is unchanged
+
+None of this cross-tab coordination changes the backend contract described
+above. The refresh token is still **one-time-use**
+(see [Token rotation and invalidation semantics](#token-rotation-and-invalidation-semantics)):
+if degraded conditions ever let two tabs present the same refresh token, the
+backend's existing 401 (`unauthorized`) response for an already-used token is
+what makes the loser's attempt fail safely — the frontend coordination above
+exists purely to make that case rare, not to replace the backend's
+enforcement of it.
---
diff --git a/lib/api/mock.ts b/lib/api/mock.ts
index 66df968..913cda1 100644
--- a/lib/api/mock.ts
+++ b/lib/api/mock.ts
@@ -1437,6 +1437,13 @@ export class MockAccessApi implements AccessApi {
async siweRefresh(refreshToken: string): Promise {
await initPromise
+ // e2e instrumentation only: mock mode makes no real network request for
+ // siweRefresh, so cross-tab race tests need some observable signal for
+ // "how many refresh attempts actually happened" per tab.
+ if (typeof window !== 'undefined') {
+ (window as any).__mockSiweRefreshCalls__ =
+ ((window as any).__mockSiweRefreshCalls__ ?? 0) + 1
+ }
if (MOCK_SESSION_STATE === 'expired' || MOCK_SESSION_STATE === 'unauthenticated') {
throw new ApiError({
status: 401,
diff --git a/lib/wallet/providers.tsx b/lib/wallet/providers.tsx
index bfa52d9..fb69924 100644
--- a/lib/wallet/providers.tsx
+++ b/lib/wallet/providers.tsx
@@ -23,7 +23,7 @@
* Multi-tab synchronisation — BroadcastChannel
* ─────────────────────────────────────────────
* A single named channel (`guildpass:auth`) broadcasts auth-state transitions
- * to every other same-origin tab. Three message types are emitted:
+ * to every other same-origin tab. Message types:
*
* { type: 'signed-in', session: SiweAuthSession }
* — Sent after a successful wallet signature. Peer tabs write the session
@@ -37,6 +37,14 @@
* — Sent after an explicit logout or a detected expiry. Peer tabs clear
* their session and transition to the appropriate unauthenticated state.
*
+ * { type: 'request-current-session', address: string }
+ * — Sent by a tab that, per lib/wallet/refresh-coordination.ts's
+ * localStorage marker, knows a peer just refreshed but never received
+ * that peer's 'refreshed' message (e.g. sent before this tab's
+ * listener existed — BroadcastChannel does not queue/replay missed
+ * messages). Any tab currently holding a valid session for that
+ * address responds by re-broadcasting 'refreshed'.
+ *
* The tab that sends a message does NOT receive it via its own listener
* (BroadcastChannel's same-tab exclusion), so there is no risk of loops.
*
@@ -72,6 +80,7 @@ import { SiweAuthSession, AdminSessionStatus } from "@/lib/api/types";
import {
clearAuthSession,
getStoredToken,
+ isAccessTokenExpired,
isRefreshTokenExpired,
loadAuthSession,
loadAuthSessionIncludingExpired,
@@ -81,6 +90,12 @@ import {
subscribeToAuthSessionStorage,
} from "@/lib/session";
import { isApiError } from "@/lib/api/errors";
+import {
+ isSessionAlreadyRefreshed,
+ markRefreshCompleted,
+ waitForPeerRefresh,
+ withRefreshLock,
+} from "@/lib/wallet/refresh-coordination";
import {
buildSiweMessage,
deriveSessionStatus,
@@ -99,7 +114,8 @@ const wagmiConfig = createConfig(walletConfig);
type AuthBroadcastMessage =
| { type: "signed-in"; session: SiweAuthSession }
| { type: "refreshed"; session: SiweAuthSession }
- | { type: "signed-out" };
+ | { type: "signed-out" }
+ | { type: "request-current-session"; address: string };
const AUTH_CHANNEL_NAME = "guildpass:auth";
@@ -225,7 +241,22 @@ export function SiweAuthProvider({ children }: { children: React.ReactNode }) {
/**
* Attempt a silent token renewal using the stored refresh token.
- * On success: updates reducer state, persists session, broadcasts.
+ *
+ * The network call is wrapped in withRefreshLock() so at most one
+ * same-origin tab performs it per address at a time. A tab that was queued
+ * behind the lock re-checks storage first (isSessionAlreadyRefreshed) and
+ * adopts a peer's already-rotated session instead of replaying the (now
+ * invalidated) refresh token. If sessionStorage hasn't caught up yet — the
+ * peer's BroadcastChannel message can be missed entirely if it was sent
+ * before this tab's listener existed, not just delayed — it asks any
+ * listening peer to resend the current session via a
+ * 'request-current-session' message before falling back to its own call.
+ * See lib/wallet/refresh-coordination.ts.
+ *
+ * On success: updates reducer state, persists + broadcasts session (only
+ * the tab that actually called the API does this), and drains any pending
+ * retry callbacks — whether this tab performed the refresh or adopted a
+ * peer's, the session is fresh either way.
* On failure: transitions to 'expired', broadcasts sign-out.
*/
const performSilentRefresh = useCallback(
@@ -240,12 +271,38 @@ export function SiweAuthProvider({ children }: { children: React.ReactNode }) {
isRefreshing.current = true;
try {
- const api = getApi(session.address);
- const refreshed = await api.siweRefresh(session.refreshToken);
- storeAuthSession(refreshed);
- dispatch({ type: "refresh-success", session: refreshed });
- broadcast({ type: "refreshed", session: refreshed });
- scheduleRenewal(refreshed);
+ const settled = await withRefreshLock(session.address, async () => {
+ // Re-check whether another tab already refreshed while this tab
+ // waited (or, without Web Locks, raced ahead of it).
+ const current = loadAuthSessionIncludingExpired();
+ if (current && isSessionAlreadyRefreshed(current, session)) {
+ return current;
+ }
+
+ // sessionStorage may not have caught up to a peer's refresh yet.
+ // Ask any listening peer to resend the current session before
+ // assuming this tab needs to perform its own (redundant,
+ // already-invalidated) call.
+ const fromPeer = await waitForPeerRefresh(
+ session.address,
+ session,
+ () => loadAuthSessionIncludingExpired(),
+ () => broadcast({ type: "request-current-session", address: session.address }),
+ );
+ if (fromPeer) {
+ return fromPeer;
+ }
+
+ const api = getApi(session.address);
+ const refreshed = await api.siweRefresh(session.refreshToken!);
+ storeAuthSession(refreshed);
+ markRefreshCompleted(session.address, refreshed.expiresAt);
+ broadcast({ type: "refreshed", session: refreshed });
+ return refreshed;
+ });
+
+ dispatch({ type: "refresh-success", session: settled });
+ scheduleRenewal(settled);
// A silent refresh also counts as session recovery — drain any pending
// retry callbacks that were registered before the 401 was surfaced.
@@ -253,7 +310,7 @@ export function SiweAuthProvider({ children }: { children: React.ReactNode }) {
pendingRetriesRef.current = [];
for (const entry of retries) {
try {
- await entry.callback(refreshed);
+ await entry.callback(settled);
} catch (retryErr) {
entry.onRetryFailure?.(retryErr);
}
@@ -373,6 +430,19 @@ export function SiweAuthProvider({ children }: { children: React.ReactNode }) {
applyIncomingSession(msg.session);
} else if (msg.type === "signed-out") {
applyIncomingSession(null);
+ } else if (msg.type === "request-current-session") {
+ // A peer missed our (or another tab's) 'refreshed' broadcast —
+ // BroadcastChannel doesn't queue/replay messages sent before a
+ // listener existed. If we currently hold a valid session for the
+ // requested address, resend it.
+ const current = loadAuthSessionIncludingExpired();
+ if (
+ current &&
+ current.address.toLowerCase() === msg.address.toLowerCase() &&
+ !isAccessTokenExpired(current)
+ ) {
+ broadcast({ type: "refreshed", session: current });
+ }
}
};
@@ -386,7 +456,7 @@ export function SiweAuthProvider({ children }: { children: React.ReactNode }) {
return () => {
unsubscribeStorage();
};
- }, [address, cancelRenewal, scheduleRenewal]);
+ }, [address, broadcast, cancelRenewal, scheduleRenewal]);
// ── Invalidation event from same tab (lib/session.ts fires this) ───────────
diff --git a/lib/wallet/refresh-coordination.ts b/lib/wallet/refresh-coordination.ts
new file mode 100644
index 0000000..e774425
--- /dev/null
+++ b/lib/wallet/refresh-coordination.ts
@@ -0,0 +1,159 @@
+/**
+ * lib/wallet/refresh-coordination.ts
+ *
+ * Cross-tab coordination for silent SIWE refresh-token rotation.
+ *
+ * `isRefreshing` in providers.tsx guards against duplicate refresh calls
+ * *within* a single tab, but each browser tab runs its own SiweAuthProvider
+ * instance with its own ref — two tabs can independently reach the proactive
+ * renewal window and both submit the same one-time-use refresh token before
+ * either sees the other's BroadcastChannel `refreshed` message.
+ *
+ * This module adds cross-tab exclusivity via the Web Locks API (scoped per
+ * address, since different tabs may hold sessions for different wallets) and
+ * a freshness check so a tab that was queued behind the lock can detect that
+ * a peer already completed the rotation and adopt that result instead of
+ * replaying an already-invalidated refresh token.
+ *
+ * When Web Locks is unavailable, `withRefreshLock` runs the operation
+ * directly. Same-tab exclusivity (via the caller's `isRefreshing` ref) still
+ * holds, and the freshness check still lets a tab that loses the race adopt
+ * a peer's result — it just no longer guarantees only one tab ever
+ * *attempts* the network call.
+ */
+
+import { isAccessTokenExpired } from '../session'
+import type { SiweAuthSession } from '../api/types'
+
+/** Per-address Web Locks lock name for the silent-refresh critical section. */
+export function refreshLockName(address: string): string {
+ return `guildpass:siwe-refresh:${address.toLowerCase()}`
+}
+
+/** True when the Web Locks API is available in this environment. */
+export function hasWebLocks(): boolean {
+ return typeof navigator !== 'undefined' && typeof navigator.locks !== 'undefined'
+}
+
+/**
+ * Run `fn` exclusively across same-origin tabs for the given address when
+ * the Web Locks API is available; otherwise run `fn` directly.
+ */
+export async function withRefreshLock(
+ address: string,
+ fn: () => Promise,
+): Promise {
+ if (!hasWebLocks()) return fn()
+ return navigator.locks.request(refreshLockName(address), fn)
+}
+
+type SessionIdentity = Pick
+
+/**
+ * True when `current` (freshly re-read from storage after acquiring the
+ * lock) is a newer session than the one this refresh attempt started with —
+ * i.e. another tab already completed the rotation while this tab was
+ * queued. Compares `expiresAt` / `refreshToken` rather than trusting
+ * message-arrival order, since those are the only fields that carry real
+ * ordering information once a tab is past the BroadcastChannel handler.
+ */
+export function isSessionAlreadyRefreshed(
+ current: SessionIdentity | null,
+ snapshot: SessionIdentity,
+): boolean {
+ if (!current) return false
+ if (current.address.toLowerCase() !== snapshot.address.toLowerCase()) return false
+ const rotated =
+ current.expiresAt !== snapshot.expiresAt || current.refreshToken !== snapshot.refreshToken
+ if (!rotated) return false
+ return !isAccessTokenExpired(current)
+}
+
+// ── Peer-refresh marker ──────────────────────────────────────────────────────
+//
+// sessionStorage is tab-scoped (by design — see lib/session.ts), so a tab
+// only learns about a peer's rotated session once it has received and
+// processed that peer's BroadcastChannel `refreshed` message — and that
+// message can be MISSED entirely (not just delayed) if the winning tab sends
+// it before this tab's own BroadcastChannel listener has been created (e.g.
+// the winner finishes its whole refresh in a handful of milliseconds while
+// this tab is still mid-navigation). BroadcastChannel does not queue or
+// replay messages sent before a listener exists.
+//
+// localStorage writes, unlike BroadcastChannel messages, are synchronously
+// visible to other same-origin tabs regardless of listener timing. It's used
+// here purely as a fast, non-secret "a refresh just happened" signal — only
+// a timestamp is ever stored, never a token — so this does not reintroduce
+// the localStorage token-exposure risk the sessionStorage-only design
+// deliberately avoids. Once the marker indicates a missed refresh, the
+// caller actively asks peers to resend it (see `requestRebroadcast` below)
+// rather than passively polling storage for a message that may never come.
+
+const REFRESH_MARKER_PREFIX = 'guildpass:siwe-refresh-marker:'
+
+function refreshMarkerKey(address: string): string {
+ return `${REFRESH_MARKER_PREFIX}${address.toLowerCase()}`
+}
+
+/** Record that a refresh just completed for `address`. */
+export function markRefreshCompleted(address: string, expiresAt: string): void {
+ if (typeof window === 'undefined') return
+ try {
+ window.localStorage.setItem(refreshMarkerKey(address), JSON.stringify({ expiresAt }))
+ } catch {
+ // Storage quota / private-mode errors — BroadcastChannel still propagates
+ // the session on its own; this is only a latency optimization.
+ }
+}
+
+function readRefreshMarker(address: string): { expiresAt: string } | null {
+ if (typeof window === 'undefined') return null
+ try {
+ const raw = window.localStorage.getItem(refreshMarkerKey(address))
+ if (!raw) return null
+ const parsed = JSON.parse(raw)
+ return typeof parsed?.expiresAt === 'string' ? { expiresAt: parsed.expiresAt } : null
+ } catch {
+ return null
+ }
+}
+
+/**
+ * When the localStorage marker shows a peer tab completed a refresh more
+ * recently than `snapshot`, call `requestRebroadcast` once (asking any
+ * listening peer to resend the current session — see the
+ * 'request-current-session' BroadcastChannel message in providers.tsx) and
+ * briefly poll `loadCurrent` (bounded by `timeoutMs`) for this tab's own
+ * storage to catch up, returning that session once fresh.
+ *
+ * Returns `null` immediately — no wait at all — when there's no marker
+ * evidence of a more recent peer refresh, so the normal, non-racing
+ * single-tab path never pays this cost. Also returns `null` if the wait
+ * times out (e.g. every peer holding the fresh session closed its tab
+ * before responding), letting the caller fall back to performing its own
+ * refresh.
+ */
+export async function waitForPeerRefresh(
+ address: string,
+ snapshot: SessionIdentity,
+ loadCurrent: () => T | null,
+ requestRebroadcast: () => void,
+ { timeoutMs = 2000, pollIntervalMs = 25 }: { timeoutMs?: number; pollIntervalMs?: number } = {},
+): Promise {
+ const marker = readRefreshMarker(address)
+ if (!marker || new Date(marker.expiresAt).getTime() <= new Date(snapshot.expiresAt).getTime()) {
+ return null
+ }
+
+ requestRebroadcast()
+
+ const deadline = Date.now() + timeoutMs
+ while (Date.now() < deadline) {
+ const current = loadCurrent()
+ if (current && isSessionAlreadyRefreshed(current, snapshot)) {
+ return current
+ }
+ await new Promise((resolve) => setTimeout(resolve, pollIntervalMs))
+ }
+ return null
+}
diff --git a/test/e2e/helpers.ts b/test/e2e/helpers.ts
index b2b054a..3f7b02b 100644
--- a/test/e2e/helpers.ts
+++ b/test/e2e/helpers.ts
@@ -168,6 +168,31 @@ export async function navigateToAdmin(page: Page, baseUrl = 'http://localhost:30
await page.goto(`${baseUrl}/admin`)
}
+/**
+ * Directly seed sessionStorage with a SIWE auth session before the app
+ * mounts (via addInitScript), bypassing the wallet-signature UI flow.
+ * Useful for deterministically forcing the provider's mount-time hydration
+ * path — e.g. seeding an already-expired access token with a still-valid
+ * refresh token triggers an immediate silent-refresh attempt on load.
+ */
+export async function seedAuthSession(
+ page: Page,
+ session: {
+ token: string
+ address: string
+ expiresAt: string
+ refreshToken: string
+ refreshExpiresAt: string
+ },
+): Promise {
+ await page.addInitScript((seeded) => {
+ window.sessionStorage.setItem(
+ 'guildpass:siwe-session',
+ JSON.stringify({ isAuthenticated: true, ...seeded }),
+ )
+ }, session)
+}
+
/**
* Get the current address from sessionStorage.
* Useful for asserting that the session was persisted correctly.
diff --git a/test/e2e/siwe-flow.spec.ts b/test/e2e/siwe-flow.spec.ts
index a2c7d79..3b39ac7 100644
--- a/test/e2e/siwe-flow.spec.ts
+++ b/test/e2e/siwe-flow.spec.ts
@@ -32,6 +32,7 @@ import {
simulateSessionExpiry,
isUserAuthenticated,
waitForText,
+ seedAuthSession,
} from './helpers'
const BASE_URL = process.env.BASE_URL || 'http://localhost:3000'
@@ -423,3 +424,91 @@ test.describe('SIWE Sign-In Flow (E2E)', () => {
}
})
})
+
+/**
+ * Cross-tab silent-refresh race (fix/cross-tab-siwe-refresh-race).
+ *
+ * Reproduces two tabs independently reaching the proactive-renewal window
+ * for the same address at nearly the same time. Both tabs are seeded with
+ * an identical session whose access token is already expired but whose
+ * refresh token is still valid — this makes providers.tsx attempt a silent
+ * refresh immediately on mount (msUntilRenewal <= 0), with no dependence on
+ * real token TTLs or a fixed sleep, so navigating both tabs back-to-back
+ * reliably exercises the race.
+ *
+ * lib/api/mock.ts increments window.__mockSiweRefreshCalls__ on every
+ * siweRefresh() call so the test can assert on the number of *attempts*
+ * directly — mock mode makes no real network request there is nothing to
+ * intercept.
+ */
+test.describe('Cross-tab silent refresh race', () => {
+ test('two tabs racing a silent refresh perform exactly one refresh call and both stay authenticated', async ({ browser }) => {
+ const context = await browser.newContext()
+ const page1 = await context.newPage()
+ const page2 = await context.newPage()
+
+ try {
+ await injectMockWalletConnector(page1, { address: DEFAULT_ADDRESS, isConnected: true })
+ await injectMockWalletConnector(page2, { address: DEFAULT_ADDRESS, isConnected: true })
+
+ const seed = {
+ token: 'mock-jwt-pre-race',
+ address: DEFAULT_ADDRESS,
+ expiresAt: new Date(Date.now() - 1000).toISOString(),
+ refreshToken: `mock-refresh-race-${Date.now()}`,
+ refreshExpiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(),
+ }
+ await seedAuthSession(page1, seed)
+ await seedAuthSession(page2, seed)
+
+ // Navigate both tabs back-to-back (not awaited sequentially) so their
+ // mount-time hydration effects fire the race close together.
+ await Promise.all([
+ navigateToAdmin(page1, BASE_URL),
+ navigateToAdmin(page2, BASE_URL),
+ ])
+
+ // Deterministic synchronization: poll actual session state rather than
+ // sleeping a fixed delay.
+ const waitForFreshSession = (page: Page) =>
+ page.waitForFunction(
+ () => {
+ const raw = window.sessionStorage.getItem('guildpass:siwe-session')
+ if (!raw) return false
+ const data = JSON.parse(raw)
+ return typeof data.expiresAt === 'string' && new Date(data.expiresAt).getTime() > Date.now()
+ },
+ { timeout: 15000 },
+ )
+
+ await Promise.all([waitForFreshSession(page1), waitForFreshSession(page2)])
+
+ const [session1, session2, calls1, calls2] = await Promise.all([
+ page1.evaluate(() => JSON.parse(window.sessionStorage.getItem('guildpass:siwe-session')!)),
+ page2.evaluate(() => JSON.parse(window.sessionStorage.getItem('guildpass:siwe-session')!)),
+ page1.evaluate(() => (window as any).__mockSiweRefreshCalls__ ?? 0),
+ page2.evaluate(() => (window as any).__mockSiweRefreshCalls__ ?? 0),
+ ])
+
+ // Exactly one of the two tabs actually called siweRefresh — the other
+ // detected the peer's rotation via isSessionAlreadyRefreshed and
+ // skipped its own network call.
+ expect(calls1 + calls2).toBe(1)
+
+ // The losing tab adopted the winning tab's rotated session rather than
+ // replaying the (now invalidated) refresh token or being signed out.
+ expect(session1.token).toBe(session2.token)
+ expect(session1.refreshToken).toBe(session2.refreshToken)
+ expect(session1.token).not.toBe(seed.token)
+ expect(session1.refreshToken).not.toBe(seed.refreshToken)
+ expect(new Date(session1.expiresAt).getTime()).toBeGreaterThan(Date.now())
+
+ const auth1 = await isUserAuthenticated(page1)
+ const auth2 = await isUserAuthenticated(page2)
+ expect(auth1).toBe(true)
+ expect(auth2).toBe(true)
+ } finally {
+ await context.close()
+ }
+ })
+})
diff --git a/test/refresh-coordination.test.ts b/test/refresh-coordination.test.ts
new file mode 100644
index 0000000..0377a94
--- /dev/null
+++ b/test/refresh-coordination.test.ts
@@ -0,0 +1,287 @@
+import { describe, test, beforeEach, afterEach } from 'node:test'
+import * as assert from 'node:assert/strict'
+import {
+ hasWebLocks,
+ isSessionAlreadyRefreshed,
+ markRefreshCompleted,
+ refreshLockName,
+ waitForPeerRefresh,
+ withRefreshLock,
+} from '../lib/wallet/refresh-coordination'
+import type { SiweAuthSession } from '../lib/api/types'
+
+/**
+ * Unit tests for the cross-tab SIWE refresh coordination helper.
+ *
+ * The repo's test harness has no jsdom, so `navigator` / `window` don't
+ * exist by default. We install a minimal fake LockManager and a minimal
+ * in-memory localStorage to exercise the Web-Locks path, the no-Locks
+ * fallback path, and the peer-refresh marker.
+ */
+
+const ADDRESS = '0x1234567890abcdef1234567890abcdef12345678'
+
+/** Minimal in-memory Storage matching the subset the helpers use. */
+class MemoryStorage {
+ private store = new Map()
+ getItem(key: string): string | null {
+ return this.store.has(key) ? this.store.get(key)! : null
+ }
+ setItem(key: string, value: string): void {
+ this.store.set(key, String(value))
+ }
+ removeItem(key: string): void {
+ this.store.delete(key)
+ }
+}
+
+function session(overrides: Partial = {}): SiweAuthSession {
+ return {
+ isAuthenticated: true,
+ token: 'jwt-abc',
+ address: ADDRESS,
+ expiresAt: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
+ refreshToken: 'refresh-abc',
+ refreshExpiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(),
+ ...overrides,
+ }
+}
+
+beforeEach(() => {
+ ;(globalThis as any).window = { localStorage: new MemoryStorage() }
+})
+
+afterEach(() => {
+ delete (globalThis as any).navigator
+ delete (globalThis as any).window
+})
+
+describe('refreshLockName', () => {
+ test('is namespaced and lowercases the address', () => {
+ assert.equal(
+ refreshLockName('0xABCDEF'),
+ 'guildpass:siwe-refresh:0xabcdef',
+ )
+ })
+})
+
+describe('hasWebLocks / withRefreshLock', () => {
+ test('hasWebLocks is false when navigator is undefined', () => {
+ assert.equal(hasWebLocks(), false)
+ })
+
+ test('hasWebLocks is false when navigator.locks is undefined', () => {
+ ;(globalThis as any).navigator = {}
+ assert.equal(hasWebLocks(), false)
+ })
+
+ test('hasWebLocks is true when navigator.locks is present', () => {
+ ;(globalThis as any).navigator = { locks: { request: async () => undefined } }
+ assert.equal(hasWebLocks(), true)
+ })
+
+ test('withRefreshLock runs fn directly when Web Locks is unavailable', async () => {
+ let ran = false
+ const result = await withRefreshLock(ADDRESS, async () => {
+ ran = true
+ return 'done'
+ })
+ assert.equal(ran, true)
+ assert.equal(result, 'done')
+ })
+
+ test('withRefreshLock requests the per-address lock when Web Locks is available', async () => {
+ const requested: string[] = []
+ ;(globalThis as any).navigator = {
+ locks: {
+ request: async (name: string, fn: () => Promise) => {
+ requested.push(name)
+ return fn()
+ },
+ },
+ }
+
+ let ran = false
+ const result = await withRefreshLock(ADDRESS, async () => {
+ ran = true
+ return 'done'
+ })
+
+ assert.equal(ran, true)
+ assert.equal(result, 'done')
+ assert.deepEqual(requested, [refreshLockName(ADDRESS)])
+ })
+
+ test('withRefreshLock serializes two concurrent callers via the fake lock manager', async () => {
+ // Simulate the browser's own serialization: a single in-flight holder,
+ // second request queues behind it.
+ let holder: Promise | null = null
+ ;(globalThis as any).navigator = {
+ locks: {
+ request: async (_name: string, fn: () => Promise) => {
+ const previous = holder ?? Promise.resolve()
+ const run = previous.then(fn)
+ holder = run.catch(() => undefined)
+ return run
+ },
+ },
+ }
+
+ const order: string[] = []
+ const first = withRefreshLock(ADDRESS, async () => {
+ order.push('first-start')
+ await new Promise((resolve) => setTimeout(resolve, 10))
+ order.push('first-end')
+ return 'first'
+ })
+ const second = withRefreshLock(ADDRESS, async () => {
+ order.push('second-start')
+ order.push('second-end')
+ return 'second'
+ })
+
+ const [firstResult, secondResult] = await Promise.all([first, second])
+ assert.equal(firstResult, 'first')
+ assert.equal(secondResult, 'second')
+ assert.deepEqual(order, ['first-start', 'first-end', 'second-start', 'second-end'])
+ })
+})
+
+describe('isSessionAlreadyRefreshed', () => {
+ test('false when nothing is stored yet', () => {
+ assert.equal(isSessionAlreadyRefreshed(null, session()), false)
+ })
+
+ test('false when the stored session is identical to the snapshot (no peer refresh happened)', () => {
+ const snapshot = session()
+ assert.equal(isSessionAlreadyRefreshed(snapshot, snapshot), false)
+ })
+
+ test('true when a peer tab already rotated the token and the result is still valid', () => {
+ const snapshot = session()
+ const rotated = session({
+ token: 'jwt-new',
+ refreshToken: 'refresh-new',
+ expiresAt: new Date(Date.now() + 90 * 60 * 1000).toISOString(),
+ })
+ assert.equal(isSessionAlreadyRefreshed(rotated, snapshot), true)
+ })
+
+ test('false when the stored session is for a different address', () => {
+ const snapshot = session()
+ const other = session({
+ address: '0x9999999999999999999999999999999999999999',
+ refreshToken: 'refresh-new',
+ expiresAt: new Date(Date.now() + 90 * 60 * 1000).toISOString(),
+ })
+ assert.equal(isSessionAlreadyRefreshed(other, snapshot), false)
+ })
+
+ test('false when the "rotated" session is itself already expired', () => {
+ const snapshot = session()
+ const staleRotation = session({
+ refreshToken: 'refresh-new',
+ expiresAt: new Date(Date.now() - 1000).toISOString(),
+ })
+ assert.equal(isSessionAlreadyRefreshed(staleRotation, snapshot), false)
+ })
+
+ test('address comparison is case-insensitive', () => {
+ const snapshot = session({ address: ADDRESS.toLowerCase() })
+ const rotated = session({
+ address: ADDRESS.toUpperCase(),
+ refreshToken: 'refresh-new',
+ expiresAt: new Date(Date.now() + 90 * 60 * 1000).toISOString(),
+ })
+ assert.equal(isSessionAlreadyRefreshed(rotated, snapshot), true)
+ })
+})
+
+describe('waitForPeerRefresh', () => {
+ test('resolves immediately with null when no marker has been written, without requesting a rebroadcast', async () => {
+ const snapshot = session()
+ let requested = false
+ const start = Date.now()
+ const result = await waitForPeerRefresh(
+ ADDRESS,
+ snapshot,
+ () => null,
+ () => {
+ requested = true
+ },
+ { timeoutMs: 500, pollIntervalMs: 10 },
+ )
+ assert.equal(result, null)
+ assert.equal(requested, false)
+ // No marker evidence of a peer refresh — must not wait at all.
+ assert.ok(Date.now() - start < 100)
+ })
+
+ test('resolves immediately with null when the marker is not newer than the snapshot', async () => {
+ const snapshot = session()
+ markRefreshCompleted(ADDRESS, snapshot.expiresAt)
+ let requested = false
+ const start = Date.now()
+ const result = await waitForPeerRefresh(
+ ADDRESS,
+ snapshot,
+ () => null,
+ () => {
+ requested = true
+ },
+ { timeoutMs: 500, pollIntervalMs: 10 },
+ )
+ assert.equal(result, null)
+ assert.equal(requested, false)
+ assert.ok(Date.now() - start < 100)
+ })
+
+ test('requests a rebroadcast and picks up a peer refresh that lands after a short delay', async () => {
+ const snapshot = session()
+ const rotated = session({
+ token: 'jwt-new',
+ refreshToken: 'refresh-new',
+ expiresAt: new Date(Date.now() + 90 * 60 * 1000).toISOString(),
+ })
+ markRefreshCompleted(ADDRESS, rotated.expiresAt)
+
+ let requested = false
+ let attempts = 0
+ const loadCurrent = () => {
+ attempts += 1
+ // Simulate a peer's 'refreshed' response (triggered by our request)
+ // landing a couple of polls later.
+ return attempts >= 3 ? rotated : null
+ }
+
+ const result = await waitForPeerRefresh(
+ ADDRESS,
+ snapshot,
+ loadCurrent,
+ () => {
+ requested = true
+ },
+ { timeoutMs: 1000, pollIntervalMs: 5 },
+ )
+ assert.equal(requested, true)
+ assert.deepEqual(result, rotated)
+ })
+
+ test('times out and returns null if no peer ever responds', async () => {
+ const snapshot = session()
+ const rotated = session({
+ refreshToken: 'refresh-new',
+ expiresAt: new Date(Date.now() + 90 * 60 * 1000).toISOString(),
+ })
+ markRefreshCompleted(ADDRESS, rotated.expiresAt)
+
+ const result = await waitForPeerRefresh(
+ ADDRESS,
+ snapshot,
+ () => null,
+ () => {},
+ { timeoutMs: 100, pollIntervalMs: 10 },
+ )
+ assert.equal(result, null)
+ })
+})