105 lines
3.2 KiB
TypeScript
105 lines
3.2 KiB
TypeScript
import axios, { type InternalAxiosRequestConfig } from "axios"
|
|
|
|
const API_URL = import.meta.env.VITE_API_URL || ""
|
|
|
|
const api = axios.create({
|
|
baseURL: `${API_URL}/api/v1`,
|
|
withCredentials: true, // Crucial for HttpOnly cookies
|
|
})
|
|
|
|
// Optional callback for unauthorized errors (fallback if refresh fails)
|
|
let onUnauthorized: (() => void) | null = null
|
|
|
|
export const registerUnauthorizedCallback = (callback: () => void) => {
|
|
onUnauthorized = callback
|
|
}
|
|
|
|
// State to manage multiple concurrent refreshes
|
|
let isRefreshing = false
|
|
let refreshSubscribers: ((token: string) => void)[] = []
|
|
let refreshErrorSubscribers: ((error: unknown) => void)[] = []
|
|
|
|
const subscribeTokenRefresh = (onSuccess: (token: string) => void, onError: (error: unknown) => void) => {
|
|
refreshSubscribers.push(onSuccess)
|
|
refreshErrorSubscribers.push(onError)
|
|
}
|
|
|
|
const onRefreshed = (token: string) => {
|
|
refreshSubscribers.forEach((callback) => callback(token))
|
|
refreshSubscribers = []
|
|
refreshErrorSubscribers = []
|
|
}
|
|
|
|
const onRefreshFailed = (error: unknown) => {
|
|
refreshErrorSubscribers.forEach((callback) => callback(error))
|
|
refreshSubscribers = []
|
|
refreshErrorSubscribers = []
|
|
}
|
|
|
|
// Add a response interceptor to handle 401s
|
|
api.interceptors.response.use(
|
|
(response) => response,
|
|
async (error) => {
|
|
const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean }
|
|
|
|
// Only handle 401 if it's not already a retry and not an auth endpoint
|
|
// We allow /auth/login and /auth/register to fail normally to show errors
|
|
const isAuthEndpoint = /^\/auth\/(login|register|oidc\/callback)/.test(originalRequest.url || "")
|
|
const isRefreshEndpoint = /^\/auth\/refresh/.test(originalRequest.url || "")
|
|
|
|
if (error.response?.status === 401 && !isAuthEndpoint && !isRefreshEndpoint && !originalRequest._retry) {
|
|
if (isRefreshing) {
|
|
// Wait for the current refresh to complete
|
|
return new Promise((resolve, reject) => {
|
|
subscribeTokenRefresh(
|
|
() => resolve(api(originalRequest)),
|
|
(err) => reject(err)
|
|
)
|
|
})
|
|
}
|
|
|
|
originalRequest._retry = true
|
|
isRefreshing = true
|
|
|
|
try {
|
|
console.log("Reactive refresh: Access token expired, attempting to refresh...")
|
|
// Call refresh endpoint
|
|
const response = await api.post("/auth/refresh")
|
|
const { access_token } = response.data
|
|
|
|
isRefreshing = false
|
|
onRefreshed(access_token)
|
|
|
|
// Retry the original request
|
|
return api(originalRequest)
|
|
} catch (refreshError: unknown) {
|
|
isRefreshing = false
|
|
onRefreshFailed(refreshError)
|
|
console.error("Reactive refresh failed:", refreshError)
|
|
|
|
// Final failure - session is dead
|
|
handleUnauthorized()
|
|
return Promise.reject(refreshError)
|
|
}
|
|
}
|
|
|
|
// If it's a 401 on an endpoint we don't/can't refresh (like refresh itself or login)
|
|
if (error.response?.status === 401) {
|
|
handleUnauthorized()
|
|
}
|
|
|
|
return Promise.reject(error)
|
|
}
|
|
)
|
|
|
|
/**
|
|
* Shared helper to trigger logout/unauthorized cleanup.
|
|
*/
|
|
function handleUnauthorized() {
|
|
if (onUnauthorized) {
|
|
onUnauthorized()
|
|
}
|
|
}
|
|
|
|
export default api
|