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']), refreshInvalid: new Set(['REFRESH_TOKEN_INVALID', 'REFRESH_TOKEN_EXPIRED', 'UNAUTHORIZED']), } type RequestConfigWithRetry = InternalAxiosRequestConfig & { _retry?: 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' let refreshPromise: Promise | 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 => { 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 => { 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): void => { const { refreshToken: _legacyRefreshToken, ...sanitized } = next as UserStorageData & { refreshToken?: unknown } void _legacyRefreshToken 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) | null => { if (!isClient) return null const raw = localStorage.getItem(AUTH_CONFIG.localStorageKey) if (!raw) return null try { return JSON.parse(raw) as UserStorageData & Record } 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 => { if (!canAttemptTokenRefresh()) return null const userData = getStoredUser() const url = new URL(AUTH_CONFIG.refreshPath, AUTH_CONFIG.baseURL) let data: RefreshApiResponse const requestRefresh = () => axios.post( 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(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 } /** * 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 => { refreshPromise ??= refreshAccessToken() .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 => { 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 { 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 { try { const decodedAccessToken = jwtDecode(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 shouldTryRefreshFor401 = (error: AxiosError, config: RequestConfigWithRetry): boolean => { if (isRefreshTokenRequest(config)) return false if (config._retry) return false if (!canAttemptTokenRefresh()) return false const message = getErrorMessage(error) if (TOKEN_MESSAGES.refreshInvalid.has(message) && isRefreshTokenRequest(config)) return false if (!message) return true return TOKEN_MESSAGES.accessInvalid.has(message) || message === 'Unauthorized' } async function handle401Response(error: AxiosError): Promise { const config = error.config as RequestConfigWithRetry | undefined if (!config) { return Promise.reject(error) } if (isRefreshTokenRequest(config)) { return handleSessionExpired() } if (!shouldTryRefreshFor401(error, config)) { 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