feat(frontend): Implement reactive refresh interceptor
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import axios from "axios"
|
||||
import axios, { type InternalAxiosRequestConfig } from "axios"
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL || ""
|
||||
|
||||
@@ -7,27 +7,82 @@ const api = axios.create({
|
||||
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
|
||||
|
||||
export const registerUnauthorizedCallback = (callback: () => void) => {
|
||||
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
|
||||
api.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
// Only handle if it's not an auth endpoint
|
||||
// This prevents loops during bootstrap and allows login form to show errors
|
||||
const isAuthEndpoint = /^\/auth\//.test(error.config?.url)
|
||||
async (error) => {
|
||||
const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean }
|
||||
|
||||
if (error.response?.status === 401 && !isAuthEndpoint) {
|
||||
// Unauthorized - session likely expired on a protected data route
|
||||
if (onUnauthorized) {
|
||||
onUnauthorized()
|
||||
// Only handle 401 if it's not already a retry and not an auth endpoint
|
||||
// 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) {
|
||||
onUnauthorized()
|
||||
}
|
||||
}
|
||||
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -83,4 +83,19 @@ describe("AuthService", () => {
|
||||
await AuthService.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")
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user