Spaces:
Paused
Paused
File size: 5,853 Bytes
b1a388c b3e4ba6 b1a388c b3e4ba6 b1a388c 1c2ed83 1e7cc3a b1a388c b3e4ba6 b1a388c b3e4ba6 1c2ed83 b3e4ba6 b1a388c 65d3935 1c2ed83 b1a388c 1c2ed83 b3e4ba6 1c2ed83 b1a388c b3e4ba6 b1a388c b3e4ba6 1c2ed83 b3e4ba6 1c2ed83 b3e4ba6 b1a388c 1c2ed83 b3e4ba6 3b1f979 b3e4ba6 3b1f979 b3e4ba6 1c2ed83 b3e4ba6 1c2ed83 b3e4ba6 1c2ed83 b1a388c b3e4ba6 1c2ed83 b3e4ba6 1c2ed83 b3e4ba6 b1a388c b3e4ba6 1c2ed83 b3e4ba6 b1a388c b3e4ba6 e5dee87 b3e4ba6 e5dee87 b3e4ba6 e5dee87 b3e4ba6 b1a388c | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 | /**
* 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
|