feat(axios): implement token synchronization across tabs for access token refresh

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.
This commit is contained in:
alisaza 2026-09-10 20:26:20 +03:30
parent 661953f7ca
commit 64e2c11076
2 changed files with 170 additions and 2 deletions

View File

@ -0,0 +1,102 @@
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')
}
}
})
})

View File

@ -34,7 +34,11 @@ const TOKEN_MESSAGES = {
accessInvalid: new Set(['ACCESS_TOKEN_INVALID', 'ACCESS_TOKEN_EXPIRED', 'UNAUTHORIZED']),
}
type RequestConfigWithRetry = InternalAxiosRequestConfig & { _retry?: boolean }
type RequestConfigWithRetry = InternalAxiosRequestConfig & {
_retry?: boolean
/** One retry using a newer token published while this request was in flight. */
_authTokenSyncRetry?: boolean
}
interface RefreshApiResponse {
success: boolean
@ -53,6 +57,7 @@ interface RefreshApiResponse {
}
const isClient = typeof window !== 'undefined'
const AUTH_REFRESH_LOCK_NAME = 'ghabilee:auth-refresh'
let refreshPromise: Promise<string | null> | null = null
let sessionExpiryInFlight = false
@ -242,6 +247,33 @@ const refreshAccessToken = async (): Promise<string | null> => {
return tokens.accessToken
}
/**
* Serialize refresh-token rotation across same-origin tabs. The module-level
* promise only protects one JavaScript runtime; without this lock, two tabs
* can submit the same rotating cookie and successively revoke each other's
* freshly-issued sessions.
*/
const refreshAccessTokenAcrossTabs = async (): Promise<string | null> => {
const observedAccessToken = getStoredUser()?.accessToken ?? null
const runAfterLock = async (): Promise<string | null> => {
const current = getStoredUser()
// Another tab refreshed while this tab was waiting for the lock. Reuse
// its published access token instead of rotating the shared cookie again.
if (current?.accessToken && current.accessToken !== observedAccessToken && !isTokenExpired(current.AccessTokenExpireTime)) {
setAccessCookie(current.accessToken, current.AccessTokenExpireTime)
return current.accessToken
}
return refreshAccessToken()
}
if (!isClient || !navigator.locks) return runAfterLock()
return navigator.locks.request(AUTH_REFRESH_LOCK_NAME, runAfterLock)
}
/**
* Exported so `AuthContext` can call this proactively (before the short-lived
* access token expires) instead of only reactively on a 401. See the
@ -256,7 +288,7 @@ const refreshAccessToken = async (): Promise<string | null> => {
* the session instead of only returning null.
*/
export const getOrRefreshAccessToken = async (): Promise<string | null> => {
refreshPromise ??= refreshAccessToken()
refreshPromise ??= refreshAccessTokenAcrossTabs()
.catch(() => null)
.finally(() => {
refreshPromise = null
@ -435,6 +467,36 @@ const shouldTryRefreshFor401 = (error: AxiosError, config: RequestConfigWithRetr
return true
}
const getRequestBearerToken = (config: InternalAxiosRequestConfig): string | null => {
const authorization = AxiosHeaders.from(config.headers).get('Authorization')
if (typeof authorization !== 'string') return null
const match = /^Bearer\s+(.+)$/i.exec(authorization.trim())
return match?.[1] ?? null
}
/**
* A refresh revokes the old refresh-token row while requests using its access
* token may still be in flight. If a newer token has already been published,
* replay the late 401 with that token without rotating the cookie yet again.
*/
const retryWithNewerStoredToken = (error: AxiosError, config: RequestConfigWithRetry): Promise<AxiosResponse> | null => {
if (config._authTokenSyncRetry || !isSessionAuthFailure(error)) return null
const requestToken = getRequestBearerToken(config)
const current = getStoredUser()
if (!requestToken || !current?.accessToken || current.accessToken === requestToken || isTokenExpired(current.AccessTokenExpireTime)) {
return null
}
config._authTokenSyncRetry = true
config.headers = AxiosHeaders.from(config.headers).set('Authorization', `Bearer ${current.accessToken}`)
return axiosInstance.request(config)
}
async function handle401Response(error: AxiosError): Promise<AxiosResponse> {
const config = error.config as RequestConfigWithRetry | undefined
@ -451,6 +513,10 @@ async function handle401Response(error: AxiosError): Promise<AxiosResponse> {
return handleSessionExpired()
}
const retryWithCurrentToken = retryWithNewerStoredToken(error, config)
if (retryWithCurrentToken) return retryWithCurrentToken
if (!shouldTryRefreshFor401(error, config)) {
// قبلاً فقط reject می‌شد → داشبورد ارور می‌دید ولی لاگ‌اوت نمی‌شد
// (مثلاً 401 بدون توکن، یا retry بعد از refresh که باز هم UNAUTHORIZED بود).