From 0949f4c8548637c90e6fed292adaf954559346e0 Mon Sep 17 00:00:00 2001 From: kolaente Date: Fri, 19 Jun 2026 22:29:39 +0200 Subject: [PATCH] fix(auth): don't let an in-flight refresh repopulate the token after logout --- frontend/src/helpers/auth.test.ts | 14 ++++++++++++++ frontend/src/helpers/auth.ts | 26 ++++++++++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/frontend/src/helpers/auth.test.ts b/frontend/src/helpers/auth.test.ts index 46fc9232c..a663e8a8a 100644 --- a/frontend/src/helpers/auth.test.ts +++ b/frontend/src/helpers/auth.test.ts @@ -106,4 +106,18 @@ describe('refreshToken in-flight dedup', () => { settlePost() await p2 }) + + it('does not re-persist the token when logout happens during an in-flight refresh', async () => { + const p1 = refreshToken(true) + expect(postCallCount).toBe(1) + + // User logs out while the refresh POST is still in flight. + removeToken() + + // The in-flight POST resolves afterwards — it must not undo the logout. + settlePost() + await p1 + + expect(localStorage.getItem('token')).toBeNull() + }) }) diff --git a/frontend/src/helpers/auth.ts b/frontend/src/helpers/auth.ts index db1d967ba..1ad9ef616 100644 --- a/frontend/src/helpers/auth.ts +++ b/frontend/src/helpers/auth.ts @@ -33,6 +33,11 @@ export const removeToken = () => { savedToken = null localStorage.removeItem('token') localStorage.removeItem('desktopOAuthRefreshToken') + + // Bump the epoch and drop the in-flight refresh so a refresh that started + // before this logout can't re-persist a token after we cleared it. + authEpoch++ + inFlightRefresh = null } // Coalesces concurrent same-tab refreshes into one POST. Web Locks (below) is @@ -41,6 +46,11 @@ export const removeToken = () => { // cookie and all but one get a 401. let inFlightRefresh: Promise | null = null +// Incremented on every removeToken()/logout. A refresh captures the epoch when +// it starts and only persists its result if the epoch is unchanged, so a +// refresh that resolves after a logout can't undo it. +let authEpoch = 0 + /** * Refreshes an auth token while ensuring it is updated everywhere. * The refresh token is sent automatically as an HttpOnly cookie. @@ -60,6 +70,10 @@ export async function refreshToken(persist: boolean): Promise { } async function doRefresh(persist: boolean): Promise { + // Snapshot the epoch so we can tell if a logout happened while we awaited. + const epochAtStart = authEpoch + const loggedOutSinceStart = () => authEpoch !== epochAtStart + // In desktop mode, refresh via IPC to the Electron main process if (isDesktopApp()) { const storedRefreshToken = localStorage.getItem('desktopOAuthRefreshToken') @@ -68,6 +82,9 @@ async function doRefresh(persist: boolean): Promise { } try { const tokens = await refreshDesktopToken(window.API_URL, storedRefreshToken) + if (loggedOutSinceStart()) { + return + } saveToken(tokens.access_token, persist) localStorage.setItem('desktopOAuthRefreshToken', tokens.refresh_token) } catch (e) { @@ -81,6 +98,12 @@ async function doRefresh(persist: boolean): Promise { const tokenBeforeLock = localStorage.getItem('token') const refreshUnderLock = async () => { + // A logout may have happened while we waited for the lock — don't + // re-adopt or re-fetch a token after the user signed out. + if (loggedOutSinceStart()) { + return + } + // If the token in localStorage changed while waiting for the lock, // another tab already refreshed. Just adopt the new token. const currentToken = localStorage.getItem('token') @@ -93,6 +116,9 @@ async function doRefresh(persist: boolean): Promise { const HTTP = HTTPFactory() try { const response = await HTTP.post('user/token/refresh') + if (loggedOutSinceStart()) { + return + } saveToken(response.data.token, persist) } catch (e) { throw new Error('Error renewing token: ', {cause: e})