From b3ad0d56a61d066681c94620ebdb55937e3601d4 Mon Sep 17 00:00:00 2001 From: Paul Irish Date: Thu, 15 Jan 2026 17:14:43 -0800 Subject: [PATCH 1/2] fix(auth): prevent infinite fetch recursion and multiple wrapper layers This fixes an issue where the frontend would spam the auth endpoints repeatedly when logged out or when a session expired. 1. Infinite Recursion on 401: The window.fetch wrapper would catch a 401, call tryRefresh(), which then called fetch() again, triggering the wrapper recursively if the refresh also failed. We now use the original fetch for refresh attempts and exclude auth endpoints from auto-refresh logic. 2. Multiple Wrapper Layers: Since useAuth is a hook used by many components, multiple instances were independently wrapping window.fetch. We now store the original fetch globally and ensure wrapping only happens once. --- .../src/features/auth/hooks/useAuth.ts | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/web/frontend/src/features/auth/hooks/useAuth.ts b/web/frontend/src/features/auth/hooks/useAuth.ts index 69f0645f2..806478421 100644 --- a/web/frontend/src/features/auth/hooks/useAuth.ts +++ b/web/frontend/src/features/auth/hooks/useAuth.ts @@ -63,7 +63,8 @@ export function useAuth() { const tryRefresh = useCallback(async (): Promise => { try { - const res = await fetch('/api/v1/auth/refresh', { method: 'POST' }) + const fetchToUse = (window as any).__scriberr_original_fetch || window.fetch; + const res = await fetchToUse('/api/v1/auth/refresh', { method: 'POST' }) if (!res.ok) return null const data = await res.json() if (data?.token) { @@ -80,10 +81,17 @@ export function useAuth() { // Consolidated token management useEffect(() => { if (!fetchWrapperSetupRef.current) { - const originalFetch = window.fetch.bind(window); + if (!(window as any).__scriberr_original_fetch) { + (window as any).__scriberr_original_fetch = window.fetch.bind(window); + } + + const originalFetch = (window as any).__scriberr_original_fetch; const wrappedFetch: typeof window.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { + const url = typeof input === 'string' ? input : (input instanceof URL ? input.href : input.url); + const isAuthEndpoint = url.includes('/api/v1/auth/'); + let res = await originalFetch(input, init); - if (res.status === 401) { + if (res.status === 401 && !isAuthEndpoint) { const newToken = await tryRefresh() if (newToken) { const newInit: RequestInit | undefined = init ? { ...init } : undefined @@ -101,7 +109,9 @@ export function useAuth() { // eslint-disable-next-line @typescript-eslint/no-explicit-any window.fetch = wrappedFetch as any; fetchWrapperSetupRef.current = true; - return () => { window.fetch = originalFetch; }; + // Note: We don't restore originalFetch on unmount because other components + // also use useAuth and expect the wrapped version. This is a bit hacky + // but safer than multiple re-wrapping/unwrapping. } if (tokenCheckIntervalRef.current) clearInterval(tokenCheckIntervalRef.current); From 24eb85e4c38e84e68581be9b7b6383cdb4cb163e Mon Sep 17 00:00:00 2001 From: Paul Irish Date: Thu, 15 Jan 2026 17:26:05 -0800 Subject: [PATCH 2/2] fix the 'any's --- .../src/features/auth/hooks/useAuth.ts | 36 ++++++++++--------- 1 file changed, 20 insertions(+), 16 deletions(-) diff --git a/web/frontend/src/features/auth/hooks/useAuth.ts b/web/frontend/src/features/auth/hooks/useAuth.ts index 806478421..63fcd09c3 100644 --- a/web/frontend/src/features/auth/hooks/useAuth.ts +++ b/web/frontend/src/features/auth/hooks/useAuth.ts @@ -1,6 +1,12 @@ import { useEffect, useRef, useCallback } from 'react'; import { useAuthStore } from '../store/authStore'; +declare global { + interface Window { + __scriberr_original_fetch?: typeof window.fetch; + } +} + export function useAuth() { const { token, @@ -49,8 +55,7 @@ export function useAuth() { if (window.location.pathname !== "/") { // Force navigation handled by RouterContext or window.location if critical window.history.pushState({ route: { path: 'home' } }, "", "/"); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - window.dispatchEvent(new PopStateEvent('popstate', { state: { route: { path: 'home' } } as any })); + window.dispatchEvent(new PopStateEvent('popstate', { state: { route: { path: 'home' } } })); } }, [token, storeLogout]); @@ -63,7 +68,7 @@ export function useAuth() { const tryRefresh = useCallback(async (): Promise => { try { - const fetchToUse = (window as any).__scriberr_original_fetch || window.fetch; + const fetchToUse = window.__scriberr_original_fetch || window.fetch; const res = await fetchToUse('/api/v1/auth/refresh', { method: 'POST' }) if (!res.ok) return null const data = await res.json() @@ -81,11 +86,11 @@ export function useAuth() { // Consolidated token management useEffect(() => { if (!fetchWrapperSetupRef.current) { - if (!(window as any).__scriberr_original_fetch) { - (window as any).__scriberr_original_fetch = window.fetch.bind(window); + if (!window.__scriberr_original_fetch) { + window.__scriberr_original_fetch = window.fetch.bind(window); } - - const originalFetch = (window as any).__scriberr_original_fetch; + + const originalFetch = window.__scriberr_original_fetch!; const wrappedFetch: typeof window.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { const url = typeof input === 'string' ? input : (input instanceof URL ? input.href : input.url); const isAuthEndpoint = url.includes('/api/v1/auth/'); @@ -94,11 +99,11 @@ export function useAuth() { if (res.status === 401 && !isAuthEndpoint) { const newToken = await tryRefresh() if (newToken) { - const newInit: RequestInit | undefined = init ? { ...init } : undefined - if (newInit?.headers && typeof newInit.headers === 'object') { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (newInit.headers as any)['Authorization'] = `Bearer ${newToken}` - } + const newInit: RequestInit = init ? { ...init } : {}; + const headers = new Headers(newInit.headers); + headers.set('Authorization', `Bearer ${newToken}`); + newInit.headers = headers; + res = await originalFetch(input, newInit) if (res.status !== 401) return res } @@ -106,11 +111,10 @@ export function useAuth() { } return res; }; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - window.fetch = wrappedFetch as any; + window.fetch = wrappedFetch; fetchWrapperSetupRef.current = true; - // Note: We don't restore originalFetch on unmount because other components - // also use useAuth and expect the wrapped version. This is a bit hacky + // Note: We don't restore originalFetch on unmount because other components + // also use useAuth and expect the wrapped version. This is a bit hacky // but safer than multiple re-wrapping/unwrapping. }