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
+119 -79
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,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