fix(frontend): keep auth tokens in sync across browser tabs
deploy / deploy (push) Successful in 1m4s
deploy / deploy (push) Successful in 1m4s
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 <noreply@anthropic.com>
This commit is contained in:
@@ -46,8 +46,8 @@ export class ApiError extends Error {
|
|||||||
let refreshPromise: Promise<void> | null = null;
|
let refreshPromise: Promise<void> | null = null;
|
||||||
|
|
||||||
async function refreshTokens(): Promise<void> {
|
async function refreshTokens(): Promise<void> {
|
||||||
const { refreshToken } = get(authStore);
|
const attempted = get(authStore).refreshToken;
|
||||||
if (!refreshToken) {
|
if (!attempted) {
|
||||||
endSession();
|
endSession();
|
||||||
throw new ApiError(401, 'unauthorized', 'Session expired');
|
throw new ApiError(401, 'unauthorized', 'Session expired');
|
||||||
}
|
}
|
||||||
@@ -55,10 +55,15 @@ async function refreshTokens(): Promise<void> {
|
|||||||
const res = await fetch(`${BASE}/auth/refresh`, {
|
const res = await fetch(`${BASE}/auth/refresh`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ refresh_token: refreshToken })
|
body: JSON.stringify({ refresh_token: attempted })
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!res.ok) {
|
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();
|
endSession();
|
||||||
throw new ApiError(401, 'unauthorized', 'Session expired');
|
throw new ApiError(401, 'unauthorized', 'Session expired');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,10 +25,35 @@ function loadStored(): AuthState {
|
|||||||
|
|
||||||
export const authStore = writable<AuthState>(loadStored());
|
export const authStore = writable<AuthState>(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) => {
|
authStore.subscribe((state) => {
|
||||||
if (typeof localStorage !== 'undefined') {
|
if (typeof localStorage === 'undefined') return;
|
||||||
localStorage.setItem('auth', JSON.stringify(state));
|
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);
|
export const isAuthenticated = derived(authStore, ($auth) => !!$auth.accessToken);
|
||||||
|
|||||||
Reference in New Issue
Block a user