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..1bb64f13131e --- /dev/null +++ b/core/packages/google-auth-library-nodejs/src/auth/gdchclient.ts @@ -0,0 +1,354 @@ +// 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, GaxiosResponse} from 'gaxios'; +import { + GetTokenResponse, + OAuth2Client, + OAuth2ClientOptions, +} from './oauth2client'; +import {CredentialRequest, Credentials} from './credentials'; + +const DEFAULT_LIFETIME_IN_SECONDS = 3600; +export const GDCH_SERVICE_ACCOUNT_TYPE = 'gdch_service_account'; + +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_service_account'; + 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; + private caAgentPromise?: Promise; + private cachedCaCertPath?: string; + private lastCaCertReadTime = 0; + private readonly CA_CERT_TTL_MS = 5 * 60 * 1000; + + 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_SERVICE_ACCOUNT_TYPE) { + throw new Error( + `The incoming JSON object does not have the "${GDCH_SERVICE_ACCOUNT_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; + + 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 { + 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: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', + }; + + const requestOpts: GaxiosOptions = { + url: this.tokenServerUri, + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + data, + responseType: 'json', + timeout: 10000, + retry: true, + retryConfig: { + httpMethodsToRetry: ['POST'], + statusCodesToRetry: [[500, 599]], + noResponseRetries: 3, + }, + }; + + if (this.caCertPath) { + requestOpts.agent = await this.getCaAgent(); + } + + 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: 'STS-Bearer', + }; + + if (tokenResponse.expires_in) { + tokens.expiry_date = Date.now() + tokenResponse.expires_in * 1000; + } + + this.emit('tokens', tokens); + return {res, tokens}; + } 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}`; + } + 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}`; + } + + + 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; + this.cachedCaCertPath = undefined; + this.lastCaCertReadTime = 0; + return undefined; + } + + 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 { + const ca = await fs.promises.readFile(currentPath); + return new https.Agent({ca}); + } catch (err) { + 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}`; + } + throw err; + } + })(); + + 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/src/auth/googleauth.ts b/core/packages/google-auth-library-nodejs/src/auth/googleauth.ts index 4d9832c67f20..86e4cb4dd358 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_SERVICE_ACCOUNT_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; @@ -174,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 @@ -242,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; @@ -764,7 +770,7 @@ export class GoogleAuth { * @returns JWT or UserRefresh Client with data */ fromJSON( - json: JWTInput | ImpersonatedJWTInput, + json: JWTInput | ImpersonatedJWTInput | GdchCredentialsInput | ExternalAccountClientOptions, options: AuthClientOptions = {}, ): JSONClient { let client: JSONClient; @@ -775,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) { @@ -789,11 +795,14 @@ export class GoogleAuth { ...json, ...options, } as ExternalAccountAuthorizedUserClientOptions); + } else if (json.type === GDCH_SERVICE_ACCOUNT_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) { @@ -811,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); @@ -1109,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, }; } @@ -1140,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 5eabfea9c70a..c0dcff761859 100644 --- a/core/packages/google-auth-library-nodejs/src/index.ts +++ b/core/packages/google-auth-library-nodejs/src/index.ts @@ -92,6 +92,12 @@ export { ExternalAccountAuthorizedUserClientOptions, } from './auth/externalAccountAuthorizedUserClient'; export {PassThroughClient} from './auth/passthrough'; +export { + GdchClient, + GdchClientOptions, + GdchCredentialsInput, + GDCH_SERVICE_ACCOUNT_TYPE, +} 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..6cd1db66fec9 --- /dev/null +++ b/core/packages/google-auth-library-nodejs/test/test.gdchclient.ts @@ -0,0 +1,804 @@ +// 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_SERVICE_ACCOUNT_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_SERVICE_ACCOUNT_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_service_account" type/); + }); + + it('fromJSON() should throw error if format_version is unsupported', () => { + const client = new GdchClient(); + const json: GdchCredentialsInput = { + type: GDCH_SERVICE_ACCOUNT_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_SERVICE_ACCOUNT_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 request token correctly', 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, + }); + + 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: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); + 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); + }); + + 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', + 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('.'); + 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); + }); + + 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]}`; + 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.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'); + }); + + 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(readFileStub.calledOnce); + 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 token refreshes', 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: 'ca-verified-token-1', + expires_in: 3600, + }); + + const tokenScope2 = nock('https://token-server.local') + .post('/token') + .reply(200, { + access_token: 'ca-verified-token-2', + expires_in: 3600, + }); + + // 1. First token exchange + const res1 = await client.getAccessToken(); + assert.strictEqual(res1.token, 'ca-verified-token-1'); + tokenScope1.done(); + + // Force expiry to trigger second refresh + client.credentials.expiry_date = 1; + + // 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); + }); + + 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 tokenScope1 = 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 tokenScope2 = nock('https://token-server.local') + .post('/token') + .reply(200, { + access_token: 'exchange-token-xyz789', + expires_in: 3600, + }); + + // 1. First refresh + const res1 = await client.getAccessToken(); + assert.strictEqual(res1.token, 'exchange-token-abc123'); + tokenScope1.done(); + + // Change the path and force expiry + client.caCertPath = '/path/to/second-ca.pem'; + client.credentials.expiry_date = 1; + + // 2. Second refresh + const res2 = await client.getAccessToken(); + assert.strictEqual(res2.token, 'exchange-token-xyz789'); + tokenScope2.done(); + + assert.ok(readFileStub.calledTwice); + }); + + 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 () => { + 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; + }); + nock.cleanAll(); + 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', + 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(); + }); + + 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('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('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(); + 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); + } + }); + }); +}); 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..350b326be6e4 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 service account credentials', () => { + const json = { + 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', + }; + 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) => { @@ -2991,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/' + ); + }); + }); });