From 7684436877b8e78d39d86ddcb1453e3a4d90d903 Mon Sep 17 00:00:00 2001 From: nkechiogbuji Date: Wed, 29 Jul 2026 02:18:05 +0100 Subject: [PATCH] Fix cross-tab race condition in silent SIWE refresh-token rotation --- docs/refresh-token-contract.md | 95 ++++++++++ lib/api/mock.ts | 7 + lib/wallet/providers.tsx | 92 +++++++-- lib/wallet/refresh-coordination.ts | 159 ++++++++++++++++ test/e2e/helpers.ts | 25 +++ test/e2e/siwe-flow.spec.ts | 89 +++++++++ test/refresh-coordination.test.ts | 287 +++++++++++++++++++++++++++++ 7 files changed, 743 insertions(+), 11 deletions(-) create mode 100644 lib/wallet/refresh-coordination.ts create mode 100644 test/refresh-coordination.test.ts 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) + }) +})