Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 82 additions & 0 deletions frontend/src/config.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
/**
* Copyright 2026 Redpanda Data, Inc.
*
* Use of this software is governed by the Business Source License
* included in the file https://github.com/redpanda-data/redpanda/blob/dev/licenses/bsl.md
*
* As of the Change Date specified in that file, in accordance with
* the Business Source License, use of this software will be governed
* by the Apache License, Version 2.0
*/

import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';

import { config, createAuthInjectingFetch } from './config';

describe('createAuthInjectingFetch', () => {
const ok = new Response(null, { status: 200 });
let originalJwt: string | undefined;

beforeEach(() => {
originalJwt = config.jwt;
});

afterEach(() => {
config.jwt = originalJwt;
});

test('attaches the Bearer token from config.jwt when no Authorization header is present', async () => {
config.jwt = 'token-123';
const baseFetch = vi.fn().mockResolvedValue(ok);

await createAuthInjectingFetch(baseFetch)('/api/schema-registry/subjects/x/versions/latest/validate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: '{}',
});

expect(baseFetch).toHaveBeenCalledTimes(1);
const [input, init] = baseFetch.mock.calls[0];
const headers = new Headers(init.headers);
expect(input).toBe('/api/schema-registry/subjects/x/versions/latest/validate');
expect(headers.get('Authorization')).toBe('Bearer token-123');
// existing init is preserved
expect(headers.get('Content-Type')).toBe('application/json');
expect(init.method).toBe('POST');
expect(init.body).toBe('{}');
});

test('does not overwrite an Authorization header a host-provided (V1) fetch already set', async () => {
config.jwt = 'token-123';
const baseFetch = vi.fn().mockResolvedValue(ok);

await createAuthInjectingFetch(baseFetch)('/api/topics', {
headers: { Authorization: 'Bearer host-token' },
});

const headers = new Headers(baseFetch.mock.calls[0][1].headers);
expect(headers.get('Authorization')).toBe('Bearer host-token');
});

test('adds no Authorization header when config.jwt is unset (standalone OSS)', async () => {
config.jwt = undefined;
const baseFetch = vi.fn().mockResolvedValue(ok);

await createAuthInjectingFetch(baseFetch)('/api/topics');

const headers = new Headers(baseFetch.mock.calls[0][1].headers);
expect(headers.has('Authorization')).toBe(false);
});

test('reads config.jwt lazily at call time so token refreshes are picked up', async () => {
config.jwt = 'old-token';
const baseFetch = vi.fn().mockResolvedValue(ok);
const authFetch = createAuthInjectingFetch(baseFetch);

config.jwt = 'refreshed-token';
await authFetch('/api/topics');

const headers = new Headers(baseFetch.mock.calls[0][1].headers);
expect(headers.get('Authorization')).toBe('Bearer refreshed-token');
});
});
26 changes: 25 additions & 1 deletion frontend/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,30 @@ export const addBearerTokenInterceptor: ConnectRpcInterceptor = (next) => async
return await next(request);
};

/**
* Wraps a base `fetch` so REST requests carry the same Bearer token as gRPC
* (see {@link addBearerTokenInterceptor}).
*
* Under Module Federation v2 the Cloud UI host supplies the access token via
* `getAccessToken`/`config.jwt` rather than a pre-authenticated `fetch`, so
* Console must attach the header itself on every `config.fetch` call. Centralizing
* it here covers the REST helpers that call `config.fetch` directly instead of the
* `rest<T>()` wrapper, which already injects the token.
*
* The token is read lazily at call time because the host may refresh `config.jwt`
* in place. We never overwrite an `Authorization` header that a legacy
* host-provided (V1) authenticatedFetch may already have set.
*/
export const createAuthInjectingFetch =
(baseFetch: WindowOrWorkerGlobalScope['fetch']): WindowOrWorkerGlobalScope['fetch'] =>
(input, init) => {
const headers = new Headers(init?.headers);
if (config.jwt && !headers.has('Authorization')) {
headers.set('Authorization', `Bearer ${config.jwt}`);
}
return baseFetch(input, { ...init, headers });
};

/**
* Interceptor to handle license expiration errors in gRPC responses.
*
Expand Down Expand Up @@ -250,7 +274,7 @@ const setConfig = ({
restBasePath: getRestBasePath(urlOverride?.rest),
grpcBasePath: getGrpcBasePath(urlOverride?.grpc),
controlplaneUrl: config.controlplaneUrl,
fetch: fetch ?? window.fetch.bind(window),
fetch: createAuthInjectingFetch(fetch ?? window.fetch.bind(window)),
assetsPath: assetsUrl ?? getBasePath(),
authenticationClient: authenticationGrpcClient,
licenseClient: licenseGrpcClient,
Expand Down
Loading