From a371045b4194a2b7e1e4bcaed434a9ce61df017a Mon Sep 17 00:00:00 2001 From: Masahiko AMANO Date: Fri, 12 Jun 2026 00:13:13 +0300 Subject: [PATCH] fix(frontend): keep auth tokens in sync across browser tabs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refresh tokens rotate on every use and each refresh deletes the old session server-side, so when one tab refreshed, other open tabs were left holding a dead access token and a rotated-away refresh token — their next request 401'd and bounced them to the login screen. Sync the auth store across tabs via the storage event (propagating logins, refreshes, and logouts), and make refresh race-resilient: if a refresh fails but a newer token has meanwhile synced in from another tab, adopt it and retry instead of ending a still-valid session. Co-Authored-By: Claude Opus 4.8 --- frontend/src/lib/api/client.ts | 11 ++++++++--- frontend/src/lib/stores/auth.ts | 29 +++++++++++++++++++++++++++-- 2 files changed, 35 insertions(+), 5 deletions(-) diff --git a/frontend/src/lib/api/client.ts b/frontend/src/lib/api/client.ts index 31b97ee..6cd0660 100644 --- a/frontend/src/lib/api/client.ts +++ b/frontend/src/lib/api/client.ts @@ -46,8 +46,8 @@ export class ApiError extends Error { let refreshPromise: Promise | null = null; async function refreshTokens(): Promise { - const { refreshToken } = get(authStore); - if (!refreshToken) { + const attempted = get(authStore).refreshToken; + if (!attempted) { endSession(); throw new ApiError(401, 'unauthorized', 'Session expired'); } @@ -55,10 +55,15 @@ async function refreshTokens(): Promise { const res = await fetch(`${BASE}/auth/refresh`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ refresh_token: refreshToken }) + body: JSON.stringify({ refresh_token: attempted }) }); if (!res.ok) { + // Refresh tokens rotate, so another tab may have already refreshed and + // rotated ours out. If a newer token has since synced in from that tab (via + // the auth store's storage listener), adopt it and let the caller retry + // rather than ending a session that's actually still alive. + if (get(authStore).refreshToken !== attempted) return; endSession(); throw new ApiError(401, 'unauthorized', 'Session expired'); } diff --git a/frontend/src/lib/stores/auth.ts b/frontend/src/lib/stores/auth.ts index 38b1844..72c08a7 100644 --- a/frontend/src/lib/stores/auth.ts +++ b/frontend/src/lib/stores/auth.ts @@ -25,10 +25,35 @@ function loadStored(): AuthState { export const authStore = writable(loadStored()); +// Persist on change. Compare first so a value that just arrived from another tab +// (applied by the storage listener below) isn't written straight back, which +// would risk a storage-event echo between tabs. authStore.subscribe((state) => { - if (typeof localStorage !== 'undefined') { - localStorage.setItem('auth', JSON.stringify(state)); + if (typeof localStorage === 'undefined') return; + const serialized = JSON.stringify(state); + if (localStorage.getItem('auth') !== serialized) { + localStorage.setItem('auth', serialized); } }); +// Keep tabs in sync. Refresh tokens rotate on every use (each refresh deletes the +// old session server-side), so when one tab logs in, refreshes, or logs out, the +// others must pick up the new tokens — or the cleared session — immediately. +// Otherwise a second tab would later refresh with a token that's already been +// rotated away and get bounced to the login screen. +if (typeof window !== 'undefined') { + window.addEventListener('storage', (e) => { + if (e.key !== 'auth') return; + let next: AuthState = initial; + if (e.newValue) { + try { + next = (JSON.parse(e.newValue) as AuthState) ?? initial; + } catch { + next = initial; + } + } + authStore.set(next); + }); +} + export const isAuthenticated = derived(authStore, ($auth) => !!$auth.accessToken);