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:
parent
661953f7ca
commit
64e2c11076
102
config/axios.functional.test.ts
Normal file
102
config/axios.functional.test.ts
Normal 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')
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
@ -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 بود).
|
||||
|
||||
Loading…
Reference in New Issue
Block a user