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:
Anthony Stirling
2026-06-10 13:44:21 +01:00
co-authored by Claude Opus 4.8
parent 247ef6313c
commit e7bbbb4702
5 changed files with 79 additions and 4 deletions
+8 -1
View File
@@ -161,7 +161,14 @@ export function AuthProvider({ children }: { children: ReactNode }) {
"[Auth Debug] Fetching credits for user:",
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;
// Map server payload to app CreditSummary
+18 -1
View File
@@ -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();