fix(desktop): coordinate token refresh across renderer windows

The desktop branch of doRefresh() returned before the Web Locks section,
so the main window and the quick entry window could both POST the same
single-use OAuth refresh token on startup. The server rotates it on first
use, so the losing window got a 401 and logged the user out.

Move the desktop branch inside the lock and adopt the JWT another window
already fetched when the stored refresh token changed while queued.

Fixes https://github.com/go-vikunja/vikunja/issues/3275
This commit is contained in:
kolaente
2026-07-29 22:23:21 +02:00
parent 654bb94930
commit c0d7c0ffea
2 changed files with 144 additions and 23 deletions
+113 -3
View File
@@ -1,6 +1,6 @@
import {describe, it, expect, vi, beforeEach, afterEach} from 'vitest'
import {refreshToken, removeToken} from './auth'
import {getToken, refreshToken, removeToken} from './auth'
// Count how many times the refresh endpoint is actually POSTed. The whole point
// of the in-flight dedup is that concurrent refreshToken() calls share a single
@@ -19,11 +19,16 @@ vi.mock('@/helpers/fetcher', () => ({
}),
}))
vi.mock('@/helpers/desktopAuth', () => ({
isDesktopApp: () => false,
const desktop = vi.hoisted(() => ({
isDesktop: false,
refreshDesktopToken: vi.fn(),
}))
vi.mock('@/helpers/desktopAuth', () => ({
isDesktopApp: () => desktop.isDesktop,
refreshDesktopToken: desktop.refreshDesktopToken,
}))
const FAKE_TOKEN = 'header.payload.signature'
function settlePost() {
@@ -151,3 +156,108 @@ describe('refreshToken in-flight dedup', () => {
await Promise.all([pB, pB2])
})
})
describe('refreshToken in desktop mode', () => {
const originalLocks = navigator.locks
let resolveRefresh: ((tokens: unknown) => void) | null = null
// Runs the lock callback only once the returned release function is called,
// so a test can act as the other renderer window while we sit in the queue.
function stubQueuedLocks() {
let openGate: (() => void) | null = null
const gate = new Promise<void>((resolve) => {
openGate = resolve
})
Object.defineProperty(navigator, 'locks', {
value: {
request: async (_name: string, cb: () => unknown) => {
await gate
return cb()
},
},
configurable: true,
writable: true,
})
return () => openGate?.()
}
beforeEach(() => {
resolveRefresh = null
removeToken()
localStorage.clear()
desktop.isDesktop = true
desktop.refreshDesktopToken.mockReset()
desktop.refreshDesktopToken.mockImplementation(() => new Promise((resolve) => {
resolveRefresh = resolve
}))
Object.defineProperty(navigator, 'locks', {
value: {request: (_name: string, cb: () => unknown) => cb()},
configurable: true,
writable: true,
})
})
afterEach(() => {
desktop.isDesktop = false
Object.defineProperty(navigator, 'locks', {
value: originalLocks,
configurable: true,
writable: true,
})
})
it('coalesces concurrent calls into a single IPC refresh', async () => {
localStorage.setItem('desktopOAuthRefreshToken', 'refresh-1')
const p1 = refreshToken(true)
const p2 = refreshToken(true)
expect(desktop.refreshDesktopToken).toHaveBeenCalledTimes(1)
resolveRefresh?.({access_token: FAKE_TOKEN, refresh_token: 'refresh-2'})
await Promise.all([p1, p2])
expect(desktop.refreshDesktopToken).toHaveBeenCalledTimes(1)
expect(localStorage.getItem('token')).toBe(FAKE_TOKEN)
expect(localStorage.getItem('desktopOAuthRefreshToken')).toBe('refresh-2')
})
it('adopts the token another renderer window refreshed instead of spending the rotated one', async () => {
localStorage.setItem('desktopOAuthRefreshToken', 'refresh-1')
localStorage.setItem('token', 'old-token')
const openGate = stubQueuedLocks()
// This window queues for the lock...
const p = refreshToken(true)
// ...while the other window wins it and rotates both tokens.
localStorage.setItem('desktopOAuthRefreshToken', 'refresh-2')
localStorage.setItem('token', FAKE_TOKEN)
openGate()
await p
expect(desktop.refreshDesktopToken).not.toHaveBeenCalled()
expect(getToken()).toBe(FAKE_TOKEN)
expect(localStorage.getItem('desktopOAuthRefreshToken')).toBe('refresh-2')
})
it('does not re-persist tokens when logout happens during an in-flight refresh', async () => {
localStorage.setItem('desktopOAuthRefreshToken', 'refresh-1')
const p = refreshToken(true)
expect(desktop.refreshDesktopToken).toHaveBeenCalledTimes(1)
removeToken()
resolveRefresh?.({access_token: FAKE_TOKEN, refresh_token: 'refresh-2'})
await p
expect(localStorage.getItem('token')).toBeNull()
expect(localStorage.getItem('desktopOAuthRefreshToken')).toBeNull()
})
})
+31 -20
View File
@@ -80,28 +80,10 @@ async function doRefresh(persist: boolean): Promise<void> {
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')
if (!storedRefreshToken) {
throw new Error('No desktop OAuth refresh token available')
}
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) {
throw new Error('Error renewing token: ', {cause: e})
}
return
}
// Capture the token before waiting for the lock so we can detect
// Capture the tokens before waiting for the lock so we can detect
// if another tab refreshed while we were queued.
const tokenBeforeLock = localStorage.getItem('token')
const desktopRefreshTokenBeforeLock = localStorage.getItem('desktopOAuthRefreshToken')
const refreshUnderLock = async () => {
// A logout may have happened while we waited for the lock — don't
@@ -110,6 +92,35 @@ async function doRefresh(persist: boolean): Promise<void> {
return
}
// In desktop mode, refresh via IPC to the Electron main process
if (isDesktopApp()) {
const storedRefreshToken = localStorage.getItem('desktopOAuthRefreshToken')
if (storedRefreshToken !== desktopRefreshTokenBeforeLock) {
const currentToken = localStorage.getItem('token')
if (currentToken) {
savedToken = currentToken
return
}
}
if (!storedRefreshToken) {
throw new Error('No desktop OAuth refresh token available')
}
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) {
throw new Error('Error renewing token: ', {cause: e})
}
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')