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']),
|
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 {
|
interface RefreshApiResponse {
|
||||||
success: boolean
|
success: boolean
|
||||||
@ -53,6 +57,7 @@ interface RefreshApiResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const isClient = typeof window !== 'undefined'
|
const isClient = typeof window !== 'undefined'
|
||||||
|
const AUTH_REFRESH_LOCK_NAME = 'ghabilee:auth-refresh'
|
||||||
let refreshPromise: Promise<string | null> | null = null
|
let refreshPromise: Promise<string | null> | null = null
|
||||||
let sessionExpiryInFlight = false
|
let sessionExpiryInFlight = false
|
||||||
|
|
||||||
@ -242,6 +247,33 @@ const refreshAccessToken = async (): Promise<string | null> => {
|
|||||||
return tokens.accessToken
|
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
|
* Exported so `AuthContext` can call this proactively (before the short-lived
|
||||||
* access token expires) instead of only reactively on a 401. See the
|
* 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.
|
* the session instead of only returning null.
|
||||||
*/
|
*/
|
||||||
export const getOrRefreshAccessToken = async (): Promise<string | null> => {
|
export const getOrRefreshAccessToken = async (): Promise<string | null> => {
|
||||||
refreshPromise ??= refreshAccessToken()
|
refreshPromise ??= refreshAccessTokenAcrossTabs()
|
||||||
.catch(() => null)
|
.catch(() => null)
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
refreshPromise = null
|
refreshPromise = null
|
||||||
@ -435,6 +467,36 @@ const shouldTryRefreshFor401 = (error: AxiosError, config: RequestConfigWithRetr
|
|||||||
return true
|
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> {
|
async function handle401Response(error: AxiosError): Promise<AxiosResponse> {
|
||||||
const config = error.config as RequestConfigWithRetry | undefined
|
const config = error.config as RequestConfigWithRetry | undefined
|
||||||
|
|
||||||
@ -451,6 +513,10 @@ async function handle401Response(error: AxiosError): Promise<AxiosResponse> {
|
|||||||
return handleSessionExpired()
|
return handleSessionExpired()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const retryWithCurrentToken = retryWithNewerStoredToken(error, config)
|
||||||
|
|
||||||
|
if (retryWithCurrentToken) return retryWithCurrentToken
|
||||||
|
|
||||||
if (!shouldTryRefreshFor401(error, config)) {
|
if (!shouldTryRefreshFor401(error, config)) {
|
||||||
// قبلاً فقط reject میشد → داشبورد ارور میدید ولی لاگاوت نمیشد
|
// قبلاً فقط reject میشد → داشبورد ارور میدید ولی لاگاوت نمیشد
|
||||||
// (مثلاً 401 بدون توکن، یا retry بعد از refresh که باز هم UNAUTHORIZED بود).
|
// (مثلاً 401 بدون توکن، یا retry بعد از refresh که باز هم UNAUTHORIZED بود).
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user