From 7cc34ceb0b9515936ca74a8c60fac0c71f6da790 Mon Sep 17 00:00:00 2001 From: Yunxiao Xu Date: Wed, 18 Feb 2026 13:52:02 -0800 Subject: [PATCH] feat(frontend): Implement reactive refresh interceptor --- frontend/src/services/api.ts | 75 ++++++++++++++++++++++++++---- frontend/src/services/auth.test.ts | 15 ++++++ 2 files changed, 80 insertions(+), 10 deletions(-) diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index f42744d..1f91170 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -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) } ) diff --git a/frontend/src/services/auth.test.ts b/frontend/src/services/auth.test.ts index c11d08d..2039f3e 100644 --- a/frontend/src/services/auth.test.ts +++ b/frontend/src/services/auth.test.ts @@ -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") + }) })