diff --git a/src/tests/web-auth-token.test.ts b/src/tests/web-auth-token.test.ts index 4fd5fff5a..9f3571c57 100644 --- a/src/tests/web-auth-token.test.ts +++ b/src/tests/web-auth-token.test.ts @@ -16,33 +16,33 @@ const projectRoot = process.cwd() 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 persists token to localStorage on extraction', () => { + assert.match(authSource, /localStorage\.setItem/, 'should persist token to localStorage 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 falls back to localStorage when hash is absent', () => { + assert.match(authSource, /localStorage\.getItem/, 'should read from localStorage 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 defines an auth storage key constant', () => { + assert.match(authSource, /AUTH_STORAGE_KEY/, 'should use a named constant for the localStorage 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') +test('auth.ts wraps localStorage calls in try/catch for private browsing', () => { + // localStorage can throw in private browsing when quota is exceeded + const setItemIndex = authSource.indexOf('localStorage.setItem') + const getItemIndex = authSource.indexOf('localStorage.getItem') assert.ok(setItemIndex > -1) assert.ok(getItemIndex > -1) - // Both sessionStorage accesses should be inside try blocks + // Both localStorage 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') + assert.match(beforeSetItem, /try\s*\{/, 'localStorage.setItem should be inside a try block') + assert.match(beforeGetItem, /try\s*\{/, 'localStorage.getItem should be inside a try block') }) // ─── sendBeacon auth token tests ──────────────────────────────────────────── diff --git a/web/lib/auth.ts b/web/lib/auth.ts index 780df8be1..78f8abca5 100644 --- a/web/lib/auth.ts +++ b/web/lib/auth.ts @@ -7,22 +7,27 @@ * keeping the token local to the machine. * * 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. + * it to localStorage (so it survives page refreshes and is accessible from + * all tabs on the same origin), and clears the fragment from the address bar. + * All subsequent API calls attach the token via the `Authorization: Bearer` + * header. + * + * localStorage is shared across all tabs on the same origin. Because each + * GSD instance binds to a unique random port, the origin already scopes + * the token to that instance — no additional namespacing is needed. * * 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" +const AUTH_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. Falls back to sessionStorage so the token survives - * page refreshes (which clear the in-memory cache and the URL fragment). + * the cached value. Falls back to localStorage so the token survives + * page refreshes and is available to all tabs on the same origin. * Clears the fragment from the address bar after extraction. */ export function getAuthToken(): string | null { @@ -36,11 +41,10 @@ export function getAuthToken(): string | null { 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. + // Persist to localStorage so the token survives page refreshes and + // is available to other tabs on the same origin (same GSD instance). try { - sessionStorage.setItem(SESSION_STORAGE_KEY, cachedToken) + localStorage.setItem(AUTH_STORAGE_KEY, cachedToken) } catch { // Storage unavailable (e.g. private browsing quota exceeded) — the // in-memory cache still works for the current page lifecycle. @@ -52,9 +56,9 @@ export function getAuthToken(): string | null { } } - // 2. Fall back to sessionStorage (page refresh, bookmark without hash) + // 2. Fall back to localStorage (page refresh, second tab, bookmark without hash) try { - const stored = sessionStorage.getItem(SESSION_STORAGE_KEY) + const stored = localStorage.getItem(AUTH_STORAGE_KEY) if (stored) { cachedToken = stored return cachedToken @@ -66,6 +70,19 @@ export function getAuthToken(): string | null { return null } +/** + * Listen for token changes from other tabs via the `storage` event. + * When another tab writes a new token to localStorage, this tab picks + * it up immediately without requiring a page refresh. + */ +if (typeof window !== "undefined") { + window.addEventListener("storage", (event) => { + if (event.key === AUTH_STORAGE_KEY && event.newValue) { + cachedToken = event.newValue + } + }) +} + /** * Returns an object with the `Authorization` header for use with `fetch()`. * Merges with any additional headers provided. @@ -82,7 +99,7 @@ export function authHeaders(extra?: Record): Record