From e7bbbb47029c181b4e919cc73db6fd5b873dd815 Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+frooodle@users.noreply.github.com> Date: Wed, 10 Jun 2026 13:44:21 +0100 Subject: [PATCH] fix: make the 401 login redirect loop structurally impossible Audit of every code path that can produce the login->/->login cycle found the observed loop was one instance of a repeatable class: any automatic API call that persistently 401s while the Supabase session is valid triggers httpErrorHandler's hard redirect to /login, which sees the valid session and bounces back. Close the class, not just the instance: - saas apiClient: a 401 that survives a refresh-and-retry means the backend rejected a valid token (authz bug / wrong origin), not an expired session - never redirect to /login for it. Also fix the stale publicEndpoints entry ('endpoints-enabled' matched nothing; the real routes are endpoints-availability and endpoint-enabled). - httpErrorHandler: sessionStorage loop breaker - if a 401 redirect already fired within 10s, suppress the repeat instead of cycling. - Guard the remaining unflagged automatic callers: /api/v1/credits (fires on session init and TOKEN_REFRESHED), endpoints-availability (fires on app load), and ui-data/login (auto-called when a stale stirling_jwt is present). Co-Authored-By: Claude Opus 4.8 --- .../src/core/hooks/useEndpointConfig.ts | 10 ++++- .../src/core/services/accountService.ts | 4 ++ .../src/core/services/httpErrorHandler.ts | 41 +++++++++++++++++++ frontend/editor/src/saas/auth/UseSession.tsx | 9 +++- .../editor/src/saas/services/apiClient.ts | 19 ++++++++- 5 files changed, 79 insertions(+), 4 deletions(-) diff --git a/frontend/editor/src/core/hooks/useEndpointConfig.ts b/frontend/editor/src/core/hooks/useEndpointConfig.ts index 9bd5f1712..307b4a460 100644 --- a/frontend/editor/src/core/hooks/useEndpointConfig.ts +++ b/frontend/editor/src/core/hooks/useEndpointConfig.ts @@ -128,10 +128,16 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): { "[useEndpointConfig] Fetching all endpoint statuses from server", ); - // Fetch all endpoints at once - no query params needed + // Fetch all endpoints at once - no query params needed. + // Fires automatically on app load; a 401 here (e.g. backend rejecting + // an otherwise-valid session) must fail silently rather than reach the + // global handler, which hard-redirects to /login and can loop. const response = await apiClient.get< Record - >(`/api/v1/config/endpoints-availability`); + >(`/api/v1/config/endpoints-availability`, { + suppressErrorToast: true, + skipAuthRedirect: true, + }); // Populate global cache with all results Object.entries(response.data).forEach(([endpoint, details]) => { diff --git a/frontend/editor/src/core/services/accountService.ts b/frontend/editor/src/core/services/accountService.ts index 378a08423..97d4f4cad 100644 --- a/frontend/editor/src/core/services/accountService.ts +++ b/frontend/editor/src/core/services/accountService.ts @@ -28,8 +28,12 @@ export const accountService = { * This is a public endpoint - doesn't require authentication */ async getLoginPageData(): Promise { + // Public endpoint, but also auto-called by the onboarding orchestrator + // when a stale stirling_jwt is in localStorage — a 401 must never trigger + // the global login redirect (it would loop on every page load). const response = await apiClient.get( "/api/v1/proprietary/ui-data/login", + { suppressErrorToast: true, skipAuthRedirect: true }, ); return response.data; }, diff --git a/frontend/editor/src/core/services/httpErrorHandler.ts b/frontend/editor/src/core/services/httpErrorHandler.ts index 968a44a99..b831f1581 100644 --- a/frontend/editor/src/core/services/httpErrorHandler.ts +++ b/frontend/editor/src/core/services/httpErrorHandler.ts @@ -46,6 +46,39 @@ function stashPostLoginRedirect(path: string): void { } } +// Loop breaker for the 401 hard-redirect below. If the backend persistently +// 401s an automatic call while the auth session is actually valid, the +// redirect lands on /login, the login page sees the valid session and bounces +// back, the call 401s again and we redirect again — forever. sessionStorage +// survives the full-page navigations of that cycle (per tab), so a second +// redirect within the window means we're looping, not expiring. +const LOGIN_REDIRECT_THROTTLE_KEY = "stirling_last_401_redirect"; +const LOGIN_REDIRECT_THROTTLE_MS = 10_000; + +function loginRedirectRecentlyFired(): boolean { + try { + const last = Number( + window.sessionStorage.getItem(LOGIN_REDIRECT_THROTTLE_KEY), + ); + return ( + Number.isFinite(last) && Date.now() - last < LOGIN_REDIRECT_THROTTLE_MS + ); + } catch { + return false; + } +} + +function markLoginRedirectFired(): void { + try { + window.sessionStorage.setItem( + LOGIN_REDIRECT_THROTTLE_KEY, + String(Date.now()), + ); + } catch { + // sessionStorage unavailable — fail open + } +} + /** * Handles HTTP errors with toast notifications and file error broadcasting * Returns true if the error should be suppressed (deduplicated), false otherwise @@ -71,6 +104,13 @@ export async function handleHttpError(error: any): Promise { // If not on auth page, redirect to login with expired session message if (!isAuthPage && !skipAuthRedirect) { + if (loginRedirectRecentlyFired()) { + console.warn( + "[httpErrorHandler] 401 redirect already fired moments ago — suppressing repeat to avoid a login loop:", + error?.config?.url, + ); + return true; + } console.debug("[httpErrorHandler] 401 detected, redirecting to login"); // Spring 302-strips the ?from= query from /login, so stash the return // path in sessionStorage (AuthCallback reads it after SSO round-trip). @@ -83,6 +123,7 @@ export async function handleHttpError(error: any): Promise { // ignore storage access failures } const expiredPrefix = hadStoredJwt ? "expired=true&" : ""; + markLoginRedirectFired(); window.location.href = `${withBasePath("/login")}?${expiredPrefix}from=${encodeURIComponent(currentLocation)}`; return true; // Suppress toast since we're redirecting } diff --git a/frontend/editor/src/saas/auth/UseSession.tsx b/frontend/editor/src/saas/auth/UseSession.tsx index 8f46a1833..c493df006 100644 --- a/frontend/editor/src/saas/auth/UseSession.tsx +++ b/frontend/editor/src/saas/auth/UseSession.tsx @@ -161,7 +161,14 @@ export function AuthProvider({ children }: { children: ReactNode }) { "[Auth Debug] Fetching credits for user:", currentSession.user.id, ); - const response = await apiClient.get("/api/v1/credits"); + // Fired automatically on session init and TOKEN_REFRESHED. If the + // backend rejects it (deploy skew, authz bug) the failure must stay + // local — the global 401 handler would hard-redirect to /login and, + // with a valid session, loop login -> / -> login forever. + const response = await apiClient.get("/api/v1/credits", { + suppressErrorToast: true, + skipAuthRedirect: true, + }); const apiCredits = response.data; // Map server payload to app CreditSummary diff --git a/frontend/editor/src/saas/services/apiClient.ts b/frontend/editor/src/saas/services/apiClient.ts index f484b28f8..c806d5217 100644 --- a/frontend/editor/src/saas/services/apiClient.ts +++ b/frontend/editor/src/saas/services/apiClient.ts @@ -116,7 +116,10 @@ const publicEndpoints = [ "/api/v1/config/app-config", "/api/v1/info/status", "/api/v1/config/public-config", - "/api/v1/config/endpoints-enabled", + // Both real endpoint-config routes (an earlier entry here said + // "endpoints-enabled", which matches neither and silently guarded nothing). + "/api/v1/config/endpoints-availability", + "/api/v1/config/endpoint-enabled", ]; // De-duplicate concurrent token refreshes. @@ -254,6 +257,20 @@ apiClient.interceptors.response.use( ); originalRequest.skipAuthRedirect = true; } + + // If a request was already retried with a freshly refreshed token and the + // backend STILL returned 401, the session is not the problem — the backend + // is rejecting a valid token (authorization bug, wrong API origin, etc.). + // Redirecting to /login cannot fix that: the login page sees the valid + // Supabase session and bounces straight back, producing an infinite + // login -> / -> login loop. Keep the error toast, skip the redirect. + if (status === 401 && originalRequest._retry && !isPublicEndpoint) { + console.warn( + "[API Client] 401 persisted after token refresh; backend rejected a valid session — not redirecting to login:", + originalRequest.url, + ); + originalRequest.skipAuthRedirect = true; + } const url = error.config?.url; const method = error.config?.method?.toUpperCase();