Spaces:
Paused
Paused
| /** | |
| * src/api/client.js | |
| * ────────────────── | |
| * Axios instance with automatic token refresh on 401. | |
| * | |
| * Auth flow: | |
| * 1. Every request carries the access token (Authorization: Bearer …) | |
| * 2. On 401 (expired) → silently POST /api/auth/refresh (sends httpOnly | |
| * refresh cookie automatically) → store new access token → retry | |
| * 3. If refresh also fails → clear session → redirect /login | |
| * | |
| * CSRF: double-submit pattern. | |
| * The backend sets a readable csrf_token cookie (non-httpOnly, path=/). | |
| * We read it and echo it back in X-CSRF-Token on mutating requests. | |
| * /auth/refresh and /auth/logout are exempt on the backend side. | |
| */ | |
| import axios from 'axios' | |
| import { clearTokens, getAccessToken, setTokens } from '@/api/tokenStore.js' | |
| const apiClient = axios.create({ | |
| baseURL: '/api', | |
| timeout: 80000, | |
| headers: { 'Content-Type': 'application/json' }, | |
| withCredentials: true, // always send cookies (refresh token, csrf_token) | |
| }) | |
| // ── CSRF helper ─────────────────────────────────────────────────────────────── | |
| function getCsrfToken() { | |
| const match = document.cookie.match(/(?:^|;\s*)csrf_token=([^;]+)/) | |
| return match ? decodeURIComponent(match[1]) : null | |
| } | |
| // ── Request interceptor: attach access token + CSRF ─────────────────────────── | |
| apiClient.interceptors.request.use( | |
| (config) => { | |
| if (config.data instanceof FormData) { | |
| delete config.headers['Content-Type'] | |
| delete config.headers['content-type'] | |
| } | |
| const token = getAccessToken() | |
| if (token) { | |
| config.headers.Authorization = `Bearer ${token}` | |
| } | |
| const method = (config.method || 'get').toLowerCase() | |
| if (['post', 'put', 'patch', 'delete'].includes(method)) { | |
| const csrf = getCsrfToken() | |
| if (csrf) config.headers['X-CSRF-Token'] = csrf | |
| } | |
| return config | |
| }, | |
| (error) => Promise.reject(error), | |
| ) | |
| // ── Token refresh state ─────────────────────────────────────────────────────── | |
| let _refreshing = false | |
| let _pendingQueue = [] // requests waiting while refresh is in progress | |
| function _flushQueue(error, token = null) { | |
| _pendingQueue.forEach((p) => (error ? p.reject(error) : p.resolve(token))) | |
| _pendingQueue = [] | |
| } | |
| // ── Response interceptor: handle 401 / 403 ─────────────────────────────────── | |
| apiClient.interceptors.response.use( | |
| (response) => response, | |
| async (err) => { | |
| const original = err.config | |
| const status = err.response?.status | |
| // ── Automatic token refresh on 401 ────────────────────────────────────── | |
| const isRefreshEndpoint = original?.url?.includes('/auth/refresh') | |
| const isLoginEndpoint = original?.url?.includes('/auth/login') | |
| const isGoogleEndpoint = original?.url?.includes('/auth/google') | |
| if (status === 401 && original && !original._retry && !isRefreshEndpoint && !isLoginEndpoint && !isGoogleEndpoint) { | |
| // Queue concurrent requests until refresh completes | |
| if (_refreshing) { | |
| return new Promise((resolve, reject) => { | |
| _pendingQueue.push({ resolve, reject }) | |
| }).then((newToken) => { | |
| original.headers.Authorization = `Bearer ${newToken}` | |
| return apiClient(original) | |
| }) | |
| } | |
| original._retry = true | |
| _refreshing = true | |
| try { | |
| // Use bare axios so this call never goes through the response interceptor | |
| // (prevents infinite 401 → refresh → 401 loops) | |
| const { data } = await axios.post( | |
| '/api/auth/refresh', | |
| {}, | |
| { withCredentials: true }, | |
| ) | |
| const newToken = data.access_token | |
| setTokens(newToken) | |
| apiClient.defaults.headers.common.Authorization = `Bearer ${newToken}` | |
| original.headers.Authorization = `Bearer ${newToken}` | |
| _flushQueue(null, newToken) | |
| return apiClient(original) | |
| } catch (refreshError) { | |
| _flushQueue(refreshError, null) | |
| _forceLogout() | |
| return Promise.reject(refreshError) | |
| } finally { | |
| _refreshing = false | |
| } | |
| } | |
| // ── Refresh token itself is invalid / expired → force logout ──────────── | |
| // Only force logout if we were actively rotating a token (_refreshing), | |
| // NOT during initial session check from the router guard — that case is | |
| // handled gracefully by refreshSession()'s own catch block. | |
| if (status === 401 && isRefreshEndpoint && _refreshing) { | |
| _flushQueue(new Error('Refresh token expired or revoked'), null) | |
| _refreshing = false | |
| _forceLogout() | |
| } | |
| // ── Account locked ─────────────────────────────────────────────────────── | |
| if (status === 403 && err.response?.data?.detail === 'Account is locked') { | |
| _forceLogout() | |
| } | |
| return Promise.reject(err) | |
| }, | |
| ) | |
| /** | |
| * Clear local auth state and redirect to login. | |
| * Lazy-imports the auth store to avoid circular dependency at module load. | |
| */ | |
| async function _forceLogout() { | |
| try { | |
| const { useAuthStore } = await import('@/stores/auth.js') | |
| await useAuthStore().resetLocalAuth() | |
| } catch { | |
| clearTokens() | |
| } | |
| const { default: router } = await import('@/router/index.js') | |
| if (router.currentRoute.value.path !== '/login') { | |
| await router.push('/login') | |
| } | |
| } | |
| export default apiClient | |