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 {
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,18 +52,58 @@ 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<AxiosResponse> => {
// 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 (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) {
return new Promise((resolve, reject) => {
queue.push({
@@ -74,15 +121,14 @@ api.interceptors.response.use(
const tryHeaderRefresh = async () => {
if (!refreshToken) return null
return axios.post(
`${baseURL}/auth/refresh`,
{},
{ headers: { Authorization: `Bearer ${refreshToken}` } }
)
// Use dedicated refreshApi to handle baseURL and credentials consistently.
return refreshApi.post("/auth/refresh", {}, {
headers: { Authorization: `Bearer ${refreshToken}` }
})
}
const tryCookieRefresh = async () => {
return axios.post(`${baseURL}/auth/refresh`, {}, { withCredentials: true })
return refreshApi.post("/auth/refresh", {})
}
let refreshRes: any = null
@@ -101,33 +147,27 @@ api.interceptors.response.use(
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
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()
if (typeof window !== "undefined") {
window.location.href = "/signin"
}
}
return Promise.reject(refreshErr)
} finally {
isRefreshing = false
}
}
return Promise.reject(err)
}
)
}
export default api
+51 -178
View File
@@ -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<T>(
init?: RequestInit,
options?: RequestJsonOptions
): 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 {
@@ -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> {
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
const source = value as Record<string, unknown>;
@@ -147,18 +109,6 @@ function extractErrorMessage(payload: unknown, envelope: ApiEnvelope<unknown> |
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<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;
}
}