feat: consolidate auth logic into axios interceptors and refactor http layer
This commit is contained in:
@@ -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;
|
||||
+119
-79
@@ -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<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 (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<AxiosResponse> {
|
||||
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
|
||||
|
||||
+51
-178
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user