mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-14 10:34:06 +02:00
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 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
247ef6313c
commit
e7bbbb4702
@@ -128,10 +128,16 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): {
|
|||||||
"[useEndpointConfig] Fetching all endpoint statuses from server",
|
"[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<
|
const response = await apiClient.get<
|
||||||
Record<string, EndpointAvailabilityDetails>
|
Record<string, EndpointAvailabilityDetails>
|
||||||
>(`/api/v1/config/endpoints-availability`);
|
>(`/api/v1/config/endpoints-availability`, {
|
||||||
|
suppressErrorToast: true,
|
||||||
|
skipAuthRedirect: true,
|
||||||
|
});
|
||||||
|
|
||||||
// Populate global cache with all results
|
// Populate global cache with all results
|
||||||
Object.entries(response.data).forEach(([endpoint, details]) => {
|
Object.entries(response.data).forEach(([endpoint, details]) => {
|
||||||
|
|||||||
@@ -28,8 +28,12 @@ export const accountService = {
|
|||||||
* This is a public endpoint - doesn't require authentication
|
* This is a public endpoint - doesn't require authentication
|
||||||
*/
|
*/
|
||||||
async getLoginPageData(): Promise<LoginPageData> {
|
async getLoginPageData(): Promise<LoginPageData> {
|
||||||
|
// 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<LoginPageData>(
|
const response = await apiClient.get<LoginPageData>(
|
||||||
"/api/v1/proprietary/ui-data/login",
|
"/api/v1/proprietary/ui-data/login",
|
||||||
|
{ suppressErrorToast: true, skipAuthRedirect: true },
|
||||||
);
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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
|
* Handles HTTP errors with toast notifications and file error broadcasting
|
||||||
* Returns true if the error should be suppressed (deduplicated), false otherwise
|
* Returns true if the error should be suppressed (deduplicated), false otherwise
|
||||||
@@ -71,6 +104,13 @@ export async function handleHttpError(error: any): Promise<boolean> {
|
|||||||
|
|
||||||
// If not on auth page, redirect to login with expired session message
|
// If not on auth page, redirect to login with expired session message
|
||||||
if (!isAuthPage && !skipAuthRedirect) {
|
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");
|
console.debug("[httpErrorHandler] 401 detected, redirecting to login");
|
||||||
// Spring 302-strips the ?from= query from /login, so stash the return
|
// Spring 302-strips the ?from= query from /login, so stash the return
|
||||||
// path in sessionStorage (AuthCallback reads it after SSO round-trip).
|
// path in sessionStorage (AuthCallback reads it after SSO round-trip).
|
||||||
@@ -83,6 +123,7 @@ export async function handleHttpError(error: any): Promise<boolean> {
|
|||||||
// ignore storage access failures
|
// ignore storage access failures
|
||||||
}
|
}
|
||||||
const expiredPrefix = hadStoredJwt ? "expired=true&" : "";
|
const expiredPrefix = hadStoredJwt ? "expired=true&" : "";
|
||||||
|
markLoginRedirectFired();
|
||||||
window.location.href = `${withBasePath("/login")}?${expiredPrefix}from=${encodeURIComponent(currentLocation)}`;
|
window.location.href = `${withBasePath("/login")}?${expiredPrefix}from=${encodeURIComponent(currentLocation)}`;
|
||||||
return true; // Suppress toast since we're redirecting
|
return true; // Suppress toast since we're redirecting
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -161,7 +161,14 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
"[Auth Debug] Fetching credits for user:",
|
"[Auth Debug] Fetching credits for user:",
|
||||||
currentSession.user.id,
|
currentSession.user.id,
|
||||||
);
|
);
|
||||||
const response = await apiClient.get<ApiCredits>("/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<ApiCredits>("/api/v1/credits", {
|
||||||
|
suppressErrorToast: true,
|
||||||
|
skipAuthRedirect: true,
|
||||||
|
});
|
||||||
const apiCredits = response.data;
|
const apiCredits = response.data;
|
||||||
|
|
||||||
// Map server payload to app CreditSummary
|
// Map server payload to app CreditSummary
|
||||||
|
|||||||
@@ -116,7 +116,10 @@ const publicEndpoints = [
|
|||||||
"/api/v1/config/app-config",
|
"/api/v1/config/app-config",
|
||||||
"/api/v1/info/status",
|
"/api/v1/info/status",
|
||||||
"/api/v1/config/public-config",
|
"/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.
|
// De-duplicate concurrent token refreshes.
|
||||||
@@ -254,6 +257,20 @@ apiClient.interceptors.response.use(
|
|||||||
);
|
);
|
||||||
originalRequest.skipAuthRedirect = true;
|
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 url = error.config?.url;
|
||||||
const method = error.config?.method?.toUpperCase();
|
const method = error.config?.method?.toUpperCase();
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user