From bbdc998646e8093223a448378a2c29690f0d8612 Mon Sep 17 00:00:00 2001 From: Steven Date: Tue, 24 Feb 2026 23:31:59 +0800 Subject: [PATCH] fix(web): use BroadcastChannel to sync token refreshes across tabs When multiple tabs are open and a token expires, each tab independently attempts a refresh. With server-side token rotation this causes all but the first tab to fail, logging the user out. Add a BroadcastChannel (memos_token_sync) so that when any tab successfully refreshes, it broadcasts the new token to all other tabs. Receiving tabs adopt the token in-memory immediately, skipping their own refresh request and avoiding conflicts with token rotation. Falls back gracefully when BroadcastChannel is unavailable (e.g. some privacy modes). --- web/src/auth-state.ts | 40 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/web/src/auth-state.ts b/web/src/auth-state.ts index 50ec6326e..95abb3382 100644 --- a/web/src/auth-state.ts +++ b/web/src/auth-state.ts @@ -6,6 +6,43 @@ let tokenExpiresAt: Date | null = null; const TOKEN_KEY = "memos_access_token"; const EXPIRES_KEY = "memos_token_expires_at"; +// BroadcastChannel lets tabs share freshly-refreshed tokens so that only one +// tab needs to hit the refresh endpoint. When another tab successfully refreshes +// we adopt the new token immediately, avoiding a redundant (and potentially +// conflicting) refresh request of our own. +const TOKEN_CHANNEL_NAME = "memos_token_sync"; + +interface TokenBroadcastMessage { + token: string; + expiresAt: string; // ISO string +} + +let tokenChannel: BroadcastChannel | null = null; + +function getTokenChannel(): BroadcastChannel | null { + if (tokenChannel) return tokenChannel; + try { + tokenChannel = new BroadcastChannel(TOKEN_CHANNEL_NAME); + tokenChannel.onmessage = (event: MessageEvent) => { + const { token, expiresAt } = event.data ?? {}; + if (token && expiresAt) { + // Another tab refreshed — adopt the token in-memory so we don't + // fire our own refresh request. + accessToken = token; + tokenExpiresAt = new Date(expiresAt); + } + }; + } catch { + // BroadcastChannel not available (e.g. some privacy modes) + tokenChannel = null; + } + return tokenChannel; +} + +// Initialize the channel at module load so the listener is registered +// before any token refresh can occur in any tab. +getTokenChannel(); + export const getAccessToken = (): string | null => { if (!accessToken) { try { @@ -41,6 +78,9 @@ export const setAccessToken = (token: string | null, expiresAt?: Date): void => if (token && expiresAt) { localStorage.setItem(TOKEN_KEY, token); localStorage.setItem(EXPIRES_KEY, expiresAt.toISOString()); + // Broadcast to other tabs so they adopt the new token without refreshing. + const msg: TokenBroadcastMessage = { token, expiresAt: expiresAt.toISOString() }; + getTokenChannel()?.postMessage(msg); } else { localStorage.removeItem(TOKEN_KEY); localStorage.removeItem(EXPIRES_KEY);