Added a new configuration file `.oxlintrc.json` to incorporate Oxlint into the project, allowing for improved linting of TypeScript and React code. Updated the linting scripts in `package.json` to run Oxlint alongside ESLint, ensuring a comprehensive linting process. This change enhances code quality and consistency across the codebase.
535 lines
17 KiB
TypeScript
535 lines
17 KiB
TypeScript
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
|
||
}
|
||
|
||
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
|