From ac8b0404ddd6bdd89afedcfd29e0397d6257b96e Mon Sep 17 00:00:00 2001 From: taDuc Date: Tue, 12 May 2026 01:21:56 +0700 Subject: [PATCH] feat: consolidate auth logic into axios interceptors and refactor http layer --- src/config/axiosInstance.ts | 37 ------ src/config/config.ts | 198 ++++++++++++++++++------------- src/uhm/api/http.ts | 229 ++++++++---------------------------- 3 files changed, 170 insertions(+), 294 deletions(-) delete mode 100644 src/config/axiosInstance.ts diff --git a/src/config/axiosInstance.ts b/src/config/axiosInstance.ts deleted file mode 100644 index 4ae93e5..0000000 --- a/src/config/axiosInstance.ts +++ /dev/null @@ -1,37 +0,0 @@ -import axios from "axios"; -import { API } from "../../api"; - -const axiosInstance = axios.create({ - baseURL: "/", - withCredentials: true, - headers: { - "Content-Type": "application/json", - }, -}); - -axiosInstance.interceptors.response.use( - (response) => { - if (response.data && response.data.status === false) { - return handleRefreshToken(response); - } - return response; - }, - async (error) => { - return Promise.reject(error); - } -); - -async function handleRefreshToken(originalResponse: any) { - try { - const refreshRes = await axios.get(API.Auth.REFRESH, { withCredentials: true }); - - if (refreshRes.data && refreshRes.data.status !== false) { - return axiosInstance(originalResponse.config); - } - } catch (err) { - console.error("Refresh token failed", err); - } - return originalResponse; -} - -export default axiosInstance; \ No newline at end of file diff --git a/src/config/config.ts b/src/config/config.ts index b8f6313..b2f63f0 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -1,4 +1,4 @@ -import axios from "axios" +import axios, { AxiosResponse } from "axios" import { API_URL_ROOT } from "../../api" import { clearStoredTokens, @@ -16,6 +16,12 @@ const api = axios.create({ 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[] = [] @@ -27,16 +33,17 @@ const processQueue = (error?: any) => { queue = [] } -api.interceptors.request.use((config) => { - const token = getAccessToken() +api.interceptors.request.use((config: any) => { + if (config.skipAuth) return config + + const token = config.authToken || getAccessToken() if (token) { const headers: any = config.headers || {} - // Do not override if caller set Authorization explicitly (case-insensitive). - const already = - typeof headers.get === "function" - ? headers.get("Authorization") - : headers.Authorization || headers.authorization - if (!already) { + // 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}` } @@ -45,89 +52,122 @@ api.interceptors.request.use((config) => { return config }) +function isAuthTokenExpiredMessage(message: string): boolean { + const normalized = message.trim().toLowerCase() + if (!normalized) return false + 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("unauthorized") || + normalized.includes("access denied") || + normalized.includes("not authenticated") + ) +} + api.interceptors.response.use( - (res) => { + 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 + const originalRequest = err.config as any const url = String(originalRequest?.url || "") - if (err.response?.status === 401 && !originalRequest._retry && !url.includes("/auth/")) { - if (isRefreshing) { - return new Promise((resolve, reject) => { - queue.push({ - resolve: () => resolve(api(originalRequest)), - reject - }) - }) - } - - originalRequest._retry = true - isRefreshing = true - - try { - const refreshToken = getRefreshToken() - - const tryHeaderRefresh = async () => { - if (!refreshToken) return null - return axios.post( - `${baseURL}/auth/refresh`, - {}, - { headers: { Authorization: `Bearer ${refreshToken}` } } - ) - } - - const tryCookieRefresh = async () => { - return axios.post(`${baseURL}/auth/refresh`, {}, { withCredentials: true }) - } - - let refreshRes: any = null - try { - refreshRes = (await tryHeaderRefresh()) || (await tryCookieRefresh()) - } catch (e: any) { - // If header-based refresh fails (wrong token type), fall back to cookie refresh. - if (refreshToken && e?.response?.status === 401) { - refreshRes = await tryCookieRefresh() - } else { - throw e - } - } - - const nextTokens = extractTokensFromResponsePayload(refreshRes?.data) - if (nextTokens) setStoredTokens(nextTokens) - // Some backends may return only a new access token; keep refresh token. - else { - const maybeAccess = (refreshRes?.data?.data?.access_token ?? - refreshRes?.data?.access_token) as unknown - if (typeof maybeAccess === "string" && maybeAccess.trim()) { - // Keep refresh token if we have one; otherwise rely on cookies. - if (refreshToken) setStoredTokens({ access_token: maybeAccess, refresh_token: refreshToken }) - } - } - - processQueue() - - return api(originalRequest) - } catch (refreshErr: any) { - processQueue(refreshErr) - // Only force logout when refresh token/session is truly invalid (401). - if (refreshErr?.response?.status === 401) { - clearStoredTokens() - window.location.href = "/signin" - } - - return Promise.reject(refreshErr) - } finally { - isRefreshing = false - } + 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 refreshToken = getRefreshToken() + + const tryHeaderRefresh = async () => { + if (!refreshToken) return null + // Use dedicated refreshApi to handle baseURL and credentials consistently. + return refreshApi.post("/auth/refresh", {}, { + headers: { Authorization: `Bearer ${refreshToken}` } + }) + } + + const tryCookieRefresh = async () => { + return refreshApi.post("/auth/refresh", {}) + } + + let refreshRes: any = null + try { + refreshRes = (await tryHeaderRefresh()) || (await tryCookieRefresh()) + } catch (e: any) { + // If header-based refresh fails (wrong token type), fall back to cookie refresh. + if (refreshToken && e?.response?.status === 401) { + refreshRes = await tryCookieRefresh() + } else { + throw e + } + } + + const nextTokens = extractTokensFromResponsePayload(refreshRes?.data) + if (nextTokens) setStoredTokens(nextTokens) + // Some backends may return only a new access token; keep refresh token. + else { + const maybeAccess = (refreshRes?.data?.data?.access_token ?? refreshRes?.data?.access_token) as unknown + if (typeof maybeAccess === "string" && maybeAccess.trim()) { + if (refreshToken) setStoredTokens({ access_token: maybeAccess, refresh_token: refreshToken }) + } + } + + processQueue() + return api(originalRequest) + } catch (refreshErr: any) { + processQueue(refreshErr) + // Only force logout when refresh token/session is truly invalid (401). + if (refreshErr?.response?.status === 401) { + clearStoredTokens() + if (typeof window !== "undefined") { + window.location.href = "/signin" + } + } + return Promise.reject(refreshErr) + } finally { + isRefreshing = false + } +} + export default api diff --git a/src/uhm/api/http.ts b/src/uhm/api/http.ts index bba5a11..e62746e 100644 --- a/src/uhm/api/http.ts +++ b/src/uhm/api/http.ts @@ -1,6 +1,5 @@ import type { ApiEnvelope } from "@/uhm/types/api"; -import { API_ENDPOINTS } from "@/uhm/api/config"; -import { getAccessToken, getRefreshToken, setStoredTokens, type StoredTokens, extractTokensFromResponsePayload } from "@/auth/tokenStore"; +import api from "@/config/config"; export class ApiError extends Error { status: number; @@ -16,8 +15,6 @@ export class ApiError extends Error { } } -// History API auth flow supports Bearer JWT and (in some deployments) cookie-based sessions. - type RequestJsonOptions = { skipAuth?: boolean; skipRefresh?: boolean; @@ -29,7 +26,56 @@ export async function requestJson( init?: RequestInit, options?: RequestJsonOptions ): Promise { - return requestJsonInternal(input, init, options); + const url = typeof input === "string" ? input : String(input); + const method = init?.method || "GET"; + + // Convert RequestInit.body to object if it's a JSON string. + let data = init?.body; + if (typeof data === "string" && data.length > 0) { + try { + data = JSON.parse(data); + } catch { + // Keep as string if not JSON. + } + } + + try { + const response = await api.request({ + url, + method, + data, + headers: init?.headers as any, + // Custom properties for our axios interceptor. + skipAuth: options?.skipAuth, + authToken: options?.authToken, + skipRefresh: options?.skipRefresh, + } as any); + + const payload = response.data; + const envelope = isApiEnvelopeLike(payload) ? payload : null; + + if (envelope) { + const isError = envelope.status === false || envelope.status === "error"; + if (isError) { + const message = extractErrorMessage(payload, envelope) || "Request failed"; + throw new ApiError(message, response.status, stringifyPayload(envelope), normalizeErrors(envelope.errors)); + } + return (envelope.data ?? null) as T; + } + + return payload as T; + } catch (err: any) { + if (err instanceof ApiError) throw err; + + const status = err.response?.status || 0; + const payload = err.response?.data; + const envelope = isApiEnvelopeLike(payload) ? payload : null; + const message = extractErrorMessage(payload, envelope) || err.message || "Request failed"; + const body = envelope ? stringifyPayload(envelope) : stringifyPayload(payload); + const errors = envelope?.errors ? normalizeErrors(envelope.errors) : []; + + throw new ApiError(message, status, body, errors); + } } export function jsonRequestInit(method: string, body: unknown): RequestInit { @@ -40,90 +86,6 @@ export function jsonRequestInit(method: string, body: unknown): RequestInit { }; } -async function requestJsonInternal( - input: RequestInfo | URL, - init?: RequestInit, - options?: RequestJsonOptions -): Promise { - const nextInit = withAuthHeaders(init, options); - let res: Response; - try { - res = await fetch(input, nextInit); - } catch (err) { - // Browser "TypeError: Failed to fetch" typically means: - // - CORS blocked (common when using 127.0.0.1 instead of localhost in dev), - // - DNS/TLS/network error, - // - request blocked by the browser. - const origin = typeof window !== "undefined" ? window.location.origin : ""; - const url = typeof input === "string" ? input : String(input); - const details = { origin, url, apiBase: API_ENDPOINTS.projects.split("/projects")[0] }; - throw new ApiError("Network error (failed to fetch)", 0, stringifyPayload(details)); - } - - // One-shot refresh + retry for protected endpoints. - if ( - res.status === 401 && - !options?.skipRefresh && - !options?.skipAuth && - typeof input === "string" && - !String(input).includes("/auth/") - ) { - const refreshed = await tryRefreshTokens(); - if (refreshed) { - return requestJsonInternal(input, init, { ...(options || {}), skipRefresh: true }); - } - } - - const payload = await parseJsonResponse(res); - const envelope = isApiEnvelopeLike(payload) ? payload : null; - - if (!res.ok) { - const message = extractErrorMessage(payload, envelope) || `Request failed with status ${res.status}`; - const body = envelope ? stringifyPayload(envelope) : stringifyPayload(payload); - const errors = envelope?.errors ? normalizeErrors(envelope.errors) : []; - throw new ApiError(message, res.status, body, errors); - } - - if (envelope) { - const isError = - envelope.status === false || - envelope.status === "error"; - if (isError) { - const message = extractErrorMessage(payload, envelope) || "Request failed"; - - // Some backends return 200 with {status:false,message:"Invalid or expired JWT"} instead of HTTP 401. - // In that case, try refresh + retry once to keep UX smooth. - if ( - !options?.skipRefresh && - !options?.skipAuth && - typeof input === "string" && - !String(input).includes("/auth/") && - isAuthTokenExpiredMessage(message) - ) { - const refreshed = await tryRefreshTokens(); - if (refreshed) { - return requestJsonInternal(input, init, { ...(options || {}), skipRefresh: true }); - } - } - - throw new ApiError(message, res.status, stringifyPayload(envelope), normalizeErrors(envelope.errors)); - } - return (envelope.data ?? null) as T; - } - - return payload as T; -} - -async function parseJsonResponse(res: Response): Promise { - const text = await res.text(); - if (!text.length) return null; - try { - return JSON.parse(text); - } catch { - return text; - } -} - function isApiEnvelopeLike(value: unknown): value is ApiEnvelope { if (!value || typeof value !== "object" || Array.isArray(value)) return false; const source = value as Record; @@ -147,18 +109,6 @@ function extractErrorMessage(payload: unknown, envelope: ApiEnvelope | return null; } -function isAuthTokenExpiredMessage(message: string): boolean { - const normalized = message.trim().toLowerCase(); - if (!normalized) return false; - return ( - normalized.includes("invalid or expired jwt") || - normalized.includes("jwt expired") || - normalized.includes("token expired") || - normalized.includes("invalid token") || - normalized.includes("expired token") - ); -} - function stringifyPayload(payload: unknown): string { if (typeof payload === "string") return payload; try { @@ -167,80 +117,3 @@ function stringifyPayload(payload: unknown): string { return String(payload); } } - -function withAuthHeaders(init: RequestInit | undefined, options?: RequestJsonOptions): RequestInit | undefined { - const baseInit: RequestInit = { - ...init, - credentials: init?.credentials ?? "include", - }; - - const headers = new Headers(baseInit.headers || undefined); - - const override = options?.authToken; - if (override) { - headers.set("Authorization", `Bearer ${override}`); - return { ...baseInit, headers }; - } - - if (options?.skipAuth) return baseInit; - - const access = getAccessToken(); - if (access) headers.set("Authorization", `Bearer ${access}`); - return { ...baseInit, headers }; -} - -let refreshInFlight: Promise | null = null; - -async function tryRefreshTokens(): Promise { - // Single-flight refresh for concurrent 401s. - if (refreshInFlight) return refreshInFlight; - refreshInFlight = (async () => { - try { - const refreshToken = getRefreshToken(); - - // Try header-based refresh first (per swagger), but fall back to cookie-based refresh if needed. - let payload: unknown; - try { - payload = await requestJsonInternal( - API_ENDPOINTS.authRefresh, - { method: "POST" }, - refreshToken - ? { skipRefresh: true, authToken: refreshToken } - : { skipRefresh: true, skipAuth: true } - ); - } catch (err) { - if (refreshToken && err instanceof ApiError && err.status === 401) { - payload = await requestJsonInternal( - API_ENDPOINTS.authRefresh, - { method: "POST" }, - { skipRefresh: true, skipAuth: true } - ); - } else { - throw err; - } - } - - const next = extractTokensFromResponsePayload(payload) as StoredTokens | null; - if (next) { - setStoredTokens(next); - return true; - } - - // Fallback: if server returns only access_token, keep existing refresh token (if any). - const maybeAccess = (payload as any)?.access_token ?? (payload as any)?.data?.access_token; - if (typeof maybeAccess === "string" && maybeAccess.trim()) { - if (refreshToken) setStoredTokens({ access_token: maybeAccess, refresh_token: refreshToken }); - return true; - } - - return false; - } catch { - return false; - } - })(); - try { - return await refreshInFlight; - } finally { - refreshInFlight = null; - } -}