diff --git a/config/axios.functional.test.ts b/config/axios.functional.test.ts new file mode 100644 index 0000000..2d4f120 --- /dev/null +++ b/config/axios.functional.test.ts @@ -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) => { + // 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') + } + } + }) +}) diff --git a/config/axios.ts b/config/axios.ts index 66c78f0..1093d6c 100644 --- a/config/axios.ts +++ b/config/axios.ts @@ -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 | null = null let sessionExpiryInFlight = false @@ -242,6 +247,33 @@ const refreshAccessToken = async (): Promise => { 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 => { + const observedAccessToken = getStoredUser()?.accessToken ?? null + const runAfterLock = async (): Promise => { + 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 => { * the session instead of only returning null. */ export const getOrRefreshAccessToken = async (): Promise => { - 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 | 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 { const config = error.config as RequestConfigWithRetry | undefined @@ -451,6 +513,10 @@ async function handle401Response(error: AxiosError): Promise { return handleSessionExpired() } + const retryWithCurrentToken = retryWithNewerStoredToken(error, config) + + if (retryWithCurrentToken) return retryWithCurrentToken + if (!shouldTryRefreshFor401(error, config)) { // قبلاً فقط reject می‌شد → داشبورد ارور می‌دید ولی لاگ‌اوت نمی‌شد // (مثلاً 401 بدون توکن، یا retry بعد از refresh که باز هم UNAUTHORIZED بود).