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;
|
|
||||||
+68
-28
@@ -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
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user