From 476f16a3cebf02265c16af57fe41c84503fa218c Mon Sep 17 00:00:00 2001 From: macastelaz <34776182+macastelaz@users.noreply.github.com> Date: Mon, 18 May 2026 13:58:13 -0500 Subject: [PATCH 01/10] feat: port GDCH credentials support to Node.js Auth SDK --- .../src/auth/gdchclient.ts | 257 ++++++++++++++ .../src/auth/googleauth.ts | 15 +- .../google-auth-library-nodejs/src/index.ts | 4 + .../test/test.gdchclient.ts | 336 ++++++++++++++++++ .../test/test.googleauth.ts | 17 + 5 files changed, 626 insertions(+), 3 deletions(-) create mode 100644 core/packages/google-auth-library-nodejs/src/auth/gdchclient.ts create mode 100644 core/packages/google-auth-library-nodejs/test/test.gdchclient.ts diff --git a/core/packages/google-auth-library-nodejs/src/auth/gdchclient.ts b/core/packages/google-auth-library-nodejs/src/auth/gdchclient.ts new file mode 100644 index 000000000000..52223cc6cddb --- /dev/null +++ b/core/packages/google-auth-library-nodejs/src/auth/gdchclient.ts @@ -0,0 +1,257 @@ +// Copyright 2026 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 * as https from 'https'; +import {GaxiosOptions} from 'gaxios'; +import { + GetTokenResponse, + OAuth2Client, + OAuth2ClientOptions, +} from './oauth2client'; +import {CredentialRequest, Credentials} from './credentials'; + +const DEFAULT_LIFETIME_IN_SECONDS = 3600; +export const GDCH_CREDENTIALS_TYPE = 'gdch_credentials'; + +export interface GdchClientOptions extends OAuth2ClientOptions { + projectId?: string | null; + privateKeyId?: string; + privateKey?: string; + serviceIdentityName?: string; + tokenServerUri?: string; + caCertPath?: string; + apiAudience?: string; + lifetime?: number; +} + +export interface GdchCredentialsInput { + type: 'gdch_credentials'; + format_version: string; + project: string; + private_key_id: string; + private_key: string; + name: string; + token_uri: string; + ca_cert_path?: string; +} + +export class GdchClient extends OAuth2Client { + projectId?: string; + privateKeyId?: string; + privateKey?: string; + serviceIdentityName?: string; + tokenServerUri?: string; + caCertPath?: string; + apiAudience?: string; + lifetime: number; + private gdchOptions: GdchClientOptions; + + constructor(options: GdchClientOptions = {}) { + super(options); + this.gdchOptions = options; + this.projectId = options.projectId || undefined; + this.privateKeyId = options.privateKeyId; + this.privateKey = options.privateKey; + this.serviceIdentityName = options.serviceIdentityName; + this.tokenServerUri = options.tokenServerUri; + this.caCertPath = options.caCertPath; + this.apiAudience = options.apiAudience; + this.lifetime = options.lifetime || DEFAULT_LIFETIME_IN_SECONDS; + + // Start with an expired refresh token, which will automatically be + // refreshed before the first API call is made. + this.credentials = {refresh_token: 'gdch-placeholder', expiry_date: 1}; + } + + createWithGdchAudience(apiAudience: string): GdchClient { + if (!apiAudience) { + throw new Error( + 'Audience cannot be null or empty for GDCH service account credentials.' + ); + } + return new GdchClient({ + ...this.gdchOptions, + projectId: this.projectId, + privateKeyId: this.privateKeyId, + privateKey: this.privateKey, + serviceIdentityName: this.serviceIdentityName, + tokenServerUri: this.tokenServerUri, + caCertPath: this.caCertPath, + lifetime: this.lifetime, + apiAudience, + }); + } + + fromJSON(json: GdchCredentialsInput): void { + if (!json) { + throw new Error( + 'Must pass in a JSON object containing the GDCH credentials settings.' + ); + } + if (json.type !== GDCH_CREDENTIALS_TYPE) { + throw new Error( + `The incoming JSON object does not have the "${GDCH_CREDENTIALS_TYPE}" type` + ); + } + if (json.format_version !== '1') { + throw new Error('Only format version 1 is supported.'); + } + if (!json.project) { + throw new Error('The incoming JSON object does not contain a project field'); + } + if (!json.private_key_id) { + throw new Error( + 'The incoming JSON object does not contain a private_key_id field' + ); + } + if (!json.private_key) { + throw new Error('The incoming JSON object does not contain a private_key field'); + } + if (!json.name) { + throw new Error('The incoming JSON object does not contain a name field'); + } + if (!json.token_uri) { + throw new Error('The incoming JSON object does not contain a token_uri field'); + } + + this.projectId = json.project; + this.privateKeyId = json.private_key_id; + this.privateKey = json.private_key; + this.serviceIdentityName = json.name; + this.tokenServerUri = json.token_uri; + this.caCertPath = json.ca_cert_path; + } + + protected async refreshTokenNoCache(): Promise { + if (!this.apiAudience) { + throw new Error( + 'Audience cannot be null or empty for GDCH service account credentials. ' + + 'Specify the audience by calling createWithGdchAudience.' + ); + } + if (!this.privateKey) { + throw new Error('Private key is not configured for GDCH credentials.'); + } + if (!this.privateKeyId) { + throw new Error('Private key ID is not configured for GDCH credentials.'); + } + if (!this.projectId) { + throw new Error('Project is not configured for GDCH credentials.'); + } + if (!this.serviceIdentityName) { + throw new Error('Service identity name is not configured for GDCH credentials.'); + } + if (!this.tokenServerUri) { + throw new Error('Token server URI is not configured for GDCH credentials.'); + } + + const assertion = this.createAssertion(); + + const data = { + audience: this.apiAudience, + grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange', + requested_token_type: 'urn:ietf:params:oauth:token-type:access_token', + subject_token: assertion, + subject_token_type: 'urn:k8s:params:oauth:token-type:serviceaccount', + }; + + const requestOpts: GaxiosOptions = { + url: this.tokenServerUri, + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + data, + responseType: 'json', + }; + + if (this.caCertPath) { + try { + const ca = fs.readFileSync(this.caCertPath); + requestOpts.agent = new https.Agent({ ca }); + } catch (err) { + if (err instanceof Error) { + err.message = `Error reading certificate file from CA cert path, value '${this.caCertPath}': ${err.message}`; + } + throw err; + } + } + + try { + const res = await this.transporter.request(requestOpts); + const tokenResponse = res.data; + const tokens: Credentials = { + access_token: tokenResponse.access_token, + token_type: 'Bearer', + }; + + if (tokenResponse.expires_in) { + tokens.expiry_date = new Date().getTime() + tokenResponse.expires_in * 1000; + } + + this.emit('tokens', tokens); + return {res, tokens}; + } catch (e) { + if (e instanceof Error) { + e.message = `Error getting access token for GDCH service account: ${e.message}, iss: ${this.serviceIdentityName}`; + } + throw e; + } + } + + private createAssertion(): string { + const header = { + alg: 'ES256', + typ: 'JWT', + kid: this.privateKeyId, + }; + + const issSub = `system:serviceaccount:${this.projectId}:${this.serviceIdentityName}`; + const currentTime = Math.floor(Date.now() / 1000); + const payload = { + iss: issSub, + sub: issSub, + iat: currentTime, + exp: currentTime + this.lifetime, + aud: this.tokenServerUri, + }; + + const encodedHeader = this.base64UrlEncode(JSON.stringify(header)); + const encodedPayload = this.base64UrlEncode(JSON.stringify(payload)); + const signingInput = `${encodedHeader}.${encodedPayload}`; + + const signature = crypto.sign( + 'sha256', + Buffer.from(signingInput), + { + key: this.privateKey!, + dsaEncoding: 'ieee-p1363', + } + ); + + const encodedSignature = this.base64UrlEncode(signature); + return `${signingInput}.${encodedSignature}`; + } + + private base64UrlEncode(str: string | Buffer): string { + const buffer = typeof str === 'string' ? Buffer.from(str) : str; + return buffer + .toString('base64') + .replace(/=/g, '') + .replace(/\+/g, '-') + .replace(/\//g, '_'); + } +} diff --git a/core/packages/google-auth-library-nodejs/src/auth/googleauth.ts b/core/packages/google-auth-library-nodejs/src/auth/googleauth.ts index 4d9832c67f20..b56366a55be4 100644 --- a/core/packages/google-auth-library-nodejs/src/auth/googleauth.ts +++ b/core/packages/google-auth-library-nodejs/src/auth/googleauth.ts @@ -42,6 +42,11 @@ import { ExternalAccountAuthorizedUserClient, ExternalAccountAuthorizedUserClientOptions, } from './externalAccountAuthorizedUserClient'; +import { + GdchClient, + GDCH_CREDENTIALS_TYPE, + GdchCredentialsInput, +} from './gdchclient'; import {originalOrCamelOptions} from '../util'; import {AnyAuthClient, AnyAuthClientConstructor} from '..'; @@ -54,7 +59,8 @@ export type JSONClient = | UserRefreshClient | BaseExternalAccountClient | ExternalAccountAuthorizedUserClient - | Impersonated; + | Impersonated + | GdchClient; export interface ProjectIdCallback { (err?: Error | null, projectId?: string | null): void; @@ -764,7 +770,7 @@ export class GoogleAuth { * @returns JWT or UserRefresh Client with data */ fromJSON( - json: JWTInput | ImpersonatedJWTInput, + json: JWTInput | ImpersonatedJWTInput | GdchCredentialsInput, options: AuthClientOptions = {}, ): JSONClient { let client: JSONClient; @@ -789,11 +795,14 @@ export class GoogleAuth { ...json, ...options, } as ExternalAccountAuthorizedUserClientOptions); + } else if (json.type === GDCH_CREDENTIALS_TYPE) { + client = new GdchClient(options); + client.fromJSON(json as GdchCredentialsInput); } else { (options as JWTOptions).scopes = this.scopes; client = new JWT(options); this.setGapicJWTValues(client); - client.fromJSON(json); + client.fromJSON(json as JWTInput); } if (preferredUniverseDomain) { diff --git a/core/packages/google-auth-library-nodejs/src/index.ts b/core/packages/google-auth-library-nodejs/src/index.ts index 5eabfea9c70a..f1c81ed1e2b5 100644 --- a/core/packages/google-auth-library-nodejs/src/index.ts +++ b/core/packages/google-auth-library-nodejs/src/index.ts @@ -92,6 +92,10 @@ export { ExternalAccountAuthorizedUserClientOptions, } from './auth/externalAccountAuthorizedUserClient'; export {PassThroughClient} from './auth/passthrough'; +export { + GdchClient, + GdchClientOptions, +} from './auth/gdchclient'; export * from './gtoken/googleToken'; type ALL_EXPORTS = (typeof import('./'))[keyof typeof import('./')]; diff --git a/core/packages/google-auth-library-nodejs/test/test.gdchclient.ts b/core/packages/google-auth-library-nodejs/test/test.gdchclient.ts new file mode 100644 index 000000000000..7e63c458e708 --- /dev/null +++ b/core/packages/google-auth-library-nodejs/test/test.gdchclient.ts @@ -0,0 +1,336 @@ +// Copyright 2026 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, beforeEach, afterEach} from 'mocha'; +import * as nock from 'nock'; +import * as crypto from 'crypto'; +import * as fs from 'fs'; +import * as sinon from 'sinon'; +import {GdchClient, GDCH_CREDENTIALS_TYPE, GdchCredentialsInput} from '../src/auth/gdchclient'; + +nock.disableNetConnect(); + +describe('GdchClient', () => { + let privateKeyPemSec1: string; + let privateKeyPemPkcs8: string; + let publicKeyPem: string; + + beforeEach(() => { + // Dynamically generate an EC key pair for testing + const {privateKey: keySec1, publicKey} = crypto.generateKeyPairSync('ec', { + namedCurve: 'prime256v1', + privateKeyEncoding: { + type: 'sec1', + format: 'pem', + }, + publicKeyEncoding: { + type: 'spki', + format: 'pem', + }, + }); + privateKeyPemSec1 = keySec1; + publicKeyPem = publicKey; + + const {privateKey: keyPkcs8} = crypto.generateKeyPairSync('ec', { + namedCurve: 'prime256v1', + publicKeyEncoding: { + type: 'spki', + format: 'pem', + }, + privateKeyEncoding: { + type: 'pkcs8', + format: 'pem', + }, + }); + privateKeyPemPkcs8 = keyPkcs8; + }); + + afterEach(() => { + nock.cleanAll(); + sinon.restore(); + }); + + it('should initialize options properly in constructor', () => { + const client = new GdchClient({ + projectId: 'test-project', + privateKeyId: 'key-id-123', + privateKey: privateKeyPemSec1, + serviceIdentityName: 'sa-name', + tokenServerUri: 'https://token-server.local/token', + apiAudience: 'target-audience', + lifetime: 1800, + }); + + assert.strictEqual(client.projectId, 'test-project'); + assert.strictEqual(client.privateKeyId, 'key-id-123'); + assert.strictEqual(client.privateKey, privateKeyPemSec1); + assert.strictEqual(client.serviceIdentityName, 'sa-name'); + assert.strictEqual(client.tokenServerUri, 'https://token-server.local/token'); + assert.strictEqual(client.apiAudience, 'target-audience'); + assert.strictEqual(client.lifetime, 1800); + assert.strictEqual(client.credentials.refresh_token, 'gdch-placeholder'); + }); + + it('should parse JSON options via fromJSON() correctly', () => { + const client = new GdchClient(); + const json: GdchCredentialsInput = { + type: GDCH_CREDENTIALS_TYPE, + format_version: '1', + project: 'test-project', + private_key_id: 'key-id-123', + private_key: privateKeyPemSec1, + name: 'sa-name', + token_uri: 'https://token-server.local/token', + ca_cert_path: '/path/to/ca.crt', + }; + + client.fromJSON(json); + + assert.strictEqual(client.projectId, 'test-project'); + assert.strictEqual(client.privateKeyId, 'key-id-123'); + assert.strictEqual(client.privateKey, privateKeyPemSec1); + assert.strictEqual(client.serviceIdentityName, 'sa-name'); + assert.strictEqual(client.tokenServerUri, 'https://token-server.local/token'); + assert.strictEqual(client.caCertPath, '/path/to/ca.crt'); + }); + + it('fromJSON() should throw error if type is mismatch', () => { + const client = new GdchClient(); + const json = { + type: 'invalid_type', + format_version: '1', + } as unknown as GdchCredentialsInput; + + assert.throws(() => { + client.fromJSON(json); + }, /does not have the "gdch_credentials" type/); + }); + + it('fromJSON() should throw error if format_version is unsupported', () => { + const client = new GdchClient(); + const json: GdchCredentialsInput = { + type: GDCH_CREDENTIALS_TYPE, + format_version: '2', + project: 'p', + private_key_id: 'k', + private_key: 'pk', + name: 'n', + token_uri: 'uri', + }; + + assert.throws(() => { + client.fromJSON(json); + }, /Only format version 1 is supported/); + }); + + it('fromJSON() should throw error on missing mandatory fields', () => { + const mandatoryFields: Array = [ + 'project', + 'private_key_id', + 'private_key', + 'name', + 'token_uri', + ]; + + mandatoryFields.forEach(field => { + const json: Partial = { + type: GDCH_CREDENTIALS_TYPE, + format_version: '1', + project: 'test-project', + private_key_id: 'key-id-123', + private_key: privateKeyPemSec1, + name: 'sa-name', + token_uri: 'https://token-server.local/token', + }; + delete json[field]; + + const client = new GdchClient(); + assert.throws(() => { + client.fromJSON(json as GdchCredentialsInput); + }, new RegExp(`does not contain a ${field === 'project' ? 'project' : field === 'name' ? 'name' : field === 'token_uri' ? 'token_uri' : field} field`)); + }); + }); + + it('should create a scoped client with a custom audience via createWithGdchAudience()', () => { + const client = new GdchClient({ + projectId: 'test-project', + privateKeyId: 'key-id-123', + privateKey: privateKeyPemSec1, + serviceIdentityName: 'sa-name', + tokenServerUri: 'https://token-server.local/token', + apiAudience: 'target-audience', + lifetime: 1800, + }); + + const scoped = client.createWithGdchAudience('new-audience'); + + assert.notStrictEqual(client, scoped); + assert.strictEqual(scoped.apiAudience, 'new-audience'); + assert.strictEqual(scoped.projectId, 'test-project'); + assert.strictEqual(scoped.privateKey, privateKeyPemSec1); + assert.strictEqual(scoped.lifetime, 1800); + }); + + it('createWithGdchAudience() should throw error if audience is empty', () => { + const client = new GdchClient(); + assert.throws(() => { + client.createWithGdchAudience(''); + }, /Audience cannot be null or empty/); + }); + + it('should perform token exchange successfully with valid assertion signature (SEC1 key)', async () => { + const client = new GdchClient({ + projectId: 'test-project', + privateKeyId: 'key-id-123', + privateKey: privateKeyPemSec1, + serviceIdentityName: 'sa-name', + tokenServerUri: 'https://token-server.local/token', + apiAudience: 'target-audience', + lifetime: 1800, + }); + + let interceptedAssertion = ''; + + const scope = nock('https://token-server.local') + .post('/token', (body) => { + assert.strictEqual(body.audience, 'target-audience'); + assert.strictEqual(body.grant_type, 'urn:ietf:params:oauth:grant-type:token-exchange'); + assert.strictEqual(body.requested_token_type, 'urn:ietf:params:oauth:token-type:access_token'); + assert.strictEqual(body.subject_token_type, 'urn:k8s:params:oauth:token-type:serviceaccount'); + assert.ok(body.subject_token); + interceptedAssertion = body.subject_token; + return true; + }) + .reply(200, { + access_token: 'exchange-token-abc123', + expires_in: 3600, + }); + + const res = await client.getAccessToken(); + scope.done(); + + assert.strictEqual(res.token, 'exchange-token-abc123'); + assert.strictEqual(client.credentials.access_token, 'exchange-token-abc123'); + assert.ok(client.credentials.expiry_date); + + // Validate assertion signature + const parts = interceptedAssertion.split('.'); + assert.strictEqual(parts.length, 3); + + const header = JSON.parse(Buffer.from(parts[0], 'base64').toString('utf8')); + const payload = JSON.parse(Buffer.from(parts[1], 'base64').toString('utf8')); + + assert.strictEqual(header.alg, 'ES256'); + assert.strictEqual(header.typ, 'JWT'); + assert.strictEqual(header.kid, 'key-id-123'); + + assert.strictEqual(payload.iss, 'system:serviceaccount:test-project:sa-name'); + assert.strictEqual(payload.sub, 'system:serviceaccount:test-project:sa-name'); + assert.strictEqual(payload.aud, 'https://token-server.local/token'); + assert.ok(payload.iat); + assert.strictEqual(payload.exp, payload.iat + 1800); + + // Verify Signature using the Public Key + const signingInput = `${parts[0]}.${parts[1]}`; + const signature = Buffer.from(parts[2], 'base64'); + + const verifier = crypto.createVerify('sha256'); + verifier.update(signingInput); + const signatureValid = verifier.verify( + { + key: publicKeyPem, + dsaEncoding: 'ieee-p1363', + }, + signature + ); + assert.ok(signatureValid, 'JWT assertion signature should be valid.'); + }); + + it('should perform token exchange successfully with PKCS#8 key', async () => { + const client = new GdchClient({ + projectId: 'test-project', + privateKeyId: 'key-id-123', + privateKey: privateKeyPemPkcs8, + serviceIdentityName: 'sa-name', + tokenServerUri: 'https://token-server.local/token', + apiAudience: 'target-audience', + }); + + const scope = nock('https://token-server.local') + .post('/token') + .reply(200, { + access_token: 'pkcs8-token', + expires_in: 1800, + }); + + const res = await client.getAccessToken(); + scope.done(); + + assert.strictEqual(res.token, 'pkcs8-token'); + assert.strictEqual(client.credentials.access_token, 'pkcs8-token'); + }); + + it('should attach custom CA to request agent when ca_cert_path is provided', async () => { + const caCertPath = '/path/to/custom-ca.pem'; + const client = new GdchClient({ + projectId: 'test-project', + privateKeyId: 'key-id-123', + privateKey: privateKeyPemSec1, + serviceIdentityName: 'sa-name', + tokenServerUri: 'https://token-server.local/token', + apiAudience: 'target-audience', + caCertPath, + }); + + // Stub fs.readFileSync to return a mock certificate + const readFileSyncStub = sinon.stub(fs, 'readFileSync').callsFake((path) => { + assert.strictEqual(path, caCertPath); + return Buffer.from('mock-ca-cert-content'); + }); + + const nockScope = nock('https://token-server.local') + .post('/token') + .reply(200, { + access_token: 'ca-verified-token', + }); + + const res = await client.getAccessToken(); + nockScope.done(); + assert.ok(readFileSyncStub.calledOnce); + assert.strictEqual(res.token, 'ca-verified-token'); + }); + + it('should raise helpful error message if token exchange fails', async () => { + const client = new GdchClient({ + projectId: 'test-project', + privateKeyId: 'key-id-123', + privateKey: privateKeyPemSec1, + serviceIdentityName: 'sa-name', + tokenServerUri: 'https://token-server.local/token', + apiAudience: 'target-audience', + }); + + const scope = nock('https://token-server.local') + .post('/token') + .reply(400, 'Bad Token Request'); + + await assert.rejects(client.getAccessToken(), (err: Error) => { + assert.ok(err.message.includes('Error getting access token for GDCH service account')); + assert.ok(err.message.includes('iss: sa-name')); + return true; + }); + scope.done(); + }); +}); diff --git a/core/packages/google-auth-library-nodejs/test/test.googleauth.ts b/core/packages/google-auth-library-nodejs/test/test.googleauth.ts index e2f223565cc3..23b1e82498f3 100644 --- a/core/packages/google-auth-library-nodejs/test/test.googleauth.ts +++ b/core/packages/google-auth-library-nodejs/test/test.googleauth.ts @@ -43,6 +43,7 @@ import { IdentityPoolClient, PassThroughClient, AnyAuthClient, + GdchClient, } from '../src'; import {CredentialBody} from '../src/auth/credentials'; import * as envDetect from '../src/auth/envDetect'; @@ -549,6 +550,22 @@ describe('googleauth', () => { assert.strictEqual(300000, (result as JWT).eagerRefreshThresholdMillis); }); + it('fromJSON should create GdchClient for GDCH type credentials', () => { + const json = { + type: 'gdch_credentials', + format_version: '1', + project: 'test-project', + private_key_id: 'key-id-123', + private_key: 'private-key-pem-content', + name: 'sa-name', + token_uri: 'https://token-server.local/token', + }; + const result = auth.fromJSON(json); + assert.ok(result instanceof GdchClient); + assert.strictEqual((result as GdchClient).projectId, 'test-project'); + assert.strictEqual((result as GdchClient).privateKey, 'private-key-pem-content'); + }); + it('fromStream should error on null stream', done => { // Test verifies invalid parameter tests, which requires cast to any. (auth as ReturnType).fromStream(null, (err: Error) => { From c5121cbf985e22003d344f2ca83eeb2c7f01d9d4 Mon Sep 17 00:00:00 2001 From: macastelaz <34776182+macastelaz@users.noreply.github.com> Date: Wed, 20 May 2026 08:46:21 -0500 Subject: [PATCH 02/10] Make small adjustments to address gemini feedback, focusing on four topics. 1) Async CA file reading 2) Token response validation 3) Option synchronization and 4) Expanded unit test coverage. --- .../src/auth/gdchclient.ts | 15 ++++- .../test/test.gdchclient.ts | 59 ++++++++++++++++++- 2 files changed, 70 insertions(+), 4 deletions(-) diff --git a/core/packages/google-auth-library-nodejs/src/auth/gdchclient.ts b/core/packages/google-auth-library-nodejs/src/auth/gdchclient.ts index 52223cc6cddb..052bdf13b61a 100644 --- a/core/packages/google-auth-library-nodejs/src/auth/gdchclient.ts +++ b/core/packages/google-auth-library-nodejs/src/auth/gdchclient.ts @@ -133,6 +133,16 @@ export class GdchClient extends OAuth2Client { this.serviceIdentityName = json.name; this.tokenServerUri = json.token_uri; this.caCertPath = json.ca_cert_path; + + this.gdchOptions = { + ...this.gdchOptions, + projectId: json.project, + privateKeyId: json.private_key_id, + privateKey: json.private_key, + serviceIdentityName: json.name, + tokenServerUri: json.token_uri, + caCertPath: json.ca_cert_path, + }; } protected async refreshTokenNoCache(): Promise { @@ -180,7 +190,7 @@ export class GdchClient extends OAuth2Client { if (this.caCertPath) { try { - const ca = fs.readFileSync(this.caCertPath); + const ca = await fs.promises.readFile(this.caCertPath); requestOpts.agent = new https.Agent({ ca }); } catch (err) { if (err instanceof Error) { @@ -193,6 +203,9 @@ export class GdchClient extends OAuth2Client { try { const res = await this.transporter.request(requestOpts); const tokenResponse = res.data; + if (!tokenResponse.access_token) { + throw new Error('Token response did not contain an access_token.'); + } const tokens: Credentials = { access_token: tokenResponse.access_token, token_type: 'Bearer', diff --git a/core/packages/google-auth-library-nodejs/test/test.gdchclient.ts b/core/packages/google-auth-library-nodejs/test/test.gdchclient.ts index 7e63c458e708..057914aed609 100644 --- a/core/packages/google-auth-library-nodejs/test/test.gdchclient.ts +++ b/core/packages/google-auth-library-nodejs/test/test.gdchclient.ts @@ -294,8 +294,8 @@ describe('GdchClient', () => { caCertPath, }); - // Stub fs.readFileSync to return a mock certificate - const readFileSyncStub = sinon.stub(fs, 'readFileSync').callsFake((path) => { + // Stub fs.promises.readFile to return a mock certificate + const readFileStub = sinon.stub(fs.promises, 'readFile').callsFake(async (path) => { assert.strictEqual(path, caCertPath); return Buffer.from('mock-ca-cert-content'); }); @@ -308,10 +308,63 @@ describe('GdchClient', () => { const res = await client.getAccessToken(); nockScope.done(); - assert.ok(readFileSyncStub.calledOnce); + assert.ok(readFileStub.calledOnce); assert.strictEqual(res.token, 'ca-verified-token'); }); + it('should raise helpful error message if CA cert file is unreadable', async () => { + const caCertPath = '/path/to/custom-ca.pem'; + const client = new GdchClient({ + projectId: 'test-project', + privateKeyId: 'key-id-123', + privateKey: privateKeyPemSec1, + serviceIdentityName: 'sa-name', + tokenServerUri: 'https://token-server.local/token', + apiAudience: 'target-audience', + caCertPath, + }); + + // Stub fs.promises.readFile to throw an error + const readFileStub = sinon.stub(fs.promises, 'readFile').rejects(new Error('Permission denied')); + + const nockScope = nock('https://token-server.local') + .post('/token') + .reply(200, { + access_token: 'ca-verified-token', + }); + + await assert.rejects(client.getAccessToken(), (err: Error) => { + assert.ok(err.message.includes('Error reading certificate file from CA cert path')); + assert.ok(err.message.includes('Permission denied')); + return true; + }); + nockScope.restore(); + assert.ok(readFileStub.calledOnce); + }); + + it('should throw error if token response does not contain access_token', async () => { + const client = new GdchClient({ + projectId: 'test-project', + privateKeyId: 'key-id-123', + privateKey: privateKeyPemSec1, + serviceIdentityName: 'sa-name', + tokenServerUri: 'https://token-server.local/token', + apiAudience: 'target-audience', + }); + + const scope = nock('https://token-server.local') + .post('/token') + .reply(200, { + expires_in: 3600, + }); + + await assert.rejects(client.getAccessToken(), (err: Error) => { + assert.ok(err.message.includes('Token response did not contain an access_token.')); + return true; + }); + scope.done(); + }); + it('should raise helpful error message if token exchange fails', async () => { const client = new GdchClient({ projectId: 'test-project', From d3c4af0fe7088dd73f72564818afa70d2e7aa850 Mon Sep 17 00:00:00 2001 From: macastelaz <34776182+macastelaz@users.noreply.github.com> Date: Thu, 21 May 2026 09:14:17 -0500 Subject: [PATCH 03/10] Fix several issues in the original implementation, identified during manual testing in a GDCH environment. These fixes include: 1) Correcting the type of the credential being parsed from 'gdch_credentials' to 'gdch_service_account' which is the type generated in the SA file. 2)Changing the audience property in token exchange to get the STS-Bearer instead of just Bearer token. 3) Override the requestAsync method to ensure client operations automatically read and trust custom CA certificate files specificied in the credentials json 'ca_cert_path'. 4) Fixed unit test oversight of incorrect call to nockScope.restore(). --- .../src/auth/gdchclient.ts | 32 +++++++++++++++---- .../src/auth/googleauth.ts | 4 +-- .../test/test.gdchclient.ts | 14 ++++---- .../test/test.googleauth.ts | 4 +-- 4 files changed, 36 insertions(+), 18 deletions(-) diff --git a/core/packages/google-auth-library-nodejs/src/auth/gdchclient.ts b/core/packages/google-auth-library-nodejs/src/auth/gdchclient.ts index 052bdf13b61a..6b9b494b4c64 100644 --- a/core/packages/google-auth-library-nodejs/src/auth/gdchclient.ts +++ b/core/packages/google-auth-library-nodejs/src/auth/gdchclient.ts @@ -15,7 +15,7 @@ import * as crypto from 'crypto'; import * as fs from 'fs'; import * as https from 'https'; -import {GaxiosOptions} from 'gaxios'; +import {GaxiosOptions, GaxiosResponse} from 'gaxios'; import { GetTokenResponse, OAuth2Client, @@ -24,7 +24,7 @@ import { import {CredentialRequest, Credentials} from './credentials'; const DEFAULT_LIFETIME_IN_SECONDS = 3600; -export const GDCH_CREDENTIALS_TYPE = 'gdch_credentials'; +export const GDCH_SERVICE_ACCOUNT_TYPE = 'gdch_service_account'; export interface GdchClientOptions extends OAuth2ClientOptions { projectId?: string | null; @@ -38,7 +38,7 @@ export interface GdchClientOptions extends OAuth2ClientOptions { } export interface GdchCredentialsInput { - type: 'gdch_credentials'; + type: 'gdch_service_account'; format_version: string; project: string; private_key_id: string; @@ -101,9 +101,9 @@ export class GdchClient extends OAuth2Client { 'Must pass in a JSON object containing the GDCH credentials settings.' ); } - if (json.type !== GDCH_CREDENTIALS_TYPE) { + if (json.type !== GDCH_SERVICE_ACCOUNT_TYPE) { throw new Error( - `The incoming JSON object does not have the "${GDCH_CREDENTIALS_TYPE}" type` + `The incoming JSON object does not have the "${GDCH_SERVICE_ACCOUNT_TYPE}" type` ); } if (json.format_version !== '1') { @@ -172,7 +172,7 @@ export class GdchClient extends OAuth2Client { const data = { audience: this.apiAudience, - grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange', + grant_type: 'urn:ietf:params:oauth:token-type:token-exchange', requested_token_type: 'urn:ietf:params:oauth:token-type:access_token', subject_token: assertion, subject_token_type: 'urn:k8s:params:oauth:token-type:serviceaccount', @@ -208,7 +208,7 @@ export class GdchClient extends OAuth2Client { } const tokens: Credentials = { access_token: tokenResponse.access_token, - token_type: 'Bearer', + token_type: 'STS-Bearer', }; if (tokenResponse.expires_in) { @@ -259,6 +259,24 @@ export class GdchClient extends OAuth2Client { return `${signingInput}.${encodedSignature}`; } + override async requestAsync( + opts: GaxiosOptions, + retry = false + ): Promise> { + if (this.caCertPath && !opts.agent) { + try { + const ca = await fs.promises.readFile(this.caCertPath); + opts.agent = new https.Agent({ca}); + } catch (err) { + if (err instanceof Error) { + err.message = `Error reading certificate file from CA cert path, value '${this.caCertPath}': ${err.message}`; + } + throw err; + } + } + return super.requestAsync(opts, retry); + } + private base64UrlEncode(str: string | Buffer): string { const buffer = typeof str === 'string' ? Buffer.from(str) : str; return buffer diff --git a/core/packages/google-auth-library-nodejs/src/auth/googleauth.ts b/core/packages/google-auth-library-nodejs/src/auth/googleauth.ts index b56366a55be4..2ae53c9ce6e2 100644 --- a/core/packages/google-auth-library-nodejs/src/auth/googleauth.ts +++ b/core/packages/google-auth-library-nodejs/src/auth/googleauth.ts @@ -44,7 +44,7 @@ import { } from './externalAccountAuthorizedUserClient'; import { GdchClient, - GDCH_CREDENTIALS_TYPE, + GDCH_SERVICE_ACCOUNT_TYPE, GdchCredentialsInput, } from './gdchclient'; import {originalOrCamelOptions} from '../util'; @@ -795,7 +795,7 @@ export class GoogleAuth { ...json, ...options, } as ExternalAccountAuthorizedUserClientOptions); - } else if (json.type === GDCH_CREDENTIALS_TYPE) { + } else if (json.type === GDCH_SERVICE_ACCOUNT_TYPE) { client = new GdchClient(options); client.fromJSON(json as GdchCredentialsInput); } else { diff --git a/core/packages/google-auth-library-nodejs/test/test.gdchclient.ts b/core/packages/google-auth-library-nodejs/test/test.gdchclient.ts index 057914aed609..266df56d8adb 100644 --- a/core/packages/google-auth-library-nodejs/test/test.gdchclient.ts +++ b/core/packages/google-auth-library-nodejs/test/test.gdchclient.ts @@ -18,7 +18,7 @@ import * as nock from 'nock'; import * as crypto from 'crypto'; import * as fs from 'fs'; import * as sinon from 'sinon'; -import {GdchClient, GDCH_CREDENTIALS_TYPE, GdchCredentialsInput} from '../src/auth/gdchclient'; +import {GdchClient, GDCH_SERVICE_ACCOUNT_TYPE, GdchCredentialsInput} from '../src/auth/gdchclient'; nock.disableNetConnect(); @@ -86,7 +86,7 @@ describe('GdchClient', () => { it('should parse JSON options via fromJSON() correctly', () => { const client = new GdchClient(); const json: GdchCredentialsInput = { - type: GDCH_CREDENTIALS_TYPE, + type: GDCH_SERVICE_ACCOUNT_TYPE, format_version: '1', project: 'test-project', private_key_id: 'key-id-123', @@ -115,13 +115,13 @@ describe('GdchClient', () => { assert.throws(() => { client.fromJSON(json); - }, /does not have the "gdch_credentials" type/); + }, /does not have the "gdch_service_account" type/); }); it('fromJSON() should throw error if format_version is unsupported', () => { const client = new GdchClient(); const json: GdchCredentialsInput = { - type: GDCH_CREDENTIALS_TYPE, + type: GDCH_SERVICE_ACCOUNT_TYPE, format_version: '2', project: 'p', private_key_id: 'k', @@ -146,7 +146,7 @@ describe('GdchClient', () => { mandatoryFields.forEach(field => { const json: Partial = { - type: GDCH_CREDENTIALS_TYPE, + type: GDCH_SERVICE_ACCOUNT_TYPE, format_version: '1', project: 'test-project', private_key_id: 'key-id-123', @@ -206,7 +206,7 @@ describe('GdchClient', () => { const scope = nock('https://token-server.local') .post('/token', (body) => { assert.strictEqual(body.audience, 'target-audience'); - assert.strictEqual(body.grant_type, 'urn:ietf:params:oauth:grant-type:token-exchange'); + assert.strictEqual(body.grant_type, 'urn:ietf:params:oauth:token-type:token-exchange'); assert.strictEqual(body.requested_token_type, 'urn:ietf:params:oauth:token-type:access_token'); assert.strictEqual(body.subject_token_type, 'urn:k8s:params:oauth:token-type:serviceaccount'); assert.ok(body.subject_token); @@ -338,7 +338,7 @@ describe('GdchClient', () => { assert.ok(err.message.includes('Permission denied')); return true; }); - nockScope.restore(); + nock.cleanAll(); assert.ok(readFileStub.calledOnce); }); diff --git a/core/packages/google-auth-library-nodejs/test/test.googleauth.ts b/core/packages/google-auth-library-nodejs/test/test.googleauth.ts index 23b1e82498f3..2551a2ebbf5b 100644 --- a/core/packages/google-auth-library-nodejs/test/test.googleauth.ts +++ b/core/packages/google-auth-library-nodejs/test/test.googleauth.ts @@ -550,9 +550,9 @@ describe('googleauth', () => { assert.strictEqual(300000, (result as JWT).eagerRefreshThresholdMillis); }); - it('fromJSON should create GdchClient for GDCH type credentials', () => { + it('fromJSON should create GdchClient for GDCH service account credentials', () => { const json = { - type: 'gdch_credentials', + type: 'gdch_service_account', format_version: '1', project: 'test-project', private_key_id: 'key-id-123', From a8f70af76012d19960c2d23c94985099f57357af Mon Sep 17 00:00:00 2001 From: macastelaz <34776182+macastelaz@users.noreply.github.com> Date: Thu, 21 May 2026 23:36:04 -0500 Subject: [PATCH 04/10] Update core/packages/google-auth-library-nodejs/src/auth/gdchclient.ts Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- core/packages/google-auth-library-nodejs/src/auth/gdchclient.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/packages/google-auth-library-nodejs/src/auth/gdchclient.ts b/core/packages/google-auth-library-nodejs/src/auth/gdchclient.ts index 6b9b494b4c64..e8a2e20ebc02 100644 --- a/core/packages/google-auth-library-nodejs/src/auth/gdchclient.ts +++ b/core/packages/google-auth-library-nodejs/src/auth/gdchclient.ts @@ -212,7 +212,7 @@ export class GdchClient extends OAuth2Client { }; if (tokenResponse.expires_in) { - tokens.expiry_date = new Date().getTime() + tokenResponse.expires_in * 1000; + tokens.expiry_date = Date.now() + tokenResponse.expires_in * 1000; } this.emit('tokens', tokens); From b2e7e41f69cebce77e3514a6879ef4470b07f366 Mon Sep 17 00:00:00 2001 From: macastelaz <34776182+macastelaz@users.noreply.github.com> Date: Fri, 22 May 2026 20:41:40 -0500 Subject: [PATCH 05/10] perf(auth): cache GDCH CA cert agent to avoid redundant file reads --- .../src/auth/gdchclient.ts | 46 ++++++--- .../test/test.gdchclient.ts | 98 +++++++++++++++++++ 2 files changed, 130 insertions(+), 14 deletions(-) diff --git a/core/packages/google-auth-library-nodejs/src/auth/gdchclient.ts b/core/packages/google-auth-library-nodejs/src/auth/gdchclient.ts index e8a2e20ebc02..cae0b9c83be3 100644 --- a/core/packages/google-auth-library-nodejs/src/auth/gdchclient.ts +++ b/core/packages/google-auth-library-nodejs/src/auth/gdchclient.ts @@ -58,6 +58,8 @@ export class GdchClient extends OAuth2Client { apiAudience?: string; lifetime: number; private gdchOptions: GdchClientOptions; + private caAgentPromise?: Promise; + private cachedCaCertPath?: string; constructor(options: GdchClientOptions = {}) { super(options); @@ -189,15 +191,7 @@ export class GdchClient extends OAuth2Client { }; if (this.caCertPath) { - try { - const ca = await fs.promises.readFile(this.caCertPath); - requestOpts.agent = new https.Agent({ ca }); - } catch (err) { - if (err instanceof Error) { - err.message = `Error reading certificate file from CA cert path, value '${this.caCertPath}': ${err.message}`; - } - throw err; - } + requestOpts.agent = await this.getCaAgent(); } try { @@ -264,17 +258,41 @@ export class GdchClient extends OAuth2Client { retry = false ): Promise> { if (this.caCertPath && !opts.agent) { + opts.agent = await this.getCaAgent(); + } + return super.requestAsync(opts, retry); + } + + private getCaAgent(): Promise | undefined { + if (!this.caCertPath) { + this.caAgentPromise = undefined; + this.cachedCaCertPath = undefined; + return undefined; + } + + if (this.caAgentPromise && this.caCertPath === this.cachedCaCertPath) { + return this.caAgentPromise; + } + + this.cachedCaCertPath = this.caCertPath; + const currentPath = this.caCertPath; + this.caAgentPromise = (async () => { try { - const ca = await fs.promises.readFile(this.caCertPath); - opts.agent = new https.Agent({ca}); + const ca = await fs.promises.readFile(currentPath); + return new https.Agent({ca}); } catch (err) { + if (this.cachedCaCertPath === currentPath) { + this.caAgentPromise = undefined; + this.cachedCaCertPath = undefined; + } if (err instanceof Error) { - err.message = `Error reading certificate file from CA cert path, value '${this.caCertPath}': ${err.message}`; + err.message = `Error reading certificate file from CA cert path, value '${currentPath}': ${err.message}`; } throw err; } - } - return super.requestAsync(opts, retry); + })(); + + return this.caAgentPromise; } private base64UrlEncode(str: string | Buffer): string { diff --git a/core/packages/google-auth-library-nodejs/test/test.gdchclient.ts b/core/packages/google-auth-library-nodejs/test/test.gdchclient.ts index 266df56d8adb..cc18c469a344 100644 --- a/core/packages/google-auth-library-nodejs/test/test.gdchclient.ts +++ b/core/packages/google-auth-library-nodejs/test/test.gdchclient.ts @@ -312,6 +312,104 @@ describe('GdchClient', () => { assert.strictEqual(res.token, 'ca-verified-token'); }); + it('should cache the CA cert agent and not reread the file or recreate the agent for subsequent requests', async () => { + const caCertPath = '/path/to/custom-ca.pem'; + const client = new GdchClient({ + projectId: 'test-project', + privateKeyId: 'key-id-123', + privateKey: privateKeyPemSec1, + serviceIdentityName: 'sa-name', + tokenServerUri: 'https://token-server.local/token', + apiAudience: 'target-audience', + caCertPath, + }); + + const readFileStub = sinon.stub(fs.promises, 'readFile').callsFake(async (path) => { + assert.strictEqual(path, caCertPath); + return Buffer.from('mock-ca-cert-content'); + }); + + const tokenScope = nock('https://token-server.local') + .post('/token') + .reply(200, { + access_token: 'ca-verified-token', + }); + + const apiScope = nock('https://api-server.local') + .get('/data') + .reply(200, { + data: 'foo', + }); + + // 1. First request - Token exchange (which reads caCertPath and sets agent) + const res = await client.getAccessToken(); + assert.strictEqual(res.token, 'ca-verified-token'); + tokenScope.done(); + + const opts: any = { + url: 'https://api-server.local/data', + method: 'GET', + }; + const apiRes = await client.requestAsync(opts); + assert.strictEqual(apiRes.status, 200); + apiScope.done(); + + // fs.promises.readFile should only be called once + assert.ok(readFileStub.calledOnce); + assert.ok(opts.agent); + }); + + it('should reload the CA cert if caCertPath changes', async () => { + const client = new GdchClient({ + projectId: 'test-project', + privateKeyId: 'key-id-123', + privateKey: privateKeyPemSec1, + serviceIdentityName: 'sa-name', + tokenServerUri: 'https://token-server.local/token', + apiAudience: 'target-audience', + caCertPath: '/path/to/first-ca.pem', + }); + + const tokenScope = nock('https://token-server.local') + .post('/token') + .reply(200, { + access_token: 'exchange-token-abc123', + expires_in: 3600, + }); + + const readFileStub = sinon.stub(fs.promises, 'readFile').callsFake(async (path) => { + return Buffer.from(`content-for-${path}`); + }); + + const apiScope1 = nock('https://api-server.local') + .get('/data1') + .reply(200, {}); + const apiScope2 = nock('https://api-server.local') + .get('/data2') + .reply(200, {}); + + const opts1: any = { + url: 'https://api-server.local/data1', + }; + await client.requestAsync(opts1); + apiScope1.done(); + tokenScope.done(); + const agent1 = opts1.agent; + + // Change the path + client.caCertPath = '/path/to/second-ca.pem'; + + const opts2: any = { + url: 'https://api-server.local/data2', + }; + await client.requestAsync(opts2); + apiScope2.done(); + const agent2 = opts2.agent; + + assert.ok(readFileStub.calledTwice); + assert.notStrictEqual(agent1, agent2); + }); + it('should raise helpful error message if CA cert file is unreadable', async () => { const caCertPath = '/path/to/custom-ca.pem'; const client = new GdchClient({ From 781ac2d593568ecccb03ef354a36309cc21c0ad7 Mon Sep 17 00:00:00 2001 From: macastelaz <34776182+macastelaz@users.noreply.github.com> Date: Tue, 26 May 2026 10:00:02 -0500 Subject: [PATCH 06/10] Use base64urldirectly instead of doing multiple replaces to account for url handling. This addresses PR feedback. --- .../src/auth/gdchclient.ts | 6 +----- .../test/test.gdchclient.ts | 20 +++++++++++++++++++ 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/core/packages/google-auth-library-nodejs/src/auth/gdchclient.ts b/core/packages/google-auth-library-nodejs/src/auth/gdchclient.ts index cae0b9c83be3..886973022c8c 100644 --- a/core/packages/google-auth-library-nodejs/src/auth/gdchclient.ts +++ b/core/packages/google-auth-library-nodejs/src/auth/gdchclient.ts @@ -297,10 +297,6 @@ export class GdchClient extends OAuth2Client { private base64UrlEncode(str: string | Buffer): string { const buffer = typeof str === 'string' ? Buffer.from(str) : str; - return buffer - .toString('base64') - .replace(/=/g, '') - .replace(/\+/g, '-') - .replace(/\//g, '_'); + return buffer.toString('base64url'); } } diff --git a/core/packages/google-auth-library-nodejs/test/test.gdchclient.ts b/core/packages/google-auth-library-nodejs/test/test.gdchclient.ts index cc18c469a344..878b3bf6edfb 100644 --- a/core/packages/google-auth-library-nodejs/test/test.gdchclient.ts +++ b/core/packages/google-auth-library-nodejs/test/test.gdchclient.ts @@ -484,4 +484,24 @@ describe('GdchClient', () => { }); scope.done(); }); + + describe('base64UrlEncode', () => { + it('should correctly encode strings and buffers in base64url format', () => { + const client = new GdchClient(); + const testCases = [ + {input: 'hello world', expected: 'aGVsbG8gd29ybGQ'}, + {input: 'foo bar baz', expected: 'Zm9vIGJhciBiYXo'}, + {input: 'this is a test', expected: 'dGhpcyBpcyBhIHRlc3Q'}, + {input: 'n>?', expected: 'bj4_'}, + {input: 'n>~', expected: 'bj5-'}, + ]; + + for (const tc of testCases) { + const stringResult = (client as any).base64UrlEncode(tc.input); + const bufferResult = (client as any).base64UrlEncode(Buffer.from(tc.input)); + assert.strictEqual(stringResult, tc.expected); + assert.strictEqual(bufferResult, tc.expected); + } + }); + }); }); From 7dfe5aec3143eb37b554f172920785bb55a09495 Mon Sep 17 00:00:00 2001 From: macastelaz <34776182+macastelaz@users.noreply.github.com> Date: Wed, 27 May 2026 19:42:00 -0500 Subject: [PATCH 07/10] test(auth): break up large gdch client test into smaller targeted tests --- .../test/test.gdchclient.ts | 62 +++++++++++++++++-- 1 file changed, 58 insertions(+), 4 deletions(-) diff --git a/core/packages/google-auth-library-nodejs/test/test.gdchclient.ts b/core/packages/google-auth-library-nodejs/test/test.gdchclient.ts index 878b3bf6edfb..ddbdc4c6625d 100644 --- a/core/packages/google-auth-library-nodejs/test/test.gdchclient.ts +++ b/core/packages/google-auth-library-nodejs/test/test.gdchclient.ts @@ -190,7 +190,7 @@ describe('GdchClient', () => { }, /Audience cannot be null or empty/); }); - it('should perform token exchange successfully with valid assertion signature (SEC1 key)', async () => { + it('should request token correctly', async () => { const client = new GdchClient({ projectId: 'test-project', privateKeyId: 'key-id-123', @@ -201,8 +201,6 @@ describe('GdchClient', () => { lifetime: 1800, }); - let interceptedAssertion = ''; - const scope = nock('https://token-server.local') .post('/token', (body) => { assert.strictEqual(body.audience, 'target-audience'); @@ -210,7 +208,6 @@ describe('GdchClient', () => { assert.strictEqual(body.requested_token_type, 'urn:ietf:params:oauth:token-type:access_token'); assert.strictEqual(body.subject_token_type, 'urn:k8s:params:oauth:token-type:serviceaccount'); assert.ok(body.subject_token); - interceptedAssertion = body.subject_token; return true; }) .reply(200, { @@ -224,6 +221,33 @@ describe('GdchClient', () => { assert.strictEqual(res.token, 'exchange-token-abc123'); assert.strictEqual(client.credentials.access_token, 'exchange-token-abc123'); assert.ok(client.credentials.expiry_date); + }); + + it('should generate assertion signature with correct header and payload properties', async () => { + const client = new GdchClient({ + projectId: 'test-project', + privateKeyId: 'key-id-123', + privateKey: privateKeyPemSec1, + serviceIdentityName: 'sa-name', + tokenServerUri: 'https://token-server.local/token', + apiAudience: 'target-audience', + lifetime: 1800, + }); + + let interceptedAssertion = ''; + + const scope = nock('https://token-server.local') + .post('/token', (body) => { + interceptedAssertion = body.subject_token; + return true; + }) + .reply(200, { + access_token: 'exchange-token-abc123', + expires_in: 3600, + }); + + await client.getAccessToken(); + scope.done(); // Validate assertion signature const parts = interceptedAssertion.split('.'); @@ -241,6 +265,36 @@ describe('GdchClient', () => { assert.strictEqual(payload.aud, 'https://token-server.local/token'); assert.ok(payload.iat); assert.strictEqual(payload.exp, payload.iat + 1800); + }); + + it('should generate assertion signature that can be verified with the public key', async () => { + const client = new GdchClient({ + projectId: 'test-project', + privateKeyId: 'key-id-123', + privateKey: privateKeyPemSec1, + serviceIdentityName: 'sa-name', + tokenServerUri: 'https://token-server.local/token', + apiAudience: 'target-audience', + lifetime: 1800, + }); + + let interceptedAssertion = ''; + + const scope = nock('https://token-server.local') + .post('/token', (body) => { + interceptedAssertion = body.subject_token; + return true; + }) + .reply(200, { + access_token: 'exchange-token-abc123', + expires_in: 3600, + }); + + await client.getAccessToken(); + scope.done(); + + const parts = interceptedAssertion.split('.'); + assert.strictEqual(parts.length, 3); // Verify Signature using the Public Key const signingInput = `${parts[0]}.${parts[1]}`; From 81be49f1a204a55a4d0785fc198a8d9535709b99 Mon Sep 17 00:00:00 2001 From: macastelaz <34776182+macastelaz@users.noreply.github.com> Date: Thu, 28 May 2026 16:04:42 -0500 Subject: [PATCH 08/10] feat(auth): address PR 8301 review comments for GDCH client --- .../src/auth/gdchclient.ts | 38 ++-- .../src/auth/googleauth.ts | 26 ++- .../google-auth-library-nodejs/src/index.ts | 2 + .../test/test.gdchclient.ts | 164 +++++++++++++----- .../test/test.googleauth.ts | 77 ++++++++ 5 files changed, 248 insertions(+), 59 deletions(-) diff --git a/core/packages/google-auth-library-nodejs/src/auth/gdchclient.ts b/core/packages/google-auth-library-nodejs/src/auth/gdchclient.ts index 886973022c8c..fa65d9c296e1 100644 --- a/core/packages/google-auth-library-nodejs/src/auth/gdchclient.ts +++ b/core/packages/google-auth-library-nodejs/src/auth/gdchclient.ts @@ -60,6 +60,8 @@ export class GdchClient extends OAuth2Client { private gdchOptions: GdchClientOptions; private caAgentPromise?: Promise; private cachedCaCertPath?: string; + private lastCaCertReadTime = 0; + private readonly CA_CERT_TTL_MS = 5 * 60 * 1000; constructor(options: GdchClientOptions = {}) { super(options); @@ -211,7 +213,20 @@ export class GdchClient extends OAuth2Client { this.emit('tokens', tokens); return {res, tokens}; - } catch (e) { + } catch (e: any) { + if (e && e.config && e.config.data) { + try { + if (typeof e.config.data === 'string') { + const parsedData = JSON.parse(e.config.data); + if (parsedData.subject_token) { + parsedData.subject_token = '***REDACTED***'; + e.config.data = JSON.stringify(parsedData); + } + } else if (typeof e.config.data === 'object' && e.config.data.subject_token) { + e.config.data.subject_token = '***REDACTED***'; + } + } catch {} + } if (e instanceof Error) { e.message = `Error getting access token for GDCH service account: ${e.message}, iss: ${this.serviceIdentityName}`; } @@ -253,28 +268,28 @@ export class GdchClient extends OAuth2Client { return `${signingInput}.${encodedSignature}`; } - override async requestAsync( - opts: GaxiosOptions, - retry = false - ): Promise> { - if (this.caCertPath && !opts.agent) { - opts.agent = await this.getCaAgent(); - } - return super.requestAsync(opts, retry); - } private getCaAgent(): Promise | undefined { if (!this.caCertPath) { this.caAgentPromise = undefined; this.cachedCaCertPath = undefined; + this.lastCaCertReadTime = 0; return undefined; } - if (this.caAgentPromise && this.caCertPath === this.cachedCaCertPath) { + const now = Date.now(); + const isCacheExpired = now - this.lastCaCertReadTime > this.CA_CERT_TTL_MS; + + if ( + this.caAgentPromise && + this.caCertPath === this.cachedCaCertPath && + !isCacheExpired + ) { return this.caAgentPromise; } this.cachedCaCertPath = this.caCertPath; + this.lastCaCertReadTime = now; const currentPath = this.caCertPath; this.caAgentPromise = (async () => { try { @@ -284,6 +299,7 @@ export class GdchClient extends OAuth2Client { if (this.cachedCaCertPath === currentPath) { this.caAgentPromise = undefined; this.cachedCaCertPath = undefined; + this.lastCaCertReadTime = 0; } if (err instanceof Error) { err.message = `Error reading certificate file from CA cert path, value '${currentPath}': ${err.message}`; diff --git a/core/packages/google-auth-library-nodejs/src/auth/googleauth.ts b/core/packages/google-auth-library-nodejs/src/auth/googleauth.ts index 2ae53c9ce6e2..86e4cb4dd358 100644 --- a/core/packages/google-auth-library-nodejs/src/auth/googleauth.ts +++ b/core/packages/google-auth-library-nodejs/src/auth/googleauth.ts @@ -180,7 +180,7 @@ export interface GoogleAuthOptions { * * For more details, see https://cloud.google.com/docs/authentication/external/externally-sourced-credentials. */ - credentials?: JWTInput | ExternalAccountClientOptions; + credentials?: JWTInput | ExternalAccountClientOptions | GdchCredentialsInput; /** * `AuthClientOptions` object passed to the constructor of the client @@ -248,7 +248,7 @@ export class GoogleAuth { private _cachedProjectId?: string | null; // To save the contents of the JSON credential file - jsonContent: JWTInput | ExternalAccountClientOptions | null = null; + jsonContent: JWTInput | ExternalAccountClientOptions | GdchCredentialsInput | null = null; apiKey: string | null; cachedCredential: AnyAuthClient | T | null = null; @@ -770,7 +770,7 @@ export class GoogleAuth { * @returns JWT or UserRefresh Client with data */ fromJSON( - json: JWTInput | ImpersonatedJWTInput | GdchCredentialsInput, + json: JWTInput | ImpersonatedJWTInput | GdchCredentialsInput | ExternalAccountClientOptions, options: AuthClientOptions = {}, ): JSONClient { let client: JSONClient; @@ -781,7 +781,7 @@ export class GoogleAuth { if (json.type === USER_REFRESH_ACCOUNT_TYPE) { client = new UserRefreshClient(options); - client.fromJSON(json); + client.fromJSON(json as JWTInput); } else if (json.type === IMPERSONATED_ACCOUNT_TYPE) { client = this.fromImpersonatedJSON(json as ImpersonatedJWTInput); } else if (json.type === EXTERNAL_ACCOUNT_TYPE) { @@ -820,7 +820,7 @@ export class GoogleAuth { * @returns JWT or UserRefresh Client with data */ private _cacheClientFromJSON( - json: JWTInput | ImpersonatedJWTInput, + json: JWTInput | ImpersonatedJWTInput | GdchCredentialsInput | ExternalAccountClientOptions, options?: AuthClientOptions, ): JSONClient { const client = this.fromJSON(json, options); @@ -1118,7 +1118,7 @@ export class GoogleAuth { return { client_email: (this.jsonContent as JWTInput).client_email, private_key: (this.jsonContent as JWTInput).private_key, - universe_domain: this.jsonContent.universe_domain, + universe_domain: (this.jsonContent as any).universe_domain, }; } @@ -1149,7 +1149,19 @@ export class GoogleAuth { this.#pendingAuthClient || this.#determineClient(); try { - return await this.#pendingAuthClient; + const client = await this.#pendingAuthClient; + if (client instanceof GdchClient && !client.apiAudience) { + const opts = this.clientOptions as any; + const endpoint = opts.apiEndpoint || opts.servicePath; + if (endpoint) { + const scheme = endpoint.startsWith('http') ? '' : 'https://'; + const formattedAudience = `${scheme}${endpoint}/`.replace(/\/+$/, '/'); + const newClient = client.createWithGdchAudience(formattedAudience); + this.cachedCredential = newClient; + return newClient; + } + } + return client; } finally { // reset the pending auth client in case it is changed later this.#pendingAuthClient = null; diff --git a/core/packages/google-auth-library-nodejs/src/index.ts b/core/packages/google-auth-library-nodejs/src/index.ts index f1c81ed1e2b5..c0dcff761859 100644 --- a/core/packages/google-auth-library-nodejs/src/index.ts +++ b/core/packages/google-auth-library-nodejs/src/index.ts @@ -95,6 +95,8 @@ export {PassThroughClient} from './auth/passthrough'; export { GdchClient, GdchClientOptions, + GdchCredentialsInput, + GDCH_SERVICE_ACCOUNT_TYPE, } from './auth/gdchclient'; export * from './gtoken/googleToken'; diff --git a/core/packages/google-auth-library-nodejs/test/test.gdchclient.ts b/core/packages/google-auth-library-nodejs/test/test.gdchclient.ts index ddbdc4c6625d..cef71c0da228 100644 --- a/core/packages/google-auth-library-nodejs/test/test.gdchclient.ts +++ b/core/packages/google-auth-library-nodejs/test/test.gdchclient.ts @@ -366,7 +366,7 @@ describe('GdchClient', () => { assert.strictEqual(res.token, 'ca-verified-token'); }); - it('should cache the CA cert agent and not reread the file or recreate the agent for subsequent requests', async () => { + it('should cache the CA cert agent and not reread the file or recreate the agent for subsequent token refreshes', async () => { const caCertPath = '/path/to/custom-ca.pem'; const client = new GdchClient({ projectId: 'test-project', @@ -383,34 +383,35 @@ describe('GdchClient', () => { return Buffer.from('mock-ca-cert-content'); }); - const tokenScope = nock('https://token-server.local') + const tokenScope1 = nock('https://token-server.local') .post('/token') .reply(200, { - access_token: 'ca-verified-token', + access_token: 'ca-verified-token-1', + expires_in: 3600, }); - const apiScope = nock('https://api-server.local') - .get('/data') + const tokenScope2 = nock('https://token-server.local') + .post('/token') .reply(200, { - data: 'foo', + access_token: 'ca-verified-token-2', + expires_in: 3600, }); - // 1. First request - Token exchange (which reads caCertPath and sets agent) - const res = await client.getAccessToken(); - assert.strictEqual(res.token, 'ca-verified-token'); - tokenScope.done(); + // 1. First token exchange + const res1 = await client.getAccessToken(); + assert.strictEqual(res1.token, 'ca-verified-token-1'); + tokenScope1.done(); - const opts: any = { - url: 'https://api-server.local/data', - method: 'GET', - }; - const apiRes = await client.requestAsync(opts); - assert.strictEqual(apiRes.status, 200); - apiScope.done(); + // Force expiry to trigger second refresh + client.credentials.expiry_date = 1; - // fs.promises.readFile should only be called once + // 2. Second token exchange + const res2 = await client.getAccessToken(); + assert.strictEqual(res2.token, 'ca-verified-token-2'); + tokenScope2.done(); + + // fs.promises.readFile should only be called once because the agent was cached! assert.ok(readFileStub.calledOnce); - assert.ok(opts.agent); }); it('should reload the CA cert if caCertPath changes', async () => { @@ -424,7 +425,7 @@ describe('GdchClient', () => { caCertPath: '/path/to/first-ca.pem', }); - const tokenScope = nock('https://token-server.local') + const tokenScope1 = nock('https://token-server.local') .post('/token') .reply(200, { access_token: 'exchange-token-abc123', @@ -435,33 +436,87 @@ describe('GdchClient', () => { return Buffer.from(`content-for-${path}`); }); - const apiScope1 = nock('https://api-server.local') - .get('/data1') - .reply(200, {}); - const apiScope2 = nock('https://api-server.local') - .get('/data2') - .reply(200, {}); + const tokenScope2 = nock('https://token-server.local') + .post('/token') + .reply(200, { + access_token: 'exchange-token-xyz789', + expires_in: 3600, + }); - const opts1: any = { - url: 'https://api-server.local/data1', - }; - await client.requestAsync(opts1); - apiScope1.done(); - tokenScope.done(); - const agent1 = opts1.agent; + // 1. First refresh + const res1 = await client.getAccessToken(); + assert.strictEqual(res1.token, 'exchange-token-abc123'); + tokenScope1.done(); - // Change the path + // Change the path and force expiry client.caCertPath = '/path/to/second-ca.pem'; + client.credentials.expiry_date = 1; - const opts2: any = { - url: 'https://api-server.local/data2', - }; - await client.requestAsync(opts2); - apiScope2.done(); - const agent2 = opts2.agent; + // 2. Second refresh + const res2 = await client.getAccessToken(); + assert.strictEqual(res2.token, 'exchange-token-xyz789'); + tokenScope2.done(); assert.ok(readFileStub.calledTwice); - assert.notStrictEqual(agent1, agent2); + }); + + it('should reread the CA cert file if CA_CERT_TTL_MS has expired', async () => { + const caCertPath = '/path/to/custom-ca.pem'; + const client = new GdchClient({ + projectId: 'test-project', + privateKeyId: 'key-id-123', + privateKey: privateKeyPemSec1, + serviceIdentityName: 'sa-name', + tokenServerUri: 'https://token-server.local/token', + apiAudience: 'target-audience', + caCertPath, + }); + + const readFileStub = sinon.stub(fs.promises, 'readFile').callsFake(async (path) => { + assert.strictEqual(path, caCertPath); + return Buffer.from('mock-ca-cert-content'); + }); + + const tokenScope1 = nock('https://token-server.local') + .post('/token') + .reply(200, { + access_token: 'token-1', + expires_in: 3600, + }); + + const tokenScope2 = nock('https://token-server.local') + .post('/token') + .reply(200, { + access_token: 'token-2', + expires_in: 3600, + }); + + let nowTime = Date.now(); + const dateNowStub = sinon.stub(Date, 'now').callsFake(() => nowTime); + + try { + // 1. First token exchange + const res1 = await client.getAccessToken(); + assert.strictEqual(res1.token, 'token-1'); + tokenScope1.done(); + assert.ok(readFileStub.calledOnce); + + // Force token expiry + client.credentials.expiry_date = 1; + + // Fast-forward time by 5 minutes and 1 second (300001 ms) to expire the cert cache + nowTime += 5 * 60 * 1000 + 1; + + // 2. Second token exchange after cert cache expiration + const res2 = await client.getAccessToken(); + assert.strictEqual(res2.token, 'token-2'); + tokenScope2.done(); + + // File should be read a second time! + assert.ok(readFileStub.calledTwice); + } finally { + dateNowStub.restore(); + } }); it('should raise helpful error message if CA cert file is unreadable', async () => { @@ -539,6 +594,33 @@ describe('GdchClient', () => { scope.done(); }); + it('should redact subject_token in error response on token exchange failure', async () => { + const client = new GdchClient({ + projectId: 'test-project', + privateKeyId: 'key-id-123', + privateKey: privateKeyPemSec1, + serviceIdentityName: 'sa-name', + tokenServerUri: 'https://token-server.local/token', + apiAudience: 'target-audience', + }); + + const scope = nock('https://token-server.local') + .post('/token') + .reply(400, 'Bad Request'); + + await assert.rejects(client.getAccessToken(), (err: any) => { + assert.ok(err.message.includes('Error getting access token for GDCH service account')); + assert.ok(err.config !== undefined); + assert.ok(err.config.data !== undefined); + const parsedData = typeof err.config.data === 'string' + ? JSON.parse(err.config.data) + : err.config.data; + assert.strictEqual(parsedData.subject_token, '***REDACTED***'); + return true; + }); + scope.done(); + }); + describe('base64UrlEncode', () => { it('should correctly encode strings and buffers in base64url format', () => { const client = new GdchClient(); diff --git a/core/packages/google-auth-library-nodejs/test/test.googleauth.ts b/core/packages/google-auth-library-nodejs/test/test.googleauth.ts index 2551a2ebbf5b..350b326be6e4 100644 --- a/core/packages/google-auth-library-nodejs/test/test.googleauth.ts +++ b/core/packages/google-auth-library-nodejs/test/test.googleauth.ts @@ -3008,4 +3008,81 @@ describe('googleauth', () => { (jwt as JWT).gtoken!.googleTokenOptions.scope, ); }); + + describe('GdchClient automatic audience resolution in getClient()', () => { + const gdchJson = { + type: 'gdch_service_account', + format_version: '1', + project: 'test-project', + private_key_id: 'key-id-123', + private_key: 'private-key-pem-content', + name: 'sa-name', + token_uri: 'https://token-server.local/token', + }; + + it('should dynamically resolve audience using apiEndpoint in clientOptions if missing', async () => { + const auth = new GoogleAuth({ + credentials: gdchJson, + clientOptions: { + apiEndpoint: 'hardwaremanagement.us-west1.gdch.google.com', + } as any, + }); + + const client = await auth.getClient(); + assert.ok(client instanceof GdchClient); + assert.strictEqual( + (client as GdchClient).apiAudience, + 'https://hardwaremanagement.us-west1.gdch.google.com/' + ); + }); + + it('should dynamically resolve audience using servicePath in clientOptions if missing', async () => { + const auth = new GoogleAuth({ + credentials: gdchJson, + clientOptions: { + servicePath: 'hardwaremanagement.us-west1.gdch.google.com', + } as any, + }); + + const client = await auth.getClient(); + assert.ok(client instanceof GdchClient); + assert.strictEqual( + (client as GdchClient).apiAudience, + 'https://hardwaremanagement.us-west1.gdch.google.com/' + ); + }); + + it('should format audience url correctly if it has http/https scheme or trailing slashes', async () => { + const auth = new GoogleAuth({ + credentials: gdchJson, + clientOptions: { + apiEndpoint: 'http://hardwaremanagement.us-west1.gdch.google.com///', + } as any, + }); + + const client = await auth.getClient(); + assert.ok(client instanceof GdchClient); + assert.strictEqual( + (client as GdchClient).apiAudience, + 'http://hardwaremanagement.us-west1.gdch.google.com/' + ); + }); + + it('should keep explicit apiAudience if already provided', async () => { + const auth = new GoogleAuth({ + credentials: gdchJson, + clientOptions: { + apiAudience: 'https://explicit-audience.local/', + apiEndpoint: 'hardwaremanagement.us-west1.gdch.google.com', + } as any, + }); + + const client = await auth.getClient(); + assert.ok(client instanceof GdchClient); + assert.strictEqual( + (client as GdchClient).apiAudience, + 'https://explicit-audience.local/' + ); + }); + }); }); From a91f687c0ff4853a98e217f7bb9b7d723535ca6e Mon Sep 17 00:00:00 2001 From: macastelaz <34776182+macastelaz@users.noreply.github.com> Date: Thu, 28 May 2026 16:24:19 -0500 Subject: [PATCH 09/10] feat(auth): conditionally inject CA cert agent into requestAsync for private GDCH endpoints --- .../src/auth/gdchclient.ts | 13 +++ .../test/test.gdchclient.ts | 81 +++++++++++++++++++ 2 files changed, 94 insertions(+) diff --git a/core/packages/google-auth-library-nodejs/src/auth/gdchclient.ts b/core/packages/google-auth-library-nodejs/src/auth/gdchclient.ts index fa65d9c296e1..36a261cd9945 100644 --- a/core/packages/google-auth-library-nodejs/src/auth/gdchclient.ts +++ b/core/packages/google-auth-library-nodejs/src/auth/gdchclient.ts @@ -269,6 +269,19 @@ export class GdchClient extends OAuth2Client { } + override async requestAsync( + opts: GaxiosOptions, + retry = false + ): Promise> { + if (this.caCertPath && !opts.agent) { + const url = (opts.url || '').toString(); + if (!url.includes('googleapis.com') && !url.includes('google.com')) { + opts.agent = await this.getCaAgent(); + } + } + return super.requestAsync(opts, retry); + } + private getCaAgent(): Promise | undefined { if (!this.caCertPath) { this.caAgentPromise = undefined; diff --git a/core/packages/google-auth-library-nodejs/test/test.gdchclient.ts b/core/packages/google-auth-library-nodejs/test/test.gdchclient.ts index cef71c0da228..0e65ed3881eb 100644 --- a/core/packages/google-auth-library-nodejs/test/test.gdchclient.ts +++ b/core/packages/google-auth-library-nodejs/test/test.gdchclient.ts @@ -621,6 +621,87 @@ describe('GdchClient', () => { scope.done(); }); + describe('requestAsync', () => { + it('should inject the CA agent for private/local GDCH API requests', async () => { + const caCertPath = '/path/to/custom-ca.pem'; + const client = new GdchClient({ + projectId: 'test-project', + privateKeyId: 'key-id-123', + privateKey: privateKeyPemSec1, + serviceIdentityName: 'sa-name', + tokenServerUri: 'https://token-server.local/token', + apiAudience: 'target-audience', + caCertPath, + }); + + // Set active mock token to prevent refresh request + client.credentials = { + access_token: 'valid-active-mock-token', + expiry_date: Date.now() + 1000000, + }; + + const readFileStub = sinon.stub(fs.promises, 'readFile').callsFake(async (path) => { + assert.strictEqual(path, caCertPath); + return Buffer.from('mock-ca-cert-content'); + }); + + const apiScope = nock('https://api-server.local') + .get('/data') + .reply(200, {}); + + const opts: any = { + url: 'https://api-server.local/data', + method: 'GET', + }; + + const res = await (client as any).requestAsync(opts); + assert.strictEqual(res.status, 200); + apiScope.done(); + + assert.ok(readFileStub.calledOnce); + assert.ok(opts.agent !== undefined); + }); + + it('should NOT inject the CA agent for standard public Google API requests', async () => { + const caCertPath = '/path/to/custom-ca.pem'; + const client = new GdchClient({ + projectId: 'test-project', + privateKeyId: 'key-id-123', + privateKey: privateKeyPemSec1, + serviceIdentityName: 'sa-name', + tokenServerUri: 'https://token-server.local/token', + apiAudience: 'target-audience', + caCertPath, + }); + + // Set active mock token to prevent refresh request + client.credentials = { + access_token: 'valid-active-mock-token', + expiry_date: Date.now() + 1000000, + }; + + const readFileStub = sinon.stub(fs.promises, 'readFile').callsFake(async (path) => { + return Buffer.from('mock-ca-cert-content'); + }); + + const googleScope = nock('https://storage.googleapis.com') + .get('/bucket/data') + .reply(200, {}); + + const opts: any = { + url: 'https://storage.googleapis.com/bucket/data', + method: 'GET', + }; + + const res = await (client as any).requestAsync(opts); + assert.strictEqual(res.status, 200); + googleScope.done(); + + assert.ok(readFileStub.notCalled); + assert.strictEqual(opts.agent, undefined); + }); + }); + describe('base64UrlEncode', () => { it('should correctly encode strings and buffers in base64url format', () => { const client = new GdchClient(); From d885a6e5337efcebe639ba71b3c4ad7e46e5339c Mon Sep 17 00:00:00 2001 From: macastelaz <34776182+macastelaz@users.noreply.github.com> Date: Thu, 28 May 2026 18:06:17 -0500 Subject: [PATCH 10/10] feat(auth): implement private key/token redaction and STS token request timeout/retry on GdchClient --- .../src/auth/gdchclient.ts | 23 ++++++ .../test/test.gdchclient.ts | 80 +++++++++++++++++++ 2 files changed, 103 insertions(+) diff --git a/core/packages/google-auth-library-nodejs/src/auth/gdchclient.ts b/core/packages/google-auth-library-nodejs/src/auth/gdchclient.ts index 36a261cd9945..1bb64f13131e 100644 --- a/core/packages/google-auth-library-nodejs/src/auth/gdchclient.ts +++ b/core/packages/google-auth-library-nodejs/src/auth/gdchclient.ts @@ -190,6 +190,13 @@ export class GdchClient extends OAuth2Client { }, data, responseType: 'json', + timeout: 10000, + retry: true, + retryConfig: { + httpMethodsToRetry: ['POST'], + statusCodesToRetry: [[500, 599]], + noResponseRetries: 3, + }, }; if (this.caCertPath) { @@ -324,6 +331,22 @@ export class GdchClient extends OAuth2Client { return this.caAgentPromise; } + toJSON(): Record { + return { + ...this, + privateKey: this.privateKey ? '***REDACTED***' : undefined, + credentials: { + ...this.credentials, + access_token: this.credentials?.access_token ? '***REDACTED***' : undefined, + refresh_token: this.credentials?.refresh_token ? '***REDACTED***' : undefined, + }, + }; + } + + [Symbol.for('nodejs.util.inspect.custom')]() { + return this.toJSON(); + } + private base64UrlEncode(str: string | Buffer): string { const buffer = typeof str === 'string' ? Buffer.from(str) : str; return buffer.toString('base64url'); diff --git a/core/packages/google-auth-library-nodejs/test/test.gdchclient.ts b/core/packages/google-auth-library-nodejs/test/test.gdchclient.ts index 0e65ed3881eb..6cd1db66fec9 100644 --- a/core/packages/google-auth-library-nodejs/test/test.gdchclient.ts +++ b/core/packages/google-auth-library-nodejs/test/test.gdchclient.ts @@ -223,6 +223,40 @@ describe('GdchClient', () => { assert.ok(client.credentials.expiry_date); }); + it('should request token with configured timeout and retry settings', async () => { + const client = new GdchClient({ + projectId: 'test-project', + privateKeyId: 'key-id-123', + privateKey: privateKeyPemSec1, + serviceIdentityName: 'sa-name', + tokenServerUri: 'https://token-server.local/token', + apiAudience: 'target-audience', + }); + + const requestStub = sinon.stub(client.transporter, 'request').resolves({ + data: { + access_token: 'mocked-token', + expires_in: 3600, + }, + status: 200, + statusText: 'OK', + headers: {}, + config: {}, + } as any); + + await client.getAccessToken(); + + assert.ok(requestStub.calledOnce); + const requestOpts = requestStub.firstCall.args[0] as any; + assert.strictEqual(requestOpts.timeout, 10000); + assert.strictEqual(requestOpts.retry, true); + assert.deepStrictEqual(requestOpts.retryConfig, { + httpMethodsToRetry: ['POST'], + statusCodesToRetry: [[500, 599]], + noResponseRetries: 3, + }); + }); + it('should generate assertion signature with correct header and payload properties', async () => { const client = new GdchClient({ projectId: 'test-project', @@ -702,6 +736,52 @@ describe('GdchClient', () => { }); }); + describe('serialization and logging safety', () => { + it('should redact private key and credentials in toJSON() serialization', () => { + const client = new GdchClient({ + projectId: 'test-project', + privateKeyId: 'key-id-123', + privateKey: 'raw-secret-private-key', + serviceIdentityName: 'sa-name', + }); + + client.credentials = { + access_token: 'secret-access-token-abc123', + refresh_token: 'secret-refresh-token-xyz789', + }; + + const serialized = client.toJSON(); + + assert.strictEqual(serialized.projectId, 'test-project'); + assert.strictEqual(serialized.privateKeyId, 'key-id-123'); + assert.strictEqual(serialized.privateKey, '***REDACTED***'); + assert.strictEqual(serialized.credentials.access_token, '***REDACTED***'); + assert.strictEqual(serialized.credentials.refresh_token, '***REDACTED***'); + }); + + it('should redact private key and credentials in custom inspect console output', () => { + const client = new GdchClient({ + projectId: 'test-project', + privateKeyId: 'key-id-123', + privateKey: 'raw-secret-private-key', + serviceIdentityName: 'sa-name', + }); + + client.credentials = { + access_token: 'secret-access-token-abc123', + refresh_token: 'secret-refresh-token-xyz789', + }; + + const customInspectSymbol = Symbol.for('nodejs.util.inspect.custom'); + const inspected = (client as any)[customInspectSymbol](); + + assert.strictEqual(inspected.projectId, 'test-project'); + assert.strictEqual(inspected.privateKey, '***REDACTED***'); + assert.strictEqual(inspected.credentials.access_token, '***REDACTED***'); + assert.strictEqual(inspected.credentials.refresh_token, '***REDACTED***'); + }); + }); + describe('base64UrlEncode', () => { it('should correctly encode strings and buffers in base64url format', () => { const client = new GdchClient();