diff --git a/.env.example b/.env.example index 366c4b1..2d3f838 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 fc7cfc1..3024e0a 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 62b2f4b..f58b57c 100644 --- a/lib/api/live.ts +++ b/lib/api/live.ts @@ -17,6 +17,8 @@ import { ResourceLookupResult, Role, Session, + SessionStatus, + SessionStatusSchema, SiweAuthSession, WalletVerification, BackendSession, @@ -523,6 +525,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) { @@ -1121,15 +1132,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 9c609c5..8c15089 100644 --- a/lib/api/mock.ts +++ b/lib/api/mock.ts @@ -41,6 +41,7 @@ import { ResourceLookupResult, Role, Session, + SessionStatus, SiweAuthSession, WalletVerification, WebhookEventLog, @@ -71,6 +72,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 = @@ -78,6 +80,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', @@ -1434,11 +1471,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, @@ -1462,7 +1504,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', @@ -1472,19 +1524,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 2f49430..34b9c57 100644 --- a/lib/api/types.ts +++ b/lib/api/types.ts @@ -845,6 +845,27 @@ export interface AdminAccessApi { analytics: AnalyticsDataSource } +/** + * 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. */ @@ -867,9 +888,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, + ) + }) +})