mirror of
https://github.com/go-vikunja/vikunja.git
synced 2026-08-01 03:10:13 -05:00
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:
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user