From ae491274b1eff5d968f3eb7283768fdef838ed03 Mon Sep 17 00:00:00 2001 From: nkechiogbuji Date: Wed, 29 Jul 2026 02:18:05 +0100 Subject: [PATCH 1/2] 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) + }) +}) From b9087b77e0999f6afbd75fa75f591e903178533e Mon Sep 17 00:00:00 2001 From: nkechiogbuji Date: Wed, 29 Jul 2026 04:40:14 +0100 Subject: [PATCH 2/2] Implement frontend dual-mode readiness for httpOnly cookie SIWE migration (Phase 2) --- .env.example | 8 + README.md | 22 +++ docs/http-only-cookie-migration.md | 49 ++++-- lib/api/live.ts | 50 +++++- lib/api/mock.ts | 89 +++++++++- lib/api/types.ts | 39 ++++- lib/config-validation.d.ts | 4 + lib/config-validation.js | 12 ++ lib/config.ts | 11 ++ lib/session.ts | 59 ++++++- lib/wallet/providers.tsx | 181 +++++++++++++++++++-- lib/wallet/siwe-session.ts | 29 ++++ scripts/sync-api-types.js | 39 ++++- test/api-version.test.ts | 1 + test/config-auth-mode.test.ts | 62 +++++++ test/e2e/helpers.ts | 25 +++ test/e2e/siwe-flow-cookie-mode.spec.ts | 143 ++++++++++++++++ test/live-api-cookie-mode.test.ts | 179 ++++++++++++++++++++ test/mock-cookie-session.test.ts | 176 ++++++++++++++++++++ test/refresh-coordination.test.ts | 1 + test/session-cookie-mode.test.ts | 215 +++++++++++++++++++++++++ test/siwe-broadcast-validation.test.ts | 88 ++++++++++ 22 files changed, 1443 insertions(+), 39 deletions(-) create mode 100644 test/config-auth-mode.test.ts create mode 100644 test/e2e/siwe-flow-cookie-mode.spec.ts create mode 100644 test/live-api-cookie-mode.test.ts create mode 100644 test/mock-cookie-session.test.ts create mode 100644 test/session-cookie-mode.test.ts create mode 100644 test/siwe-broadcast-validation.test.ts diff --git a/.env.example b/.env.example index 4cd63f9..2652428 100644 --- a/.env.example +++ b/.env.example @@ -61,6 +61,14 @@ NEXT_PUBLIC_SIWE_DOMAIN=localhost:3000 # 200 characters — invalid values throw a ConfigError at startup. NEXT_PUBLIC_SIWE_STATEMENT="Sign in to GuildPass Admin" +# Session auth mode. "bearer" (default) persists the SIWE token in +# sessionStorage and sends it as an Authorization header. "cookie" is +# dual-mode readiness for the httpOnly-cookie migration (see +# docs/http-only-cookie-migration.md) — no token is ever read from or +# written to sessionStorage, and no Authorization header is sent. Any value +# other than the literal "cookie" falls back to "bearer". +# NEXT_PUBLIC_AUTH_MODE=cookie + # ── Wallet / Chain Configuration ────────────────────────────────────────────── # Comma-separated chain names offered to wagmi. Supported values: diff --git a/README.md b/README.md index 45e04ae..2e973d1 100644 --- a/README.md +++ b/README.md @@ -105,6 +105,27 @@ Admin actions are protected by [Sign-In with Ethereum (EIP-4361)](https://eips.e > In **mock mode** all three endpoints are simulated in-memory — no backend required. +### Dual-mode readiness: httpOnly cookie auth (`NEXT_PUBLIC_AUTH_MODE`) + +`NEXT_PUBLIC_AUTH_MODE=cookie` switches the flow above to session-cookie auth +instead of a sessionStorage-persisted bearer token — see +[docs/http-only-cookie-migration.md](./docs/http-only-cookie-migration.md) +for the full design and current implementation status. `bearer` (the +default) is unchanged by this flag. In short, cookie mode: + +- Never reads or writes a bearer token to `sessionStorage`. +- Never sends an `Authorization` header — the browser sends the session + cookie automatically (`credentials: 'include'`). +- Hydrates session state on mount via `isSessionActive()` + (`GET /v1/auth/session`, mocked in mock mode) instead of reading storage. +- Broadcasts only `signed-in`/`refreshed`/`signed-out` signals to peer tabs + without a real token. + +This is **not yet a full Phase 2 cutover** — `guildpass-core` doesn't +implement `GET /v1/auth/session` yet (mock mode simulates it), and +cookie-mode silent refresh does not yet use the same cross-tab Web-Locks +coordination bearer mode has (see the doc for details). + --- ## Environment Variables @@ -121,6 +142,7 @@ check automatically via the `predev` script. | ---- | ------- | ----------- | | `NEXT_PUBLIC_MOCK_MODE` | No | Set `true` for in-memory mock API; SIWE fully simulated | | `NEXT_PUBLIC_DEMO_MODE` | No | Alias for `NEXT_PUBLIC_MOCK_MODE` | +| `NEXT_PUBLIC_AUTH_MODE` | No | `bearer` (default) or `cookie` — see [Dual-mode readiness](#dual-mode-readiness-httponly-cookie-auth-next_public_auth_mode) above. Any value other than the literal `cookie` falls back to `bearer`. | | `NEXT_PUBLIC_CORE_API_URL` | Live mode only (validated) | Base URL of the `guildpass-core` access-api — must be a valid absolute URL in live mode | | `NEXT_PUBLIC_SIWE_DOMAIN` | No | Domain field in the EIP-4361 message (defaults to `localhost:3000`) | | `NEXT_PUBLIC_SIWE_STATEMENT` | No | Human-readable statement shown in the signed message | diff --git a/docs/http-only-cookie-migration.md b/docs/http-only-cookie-migration.md index 86a6d9e..8577d5b 100644 --- a/docs/http-only-cookie-migration.md +++ b/docs/http-only-cookie-migration.md @@ -74,21 +74,52 @@ The backend still returns `token` in the `/verify` and `/refresh` response bodies. No frontend changes are needed yet; this phase verifies the cookie path works end-to-end without risking a regression. -### Phase 2 — Switch to cookie +### Phase 1.5 — Dual-mode readiness (implemented) -| Module | Change | +Rather than a hard cutover, the frontend now supports **both** modes behind +`NEXT_PUBLIC_AUTH_MODE` (`bearer` default, `cookie` opt-in — see the +[README](../README.md#dual-mode-readiness-httponly-cookie-auth-next_public_auth_mode)). +`bearer` mode is byte-for-byte unchanged. `cookie` mode implements the target +architecture below, gated behind the flag, entirely against the mock (no +real `guildpass-core` endpoint exists yet): + +| Module | What actually changed | |--------|--------| -| `lib/session.ts` | Remove all `sessionStorage` read/write/clear logic. `storeAuthSession()`, `loadAuthSession()`, `clearAuthSession()` become no-ops or thin wrappers around a new session-status check. `getStoredToken()` returns `null` — the token is no longer accessible to JS. | -| `lib/session.ts` — new | Add `isSessionActive(): Promise` that calls `GET /v1/auth/session` (or similar lightweight endpoint that returns `{ authenticated: true/false }`). This replaces the client-side expiry check. | -| `lib/api/live.ts` | Remove `authHeaders()` entirely — the cookie is sent automatically by the browser. `LiveAccessApi` no longer needs the `token` constructor parameter. | -| `lib/api/index.ts` | `getApi()` no longer takes a `token` argument. | -| `lib/wallet/providers.tsx` | `SiweAuthProvider` no longer hydrates from `sessionStorage` on mount. Auth status is determined by calling `isSessionActive()`. Silent refresh still triggers `siweRefresh()` but the new cookie is set by the backend automatically. | -| All call sites | Remove `authSession?.token` from `getApi(...)` calls in `nav.tsx`, `app/admin/*/page.tsx`. The constructor parameter is gone; the cookie is transparent. | +| `lib/config.ts` | New `config.authMode: 'bearer' \| 'cookie'`, parsed from `NEXT_PUBLIC_AUTH_MODE`. Any value other than the literal `cookie` falls back to `bearer`. | +| `lib/session.ts` | `storeAuthSession()`, `loadAuthSession()`, `loadAuthSessionIncludingExpired()`, and the `sessionStorage` branch of `clearAuthSession()` become no-ops in cookie mode — proven by unit tests that spy on `sessionStorage` call counts, not just return values. `getStoredToken()`/`getStoredAddress()` return `null` via the same gate. New `isSessionActive(): Promise` calls the endpoint below. | +| `lib/api/types.ts` | New `SessionStatus { authenticated: boolean; address?: string; expiresAt?: string }` / `SessionStatusSchema` — provisional, like `AnalyticsSummary`. Added to `SiweAuthApi.getSessionStatus()`. `siweLogout(token?: string)` — token is now optional so a cookie-mode caller can invoke it with none. | +| `lib/api/live.ts` | `getSessionStatus()` calls `GET /v1/auth/session` (no live backend implements this yet — 404 is treated the same as "no session"). `authHeaders()` never adds `Authorization` when `authMode === 'cookie'`, regardless of whether a token is present in memory. `getJson()` sends `credentials: 'include'` only in cookie mode (needed because the core API can be cross-origin in dev — the browser's `same-origin` default excludes those cookies). The `token` constructor parameter was already optional; it stays. | +| `lib/api/mock.ts` | Mock `getSessionStatus()`/cookie simulation via a non-httpOnly `document.cookie` entry (mock JS cannot set a real httpOnly cookie either) — entirely separate storage from `sessionStorage`, active only when `authMode === 'cookie'`. | +| `lib/wallet/providers.tsx` | Mount hydration calls `getApi().getSessionStatus()` instead of reading `sessionStorage` when in cookie mode. Sign-in/refresh responses still legitimately carry a `token`/`refreshToken` from the backend during the dual-ship window, but the provider scrubs both to empty before the session is stored, dispatched, or broadcast, so a real token is never persisted or sent to a peer tab. | +| Call sites (`nav.tsx`, `app/**/page.tsx`) | **Unchanged.** `getApi(address, authSession?.token, communitySlug)` keeps its exact signature everywhere — in cookie mode `authSession.token` is simply `''`, which `authHeaders()` already ignores. This was a deliberate scope decision to avoid a 20-file diff for this PR; see "Known gaps" below for the follow-up once the backend endpoint ships. | + +**Known gaps in this dual-mode-readiness pass** (tracked for the actual +Phase 2 cutover once `guildpass-core` ships `GET /v1/auth/session`): + +- Cookie mode's silent refresh does **not** use the same Web-Locks-based + cross-tab mutual exclusion bearer mode has + (`lib/wallet/refresh-coordination.ts`) — it performs its own + `siweRefresh()` call directly. Acceptable for now per the "tab sync + becomes less critical" note below; two tabs racing a refresh at the exact + same moment could redundantly both call the backend. Bearer mode's + coordination is untouched. +- `getSessionStatus()` is unimplemented on the real `guildpass-core` + backend — only the mock simulates it. + +### Phase 2 — Switch to cookie (remaining work once the backend ships) + +With the dual-mode plumbing above already in place, the remaining Phase 2 +work is: `guildpass-core` implements `GET /v1/auth/session` and starts +setting the `gp_session` cookie; the frontend's default flips from `bearer` +to `cookie` (or ops sets `NEXT_PUBLIC_AUTH_MODE=cookie` per-environment); +and the cross-tab refresh coordination gap above gets closed. ### Phase 3 — Cleanup Remove backward-compat `token` fields from the response body (backend) and -remove the dead code paths in the frontend. +remove the dead code paths in the frontend (the bearer-mode branches in +`lib/session.ts` / `lib/wallet/providers.tsx` / `lib/api/live.ts` once +`bearer` mode is no longer supported). --- diff --git a/lib/api/live.ts b/lib/api/live.ts index 3edcdc6..7edcb8b 100644 --- a/lib/api/live.ts +++ b/lib/api/live.ts @@ -17,6 +17,8 @@ import { ResourceLookupResult, Role, Session, + SessionStatus, + SessionStatusSchema, SiweAuthSession, WalletVerification, BackendSession, @@ -518,6 +520,12 @@ async function getJson(path: string, options: RequestOptions = {}): Promise = { ...(extra as Record ?? {}) } - if (this.token) { + // Cookie mode relies exclusively on the httpOnly session cookie (sent + // automatically via `credentials: 'include'` in getJson()) — never send + // Authorization here, even if a token happens to be held in memory. + if (config.authMode !== 'cookie' && this.token) { headers['Authorization'] = `Bearer ${this.token}` } if (this.communityId) { @@ -1116,15 +1127,48 @@ export class LiveAccessApi implements AccessApi { return { isAuthenticated: true, ...data } } - async siweLogout(token: string): Promise { + async siweLogout(token?: string): Promise { + const headers: Record = {} + // Guard against a falsy token producing "Bearer undefined"/"Bearer null"/ + // "Bearer " — and never send Authorization at all in cookie mode, where + // the session cookie (sent via credentials: 'include') identifies the + // caller instead. + if (config.authMode !== 'cookie' && token) { + headers['Authorization'] = `Bearer ${token}` + } await getJson('/v1/auth/siwe/logout', { method: 'POST', - headers: { Authorization: `Bearer ${token}` }, + headers, }).catch(() => { // best-effort logout }) } + /** + * Lightweight session-status check for httpOnly-cookie auth mode. Never + * sends or receives a bearer token — the cookie is transparent to this + * request (`credentials: 'include'` in getJson()). + * + * "No session" (401/404) resolves to `{ authenticated: false }` rather + * than throwing, so callers can treat it as a steady state. Network/5xx + * failures propagate so a caller can distinguish "definitely signed out" + * from "backend unreachable." + */ + async getSessionStatus(signal?: AbortSignal): Promise { + try { + return await getJson('/v1/auth/session', { + method: 'GET', + schema: SessionStatusSchema, + signal, + }) + } catch (err) { + if (err instanceof ApiError && err.code === 'aborted') throw err + if (err instanceof AuthError) return { authenticated: false } + if (err instanceof ApiError && err.status === 404) return { authenticated: false } + throw err + } + } + // ── Social Graph (Connections / Blocks) ── async getConnections(address: string, signal?: AbortSignal): Promise { const path = `/v1/members/${encodeURIComponent(address)}/connections` diff --git a/lib/api/mock.ts b/lib/api/mock.ts index 913cda1..2e272b2 100644 --- a/lib/api/mock.ts +++ b/lib/api/mock.ts @@ -41,6 +41,7 @@ import { ResourceLookupResult, Role, Session, + SessionStatus, SiweAuthSession, WalletVerification, WebhookEventLog, @@ -65,6 +66,7 @@ import { clearPersistedState, LS_KEY, } from './mock-storage' +import { config } from '../config' /** Read once at module load so it is stable across renders. */ const MOCK_SESSION_STATE = @@ -72,6 +74,41 @@ const MOCK_SESSION_STATE = process.env.NEXT_PUBLIC_MOCK_SESSION_STATE) || '' +// ── Mock cookie-session simulation (cookie auth mode) ─────────────────────── +// +// There is no real backend in mock mode, so a real httpOnly cookie can't be +// set. This uses a plain, non-httpOnly document.cookie entry to simulate +// "the browser is holding a session cookie" — an honest simulation boundary +// (mock JS genuinely cannot set an httpOnly cookie either). It intentionally +// never touches sessionStorage, so cookie-mode session state is provably +// independent of the bearer-token sessionStorage path in lib/session.ts. +// Only ever written/read when config.authMode === 'cookie', so bearer-mode +// mock runs get zero new side effects. + +const MOCK_SESSION_COOKIE = 'gp_mock_session' + +function setMockSessionCookie(address: string, expiresAt: string): void { + if (typeof document === 'undefined') return + const value = encodeURIComponent(`${address}|${expiresAt}`) + document.cookie = `${MOCK_SESSION_COOKIE}=${value}; path=/; SameSite=Lax` +} + +function clearMockSessionCookie(): void { + if (typeof document === 'undefined') return + document.cookie = `${MOCK_SESSION_COOKIE}=; path=/; Max-Age=0; SameSite=Lax` +} + +function readMockSessionCookie(): { address: string; expiresAt: string } | null { + if (typeof document === 'undefined') return null + const row = document.cookie + .split('; ') + .find((entry) => entry.startsWith(`${MOCK_SESSION_COOKIE}=`)) + if (!row) return null + const raw = decodeURIComponent(row.slice(MOCK_SESSION_COOKIE.length + 1)) + const [address, expiresAt] = raw.split('|') + return address && expiresAt ? { address, expiresAt } : null +} + const DEFAULT_COMMUNITY: Community = { id: 'guildpass-demo', name: 'GuildPass Demo Community', @@ -1424,11 +1461,16 @@ export class MockAccessApi implements AccessApi { : new Date(Date.now() + 60 * 60 * 1000).toISOString() const refreshExpiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString() + const resolvedAddress = this.address ?? '0x0000000000000000000000000000000000000000' + + if (config.authMode === 'cookie') { + setMockSessionCookie(resolvedAddress, expiresAt) + } return { isAuthenticated: true, token: `mock-jwt-${randomHex()}`, - address: this.address ?? '0x0000000000000000000000000000000000000000', + address: resolvedAddress, expiresAt, refreshToken: `mock-refresh-${randomHex()}`, refreshExpiresAt, @@ -1452,7 +1494,17 @@ export class MockAccessApi implements AccessApi { }) } - if (!refreshToken || !refreshToken.startsWith('mock-refresh-')) { + if (config.authMode === 'cookie') { + // Cookie mode has no refresh-token string for the frontend to hold — + // the (mock) session cookie is the only refreshability signal. + if (!readMockSessionCookie()) { + throw new ApiError({ + status: 401, + code: 'unauthorized', + safeMessage: 'Invalid refresh token.', + }) + } + } else if (!refreshToken || !refreshToken.startsWith('mock-refresh-')) { throw new ApiError({ status: 401, code: 'unauthorized', @@ -1462,19 +1514,48 @@ export class MockAccessApi implements AccessApi { const expiresAt = new Date(Date.now() + 60 * 60 * 1000).toISOString() const refreshExpiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString() + const resolvedAddress = this.address ?? '0x0000000000000000000000000000000000000000' + + if (config.authMode === 'cookie') { + setMockSessionCookie(resolvedAddress, expiresAt) + } return { isAuthenticated: true, token: `mock-jwt-${randomHex()}`, - address: this.address ?? '0x0000000000000000000000000000000000000000', + address: resolvedAddress, expiresAt, refreshToken: `mock-refresh-${randomHex()}`, refreshExpiresAt, } } - async siweLogout(_token: string): Promise { + async siweLogout(_token?: string): Promise { await initPromise + if (config.authMode === 'cookie') { + clearMockSessionCookie() + } + } + + /** + * Mock counterpart to LiveAccessApi.getSessionStatus(). Reads only the + * simulated document.cookie session marker set by siweVerify/siweRefresh — + * never sessionStorage — so cookie-mode session state stays deterministic + * and independent of the bearer-token sessionStorage path. + */ + async getSessionStatus(_signal?: AbortSignal): Promise { + await initPromise + if (MOCK_SESSION_STATE === 'unauthenticated') { + return { authenticated: false } + } + const cookie = readMockSessionCookie() + if (!cookie) { + return { authenticated: false } + } + if (MOCK_SESSION_STATE === 'expired' || new Date(cookie.expiresAt).getTime() <= Date.now()) { + return { authenticated: false } + } + return { authenticated: true, address: cookie.address, expiresAt: cookie.expiresAt } } async verifyWallet(_address: string, _signal?: AbortSignal): Promise { diff --git a/lib/api/types.ts b/lib/api/types.ts index ed105c2..d63f1b3 100644 --- a/lib/api/types.ts +++ b/lib/api/types.ts @@ -692,6 +692,27 @@ export interface AdminAccessApi { updateReportState(id: string, state: ModerationState, updates?: Partial): Promise } +/** + * Lightweight session-status check for httpOnly-cookie auth mode (dual-mode + * readiness — see docs/http-only-cookie-migration.md). Never carries a + * bearer token; `authenticated` is the only field guaranteed present. + * + * @provisional `GET /v1/auth/session` is not yet implemented in + * guildpass-core. This contract lets the frontend cookie-mode path be built + * and tested against the mock ahead of the backend endpoint shipping. + */ +export interface SessionStatus { + authenticated: boolean + address?: string + expiresAt?: string +} + +export const SessionStatusSchema = z.object({ + authenticated: z.boolean(), + address: z.string().optional(), + expiresAt: z.string().optional(), +}) + /** * SIWE authentication endpoints. */ @@ -714,9 +735,23 @@ export interface SiweAuthApi { * signalling that the user must re-sign with their wallet. */ siweRefresh(refreshToken: string): Promise - /** Invalidate the current server-side session (no-op for stateless JWTs). */ - siweLogout(token: string): Promise + /** + * Invalidate the current server-side session (no-op for stateless JWTs). + * `token` is optional so cookie-auth-mode callers — which never hold a + * bearer token — can invoke this with no argument; the session cookie + * identifies the caller instead. + */ + siweLogout(token?: string): Promise verifyWallet(address: string): Promise + /** + * Lightweight session-status check for httpOnly-cookie auth mode. Returns + * `{ authenticated: false }` for "no session" (never throws for that + * case); network/server errors propagate so the caller can distinguish an + * unreachable backend from a genuinely absent session. + * + * @provisional See {@link SessionStatus}. + */ + getSessionStatus(signal?: AbortSignal): Promise } /** diff --git a/lib/config-validation.d.ts b/lib/config-validation.d.ts index 882d685..38506a4 100644 --- a/lib/config-validation.d.ts +++ b/lib/config-validation.d.ts @@ -2,6 +2,8 @@ export type EnvSource = Record export type ApiMode = 'mock' | 'live' +export type AuthMode = 'bearer' | 'cookie' + export interface SiweConfig { domain: string statement: string @@ -27,6 +29,7 @@ export interface IntegrationGatewayConfig { export interface AppConfig { apiMode: ApiMode + authMode: AuthMode apiUrl: string siwe: SiweConfig features: FeatureFlags @@ -49,6 +52,7 @@ export function buildFeatureFlags( export function coreApiUrlRequiredMessage(): string export function flag(source: EnvSource, varName: string, defaultVal: boolean): boolean export function parseApiModeFromEnv(source?: EnvSource): ApiMode +export function parseAuthModeFromEnv(source?: EnvSource): AuthMode export function resolveCoreApiUrl(source?: EnvSource, apiMode?: ApiMode): string export function resolveWarningThresholdSeconds(source?: EnvSource): number export function validateSiweStatement(value: string): string diff --git a/lib/config-validation.js b/lib/config-validation.js index 591986f..0066f88 100644 --- a/lib/config-validation.js +++ b/lib/config-validation.js @@ -18,6 +18,15 @@ function parseApiModeFromEnv(source = process.env) { return mock === 'true' || demo === 'true' ? 'mock' : 'live' } +/** + * 'cookie' only when explicitly requested; any other value (including unset + * or a typo) falls back to 'bearer' so the frontend never silently drops + * bearer-token auth in a misconfigured environment. + */ +function parseAuthModeFromEnv(source = process.env) { + return env(source, 'NEXT_PUBLIC_AUTH_MODE') === 'cookie' ? 'cookie' : 'bearer' +} + function coreApiUrlRequiredMessage() { return [ 'NEXT_PUBLIC_CORE_API_URL is required when API mode is "live".', @@ -114,6 +123,7 @@ function buildFeatureFlags(source = process.env, apiMode = parseApiModeFromEnv(s function buildAppConfig(source = process.env) { const apiMode = parseApiModeFromEnv(source) + const authMode = parseAuthModeFromEnv(source) const siwe = Object.freeze({ domain: env(source, 'NEXT_PUBLIC_SIWE_DOMAIN') ?? 'localhost:3000', statement: validateSiweStatement( @@ -124,6 +134,7 @@ function buildAppConfig(source = process.env) { return Object.freeze({ apiMode, + authMode, apiUrl: resolveCoreApiUrl(source, apiMode), siwe, features: Object.freeze(buildFeatureFlags(source, apiMode)), @@ -145,6 +156,7 @@ module.exports = { coreApiUrlRequiredMessage, flag, parseApiModeFromEnv, + parseAuthModeFromEnv, resolveCoreApiUrl, resolveWarningThresholdSeconds, validateSiweStatement, diff --git a/lib/config.ts b/lib/config.ts index 000b19a..bc3ace2 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -19,6 +19,15 @@ import { export type ApiMode = 'mock' | 'live' +/** + * 'bearer' (default) — sessionStorage-persisted token, Authorization header + * on every authenticated request. + * 'cookie' — httpOnly-cookie session (dual-mode readiness for the migration + * in docs/http-only-cookie-migration.md). No bearer token is ever read from + * or written to sessionStorage, and no Authorization header is sent. + */ +export type AuthMode = 'bearer' | 'cookie' + export interface SiweConfig { domain: string statement: string @@ -46,6 +55,8 @@ export interface IntegrationGatewayConfig { export interface AppConfig { /** 'mock' when NEXT_PUBLIC_MOCK_MODE or NEXT_PUBLIC_DEMO_MODE is 'true', otherwise 'live' */ apiMode: ApiMode + /** 'cookie' when NEXT_PUBLIC_AUTH_MODE is exactly 'cookie', otherwise 'bearer' (default). */ + authMode: AuthMode /** * Base URL for the guildpass-core API. * - In mock mode: defaults to 'http://localhost:4000' if unset. diff --git a/lib/session.ts b/lib/session.ts index d622ae2..8f21798 100644 --- a/lib/session.ts +++ b/lib/session.ts @@ -22,16 +22,34 @@ * or silent refresh occurs in any tab, the provider broadcasts the updated * session; peer tabs write the received session via `storeAuthSession()` so * every tab eventually converges on the same state. + * + * Cookie auth mode (dual-mode readiness) + * ─────────────────────────────────────── + * When `config.authMode === 'cookie'` (see docs/http-only-cookie-migration.md), + * this module's persistence functions become no-ops *before* touching + * `sessionStorage` at all: `storeAuthSession()` never writes, and + * `loadAuthSessionIncludingExpired()` (and therefore `loadAuthSession()`, + * `getStoredToken()`, `getStoredAddress()`, all of which call through it) + * never reads. No bearer token is ever persisted client-side in this mode. + * `isSessionActive()` — a network call, not a storage read — replaces the + * client-side expiry check for cookie mode. */ import type { SiweAuthSession } from './api/types' +import { config } from './config' +import { getApi } from './api' export const SESSION_KEY = 'guildpass:siwe-session' // ── Persist ─────────────────────────────────────────────────────────────────── -/** Persist an authenticated session (including any refresh token) to sessionStorage. */ +/** + * Persist an authenticated session (including any refresh token) to + * sessionStorage. No-op in cookie auth mode — no bearer token is ever + * written to sessionStorage in that mode. + */ export function storeAuthSession(session: SiweAuthSession): void { + if (config.authMode === 'cookie') return if (typeof window === 'undefined') return try { window.sessionStorage.setItem(SESSION_KEY, JSON.stringify(session)) @@ -70,8 +88,12 @@ export function loadAuthSession(): SiweAuthSession | null { * Load the raw stored session without any expiry filtering. * Useful when you need to retrieve the refresh token even after the access * token has expired. + * + * Always returns `null` in cookie auth mode without touching + * `sessionStorage` — no bearer token is ever read from storage in that mode. */ export function loadAuthSessionIncludingExpired(): SiweAuthSession | null { + if (config.authMode === 'cookie') return null if (typeof window === 'undefined') return null try { const raw = window.sessionStorage.getItem(SESSION_KEY) @@ -121,9 +143,23 @@ export function subscribeToAuthSessionStorage( // ── Clear ───────────────────────────────────────────────────────────────────── -/** Remove the stored session from sessionStorage. */ +/** + * Remove the stored session from sessionStorage and notify same-tab + * listeners. In cookie auth mode, sessionStorage is never touched (there is + * nothing to remove there) — only the invalidation event fires, so + * providers.tsx's markExpired handling keeps working identically in both + * modes. + */ export function clearAuthSession(): void { if (typeof window === 'undefined') return + if (config.authMode === 'cookie') { + try { + window.dispatchEvent(new CustomEvent('siwe:invalidated')) + } catch { + // Ignore environments that disallow CustomEvent + } + return + } try { window.sessionStorage.removeItem(SESSION_KEY) try { @@ -247,3 +283,22 @@ export function formatTimeRemaining(seconds: number): string { return `${mins}m ${secs}s` } +// ── Cookie auth mode: session-status check ────────────────────────────────── + +/** + * Checks whether the browser currently holds a valid session by calling the + * approved session-status endpoint (`GET /v1/auth/session` — see + * docs/http-only-cookie-migration.md) rather than reading a client-side + * token. This is the cookie-mode replacement for the optimistic client-side + * expiry check bearer mode does via `isAccessTokenExpired()`. + * + * Network/server errors are NOT swallowed as `false` — they propagate so the + * caller can distinguish an unreachable backend from a genuinely absent + * session (treating an outage as "signed out" would incorrectly sign a user + * out during a transient failure). + */ +export async function isSessionActive(): Promise { + const status = await getApi().getSessionStatus() + return status.authenticated +} + diff --git a/lib/wallet/providers.tsx b/lib/wallet/providers.tsx index fb69924..871f0f4 100644 --- a/lib/wallet/providers.tsx +++ b/lib/wallet/providers.tsx @@ -51,6 +51,21 @@ * lib/session.ts remains the single source of truth for the persisted token. * BroadcastChannel is used only for propagation; each tab writes its own * sessionStorage entry independently. + * + * Cookie auth mode (dual-mode readiness) + * ───────────────────────────────────── + * When `config.authMode === 'cookie'` (see docs/http-only-cookie-migration.md), + * this provider never hydrates from sessionStorage — it calls + * `getApi().getSessionStatus()` on mount instead. Sign-in/refresh responses + * still carry a `token`/`refreshToken` (the backend keeps returning them + * during the dual-ship window), but this provider scrubs both to empty + * before the session is stored, dispatched to broadcast, or held as local + * state, so no real token is ever persisted or sent to a peer tab. The + * existing Web-Locks-based cross-tab refresh coordination + * (lib/wallet/refresh-coordination.ts) is bearer-mode-only in this PR; cookie + * mode performs its own refresh call without that mutual-exclusion layer — + * acceptable for now per docs/http-only-cookie-migration.md's own note that + * tab sync "becomes less critical" once the cookie jar is shared across tabs. */ import { @@ -100,6 +115,7 @@ import { buildSiweMessage, deriveSessionStatus, initialSiweSessionState, + isValidBroadcastSession, siweSessionReducer, } from "@/lib/wallet/siwe-session"; import { SiweAuthContext } from "@/lib/wallet/siwe-context"; @@ -262,6 +278,56 @@ export function SiweAuthProvider({ children }: { children: React.ReactNode }) { const performSilentRefresh = useCallback( async (session: SiweAuthSession) => { if (isRefreshing.current) return; + + // ── Cookie mode ─────────────────────────────────────────────────────── + // No refresh-token string is held locally (scrubbed at sign-in/refresh + // time — see signIn() and the bearer branch below). Renewability is + // judged from `refreshExpiresAt` alone (a non-secret timestamp); when + // absent (e.g. a session reconstructed from getSessionStatus() at + // mount, which never returns refresh data) renewal is attempted + // optimistically and the backend's response is authoritative. + if (config.authMode === "cookie") { + const stillRenewable = + !session.refreshExpiresAt || + new Date(session.refreshExpiresAt).getTime() > Date.now(); + if (!stillRenewable) { + dispatch({ type: "mark-expired" }); + broadcast({ type: "signed-out" }); + return; + } + + isRefreshing.current = true; + try { + const api = getApi(session.address); + const refreshed = await api.siweRefresh(""); + const settled: SiweAuthSession = { + ...refreshed, + token: "", + refreshToken: undefined, + }; + dispatch({ type: "refresh-success", session: settled }); + scheduleRenewal(settled); + broadcast({ type: "refreshed", session: settled }); + + const retries = pendingRetriesRef.current; + pendingRetriesRef.current = []; + for (const entry of retries) { + try { + await entry.callback(settled); + } catch (retryErr) { + entry.onRetryFailure?.(retryErr); + } + } + } catch { + dispatch({ type: "mark-expired" }); + broadcast({ type: "signed-out" }); + } finally { + isRefreshing.current = false; + } + return; + } + + // ── Bearer mode (unchanged) ────────────────────────────────────────── if (!session.refreshToken || isRefreshTokenExpired(session)) { dispatch({ type: "mark-expired" }); clearAuthSession(); @@ -335,6 +401,28 @@ export function SiweAuthProvider({ children }: { children: React.ReactNode }) { const scheduleRenewal = useCallback( (session: SiweAuthSession) => { cancelRenewal(); + + if (config.authMode === "cookie") { + const stillRenewable = + !session.refreshExpiresAt || + new Date(session.refreshExpiresAt).getTime() > Date.now(); + if (!stillRenewable) return; + + const delay = msUntilRenewal(session, 60_000); + if (delay <= 0) { + void performSilentRefresh(session); + return; + } + + renewalTimer.current = setTimeout(() => { + // Nothing is persisted to re-read in cookie mode — the closed-over + // session is the freshest local knowledge; the backend call itself + // is authoritative regardless. + void performSilentRefresh(session); + }, delay); + return; + } + if (isRefreshTokenExpired(session)) return; // no renewal possible const delay = msUntilRenewal(session, 60_000); @@ -353,9 +441,43 @@ export function SiweAuthProvider({ children }: { children: React.ReactNode }) { [cancelRenewal, performSilentRefresh], ); - // ── Hydrate from sessionStorage on mount ─────────────────────────────────── + // ── Hydrate on mount ───────────────────────────────────────────────────── useEffect(() => { + if (config.authMode === "cookie") { + // No sessionStorage to hydrate from — ask the backend whether the + // browser currently holds a valid session cookie. + let cancelled = false; + void (async () => { + try { + const status = await getApi().getSessionStatus(); + if ( + cancelled || + !status.authenticated || + !status.address || + !status.expiresAt + ) { + return; + } + const session: SiweAuthSession = { + isAuthenticated: true, + token: "", + address: status.address, + expiresAt: status.expiresAt, + }; + dispatch({ type: "restore", session }); + scheduleRenewal(session); + } catch { + // Backend unreachable or check failed — leave state unauthenticated; + // the user can retry via signIn(). Do not treat this the same as a + // confirmed "no session" response. + } + })(); + return () => { + cancelled = true; + }; + } + const stored = loadAuthSession(); if (stored) { dispatch({ type: "restore", session: stored }); @@ -383,14 +505,7 @@ export function SiweAuthProvider({ children }: { children: React.ReactNode }) { dispatch({ type: "clear" }); return; } - if ( - typeof session.token !== "string" || - !session.token.trim() || - typeof session.address !== "string" || - !session.address.trim() || - typeof session.expiresAt !== "string" || - !session.expiresAt.trim() - ) { + if (!isValidBroadcastSession(session, config.authMode)) { return; } // If a wallet is currently connected in this tab, discard sessions for other addresses @@ -635,10 +750,20 @@ export function SiweAuthProvider({ children }: { children: React.ReactNode }) { const signature = await signMessageAsync({ message }); const session = await api.siweVerify(message, signature); - storeAuthSession(session); - dispatch({ type: "sign-in-success", session }); - scheduleRenewal(session); - broadcast({ type: "signed-in", session }); + // Cookie mode: the backend response still legitimately carries a + // token/refreshToken during the dual-ship window, but this frontend + // never persists, broadcasts, or holds one — the session cookie (set + // by the backend alongside this same response) is the actual + // credential. Scrubbing here means the token never reaches + // storeAuthSession(), the reducer, or a peer tab. + const settledSession: SiweAuthSession = + config.authMode === "cookie" + ? { ...session, token: "", refreshToken: undefined } + : session; + storeAuthSession(settledSession); + dispatch({ type: "sign-in-success", session: settledSession }); + scheduleRenewal(settledSession); + broadcast({ type: "signed-in", session: settledSession }); // Drain any pending retry callbacks registered before re-auth. // We take the entire queue atomically so a second 401 inside a callback @@ -647,7 +772,7 @@ export function SiweAuthProvider({ children }: { children: React.ReactNode }) { pendingRetriesRef.current = []; for (const entry of retries) { try { - await entry.callback(session); + await entry.callback(settledSession); } catch (retryErr) { // The retried call failed — invoke the registered failure handler // rather than silently swallowing the error. @@ -673,23 +798,45 @@ export function SiweAuthProvider({ children }: { children: React.ReactNode }) { const logout = useCallback(async () => { cancelRenewal(); const token = getStoredToken(); + const hadCookieSession = + config.authMode === "cookie" && state.authSession !== null; clearAuthSession(); dispatch({ type: "clear" }); broadcast({ type: "signed-out" }); disconnect(); - if (token) { + if (hadCookieSession) { + // No token to send — the cookie identifies the session to invalidate. + await getApi(address) + .siweLogout() + .catch(() => { + // best-effort server-side invalidation + }); + return; + } + if (config.authMode !== "cookie" && token) { await getApi(address) .siweLogout(token) .catch(() => { // best-effort server-side invalidation }); } - }, [address, cancelRenewal, broadcast, disconnect]); + }, [address, cancelRenewal, broadcast, disconnect, state.authSession]); // ── markExpired ───────────────────────────────────────────────────────────── const markExpired = useCallback(() => { cancelRenewal(); + + if (config.authMode === "cookie") { + if (state.authSession) { + void performSilentRefresh(state.authSession); + return; + } + dispatch({ type: "mark-expired" }); + broadcast({ type: "signed-out" }); + return; + } + // Attempt a silent refresh if a refresh token is still available const raw = loadAuthSessionIncludingExpired(); if (raw && !isRefreshTokenExpired(raw)) { @@ -699,7 +846,7 @@ export function SiweAuthProvider({ children }: { children: React.ReactNode }) { dispatch({ type: "mark-expired" }); broadcast({ type: "signed-out" }); } - }, [cancelRenewal, performSilentRefresh, broadcast]); + }, [cancelRenewal, performSilentRefresh, broadcast, state.authSession]); // ── Derived values ────────────────────────────────────────────────────────── diff --git a/lib/wallet/siwe-session.ts b/lib/wallet/siwe-session.ts index 5978923..1fd8c80 100644 --- a/lib/wallet/siwe-session.ts +++ b/lib/wallet/siwe-session.ts @@ -11,6 +11,7 @@ */ import type { AdminSessionStatus, SiweAuthSession } from '../api/types' +import type { AuthMode } from '../config' export interface SiweSessionState { /** The authenticated session, or null if none is held. */ @@ -119,3 +120,31 @@ export function buildSiweMessage(fields: SiweMessageFields): string { `Issued At: ${fields.issuedAt}`, ].join('\n') } + +/** + * Validates a session payload received via BroadcastChannel (or the + * storage-event fallback) before it is applied to local state. + * + * Bearer mode requires a non-empty `token` — identical to the inline check + * providers.tsx used before this was extracted. Cookie mode never broadcasts + * a real token (see providers.tsx's session-scrubbing at the sign-in/refresh + * boundary), so the token check is skipped there; `address`/`expiresAt` are + * still required in both modes since a payload missing those is malformed + * regardless of auth mode. + */ +export function isValidBroadcastSession( + session: Pick | null | undefined, + authMode: AuthMode, +): boolean { + if (!session) return false + const tokenOk = + authMode === 'cookie' || + (typeof session.token === 'string' && session.token.trim().length > 0) + return ( + tokenOk && + typeof session.address === 'string' && + session.address.trim().length > 0 && + typeof session.expiresAt === 'string' && + session.expiresAt.trim().length > 0 + ) +} diff --git a/scripts/sync-api-types.js b/scripts/sync-api-types.js index e9e4afd..5ee386d 100644 --- a/scripts/sync-api-types.js +++ b/scripts/sync-api-types.js @@ -339,6 +339,27 @@ export interface AdminAccessApi { updateReportState(id: string, state: ModerationState, updates?: Partial): Promise } +/** + * Lightweight session-status check for httpOnly-cookie auth mode (dual-mode + * readiness — see docs/http-only-cookie-migration.md). Never carries a + * bearer token; \`authenticated\` is the only field guaranteed present. + * + * @provisional \`GET /v1/auth/session\` is not yet implemented in + * guildpass-core. This contract lets the frontend cookie-mode path be built + * and tested against the mock ahead of the backend endpoint shipping. + */ +export interface SessionStatus { + authenticated: boolean + address?: string + expiresAt?: string +} + +export const SessionStatusSchema = z.object({ + authenticated: z.boolean(), + address: z.string().optional(), + expiresAt: z.string().optional(), +}) + /** * SIWE authentication endpoints. */ @@ -361,9 +382,23 @@ export interface SiweAuthApi { * signalling that the user must re-sign with their wallet. */ siweRefresh(refreshToken: string): Promise - /** Invalidate the current server-side session (no-op for stateless JWTs). */ - siweLogout(token: string): Promise + /** + * Invalidate the current server-side session (no-op for stateless JWTs). + * \`token\` is optional so cookie-auth-mode callers — which never hold a + * bearer token — can invoke this with no argument; the session cookie + * identifies the caller instead. + */ + siweLogout(token?: string): Promise verifyWallet(address: string): Promise + /** + * Lightweight session-status check for httpOnly-cookie auth mode. Returns + * \`{ authenticated: false }\` for "no session" (never throws for that + * case); network/server errors propagate so the caller can distinguish an + * unreachable backend from a genuinely absent session. + * + * @provisional See {@link SessionStatus}. + */ + getSessionStatus(signal?: AbortSignal): Promise } /** diff --git a/test/api-version.test.ts b/test/api-version.test.ts index fa15ba1..0f5d238 100644 --- a/test/api-version.test.ts +++ b/test/api-version.test.ts @@ -6,6 +6,7 @@ * - EXPECTED_API_VERSION constant presence */ +import './setup-env' import assert from 'node:assert' import { describe, it, before, after } from 'node:test' import { checkVersionCompatibility } from '../lib/api/version' diff --git a/test/config-auth-mode.test.ts b/test/config-auth-mode.test.ts new file mode 100644 index 0000000..964956f --- /dev/null +++ b/test/config-auth-mode.test.ts @@ -0,0 +1,62 @@ +import './setup-env' +import { describe, test } from 'node:test' +import * as assert from 'node:assert/strict' +import { buildAppConfig } from '../lib/config' + +/** + * Focused unit tests for NEXT_PUBLIC_AUTH_MODE parsing (dual-mode readiness + * for the httpOnly-cookie SIWE migration — see + * docs/http-only-cookie-migration.md). + * + * `buildAppConfig` takes an explicit EnvSource, so these tests call it + * directly with synthetic env objects rather than mutating process.env / + * clearing the require cache. + */ + +const MOCK_BASE = { NEXT_PUBLIC_MOCK_MODE: 'true' } + +describe('NEXT_PUBLIC_AUTH_MODE (#dual-mode-cookie-readiness)', () => { + test('defaults to bearer when unset', () => { + const config = buildAppConfig({ ...MOCK_BASE }) + assert.equal(config.authMode, 'bearer') + }) + + test('defaults to bearer when the var is an empty string', () => { + const config = buildAppConfig({ ...MOCK_BASE, NEXT_PUBLIC_AUTH_MODE: '' }) + assert.equal(config.authMode, 'bearer') + }) + + test('is cookie only for the exact literal "cookie"', () => { + const config = buildAppConfig({ ...MOCK_BASE, NEXT_PUBLIC_AUTH_MODE: 'cookie' }) + assert.equal(config.authMode, 'cookie') + }) + + test('falls back to bearer for any other value — never throws, never silently becomes cookie', () => { + for (const value of ['COOKIE', 'Cookie', ' cookie', 'cookie ', 'bearer-ish', 'true', '1']) { + const config = buildAppConfig({ ...MOCK_BASE, NEXT_PUBLIC_AUTH_MODE: value }) + assert.equal(config.authMode, 'bearer', `expected bearer fallback for ${JSON.stringify(value)}`) + } + }) + + test('is independent of apiMode — cookie auth mode works in both mock and live api mode', () => { + const mockCookie = buildAppConfig({ + NEXT_PUBLIC_MOCK_MODE: 'true', + NEXT_PUBLIC_AUTH_MODE: 'cookie', + }) + assert.equal(mockCookie.apiMode, 'mock') + assert.equal(mockCookie.authMode, 'cookie') + + const liveCookie = buildAppConfig({ + NEXT_PUBLIC_CORE_API_URL: 'http://localhost:4000', + NEXT_PUBLIC_AUTH_MODE: 'cookie', + }) + assert.equal(liveCookie.apiMode, 'live') + assert.equal(liveCookie.authMode, 'cookie') + }) + + test('the frozen config object exposes authMode as a top-level field', () => { + const config = buildAppConfig({ ...MOCK_BASE, NEXT_PUBLIC_AUTH_MODE: 'cookie' }) + assert.ok(Object.isFrozen(config)) + assert.equal(config.authMode, 'cookie') + }) +}) diff --git a/test/e2e/helpers.ts b/test/e2e/helpers.ts index 3f7b02b..8c64f73 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`) } +/** + * Reads the raw SIWE session entry from sessionStorage (guildpass:siwe-session). + * Returns null if nothing is stored — the expected state at every point in a + * cookie-auth-mode flow (see docs/http-only-cookie-migration.md). Used by + * siwe-flow-cookie-mode.spec.ts to prove cookie mode never writes a bearer + * token there. + */ +export async function getSessionStorageEntry(page: Page): Promise { + return page.evaluate(() => window.sessionStorage.getItem('guildpass:siwe-session')) +} + +/** + * Reads the mock cookie-session marker set by lib/api/mock.ts's cookie-mode + * simulation (`gp_mock_session`). This mock cookie is intentionally + * non-httpOnly — mock JS cannot set a real httpOnly cookie either — so e2e + * tests can assert cookie-mode session persistence (survives reload, absent + * after logout) without ever reading sessionStorage. + */ +export async function getMockSessionCookie(page: Page): Promise { + return page.evaluate(() => { + const row = document.cookie.split('; ').find((entry) => entry.startsWith('gp_mock_session=')) + return row ? row.slice('gp_mock_session='.length) : null + }) +} + /** * Directly seed sessionStorage with a SIWE auth session before the app * mounts (via addInitScript), bypassing the wallet-signature UI flow. diff --git a/test/e2e/siwe-flow-cookie-mode.spec.ts b/test/e2e/siwe-flow-cookie-mode.spec.ts new file mode 100644 index 0000000..72a33a0 --- /dev/null +++ b/test/e2e/siwe-flow-cookie-mode.spec.ts @@ -0,0 +1,143 @@ +/** + * test/e2e/siwe-flow-cookie-mode.spec.ts + * + * End-to-end tests for the SIWE flow in cookie auth mode (dual-mode + * readiness for the httpOnly-cookie migration — see + * docs/http-only-cookie-migration.md). Mirrors the happy-path coverage in + * siwe-flow.spec.ts (bearer mode), but every assertion here proves the + * cookie-mode-specific security invariant: sessionStorage never receives a + * bearer token, at any point in the flow. + * + * Requires the dev server to be started with NEXT_PUBLIC_AUTH_MODE=cookie + * (NEXT_PUBLIC_AUTH_MODE is a NEXT_PUBLIC_* var, inlined at server-start / + * build time — a server already running in bearer mode will NOT pick this + * up, and playwright.config.ts's webServer reuses an already-running server + * by default outside CI). Run with: + * + * NEXT_PUBLIC_AUTH_MODE=cookie NEXT_PUBLIC_MOCK_MODE=true \ + * npx playwright test test/e2e/siwe-flow-cookie-mode.spec.ts + * + * lib/api/mock.ts simulates the httpOnly cookie with a non-httpOnly + * `gp_mock_session` document.cookie entry (mock JS cannot set a real + * httpOnly cookie either) — see getMockSessionCookie() in ./helpers. + */ + +import { test, expect } from '@playwright/test' +import { + injectMockWalletConnector, + setMockSessionState, + waitForSignInButton, + waitForAuthenticatedState, + navigateToAdmin, + navigateToAdminMembers, + getSessionStorageEntry, + getMockSessionCookie, +} from './helpers' + +const BASE_URL = process.env.BASE_URL || 'http://localhost:3000' +const DEFAULT_ADDRESS = '0x1234567890abcdef1234567890abcdef12345678' + +test.describe('SIWE Sign-In Flow — cookie auth mode (E2E)', () => { + test.beforeEach(async ({ page }) => { + await page.goto(BASE_URL) + await page.evaluate(() => { + window.sessionStorage.clear() + document.cookie = 'gp_mock_session=; path=/; Max-Age=0' + }) + + await injectMockWalletConnector(page, { + address: DEFAULT_ADDRESS, + isConnected: true, + }) + await setMockSessionState(page, 'default') + }) + + test('happy path: navigate → sign in → authenticated, without ever touching sessionStorage', async ({ page }) => { + await navigateToAdmin(page, BASE_URL) + await expect(page).toHaveTitle(/GuildPass/) + + // sessionStorage must be empty before sign-in. + expect(await getSessionStorageEntry(page)).toBeNull() + + const signInVisible = await waitForSignInButton(page, 5000) + if (signInVisible) { + await page.locator('button:has-text("Sign In")').first().click() + } + + const authenticated = await waitForAuthenticatedState(page, 10000) + expect(authenticated).toBe(true) + + // The mock httpOnly-cookie simulation should now hold the session. + const cookie = await getMockSessionCookie(page) + expect(cookie).toBeTruthy() + expect(decodeURIComponent(cookie ?? '')).toContain(DEFAULT_ADDRESS) + + // sessionStorage must STILL be empty after a successful sign-in — this + // is the core cookie-mode invariant. + expect(await getSessionStorageEntry(page)).toBeNull() + }) + + test('sessionStorage stays empty across sign-in, navigation, and reload', async ({ page }) => { + await navigateToAdmin(page, BASE_URL) + const signInVisible = await waitForSignInButton(page, 5000) + if (signInVisible) { + await page.locator('button:has-text("Sign In")').first().click() + } + await waitForAuthenticatedState(page, 10000) + expect(await getSessionStorageEntry(page)).toBeNull() + + await navigateToAdminMembers(page, BASE_URL) + await page.waitForLoadState('networkidle') + expect(await getSessionStorageEntry(page)).toBeNull() + + await page.reload() + await page.waitForLoadState('networkidle') + expect(await getSessionStorageEntry(page)).toBeNull() + }) + + test('reload preserves the authenticated session via the session-status check (no sessionStorage involved)', async ({ page }) => { + await navigateToAdmin(page, BASE_URL) + const signInVisible = await waitForSignInButton(page, 5000) + if (signInVisible) { + await page.locator('button:has-text("Sign In")').first().click() + } + await waitForAuthenticatedState(page, 10000) + const cookieBeforeReload = await getMockSessionCookie(page) + expect(cookieBeforeReload).toBeTruthy() + + await page.reload() + await page.waitForLoadState('networkidle') + + // The mock cookie survived the reload (real browser cookie semantics — + // unlike sessionStorage-based hydration, this isn't a client re-read of + // a client-written value; it's the same check a real page load would do + // against the backend's session-status endpoint). + const cookieAfterReload = await getMockSessionCookie(page) + expect(cookieAfterReload).toBeTruthy() + + const stillAuthenticated = await waitForAuthenticatedState(page, 10000) + expect(stillAuthenticated).toBe(true) + expect(await getSessionStorageEntry(page)).toBeNull() + }) + + test('logout clears the mock cookie and sessionStorage remains empty throughout', async ({ page }) => { + await navigateToAdmin(page, BASE_URL) + const signInVisible = await waitForSignInButton(page, 5000) + if (signInVisible) { + await page.locator('button:has-text("Sign In")').first().click() + } + await waitForAuthenticatedState(page, 10000) + expect(await getMockSessionCookie(page)).toBeTruthy() + + const logoutButton = page.locator('button:has-text("Logout"), button:has-text("Sign Out")').first() + const isVisible = await logoutButton.isVisible().catch(() => false) + + if (isVisible) { + await logoutButton.click() + await page.waitForTimeout(1000) + + expect(await getMockSessionCookie(page)).toBeNull() + expect(await getSessionStorageEntry(page)).toBeNull() + } + }) +}) diff --git a/test/live-api-cookie-mode.test.ts b/test/live-api-cookie-mode.test.ts new file mode 100644 index 0000000..822bbbc --- /dev/null +++ b/test/live-api-cookie-mode.test.ts @@ -0,0 +1,179 @@ +import './setup-env' +import { describe, test, beforeEach, afterEach } from 'node:test' +import * as assert from 'node:assert/strict' + +/** + * Focused unit tests for lib/api/live.ts's cookie-auth-mode invariants + * (dual-mode readiness — see docs/http-only-cookie-migration.md): + * + * - Authorization is never sent in cookie mode, even when a token is + * passed to the constructor or to siweLogout(). + * - credentials: 'include' is sent on every request in cookie mode (and + * omitted — not merely falsy, actually absent as a key — in bearer mode, + * so the request shape is provably unchanged there). + * - siweLogout() never produces "Bearer undefined" / "Bearer null" / + * "Bearer " in ANY mode. + * - getSessionStatus() maps 200 authenticated:true/false, 401, 404, 500, + * and network failure correctly, and never includes a token field. + * + * lib/api/live.ts reads `config.authMode` from the frozen lib/config.ts + * singleton, so each variant requires a fresh require() after setting + * NEXT_PUBLIC_AUTH_MODE (same pattern as test/session-cookie-mode.test.ts). + */ + +type CapturedCall = { url: string; init: RequestInit } + +let captured: CapturedCall[] = [] + +function stubFetch(respond: (call: CapturedCall) => Response | Promise): void { + captured = [] + ;(global as any).fetch = async (url: string, init: RequestInit = {}) => { + const call = { url, init } + captured.push(call) + return respond(call) + } +} + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }) as any +} + +function loadLiveApi(authMode: 'bearer' | 'cookie' | undefined): typeof import('../lib/api/live') { + if (authMode === undefined) { + delete process.env.NEXT_PUBLIC_AUTH_MODE + } else { + process.env.NEXT_PUBLIC_AUTH_MODE = authMode + } + // apiMode and authMode are orthogonal (see test/config-auth-mode.test.ts). + // Keep apiMode 'mock' so lib/api/backendStatus.ts's ensureOnline() takes + // its mock-mode fast path instead of issuing its own /healthz fetch — that + // extra call would otherwise also hit the stub below and pollute the call + // count this suite asserts on. LiveAccessApi itself does not read apiMode. + process.env.NEXT_PUBLIC_MOCK_MODE = 'true' + delete process.env.NEXT_PUBLIC_CORE_API_URL + delete require.cache[require.resolve('../lib/config')] + delete require.cache[require.resolve('../lib/api/live')] + return require('../lib/api/live') +} + +function headerValue(init: RequestInit, name: string): string | undefined { + const headers = init.headers as Record | undefined + if (!headers) return undefined + const key = Object.keys(headers).find((k) => k.toLowerCase() === name.toLowerCase()) + return key ? headers[key] : undefined +} + +afterEach(() => { + delete process.env.NEXT_PUBLIC_AUTH_MODE + delete process.env.NEXT_PUBLIC_MOCK_MODE +}) + +describe('LiveAccessApi cookie auth mode (#dual-mode-cookie-readiness)', () => { + test('bearer mode: Authorization header is sent with the constructor token, no credentials key', async () => { + const { LiveAccessApi } = loadLiveApi('bearer') + stubFetch(() => jsonResponse({ id: 'c1', name: 'Guild', tiers: ['free'] })) + const api = new LiveAccessApi('0xabc', 'real-token') + await api.getCommunity() + + assert.equal(captured.length, 1) + assert.equal(headerValue(captured[0].init, 'Authorization'), 'Bearer real-token') + // `credentials: undefined` is equivalent to omitting the option for + // fetch's purposes — bearer mode's request shape is unchanged. + assert.equal(captured[0].init.credentials, undefined) + }) + + test('cookie mode: Authorization is never sent even if a token is passed to the constructor', async () => { + const { LiveAccessApi } = loadLiveApi('cookie') + stubFetch(() => jsonResponse({ id: 'c1', name: 'Guild', tiers: ['free'] })) + const api = new LiveAccessApi('0xabc', 'real-token') + await api.getCommunity() + + assert.equal(captured.length, 1) + assert.equal(headerValue(captured[0].init, 'Authorization'), undefined) + assert.equal(captured[0].init.credentials, 'include') + }) + + test('cookie mode: credentials "include" is sent on every request type (GET and POST)', async () => { + const { LiveAccessApi } = loadLiveApi('cookie') + stubFetch(() => jsonResponse({ nonce: 'abc123' })) + const api = new LiveAccessApi('0xabc') + await api.getNonce('0xabc') + + assert.equal(captured[0].init.credentials, 'include') + }) + + for (const token of [undefined, null, ''] as const) { + test(`siweLogout(${JSON.stringify(token)}) in bearer mode never sends "Bearer undefined/null/empty"`, async () => { + const { LiveAccessApi } = loadLiveApi('bearer') + stubFetch(() => new Response(null, { status: 204 }) as any) + const api = new LiveAccessApi('0xabc') + await api.siweLogout(token as any) + + const authHeader = headerValue(captured[0].init, 'Authorization') + assert.equal(authHeader, undefined) + }) + } + + test('siweLogout() with a real token in bearer mode sends it; in cookie mode it never does', async () => { + const bearerApi = new (loadLiveApi('bearer').LiveAccessApi)('0xabc') + stubFetch(() => new Response(null, { status: 204 }) as any) + await bearerApi.siweLogout('real-token') + assert.equal(headerValue(captured[0].init, 'Authorization'), 'Bearer real-token') + + const cookieApi = new (loadLiveApi('cookie').LiveAccessApi)('0xabc') + stubFetch(() => new Response(null, { status: 204 }) as any) + await cookieApi.siweLogout('real-token') + assert.equal(headerValue(captured[0].init, 'Authorization'), undefined) + }) + + test('getSessionStatus(): 200 authenticated:true passes through with no token field', async () => { + const { LiveAccessApi } = loadLiveApi('cookie') + stubFetch(() => + jsonResponse({ authenticated: true, address: '0xabc', expiresAt: new Date(Date.now() + 60_000).toISOString() }), + ) + const api = new LiveAccessApi() + const status = await api.getSessionStatus() + assert.equal(status.authenticated, true) + assert.equal('token' in status, false) + }) + + test('getSessionStatus(): 200 authenticated:false resolves without throwing', async () => { + const { LiveAccessApi } = loadLiveApi('cookie') + stubFetch(() => jsonResponse({ authenticated: false })) + const api = new LiveAccessApi() + assert.deepEqual(await api.getSessionStatus(), { authenticated: false }) + }) + + test('getSessionStatus(): 401 resolves to authenticated:false (does not throw)', async () => { + const { LiveAccessApi } = loadLiveApi('cookie') + stubFetch(() => jsonResponse({ code: 'unauthorized' }, 401)) + const api = new LiveAccessApi() + assert.deepEqual(await api.getSessionStatus(), { authenticated: false }) + }) + + test('getSessionStatus(): 404 resolves to authenticated:false (does not throw)', async () => { + const { LiveAccessApi } = loadLiveApi('cookie') + stubFetch(() => jsonResponse({}, 404)) + const api = new LiveAccessApi() + assert.deepEqual(await api.getSessionStatus(), { authenticated: false }) + }) + + test('getSessionStatus(): 500 propagates as an ApiError (does not resolve false)', async () => { + const { LiveAccessApi } = loadLiveApi('cookie') + stubFetch(() => jsonResponse({ message: 'boom' }, 500)) + const api = new LiveAccessApi() + await assert.rejects(() => api.getSessionStatus()) + }) + + test('getSessionStatus(): network failure propagates (does not resolve false)', async () => { + const { LiveAccessApi } = loadLiveApi('cookie') + ;(global as any).fetch = async () => { + throw new Error('connect ECONNREFUSED') + } + const api = new LiveAccessApi() + await assert.rejects(() => api.getSessionStatus()) + }) +}) diff --git a/test/mock-cookie-session.test.ts b/test/mock-cookie-session.test.ts new file mode 100644 index 0000000..1d35cc8 --- /dev/null +++ b/test/mock-cookie-session.test.ts @@ -0,0 +1,176 @@ +import './setup-env' +import { describe, test, beforeEach, afterEach } from 'node:test' +import * as assert from 'node:assert/strict' + +/** + * Focused unit tests for lib/api/mock.ts's cookie-auth-mode session + * simulation (dual-mode readiness — see docs/http-only-cookie-migration.md). + * + * Proves: + * - getSessionStatus()/siweVerify()/siweRefresh()/siweLogout() never read + * or write sessionStorage — only the simulated document.cookie jar — + * so cookie-mode mock session state is deterministic and independent of + * the bearer-token sessionStorage path. + * - The cookie "survives reload" (a fresh MockAccessApi instance still + * sees it, since document.cookie is not per-instance state) but does not + * leak into bearer mode (no cookie writes happen there at all). + */ + +let sessionStorageCalls = 0 + +function installFakeDocument(): void { + const jar = new Map() + ;(globalThis as any).document = { + get cookie(): string { + return Array.from(jar.entries()) + .map(([k, v]) => `${k}=${v}`) + .join('; ') + }, + set cookie(raw: string) { + const [pair, ...attrs] = raw.split('; ') + const eq = pair.indexOf('=') + const name = pair.slice(0, eq) + const value = pair.slice(eq + 1) + const maxAge = attrs.find((a) => a.toLowerCase().startsWith('max-age=')) + if (maxAge && Number(maxAge.split('=')[1]) <= 0) { + jar.delete(name) + } else { + jar.set(name, value) + } + }, + } +} + +/** A window whose sessionStorage spies on every call — mock.ts must never touch it. */ +function installSpySessionStorageWindow(): void { + sessionStorageCalls = 0 + const store = new Map() + ;(globalThis as any).window = { + sessionStorage: { + getItem: (k: string) => { + sessionStorageCalls += 1 + return store.has(k) ? store.get(k)! : null + }, + setItem: (k: string, v: string) => { + sessionStorageCalls += 1 + store.set(k, v) + }, + removeItem: (k: string) => { + sessionStorageCalls += 1 + store.delete(k) + }, + }, + addEventListener: () => {}, + removeEventListener: () => {}, + } +} + +function uninstallFakes(): void { + delete (globalThis as any).document + delete (globalThis as any).window +} + +function loadMockApi(authMode: 'bearer' | 'cookie'): typeof import('../lib/api/mock') { + process.env.NEXT_PUBLIC_AUTH_MODE = authMode + process.env.NEXT_PUBLIC_MOCK_MODE = 'true' + delete require.cache[require.resolve('../lib/config')] + delete require.cache[require.resolve('../lib/api/mock')] + return require('../lib/api/mock') +} + +const ADDRESS = '0x1234567890123456789012345678901234567890' + +async function signIn(mock: typeof import('../lib/api/mock'), address = ADDRESS) { + const api = new mock.MockAccessApi(address) + const nonce = await api.getNonce(address) + const message = `localhost:3000 wants you to sign in with your Ethereum account:\n${address}\n\nSign in to GuildPass Admin\n\nURI: https://localhost:3000\nVersion: 1\nChain ID: 1\nNonce: ${nonce}\nIssued At: ${new Date().toISOString()}` + await api.siweVerify(message, 'mock-signature') + return api +} + +beforeEach(() => { + installFakeDocument() + installSpySessionStorageWindow() +}) + +afterEach(() => { + uninstallFakes() + delete process.env.NEXT_PUBLIC_AUTH_MODE + delete process.env.NEXT_PUBLIC_MOCK_MODE +}) + +describe('MockAccessApi cookie-session simulation (#dual-mode-cookie-readiness)', () => { + test('cookie mode: getSessionStatus() is false before sign-in', async () => { + const mock = loadMockApi('cookie') + const api = new mock.MockAccessApi(ADDRESS) + const status = await api.getSessionStatus() + assert.deepEqual(status, { authenticated: false }) + }) + + test('cookie mode: siweVerify() sets the mock cookie; getSessionStatus() then reports authenticated:true', async () => { + const mock = loadMockApi('cookie') + await signIn(mock) + + const freshApi = new mock.MockAccessApi(ADDRESS) + const status = await freshApi.getSessionStatus() + assert.equal(status.authenticated, true) + assert.equal(status.address, ADDRESS) + assert.ok(status.expiresAt) + }) + + test('cookie mode: the mock cookie survives a fresh MockAccessApi instance ("reload")', async () => { + const mock = loadMockApi('cookie') + await signIn(mock) + + // A brand-new instance (simulating a page reload, where a fresh API + // client is constructed) still sees the same cookie jar. + const reloaded = new mock.MockAccessApi(ADDRESS) + assert.equal((await reloaded.getSessionStatus()).authenticated, true) + }) + + test('cookie mode: siweLogout() clears the mock cookie', async () => { + const mock = loadMockApi('cookie') + const api = await signIn(mock) + assert.equal((await api.getSessionStatus()).authenticated, true) + + await api.siweLogout() + assert.deepEqual(await api.getSessionStatus(), { authenticated: false }) + }) + + test('cookie mode: siweRefresh() rotates the cookie and keeps getSessionStatus() true', async () => { + const mock = loadMockApi('cookie') + const api = await signIn(mock) + + // Cookie mode has no refresh-token string to pass; the mock validates + // against the cookie itself instead. + await api.siweRefresh('') + assert.equal((await api.getSessionStatus()).authenticated, true) + }) + + test('cookie mode: siweRefresh() fails once the cookie has been cleared', async () => { + const mock = loadMockApi('cookie') + const api = await signIn(mock) + await api.siweLogout() + + await assert.rejects(() => api.siweRefresh('')) + }) + + test('cookie-mode session simulation never reads or writes sessionStorage', async () => { + const mock = loadMockApi('cookie') + const api = await signIn(mock) + await api.getSessionStatus() + await api.siweRefresh('') + await api.siweLogout() + assert.equal(sessionStorageCalls, 0) + }) + + test('bearer mode: siweVerify()/siweLogout() never touch document.cookie', async () => { + const mock = loadMockApi('bearer') + const api = await signIn(mock) + // getSessionStatus() itself is mode-agnostic at the type level, but + // bearer-mode sign-in/logout must never have written the mock cookie. + assert.equal((globalThis as any).document.cookie, '') + await api.siweLogout() + assert.equal((globalThis as any).document.cookie, '') + }) +}) diff --git a/test/refresh-coordination.test.ts b/test/refresh-coordination.test.ts index 0377a94..ff70eb9 100644 --- a/test/refresh-coordination.test.ts +++ b/test/refresh-coordination.test.ts @@ -1,3 +1,4 @@ +import './setup-env' import { describe, test, beforeEach, afterEach } from 'node:test' import * as assert from 'node:assert/strict' import { diff --git a/test/session-cookie-mode.test.ts b/test/session-cookie-mode.test.ts new file mode 100644 index 0000000..f125053 --- /dev/null +++ b/test/session-cookie-mode.test.ts @@ -0,0 +1,215 @@ +import './setup-env' +import { describe, test, beforeEach, afterEach } from 'node:test' +import * as assert from 'node:assert/strict' + +/** + * Focused unit tests proving lib/session.ts's cookie-auth-mode invariants + * (dual-mode readiness — see docs/http-only-cookie-migration.md): + * + * - storeAuthSession/loadAuthSession/loadAuthSessionIncludingExpired/ + * getStoredToken/getStoredAddress/clearAuthSession never call + * sessionStorage.{setItem,getItem,removeItem} when config.authMode is + * 'cookie' — proven by call-count spies, not just return-value checks. + * - bearer mode (the default) is completely unaffected. + * - isSessionActive() reflects the approved session-status endpoint. + * + * lib/session.ts reads `config.authMode` from the frozen `lib/config.ts` + * singleton, so each authMode variant requires setting + * NEXT_PUBLIC_AUTH_MODE *before* a fresh require() of both modules (a fresh + * `lib/config` instance is required too — unlike test/wallet-config.test.ts, + * nothing here depends on `instanceof` identity across module instances). + */ + +let dispatched: string[] = [] +let sessionStorageCalls: { setItem: number; getItem: number; removeItem: number } = { + setItem: 0, + getItem: 0, + removeItem: 0, +} + +class SpyStorage { + private store = new Map() + getItem(key: string): string | null { + sessionStorageCalls.getItem += 1 + return this.store.has(key) ? this.store.get(key)! : null + } + setItem(key: string, value: string): void { + sessionStorageCalls.setItem += 1 + this.store.set(key, String(value)) + } + removeItem(key: string): void { + sessionStorageCalls.removeItem += 1 + this.store.delete(key) + } +} + +function installFakeWindow(): void { + dispatched = [] + sessionStorageCalls = { setItem: 0, getItem: 0, removeItem: 0 } + ;(globalThis as any).window = { + sessionStorage: new SpyStorage(), + dispatchEvent: (event: { type: string }) => { + dispatched.push(event.type) + return true + }, + addEventListener: () => {}, + removeEventListener: () => {}, + } + ;(globalThis as any).CustomEvent = class { + type: string + constructor(type: string) { + this.type = type + } + } +} + +function uninstallFakeWindow(): void { + delete (globalThis as any).window + delete (globalThis as any).CustomEvent +} + +/** Minimal document.cookie jar shim so the mock API's cookie simulation works under node:test (no jsdom). */ +function installFakeDocument(): void { + const jar = new Map() + ;(globalThis as any).document = { + get cookie(): string { + return Array.from(jar.entries()) + .map(([k, v]) => `${k}=${v}`) + .join('; ') + }, + set cookie(raw: string) { + const [pair, ...attrs] = raw.split('; ') + const eq = pair.indexOf('=') + const name = pair.slice(0, eq) + const value = pair.slice(eq + 1) + const maxAge = attrs.find((a) => a.toLowerCase().startsWith('max-age=')) + if (maxAge && Number(maxAge.split('=')[1]) <= 0) { + jar.delete(name) + } else { + jar.set(name, value) + } + }, + } +} + +function uninstallFakeDocument(): void { + delete (globalThis as any).document +} + +function loadSessionModule(authMode: 'bearer' | 'cookie' | undefined): typeof import('../lib/session') { + if (authMode === undefined) { + delete process.env.NEXT_PUBLIC_AUTH_MODE + } else { + process.env.NEXT_PUBLIC_AUTH_MODE = authMode + } + delete require.cache[require.resolve('../lib/config')] + delete require.cache[require.resolve('../lib/session')] + delete require.cache[require.resolve('../lib/api')] + delete require.cache[require.resolve('../lib/api/mock')] + delete require.cache[require.resolve('../lib/api/live')] + return require('../lib/session') +} + +beforeEach(() => { + installFakeWindow() + installFakeDocument() +}) + +afterEach(() => { + uninstallFakeWindow() + uninstallFakeDocument() + delete process.env.NEXT_PUBLIC_AUTH_MODE +}) + +describe('lib/session.ts cookie auth mode (#dual-mode-cookie-readiness)', () => { + test('cookie mode: storeAuthSession never calls sessionStorage.setItem', () => { + const { storeAuthSession } = loadSessionModule('cookie') + storeAuthSession({ + isAuthenticated: true, + token: 'real-bearer-token', + address: '0xabc', + expiresAt: new Date(Date.now() + 60_000).toISOString(), + } as any) + assert.equal(sessionStorageCalls.setItem, 0) + }) + + test('cookie mode: loadAuthSession/loadAuthSessionIncludingExpired never call sessionStorage.getItem and return null', () => { + const { loadAuthSession, loadAuthSessionIncludingExpired } = loadSessionModule('cookie') + assert.equal(loadAuthSession(), null) + assert.equal(loadAuthSessionIncludingExpired(), null) + assert.equal(sessionStorageCalls.getItem, 0) + }) + + test('cookie mode: getStoredToken/getStoredAddress never touch sessionStorage', () => { + const { getStoredToken, getStoredAddress } = loadSessionModule('cookie') + assert.equal(getStoredToken(), null) + assert.equal(getStoredAddress(), null) + assert.equal(sessionStorageCalls.getItem, 0) + }) + + test('cookie mode: clearAuthSession never calls sessionStorage.removeItem, but still dispatches siwe:invalidated', () => { + const { clearAuthSession } = loadSessionModule('cookie') + clearAuthSession() + assert.equal(sessionStorageCalls.removeItem, 0) + assert.ok(dispatched.includes('siwe:invalidated')) + }) + + test('cookie mode: a full store→load→clear sequence never calls any sessionStorage method', () => { + const { storeAuthSession, loadAuthSession, clearAuthSession } = loadSessionModule('cookie') + storeAuthSession({ + isAuthenticated: true, + token: 'real-bearer-token', + address: '0xabc', + expiresAt: new Date(Date.now() + 60_000).toISOString(), + } as any) + loadAuthSession() + clearAuthSession() + assert.deepEqual(sessionStorageCalls, { setItem: 0, getItem: 0, removeItem: 0 }) + }) + + test('bearer mode (default, unset env var): sessionStorage round-trip still works exactly as before', () => { + const { storeAuthSession, loadAuthSession } = loadSessionModule(undefined) + const session = { + isAuthenticated: true, + token: 'jwt-abc-123', + address: '0xabc', + expiresAt: new Date(Date.now() + 60_000).toISOString(), + } + storeAuthSession(session as any) + assert.deepEqual(loadAuthSession(), session) + assert.equal(sessionStorageCalls.setItem, 1) + assert.equal(sessionStorageCalls.getItem > 0, true) + }) + + test('bearer mode (explicit "bearer"): behaves identically to unset', () => { + const { storeAuthSession, loadAuthSession } = loadSessionModule('bearer') + const session = { + isAuthenticated: true, + token: 'jwt-xyz', + address: '0xdef', + expiresAt: new Date(Date.now() + 60_000).toISOString(), + } + storeAuthSession(session as any) + assert.deepEqual(loadAuthSession(), session) + }) + + test('isSessionActive() reflects false before sign-in, true after a mock cookie sign-in, false after logout', async () => { + process.env.NEXT_PUBLIC_MOCK_MODE = 'true' + const { isSessionActive } = loadSessionModule('cookie') + const { MockAccessApi } = require('../lib/api/mock') + + assert.equal(await isSessionActive(), false) + + const api = new MockAccessApi('0x1234567890123456789012345678901234567890') + const nonce = await api.getNonce('0x1234567890123456789012345678901234567890') + const message = `localhost:3000 wants you to sign in with your Ethereum account:\n0x1234567890123456789012345678901234567890\n\nSign in to GuildPass Admin\n\nURI: https://localhost:3000\nVersion: 1\nChain ID: 1\nNonce: ${nonce}\nIssued At: ${new Date().toISOString()}` + await api.siweVerify(message, 'mock-signature') + + assert.equal(await isSessionActive(), true) + + await api.siweLogout() + assert.equal(await isSessionActive(), false) + + delete process.env.NEXT_PUBLIC_MOCK_MODE + }) +}) diff --git a/test/siwe-broadcast-validation.test.ts b/test/siwe-broadcast-validation.test.ts new file mode 100644 index 0000000..5fb7fa8 --- /dev/null +++ b/test/siwe-broadcast-validation.test.ts @@ -0,0 +1,88 @@ +import './setup-env' +import { describe, test } from 'node:test' +import * as assert from 'node:assert/strict' +import { isValidBroadcastSession } from '../lib/wallet/siwe-session' + +/** + * Focused unit tests for isValidBroadcastSession() — the guard providers.tsx + * applies to every session payload received via BroadcastChannel (or the + * storage-event fallback) before applying it to local state. + * + * Bearer mode requires a non-empty token (identical to the inline check this + * was extracted from). Cookie mode never broadcasts a real token — see + * providers.tsx's signIn()/performSilentRefresh() scrubbing — so the token + * check is skipped there, but address/expiresAt are still required in both + * modes. + */ + +const validExpiry = new Date(Date.now() + 60_000).toISOString() + +describe('isValidBroadcastSession (#dual-mode-cookie-readiness)', () => { + test('null/undefined session is always invalid', () => { + assert.equal(isValidBroadcastSession(null, 'bearer'), false) + assert.equal(isValidBroadcastSession(undefined, 'bearer'), false) + assert.equal(isValidBroadcastSession(null, 'cookie'), false) + }) + + test('bearer mode: a well-formed session with a real token is valid', () => { + assert.equal( + isValidBroadcastSession({ token: 'jwt-abc', address: '0xabc', expiresAt: validExpiry }, 'bearer'), + true, + ) + }) + + test('bearer mode: an empty or missing token is rejected', () => { + assert.equal( + isValidBroadcastSession({ token: '', address: '0xabc', expiresAt: validExpiry }, 'bearer'), + false, + ) + assert.equal( + isValidBroadcastSession({ token: ' ', address: '0xabc', expiresAt: validExpiry }, 'bearer'), + false, + ) + assert.equal( + isValidBroadcastSession({ address: '0xabc', expiresAt: validExpiry } as any, 'bearer'), + false, + ) + }) + + test('cookie mode: an empty token is accepted (cookie mode never carries a real one)', () => { + assert.equal( + isValidBroadcastSession({ token: '', address: '0xabc', expiresAt: validExpiry }, 'cookie'), + true, + ) + assert.equal( + isValidBroadcastSession({ address: '0xabc', expiresAt: validExpiry } as any, 'cookie'), + true, + ) + }) + + test('cookie mode: a real, non-empty token is still accepted (dual-ship backend may still send one)', () => { + assert.equal( + isValidBroadcastSession({ token: 'jwt-abc', address: '0xabc', expiresAt: validExpiry }, 'cookie'), + true, + ) + }) + + test('both modes: missing or empty address is always rejected', () => { + assert.equal( + isValidBroadcastSession({ token: 'jwt-abc', address: '', expiresAt: validExpiry }, 'bearer'), + false, + ) + assert.equal( + isValidBroadcastSession({ token: '', address: '', expiresAt: validExpiry }, 'cookie'), + false, + ) + }) + + test('both modes: missing or empty expiresAt is always rejected', () => { + assert.equal( + isValidBroadcastSession({ token: 'jwt-abc', address: '0xabc', expiresAt: '' }, 'bearer'), + false, + ) + assert.equal( + isValidBroadcastSession({ token: '', address: '0xabc', expiresAt: '' }, 'cookie'), + false, + ) + }) +})