fix(web): persist auth token in sessionStorage to survive page refreshes (#1877)

Next.js 16 auto-detects web/proxy.ts as middleware, gating all /api/*
routes behind bearer token validation. The token was only cached in
memory (lost on page refresh) and extracted from the URL hash fragment
(cleared after first extraction). This caused 401 errors on page
refresh and broke the sendBeacon shutdown call which cannot set
custom headers.

Changes:
- Persist the auth token to sessionStorage after extracting from the
  URL fragment so it survives page refreshes within the same tab
- Fall back to sessionStorage when the URL hash is absent (refresh,
  bookmark without hash)
- Pass the auth token as a _token query parameter in the sendBeacon
  shutdown call since sendBeacon cannot set Authorization headers
- Add regression tests for token persistence, sessionStorage fallback,
  and sendBeacon authentication

Fixes #1851

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Tom Boucher 2026-03-21 17:25:27 -04:00 committed by GitHub
parent e0011a897a
commit f4db25b9b8
3 changed files with 124 additions and 6 deletions

View file

@ -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')
})

View file

@ -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)

View file

@ -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
}
/**