diff --git a/src/tests/web-auth-token.test.ts b/src/tests/web-auth-token.test.ts new file mode 100644 index 000000000..4fd5fff5a --- /dev/null +++ b/src/tests/web-auth-token.test.ts @@ -0,0 +1,87 @@ +/** + * Tests for the web auth token flow (web/lib/auth.ts). + * + * The auth module runs in the browser, so we verify the source code contains + * the expected patterns for token extraction, persistence, and transmission. + */ + +import test from 'node:test' +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { join } from 'node:path' + +const projectRoot = process.cwd() + +// ─── Source contract tests ────────────────────────────────────────────────── + +const authSource = readFileSync(join(projectRoot, 'web', 'lib', 'auth.ts'), 'utf-8') + +test('auth.ts persists token to sessionStorage on extraction', () => { + assert.match(authSource, /sessionStorage\.setItem/, 'should persist token to sessionStorage after extracting from hash') +}) + +test('auth.ts falls back to sessionStorage when hash is absent', () => { + assert.match(authSource, /sessionStorage\.getItem/, 'should read from sessionStorage when URL hash is empty') +}) + +test('auth.ts defines a sessionStorage key constant', () => { + assert.match(authSource, /SESSION_STORAGE_KEY/, 'should use a named constant for the sessionStorage key') +}) + +test('auth.ts clears the URL fragment after token extraction', () => { + assert.match(authSource, /replaceState/, 'should clear the hash from the address bar') +}) + +test('auth.ts wraps sessionStorage calls in try/catch for private browsing', () => { + // sessionStorage can throw in private browsing when quota is exceeded + const setItemIndex = authSource.indexOf('sessionStorage.setItem') + const getItemIndex = authSource.indexOf('sessionStorage.getItem') + assert.ok(setItemIndex > -1) + assert.ok(getItemIndex > -1) + // Both sessionStorage accesses should be inside try blocks + const beforeSetItem = authSource.slice(Math.max(0, setItemIndex - 200), setItemIndex) + const beforeGetItem = authSource.slice(Math.max(0, getItemIndex - 200), getItemIndex) + assert.match(beforeSetItem, /try\s*\{/, 'sessionStorage.setItem should be inside a try block') + assert.match(beforeGetItem, /try\s*\{/, 'sessionStorage.getItem should be inside a try block') +}) + +// ─── sendBeacon auth token tests ──────────────────────────────────────────── + +const appShellSource = readFileSync(join(projectRoot, 'web', 'components', 'gsd', 'app-shell.tsx'), 'utf-8') + +test('app-shell.tsx sendBeacon includes auth token as query parameter', () => { + // sendBeacon cannot set custom headers, so the token must be passed + // as a _token query parameter for the proxy to accept the request. + assert.match(appShellSource, /_token=/, 'sendBeacon URL should include _token query parameter') +}) + +test('app-shell.tsx sendBeacon does not send bare unauthenticated URL', () => { + // Every sendBeacon to /api/ should include the auth token + const beaconCalls = appShellSource.match(/sendBeacon\([^)]+\)/g) || [] + for (const call of beaconCalls) { + if (call.includes('/api/')) { + // The URL should be constructed with the token, not a bare string literal + assert.ok( + !call.includes('"/api/shutdown"') && !call.includes("'/api/shutdown'"), + `sendBeacon call should not use a bare /api/ URL without auth: ${call}` + ) + } + } +}) + +// ─── proxy.ts contract tests ──────────────────────────────────────────────── + +const proxySource = readFileSync(join(projectRoot, 'web', 'proxy.ts'), 'utf-8') + +test('proxy.ts accepts _token query parameter as fallback authentication', () => { + assert.match(proxySource, /_token/, 'proxy should support _token query parameter for SSE/sendBeacon') +}) + +test('proxy.ts validates bearer token from Authorization header', () => { + assert.match(proxySource, /Bearer/, 'proxy should check Authorization: Bearer header') +}) + +test('proxy.ts skips auth when GSD_WEB_AUTH_TOKEN is not set', () => { + assert.match(proxySource, /GSD_WEB_AUTH_TOKEN/, 'proxy should read GSD_WEB_AUTH_TOKEN from env') + assert.match(proxySource, /NextResponse\.next\(\)/, 'proxy should pass through when no token is configured') +}) diff --git a/web/components/gsd/app-shell.tsx b/web/components/gsd/app-shell.tsx index 24c4c12e9..8f3454922 100644 --- a/web/components/gsd/app-shell.tsx +++ b/web/components/gsd/app-shell.tsx @@ -439,7 +439,11 @@ function ProjectAwareWorkspace() { // Shut down all projects when the tab actually closes useEffect(() => { const handlePageHide = () => { - navigator.sendBeacon("/api/shutdown", "") + // sendBeacon cannot set custom headers, so pass the auth token as a + // query parameter instead (the proxy accepts `_token` as a fallback). + const token = getAuthToken() + const url = token ? `/api/shutdown?_token=${token}` : "/api/shutdown" + navigator.sendBeacon(url, "") } window.addEventListener("pagehide", handlePageHide) diff --git a/web/lib/auth.ts b/web/lib/auth.ts index a153b5d04..47ac0515f 100644 --- a/web/lib/auth.ts +++ b/web/lib/auth.ts @@ -6,37 +6,64 @@ * Fragments are never sent in HTTP requests or logged by servers/proxies, * keeping the token local to the machine. * - * On first load this module extracts the token from the fragment, stores it - * in memory, and clears the fragment from the address bar. All subsequent - * API calls attach the token via the `Authorization: Bearer` header. + * On first load this module extracts the token from the fragment, persists + * it to sessionStorage (so it survives page refreshes), and clears the + * fragment from the address bar. All subsequent API calls attach the token + * via the `Authorization: Bearer` header. * * For EventSource (SSE), which cannot send custom headers, the token is * appended as a `?_token=` query parameter instead. */ +const SESSION_STORAGE_KEY = "gsd-auth-token" + let cachedToken: string | null = null /** * Extract the auth token from the URL fragment on first call, then return - * the cached value. Clears the fragment from the address bar. + * the cached value. Falls back to sessionStorage so the token survives + * page refreshes (which clear the in-memory cache and the URL fragment). + * Clears the fragment from the address bar after extraction. */ export function getAuthToken(): string | null { if (cachedToken !== null) return cachedToken if (typeof window === "undefined") return null + // 1. Try the URL fragment (initial page load from gsd --web) const hash = window.location.hash if (hash) { const match = hash.match(/token=([a-fA-F0-9]+)/) if (match) { cachedToken = match[1] + // Persist to sessionStorage so the token survives page refreshes. + // sessionStorage is scoped to this browser tab — it does not leak + // to other tabs or persist after the tab is closed. + try { + sessionStorage.setItem(SESSION_STORAGE_KEY, cachedToken) + } catch { + // Storage unavailable (e.g. private browsing quota exceeded) — the + // in-memory cache still works for the current page lifecycle. + } // Clear the fragment so the token isn't visible in the address bar // or leaked via the Referer header on external navigations. window.history.replaceState(null, "", window.location.pathname + window.location.search) + return cachedToken } } - return cachedToken + // 2. Fall back to sessionStorage (page refresh, bookmark without hash) + try { + const stored = sessionStorage.getItem(SESSION_STORAGE_KEY) + if (stored) { + cachedToken = stored + return cachedToken + } + } catch { + // Storage unavailable — fall through to null + } + + return null } /**