feat: consolidate auth logic into axios interceptors and refactor http layer

This commit is contained in:
taDuc
2026-05-12 01:21:56 +07:00
parent fe7696b72d
commit ac8b0404dd
3 changed files with 170 additions and 294 deletions
-37
View File
@@ -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;
+68 -28
View File
@@ -1,4 +1,4 @@
import axios from "axios" import axios, { AxiosResponse } from "axios"
import { API_URL_ROOT } from "../../api" import { API_URL_ROOT } from "../../api"
import { import {
clearStoredTokens, clearStoredTokens,
@@ -16,6 +16,12 @@ const api = axios.create({
withCredentials: true 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 isRefreshing = false
let queue: any[] = [] let queue: any[] = []
@@ -27,16 +33,17 @@ const processQueue = (error?: any) => {
queue = [] queue = []
} }
api.interceptors.request.use((config) => { api.interceptors.request.use((config: any) => {
const token = getAccessToken() if (config.skipAuth) return config
const token = config.authToken || getAccessToken()
if (token) { if (token) {
const headers: any = config.headers || {} const headers: any = config.headers || {}
// Do not override if caller set Authorization explicitly (case-insensitive). // If it's a retry after refresh, we MUST update the Authorization header with the fresh token.
const already = // Otherwise, we only set it if not already present.
typeof headers.get === "function" const hasAuth = !!(headers.Authorization || headers.authorization || (typeof headers.get === "function" && headers.get("Authorization")))
? headers.get("Authorization")
: headers.Authorization || headers.authorization if (config._retry || !hasAuth) {
if (!already) {
if (typeof headers.set === "function") headers.set("Authorization", `Bearer ${token}`) if (typeof headers.set === "function") headers.set("Authorization", `Bearer ${token}`)
else headers.Authorization = `Bearer ${token}` else headers.Authorization = `Bearer ${token}`
} }
@@ -45,18 +52,58 @@ api.interceptors.request.use((config) => {
return 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( api.interceptors.response.use(
(res) => { async (res: AxiosResponse): Promise<AxiosResponse> => {
// Opportunistically persist tokens from signin/refresh responses. // Opportunistically persist tokens from signin/refresh responses.
const tokens = extractTokensFromResponsePayload(res?.data) const tokens = extractTokensFromResponsePayload(res?.data)
if (tokens) setStoredTokens(tokens) 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 return res
}, },
async (err) => { async (err) => {
const originalRequest = err.config const originalRequest = err.config as any
const url = String(originalRequest?.url || "") const url = String(originalRequest?.url || "")
if (err.response?.status === 401 && !originalRequest._retry && !url.includes("/auth/")) { 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<AxiosResponse> {
if (isRefreshing) { if (isRefreshing) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
queue.push({ queue.push({
@@ -74,15 +121,14 @@ api.interceptors.response.use(
const tryHeaderRefresh = async () => { const tryHeaderRefresh = async () => {
if (!refreshToken) return null if (!refreshToken) return null
return axios.post( // Use dedicated refreshApi to handle baseURL and credentials consistently.
`${baseURL}/auth/refresh`, return refreshApi.post("/auth/refresh", {}, {
{}, headers: { Authorization: `Bearer ${refreshToken}` }
{ headers: { Authorization: `Bearer ${refreshToken}` } } })
)
} }
const tryCookieRefresh = async () => { const tryCookieRefresh = async () => {
return axios.post(`${baseURL}/auth/refresh`, {}, { withCredentials: true }) return refreshApi.post("/auth/refresh", {})
} }
let refreshRes: any = null let refreshRes: any = null
@@ -101,33 +147,27 @@ api.interceptors.response.use(
if (nextTokens) setStoredTokens(nextTokens) if (nextTokens) setStoredTokens(nextTokens)
// Some backends may return only a new access token; keep refresh token. // Some backends may return only a new access token; keep refresh token.
else { else {
const maybeAccess = (refreshRes?.data?.data?.access_token ?? const maybeAccess = (refreshRes?.data?.data?.access_token ?? refreshRes?.data?.access_token) as unknown
refreshRes?.data?.access_token) as unknown
if (typeof maybeAccess === "string" && maybeAccess.trim()) { 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 }) if (refreshToken) setStoredTokens({ access_token: maybeAccess, refresh_token: refreshToken })
} }
} }
processQueue() processQueue()
return api(originalRequest) return api(originalRequest)
} catch (refreshErr: any) { } catch (refreshErr: any) {
processQueue(refreshErr) processQueue(refreshErr)
// Only force logout when refresh token/session is truly invalid (401). // Only force logout when refresh token/session is truly invalid (401).
if (refreshErr?.response?.status === 401) { if (refreshErr?.response?.status === 401) {
clearStoredTokens() clearStoredTokens()
if (typeof window !== "undefined") {
window.location.href = "/signin" window.location.href = "/signin"
} }
}
return Promise.reject(refreshErr) return Promise.reject(refreshErr)
} finally { } finally {
isRefreshing = false isRefreshing = false
} }
} }
return Promise.reject(err)
}
)
export default api export default api
+51 -178
View File
@@ -1,6 +1,5 @@
import type { ApiEnvelope } from "@/uhm/types/api"; import type { ApiEnvelope } from "@/uhm/types/api";
import { API_ENDPOINTS } from "@/uhm/api/config"; import api from "@/config/config";
import { getAccessToken, getRefreshToken, setStoredTokens, type StoredTokens, extractTokensFromResponsePayload } from "@/auth/tokenStore";
export class ApiError extends Error { export class ApiError extends Error {
status: number; 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 = { type RequestJsonOptions = {
skipAuth?: boolean; skipAuth?: boolean;
skipRefresh?: boolean; skipRefresh?: boolean;
@@ -29,7 +26,56 @@ export async function requestJson<T>(
init?: RequestInit, init?: RequestInit,
options?: RequestJsonOptions options?: RequestJsonOptions
): Promise<T> { ): Promise<T> {
return requestJsonInternal<T>(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<T>(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<T>(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 { export function jsonRequestInit(method: string, body: unknown): RequestInit {
@@ -40,90 +86,6 @@ export function jsonRequestInit(method: string, body: unknown): RequestInit {
}; };
} }
async function requestJsonInternal<T>(
input: RequestInfo | URL,
init?: RequestInit,
options?: RequestJsonOptions
): Promise<T> {
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 : "<server>";
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<T>(input, init, { ...(options || {}), skipRefresh: true });
}
}
const payload = await parseJsonResponse(res);
const envelope = isApiEnvelopeLike<T>(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<T>(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<unknown> {
const text = await res.text();
if (!text.length) return null;
try {
return JSON.parse(text);
} catch {
return text;
}
}
function isApiEnvelopeLike<T>(value: unknown): value is ApiEnvelope<T> { function isApiEnvelopeLike<T>(value: unknown): value is ApiEnvelope<T> {
if (!value || typeof value !== "object" || Array.isArray(value)) return false; if (!value || typeof value !== "object" || Array.isArray(value)) return false;
const source = value as Record<string, unknown>; const source = value as Record<string, unknown>;
@@ -147,18 +109,6 @@ function extractErrorMessage(payload: unknown, envelope: ApiEnvelope<unknown> |
return null; 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 { function stringifyPayload(payload: unknown): string {
if (typeof payload === "string") return payload; if (typeof payload === "string") return payload;
try { try {
@@ -167,80 +117,3 @@ function stringifyPayload(payload: unknown): string {
return String(payload); 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<boolean> | null = null;
async function tryRefreshTokens(): Promise<boolean> {
// 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<unknown>(
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<unknown>(
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;
}
}