fix(auth): don't let an in-flight refresh repopulate the token after logout

This commit is contained in:
kolaente 2026-06-19 22:29:39 +02:00
parent badb000269
commit 0949f4c854
2 changed files with 40 additions and 0 deletions

View File

@ -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()
})
})

View File

@ -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<void> | 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<void> {
}
async function doRefresh(persist: boolean): Promise<void> {
// 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<void> {
}
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<void> {
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<void> {
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})