Added functionality to synchronize access token refresh across multiple tabs using the `navigator.locks` API. This prevents race conditions when refreshing tokens, ensuring that a newer token published by another tab is reused instead of rotating the refresh token unnecessarily. Introduced a new method `refreshAccessTokenAcrossTabs` and updated the `getOrRefreshAccessToken` function to utilize this new method. Enhanced error handling for 401 responses to retry with the latest stored token when applicable.
103 lines
3.3 KiB
TypeScript
103 lines
3.3 KiB
TypeScript
import axios, { AxiosError, type InternalAxiosRequestConfig } from 'axios'
|
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
|
|
const mocks = vi.hoisted(() => ({
|
|
expireRefreshSession: vi.fn().mockResolvedValue(undefined),
|
|
}))
|
|
|
|
vi.mock('@/lib/expireRefreshSession', () => ({
|
|
expireRefreshSessionOnThisOrigin: () => mocks.expireRefreshSession(),
|
|
}))
|
|
|
|
const writeStoredToken = (accessToken: string): void => {
|
|
window.localStorage.setItem(
|
|
'user',
|
|
JSON.stringify({
|
|
accessToken,
|
|
userId: 'admin-1',
|
|
role: 'admin',
|
|
sessionId: accessToken === 'access-a' ? 'session-a' : 'session-b',
|
|
AccessTokenExpireTime: Date.now() + 10 * 60_000,
|
|
})
|
|
)
|
|
}
|
|
|
|
describe('admin access-token refresh races', () => {
|
|
beforeEach(async () => {
|
|
vi.clearAllMocks()
|
|
vi.restoreAllMocks()
|
|
window.localStorage.clear()
|
|
const { resetAxiosAuthModuleState } = await import('@/config/axios')
|
|
|
|
resetAxiosAuthModuleState()
|
|
})
|
|
|
|
it('replays a late 401 with the newer stored token without rotating refresh again', async () => {
|
|
writeStoredToken('access-a')
|
|
const { default: axiosInstance } = await import('@/config/axios')
|
|
const refreshRequest = vi.spyOn(axios, 'post')
|
|
const authorizationHeaders: string[] = []
|
|
let requestCount = 0
|
|
const adapter = async (config: InternalAxiosRequestConfig) => {
|
|
requestCount += 1
|
|
authorizationHeaders.push(String(config.headers.get('Authorization')))
|
|
|
|
if (requestCount === 1) {
|
|
// Another request/tab finished refresh while this request was in flight.
|
|
writeStoredToken('access-b')
|
|
throw new AxiosError('Unauthorized', 'ERR_BAD_REQUEST', config, undefined, {
|
|
config,
|
|
data: { message: 'Unauthorized' },
|
|
headers: {},
|
|
status: 401,
|
|
statusText: 'Unauthorized',
|
|
})
|
|
}
|
|
|
|
return {
|
|
config,
|
|
data: { ok: true },
|
|
headers: {},
|
|
status: 200,
|
|
statusText: 'OK',
|
|
}
|
|
}
|
|
|
|
await expect(axiosInstance.get('/race', { adapter })).resolves.toMatchObject({ data: { ok: true } })
|
|
expect(authorizationHeaders).toEqual(['Bearer access-a', 'Bearer access-b'])
|
|
expect(refreshRequest).not.toHaveBeenCalled()
|
|
expect(mocks.expireRefreshSession).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('reuses a token published by another tab while waiting for the refresh lock', async () => {
|
|
writeStoredToken('access-a')
|
|
const locksDescriptor = Object.getOwnPropertyDescriptor(navigator, 'locks')
|
|
const requestLock = vi.fn(async (_name: string, callback: () => Promise<string | null>) => {
|
|
// The other lock owner completes rotation before this callback starts.
|
|
writeStoredToken('access-b')
|
|
|
|
return callback()
|
|
})
|
|
|
|
Object.defineProperty(navigator, 'locks', {
|
|
configurable: true,
|
|
value: { request: requestLock },
|
|
})
|
|
|
|
try {
|
|
const { getOrRefreshAccessToken } = await import('@/config/axios')
|
|
const refreshRequest = vi.spyOn(axios, 'post')
|
|
|
|
await expect(getOrRefreshAccessToken()).resolves.toBe('access-b')
|
|
expect(requestLock).toHaveBeenCalledOnce()
|
|
expect(refreshRequest).not.toHaveBeenCalled()
|
|
} finally {
|
|
if (locksDescriptor) {
|
|
Object.defineProperty(navigator, 'locks', locksDescriptor)
|
|
} else {
|
|
Reflect.deleteProperty(navigator, 'locks')
|
|
}
|
|
}
|
|
})
|
|
})
|