From a4945e77be7cf03e18781eb0b77da11f285b5463 Mon Sep 17 00:00:00 2001 From: Pranav Iyer Date: Mon, 10 Nov 2025 18:26:22 -0800 Subject: [PATCH 1/3] Initial Changes for Agentic Identities for Cloudrun. --- src/auth/agentidentity.ts | 184 +++++++++++++++++++++++++++++++++++ src/auth/computeclient.ts | 11 +++ test/test.agentidentity.ts | 191 +++++++++++++++++++++++++++++++++++++ test/test.compute.ts | 33 ++++++- 4 files changed, 417 insertions(+), 2 deletions(-) create mode 100644 src/auth/agentidentity.ts create mode 100644 test/test.agentidentity.ts diff --git a/src/auth/agentidentity.ts b/src/auth/agentidentity.ts new file mode 100644 index 00000000..8bdebc60 --- /dev/null +++ b/src/auth/agentidentity.ts @@ -0,0 +1,184 @@ +// Copyright 2025 Google LLC +// +// 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 * as crypto from 'crypto'; +import * as fs from 'fs'; +import {log as makeLog} from 'google-logging-utils'; + +const log = makeLog('google-auth-library:agentidentity'); + +const CERT_CONFIG_ENV_VAR = 'GOOGLE_API_CERTIFICATE_CONFIG'; +const PREVENT_SHARING_ENV_VAR = + 'GOOGLE_API_PREVENT_AGENT_TOKEN_SHARING_FOR_GCP_SERVICES'; + +const AGENT_IDENTITY_SPIFFE_PATTERNS = [ + /^agents\.global\.org-\d+\.system\.id\.goog$/, + /^agents\.global\.proj-\d+\.system\.id\.goog$/, +]; + +// Polling configuration +// Total timeout: 30 seconds +// Phase 1: 5 seconds of fast polling (every 0.1s) = 50 cycles +// Phase 2: 25 seconds of slow polling (every 0.5s) = 50 cycles +const FAST_POLL_INTERVAL_MS = 100; +const FAST_POLL_CYCLES = 50; +const SLOW_POLL_INTERVAL_MS = 500; +const SLOW_POLL_CYCLES = 50; + +/** + * Interface for the certificate configuration file. + * Matches the structure expected by CertificateSubjectTokenSupplier but strictly for Workload. + */ +interface CertificateConfigFile { + cert_configs: { + workload?: { + cert_path: string; + }; + }; +} + +/** + * Helper function to delay execution. + * @param ms Time to sleep in milliseconds. + */ +async function sleep(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +/** + * Retrieves the path to the agent identity certificate by polling the configuration file. + * @returns The path to the certificate file, or null if not configured. + * @throws Error if the configuration or certificate file cannot be found after the timeout. + */ +async function getAgentIdentityCertificatePath(): Promise { + const configPath = process.env[CERT_CONFIG_ENV_VAR]; + if (!configPath) { + return null; + } + + let hasLoggedWarning = false; + + for (let i = 0; i < FAST_POLL_CYCLES + SLOW_POLL_CYCLES; i++) { + try { + if (fs.existsSync(configPath)) { + const configContent = await fs.promises.readFile(configPath, 'utf-8'); + const config = JSON.parse(configContent) as CertificateConfigFile; + const certPath = config.cert_configs?.workload?.cert_path; + + if (certPath && fs.existsSync(certPath)) { + return certPath; + } + } + } catch (error) { + // Ignore errors during polling, will retry. + } + + if (!hasLoggedWarning) { + log.warn( + `Certificate config file not found at ${configPath} (from ${CERT_CONFIG_ENV_VAR} environment variable). ` + + 'Retrying for up to 30 seconds.', + ); + hasLoggedWarning = true; + } + + const interval = + i < FAST_POLL_CYCLES ? FAST_POLL_INTERVAL_MS : SLOW_POLL_INTERVAL_MS; + await sleep(interval); + } + + throw new Error( + 'Certificate config or certificate file not found after multiple retries. ' + + 'Token binding protection is failing. You can turn off this protection by setting ' + + `${PREVENT_SHARING_ENV_VAR} to false to fall back to unbound tokens.`, + ); +} + +/** + * Parses a PEM-encoded certificate. + * @param certBuffer The certificate data. + * @returns The parsed X509Certificate object. + */ +function parseCertificate(certBuffer: Buffer): crypto.X509Certificate { + return new crypto.X509Certificate(certBuffer); +} + +/** + * Checks if the certificate is an Agent Identity certificate by inspecting the SPIFFE ID in the SAN. + * @param cert The parsed certificate. + * @returns True if it matches an Agent Identity pattern. + */ +function isAgentIdentityCertificate(cert: crypto.X509Certificate): boolean { + const san = cert.subjectAltName; + if (!san) { + return false; + } + + // Node's X509Certificate.subjectAltName returns a string like "URI:spiffe://..., DNS:..." + // We use a regex to find all SPIFFE URIs and check their trust domains. + const uriMatches = san.matchAll(/URI:spiffe:\/\/([^/]+)\/.*?(?:,|$)/g); + for (const match of uriMatches) { + const trustDomain = match[1]; + for (const pattern of AGENT_IDENTITY_SPIFFE_PATTERNS) { + if (pattern.test(trustDomain)) { + return true; + } + } + } + + return false; +} + +/** + * Calculates the unpadded base64url-encoded SHA256 fingerprint of the certificate. + * @param cert The parsed certificate. + * @returns The fingerprint string. + */ +function calculateCertificateFingerprint(cert: crypto.X509Certificate): string { + return crypto.createHash('sha256').update(cert.raw).digest('base64url'); +} + +/** + * Main entry point to get the bind certificate fingerprint if appropriate. + * Checks opt-out env var, polls for cert, validates it's an Agent Identity cert, and returns the fingerprint. + * @returns The fingerprint if a bound token should be requested, otherwise undefined. + */ +export async function getBindCertificateFingerprint(): Promise< + string | undefined +> { + // 1. Check opt-out + if (process.env[PREVENT_SHARING_ENV_VAR]?.toLowerCase() === 'false') { + return undefined; + } + + // 2. Get certificate path (polling if necessary) + const certPath = await getAgentIdentityCertificatePath(); + if (!certPath) { + return undefined; + } + + // 3. Read and parse certificate + // We use fs.promises.readFile here. The existence was checked in polling, + // but it might have disappeared or be unreadable. + // We let standard IO errors propagate if it fails now after polling succeeded. + const certBuffer = await fs.promises.readFile(certPath); + const cert = parseCertificate(certBuffer); + + // 4. Check if it's an Agent Identity certificate + if (!isAgentIdentityCertificate(cert)) { + return undefined; + } + + // 5. Calculate and return fingerprint + return calculateCertificateFingerprint(cert); +} diff --git a/src/auth/computeclient.ts b/src/auth/computeclient.ts index 36ca73d1..2d09f886 100644 --- a/src/auth/computeclient.ts +++ b/src/auth/computeclient.ts @@ -21,6 +21,7 @@ import { OAuth2Client, OAuth2ClientOptions, } from './oauth2client'; +import * as agentIdentity from './agentidentity'; export interface ComputeOptions extends OAuth2ClientOptions { /** @@ -75,6 +76,16 @@ export class Compute extends OAuth2Client { scopes: this.scopes.join(','), }; } + + // Check for Agent Identity certificate and add fingerprint if present + const fingerprint = await agentIdentity.getBindCertificateFingerprint(); + if (fingerprint) { + if (!instanceOptions.params) { + instanceOptions.params = {}; + } + instanceOptions.params.bindCertificateFingerprint = fingerprint; + } + data = await gcpMetadata.instance(instanceOptions); } catch (e) { if (e instanceof GaxiosError) { diff --git a/test/test.agentidentity.ts b/test/test.agentidentity.ts new file mode 100644 index 00000000..ecc04c4d --- /dev/null +++ b/test/test.agentidentity.ts @@ -0,0 +1,191 @@ +// Copyright 2025 Google LLC +// +// 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 * as assert from 'assert'; +import {describe, it, afterEach, beforeEach} from 'mocha'; +import * as sinon from 'sinon'; +import * as fs from 'fs'; +import * as crypto from 'crypto'; +import * as path from 'path'; +import * as os from 'os'; +import {getBindCertificateFingerprint} from '../src/auth/agentidentity'; +import {TestUtils} from './utils'; + +const NON_AGENTIC_CERT_PATH = path.join( + __dirname, + '../../test/fixtures/external-account-cert/leaf.crt', +); + +const AGENTIC_CERT_PEM = `-----BEGIN CERTIFICATE----- +MIIDPjCCAiagAwIBAgIUCYeV4dwM29T5yucwWrSWlOC9wwYwDQYJKoZIhvcNAQEL +BQAwIjEgMB4GA1UEAwwXVGVzdCBTUElGRkUgQ2VydGlmaWNhdGUwHhcNMjUxMTA3 +MDEyMjQ4WhcNMzUxMTA1MDEyMjQ4WjAiMSAwHgYDVQQDDBdUZXN0IFNQSUZGRSBD +ZXJ0aWZpY2F0ZTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBANDr1Bzo +KtzIZB35acQ+mpk6yScf59AnwHjjgNCMbC7kq2DSUfQzTlu9Kd0uUB6O7DmJ73D8 +Pge4XLE/Q1B6dI6DzJx7lhPoC1BiQFUGJ4Cu+TbbdlK3RiXNAZYjIj9UKP7DejCY +WRgFB+PYyLczEkByvU9cy7Z9Uuufsn6LnYu7qOG+DcRSE41ThurZxQ14OWvLfjZm +lhZXam4VBBli8Qku8qFIALe78kpy+hp2YCRnK84amATwPpGprRACp9WVka2JDYKD +LY0OoYlyAQel6960aS11N3/2v0cvx03/LM5+Yj+DTvdyb2Mk/NVeRIKo8cM5YwPn +sTLCf1cdxJvseRMCAwEAAaNsMGowSQYDVR0RBEIwQIY+c3BpZmZlOi8vYWdlbnRz +Lmdsb2JhbC5wcm9qLTEyMzQ1LnN5c3RlbS5pZC5nb29nL3Rlc3Qtd29ya2xvYWQw +HQYDVR0OBBYEFPvn+KXBcrYCmAMopkghUczUx/IkMA0GCSqGSIb3DQEBCwUAA4IB +AQCbwd9RMFkr1C9AEgnLMWd1l9ciBbK0t1Sydu3eA0SNm2w6E58ih8O+huo6eGsM +7z0E4i7YuaHnTdah/lPMqd75YRO57GSRbvi2g+yPyw6XdFl9HCHwF4WARdTF4Nkf +1c1WstvBXb24PSSQQdy9un72ZG6f9fSVQrko6hchv8Rg6yyBTFE8APPkeMR/EJtV +cnXg4CgsQIPHxJGQrhNvQhF7VLZePlTass4bqTqTYXwAte2jX/KW3qlW/t/v4AJe +/q+pcXmNIvwRpT8zYA5tJHIDVJ+v9pWZA+nhoD9Qtr7FVHfB4mdNuFv7bMPoXN0+ +mCPzP08MnjgbX7zRETVlblrx +-----END CERTIFICATE-----`; + +describe('agentidentity', () => { + let sandbox: sinon.SinonSandbox; + let clock: sinon.SinonFakeTimers; + let tmpDir: string; + let configPath: string; + let certPath: string; + + beforeEach(async () => { + sandbox = sinon.createSandbox(); + clock = TestUtils.useFakeTimers(sandbox); + tmpDir = await fs.promises.mkdtemp( + path.join(os.tmpdir(), 'agent-id-test-'), + ); + configPath = path.join(tmpDir, 'config.json'); + certPath = path.join(tmpDir, 'cert.pem'); + delete process.env.GOOGLE_API_CERTIFICATE_CONFIG; + delete process.env.GOOGLE_API_PREVENT_AGENT_TOKEN_SHARING_FOR_GCP_SERVICES; + }); + + afterEach(async () => { + if (clock) { + clock.restore(); + } + sandbox.restore(); + await fs.promises.rm(tmpDir, {recursive: true, force: true}); + }); + + it('should return undefined if CERTIFICATE_CONFIG env var is not set', async () => { + const fingerprint = await getBindCertificateFingerprint(); + assert.strictEqual(fingerprint, undefined); + }); + + it('should return undefined if opted out via env var', async () => { + process.env.GOOGLE_API_CERTIFICATE_CONFIG = configPath; + process.env.GOOGLE_API_PREVENT_AGENT_TOKEN_SHARING_FOR_GCP_SERVICES = + 'false'; + const fingerprint = await getBindCertificateFingerprint(); + assert.strictEqual(fingerprint, undefined); + }); + + it('should fail if config file does not appear within timeout', async () => { + process.env.GOOGLE_API_CERTIFICATE_CONFIG = configPath; + + const promise = getBindCertificateFingerprint(); + // Advance clock past the 30s timeout (50 * 100ms + 50 * 500ms = 30s) + // Use tickAsync to ensure promises have a chance to resolve between ticks + await clock.tickAsync(31000); + + await assert.rejects( + promise, + /Certificate config or certificate file not found after multiple retries/, + ); + }); + +// it('should fail if cert file does not appear within timeout', async () => { +// process.env.GOOGLE_API_CERTIFICATE_CONFIG = configPath; +// await fs.promises.writeFile( +// configPath, +// JSON.stringify({ +// cert_configs: { +// workload: { +// cert_path: certPath, +// }, +// }, +// }), +// ); + +// const promise = getBindCertificateFingerprint(); +// await clock.tickAsync(31000); + +// await assert.rejects( +// promise, +// /Certificate config or certificate file not found after multiple retries/, +// ); +// }); + + it('should return undefined for non-agent identity certificate', async () => { + process.env.GOOGLE_API_CERTIFICATE_CONFIG = configPath; + // Use the non-agentic cert from fixtures + await fs.promises.writeFile( + configPath, + JSON.stringify({ + cert_configs: {workload: {cert_path: NON_AGENTIC_CERT_PATH}}, + }), + ); + + const fingerprint = await getBindCertificateFingerprint(); + assert.strictEqual(fingerprint, undefined); + }); + + it('should return fingerprint for valid agent identity certificate', async () => { + process.env.GOOGLE_API_CERTIFICATE_CONFIG = configPath; + await fs.promises.writeFile( + configPath, + JSON.stringify({cert_configs: {workload: {cert_path: certPath}}}), + ); + await fs.promises.writeFile(certPath, AGENTIC_CERT_PEM); + + const cert = new crypto.X509Certificate(AGENTIC_CERT_PEM); + const expectedFingerprint = crypto + .createHash('sha256') + .update(cert.raw) + .digest('base64url'); + + const fingerprint = await getBindCertificateFingerprint(); + assert.strictEqual(fingerprint, expectedFingerprint); + }); + + it('should poll and succeed if files appear late', async () => { + process.env.GOOGLE_API_CERTIFICATE_CONFIG = configPath; + + const promise = getBindCertificateFingerprint(); + + // Wait a bit, files missing + await clock.tickAsync(1000); + + // Create config + await fs.promises.writeFile( + configPath, + JSON.stringify({cert_configs: {workload: {cert_path: certPath}}}), + ); + + // Wait more, cert missing + await clock.tickAsync(1000); + + // Create cert + await fs.promises.writeFile(certPath, AGENTIC_CERT_PEM); + + // Complete polling + await clock.tickAsync(1000); + + const cert = new crypto.X509Certificate(AGENTIC_CERT_PEM); + const expectedFingerprint = crypto + .createHash('sha256') + .update(cert.raw) + .digest('base64url'); + + const fingerprint = await promise; + assert.strictEqual(fingerprint, expectedFingerprint); + }); +}); diff --git a/test/test.compute.ts b/test/test.compute.ts index 981eac6c..bccfaa13 100644 --- a/test/test.compute.ts +++ b/test/test.compute.ts @@ -18,6 +18,7 @@ import {BASE_PATH, HEADERS, HOST_ADDRESS} from 'gcp-metadata'; import * as nock from 'nock'; import * as sinon from 'sinon'; import {Compute} from '../src'; +import * as agentIdentity from '../src/auth/agentidentity'; nock.disableNetConnect(); @@ -25,10 +26,23 @@ describe('compute', () => { const url = 'http://example.com'; const tokenPath = `${BASE_PATH}/instance/service-accounts/default/token`; const identityPath = `${BASE_PATH}/instance/service-accounts/default/identity`; - function mockToken(statusCode = 200, scopes?: string[]) { + function mockToken( + statusCode = 200, + scopes?: string[], + queryParams?: URLSearchParams, + ) { let path = tokenPath; + const params = new URLSearchParams(); if (scopes && scopes.length > 0) { - path += `?scopes=${encodeURIComponent(scopes.join(','))}`; + params.append('scopes', scopes.join(',')); + } + if (queryParams) { + queryParams.forEach((value, key) => { + params.append(key, value); + }); + } + if (params.toString()) { + path += `?${params.toString()}`; } return nock(HOST_ADDRESS) .get(path, undefined, {reqheaders: HEADERS}) @@ -261,4 +275,19 @@ describe('compute', () => { assert.fail('failed to throw'); }); + + it('should include bindCertificateFingerprint when Agent Identity is present', async () => { + const fingerprint = 'fake-fingerprint'; + sandbox + .stub(agentIdentity, 'getBindCertificateFingerprint') + .resolves(fingerprint); + + const params = new URLSearchParams(); + params.append('bindCertificateFingerprint', fingerprint); + const scopes = [mockToken(200, undefined, params), mockExample()]; + + await compute.request({url}); + scopes.forEach(s => s.done()); + assert.strictEqual(compute.credentials.access_token, 'abc123'); + }); }); From d7d85f17a8b37a5a4353c59985a4a31304b2b0b9 Mon Sep 17 00:00:00 2001 From: Pranav Iyer Date: Sun, 16 Nov 2025 14:10:09 -0800 Subject: [PATCH 2/3] Fixed tests and moved agentic cert to a new file. --- .../external-account-cert/agentic_cert.pem | 20 +++++ test/test.agentidentity.ts | 84 ++++++++++--------- 2 files changed, 63 insertions(+), 41 deletions(-) create mode 100644 test/fixtures/external-account-cert/agentic_cert.pem diff --git a/test/fixtures/external-account-cert/agentic_cert.pem b/test/fixtures/external-account-cert/agentic_cert.pem new file mode 100644 index 00000000..3d5515d3 --- /dev/null +++ b/test/fixtures/external-account-cert/agentic_cert.pem @@ -0,0 +1,20 @@ +-----BEGIN CERTIFICATE----- +MIIDPjCCAiagAwIBAgIUCYeV4dwM29T5yucwWrSWlOC9wwYwDQYJKoZIhvcNAQEL +BQAwIjEgMB4GA1UEAwwXVGVzdCBTUElGRkUgQ2VydGlmaWNhdGUwHhcNMjUxMTA3 +MDEyMjQ4WhcNMzUxMTA1MDEyMjQ4WjAiMSAwHgYDVQQDDBdUZXN0IFNQSUZGRSBD +ZXJ0aWZpY2F0ZTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBANDr1Bzo +KtzIZB35acQ+mpk6yScf59AnwHjjgNCMbC7kq2DSUfQzTlu9Kd0uUB6O7DmJ73D8 +Pge4XLE/Q1B6dI6DzJx7lhPoC1BiQFUGJ4Cu+TbbdlK3RiXNAZYjIj9UKP7DejCY +WRgFB+PYyLczEkByvU9cy7Z9Uuufsn6LnYu7qOG+DcRSE41ThurZxQ14OWvLfjZm +lhZXam4VBBli8Qku8qFIALe78kpy+hp2YCRnK84amATwPpGprRACp9WVka2JDYKD +LY0OoYlyAQel6960aS11N3/2v0cvx03/LM5+Yj+DTvdyb2Mk/NVeRIKo8cM5YwPn +sTLCf1cdxJvseRMCAwEAAaNsMGowSQYDVR0RBEIwQIY+c3BpZmZlOi8vYWdlbnRz +Lmdsb2JhbC5wcm9qLTEyMzQ1LnN5c3RlbS5pZC5nb29nL3Rlc3Qtd29ya2xvYWQw +HQYDVR0OBBYEFPvn+KXBcrYCmAMopkghUczUx/IkMA0GCSqGSIb3DQEBCwUAA4IB +AQCbwd9RMFkr1C9AEgnLMWd1l9ciBbK0t1Sydu3eA0SNm2w6E58ih8O+huo6eGsM +7z0E4i7YuaHnTdah/lPMqd75YRO57GSRbvi2g+yPyw6XdFl9HCHwF4WARdTF4Nkf +1c1WstvBXb24PSSQQdy9un72ZG6f9fSVQrko6hchv8Rg6yyBTFE8APPkeMR/EJtV +cnXg4CgsQIPHxJGQrhNvQhF7VLZePlTass4bqTqTYXwAte2jX/KW3qlW/t/v4AJe +/q+pcXmNIvwRpT8zYA5tJHIDVJ+v9pWZA+nhoD9Qtr7FVHfB4mdNuFv7bMPoXN0+ +mCPzP08MnjgbX7zRETVlblrx +-----END CERTIFICATE----- \ No newline at end of file diff --git a/test/test.agentidentity.ts b/test/test.agentidentity.ts index ecc04c4d..f8230bc3 100644 --- a/test/test.agentidentity.ts +++ b/test/test.agentidentity.ts @@ -27,26 +27,11 @@ const NON_AGENTIC_CERT_PATH = path.join( '../../test/fixtures/external-account-cert/leaf.crt', ); -const AGENTIC_CERT_PEM = `-----BEGIN CERTIFICATE----- -MIIDPjCCAiagAwIBAgIUCYeV4dwM29T5yucwWrSWlOC9wwYwDQYJKoZIhvcNAQEL -BQAwIjEgMB4GA1UEAwwXVGVzdCBTUElGRkUgQ2VydGlmaWNhdGUwHhcNMjUxMTA3 -MDEyMjQ4WhcNMzUxMTA1MDEyMjQ4WjAiMSAwHgYDVQQDDBdUZXN0IFNQSUZGRSBD -ZXJ0aWZpY2F0ZTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBANDr1Bzo -KtzIZB35acQ+mpk6yScf59AnwHjjgNCMbC7kq2DSUfQzTlu9Kd0uUB6O7DmJ73D8 -Pge4XLE/Q1B6dI6DzJx7lhPoC1BiQFUGJ4Cu+TbbdlK3RiXNAZYjIj9UKP7DejCY -WRgFB+PYyLczEkByvU9cy7Z9Uuufsn6LnYu7qOG+DcRSE41ThurZxQ14OWvLfjZm -lhZXam4VBBli8Qku8qFIALe78kpy+hp2YCRnK84amATwPpGprRACp9WVka2JDYKD -LY0OoYlyAQel6960aS11N3/2v0cvx03/LM5+Yj+DTvdyb2Mk/NVeRIKo8cM5YwPn -sTLCf1cdxJvseRMCAwEAAaNsMGowSQYDVR0RBEIwQIY+c3BpZmZlOi8vYWdlbnRz -Lmdsb2JhbC5wcm9qLTEyMzQ1LnN5c3RlbS5pZC5nb29nL3Rlc3Qtd29ya2xvYWQw -HQYDVR0OBBYEFPvn+KXBcrYCmAMopkghUczUx/IkMA0GCSqGSIb3DQEBCwUAA4IB -AQCbwd9RMFkr1C9AEgnLMWd1l9ciBbK0t1Sydu3eA0SNm2w6E58ih8O+huo6eGsM -7z0E4i7YuaHnTdah/lPMqd75YRO57GSRbvi2g+yPyw6XdFl9HCHwF4WARdTF4Nkf -1c1WstvBXb24PSSQQdy9un72ZG6f9fSVQrko6hchv8Rg6yyBTFE8APPkeMR/EJtV -cnXg4CgsQIPHxJGQrhNvQhF7VLZePlTass4bqTqTYXwAte2jX/KW3qlW/t/v4AJe -/q+pcXmNIvwRpT8zYA5tJHIDVJ+v9pWZA+nhoD9Qtr7FVHfB4mdNuFv7bMPoXN0+ -mCPzP08MnjgbX7zRETVlblrx ------END CERTIFICATE-----`; +const AGENTIC_CERT_PATH = path.join( + __dirname, + '../../test/fixtures/external-account-cert/agentic_cert.pem', +); +const AGENTIC_CERT_PEM = fs.readFileSync(AGENTIC_CERT_PATH, 'utf-8'); describe('agentidentity', () => { let sandbox: sinon.SinonSandbox; @@ -102,27 +87,44 @@ describe('agentidentity', () => { ); }); -// it('should fail if cert file does not appear within timeout', async () => { -// process.env.GOOGLE_API_CERTIFICATE_CONFIG = configPath; -// await fs.promises.writeFile( -// configPath, -// JSON.stringify({ -// cert_configs: { -// workload: { -// cert_path: certPath, -// }, -// }, -// }), -// ); - -// const promise = getBindCertificateFingerprint(); -// await clock.tickAsync(31000); - -// await assert.rejects( -// promise, -// /Certificate config or certificate file not found after multiple retries/, -// ); -// }); + it('should fail if cert file does not appear within timeout', async () => { + process.env.GOOGLE_API_CERTIFICATE_CONFIG = configPath; + + // 1. Stub fs.existsSync + // We simulate that the config file exists, but the cert file (certPath) NEVER exists. + const existsStub = sandbox.stub(fs, 'existsSync'); + existsStub.withArgs(configPath).returns(true); + existsStub.withArgs(certPath).returns(false); + existsStub.callThrough(); // Allow other unrelated checks to pass + + // 2. Stub fs.promises.readFile + // Return the config JSON immediately without hitting the disk. + const readFileStub = sandbox.stub(fs.promises, 'readFile'); + readFileStub.withArgs(configPath, 'utf-8').resolves( + JSON.stringify({ + cert_configs: { + workload: { + cert_path: certPath, + }, + }, + }), + ); + readFileStub.callThrough(); + + // 3. Start the function + const promise = getBindCertificateFingerprint(); + + // 4. Advance the clock + // Because FS is mocked, there is no "real" IO wait. The promises resolve + // in the microtask queue, which tickAsync handles automatically. + await clock.tickAsync(31000); + + // 5. Assert failure + await assert.rejects( + promise, + /Certificate config or certificate file not found after multiple retries/, + ); + }); it('should return undefined for non-agent identity certificate', async () => { process.env.GOOGLE_API_CERTIFICATE_CONFIG = configPath; From 8c6eee25808f6be4b128c72b6b4275bf497b30ce Mon Sep 17 00:00:00 2001 From: Pranav Iyer Date: Tue, 18 Nov 2025 16:17:25 -0800 Subject: [PATCH 3/3] Fixed test timeout by disabling cert bound tokens for some test files. --- test/test.compute.ts | 21 ++++++++++++++++++++- test/test.googleauth.ts | 18 ++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/test/test.compute.ts b/test/test.compute.ts index bccfaa13..baae1ff9 100644 --- a/test/test.compute.ts +++ b/test/test.compute.ts @@ -13,7 +13,7 @@ // limitations under the License. import * as assert from 'assert'; -import {describe, it, beforeEach, afterEach} from 'mocha'; +import {describe, it, before, after, beforeEach, afterEach} from 'mocha'; import {BASE_PATH, HEADERS, HOST_ADDRESS} from 'gcp-metadata'; import * as nock from 'nock'; import * as sinon from 'sinon'; @@ -23,6 +23,25 @@ import * as agentIdentity from '../src/auth/agentidentity'; nock.disableNetConnect(); describe('compute', () => { + const PREVENT_SHARING_ENV_VAR = + 'GOOGLE_API_PREVENT_AGENT_TOKEN_SHARING_FOR_GCP_SERVICES'; + let originalPreventSharing: string | undefined; + + before(() => { + // to prevent each test waiting for the x509 agentic certificate to load. + originalPreventSharing = process.env[PREVENT_SHARING_ENV_VAR]; + process.env[PREVENT_SHARING_ENV_VAR] = 'false'; + }); + + after(() => { + // restore original value of environment. + if (originalPreventSharing === undefined) { + delete process.env[PREVENT_SHARING_ENV_VAR]; + } else { + process.env[PREVENT_SHARING_ENV_VAR] = originalPreventSharing; + } + }); + const url = 'http://example.com'; const tokenPath = `${BASE_PATH}/instance/service-accounts/default/token`; const identityPath = `${BASE_PATH}/instance/service-accounts/default/identity`; diff --git a/test/test.googleauth.ts b/test/test.googleauth.ts index 42e023c9..2b6fe46d 100644 --- a/test/test.googleauth.ts +++ b/test/test.googleauth.ts @@ -69,6 +69,24 @@ import {Gaxios, GaxiosError, GaxiosPromise, GaxiosResponse} from 'gaxios'; nock.disableNetConnect(); describe('googleauth', () => { + const PREVENT_SHARING_ENV_VAR = + 'GOOGLE_API_PREVENT_AGENT_TOKEN_SHARING_FOR_GCP_SERVICES'; + let originalPreventSharing: string | undefined; + + before(() => { + // to prevent tests waiting for the x509 agentic certificate to load. + originalPreventSharing = process.env[PREVENT_SHARING_ENV_VAR]; + process.env[PREVENT_SHARING_ENV_VAR] = 'false'; + }); + + after(() => { + // restore original value of environment. + if (originalPreventSharing === undefined) { + delete process.env[PREVENT_SHARING_ENV_VAR]; + } else { + process.env[PREVENT_SHARING_ENV_VAR] = originalPreventSharing; + } + }); const isWindows = process.platform === 'win32'; const tokenPath = `${BASE_PATH}/instance/service-accounts/default/token`;