124 lines
6.3 KiB
TypeScript
124 lines
6.3 KiB
TypeScript
'use client'
|
|
|
|
import axios, { AxiosError, InternalAxiosRequestConfig } from 'axios'
|
|
import { expireSession, getAccessToken, getRefreshToken, getSessionIdentity, saveTokens, validTokens } from '@/lib/auth'
|
|
|
|
type RetryRequest = InternalAxiosRequestConfig & { _retry?: boolean; _sessionIdentity?: string }
|
|
class InvalidSession extends Error {}
|
|
let pendingRefresh: { identity: string; promise: Promise<string> } | null = null
|
|
const leaseKey = 'customerRefreshLease'
|
|
type RefreshLease = { owner: string; identity: string; expires: number }
|
|
|
|
function readLease(): RefreshLease | null {
|
|
try {
|
|
const value = JSON.parse(window.localStorage.getItem(leaseKey) || 'null')
|
|
return value && typeof value.owner === 'string' && typeof value.identity === 'string' &&
|
|
typeof value.expires === 'number' && Number.isFinite(value.expires) ? value : null
|
|
} catch { return null }
|
|
}
|
|
async function withRefreshLease(identity: string, action: () => Promise<string>): Promise<string> {
|
|
const owner = `${Date.now()}-${Math.random()}`
|
|
const deadline = Date.now() + 20000
|
|
while (Date.now() < deadline) {
|
|
if (identity !== getSessionIdentity()) throw new Error('نشست تغییر کرده است.')
|
|
const lease = readLease()
|
|
if (!lease || lease.identity !== identity || lease.expires <= Date.now()) {
|
|
try { window.localStorage.setItem(leaseKey, JSON.stringify({ owner, identity, expires: Date.now() + 15000 })) }
|
|
catch { return action() }
|
|
// Yield before checking ownership so simultaneous contenders see
|
|
// the same winner rather than both sending a rotating JWT.
|
|
await new Promise(resolve => setTimeout(resolve, 50))
|
|
if (readLease()?.owner === owner) {
|
|
try { return await action() }
|
|
finally {
|
|
try { if (readLease()?.owner === owner) window.localStorage.removeItem(leaseKey) }
|
|
catch { /* An abandoned lease expires, including after a closed tab. */ }
|
|
}
|
|
}
|
|
}
|
|
await new Promise(resolve => setTimeout(resolve, 50))
|
|
}
|
|
throw new Error('تمدید نشست موقتاً انجام نشد. دوباره تلاش کنید.')
|
|
}
|
|
|
|
const axiosInstance = axios.create({
|
|
baseURL: process.env.NEXT_PUBLIC_API_URL + '/',
|
|
timeout: 10000,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
})
|
|
axiosInstance.interceptors.request.use((request: RetryRequest) => {
|
|
const access = getAccessToken()
|
|
if (access) request.headers.Authorization = 'Bearer ' + access
|
|
else delete request.headers.Authorization
|
|
request._sessionIdentity ??= getSessionIdentity()
|
|
return request
|
|
})
|
|
|
|
async function refreshAccess(identity: string): Promise<string> {
|
|
const originalRefresh = getRefreshToken()
|
|
async function rotate(): Promise<string> {
|
|
if (identity !== getSessionIdentity()) throw new Error('نشست تغییر کرده است.')
|
|
const refresh = getRefreshToken()
|
|
if (!refresh) throw new InvalidSession()
|
|
// A different tab may have rotated while this tab waited for the lock.
|
|
if (refresh !== originalRefresh && getAccessToken()) return getAccessToken()!
|
|
try {
|
|
const response = await axios.post(process.env.NEXT_PUBLIC_API_URL + '/auth/token/refresh/', { refresh }, { timeout: 10000 })
|
|
if (identity !== getSessionIdentity()) throw new Error('نشست تغییر کرده است.')
|
|
if (getRefreshToken() !== refresh) {
|
|
if (getAccessToken()) return getAccessToken()!
|
|
throw new Error('نشست تغییر کرده است.')
|
|
}
|
|
if (!validTokens(response.data)) throw new InvalidSession()
|
|
saveTokens(response.data, refresh)
|
|
return response.data.access
|
|
} catch (error) {
|
|
if (identity !== getSessionIdentity()) throw new Error('نشست تغییر کرده است.')
|
|
if (getRefreshToken() !== refresh && getAccessToken()) return getAccessToken()!
|
|
if (axios.isAxiosError(error) && [400, 401, 403].includes(error.response?.status || 0)) throw new InvalidSession()
|
|
throw error
|
|
}
|
|
}
|
|
// Web Locks serialize refresh rotation across tabs in supporting browsers.
|
|
if (typeof navigator !== 'undefined' && navigator.locks) {
|
|
return navigator.locks.request('crm-customer-jwt-refresh', rotate)
|
|
}
|
|
return withRefreshLease(identity, rotate)
|
|
}
|
|
|
|
axiosInstance.interceptors.response.use(response => {
|
|
const identity = (response.config as RetryRequest)._sessionIdentity
|
|
if (identity && identity !== getSessionIdentity()) return Promise.reject(new Error('نشست تغییر کرده است.'))
|
|
return response
|
|
}, async (error: AxiosError) => {
|
|
const request = error.config as RetryRequest | undefined
|
|
if (error.response?.status !== 401 || !request || /\/auth\//.test(request.url || '')) return Promise.reject(error)
|
|
const identity = request._sessionIdentity!
|
|
// Never replay a previous person's request under a newly logged-in account.
|
|
if (identity !== getSessionIdentity()) return Promise.reject(error)
|
|
if (request._retry) {
|
|
const latest = getAccessToken()
|
|
if (!latest || request.headers.Authorization === 'Bearer ' + latest) expireSession()
|
|
return Promise.reject(error)
|
|
}
|
|
request._retry = true
|
|
const currentAccess = getAccessToken()
|
|
if (currentAccess && request.headers.Authorization !== 'Bearer ' + currentAccess) return axiosInstance(request)
|
|
const refresh = getRefreshToken()
|
|
try {
|
|
if (!pendingRefresh || pendingRefresh.identity !== identity) {
|
|
const pending = { identity, promise: refreshAccess(identity) }
|
|
pendingRefresh = pending
|
|
void pending.promise.finally(() => { if (pendingRefresh === pending) pendingRefresh = null }).catch(() => {})
|
|
}
|
|
const access = await pendingRefresh.promise
|
|
if (identity !== getSessionIdentity()) return Promise.reject(error)
|
|
request.headers.Authorization = 'Bearer ' + access
|
|
return axiosInstance(request)
|
|
} catch (refreshError) {
|
|
if (refreshError instanceof InvalidSession && identity === getSessionIdentity() && getRefreshToken() === refresh) expireSession()
|
|
return Promise.reject(refreshError)
|
|
}
|
|
})
|
|
export default axiosInstance
|