From a398e5959fd1b1e8068a2bed6e39094569c449d1 Mon Sep 17 00:00:00 2001 From: Jon Jackson Date: Fri, 5 Dec 2025 13:38:42 -0500 Subject: [PATCH] Fix authentication redirect loop on repeated 401 responses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add detection and handling for redirect loops that can occur when the console repeatedly receives 401 responses from the Kubernetes API. Track consecutive 401s using sessionStorage and redirect to an error page after 3 failed authentication attempts to prevent infinite loops. The redirect counter is reset on any successful Kubernetes API request, ensuring normal authentication flows are not affected. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../src/utils/__tests__/console-fetch.spec.ts | 10 +-- .../src/utils/console-fetch-utils.ts | 23 ++++-- frontend/public/module/auth.ts | 70 +++++++++++++++++++ 3 files changed, 94 insertions(+), 9 deletions(-) diff --git a/frontend/packages/console-shared/src/utils/__tests__/console-fetch.spec.ts b/frontend/packages/console-shared/src/utils/__tests__/console-fetch.spec.ts index abf3fda29be..527ee36306a 100644 --- a/frontend/packages/console-shared/src/utils/__tests__/console-fetch.spec.ts +++ b/frontend/packages/console-shared/src/utils/__tests__/console-fetch.spec.ts @@ -1,7 +1,7 @@ import { RetryError } from '@console/dynamic-plugin-sdk/src/utils/error/http-error'; import { - shouldLogout, unescapeGoUnicode, + isK8sUrl, validateStatus, } from '@console/shared/src/utils/console-fetch-utils'; import { coFetch } from '../console-fetch'; @@ -41,25 +41,25 @@ describe('consoleFetch', () => { headers.set('content-type', 'application/json'); it('logs out users who get a 401 from k8s', () => { - expect(shouldLogout('/api/kubernetes/api/v1/pods')).toEqual(true); + expect(isK8sUrl('/api/kubernetes/api/v1/pods')).toEqual(true); }); it('respects basePath and logs out users who get a 401 from k8s', () => { const originalBasePath = window.SERVER_FLAGS.basePath; window.SERVER_FLAGS.basePath = '/blah/'; - expect(shouldLogout('/blah/api/kubernetes/api/v1/pods')).toEqual(true); + expect(isK8sUrl('/blah/api/kubernetes/api/v1/pods')).toEqual(true); window.SERVER_FLAGS.basePath = originalBasePath; }); it('does not log out users who get a 401 from chargeback', () => { expect( - shouldLogout('/api/kubernetes/api/v1/namespaces/prd354/services/chargeback/proxy/api'), + isK8sUrl('/api/kubernetes/api/v1/namespaces/prd354/services/chargeback/proxy/api'), ).toEqual(false); }); it('does not log out users who get a 401 from graphs', () => { expect( - shouldLogout( + isK8sUrl( '/api/kubernetes/api/v1/proxy/namespaces/tectonic-system/services/prometheus:9090/api/v1/query?query=100%20-%20(sum(rate(node_cpu%7Bjob%3D%22node-exporter%22%2Cmode%3D%22idle%22%7D%5B2m%5D))%20%2F%20count(node_cpu%7Bjob%3D%22node-exporter%22%2C%20mode%3D%22idle%22%7D))%20*%20100', ), ).toEqual(false); diff --git a/frontend/packages/console-shared/src/utils/console-fetch-utils.ts b/frontend/packages/console-shared/src/utils/console-fetch-utils.ts index af13ba3ea0a..95f5777f8a0 100644 --- a/frontend/packages/console-shared/src/utils/console-fetch-utils.ts +++ b/frontend/packages/console-shared/src/utils/console-fetch-utils.ts @@ -112,8 +112,8 @@ export const applyConsoleHeaders = (url: string, options: RequestInit): RequestI return options; }; -// TODO: url can be url or path, but shouldLogout only handles paths -export const shouldLogout = (url: string): boolean => { +// TODO: url can be url or path, but isK8sUrl only handles paths +export const isK8sUrl = (url: string): boolean => { const k8sRegex = new RegExp(`^${window.SERVER_FLAGS.basePath}api/kubernetes/`); // 401 from k8s. show logout screen if (k8sRegex.test(url)) { @@ -151,7 +151,22 @@ export const validateStatus = async ( method: string, retry: boolean, ) => { + const isK8sRequest = isK8sUrl(url); if (response.ok || response.status === 304) { + // Reset redirect counter on successful k8s request + if (isK8sRequest) { + // We can't use regular import from outside this package, so a dynamic import is required + // This also breaks a nasty cycle - authSvc.logout calls coFetch (which calls validateStatus) + import('@console/internal/module/auth') + .then((m) => m.authSvc) + .then((authSvc) => { + authSvc.resetRedirectCount(); + }) + .catch((e) => { + // eslint-disable-next-line no-console + console.error('Error resetting redirect counter', e); + }); + } return response; } @@ -159,14 +174,14 @@ export const validateStatus = async ( throw new RetryError(); } - if (response.status === 401 && shouldLogout(url)) { + if (response.status === 401 && isK8sRequest) { const next = window.location.pathname + window.location.search + window.location.hash; // This also breaks a nasty cycle - authSvc.logout calls coFetch (which calls validateStatus) import('@console/internal/module/auth') .then((m) => m.authSvc) .then((authSvc) => { - authSvc.logout(next); + authSvc.handle401(next); }) .catch((e) => { // eslint-disable-next-line no-console diff --git a/frontend/public/module/auth.ts b/frontend/public/module/auth.ts index c112813def9..325bdaaacb3 100644 --- a/frontend/public/module/auth.ts +++ b/frontend/public/module/auth.ts @@ -26,6 +26,10 @@ const name = 'name'; const email = 'email'; const clearLocalStorageKeys = [userID, name, email]; +// Constants for redirect loop detection +const AUTH_REDIRECT_COUNT_KEY = 'auth-redirect-count'; +const MAX_AUTH_REDIRECTS = 3; + const setNext = (next: string) => { if (!next) { return; @@ -51,6 +55,39 @@ const clearLocalStorage = (keys: string[]) => { }); }; +// Helper functions for redirect counter +const getAuthRedirectCount = () => { + try { + const count = sessionStorage.getItem(AUTH_REDIRECT_COUNT_KEY); + return count ? parseInt(count, 10) : 0; + } catch (e) { + // eslint-disable-next-line no-console + console.error('Failed to get auth redirect count from sessionStorage', e); + return 0; + } +}; + +const incrementAuthRedirectCount = () => { + try { + const count = getAuthRedirectCount() + 1; + sessionStorage.setItem(AUTH_REDIRECT_COUNT_KEY, count.toString()); + return count; + } catch (e) { + // eslint-disable-next-line no-console + console.error('Failed to increment auth redirect count in sessionStorage', e); + return 0; + } +}; + +const resetAuthRedirectCount = () => { + try { + sessionStorage.removeItem(AUTH_REDIRECT_COUNT_KEY); + } catch (e) { + // eslint-disable-next-line no-console + console.error('Failed to reset auth redirect count in sessionStorage', e); + } +}; + export const authSvc = { userID: () => { const id = loginStateItem(userID); @@ -126,4 +163,37 @@ export const authSvc = { window.location.assign(loginURL); } }, + + // Handle 401 responses with redirect loop detection + handle401: (next) => { + const redirectCount = incrementAuthRedirectCount(); + + // If we've exceeded the max redirects, redirect to the error page + if (redirectCount > MAX_AUTH_REDIRECTS) { + // eslint-disable-next-line no-console + console.error( + `Authentication redirect loop detected (${redirectCount} consecutive 401 responses). Redirecting to error page.`, + ); + + // Build error page URL with query parameters + const errorURL = new URL(loginErrorURL || '/auth/error', window.location.origin); + errorURL.searchParams.set('error', 'redirect_loop_detected'); + errorURL.searchParams.set('error_type', 'auth'); + + // Avoid redirecting if we're already on the error page + if (![window.location.href, window.location.pathname].includes(loginErrorURL)) { + window.location.href = errorURL.toString(); + } + resetAuthRedirectCount(); + return; + } + + // Proceed with normal logout flow + authSvc.logout(next); + }, + + // Reset redirect counter (called on successful k8s requests) + resetRedirectCount: () => { + resetAuthRedirectCount(); + }, };