diff --git a/frontend/src/config.ts b/frontend/src/config.ts index 207d07feca..a99843c936 100644 --- a/frontend/src/config.ts +++ b/frontend/src/config.ts @@ -44,6 +44,7 @@ import { api, useApiStore } from './state/backend-api'; import { useUIStateStore } from './state/ui-state'; import { AppFeatures, getBasePath } from './utils/env'; import { getEmbeddedAvailableRoutes } from './utils/route-utils'; +import { getRegisteredTokenRefreshInterceptor } from './utils/token-refresh-interceptor'; declare const __webpack_public_path__: string; @@ -206,11 +207,18 @@ const setConfig = ({ }: SetConfigArguments) => { const assetsUrl = urlOverride?.assets === 'WEBPACK' ? String(__webpack_public_path__).removeSuffix('/') : urlOverride?.assets; + const configuredFetch = fetch ?? window.fetch.bind(window); + const tokenRefreshInterceptor = getRegisteredTokenRefreshInterceptor(); // instantiate the client once, if we need to add more clients you can add them in here, ideally only one transport is necessary const dataplaneTransport = createConnectTransport({ baseUrl: getGrpcBasePath(urlOverride?.grpc), - interceptors: [addBearerTokenInterceptor, checkExpiredLicenseInterceptor], + fetch: configuredFetch, + interceptors: [ + addBearerTokenInterceptor, + ...(tokenRefreshInterceptor ? [tokenRefreshInterceptor] : []), + checkExpiredLicenseInterceptor, + ], jsonOptions: { registry: protobufRegistry, }, @@ -218,7 +226,8 @@ const setConfig = ({ const controlplaneTransport = createConnectTransport({ baseUrl: config.controlplaneUrl, - interceptors: [addBearerTokenInterceptor], + fetch: configuredFetch, + interceptors: [addBearerTokenInterceptor, ...(tokenRefreshInterceptor ? [tokenRefreshInterceptor] : [])], jsonOptions: { registry: protobufRegistry, }, @@ -250,7 +259,7 @@ const setConfig = ({ restBasePath: getRestBasePath(urlOverride?.rest), grpcBasePath: getGrpcBasePath(urlOverride?.grpc), controlplaneUrl: config.controlplaneUrl, - fetch: fetch ?? window.fetch.bind(window), + fetch: configuredFetch, assetsPath: assetsUrl ?? getBasePath(), authenticationClient: authenticationGrpcClient, licenseClient: licenseGrpcClient, @@ -352,7 +361,7 @@ setTimeout(() => { updateSidebarItems(); } }); - } catch (error) { + } catch (_error) { // Ignore errors in test environments where stores might not be properly initialized // This setTimeout runs globally when config.ts is imported } diff --git a/frontend/src/embedded-app.test.tsx b/frontend/src/embedded-app.test.tsx new file mode 100644 index 0000000000..e56b8ba8aa --- /dev/null +++ b/frontend/src/embedded-app.test.tsx @@ -0,0 +1,94 @@ +import { render } from '@testing-library/react'; +import type { ReactNode } from 'react'; + +const { mockCreateConnectTransport, mockCreateRouter, mockFetch, mockSetup } = vi.hoisted(() => ({ + mockCreateConnectTransport: vi.fn((options) => options), + mockCreateRouter: vi.fn(() => ({ + invalidate: vi.fn().mockResolvedValue(undefined), + })), + mockFetch: vi.fn(), + mockSetup: vi.fn(), +})); + +vi.mock('@connectrpc/connect-query', () => ({ + TransportProvider: ({ children }: { children?: ReactNode }) => <>{children}, +})); + +vi.mock('@connectrpc/connect-web', () => ({ + createConnectTransport: mockCreateConnectTransport, +})); + +vi.mock('@redpanda-data/ui', () => ({ + ChakraProvider: ({ children }: { children?: ReactNode }) => <>{children}, + redpandaTheme: {}, + redpandaToastOptions: {}, +})); + +vi.mock('@tanstack/react-query', async () => { + const actual = await vi.importActual('@tanstack/react-query'); + return { + ...actual, + QueryClientProvider: ({ children }: { children?: ReactNode }) => <>{children}, + }; +}); + +vi.mock('@tanstack/react-router', () => ({ + createRouter: mockCreateRouter, + RouterProvider: () =>
, +})); + +vi.mock('custom-feature-flag-provider', () => ({ + CustomFeatureFlagProvider: ({ children }: { children?: ReactNode }) => <>{children}, +})); + +vi.mock('protobuf-registry', () => ({ + protobufRegistry: {}, +})); + +vi.mock('./components/misc/not-found-page', () => ({ + NotFoundPage: () =>
Not Found
, +})); + +vi.mock('./config', () => ({ + addBearerTokenInterceptor: vi.fn((next) => next), + checkExpiredLicenseInterceptor: vi.fn((next) => next), + getGrpcBasePath: vi.fn(() => 'http://localhost:9090'), + setup: mockSetup, +})); + +vi.mock('./routeTree.gen', () => ({ + routeTree: {}, +})); + +vi.mock('./state/app-global', () => ({ + appGlobal: { + historyLocation: vi.fn(() => ({ pathname: '/topics' })), + historyPush: vi.fn(), + }, +})); + +vi.mock('./state/backend-api', () => ({ + api: { + refreshUserData: vi.fn().mockResolvedValue(undefined), + }, +})); + +import EmbeddedApp from './embedded-app'; + +describe('EmbeddedApp', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + test('uses the host-provided fetch for the root dataplane transport', () => { + render(); + + expect(mockSetup).toHaveBeenCalled(); + expect(mockCreateConnectTransport).toHaveBeenCalledWith( + expect.objectContaining({ + baseUrl: 'http://localhost:9090', + fetch: mockFetch, + }) + ); + }); +}); diff --git a/frontend/src/embedded-app.tsx b/frontend/src/embedded-app.tsx index 318f4b21ff..f60810acf2 100644 --- a/frontend/src/embedded-app.tsx +++ b/frontend/src/embedded-app.tsx @@ -52,6 +52,12 @@ import { } from './config'; import { routeTree } from './routeTree.gen'; import { appGlobal } from './state/app-global'; +import { api } from './state/backend-api'; +import { + getRegisteredTokenRefreshInterceptor, + TokenRefreshInterceptorProvider, +} from './utils/token-refresh-interceptor'; +import { useEmbeddedAuthPrewarm } from './utils/use-embedded-auth-prewarm'; // Regex for normalizing paths by removing trailing slashes const TRAILING_SLASH_REGEX = /\/+$/; @@ -86,6 +92,10 @@ export interface EmbeddedProps extends SetConfigArguments { } function EmbeddedApp({ basePath = '', ...p }: EmbeddedProps) { + const tokenRefreshInterceptor = getRegisteredTokenRefreshInterceptor(); + const defaultFetch = useMemo(() => window.fetch.bind(window), []); + const configuredFetch = p.fetch ?? defaultFetch; + useEffect(() => { const shellNavigationHandler = (event: Event) => { const pathname = (event as CustomEvent).detail; @@ -115,12 +125,17 @@ function EmbeddedApp({ basePath = '', ...p }: EmbeddedProps) { () => createConnectTransport({ baseUrl: getGrpcBasePath(p.urlOverride?.grpc), - interceptors: [addBearerTokenInterceptor, checkExpiredLicenseInterceptor], + fetch: configuredFetch, + interceptors: [ + addBearerTokenInterceptor, + ...(tokenRefreshInterceptor ? [tokenRefreshInterceptor] : []), + checkExpiredLicenseInterceptor, + ], jsonOptions: { registry: protobufRegistry, }, }), - [p.urlOverride?.grpc] + [configuredFetch, p.urlOverride?.grpc, tokenRefreshInterceptor] ); // Create router with dynamic basePath @@ -139,20 +154,33 @@ function EmbeddedApp({ basePath = '', ...p }: EmbeddedProps) { [basePath, dataplaneTransport] ); + useEmbeddedAuthPrewarm({ + enabled: Boolean(p.isConsoleReadyToMount), + prewarm: async () => { + await api.refreshUserData().catch(() => { + // Best-effort prewarm only; embedded hosts handle any hard auth failures. + }); + + await Promise.allSettled([queryClient.invalidateQueries(), router.invalidate()]); + }, + }); + if (!p.isConsoleReadyToMount) { return null; } return ( - - - - - - - - - + + + + + + + + + + + ); } diff --git a/frontend/src/federation/console-app.test.tsx b/frontend/src/federation/console-app.test.tsx index e4b32d9b94..4553e7ccd0 100644 --- a/frontend/src/federation/console-app.test.tsx +++ b/frontend/src/federation/console-app.test.tsx @@ -9,7 +9,10 @@ * by the Apache License, Version 2.0 */ -import { render, waitFor } from '@testing-library/react'; +import { act, render, waitFor } from '@testing-library/react'; +import type { ReactNode } from 'react'; + +const tokenManagerInstances: Array<{ refresh: ReturnType }> = []; // Mock TanStack Router before any imports vi.mock('@tanstack/react-router', async () => { @@ -19,6 +22,7 @@ vi.mock('@tanstack/react-router', async () => { createRouter: vi.fn(() => ({ subscribe: vi.fn(() => vi.fn()), // Returns unsubscribe function load: vi.fn().mockResolvedValue(undefined), + invalidate: vi.fn().mockResolvedValue(undefined), state: { location: { pathname: '/topics' } }, navigate: vi.fn(), })), @@ -28,12 +32,22 @@ vi.mock('@tanstack/react-router', async () => { push: vi.fn(), replace: vi.fn(), })), - RouterProvider: ({ children }: { children?: React.ReactNode }) => ( -
{children}
- ), + RouterProvider: ({ children }: { children?: ReactNode }) =>
{children}
, }; }); +vi.mock('@redpanda-data/ui', () => ({ + ChakraProvider: ({ children }: { children?: ReactNode }) => <>{children}, + createStandaloneToast: vi.fn(() => ({ + ToastContainer: () => null, + toast: { + closeAll: vi.fn(), + }, + })), + redpandaTheme: {}, + redpandaToastOptions: {}, +})); + vi.mock('config', () => ({ config: { jwt: undefined as string | undefined, @@ -56,6 +70,16 @@ vi.mock('../components/misc/not-found-page', () => ({ NotFoundPage: () =>
Not Found
, })); +vi.mock('../state/backend-api', () => ({ + api: { + refreshUserData: vi.fn().mockResolvedValue(undefined), + }, +})); + +vi.mock('./federated-providers', () => ({ + FederatedProviders: ({ children }: { children?: ReactNode }) => <>{children}, +})); + vi.mock('./token-manager', () => ({ TokenManager: class MockTokenManager { private getToken: () => Promise; @@ -70,6 +94,7 @@ vi.mock('./token-manager', () => ({ this.setGetAccessToken.mockImplementation((fn: () => Promise) => { this.getToken = fn; }); + tokenManagerInstances.push(this); } }, })); @@ -99,16 +124,27 @@ describe('ConsoleApp', () => { beforeEach(() => { vi.clearAllMocks(); + tokenManagerInstances.length = 0; mockGetAccessToken.mockResolvedValue('test-token-123'); // Reset config.jwt config.jwt = undefined; }); + afterEach(() => { + vi.useRealTimers(); + }); + test('shows nothing while loading', () => { - const { container } = render(); + const tokenPromise = new Promise(() => { + // Intentionally unresolved to keep the component in its loading state. + }); + mockGetAccessToken.mockReturnValueOnce(tokenPromise); + + const { container, unmount } = render(); // Component returns null while initializing expect(container.firstChild).toBeNull(); + unmount(); }); test('calls getAccessToken on mount', async () => { @@ -154,7 +190,9 @@ describe('ConsoleApp', () => { test('stays in loading state while token refresh is pending', async () => { // Create a deferred promise that never resolves during this test - const tokenPromise = new Promise(() => {}); + const tokenPromise = new Promise(() => { + // Intentionally unresolved to keep the component in its loading state. + }); mockGetAccessToken.mockReturnValueOnce(tokenPromise); const { container } = render(); @@ -224,6 +262,25 @@ describe('ConsoleApp', () => { unmount(); }); + test('prewarms auth once when focus and visibility events arrive in a burst', async () => { + render(); + + await waitFor(() => { + expect(mockGetAccessToken).toHaveBeenCalledTimes(1); + }); + + expect(tokenManagerInstances).toHaveLength(1); + vi.useFakeTimers(); + + await act(async () => { + document.dispatchEvent(new Event('visibilitychange')); + window.dispatchEvent(new Event('focus')); + await vi.advanceTimersByTimeAsync(200); + }); + + expect(tokenManagerInstances[0]?.refresh).toHaveBeenCalledTimes(2); + }); + describe('Feature Flags', () => { test('passes feature flags to setup()', async () => { const flags = { schemaRegistry: true, enableKnowledgeBaseInConsoleUi: false }; diff --git a/frontend/src/federation/console-app.tsx b/frontend/src/federation/console-app.tsx index 5b09ae8b7b..1ccdc2f00a 100644 --- a/frontend/src/federation/console-app.tsx +++ b/frontend/src/federation/console-app.tsx @@ -14,7 +14,7 @@ // Array prototype extensions (must be imported early) import '../utils/array-extensions'; -import { Component, type ErrorInfo, type ReactNode, useEffect, useMemo, useRef, useState } from 'react'; +import { Component, type ErrorInfo, type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import '@xyflow/react/dist/base.css'; import '@xyflow/react/dist/style.css'; @@ -32,7 +32,6 @@ import '../assets/fonts/kumbh-sans.css'; import '../globals.css'; /* end tailwind styles */ -import { Code, ConnectError, type Interceptor } from '@connectrpc/connect'; import { createConnectTransport } from '@connectrpc/connect-web'; import { QueryClient } from '@tanstack/react-query'; import { createMemoryHistory, createRouter, RouterProvider } from '@tanstack/react-router'; @@ -44,38 +43,14 @@ import type { ConsoleAppProps } from './types'; import { NotFoundPage } from '../components/misc/not-found-page'; import { addBearerTokenInterceptor, checkExpiredLicenseInterceptor, config, getGrpcBasePath, setup } from '../config'; import { routeTree } from '../routeTree.gen'; - -/** - * Creates an interceptor that refreshes the token on 401 and retries the request. - * Uses TokenManager for deduplication and abort support. - */ -function createTokenRefreshInterceptor(tokenManager: TokenManager): Interceptor { - return (next) => async (request) => { - try { - return await next(request); - } catch (error) { - // Only handle Unauthenticated errors - if (!(error instanceof ConnectError && error.code === Code.Unauthenticated)) { - throw error; - } - - // Use TokenManager for deduplicated refresh - try { - await tokenManager.refresh(); - } catch { - throw error; // Throw original error if refresh fails - } - - // Retry the request with refreshed token. - // Header mutation is necessary because the original request was created - // with the old token by addBearerTokenInterceptor on the first attempt. - if (config.jwt) { - request.header.set('Authorization', `Bearer ${config.jwt}`); - } - return await next(request); - } - }; -} +import { api } from '../state/backend-api'; +import { createTokenRefreshInterceptor } from '../utils/create-token-refresh-interceptor'; +import { notifyEmbeddedAuthError } from '../utils/notify-embedded-auth-error'; +import { + setRegisteredTokenRefreshInterceptor, + TokenRefreshInterceptorProvider, +} from '../utils/token-refresh-interceptor'; +import { useEmbeddedAuthPrewarm } from '../utils/use-embedded-auth-prewarm'; /** * Error boundary for the federated Console app. @@ -170,6 +145,8 @@ function ConsoleAppInner({ const [isInitialized, setIsInitialized] = useState(false); // Track last notified path to prevent navigation loops between host and remote const lastNotifiedPathRef = useRef(initialPath); + const defaultFetch = useMemo(() => window.fetch.bind(window), []); + const configuredFetch = configOverrides?.fetch ?? defaultFetch; // Create stable QueryClient instance const queryClient = useMemo(() => createFederatedQueryClient(), []); @@ -194,11 +171,20 @@ function ConsoleAppInner({ }, [getAccessToken, tokenManager]); // Create token refresh interceptor using TokenManager - const tokenRefreshInterceptor = useMemo(() => createTokenRefreshInterceptor(tokenManager), [tokenManager]); + const tokenRefreshInterceptor = useMemo( + () => + createTokenRefreshInterceptor({ + getAccessToken: () => config.jwt, + refreshAccessToken: () => tokenManager.refresh(), + onRefreshFailure: () => notifyEmbeddedAuthError({ clusterId }), + }), + [clusterId, tokenManager] + ); // Initialize Console on mount and cleanup on unmount useEffect(() => { const initialize = async () => { + setRegisteredTokenRefreshInterceptor(tokenRefreshInterceptor); await tokenManager.refresh(); // Setup Console config with overrides @@ -218,22 +204,33 @@ function ConsoleAppInner({ // Cleanup on unmount return () => { + setRegisteredTokenRefreshInterceptor(undefined); tokenManager.reset(); queryClient.clear(); }; - }, [tokenManager, queryClient, clusterId, onSidebarItemsChange, onBreadcrumbsChange, featureFlags, configOverrides]); + }, [ + tokenManager, + tokenRefreshInterceptor, + queryClient, + clusterId, + onSidebarItemsChange, + onBreadcrumbsChange, + featureFlags, + configOverrides, + ]); // Create transport with token interceptors (including refresh on 401) const dataplaneTransport = useMemo( () => createConnectTransport({ baseUrl: getGrpcBasePath(configOverrides?.urlOverride?.grpc), + fetch: configuredFetch, interceptors: [addBearerTokenInterceptor, tokenRefreshInterceptor, checkExpiredLicenseInterceptor], jsonOptions: { registry: protobufRegistry, }, }), - [configOverrides?.urlOverride?.grpc, tokenRefreshInterceptor] + [configOverrides?.urlOverride?.grpc, configuredFetch, tokenRefreshInterceptor] ); // Create memory history router (host controls browser URL) @@ -293,17 +290,39 @@ function ConsoleAppInner({ } }, [navigateTo, isInitialized, router]); + const prewarmEmbeddedAuth = useCallback(async () => { + try { + await tokenManager.refresh(); + } catch { + notifyEmbeddedAuthError({ clusterId }); + return; + } + + await api.refreshUserData().catch(() => { + // Best-effort only. A failed refreshUserData call should not crash the prewarm flow. + }); + + await Promise.allSettled([queryClient.invalidateQueries(), router.invalidate()]); + }, [clusterId, queryClient, router, tokenManager]); + + useEmbeddedAuthPrewarm({ + enabled: isInitialized, + prewarm: prewarmEmbeddedAuth, + }); + // Don't render until initialized, don't show anything until then if (!isInitialized) { return null; } return ( - - - - - + + + + + + + ); } diff --git a/frontend/src/hooks/use-ai-gateway-transport.test.tsx b/frontend/src/hooks/use-ai-gateway-transport.test.tsx new file mode 100644 index 0000000000..2d63ea4575 --- /dev/null +++ b/frontend/src/hooks/use-ai-gateway-transport.test.tsx @@ -0,0 +1,54 @@ +import type { Interceptor } from '@connectrpc/connect'; +import { renderHook } from '@testing-library/react'; +import type { PropsWithChildren } from 'react'; + +const { mockAddBearerTokenInterceptor, mockCreateConnectTransport, mockFetch, mockIsEmbedded } = vi.hoisted(() => ({ + mockAddBearerTokenInterceptor: vi.fn((next) => next), + mockCreateConnectTransport: vi.fn((options) => options), + mockFetch: vi.fn(), + mockIsEmbedded: vi.fn(() => true), +})); + +vi.mock('@connectrpc/connect-web', () => ({ + createConnectTransport: mockCreateConnectTransport, +})); + +vi.mock('config', () => ({ + addBearerTokenInterceptor: mockAddBearerTokenInterceptor, + config: { + aiGatewayUrl: 'https://gateway.example', + fetch: mockFetch, + }, + isEmbedded: mockIsEmbedded, +})); + +vi.mock('protobuf-registry', () => ({ + protobufRegistry: {}, +})); + +import { useAIGatewayTransport } from './use-ai-gateway-transport'; +import { TokenRefreshInterceptorProvider } from '../utils/token-refresh-interceptor'; + +describe('useAIGatewayTransport', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + test('uses config.fetch and includes the provided token refresh interceptor', () => { + const refreshInterceptor = ((next) => next) as unknown as Interceptor; + + const wrapper = ({ children }: PropsWithChildren) => ( + {children} + ); + + renderHook(() => useAIGatewayTransport(), { wrapper }); + + expect(mockCreateConnectTransport).toHaveBeenCalledWith( + expect.objectContaining({ + baseUrl: 'https://gateway.example/.redpanda/api', + fetch: mockFetch, + interceptors: [mockAddBearerTokenInterceptor, refreshInterceptor], + }) + ); + }); +}); diff --git a/frontend/src/hooks/use-ai-gateway-transport.ts b/frontend/src/hooks/use-ai-gateway-transport.ts index 38d9ba9c56..413dd6f7c3 100644 --- a/frontend/src/hooks/use-ai-gateway-transport.ts +++ b/frontend/src/hooks/use-ai-gateway-transport.ts @@ -3,6 +3,7 @@ import { createConnectTransport } from '@connectrpc/connect-web'; import { addBearerTokenInterceptor, config, isEmbedded } from 'config'; import { protobufRegistry } from 'protobuf-registry'; import { useMemo } from 'react'; +import { useTokenRefreshInterceptor } from 'utils/token-refresh-interceptor'; /** * Custom hook to create and memoize a Connect transport for AI Gateway API calls @@ -21,12 +22,17 @@ import { useMemo } from 'react'; * @returns Transport instance configured for AI Gateway communication */ export const useAIGatewayTransport = (): Transport => { + const tokenRefreshInterceptor = useTokenRefreshInterceptor(); + const aiGatewayTransport = useMemo(() => { + const interceptors = [addBearerTokenInterceptor, ...(tokenRefreshInterceptor ? [tokenRefreshInterceptor] : [])]; + // In development, use relative path and rely on dev server proxy if (process.env.NODE_ENV === 'development') { return createConnectTransport({ baseUrl: '/.redpanda/api', - interceptors: [addBearerTokenInterceptor], + fetch: config.fetch, + interceptors, jsonOptions: { registry: protobufRegistry, }, @@ -42,7 +48,8 @@ export const useAIGatewayTransport = (): Transport => { return createConnectTransport({ baseUrl, - interceptors: [addBearerTokenInterceptor], + fetch: config.fetch, + interceptors, jsonOptions: { registry: protobufRegistry, }, @@ -52,12 +59,13 @@ export const useAIGatewayTransport = (): Transport => { // Fallback to relative path for standalone mode return createConnectTransport({ baseUrl: '/.redpanda/api', - interceptors: [addBearerTokenInterceptor], + fetch: config.fetch, + interceptors, jsonOptions: { registry: protobufRegistry, }, }); - }, []); + }, [tokenRefreshInterceptor]); return aiGatewayTransport; }; diff --git a/frontend/src/hooks/use-controlplane-transport.test.tsx b/frontend/src/hooks/use-controlplane-transport.test.tsx new file mode 100644 index 0000000000..d1c001e53e --- /dev/null +++ b/frontend/src/hooks/use-controlplane-transport.test.tsx @@ -0,0 +1,56 @@ +import type { Interceptor } from '@connectrpc/connect'; +import { renderHook } from '@testing-library/react'; + +const { mockAddBearerTokenInterceptor, mockCreateConnectTransport, mockFetch } = vi.hoisted(() => ({ + mockAddBearerTokenInterceptor: vi.fn((next) => next), + mockCreateConnectTransport: vi.fn((options) => options), + mockFetch: vi.fn(), +})); + +vi.mock('@connectrpc/connect-web', () => ({ + createConnectTransport: mockCreateConnectTransport, +})); + +vi.mock('config', () => ({ + addBearerTokenInterceptor: mockAddBearerTokenInterceptor, + config: { + controlplaneUrl: 'https://controlplane.example', + fetch: mockFetch, + }, +})); + +vi.mock('protobuf-registry', () => ({ + protobufRegistry: {}, +})); + +import { useControlplaneTransport } from './use-controlplane-transport'; +import { setRegisteredTokenRefreshInterceptor, useTokenRefreshInterceptor } from '../utils/token-refresh-interceptor'; + +describe('useControlplaneTransport', () => { + beforeEach(() => { + vi.clearAllMocks(); + setRegisteredTokenRefreshInterceptor(undefined); + }); + + afterEach(() => { + setRegisteredTokenRefreshInterceptor(undefined); + }); + + test('falls back to the registered token refresh interceptor and uses config.fetch', () => { + const refreshInterceptor = ((next) => next) as unknown as Interceptor; + setRegisteredTokenRefreshInterceptor(refreshInterceptor); + + renderHook(() => ({ + transport: useControlplaneTransport(), + interceptor: useTokenRefreshInterceptor(), + })); + + expect(mockCreateConnectTransport).toHaveBeenCalledWith( + expect.objectContaining({ + baseUrl: 'https://controlplane.example', + fetch: mockFetch, + interceptors: [mockAddBearerTokenInterceptor, refreshInterceptor], + }) + ); + }); +}); diff --git a/frontend/src/hooks/use-controlplane-transport.ts b/frontend/src/hooks/use-controlplane-transport.ts index 30781adda3..5ad471d8f0 100644 --- a/frontend/src/hooks/use-controlplane-transport.ts +++ b/frontend/src/hooks/use-controlplane-transport.ts @@ -3,22 +3,26 @@ import { createConnectTransport } from '@connectrpc/connect-web'; import { addBearerTokenInterceptor, config } from 'config'; import { protobufRegistry } from 'protobuf-registry'; import { useMemo } from 'react'; +import { useTokenRefreshInterceptor } from 'utils/token-refresh-interceptor'; /** * Custom hook to create and memoize a Connect transport for controlplane API calls * @returns Transport instance configured for controlplane communication */ export const useControlplaneTransport = (): Transport => { + const tokenRefreshInterceptor = useTokenRefreshInterceptor(); + const controlplaneTransport = useMemo( () => createConnectTransport({ baseUrl: config.controlplaneUrl, - interceptors: [addBearerTokenInterceptor], + fetch: config.fetch, + interceptors: [addBearerTokenInterceptor, ...(tokenRefreshInterceptor ? [tokenRefreshInterceptor] : [])], jsonOptions: { registry: protobufRegistry, }, }), - [] + [tokenRefreshInterceptor] ); return controlplaneTransport; diff --git a/frontend/src/state/backend-api.ts b/frontend/src/state/backend-api.ts index 378748bd0a..373c461077 100644 --- a/frontend/src/state/backend-api.ts +++ b/frontend/src/state/backend-api.ts @@ -175,6 +175,7 @@ import fetchWithTimeout from '../utils/fetch-with-timeout'; import { toJson } from '../utils/json-utils'; import { LazyMap } from '../utils/lazy-map'; import { convertListMessageData } from '../utils/message-converters'; +import { notifyEmbeddedAuthError } from '../utils/notify-embedded-auth-error'; import { ObjToKv } from '../utils/tsx-utils'; import { decodeBase64, getOidcSubject, TimeSince } from '../utils/utils'; @@ -256,11 +257,7 @@ async function handle401(res: Response) { if (inEmbeddedContext) { // In embedded mode, emit an event for the host to handle re-authentication // instead of redirecting, which breaks the embedded experience - window.dispatchEvent( - new CustomEvent('console:auth-error', { - detail: { clusterId: appConfig.clusterId, path: window.location.pathname }, - }) - ); + notifyEmbeddedAuthError({ clusterId: appConfig.clusterId }); // Don't redirect - let the request fail gracefully and allow the host to handle auth return; } diff --git a/frontend/src/utils/create-token-refresh-interceptor.test.ts b/frontend/src/utils/create-token-refresh-interceptor.test.ts new file mode 100644 index 0000000000..f8d9ad132a --- /dev/null +++ b/frontend/src/utils/create-token-refresh-interceptor.test.ts @@ -0,0 +1,82 @@ +import { Code, ConnectError } from '@connectrpc/connect'; + +import { createTokenRefreshInterceptor } from './create-token-refresh-interceptor'; + +describe('createTokenRefreshInterceptor', () => { + test('refreshes once and retries unauthenticated requests with the new token', async () => { + let token = 'stale-token'; + const refreshAccessToken = vi.fn().mockImplementation(() => { + token = 'fresh-token'; + return Promise.resolve(token); + }); + const onRefreshFailure = vi.fn(); + const next = vi + .fn() + .mockRejectedValueOnce(new ConnectError('expired', Code.Unauthenticated)) + .mockResolvedValueOnce('ok'); + + const interceptor = createTokenRefreshInterceptor({ + getAccessToken: () => token, + refreshAccessToken, + onRefreshFailure, + }); + + const request = { header: new Headers({ Authorization: 'Bearer stale-token' }) }; + + const result = await interceptor(next)(request as never); + + expect(result).toBe('ok'); + expect(refreshAccessToken).toHaveBeenCalledTimes(1); + expect(next).toHaveBeenCalledTimes(2); + expect(request.header.get('Authorization')).toBe('Bearer fresh-token'); + expect(onRefreshFailure).not.toHaveBeenCalled(); + }); + + test('calls the refresh failure handler and rethrows the original auth error when refresh fails', async () => { + const authError = new ConnectError('expired', Code.Unauthenticated); + const refreshError = new Error('refresh failed'); + const refreshAccessToken = vi.fn().mockRejectedValue(refreshError); + const onRefreshFailure = vi.fn(); + const next = vi.fn().mockRejectedValue(authError); + + const interceptor = createTokenRefreshInterceptor({ + getAccessToken: () => undefined, + refreshAccessToken, + onRefreshFailure, + }); + + await expect(interceptor(next)({ header: new Headers() } as never)).rejects.toBe(authError); + + expect(refreshAccessToken).toHaveBeenCalledTimes(1); + expect(next).toHaveBeenCalledTimes(1); + expect(onRefreshFailure).toHaveBeenCalledTimes(1); + expect(onRefreshFailure).toHaveBeenCalledWith(refreshError); + }); + + test('does not loop when the retry is still unauthenticated', async () => { + let token = 'stale-token'; + const retryError = new ConnectError('still expired', Code.Unauthenticated); + const refreshAccessToken = vi.fn().mockImplementation(() => { + token = 'fresh-token'; + return Promise.resolve(token); + }); + const onRefreshFailure = vi.fn(); + const next = vi + .fn() + .mockRejectedValueOnce(new ConnectError('expired', Code.Unauthenticated)) + .mockRejectedValueOnce(retryError); + + const interceptor = createTokenRefreshInterceptor({ + getAccessToken: () => token, + refreshAccessToken, + onRefreshFailure, + }); + + await expect(interceptor(next)({ header: new Headers() } as never)).rejects.toBe(retryError); + + expect(refreshAccessToken).toHaveBeenCalledTimes(1); + expect(next).toHaveBeenCalledTimes(2); + expect(onRefreshFailure).toHaveBeenCalledTimes(1); + expect(onRefreshFailure).toHaveBeenCalledWith(retryError); + }); +}); diff --git a/frontend/src/utils/create-token-refresh-interceptor.ts b/frontend/src/utils/create-token-refresh-interceptor.ts new file mode 100644 index 0000000000..e271706e54 --- /dev/null +++ b/frontend/src/utils/create-token-refresh-interceptor.ts @@ -0,0 +1,90 @@ +import { Code, ConnectError, type Interceptor } from '@connectrpc/connect'; + +type CreateTokenRefreshInterceptorArgs = { + getAccessToken: () => string | undefined; + refreshAccessToken: () => Promise; + onRefreshFailure?: (error: unknown) => void; +}; + +const isUnauthenticatedError = (error: unknown): error is ConnectError => + error instanceof ConnectError && error.code === Code.Unauthenticated; + +const getRefreshedToken = async ({ + getAccessToken, + refreshAccessToken, + onRefreshFailure, + originalError, +}: CreateTokenRefreshInterceptorArgs & { originalError: ConnectError }) => { + let refreshedToken: string; + + try { + refreshedToken = await refreshAccessToken(); + } catch (refreshError) { + onRefreshFailure?.(refreshError); + throw originalError; + } + + const token = refreshedToken || getAccessToken(); + if (token) { + return token; + } + + const missingTokenError = new Error('Access token missing after silent refresh'); + onRefreshFailure?.(missingTokenError); + throw originalError; +}; + +const retryRequestWithToken = async ({ + next, + request, + token, + onRefreshFailure, +}: { + next: ReturnType; + request: Parameters>[0]; + token: string; + onRefreshFailure?: (error: unknown) => void; +}) => { + request.header.set('Authorization', `Bearer ${token}`); + + try { + return await next(request); + } catch (retryError) { + if (isUnauthenticatedError(retryError)) { + onRefreshFailure?.(retryError); + } + + throw retryError; + } +}; + +/** + * Creates a Connect interceptor that retries a single unauthenticated request + * after silently refreshing the access token. + */ +export const createTokenRefreshInterceptor = + ({ getAccessToken, refreshAccessToken, onRefreshFailure }: CreateTokenRefreshInterceptorArgs): Interceptor => + (next) => + async (request) => { + try { + return await next(request); + } catch (error) { + if (!isUnauthenticatedError(error)) { + throw error; + } + + const token = await getRefreshedToken({ + getAccessToken, + refreshAccessToken, + onRefreshFailure, + originalError: error, + }); + + return await retryRequestWithToken({ + next, + request, + token, + onRefreshFailure, + }); + } + }; diff --git a/frontend/src/utils/notify-embedded-auth-error.ts b/frontend/src/utils/notify-embedded-auth-error.ts new file mode 100644 index 0000000000..5ed39b21be --- /dev/null +++ b/frontend/src/utils/notify-embedded-auth-error.ts @@ -0,0 +1,15 @@ +type NotifyEmbeddedAuthErrorArgs = { + clusterId?: string; + path?: string; +}; + +export const notifyEmbeddedAuthError = ({ + clusterId, + path = window.location.pathname, +}: NotifyEmbeddedAuthErrorArgs) => { + window.dispatchEvent( + new CustomEvent('console:auth-error', { + detail: { clusterId, path }, + }) + ); +}; diff --git a/frontend/src/utils/token-refresh-interceptor.tsx b/frontend/src/utils/token-refresh-interceptor.tsx new file mode 100644 index 0000000000..2ba04f0ad7 --- /dev/null +++ b/frontend/src/utils/token-refresh-interceptor.tsx @@ -0,0 +1,17 @@ +import type { Interceptor } from '@connectrpc/connect'; +import { createContext, useContext } from 'react'; + +let registeredTokenRefreshInterceptor: Interceptor | undefined; + +const TokenRefreshInterceptorContext = createContext(undefined); + +export const TokenRefreshInterceptorProvider = TokenRefreshInterceptorContext.Provider; + +export const setRegisteredTokenRefreshInterceptor = (interceptor: Interceptor | undefined) => { + registeredTokenRefreshInterceptor = interceptor; +}; + +export const getRegisteredTokenRefreshInterceptor = () => registeredTokenRefreshInterceptor; + +export const useTokenRefreshInterceptor = () => + useContext(TokenRefreshInterceptorContext) ?? getRegisteredTokenRefreshInterceptor(); diff --git a/frontend/src/utils/use-embedded-auth-prewarm.test.tsx b/frontend/src/utils/use-embedded-auth-prewarm.test.tsx new file mode 100644 index 0000000000..079cc320d1 --- /dev/null +++ b/frontend/src/utils/use-embedded-auth-prewarm.test.tsx @@ -0,0 +1,53 @@ +import { act, renderHook } from '@testing-library/react'; + +import { useEmbeddedAuthPrewarm } from './use-embedded-auth-prewarm'; + +describe('useEmbeddedAuthPrewarm', () => { + beforeEach(() => { + vi.useFakeTimers(); + Object.defineProperty(document, 'visibilityState', { + configurable: true, + value: 'visible', + }); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + test('deduplicates focus and visibility bursts into a single prewarm', async () => { + const prewarm = vi.fn().mockResolvedValue(undefined); + + renderHook(() => + useEmbeddedAuthPrewarm({ + debounceMs: 150, + dedupeMs: 1000, + prewarm, + }) + ); + + await act(async () => { + document.dispatchEvent(new Event('visibilitychange')); + window.dispatchEvent(new Event('focus')); + await vi.advanceTimersByTimeAsync(200); + }); + + expect(prewarm).toHaveBeenCalledTimes(1); + + await act(async () => { + document.dispatchEvent(new Event('visibilitychange')); + window.dispatchEvent(new Event('focus')); + await vi.advanceTimersByTimeAsync(200); + }); + + expect(prewarm).toHaveBeenCalledTimes(1); + + await act(async () => { + await vi.advanceTimersByTimeAsync(1000); + window.dispatchEvent(new Event('focus')); + await vi.advanceTimersByTimeAsync(200); + }); + + expect(prewarm).toHaveBeenCalledTimes(2); + }); +}); diff --git a/frontend/src/utils/use-embedded-auth-prewarm.ts b/frontend/src/utils/use-embedded-auth-prewarm.ts new file mode 100644 index 0000000000..774cd212cc --- /dev/null +++ b/frontend/src/utils/use-embedded-auth-prewarm.ts @@ -0,0 +1,76 @@ +import { useEffect, useRef } from 'react'; + +type UseEmbeddedAuthPrewarmArgs = { + enabled?: boolean; + debounceMs?: number; + dedupeMs?: number; + prewarm: () => Promise; +}; + +/** + * Runs a best-effort auth prewarm when the tab becomes active again. + * + * Focus and visibility events usually arrive in a burst, so this hook debounces + * them and also deduplicates in-flight work. + */ +export const useEmbeddedAuthPrewarm = ({ + enabled = true, + debounceMs = 150, + dedupeMs = 1000, + prewarm, +}: UseEmbeddedAuthPrewarmArgs) => { + const prewarmRef = useRef(prewarm); + const timeoutRef = useRef(null); + const inFlightRef = useRef | null>(null); + const lastRunAtRef = useRef(0); + + prewarmRef.current = prewarm; + + useEffect(() => { + if (!enabled) { + return; + } + + const runPrewarm = () => { + const now = Date.now(); + if (inFlightRef.current || now - lastRunAtRef.current < dedupeMs) { + return; + } + + lastRunAtRef.current = now; + inFlightRef.current = Promise.resolve(prewarmRef.current()) + .catch(() => { + // Best-effort prewarm only. Callers handle any hard auth failures. + }) + .finally(() => { + inFlightRef.current = null; + }); + }; + + const schedulePrewarm = () => { + if (document.visibilityState === 'hidden') { + return; + } + + if (timeoutRef.current !== null) { + window.clearTimeout(timeoutRef.current); + } + + timeoutRef.current = window.setTimeout(() => { + timeoutRef.current = null; + runPrewarm(); + }, debounceMs); + }; + + window.addEventListener('focus', schedulePrewarm); + document.addEventListener('visibilitychange', schedulePrewarm); + + return () => { + window.removeEventListener('focus', schedulePrewarm); + document.removeEventListener('visibilitychange', schedulePrewarm); + if (timeoutRef.current !== null) { + window.clearTimeout(timeoutRef.current); + } + }; + }, [debounceMs, dedupeMs, enabled]); +};