diff --git a/core/package.json b/core/package.json index 678f8c0c9..4b7c92c12 100644 --- a/core/package.json +++ b/core/package.json @@ -66,6 +66,7 @@ "js-yaml": "^4.1.1", "jsonpath-plus": "^10.4.0", "lodash-es": "^4.18.1", + "undici": "^7.16.0", "winston": "^3.19.0", "zod": "^4.2.1", "zod-to-json-schema": "^3.25.1" diff --git a/core/src/integrations/agent_registry/agent_registry.ts b/core/src/integrations/agent_registry/agent_registry.ts index 3d8e325fc..1653f977e 100644 --- a/core/src/integrations/agent_registry/agent_registry.ts +++ b/core/src/integrations/agent_registry/agent_registry.ts @@ -12,12 +12,18 @@ import { } from '@a2a-js/sdk'; import {Client, ClientFactory} from '@a2a-js/sdk/client'; import {GoogleAuth} from 'google-auth-library'; +import type {Dispatcher} from 'undici'; import {RemoteA2AAgent} from '../../a2a/a2a_remote_agent.js'; import {ReadonlyContext} from '../../agents/readonly_context.js'; import {AuthCredential} from '../../auth/auth_credential.js'; import {AuthScheme} from '../../auth/auth_schemes.js'; import {StreamableHTTPConnectionParams} from '../../tools/mcp/mcp_session_manager.js'; import {logger} from '../../utils/logger.js'; +import { + createMtlsDispatcher, + effectiveGoogleapisEndpoint, + FetchInitWithDispatcher, +} from '../../utils/mtls_utils.js'; import {AgentRegistrySingleMCPToolset} from './agent_registry_mcp_toolset.js'; import {cleanName, isGoogleApi} from './helpers.js'; import { @@ -46,6 +52,25 @@ const TRANSPORT_MAPPING: Record = { 'GRPC': 'GRPC', }; +/** The endpoint and transport a registry instance sends its requests through. */ +interface MtlsTransport { + baseUrl: string; + /** Presents the client certificate, when one was configured and loaded. */ + dispatcher?: Dispatcher; +} + +/** Loads the client certificate when one is configured and picks the host. */ +async function resolveMtlsTransport(): Promise { + const dispatcher = await createMtlsDispatcher(); + return { + baseUrl: effectiveGoogleapisEndpoint( + AGENT_REGISTRY_BASE_URL, + dispatcher !== undefined, + ), + dispatcher, + }; +} + /** * Client for interacting with the Google Cloud Agent Registry service. * @@ -64,6 +89,7 @@ export class AgentRegistry { context: ReadonlyContext, ) => Record; private readonly auth: GoogleAuth; + private mtlsTransportPromise?: Promise; constructor(options: { projectId?: string | null; @@ -130,6 +156,18 @@ export class AgentRegistry { } } + /** + * Resolves the mTLS transport once per instance. The in-flight promise is + * memoized rather than its result, so concurrent first calls share a single + * certificate load. + */ + private mtlsTransport(): Promise { + if (!this.mtlsTransportPromise) { + this.mtlsTransportPromise = resolveMtlsTransport(); + } + return this.mtlsTransportPromise; + } + /** * Helper function to execute HTTP GET requests against the Agent Registry API. * Handles path resolution, search query params compilation, and auth headers fetching. @@ -138,12 +176,13 @@ export class AgentRegistry { path: string, params?: Record, ): Promise { + const {baseUrl, dispatcher} = await this.mtlsTransport(); let url: string; // Support absolute resource paths (starting with projects/) or relative paths (resolved inside base path) if (path.startsWith('projects/')) { - url = `${AGENT_REGISTRY_BASE_URL}/${path}`; + url = `${baseUrl}/${path}`; } else { - url = `${AGENT_REGISTRY_BASE_URL}/${this.basePath}/${path}`; + url = `${baseUrl}/${this.basePath}/${path}`; } if (params && Object.keys(params).length > 0) { @@ -153,10 +192,12 @@ export class AgentRegistry { try { const headers = await this.getAuthHeaders(); - const res = await fetch(url, { + const init: FetchInitWithDispatcher = { method: 'GET', headers, - }); + ...(dispatcher ? {dispatcher} : {}), + }; + const res = await fetch(url, init); if (!res.ok) { const text = await res.text(); throw new Error( diff --git a/core/src/utils/mtls_utils.ts b/core/src/utils/mtls_utils.ts new file mode 100644 index 000000000..8125ef16d --- /dev/null +++ b/core/src/utils/mtls_utils.ts @@ -0,0 +1,196 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Utilities for mutual-TLS (mTLS) endpoint resolution against Google Cloud + * REST APIs. + * + * Behaviour is driven entirely by the standard Google client-library + * environment variables: + * + * - `GOOGLE_API_USE_CLIENT_CERTIFICATE`: `true`/`1` to present a client + * certificate; anything else (including absent) disables it. + * - `GOOGLE_API_USE_MTLS_ENDPOINT`: `auto` (default), `always` or `never`; + * decides whether requests target the `*.mtls.googleapis.com` host. + * - `GOOGLE_API_CERTIFICATE_CONFIG`: path to `certificate_config.json`, + * overriding the well-known gcloud location. + * + * This module is Node-only and is deliberately not part of the browser bundle. + */ + +import {readFile} from 'node:fs/promises'; +import {platform} from 'node:os'; +import {join} from 'node:path'; +import type {Dispatcher} from 'undici'; +import {getBooleanEnvVar} from './env_aware_utils.js'; +import {logger} from './logger.js'; + +const GOOGLEAPIS_SUFFIX = '.googleapis.com'; +const MTLS_GOOGLEAPIS_SUFFIX = '.mtls.googleapis.com'; +const CERTIFICATE_CONFIG_FILENAME = 'certificate_config.json'; + +/** Values of the `GOOGLE_API_USE_MTLS_ENDPOINT` environment variable. */ +export enum MtlsEndpointSetting { + AUTO = 'auto', + ALWAYS = 'always', + NEVER = 'never', +} + +/** + * The subset of `certificate_config.json` this module reads. The snake_case + * keys are the on-disk format written by gcloud and must not be camelCased. + */ +interface CertificateConfigFile { + cert_configs?: {workload?: {cert_path?: string; key_path?: string}}; +} + +/** + * The init argument accepted by the global `fetch`. Spelled as a derivation of + * `fetch` rather than as the `RequestInit` global it resolves to, because + * eslint's `no-undef` does not see type-only DOM globals and rejects the bare + * name. + */ +type FetchInit = NonNullable[1]>; + +/** + * Standard fetch init plus undici's non-standard `dispatcher` extension, which + * is how a client certificate is attached to a request made with the global + * `fetch`. + */ +export interface FetchInitWithDispatcher extends FetchInit { + dispatcher?: Dispatcher; +} + +/** Reads `GOOGLE_API_USE_MTLS_ENDPOINT`, defaulting to `AUTO`. */ +function mtlsEndpointSetting(): MtlsEndpointSetting { + switch ((process.env['GOOGLE_API_USE_MTLS_ENDPOINT'] ?? '').toLowerCase()) { + case MtlsEndpointSetting.ALWAYS: + return MtlsEndpointSetting.ALWAYS; + case MtlsEndpointSetting.NEVER: + return MtlsEndpointSetting.NEVER; + default: + return MtlsEndpointSetting.AUTO; + } +} + +/** + * Returns the endpoint `url` should actually be called on: its + * `*.mtls.googleapis.com` variant when the environment calls for mTLS, and + * `url` unchanged otherwise. + * + * The host is rewritten only when `GOOGLE_API_USE_MTLS_ENDPOINT` is `always`, + * or is `auto` (the default) and `hasClientCert` is true. Scheme, port, path, + * query and fragment are preserved. Hosts that are not `*.googleapis.com` + * hosts, and hosts that are already mTLS hosts, are returned unchanged, so + * non-Google providers are never affected. + * + * Unlike adk-python's `effective_googleapis_endpoint`, this takes the + * certificate state as an argument rather than leaving it to a separate + * predicate, so the policy is applied in exactly one place. + */ +export function effectiveGoogleapisEndpoint( + url: string, + hasClientCert: boolean, +): string { + const setting = mtlsEndpointSetting(); + const useMtls = + setting === MtlsEndpointSetting.ALWAYS || + (setting === MtlsEndpointSetting.AUTO && hasClientCert); + if (!useMtls) { + return url; + } + let parsed: URL; + try { + parsed = new URL(url); + } catch { + return url; + } + if ( + !parsed.hostname.endsWith(GOOGLEAPIS_SUFFIX) || + parsed.hostname.includes(MTLS_GOOGLEAPIS_SUFFIX) + ) { + return url; + } + parsed.hostname = + parsed.hostname.slice(0, -GOOGLEAPIS_SUFFIX.length) + + MTLS_GOOGLEAPIS_SUFFIX; + return parsed.toString(); +} + +/** Returns the gcloud configuration directory for the current platform. */ +function gcloudConfigDir(): string { + const cloudSdkConfig = process.env['CLOUDSDK_CONFIG']; + if (cloudSdkConfig) { + return cloudSdkConfig; + } + if (platform().startsWith('win')) { + return join(process.env['APPDATA'] ?? '', 'gcloud'); + } + return join(process.env['HOME'] ?? '', '.config', 'gcloud'); +} + +/** + * Returns the path of `certificate_config.json`, preferring the + * `GOOGLE_API_CERTIFICATE_CONFIG` override over the well-known gcloud + * location. Mirrors the resolution order used by `google-auth-library`. + */ +function certificateConfigPath(): string { + return ( + process.env['GOOGLE_API_CERTIFICATE_CONFIG'] || + join(gcloudConfigDir(), CERTIFICATE_CONFIG_FILENAME) + ); +} + +/** Reads the workload client certificate described by `configPath`. */ +async function readClientCertificate( + configPath: string, +): Promise<{cert: Buffer; key: Buffer}> { + const config = JSON.parse( + await readFile(configPath, 'utf8'), + ) as CertificateConfigFile; + const workload = config.cert_configs?.workload; + if (!workload?.cert_path || !workload.key_path) { + throw new Error('cert_configs.workload is missing cert_path or key_path'); + } + const [cert, key] = await Promise.all([ + readFile(workload.cert_path), + readFile(workload.key_path), + ]); + return {cert, key}; +} + +/** + * Builds an HTTP dispatcher that presents the application-default client + * certificate, for use as the `dispatcher` init property of a `fetch` call. + * + * Returns `undefined`, without touching the filesystem, when + * `GOOGLE_API_USE_CLIENT_CERTIFICATE` is not enabled. When a certificate is + * requested but cannot be loaded this fails open: it logs a warning and + * returns `undefined` so the caller falls back to a plain request rather than + * failing outright. It never rejects, and certificate and key material is + * never logged. + */ +export async function createMtlsDispatcher(): Promise { + if (!getBooleanEnvVar('GOOGLE_API_USE_CLIENT_CERTIFICATE')) { + return undefined; + } + const configPath = certificateConfigPath(); + try { + const {cert, key} = await readClientCertificate(configPath); + // Imported lazily so that the default path never pays for undici, and so + // that this module stays importable on runtimes below undici's engine + // floor. + const {Agent} = await import('undici'); + return new Agent({connect: {cert, key}}); + } catch (e: unknown) { + logger.warn( + `Could not load the client certificate configured by ${configPath}; ` + + `falling back to a non-mTLS request: ` + + `${e instanceof Error ? e.message : String(e)}`, + ); + return undefined; + } +} diff --git a/core/test/integrations/agent_registry_mtls_test.ts b/core/test/integrations/agent_registry_mtls_test.ts new file mode 100644 index 000000000..7a3d607af --- /dev/null +++ b/core/test/integrations/agent_registry_mtls_test.ts @@ -0,0 +1,147 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {Agent} from 'undici'; +import { + afterAll, + afterEach, + beforeEach, + describe, + expect, + it, + vi, +} from 'vitest'; +import {AgentRegistry} from '../../src/index.js'; +import {createMtlsDispatcher} from '../../src/utils/mtls_utils.js'; + +vi.mock('google-auth-library', () => ({ + GoogleAuth: vi.fn().mockImplementation(() => ({ + getClient: vi.fn().mockResolvedValue({ + getRequestHeaders: vi.fn().mockResolvedValue({ + 'Authorization': 'Bearer fake-token', + }), + }), + })), +})); + +// Only the certificate loading is faked; the endpoint-resolution helpers run +// for real so the tests exercise the actual rewrite rules. +vi.mock('../../src/utils/mtls_utils.js', async (importOriginal) => ({ + ...(await importOriginal()), + createMtlsDispatcher: vi.fn(), +})); + +const PLAIN_BASE_URL = 'https://agentregistry.googleapis.com/v1alpha'; +const MTLS_BASE_URL = 'https://agentregistry.mtls.googleapis.com/v1alpha'; + +const dispatcher = new Agent(); +const originalEnv = process.env; +const fetchMock = vi.fn(); + +/** Returns the url and init of the nth recorded fetch call. */ +function fetchCall(index: number) { + const call = fetchMock.mock.calls[index]; + expect(call).toBeDefined(); + return {url: String(call[0]), init: call[1]}; +} + +describe('AgentRegistry mTLS', () => { + let registry: AgentRegistry; + + beforeEach(() => { + vi.clearAllMocks(); + const env = {...originalEnv}; + delete env['GOOGLE_API_USE_MTLS_ENDPOINT']; + delete env['GOOGLE_API_USE_CLIENT_CERTIFICATE']; + process.env = env; + + vi.mocked(createMtlsDispatcher).mockResolvedValue(undefined); + fetchMock.mockImplementation(async () => new Response('{"agents": []}')); + global.fetch = fetchMock; + + registry = new AgentRegistry({ + projectId: 'test-project', + location: 'global', + }); + }); + + afterEach(() => { + process.env = originalEnv; + }); + + afterAll(async () => { + await dispatcher.close(); + }); + + it('uses the plain host with no dispatcher when no certificate is available', async () => { + await registry.listAgents(); + + const {url, init} = fetchCall(0); + expect(url.startsWith(PLAIN_BASE_URL)).toBe(true); + expect(init).not.toHaveProperty('dispatcher'); + }); + + it('uses the mTLS host and attaches the dispatcher when a certificate is available', async () => { + vi.mocked(createMtlsDispatcher).mockResolvedValue(dispatcher); + + await registry.listAgents(); + + const {url, init} = fetchCall(0); + expect(url.startsWith(MTLS_BASE_URL)).toBe(true); + expect(init).toHaveProperty('dispatcher', dispatcher); + }); + + it('uses the mTLS host without a dispatcher when the setting is "always"', async () => { + process.env['GOOGLE_API_USE_MTLS_ENDPOINT'] = 'always'; + + await registry.listAgents(); + + const {url, init} = fetchCall(0); + expect(url.startsWith(MTLS_BASE_URL)).toBe(true); + expect(init).not.toHaveProperty('dispatcher'); + }); + + it('keeps the plain host when the setting is "never" despite a certificate', async () => { + process.env['GOOGLE_API_USE_MTLS_ENDPOINT'] = 'never'; + vi.mocked(createMtlsDispatcher).mockResolvedValue(dispatcher); + + await registry.listAgents(); + + const {url, init} = fetchCall(0); + expect(url.startsWith(PLAIN_BASE_URL)).toBe(true); + expect(init).toHaveProperty('dispatcher', dispatcher); + }); + + it('loads the certificate once across sequential requests', async () => { + vi.mocked(createMtlsDispatcher).mockResolvedValue(dispatcher); + + await registry.listAgents(); + await registry.listAgents(); + + expect(createMtlsDispatcher).toHaveBeenCalledTimes(1); + expect(fetchCall(1).url.startsWith(MTLS_BASE_URL)).toBe(true); + }); + + it('loads the certificate once across concurrent first requests', async () => { + vi.mocked(createMtlsDispatcher).mockResolvedValue(dispatcher); + + await Promise.all([registry.listAgents(), registry.listAgents()]); + + expect(createMtlsDispatcher).toHaveBeenCalledTimes(1); + }); + + it('loads the certificate per instance', async () => { + vi.mocked(createMtlsDispatcher).mockResolvedValue(dispatcher); + + await registry.listAgents(); + await new AgentRegistry({ + projectId: 'test-project', + location: 'global', + }).listAgents(); + + expect(createMtlsDispatcher).toHaveBeenCalledTimes(2); + }); +}); diff --git a/core/test/utils/mtls_utils_test.ts b/core/test/utils/mtls_utils_test.ts new file mode 100644 index 000000000..75ae8e432 --- /dev/null +++ b/core/test/utils/mtls_utils_test.ts @@ -0,0 +1,428 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {readFile} from 'node:fs/promises'; +import {platform} from 'node:os'; +import {join} from 'node:path'; +import { + afterEach, + beforeEach, + describe, + expect, + it, + MockInstance, + vi, +} from 'vitest'; +import {logger} from '../../src/utils/logger.js'; +import { + createMtlsDispatcher, + effectiveGoogleapisEndpoint, + MtlsEndpointSetting, +} from '../../src/utils/mtls_utils.js'; + +const {agentCtor} = vi.hoisted(() => ({agentCtor: vi.fn()})); + +vi.mock('undici', () => ({Agent: agentCtor})); + +vi.mock('node:fs/promises', async (importOriginal) => ({ + ...(await importOriginal()), + readFile: vi.fn(), +})); + +vi.mock('node:os', async (importOriginal) => ({ + ...(await importOriginal()), + platform: vi.fn(), +})); + +/** Environment variables this module reads, cleared before every test. */ +const MTLS_ENV_VARS = [ + 'GOOGLE_API_USE_CLIENT_CERTIFICATE', + 'GOOGLE_API_USE_MTLS_ENDPOINT', + 'GOOGLE_API_CERTIFICATE_CONFIG', + 'CLOUDSDK_CONFIG', + 'APPDATA', + 'HOME', +]; + +const CERT_BYTES = Buffer.from('-----BEGIN CERTIFICATE----- cert-material'); +const KEY_BYTES = Buffer.from('-----BEGIN PRIVATE KEY----- key-material'); +const CERT_PATH = '/certs/workload.pem'; +const KEY_PATH = '/certs/workload.key'; +const CONFIG_PATH = '/certs/certificate_config.json'; + +const originalEnv = process.env; +let warnSpy: MockInstance<(...args: unknown[]) => void>; + +/** + * Makes `readFile` serve a valid certificate config plus its PEM files, so + * only the config path under test varies between cases. + */ +function mockCertificateFiles(configPath: string) { + const config = JSON.stringify({ + version: 1, + cert_configs: {workload: {cert_path: CERT_PATH, key_path: KEY_PATH}}, + }); + vi.mocked(readFile).mockImplementation(async (file) => { + switch (file) { + case CERT_PATH: + return CERT_BYTES; + case KEY_PATH: + return KEY_BYTES; + case configPath: + return config; + default: + throw new Error(`ENOENT: no such file or directory, open '${file}'`); + } + }); +} + +/** Makes `readFile` return `config` for the configured config file only. */ +function mockCertificateConfigContent(config: string) { + vi.mocked(readFile).mockImplementation(async (file) => { + if (file === CONFIG_PATH) { + return config; + } + throw new Error(`ENOENT: no such file or directory, open '${file}'`); + }); +} + +describe('mtls_utils', () => { + beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(platform).mockReturnValue('linux'); + warnSpy = vi.spyOn(logger, 'warn').mockImplementation(() => {}); + const env = {...originalEnv}; + for (const name of MTLS_ENV_VARS) { + delete env[name]; + } + process.env = env; + }); + + afterEach(() => { + process.env = originalEnv; + vi.restoreAllMocks(); + }); + + it('keeps the setting values adk-python writes on the wire', () => { + expect(Object.values(MtlsEndpointSetting)).toEqual([ + 'auto', + 'always', + 'never', + ]); + }); + + describe('effectiveGoogleapisEndpoint', () => { + it.each([ + [ + 'https://oauth2.googleapis.com/token', + 'https://oauth2.mtls.googleapis.com/token', + ], + [ + 'https://openidconnect.googleapis.com/v1/userinfo', + 'https://openidconnect.mtls.googleapis.com/v1/userinfo', + ], + [ + 'https://iam.googleapis.com/v1/token?foo=bar', + 'https://iam.mtls.googleapis.com/v1/token?foo=bar', + ], + [ + 'https://iam.googleapis.com:8443/v1/x#frag', + 'https://iam.mtls.googleapis.com:8443/v1/x#frag', + ], + ])('rewrites %s to %s', (url, expected) => { + expect(effectiveGoogleapisEndpoint(url, true)).toBe(expected); + }); + + it.each([ + // Already-mTLS hosts are left alone. + 'https://oauth2.mtls.googleapis.com/token', + // Non-Google providers are never rewritten. + 'https://example.com/token', + 'https://accounts.google.com/o/oauth2/v2/auth', + // A lookalike host must not match on a substring. + 'https://evil-googleapis.com.attacker.test/x', + // The bare apex is not a `*.googleapis.com` host. + 'https://googleapis.com/token', + // Unparseable input is passed through untouched. + '', + 'not a url', + ])('leaves %s unchanged', (url) => { + expect(effectiveGoogleapisEndpoint(url, true)).toBe(url); + }); + + it('rewrites for "always" even without a certificate', () => { + process.env['GOOGLE_API_USE_MTLS_ENDPOINT'] = 'always'; + expect( + effectiveGoogleapisEndpoint( + 'https://oauth2.googleapis.com/token', + false, + ), + ).toBe('https://oauth2.mtls.googleapis.com/token'); + }); + + it('does not rewrite for "never" even with a certificate', () => { + process.env['GOOGLE_API_USE_MTLS_ENDPOINT'] = 'never'; + expect( + effectiveGoogleapisEndpoint( + 'https://oauth2.googleapis.com/token', + true, + ), + ).toBe('https://oauth2.googleapis.com/token'); + }); + + it('does not rewrite for "auto" without a certificate', () => { + process.env['GOOGLE_API_USE_MTLS_ENDPOINT'] = 'auto'; + expect( + effectiveGoogleapisEndpoint( + 'https://oauth2.googleapis.com/token', + false, + ), + ).toBe('https://oauth2.googleapis.com/token'); + }); + + it('treats an unrecognised setting as "auto"', () => { + process.env['GOOGLE_API_USE_MTLS_ENDPOINT'] = 'invalid'; + expect( + effectiveGoogleapisEndpoint( + 'https://oauth2.googleapis.com/token', + true, + ), + ).toBe('https://oauth2.mtls.googleapis.com/token'); + expect( + effectiveGoogleapisEndpoint( + 'https://oauth2.googleapis.com/token', + false, + ), + ).toBe('https://oauth2.googleapis.com/token'); + }); + + it('matches the setting case-insensitively', () => { + process.env['GOOGLE_API_USE_MTLS_ENDPOINT'] = 'ALWAYS'; + expect( + effectiveGoogleapisEndpoint( + 'https://oauth2.googleapis.com/token', + false, + ), + ).toBe('https://oauth2.mtls.googleapis.com/token'); + process.env['GOOGLE_API_USE_MTLS_ENDPOINT'] = 'Never'; + expect( + effectiveGoogleapisEndpoint( + 'https://oauth2.googleapis.com/token', + true, + ), + ).toBe('https://oauth2.googleapis.com/token'); + }); + }); + + describe('createMtlsDispatcher', () => { + it.each(['true', '1', 'TRUE'])( + 'loads a certificate when GOOGLE_API_USE_CLIENT_CERTIFICATE is "%s"', + async (value) => { + process.env['GOOGLE_API_USE_CLIENT_CERTIFICATE'] = value; + process.env['GOOGLE_API_CERTIFICATE_CONFIG'] = CONFIG_PATH; + mockCertificateFiles(CONFIG_PATH); + + await expect(createMtlsDispatcher()).resolves.toBeDefined(); + }, + ); + + it.each(['false', '0', ''])( + 'returns undefined without touching the filesystem when GOOGLE_API_USE_CLIENT_CERTIFICATE is "%s"', + async (value) => { + process.env['GOOGLE_API_USE_CLIENT_CERTIFICATE'] = value; + + await expect(createMtlsDispatcher()).resolves.toBeUndefined(); + + expect(readFile).not.toHaveBeenCalled(); + expect(warnSpy).not.toHaveBeenCalled(); + }, + ); + + it('returns undefined without touching the filesystem when the variable is unset', async () => { + await expect(createMtlsDispatcher()).resolves.toBeUndefined(); + + expect(readFile).not.toHaveBeenCalled(); + expect(warnSpy).not.toHaveBeenCalled(); + }); + + it('builds a dispatcher from the GOOGLE_API_CERTIFICATE_CONFIG path', async () => { + process.env['GOOGLE_API_USE_CLIENT_CERTIFICATE'] = 'true'; + process.env['GOOGLE_API_CERTIFICATE_CONFIG'] = CONFIG_PATH; + mockCertificateFiles(CONFIG_PATH); + + const dispatcher = await createMtlsDispatcher(); + + expect(readFile).toHaveBeenCalledWith(CONFIG_PATH, 'utf8'); + expect(agentCtor).toHaveBeenCalledWith({ + connect: {cert: CERT_BYTES, key: KEY_BYTES}, + }); + expect(dispatcher).toBe(agentCtor.mock.instances[0]); + expect(warnSpy).not.toHaveBeenCalled(); + }); + + it('derives the config path from CLOUDSDK_CONFIG', async () => { + const expectedPath = join( + '/opt/gcloud-config', + 'certificate_config.json', + ); + process.env['GOOGLE_API_USE_CLIENT_CERTIFICATE'] = 'true'; + process.env['CLOUDSDK_CONFIG'] = '/opt/gcloud-config'; + mockCertificateFiles(expectedPath); + + await expect(createMtlsDispatcher()).resolves.toBeDefined(); + + expect(readFile).toHaveBeenCalledWith(expectedPath, 'utf8'); + }); + + it('falls back to the HOME gcloud directory on posix', async () => { + const expectedPath = join( + '/home/user', + '.config', + 'gcloud', + 'certificate_config.json', + ); + process.env['GOOGLE_API_USE_CLIENT_CERTIFICATE'] = 'true'; + process.env['HOME'] = '/home/user'; + mockCertificateFiles(expectedPath); + + await expect(createMtlsDispatcher()).resolves.toBeDefined(); + + expect(readFile).toHaveBeenCalledWith(expectedPath, 'utf8'); + }); + + it('treats an unset HOME as an empty prefix on posix', async () => { + const expectedPath = join('.config', 'gcloud', 'certificate_config.json'); + process.env['GOOGLE_API_USE_CLIENT_CERTIFICATE'] = 'true'; + mockCertificateFiles(expectedPath); + + await expect(createMtlsDispatcher()).resolves.toBeDefined(); + + expect(readFile).toHaveBeenCalledWith(expectedPath, 'utf8'); + }); + + it('uses the APPDATA gcloud directory on windows', async () => { + const expectedPath = join( + 'C:\\Users\\u\\AppData\\Roaming', + 'gcloud', + 'certificate_config.json', + ); + vi.mocked(platform).mockReturnValue('win32'); + process.env['GOOGLE_API_USE_CLIENT_CERTIFICATE'] = 'true'; + process.env['APPDATA'] = 'C:\\Users\\u\\AppData\\Roaming'; + mockCertificateFiles(expectedPath); + + await expect(createMtlsDispatcher()).resolves.toBeDefined(); + + expect(readFile).toHaveBeenCalledWith(expectedPath, 'utf8'); + }); + + it('treats an unset APPDATA as an empty prefix on windows', async () => { + const expectedPath = join('gcloud', 'certificate_config.json'); + vi.mocked(platform).mockReturnValue('win32'); + process.env['GOOGLE_API_USE_CLIENT_CERTIFICATE'] = 'true'; + mockCertificateFiles(expectedPath); + + await expect(createMtlsDispatcher()).resolves.toBeDefined(); + + expect(readFile).toHaveBeenCalledWith(expectedPath, 'utf8'); + }); + + it('warns and returns undefined when the config file is missing', async () => { + process.env['GOOGLE_API_USE_CLIENT_CERTIFICATE'] = 'true'; + process.env['GOOGLE_API_CERTIFICATE_CONFIG'] = CONFIG_PATH; + vi.mocked(readFile).mockRejectedValue( + new Error(`ENOENT: no such file or directory, open '${CONFIG_PATH}'`), + ); + + await expect(createMtlsDispatcher()).resolves.toBeUndefined(); + + expect(agentCtor).not.toHaveBeenCalled(); + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(warnSpy.mock.calls[0][0]).toContain(CONFIG_PATH); + }); + + it('warns and returns undefined when the config file is not valid JSON', async () => { + process.env['GOOGLE_API_USE_CLIENT_CERTIFICATE'] = 'true'; + process.env['GOOGLE_API_CERTIFICATE_CONFIG'] = CONFIG_PATH; + mockCertificateConfigContent('{not json'); + + await expect(createMtlsDispatcher()).resolves.toBeUndefined(); + + expect(agentCtor).not.toHaveBeenCalled(); + expect(warnSpy).toHaveBeenCalledTimes(1); + }); + + it.each([ + ['the workload entry is missing', '{"cert_configs": {}}'], + [ + 'cert_path is missing', + JSON.stringify({ + cert_configs: {workload: {key_path: KEY_PATH}}, + }), + ], + [ + 'key_path is missing', + JSON.stringify({ + cert_configs: {workload: {cert_path: CERT_PATH}}, + }), + ], + ])('warns and returns undefined when %s', async (_name, config) => { + process.env['GOOGLE_API_USE_CLIENT_CERTIFICATE'] = 'true'; + process.env['GOOGLE_API_CERTIFICATE_CONFIG'] = CONFIG_PATH; + mockCertificateConfigContent(config); + + await expect(createMtlsDispatcher()).resolves.toBeUndefined(); + + expect(agentCtor).not.toHaveBeenCalled(); + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(warnSpy.mock.calls[0][0]).toContain('cert_configs.workload'); + }); + + it('warns and returns undefined when a PEM file cannot be read', async () => { + process.env['GOOGLE_API_USE_CLIENT_CERTIFICATE'] = 'true'; + process.env['GOOGLE_API_CERTIFICATE_CONFIG'] = CONFIG_PATH; + // Only the config file resolves; reading the PEM files rejects. + mockCertificateConfigContent( + JSON.stringify({ + cert_configs: {workload: {cert_path: CERT_PATH, key_path: KEY_PATH}}, + }), + ); + + await expect(createMtlsDispatcher()).resolves.toBeUndefined(); + + expect(agentCtor).not.toHaveBeenCalled(); + expect(warnSpy).toHaveBeenCalledTimes(1); + }); + + it('warns and returns undefined when the failure is not an Error', async () => { + process.env['GOOGLE_API_USE_CLIENT_CERTIFICATE'] = 'true'; + process.env['GOOGLE_API_CERTIFICATE_CONFIG'] = CONFIG_PATH; + vi.mocked(readFile).mockRejectedValue('disk offline'); + + await expect(createMtlsDispatcher()).resolves.toBeUndefined(); + + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(warnSpy.mock.calls[0][0]).toContain('disk offline'); + }); + + it('never logs certificate or key material', async () => { + process.env['GOOGLE_API_USE_CLIENT_CERTIFICATE'] = 'true'; + process.env['GOOGLE_API_CERTIFICATE_CONFIG'] = CONFIG_PATH; + mockCertificateFiles(CONFIG_PATH); + agentCtor.mockImplementation(() => { + throw new Error('dispatcher construction failed'); + }); + + await expect(createMtlsDispatcher()).resolves.toBeUndefined(); + + expect(warnSpy).toHaveBeenCalledTimes(1); + const message = String(warnSpy.mock.calls[0][0]); + expect(message).not.toContain('cert-material'); + expect(message).not.toContain('key-material'); + expect(message).toContain('dispatcher construction failed'); + }); + }); +}); diff --git a/package-lock.json b/package-lock.json index e29a4a190..3e0c5b115 100644 --- a/package-lock.json +++ b/package-lock.json @@ -70,6 +70,7 @@ "js-yaml": "^4.1.1", "jsonpath-plus": "^10.4.0", "lodash-es": "^4.18.1", + "undici": "^7.16.0", "winston": "^3.19.0", "zod": "^4.2.1", "zod-to-json-schema": "^3.25.1" @@ -14439,6 +14440,14 @@ "dev": true, "license": "MIT" }, + "node_modules/undici": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz", + "integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", diff --git a/tests/integration/mtls_dispatcher_test.ts b/tests/integration/mtls_dispatcher_test.ts new file mode 100644 index 000000000..fde6ec35c --- /dev/null +++ b/tests/integration/mtls_dispatcher_test.ts @@ -0,0 +1,173 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {spawnSync} from 'node:child_process'; +import {mkdtempSync, readFileSync, rmSync, writeFileSync} from 'node:fs'; +import {createServer, Server} from 'node:https'; +import type {Socket} from 'node:net'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import type {TLSSocket} from 'node:tls'; +import {afterAll, beforeAll, describe, expect, it} from 'vitest'; +import { + createMtlsDispatcher, + FetchInitWithDispatcher, +} from '../../core/src/utils/mtls_utils.js'; + +const hasOpenssl = spawnSync('openssl', ['version']).status === 0; + +const ENV_VARS = [ + 'NODE_TLS_REJECT_UNAUTHORIZED', + 'GOOGLE_API_USE_CLIENT_CERTIFICATE', + 'GOOGLE_API_CERTIFICATE_CONFIG', +]; +const originalEnv = new Map( + ENV_VARS.map((name) => [name, process.env[name]] as const), +); + +let certDir: string; +let configPath: string; +let server: Server; +let baseUrl: string; + +/** Narrows a server socket to its TLS form. */ +function isTlsSocket(socket: Socket | TLSSocket): socket is TLSSocket { + return 'getPeerCertificate' in socket; +} + +/** + * Runs openssl with space-separated `args`, failing the test with its stderr + * if the command does not succeed. + */ +function openssl(args: string) { + const result = spawnSync('openssl', args.split(' '), { + cwd: certDir, + encoding: 'utf8', + }); + if (result.status !== 0) { + expect.fail(`openssl ${args} failed: ${result.stderr}`); + } +} + +/** Generates a throwaway CA plus a server and a client certificate. */ +function generateCertificates() { + writeFileSync(join(certDir, 'server.ext'), 'subjectAltName=DNS:localhost\n'); + openssl( + 'req -x509 -newkey rsa:2048 -nodes -keyout ca.key -out ca.pem -days 1' + + ' -subj /CN=adk-test-ca', + ); + openssl( + 'req -newkey rsa:2048 -nodes -keyout server.key -out server.csr' + + ' -subj /CN=localhost', + ); + openssl( + 'x509 -req -in server.csr -CA ca.pem -CAkey ca.key -CAcreateserial' + + ' -out server.pem -days 1 -extfile server.ext', + ); + openssl( + 'req -newkey rsa:2048 -nodes -keyout client.key -out client.csr' + + ' -subj /CN=adk-test-client', + ); + openssl( + 'x509 -req -in client.csr -CA ca.pem -CAkey ca.key -CAcreateserial' + + ' -out client.pem -days 1', + ); +} + +describe.skipIf(!hasOpenssl)('mTLS dispatcher', () => { + beforeAll(async () => { + certDir = mkdtempSync(join(tmpdir(), 'adk-mtls-')); + generateCertificates(); + configPath = join(certDir, 'certificate_config.json'); + writeFileSync( + configPath, + JSON.stringify({ + version: 1, + cert_configs: { + workload: { + cert_path: join(certDir, 'client.pem'), + key_path: join(certDir, 'client.key'), + }, + }, + }), + ); + + // The server certificate is signed by a throwaway CA this process cannot + // be told to trust (NODE_EXTRA_CA_CERTS is only read at startup), and + // createMtlsDispatcher deliberately exposes no `ca` option. The assertions + // below are about the *client* certificate, so server-certificate + // verification is disabled for this file only. + process.env['NODE_TLS_REJECT_UNAUTHORIZED'] = '0'; + + server = createServer( + { + cert: readFileSync(join(certDir, 'server.pem')), + key: readFileSync(join(certDir, 'server.key')), + ca: readFileSync(join(certDir, 'ca.pem')), + requestCert: true, + rejectUnauthorized: false, + }, + (req, res) => { + const commonName = isTlsSocket(req.socket) + ? (req.socket.getPeerCertificate().subject?.CN ?? null) + : null; + res.writeHead(200, {'content-type': 'application/json'}); + res.end(JSON.stringify({commonName})); + }, + ); + await new Promise((resolve) => { + server.listen(0, '127.0.0.1', resolve); + }); + const address = server.address(); + if (typeof address === 'string' || address === null) { + expect.fail('https server did not report a numeric port'); + } + baseUrl = `https://localhost:${address.port}/`; + }); + + afterAll(async () => { + for (const [name, value] of originalEnv) { + if (value === undefined) { + delete process.env[name]; + } else { + process.env[name] = value; + } + } + await new Promise((resolve) => { + server.close(() => resolve()); + }); + rmSync(certDir, {recursive: true, force: true}); + }); + + it('presents the configured client certificate to the server', async () => { + process.env['GOOGLE_API_USE_CLIENT_CERTIFICATE'] = 'true'; + process.env['GOOGLE_API_CERTIFICATE_CONFIG'] = configPath; + + const dispatcher = await createMtlsDispatcher(); + if (dispatcher === undefined) { + expect.fail('createMtlsDispatcher returned no dispatcher'); + } + const init: FetchInitWithDispatcher = {dispatcher}; + try { + const res = await fetch(baseUrl, init); + await expect(res.json()).resolves.toEqual({ + commonName: 'adk-test-client', + }); + } finally { + await dispatcher.close(); + } + }); + + it('sends no client certificate when the feature is disabled', async () => { + delete process.env['GOOGLE_API_USE_CLIENT_CERTIFICATE']; + process.env['GOOGLE_API_CERTIFICATE_CONFIG'] = configPath; + + await expect(createMtlsDispatcher()).resolves.toBeUndefined(); + + const res = await fetch(baseUrl); + await expect(res.json()).resolves.toEqual({commonName: null}); + }); +});