admin/config/axios.ts
2026-09-05 15:15:35 +03:30

536 lines
17 KiB
TypeScript
Raw 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 }
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<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
}
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<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
}
/**
* 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 ??= 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<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
}
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()
}
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