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) <noreply@anthropic.com>

* test: update web-auth-token test for localStorage migration

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
TÂCHES 2026-03-26 20:06:50 -06:00 committed by GitHub
parent d80927f50d
commit d5b318a222
2 changed files with 43 additions and 26 deletions

View file

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

View file

@ -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<string, string>): Record<string, stri
/**
* Wrapper around `fetch()` that automatically injects the auth token.
*
* When no token is available (missing `#token=` fragment and no sessionStorage
* When no token is available (missing `#token=` fragment and no localStorage
* entry), returns a synthetic 401 Response instead of making an unauthenticated
* request that will fail server-side anyway. This lets callers handle the
* missing-token case uniformly rather than silently cascading 401s.