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
|
||||
|
||||
Reference in New Issue
Block a user