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