From 64f4e993f0f4195b85e5ab846507374c0a7ccfaa Mon Sep 17 00:00:00 2001 From: Chad Crum Date: Thu, 10 Sep 2026 19:02:06 -0400 Subject: [PATCH] feat(dcm): forward OIDC tokens and support auth-disabled deployments (FLPATH-4480) Signed-off-by: Chad Crum --- workspaces/dcm/README.md | 19 ++ workspaces/dcm/app-config.production.yaml | 7 + workspaces/dcm/app-config.yaml | 19 +- .../dcm-backend/app-config.dynamic.yaml | 5 +- .../dcm/plugins/dcm-backend/config.d.ts | 11 + .../dcm-backend/src/routes/proxy.test.ts | 272 ++++++++---------- .../plugins/dcm-backend/src/routes/proxy.ts | 45 +-- .../dcm/plugins/dcm-common/report.api.md | 11 +- .../src/clients/AgentsClient.test.ts | 9 +- .../src/clients/CatalogClient.test.ts | 9 +- .../src/clients/DcmBaseClient.test.ts | 26 +- .../dcm-common/src/clients/DcmBaseClient.ts | 21 +- .../plugins/dcm-common/src/clients/index.ts | 2 +- .../dcm/plugins/dcm/app-config.dynamic.yaml | 5 + workspaces/dcm/plugins/dcm/config.d.ts | 11 + .../dcm/plugins/dcm/src/DcmAuth.test.ts | 35 +++ workspaces/dcm/plugins/dcm/src/DcmAuth.ts | 31 ++ .../dcm/plugins/dcm/src/Router.test.tsx | 4 + workspaces/dcm/plugins/dcm/src/Router.tsx | 33 ++- .../dcm/plugins/dcm/src/api/AuthApiRefs.ts | 29 ++ workspaces/dcm/plugins/dcm/src/plugin.ts | 25 +- 21 files changed, 430 insertions(+), 199 deletions(-) create mode 100644 workspaces/dcm/plugins/dcm/src/DcmAuth.test.ts create mode 100644 workspaces/dcm/plugins/dcm/src/DcmAuth.ts create mode 100644 workspaces/dcm/plugins/dcm/src/api/AuthApiRefs.ts diff --git a/workspaces/dcm/README.md b/workspaces/dcm/README.md index d7406ba846e..79128d6a97d 100644 --- a/workspaces/dcm/README.md +++ b/workspaces/dcm/README.md @@ -30,3 +30,22 @@ This runs the full app and backend concurrently (frontend at http://localhost:30 - **yarn start:dev** – Run both plugins in standalone mode (no full app/backend). Configuration is in `app-config.yaml`. Example catalog data is in `examples/`. + +## Authentication + +Set `dcm.auth.enabled` to match the DCM control plane's `auth.enabled` setting. + +- When enabled, the DCM UI uses the signed-in user's OIDC access token. Configure + an OIDC provider in RHDH `auth.providers`, including its metadata URL, client + ID, and client secret. The proxy requires normal RHDH/Backstage credentials + and the user token, then forwards that token to DCM as an upstream Bearer + token. +- When disabled, the UI does not require an OIDC provider or send an upstream + Bearer token. The proxy still requires normal RHDH/Backstage credentials, so + the standalone and guest-only configurations use the guest session. DCM then + applies its auth-disabled system actor rather than a per-user identity. + +The standalone local configuration uses DCM auth-disabled mode. The proxy never +uses its shared `client_credentials` token for normal UI requests. Do not +configure or expose OIDC access tokens as static application configuration +values. diff --git a/workspaces/dcm/app-config.production.yaml b/workspaces/dcm/app-config.production.yaml index 2f459cda71d..1c79dd3d7b4 100644 --- a/workspaces/dcm/app-config.production.yaml +++ b/workspaces/dcm/app-config.production.yaml @@ -11,9 +11,16 @@ backend: connection: ':memory:' dcm: + # Keep this aligned with the DCM control plane's auth.enabled setting. + auth: + enabled: ${DCM_AUTH_ENABLED:-false} + # Base URL of the DCM control plane. apiUrl: ${DCM_API_URL:-} # Legacy env var; kept until deploy configs switch to DCM_API_URL. apiGatewayUrl: ${DCM_API_GATEWAY_URL:-} + # These settings are used only by the separately permission-protected token + # endpoint. Normal DCM UI proxy requests forward the signed-in user's OIDC + # token and do not use this shared client-credentials flow. ssoBaseUrl: ${DCM_SSO_BASE_URL:-} clientId: ${DCM_CLIENT_ID:-} clientSecret: ${DCM_CLIENT_SECRET:-} diff --git a/workspaces/dcm/app-config.yaml b/workspaces/dcm/app-config.yaml index 9959b8d0ef7..a85138e1746 100644 --- a/workspaces/dcm/app-config.yaml +++ b/workspaces/dcm/app-config.yaml @@ -41,7 +41,15 @@ techdocs: auth: providers: + # Local development only. Replace this with the OIDC provider below for + # a deployed environment. guest: {} + # oidc: + # production: + # metadataUrl: https://keycloak.example.com/realms/rhdh/.well-known/openid-configuration + # clientId: rhdh-auth + # clientSecret: ${AUTH_OIDC_CLIENT_SECRET} + # prompt: auto permission: enabled: true @@ -61,6 +69,9 @@ permission: scaffolder: {} dcm: + # The standalone app uses the DCM control plane's auth-disabled mode. + auth: + enabled: false policyPacks: - security-baseline - compliance-pci @@ -70,10 +81,10 @@ dcm: # Override in app-config.local.yaml for local development. # apiUrl: https://your-control-plane.example.com # - # SSO credentials for the backend to obtain a bearer token: - # ssoBaseUrl: https://sso.redhat.com - # clientId: your-client-id - # clientSecret: your-client-secret + # Set auth.enabled to true only when the DCM control plane's auth.enabled is + # also true. In that mode, configure an OIDC provider under auth.providers. + # The normal RHDH Authorization header authenticates the request to this + # backend, while the user's OIDC token is forwarded to DCM by the proxy. catalog: import: diff --git a/workspaces/dcm/plugins/dcm-backend/app-config.dynamic.yaml b/workspaces/dcm/plugins/dcm-backend/app-config.dynamic.yaml index c22a5d07beb..8eb0b0909cd 100644 --- a/workspaces/dcm/plugins/dcm-backend/app-config.dynamic.yaml +++ b/workspaces/dcm/plugins/dcm-backend/app-config.dynamic.yaml @@ -1,10 +1,13 @@ dcm: + # Keep this aligned with the DCM control plane's auth.enabled setting. + auth: + enabled: ${DCM_AUTH_ENABLED:-true} # Base URL of the DCM control plane (required). apiUrl: ${DCM_API_URL} # Legacy env var; kept until deploy configs switch to DCM_API_URL. apiGatewayUrl: ${DCM_API_GATEWAY_URL} - # SSO configuration for the backend to obtain bearer tokens via + # SSO configuration for the separately permission-protected token endpoint. ssoBaseUrl: ${DCM_SSO_BASE_URL} clientId: ${DCM_CLIENT_ID} clientSecret: ${DCM_CLIENT_SECRET} diff --git a/workspaces/dcm/plugins/dcm-backend/config.d.ts b/workspaces/dcm/plugins/dcm-backend/config.d.ts index 78259ce87f2..eaac069a60c 100644 --- a/workspaces/dcm/plugins/dcm-backend/config.d.ts +++ b/workspaces/dcm/plugins/dcm-backend/config.d.ts @@ -39,6 +39,17 @@ export interface Config { */ apiGatewayUrl?: string; + /** + * Whether the DCM control plane requires per-user OIDC authentication. + * + * Must match the control plane's `auth.enabled` setting. + * + * @visibility backend + */ + auth?: { + enabled?: boolean; + }; + /** * Base URL for the SSO token endpoint. * diff --git a/workspaces/dcm/plugins/dcm-backend/src/routes/proxy.test.ts b/workspaces/dcm/plugins/dcm-backend/src/routes/proxy.test.ts index b00e48af428..cd032d64bd0 100644 --- a/workspaces/dcm/plugins/dcm-backend/src/routes/proxy.test.ts +++ b/workspaces/dcm/plugins/dcm-backend/src/routes/proxy.test.ts @@ -3,42 +3,29 @@ * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. */ /* eslint-disable @backstage/no-undeclared-imports -- deps in dcm-backend package.json */ import { mockServices } from '@backstage/backend-test-utils'; +import type { JsonObject } from '@backstage/types'; import express from 'express'; import request from 'supertest'; import { createDcmProxy } from './proxy'; import type { RouterOptions } from '../models/RouterOptions'; -const TOKEN_RESPONSE = { - ok: true, - json: async () => ({ access_token: 'test-token', expires_in: 3600 }), -} as Response; - -function makeApp(configData: Record> = {}) { +function makeApp(configData: JsonObject = {}, authenticated = true) { const options: RouterOptions = { logger: mockServices.rootLogger(), config: mockServices.rootConfig({ data: configData }), httpAuth: mockServices.httpAuth.mock({ - credentials: jest.fn().mockResolvedValue({ - principal: { userEntityRef: 'user:default/test' }, - }), - }), - permissions: mockServices.permissions.mock({ - authorize: jest.fn().mockResolvedValue([{ result: 'ALLOW' }]), + credentials: authenticated + ? jest.fn().mockResolvedValue({ + principal: { userEntityRef: 'user:default/test' }, + }) + : jest.fn().mockRejectedValue(new Error('unauthenticated')), }), + permissions: mockServices.permissions.mock(), cache: mockServices.cache.mock(), }; const app = express(); @@ -47,11 +34,14 @@ function makeApp(configData: Record> = {}) { type: ['application/json', 'application/merge-patch+json'], }), ); - // Mount using a wildcard path matching the router convention app.all('/proxy/*', createDcmProxy(options)); return app; } +const dcmToken = 'user-oidc-token'; +const withDcmToken = (requestBuilder: request.Test) => + requestBuilder.set('X-DCM-OIDC-Token', dcmToken); + describe('createDcmProxy', () => { let fetchSpy: jest.SpyInstance; @@ -60,195 +50,159 @@ describe('createDcmProxy', () => { }); it('returns 503 when dcm.apiUrl is not configured', async () => { - const app = makeApp({ dcm: { clientId: 'id', clientSecret: 'secret' } }); + const app = makeApp(); + const res = await withDcmToken(request(app).get('/proxy/providers')); + expect(res.status).toBe(503); + }); - const res = await request(app).get('/proxy/providers'); + it('requires normal RHDH authentication', async () => { + const app = makeApp( + { dcm: { apiUrl: 'https://control-plane.example.com' } }, + false, + ); + const res = await withDcmToken(request(app).get('/proxy/providers')); + expect(res.status).toBe(401); + expect(res.body.error).toContain('RHDH authentication'); + }); - expect(res.status).toBe(503); - expect(res.body).toMatchObject({ - error: expect.stringContaining('not configured'), + it('requires the per-user DCM OIDC token', async () => { + const app = makeApp({ + dcm: { apiUrl: 'https://control-plane.example.com' }, }); + const res = await request(app).get('/proxy/providers'); + expect(res.status).toBe(401); + expect(res.body.error).toContain('DCM OIDC authentication'); }); - it('returns 502 when token acquisition fails', async () => { - fetchSpy = jest - .spyOn(globalThis, 'fetch') - .mockRejectedValueOnce(new Error('SSO unreachable')); - + it('does not require or forward an OIDC token when DCM authentication is disabled', async () => { + fetchSpy = jest.spyOn(globalThis, 'fetch').mockResolvedValueOnce({ + status: 200, + ok: true, + headers: { get: () => null }, + text: async () => '{}', + } as unknown as Response); const app = makeApp({ dcm: { apiUrl: 'https://control-plane.example.com', - clientId: 'id', - clientSecret: 'secret', + auth: { enabled: false }, }, }); const res = await request(app).get('/proxy/providers'); - expect(res.status).toBe(502); - expect(res.body).toMatchObject({ - error: expect.stringContaining('access token'), - }); + expect(res.status).toBe(200); + expect(fetchSpy.mock.calls[0][1].headers).not.toHaveProperty( + 'Authorization', + ); + }); + + it('still requires normal RHDH authentication when DCM authentication is disabled', async () => { + const app = makeApp( + { + dcm: { + apiUrl: 'https://control-plane.example.com', + auth: { enabled: false }, + }, + }, + false, + ); + + const res = await request(app).get('/proxy/providers'); + + expect(res.status).toBe(401); + expect(res.body.error).toContain('RHDH authentication'); }); it('returns 502 when the upstream fetch throws', async () => { fetchSpy = jest .spyOn(globalThis, 'fetch') - // First call: token fetch succeeds - .mockResolvedValueOnce(TOKEN_RESPONSE) - // Second call: upstream fetch throws .mockRejectedValueOnce(new Error('Connection refused')); - const app = makeApp({ - dcm: { - apiUrl: 'https://control-plane.example.com', - clientId: 'id', - clientSecret: 'secret', - }, + dcm: { apiUrl: 'https://control-plane.example.com' }, }); - - const res = await request(app).get('/proxy/providers'); - + const res = await withDcmToken(request(app).get('/proxy/providers')); expect(res.status).toBe(502); - expect(res.body).toMatchObject({ - error: expect.stringContaining('DCM API'), - }); + expect(res.body.error).toContain('DCM API'); }); - it('proxies a GET request and forwards the upstream response', async () => { + it('forwards the per-user token and preserves the request path', async () => { const upstreamBody = JSON.stringify({ items: [] }); - fetchSpy = jest - .spyOn(globalThis, 'fetch') - // Token fetch - .mockResolvedValueOnce(TOKEN_RESPONSE) - // Upstream GET - .mockResolvedValueOnce({ - status: 200, - ok: true, - headers: { - get: (h: string) => - h === 'content-type' ? 'application/json' : null, - }, - text: async () => upstreamBody, - } as unknown as Response); + fetchSpy = jest.spyOn(globalThis, 'fetch').mockResolvedValueOnce({ + status: 200, + ok: true, + headers: { + get: (h: string) => (h === 'content-type' ? 'application/json' : null), + }, + text: async () => upstreamBody, + } as unknown as Response); const app = makeApp({ - dcm: { - apiUrl: 'https://control-plane.example.com', - clientId: 'id', - clientSecret: 'secret', - }, + dcm: { apiUrl: 'https://control-plane.example.com' }, }); - - const res = await request(app).get('/proxy/providers?foo=bar'); + const res = await withDcmToken( + request(app).get('/proxy/providers?foo=bar'), + ); expect(res.status).toBe(200); expect(res.text).toBe(upstreamBody); - - // Verify upstream URL contains path and query param - const upstreamCall = fetchSpy.mock.calls[1]; - expect(upstreamCall[0]).toContain('/api/v1alpha1/providers'); - expect(upstreamCall[0]).toContain('foo=bar'); - - // Verify auth header was injected - expect(upstreamCall[1].headers.Authorization).toBe('Bearer test-token'); + expect(fetchSpy).toHaveBeenCalledTimes(1); + const upstreamCall = fetchSpy.mock.calls[0]; + expect(upstreamCall[0]).toContain('/api/v1alpha1/providers?foo=bar'); + expect(upstreamCall[1].headers.Authorization).toBe(`Bearer ${dcmToken}`); }); - it('proxies a POST request and forwards the request body', async () => { + it('forwards a POST body', async () => { const requestBody = { name: 'my-provider' }; - fetchSpy = jest - .spyOn(globalThis, 'fetch') - .mockResolvedValueOnce(TOKEN_RESPONSE) - .mockResolvedValueOnce({ - status: 201, - ok: true, - headers: { - get: (h: string) => - h === 'content-type' ? 'application/json' : null, - }, - text: async () => JSON.stringify(requestBody), - } as unknown as Response); + fetchSpy = jest.spyOn(globalThis, 'fetch').mockResolvedValueOnce({ + status: 201, + ok: true, + headers: { + get: (h: string) => (h === 'content-type' ? 'application/json' : null), + }, + text: async () => JSON.stringify(requestBody), + } as unknown as Response); const app = makeApp({ - dcm: { - apiUrl: 'https://control-plane.example.com', - clientId: 'id', - clientSecret: 'secret', - }, + dcm: { apiUrl: 'https://control-plane.example.com' }, }); - - const res = await request(app) - .post('/proxy/providers') - .send(requestBody) - .set('Content-Type', 'application/json'); + const res = await withDcmToken( + request(app) + .post('/proxy/providers') + .send(requestBody) + .set('Content-Type', 'application/json'), + ); expect(res.status).toBe(201); - - const upstreamCall = fetchSpy.mock.calls[1]; + const upstreamCall = fetchSpy.mock.calls[0]; expect(upstreamCall[1].method).toBe('POST'); expect(JSON.parse(upstreamCall[1].body)).toEqual(requestBody); }); - it('proxies a PATCH request with application/merge-patch+json and forwards the body', async () => { + it('forwards a PATCH body and handles 204 responses', async () => { const patch = { display_name: 'updated', spec: { fields: [] } }; - fetchSpy = jest - .spyOn(globalThis, 'fetch') - .mockResolvedValueOnce(TOKEN_RESPONSE) - .mockResolvedValueOnce({ - status: 200, - ok: true, - headers: { - get: (h: string) => - h === 'content-type' ? 'application/json' : null, - }, - text: async () => JSON.stringify(patch), - } as unknown as Response); + fetchSpy = jest.spyOn(globalThis, 'fetch').mockResolvedValueOnce({ + status: 204, + ok: true, + headers: { get: () => null }, + text: async () => '', + } as unknown as Response); const app = makeApp({ - dcm: { - apiUrl: 'https://control-plane.example.com', - clientId: 'id', - clientSecret: 'secret', - }, + dcm: { apiGatewayUrl: 'https://gateway.example.com' }, }); + const res = await withDcmToken( + request(app) + .patch('/proxy/catalog-items/test-id') + .send(patch) + .set('Content-Type', 'application/merge-patch+json'), + ); - const res = await request(app) - .patch('/proxy/catalog-items/test-id') - .send(patch) - .set('Content-Type', 'application/merge-patch+json'); - - expect(res.status).toBe(200); - - const upstreamCall = fetchSpy.mock.calls[1]; + expect(res.status).toBe(204); + const upstreamCall = fetchSpy.mock.calls[0]; expect(upstreamCall[1].method).toBe('PATCH'); expect(upstreamCall[1].headers['Content-Type']).toBe( 'application/merge-patch+json', ); expect(JSON.parse(upstreamCall[1].body)).toEqual(patch); }); - - it('falls back to legacy dcm.apiGatewayUrl and handles 204 No Content', async () => { - fetchSpy = jest - .spyOn(globalThis, 'fetch') - .mockResolvedValueOnce(TOKEN_RESPONSE) - .mockResolvedValueOnce({ - status: 204, - ok: true, - headers: { get: () => null }, - text: async () => '', - } as unknown as Response); - - const app = makeApp({ - dcm: { - apiGatewayUrl: 'https://gateway.example.com', - clientId: 'id', - clientSecret: 'secret', - }, - }); - - const res = await request(app).delete('/proxy/providers/test-id'); - - expect(res.status).toBe(204); - expect(res.text).toBe(''); - }); }); diff --git a/workspaces/dcm/plugins/dcm-backend/src/routes/proxy.ts b/workspaces/dcm/plugins/dcm-backend/src/routes/proxy.ts index d51ebe8b5e4..daf14f5969e 100644 --- a/workspaces/dcm/plugins/dcm-backend/src/routes/proxy.ts +++ b/workspaces/dcm/plugins/dcm-backend/src/routes/proxy.ts @@ -16,9 +16,9 @@ import type { Request, Response } from 'express'; import type { RouterOptions } from '../models/RouterOptions'; -import { getTokenFromApi } from '../util/tokenUtil'; const API_BASE_PATH = '/api/v1alpha1'; +const DCM_OIDC_TOKEN_HEADER = 'x-dcm-oidc-token'; /** * Proxies all `ALL /proxy/*` requests to the DCM control plane. @@ -26,7 +26,9 @@ const API_BASE_PATH = '/api/v1alpha1'; * The wildcard path segment is appended to: * `{dcm.apiUrl}/api/v1alpha1/` * - * An SSO bearer token is injected automatically via `tokenUtil`. + * The authenticated user's OIDC access token is forwarded from the dedicated + * `X-DCM-OIDC-Token` header. The normal RHDH Authorization header remains + * available to Backstage's httpAuth service and is never replaced. */ export function createDcmProxy(options: RouterOptions) { return async (req: Request, res: Response): Promise => { @@ -46,6 +48,26 @@ export function createDcmProxy(options: RouterOptions) { return; } + // Authenticate the caller with the normal RHDH/Backstage bearer token. + // This deliberately happens before reading the DCM token header so the + // custom header cannot be used to bypass RHDH authentication. + try { + await options.httpAuth.credentials(req); + } catch (_err) { + res.status(401).json({ error: 'RHDH authentication is required.' }); + return; + } + + const authEnabled = config.getOptionalBoolean('dcm.auth.enabled') ?? true; + const dcmOidcToken = req.headers[DCM_OIDC_TOKEN_HEADER]; + if ( + authEnabled && + (typeof dcmOidcToken !== 'string' || !dcmOidcToken.trim()) + ) { + res.status(401).json({ error: 'DCM OIDC authentication is required.' }); + return; + } + // req.params[0] is the captured wildcard after /proxy/ const wildcardPath = (req.params as Record)[0] ?? ''; @@ -63,28 +85,11 @@ export function createDcmProxy(options: RouterOptions) { `DCM proxy: ${req.method} ${req.path} → ${targetUrl.toString()}`, ); - let tokenResult; - try { - tokenResult = await getTokenFromApi(options); - } catch (err) { - logger.error(`DCM proxy: failed to obtain access token — ${err}`); - res - .status(502) - .json({ error: 'Failed to obtain upstream access token.' }); - return; - } - const requestHeaders: Record = { Accept: (req.headers.accept as string) || 'application/json', + ...(authEnabled ? { Authorization: `Bearer ${dcmOidcToken}` } : {}), }; - // Only attach the Authorization header when an SSO token was obtained. - // When clientId/clientSecret are not configured the token is empty and - // the request is forwarded without auth (open/unauthenticated API). - if (tokenResult.accessToken) { - requestHeaders.Authorization = `Bearer ${tokenResult.accessToken}`; - } - // Forward Content-Type for requests that carry a body if (req.headers['content-type']) { requestHeaders['Content-Type'] = req.headers['content-type'] as string; diff --git a/workspaces/dcm/plugins/dcm-common/report.api.md b/workspaces/dcm/plugins/dcm-common/report.api.md index ef42b8381e0..86b6e08ebb5 100644 --- a/workspaces/dcm/plugins/dcm-common/report.api.md +++ b/workspaces/dcm/plugins/dcm-common/report.api.md @@ -275,7 +275,11 @@ export interface DcmApiError { // @public export abstract class DcmBaseClient { - constructor(options: { discoveryApi: DiscoveryApi; fetchApi: FetchApi }); + constructor(options: { + discoveryApi: DiscoveryApi; + fetchApi: FetchApi; + getAccessToken?: DcmOidcTokenProvider; + }); // (undocumented) protected readonly discoveryApi: DiscoveryApi; // (undocumented) @@ -324,6 +328,11 @@ export interface DcmHealth { status: string; } +// @public +export type DcmOidcTokenProvider = () => + | Promise + | undefined; + // @public export const dcmPluginPermissions: BasicPermission[]; diff --git a/workspaces/dcm/plugins/dcm-common/src/clients/AgentsClient.test.ts b/workspaces/dcm/plugins/dcm-common/src/clients/AgentsClient.test.ts index c6daab9b862..d4a8bc6f49e 100644 --- a/workspaces/dcm/plugins/dcm-common/src/clients/AgentsClient.test.ts +++ b/workspaces/dcm/plugins/dcm-common/src/clients/AgentsClient.test.ts @@ -43,7 +43,14 @@ function makeClient(fetchFn: jest.Mock) { getBaseUrl: jest.fn().mockResolvedValue(BASE_URL), }; const fetchApi: FetchApi = { fetch: fetchFn }; - return new AgentsClient({ discoveryApi, fetchApi }); + const oidcAuthApi = { + getAccessToken: jest.fn().mockResolvedValue('oidc-token'), + }; + return new AgentsClient({ + discoveryApi, + fetchApi, + getAccessToken: oidcAuthApi.getAccessToken, + }); } function okJson(data: unknown): Response { diff --git a/workspaces/dcm/plugins/dcm-common/src/clients/CatalogClient.test.ts b/workspaces/dcm/plugins/dcm-common/src/clients/CatalogClient.test.ts index 6e7d2c41cf2..c7c1e53d258 100644 --- a/workspaces/dcm/plugins/dcm-common/src/clients/CatalogClient.test.ts +++ b/workspaces/dcm/plugins/dcm-common/src/clients/CatalogClient.test.ts @@ -36,7 +36,14 @@ function makeClient(fetchFn: jest.Mock) { getBaseUrl: jest.fn().mockResolvedValue(BASE_URL), }; const fetchApi: FetchApi = { fetch: fetchFn }; - return new CatalogClient({ discoveryApi, fetchApi }); + const oidcAuthApi = { + getAccessToken: jest.fn().mockResolvedValue('oidc-token'), + }; + return new CatalogClient({ + discoveryApi, + fetchApi, + getAccessToken: oidcAuthApi.getAccessToken, + }); } function okJson(data: unknown): Response { diff --git a/workspaces/dcm/plugins/dcm-common/src/clients/DcmBaseClient.test.ts b/workspaces/dcm/plugins/dcm-common/src/clients/DcmBaseClient.test.ts index aaf9d44dae6..d539285da64 100644 --- a/workspaces/dcm/plugins/dcm-common/src/clients/DcmBaseClient.test.ts +++ b/workspaces/dcm/plugins/dcm-common/src/clients/DcmBaseClient.test.ts @@ -34,12 +34,19 @@ class TestClient extends DcmBaseClient { } } -function makeClient(fetchFn: jest.Mock) { +function makeClient(fetchFn: jest.Mock, authEnabled = true) { const discoveryApi: DiscoveryApi = { getBaseUrl: jest.fn().mockResolvedValue('http://localhost/api/dcm'), }; const fetchApi: FetchApi = { fetch: fetchFn }; - return new TestClient({ discoveryApi, fetchApi }); + const oidcAuthApi = { + getAccessToken: jest.fn().mockResolvedValue('oidc-token'), + }; + return new TestClient({ + discoveryApi, + fetchApi, + ...(authEnabled ? { getAccessToken: oidcAuthApi.getAccessToken } : {}), + }); } describe('DcmBaseClient', () => { @@ -57,11 +64,26 @@ describe('DcmBaseClient', () => { expect.objectContaining({ headers: expect.objectContaining({ 'Content-Type': 'application/json', + 'X-DCM-OIDC-Token': 'oidc-token', }), }), ); }); + it('does not add an OIDC token header when DCM authentication is disabled', async () => { + const fetchFn = jest.fn().mockResolvedValue({ + status: 200, + ok: true, + json: async () => ({}), + }); + const client = makeClient(fetchFn, false); + await client.getItem('providers'); + + expect(fetchFn.mock.calls[0][1].headers).not.toHaveProperty( + 'X-DCM-OIDC-Token', + ); + }); + it('returns undefined for 204 No Content', async () => { const fetchFn = jest.fn().mockResolvedValue({ status: 204, ok: true }); const client = makeClient(fetchFn); diff --git a/workspaces/dcm/plugins/dcm-common/src/clients/DcmBaseClient.ts b/workspaces/dcm/plugins/dcm-common/src/clients/DcmBaseClient.ts index 88b4d3e6bd7..bf73fdd1f91 100644 --- a/workspaces/dcm/plugins/dcm-common/src/clients/DcmBaseClient.ts +++ b/workspaces/dcm/plugins/dcm-common/src/clients/DcmBaseClient.ts @@ -18,6 +18,12 @@ import type { DiscoveryApi, FetchApi } from '@backstage/core-plugin-api'; import { DcmClientError } from '../errors/DcmClientError'; const PLUGIN_ID = 'dcm'; +const DCM_OIDC_TOKEN_HEADER = 'X-DCM-OIDC-Token'; + +/** Supplies a user OIDC access token when DCM authentication is enabled. @public */ +export type DcmOidcTokenProvider = () => + | Promise + | undefined; /** * Base class shared by all DCM API clients. @@ -30,22 +36,33 @@ const PLUGIN_ID = 'dcm'; export abstract class DcmBaseClient { protected readonly discoveryApi: DiscoveryApi; protected readonly fetchApi: FetchApi; + private readonly getAccessToken?: DcmOidcTokenProvider; /** Human-readable service name used in error messages, e.g. "Catalog". */ protected abstract readonly serviceName: string; - constructor(options: { discoveryApi: DiscoveryApi; fetchApi: FetchApi }) { + constructor(options: { + discoveryApi: DiscoveryApi; + fetchApi: FetchApi; + getAccessToken?: DcmOidcTokenProvider; + }) { this.discoveryApi = options.discoveryApi; this.fetchApi = options.fetchApi; + this.getAccessToken = options.getAccessToken; } protected async fetch(path: string, init?: RequestInit): Promise { const baseUrl = await this.discoveryApi.getBaseUrl(PLUGIN_ID); const url = `${baseUrl}/proxy/${path}`; const { headers: initHeaders, ...initRest } = init ?? {}; + const accessToken = await this.getAccessToken?.(); const response = await this.fetchApi.fetch(url, { ...initRest, - headers: { 'Content-Type': 'application/json', ...initHeaders }, + headers: { + 'Content-Type': 'application/json', + ...initHeaders, + ...(accessToken ? { [DCM_OIDC_TOKEN_HEADER]: accessToken } : {}), + }, }); if (response.status === 204) { return undefined as unknown as T; diff --git a/workspaces/dcm/plugins/dcm-common/src/clients/index.ts b/workspaces/dcm/plugins/dcm-common/src/clients/index.ts index 954a659628d..a7fecd1e5ba 100644 --- a/workspaces/dcm/plugins/dcm-common/src/clients/index.ts +++ b/workspaces/dcm/plugins/dcm-common/src/clients/index.ts @@ -19,7 +19,7 @@ export type { PolicyManagerApi } from './PolicyManagerApi'; export type { AgentsApi } from './AgentsApi'; export type { ResourcesApi } from './ResourcesApi'; -export { DcmBaseClient } from './DcmBaseClient'; +export { DcmBaseClient, type DcmOidcTokenProvider } from './DcmBaseClient'; export { CatalogClient } from './CatalogClient'; export { PolicyManagerClient } from './PolicyManagerClient'; export { AgentsClient } from './AgentsClient'; diff --git a/workspaces/dcm/plugins/dcm/app-config.dynamic.yaml b/workspaces/dcm/plugins/dcm/app-config.dynamic.yaml index 13f16294a6d..19a5a147603 100644 --- a/workspaces/dcm/plugins/dcm/app-config.dynamic.yaml +++ b/workspaces/dcm/plugins/dcm/app-config.dynamic.yaml @@ -1,3 +1,8 @@ +dcm: + # Keep this aligned with the DCM control plane's auth.enabled setting. + auth: + enabled: ${DCM_AUTH_ENABLED:-true} + dynamicPlugins: frontend: red-hat-developer-hub.backstage-plugin-dcm: diff --git a/workspaces/dcm/plugins/dcm/config.d.ts b/workspaces/dcm/plugins/dcm/config.d.ts index 7e030c42dbb..f2e9eadf1e6 100644 --- a/workspaces/dcm/plugins/dcm/config.d.ts +++ b/workspaces/dcm/plugins/dcm/config.d.ts @@ -23,5 +23,16 @@ export interface Config { * @visibility frontend */ policyPacks?: string[]; + + /** + * Whether the DCM control plane requires per-user OIDC authentication. + * + * Must match the control plane's `auth.enabled` setting. + * + * @visibility frontend + */ + auth?: { + enabled?: boolean; + }; }; } diff --git a/workspaces/dcm/plugins/dcm/src/DcmAuth.test.ts b/workspaces/dcm/plugins/dcm/src/DcmAuth.test.ts new file mode 100644 index 00000000000..21854d68e13 --- /dev/null +++ b/workspaces/dcm/plugins/dcm/src/DcmAuth.test.ts @@ -0,0 +1,35 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { getDcmAccessToken, setDcmAccessTokenProvider } from './DcmAuth'; + +describe('DcmAuth', () => { + afterEach(() => setDcmAccessTokenProvider(undefined)); + + it('does not supply a token when DCM authentication is disabled', async () => { + setDcmAccessTokenProvider(undefined); + + expect(getDcmAccessToken()).toBeUndefined(); + }); + + it('gets a token from the configured OIDC provider', async () => { + const getAccessToken = jest.fn().mockResolvedValue('oidc-token'); + setDcmAccessTokenProvider(getAccessToken); + + await expect(getDcmAccessToken()).resolves.toBe('oidc-token'); + expect(getAccessToken).toHaveBeenCalledTimes(1); + }); +}); diff --git a/workspaces/dcm/plugins/dcm/src/DcmAuth.ts b/workspaces/dcm/plugins/dcm/src/DcmAuth.ts new file mode 100644 index 00000000000..0494de0410a --- /dev/null +++ b/workspaces/dcm/plugins/dcm/src/DcmAuth.ts @@ -0,0 +1,31 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { DcmOidcTokenProvider } from '@red-hat-developer-hub/backstage-plugin-dcm-common'; + +let accessTokenProvider: DcmOidcTokenProvider | undefined; + +/** Configures the OIDC token provider used by the DCM API clients. */ +export function setDcmAccessTokenProvider( + provider: DcmOidcTokenProvider | undefined, +) { + accessTokenProvider = provider; +} + +/** Gets an OIDC token when DCM authentication is enabled. */ +export function getDcmAccessToken() { + return accessTokenProvider?.(); +} diff --git a/workspaces/dcm/plugins/dcm/src/Router.test.tsx b/workspaces/dcm/plugins/dcm/src/Router.test.tsx index b5b820f1a4a..2b7b39c64a7 100644 --- a/workspaces/dcm/plugins/dcm/src/Router.test.tsx +++ b/workspaces/dcm/plugins/dcm/src/Router.test.tsx @@ -22,6 +22,10 @@ jest.mock('./pages/data-center/DataCenterPage', () => ({ DataCenterPage: () =>
DataCenterPage
, })); +jest.mock('./DcmAuth', () => ({ + setDcmAccessTokenProvider: jest.fn(), +})); + describe('Router', () => { it('renders DataCenterPage on the default route', () => { render(wrapInTestApp()); diff --git a/workspaces/dcm/plugins/dcm/src/Router.tsx b/workspaces/dcm/plugins/dcm/src/Router.tsx index 76a45c360a9..27c925f5201 100644 --- a/workspaces/dcm/plugins/dcm/src/Router.tsx +++ b/workspaces/dcm/plugins/dcm/src/Router.tsx @@ -15,8 +15,33 @@ */ import { ErrorBoundary } from '@backstage/core-components'; +import type { ReactNode } from 'react'; +import { configApiRef, useApi, useApiHolder } from '@backstage/core-plugin-api'; import { Routes, Route } from 'react-router-dom'; import { DataCenterPage } from './pages/data-center/DataCenterPage'; +import { oidcAuthApiRef } from './api/AuthApiRefs'; +import { setDcmAccessTokenProvider } from './DcmAuth'; + +function DcmAuthConfigurator({ children }: { children: ReactNode }) { + const configApi = useApi(configApiRef); + const apiHolder = useApiHolder(); + const authEnabled = configApi.getOptionalBoolean('dcm.auth.enabled') ?? true; + const oidcAuthApi = authEnabled ? apiHolder.get(oidcAuthApiRef) : undefined; + + setDcmAccessTokenProvider( + authEnabled + ? oidcAuthApi?.getAccessToken.bind(oidcAuthApi) ?? + (() => + Promise.reject( + new Error( + 'DCM authentication is enabled, but the host does not provide internal.auth.oidc.', + ), + )) + : undefined, + ); + + return <>{children}; +} /** * Plugin-level router. All DCM routes are defined here (app mounts at /dcm/*). @@ -26,9 +51,11 @@ import { DataCenterPage } from './pages/data-center/DataCenterPage'; export function Router() { return ( - - } /> - + + + } /> + + ); } diff --git a/workspaces/dcm/plugins/dcm/src/api/AuthApiRefs.ts b/workspaces/dcm/plugins/dcm/src/api/AuthApiRefs.ts new file mode 100644 index 00000000000..ea2d94e0b3b --- /dev/null +++ b/workspaces/dcm/plugins/dcm/src/api/AuthApiRefs.ts @@ -0,0 +1,29 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + createApiRef, + type ApiRef, + type OAuthApi, + type OpenIdConnectApi, +} from '@backstage/core-plugin-api'; + +/** RHDH's OIDC API, provided by the host application. */ +export const oidcAuthApiRef: ApiRef = createApiRef< + OAuthApi & OpenIdConnectApi +>({ + id: 'internal.auth.oidc', +}); diff --git a/workspaces/dcm/plugins/dcm/src/plugin.ts b/workspaces/dcm/plugins/dcm/src/plugin.ts index 25f23312535..2b4fc4098bb 100644 --- a/workspaces/dcm/plugins/dcm/src/plugin.ts +++ b/workspaces/dcm/plugins/dcm/src/plugin.ts @@ -42,6 +42,7 @@ import { policyManagerApiRef, resourcesApiRef, } from './apis'; +import { getDcmAccessToken } from './DcmAuth'; /** * DCM plugin instance. @@ -64,28 +65,44 @@ export const dcmPlugin = createPlugin({ api: catalogApiRef, deps: { discoveryApi: discoveryApiRef, fetchApi: fetchApiRef }, factory({ discoveryApi, fetchApi }) { - return new CatalogClient({ discoveryApi, fetchApi }); + return new CatalogClient({ + discoveryApi, + fetchApi, + getAccessToken: getDcmAccessToken, + }); }, }), createApiFactory({ api: policyManagerApiRef, deps: { discoveryApi: discoveryApiRef, fetchApi: fetchApiRef }, factory({ discoveryApi, fetchApi }) { - return new PolicyManagerClient({ discoveryApi, fetchApi }); + return new PolicyManagerClient({ + discoveryApi, + fetchApi, + getAccessToken: getDcmAccessToken, + }); }, }), createApiFactory({ api: agentsApiRef, deps: { discoveryApi: discoveryApiRef, fetchApi: fetchApiRef }, factory({ discoveryApi, fetchApi }) { - return new AgentsClient({ discoveryApi, fetchApi }); + return new AgentsClient({ + discoveryApi, + fetchApi, + getAccessToken: getDcmAccessToken, + }); }, }), createApiFactory({ api: resourcesApiRef, deps: { discoveryApi: discoveryApiRef, fetchApi: fetchApiRef }, factory({ discoveryApi, fetchApi }) { - return new ResourcesClient({ discoveryApi, fetchApi }); + return new ResourcesClient({ + discoveryApi, + fetchApi, + getAccessToken: getDcmAccessToken, + }); }, }), ],