From d5b318a222242b90835abb20d8de03c618558ebc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?T=C3=82CHES?= Date: Thu, 26 Mar 2026 20:06:50 -0600 Subject: [PATCH] fix: use localStorage for auth token to enable multi-tab usage (#2785) * fix: use localStorage for auth token to enable multi-tab usage sessionStorage is tab-scoped, so manually opened second tabs cannot access the auth token delivered via URL fragment to the first tab. localStorage is shared across all tabs on the same origin, and since each GSD instance binds to a unique random port the origin already scopes the token to that instance. Also adds a `storage` event listener so already-open tabs pick up token changes immediately. Closes #2714 Co-Authored-By: Claude Opus 4.6 (1M context) * test: update web-auth-token test for localStorage migration Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- src/tests/web-auth-token.test.ts | 26 +++++++++---------- web/lib/auth.ts | 43 ++++++++++++++++++++++---------- 2 files changed, 43 insertions(+), 26 deletions(-) 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