admin/config/axios.ts
alisaza 64e2c11076 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.
2026-09-10 20:26:20 +03:30

601 lines
19 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import axios, { AxiosError, AxiosHeaders, type AxiosInstance, type AxiosResponse, type InternalAxiosRequestConfig } from 'axios'
import { jwtDecode } from 'jwt-decode'
import Cookies from 'js-cookie'
import type { UserStorageData, DecodedToken } from '@/types'
import { buildGuestLoginRedirect, CONSUMER_SESSION_EXPIRED_EVENT } from '@/lib/authRouting'
import { reportBrowserEvent } from '@/lib/observability/client'
import { parseRefreshExpiry } from '@/lib/refreshTokenExpiry'
import { localizeApiErrorPayload } from '@/services/apiErrorLocalization'
import { setAccountSuspended } from '@/lib/accountSuspension'
import { applyFormDataRequestDefaults } from '@/lib/formDataRequest'
import { expireRefreshSessionOnThisOrigin } from '@/lib/expireRefreshSession'
const resolveApiBaseUrl = (raw?: string): string | undefined => {
if (!raw) return undefined
const trimmed = raw.replace(/\/+$/, '')
if (trimmed.endsWith('/api/v1')) return `${trimmed}/`
return `${trimmed}/api/v1/`
}
const AUTH_CONFIG = {
baseURL: resolveApiBaseUrl(process.env.NEXT_PUBLIC_API_URL),
cookieName: 'accessToken',
/** Legacy JS-readable cookie from the abandoned body-token model — remove only. */
legacyRefreshCookieName: 'refreshToken',
localStorageKey: 'user',
refreshPath: 'auth/refresh',
}
const TOKEN_MESSAGES = {
accessInvalid: new Set(['ACCESS_TOKEN_INVALID', 'ACCESS_TOKEN_EXPIRED', 'UNAUTHORIZED']),
}
type RequestConfigWithRetry = InternalAxiosRequestConfig & {
_retry?: boolean
/** One retry using a newer token published while this request was in flight. */
_authTokenSyncRetry?: boolean
}
interface RefreshApiResponse {
success: boolean
data?: {
accessToken: string
sessionId?: string
expiresAt?: string
}
body?: {
accessToken: string
sessionId?: string
expiresAt?: string
}
message?: string
code?: string
}
const isClient = typeof window !== 'undefined'
const AUTH_REFRESH_LOCK_NAME = 'ghabilee:auth-refresh'
let refreshPromise: Promise<string | null> | null = null
let sessionExpiryInFlight = false
/**
* صفر کردن state ماژول‌سطح axios (مثلاً بین تست‌ها، یا بعد از ورود مجدد نرم
* بدون hard navigation که ماژول را از نو لود کند).
*/
export const resetAxiosAuthModuleState = (): void => {
refreshPromise = null
sessionExpiryInFlight = false
}
const isTokenExpired = (expireTime: number): boolean => Date.now() >= expireTime
const redirectToLogin = (): void => {
if (!isClient) return
// Admin session expiry → `/auth`. Consumer/host → home + AuthGate modal.
const target = buildGuestLoginRedirect(window.location.origin, window.location.pathname, window.location.search)
const targetUrl = `${target.pathname}${target.search}`
if (target.pathname === '/auth' || target.pathname.startsWith('/auth/')) {
window.location.replace(targetUrl)
return
}
window.dispatchEvent(new CustomEvent(CONSUMER_SESSION_EXPIRED_EVENT, { detail: { targetUrl } }))
}
const clearAuthData = (): void => {
if (!isClient) return
localStorage.removeItem(AUTH_CONFIG.localStorageKey)
Cookies.remove(AUTH_CONFIG.cookieName)
Cookies.remove(AUTH_CONFIG.legacyRefreshCookieName)
Cookies.remove('userStatus')
Cookies.remove('userRole')
}
const handleSessionExpired = async (): Promise<never> => {
if (sessionExpiryInFlight) {
throw new AxiosError('نشست منقضی شده است')
}
sessionExpiryInFlight = true
try {
clearAuthData()
window.dispatchEvent(new Event('userChanged'))
await expireRefreshSessionOnThisOrigin()
redirectToLogin()
throw new AxiosError('نشست منقضی شده است')
} finally {
// بعد از اتمام این دور، ورود مجدد نرم (AuthGate بدون reload) باید بتواند
// دوباره مسیر expiry را طی کند؛ تست‌ها هم به همین دلیل flaky بودند.
sessionExpiryInFlight = false
}
}
const tryRefreshAccessToken = async (): Promise<string | null> => {
try {
return await getOrRefreshAccessToken()
} catch {
return null
}
}
const createStorageData = (
accessToken: string,
decodedAccess: DecodedToken,
refreshExpireTime: number,
sessionId?: string
): UserStorageData => ({
accessToken,
userId: decodedAccess.sub,
role: decodedAccess.role,
sessionId,
AccessTokenExpireTime: decodedAccess.exp * 1000,
refreshTokenExpireTime: refreshExpireTime,
})
const persistUserStorage = (next: UserStorageData & Record<string, unknown>): void => {
const { refreshToken: _legacyRefreshToken, ...sanitized } = next as UserStorageData & {
refreshToken?: unknown
}
localStorage.setItem(AUTH_CONFIG.localStorageKey, JSON.stringify(sanitized))
}
const setAccessCookie = (token: string, cookieExpiryTime: number): void => {
const expireInSeconds = Math.floor((cookieExpiryTime - Date.now()) / 1000)
if (expireInSeconds <= 0) return
Cookies.set(AUTH_CONFIG.cookieName, token, { expires: expireInSeconds / 86400, sameSite: 'lax' })
}
const getStoredUser = (): (UserStorageData & Record<string, unknown>) | null => {
if (!isClient) return null
const raw = localStorage.getItem(AUTH_CONFIG.localStorageKey)
if (!raw) return null
try {
return JSON.parse(raw) as UserStorageData & Record<string, unknown>
} catch {
return null
}
}
/**
* Refresh sends the Nest httpOnly cookie via `withCredentials` — JS cannot
* read that cookie. Guests (no stored session and no access cookie) must
* never hit `auth/refresh`. Do not gate on `refreshTokenExpireTime`; the
* httpOnly cookie is the source of truth.
*/
const canAttemptTokenRefresh = (): boolean => {
if (!isClient) return false
const userData = getStoredUser()
if (userData?.accessToken || userData?.userId) return true
return Boolean(Cookies.get(AUTH_CONFIG.cookieName))
}
const isTransientRefreshFailure = (error: unknown): boolean => axios.isAxiosError(error) && !error.response
const refreshAccessToken = async (): Promise<string | null> => {
if (!canAttemptTokenRefresh()) return null
const userData = getStoredUser()
const url = new URL(AUTH_CONFIG.refreshPath, AUTH_CONFIG.baseURL)
let data: RefreshApiResponse
const requestRefresh = () =>
axios.post<RefreshApiResponse>(
url.href,
{},
{
withCredentials: true,
headers: {
'Content-Type': 'application/json',
},
}
)
try {
data = (await requestRefresh()).data
} catch (error) {
// A VPN reconnect can drop the response after the server has already
// rotated the httpOnly cookie. The API permits one device-bound recovery
// retry in that short window; never retry HTTP responses.
if (isTransientRefreshFailure(error)) {
data = (await requestRefresh()).data
} else {
if (
axios.isAxiosError(error) &&
error.response?.status === 403 &&
(error.response.data as { code?: string } | undefined)?.code === 'ACCOUNT_SUSPENDED'
) {
setAccountSuspended(true)
}
throw error
}
}
const tokens = data.success ? (data.data ?? data.body) : null
if (!tokens?.accessToken) {
throw new Error(data.message ?? 'پاسخ تمدید نشست معتبر نیست')
}
const decodedAccess = jwtDecode<DecodedToken>(tokens.accessToken)
const refreshExpireTime = parseRefreshExpiry(tokens.expiresAt)
const tokenStorage = createStorageData(tokens.accessToken, decodedAccess, refreshExpireTime, tokens.sessionId)
persistUserStorage({ ...userData, ...tokenStorage })
setAccessCookie(tokens.accessToken, decodedAccess.exp * 1000)
// `localStorage.setItem` only fires the native `storage` event in *other*
// tabs, not this one — AuthContext's `user` state (and anything derived
// from it, e.g. the chat WebSocket's auth token in useChatSocket) would
// otherwise stay stale until a full page reload. See docs/prompt/_shared.md.
if (isClient) window.dispatchEvent(new Event('userChanged'))
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
* scheduler in AuthContext.tsx for why that matters — proxy.ts checks the
* `accessToken` cookie and the Nest httpOnly refresh cookie on every
* navigation, including soft ones, and a stale access cookie there forces a
* full-page reload.
*
* Never rejects: refresh failures resolve to `null` so fire-and-forget callers
* cannot leak `unhandledrejection` (Sentry). Use
* `refreshAccessTokenProactively` when a failed rotation should soft-expire
* the session instead of only returning null.
*/
export const getOrRefreshAccessToken = async (): Promise<string | null> => {
refreshPromise ??= refreshAccessTokenAcrossTabs()
.catch(() => null)
.finally(() => {
refreshPromise = null
})
return refreshPromise
}
/**
* Proactive access-token rotation for the AuthContext expiry timer.
* On failure runs the same soft session-expiry path as a 401 interceptor
* (clear local auth, expire refresh cookie, open AuthGate / `/auth`).
* Never rejects — safe for `void refreshAccessTokenProactively()`.
*/
export const refreshAccessTokenProactively = async (): Promise<void> => {
const token = await getOrRefreshAccessToken()
if (token) return
try {
await handleSessionExpired()
} catch {
// handleSessionExpired always throws after cleanup; swallow for timers.
}
}
const axiosInstance: AxiosInstance = axios.create({
baseURL: AUTH_CONFIG.baseURL,
withCredentials: true,
})
function attachAuthorization(config: InternalAxiosRequestConfig, token: string, cookieExpiryTime?: number): InternalAxiosRequestConfig {
const headers = AxiosHeaders.from(config.headers)
headers.set('Authorization', `Bearer ${token}`)
config.headers = headers
if (cookieExpiryTime) setAccessCookie(token, cookieExpiryTime)
return config
}
/**
* Relative `url` values resolve against our API `baseURL` and are always
* treated as same-origin backend requests (JWT attached). Absolute URLs are
* only same-backend when their origin matches `NEXT_PUBLIC_API_URL` — used so
* bearer tokens are never sent to unrelated hosts.
*/
function isSameBackendRequest(config: { url?: string }): boolean {
const url = config.url ?? ''
if (!/^https?:\/\//i.test(url)) return true
const apiBaseUrl = AUTH_CONFIG.baseURL
if (!apiBaseUrl) return false
try {
return new URL(url).origin === new URL(apiBaseUrl).origin
} catch {
return false
}
}
async function handleUserWithStorageToken(config: InternalAxiosRequestConfig): Promise<InternalAxiosRequestConfig> {
const userData = getStoredUser()
if (!userData?.accessToken) return config
const { accessToken, AccessTokenExpireTime } = userData
// Queue new requests while token refresh is in flight (including 401 path)
if (refreshPromise) {
const refreshedToken = await tryRefreshAccessToken()
if (!refreshedToken) {
return handleSessionExpired()
}
return attachAuthorization(config, refreshedToken)
}
if (!isTokenExpired(AccessTokenExpireTime)) {
return attachAuthorization(config, accessToken, AccessTokenExpireTime)
}
const newToken = await tryRefreshAccessToken()
if (!newToken) {
return handleSessionExpired()
}
axiosInstance.defaults.headers.Authorization = `Bearer ${newToken}`
return attachAuthorization(config, newToken)
}
async function handleUserWithCookieToken(
config: InternalAxiosRequestConfig,
accessTokenCookie: string
): Promise<InternalAxiosRequestConfig> {
try {
const decodedAccessToken = jwtDecode<DecodedToken>(accessTokenCookie)
if (!isTokenExpired(decodedAccessToken.exp * 1000)) {
return attachAuthorization(config, accessTokenCookie, decodedAccessToken.exp * 1000)
}
} catch {
// Fall through to refresh when the cookie JWT is malformed/expired.
}
// Cookie-only sessions (no localStorage user yet) must still rotate via
// the httpOnly refresh cookie before clearing auth — otherwise an expired
// access cookie logs the user out even when refresh is still valid.
if (!canAttemptTokenRefresh()) {
return handleSessionExpired()
}
const newToken = await tryRefreshAccessToken()
if (!newToken) {
return handleSessionExpired()
}
axiosInstance.defaults.headers.Authorization = `Bearer ${newToken}`
return attachAuthorization(config, newToken)
}
axiosInstance.interceptors.request.use(
async (config: InternalAxiosRequestConfig) => {
if (!isSameBackendRequest(config)) {
config.withCredentials = false
return config
}
config.withCredentials = true
applyFormDataRequestDefaults(config)
if (!isClient) return config
const userData = getStoredUser()
if (userData?.accessToken) return handleUserWithStorageToken(config)
const accessTokenCookie = Cookies.get(AUTH_CONFIG.cookieName)
if (accessTokenCookie) return handleUserWithCookieToken(config, accessTokenCookie)
return config
},
(error: AxiosError) => Promise.reject(error)
)
const isRefreshTokenRequest = (config?: InternalAxiosRequestConfig): boolean => (config?.url ?? '').includes('auth/refresh')
const getErrorMessage = (error: AxiosError): string => {
const data = error.response?.data as { message?: string; code?: string } | undefined
return data?.code ?? data?.message ?? ''
}
const isSessionAuthFailure = (error: AxiosError): boolean => {
const message = getErrorMessage(error)
// Bare 401 (no code/message) is treated as a dead session.
if (!message) return true
return TOKEN_MESSAGES.accessInvalid.has(message) || message === 'Unauthorized'
}
const shouldTryRefreshFor401 = (error: AxiosError, config: RequestConfigWithRetry): boolean => {
if (isRefreshTokenRequest(config)) return false
if (config._retry) return false
if (!canAttemptTokenRefresh()) return false
if (!isSessionAuthFailure(error)) return false
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
if (!config) {
// بدون config نمی‌توان retry کرد؛ 401 نشست را expire کن، بقیه (مثلاً OTP) را رد کن.
if (isSessionAuthFailure(error)) {
return handleSessionExpired()
}
return Promise.reject(error)
}
if (isRefreshTokenRequest(config)) {
return handleSessionExpired()
}
const retryWithCurrentToken = retryWithNewerStoredToken(error, config)
if (retryWithCurrentToken) return retryWithCurrentToken
if (!shouldTryRefreshFor401(error, config)) {
// قبلاً فقط reject می‌شد → داشبورد ارور می‌دید ولی لاگ‌اوت نمی‌شد
// (مثلاً 401 بدون توکن، یا retry بعد از refresh که باز هم UNAUTHORIZED بود).
// OTP_INVALID و سایر 401های کسب‌وکاری نباید نشست را پاک کنند.
if (isSessionAuthFailure(error)) {
return handleSessionExpired()
}
return Promise.reject(error)
}
config._retry = true
try {
const newToken = await tryRefreshAccessToken()
if (!newToken) throw new Error('تمدید نشست ناموفق بود')
config.headers = AxiosHeaders.from(config.headers).set('Authorization', `Bearer ${newToken}`)
return await axiosInstance.request(config)
} catch (_refreshError) {
return handleSessionExpired()
}
}
axiosInstance.interceptors.response.use(
(response: AxiosResponse) => {
const path = (response.config.url ?? '').split('?')[0]
// A successful authenticated profile request is authoritative: the
// account has been reactivated since the suspension was first observed.
if (/\/?users\/me\/?$/.test(path)) setAccountSuspended(false)
return response
},
async (error: AxiosError) => {
const rawPath = (error.config?.url ?? 'unknown').split('?')[0]
const endpoint = rawPath.replace(/\b[0-9a-f]{8}-[0-9a-f-]{27,}\b/gi, ':id').replace(/\/\d+(?=\/|$)/g, '/:id')
const status = error.response?.status ?? 0
if (status === 403 && getErrorMessage(error) === 'ACCOUNT_SUSPENDED') {
setAccountSuspended(true)
}
// Only surface server/network failures to Sentry — expected 4xx (auth,
// validation, not-found) would otherwise flood Issues.
if (!error.response || status >= 500) {
reportBrowserEvent({
name: 'api_failure',
level: 'error',
attributes: {
endpoint: endpoint.slice(0, 160),
method: error.config?.method?.toUpperCase() ?? 'UNKNOWN',
status,
},
})
}
if (error.response?.status === 401 && isSameBackendRequest(error.config ?? {})) {
try {
return await handle401Response(error)
} catch (refreshError) {
if (refreshError instanceof AxiosError && refreshError.response) {
refreshError.response.data = localizeApiErrorPayload(refreshError.response.data, refreshError.response.status)
}
return Promise.reject(refreshError instanceof Error ? refreshError : new Error('تمدید نشست ناموفق بود'))
}
}
if (error.response) {
error.response.data = localizeApiErrorPayload(error.response.data, error.response.status)
}
return Promise.reject(error)
}
)
export default axiosInstance