import axios, { AxiosResponse } from "axios"; import { API_URL_ROOT } from "../../api"; import { clearStoredTokens, extractTokensFromResponsePayload, getAccessToken, setStoredTokens, } from "@/auth/tokenStore"; export const baseURL = API_URL_ROOT; export const api = axios.create({ baseURL, // Support both cookie-based auth (httpOnly) and Bearer JWT. withCredentials: true, }); // Dedicated instance for refresh to avoid interceptor loops and handle baseURL correctly. const refreshApi = axios.create({ baseURL, withCredentials: true, }); let isRefreshing = false; let queue: any[] = []; function shouldRedirectToSigninOnRefreshFailure(status?: number): boolean { return status === 401 || status === 404; } const processQueue = (error?: any) => { queue.forEach((p) => { if (error) p.reject(error); else p.resolve(); }); queue = []; }; api.interceptors.request.use((config: any) => { if (config.skipAuth) return config; const token = config.authToken || getAccessToken(); if (token) { const headers: any = config.headers || {}; // If it's a retry after refresh, we MUST update the Authorization header with the fresh token. // Otherwise, we only set it if not already present. const hasAuth = !!(headers.Authorization || headers.authorization || (typeof headers.get === "function" && headers.get("Authorization"))); if (config._retry || !hasAuth) { if (typeof headers.set === "function") headers.set("Authorization", `Bearer ${token}`); else headers.Authorization = `Bearer ${token}`; } config.headers = headers; } return config; }); function isAuthTokenExpiredMessage(message: string): boolean { const normalized = message.trim().toLowerCase(); if (!normalized) return false; // Be specific: don't match general "unauthorized" or "access denied" which could be 403. // Match only messages clearly indicating token expiration or invalidity. return ( normalized.includes("invalid or expired jwt") || normalized.includes("jwt expired") || normalized.includes("token expired") || normalized.includes("invalid token") || normalized.includes("expired token") || normalized.includes("token is invalid") || normalized.includes("not authenticated") ); } api.interceptors.response.use( async (res: AxiosResponse): Promise => { // Opportunistically persist tokens from signin/refresh responses. const tokens = extractTokensFromResponsePayload(res?.data); if (tokens) setStoredTokens(tokens); // Handle backends that return 200 OK with status:false + expired token message. const data = res.data; const originalRequest = res.config as any; const url = String(originalRequest?.url || ""); if ( data && data.status === false && isAuthTokenExpiredMessage(data.message || "") && !originalRequest._retry && !originalRequest.skipRefresh && !url.includes("/auth/") ) { return performRefreshAndRetry(originalRequest); } return res; }, async (err) => { const originalRequest = err.config as any; const url = String(originalRequest?.url || ""); if (err.response?.status === 401 && !originalRequest._retry && !originalRequest.skipRefresh && !url.includes("/auth/")) { return performRefreshAndRetry(originalRequest); } return Promise.reject(err); } ); async function performRefreshAndRetry(originalRequest: any): Promise { if (isRefreshing) { return new Promise((resolve, reject) => { queue.push({ resolve: () => resolve(api(originalRequest)), reject, }); }); } originalRequest._retry = true; isRefreshing = true; try { const tryCookieRefresh = async () => { return refreshApi.post("/auth/refresh", {}); }; const refreshRes: any = await tryCookieRefresh(); const nextTokens = extractTokensFromResponsePayload(refreshRes?.data); if (nextTokens) setStoredTokens(nextTokens); // Some backends may return only a new access token. else { const maybeAccess = (refreshRes?.data?.data?.access_token ?? refreshRes?.data?.access_token) as unknown; if (typeof maybeAccess === "string" && maybeAccess.trim()) { setStoredTokens({ access_token: maybeAccess }); } } processQueue(); return api(originalRequest); } catch (refreshErr: any) { processQueue(refreshErr); // Treat missing/invalid refresh endpoints or sessions as logged-out state. if (shouldRedirectToSigninOnRefreshFailure(refreshErr?.response?.status)) { clearStoredTokens(); if (typeof window !== "undefined") { window.location.href = "/auth/signin"; } } return Promise.reject(refreshErr); } finally { isRefreshing = false; } }