feat(frontend): Implement reactive refresh interceptor

This commit is contained in:
Yunxiao Xu
2026-02-18 13:52:02 -08:00
parent 5adc826cfb
commit 7cc34ceb0b
2 changed files with 80 additions and 10 deletions

View File

@@ -1,4 +1,4 @@
import axios from "axios" import axios, { type InternalAxiosRequestConfig } from "axios"
const API_URL = import.meta.env.VITE_API_URL || "" const API_URL = import.meta.env.VITE_API_URL || ""
@@ -7,27 +7,82 @@ const api = axios.create({
withCredentials: true, // Crucial for HttpOnly cookies withCredentials: true, // Crucial for HttpOnly cookies
}) })
// Optional callback for unauthorized errors // Optional callback for unauthorized errors (fallback if refresh fails)
let onUnauthorized: (() => void) | null = null let onUnauthorized: (() => void) | null = null
export const registerUnauthorizedCallback = (callback: () => void) => { export const registerUnauthorizedCallback = (callback: () => void) => {
onUnauthorized = callback onUnauthorized = callback
} }
// State to manage multiple concurrent refreshes
let isRefreshing = false
let refreshSubscribers: ((token: string) => void)[] = []
const subscribeTokenRefresh = (callback: (token: string) => void) => {
refreshSubscribers.push(callback)
}
const onRefreshed = (token: string) => {
refreshSubscribers.forEach((callback) => callback(token))
refreshSubscribers = []
}
// Add a response interceptor to handle 401s // Add a response interceptor to handle 401s
api.interceptors.response.use( api.interceptors.response.use(
(response) => response, (response) => response,
(error) => { async (error) => {
// Only handle if it's not an auth endpoint const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean }
// This prevents loops during bootstrap and allows login form to show errors
const isAuthEndpoint = /^\/auth\//.test(error.config?.url)
if (error.response?.status === 401 && !isAuthEndpoint) { // Only handle 401 if it's not already a retry and not an auth endpoint
// Unauthorized - session likely expired on a protected data route // We allow /auth/login and /auth/register to fail normally to show errors
const isAuthEndpoint = /^\/auth\/(login|register|oidc\/callback)/.test(originalRequest.url || "")
const isRefreshEndpoint = /^\/auth\/refresh/.test(originalRequest.url || "")
if (error.response?.status === 401 && !isAuthEndpoint && !isRefreshEndpoint && !originalRequest._retry) {
if (isRefreshing) {
// Wait for the current refresh to complete
return new Promise((resolve) => {
subscribeTokenRefresh((token) => {
// Re-run the original request
resolve(api(originalRequest))
})
})
}
originalRequest._retry = true
isRefreshing = true
try {
console.log("Reactive refresh: Access token expired, attempting to refresh...")
// Call refresh endpoint
const response = await api.post("/auth/refresh")
const { access_token } = response.data
isRefreshing = false
onRefreshed(access_token)
// Retry the original request
return api(originalRequest)
} catch (refreshError) {
isRefreshing = false
refreshSubscribers = []
console.error("Reactive refresh failed:", refreshError)
// Final failure - session is dead
if (onUnauthorized) {
onUnauthorized()
}
return Promise.reject(refreshError)
}
}
// If it's the refresh endpoint itself failing with 401, trigger logout
if (error.response?.status === 401 && isRefreshEndpoint) {
if (onUnauthorized) { if (onUnauthorized) {
onUnauthorized() onUnauthorized()
} }
} }
return Promise.reject(error) return Promise.reject(error)
} }
) )

View File

@@ -83,4 +83,19 @@ describe("AuthService", () => {
await AuthService.logout() await AuthService.logout()
expect(mockedApi.post).toHaveBeenCalledWith("/auth/logout") expect(mockedApi.post).toHaveBeenCalledWith("/auth/logout")
}) })
it("successfully refreshes session", async () => {
const mockResponse = {
data: {
access_token: "new-fake-token",
token_type: "bearer",
},
}
mockedApi.post.mockResolvedValueOnce(mockResponse)
const result = await AuthService.refreshSession()
expect(mockedApi.post).toHaveBeenCalledWith("/auth/refresh")
expect(result.access_token).toBe("new-fake-token")
})
}) })