From 62668a74e8add5e7b3fd521d460ef0a3bf68bf11 Mon Sep 17 00:00:00 2001 From: Daniel Bankhead Date: Thu, 9 Nov 2023 17:22:18 -0800 Subject: [PATCH 1/6] feat: Get `universe_domain` for Compute clients --- src/auth/credentials.ts | 2 ++ src/auth/googleauth.ts | 28 +++++++++++++++++++--------- 2 files changed, 21 insertions(+), 9 deletions(-) diff --git a/src/auth/credentials.ts b/src/auth/credentials.ts index ce149b63..f0d3876f 100644 --- a/src/auth/credentials.ts +++ b/src/auth/credentials.ts @@ -76,6 +76,7 @@ export interface JWTInput { client_secret?: string; refresh_token?: string; quota_project_id?: string; + universe_domain?: string; } export interface ImpersonatedJWTInput { @@ -88,4 +89,5 @@ export interface ImpersonatedJWTInput { export interface CredentialBody { client_email?: string; private_key?: string; + universe_domain?: string; } diff --git a/src/auth/googleauth.ts b/src/auth/googleauth.ts index 647090c1..9279d494 100644 --- a/src/auth/googleauth.ts +++ b/src/auth/googleauth.ts @@ -53,6 +53,7 @@ import { ExternalAccountAuthorizedUserClient, ExternalAccountAuthorizedUserClientOptions, } from './externalAccountAuthorizedUserClient'; +import {originalOrCamelOptions} from '../util'; /** * Defines all types of explicit clients that are determined via ADC JSON @@ -388,6 +389,11 @@ export class GoogleAuth { ); } + if (!originalOrCamelOptions(options).get('universe_domain')) { + options.universeDomain = + (await gcpMetadata.universe('universe_domain')) || undefined; + } + // For GCE, just return a default ComputeClient. It will take care of // the rest. (options as ComputeOptions).scopes = this.getAnyScopes(); @@ -901,6 +907,7 @@ export class GoogleAuth { const credential: CredentialBody = { client_email: (this.jsonContent as JWTInput).client_email, private_key: (this.jsonContent as JWTInput).private_key, + universe_domain: this.jsonContent.universe_domain, }; return credential; } @@ -914,16 +921,19 @@ export class GoogleAuth { // NOTE: The trailing '/' at the end of service-accounts/ is very important! // The GCF metadata server doesn't respect querystring params if this / is // not included. - const data = await gcpMetadata.instance({ - property: 'service-accounts/', - params: {recursive: 'true'}, - }); - - if (!data || !data.default || !data.default.email) { - throw new Error('Failure from metadata server.'); - } + const data = await gcpMetadata.bulk([ + { + metadataKey: 'instance/service-accounts/default/email', + }, + { + metadataKey: 'universe/universe_domain', + }, + ]); - return {client_email: data.default.email}; + return { + client_email: data['instance/service-accounts/default/email'], + universe_domain: data['universe/universe_domain'] || undefined, + }; } /** From aaaeeab9f5e34509bca9181d57df45de0067fdb5 Mon Sep 17 00:00:00 2001 From: Daniel Bankhead Date: Mon, 13 Nov 2023 13:24:26 -0800 Subject: [PATCH 2/6] refactor: streamline --- src/auth/googleauth.ts | 59 ++++++++++++++++++++++++++--------------- test/test.googleauth.ts | 41 +++++++++++++--------------- 2 files changed, 55 insertions(+), 45 deletions(-) diff --git a/src/auth/googleauth.ts b/src/auth/googleauth.ts index 9279d494..23a8389d 100644 --- a/src/auth/googleauth.ts +++ b/src/auth/googleauth.ts @@ -132,6 +132,14 @@ const GoogleAuthExceptionMessages = { 'Unable to detect a Project Id in the current environment. \n' + 'To learn more about authentication and Google APIs, visit: \n' + 'https://cloud.google.com/docs/authentication/getting-started', + NO_CREDENTIALS_FOUND: + 'Unable to find credentials in current environment. \n' + + 'To learn more about authentication and Google APIs, visit: \n' + + 'https://cloud.google.com/docs/authentication/getting-started', + NO_UNIVERSE_DOMAIN_FOUND: + 'Unable to detect a Universe Domain in the current environment.\n' + + 'To learn more about Universe Domain retrieval, visit: \n' + + 'https://cloud.google.com/compute/docs/metadata/predefined-metadata-keys', } as const; export class GoogleAuth { @@ -169,6 +177,8 @@ export class GoogleAuth { private scopes?: string | string[]; private clientOptions?: AuthClientOptions; + #universeDomain?: string = undefined; + /** * Export DefaultTransporter as a static property of the class. */ @@ -287,6 +297,24 @@ export class GoogleAuth { return this._findProjectIdPromise; } + async getUniverseDomain(): Promise { + // Get the universe from `clientOptions`, if available + this.#universeDomain ??= originalOrCamelOptions(this.clientOptions).get( + 'universe_domain' + ); + + if (await this._checkIsGCE()) { + // Get the universe from the metadata server, if available + this.#universeDomain ??= await gcpMetadata.universe('universe_domain'); + } + + if (!this.#universeDomain) { + throw new Error(GoogleAuthExceptionMessages.NO_UNIVERSE_DOMAIN_FOUND); + } + + return this.#universeDomain; + } + /** * @returns Any scopes (user-specified or default scopes specified by the * client library) that need to be set on the current Auth client. @@ -390,8 +418,7 @@ export class GoogleAuth { } if (!originalOrCamelOptions(options).get('universe_domain')) { - options.universeDomain = - (await gcpMetadata.universe('universe_domain')) || undefined; + options.universeDomain = await this.getUniverseDomain(); } // For GCE, just return a default ComputeClient. It will take care of @@ -912,28 +939,16 @@ export class GoogleAuth { return credential; } - const isGCE = await this._checkIsGCE(); - if (!isGCE) { - throw new Error('Unknown error.'); - } + if (await this._checkIsGCE()) { + const [client_email, universe_domain] = await Promise.all([ + gcpMetadata.instance('service-accounts/default/email'), + this.getUniverseDomain(), + ]); - // For GCE, return the service account details from the metadata server - // NOTE: The trailing '/' at the end of service-accounts/ is very important! - // The GCF metadata server doesn't respect querystring params if this / is - // not included. - const data = await gcpMetadata.bulk([ - { - metadataKey: 'instance/service-accounts/default/email', - }, - { - metadataKey: 'universe/universe_domain', - }, - ]); + return {client_email, universe_domain}; + } - return { - client_email: data['instance/service-accounts/default/email'], - universe_domain: data['universe/universe_domain'] || undefined, - }; + throw new Error(GoogleAuthExceptionMessages.NO_CREDENTIALS_FOUND); } /** diff --git a/test/test.googleauth.ts b/test/test.googleauth.ts index e22556d8..b0ce97d3 100644 --- a/test/test.googleauth.ts +++ b/test/test.googleauth.ts @@ -64,7 +64,8 @@ describe('googleauth', () => { const tokenPath = `${BASE_PATH}/instance/service-accounts/default/token`; const host = HOST_ADDRESS; const instancePath = `${BASE_PATH}/instance`; - const svcAccountPath = `${instancePath}/service-accounts/?recursive=true`; + const svcAccountPath = `${instancePath}/service-accounts/default/email`; + const universeDomainPath = `${BASE_PATH}/universe/universe_domain`; const API_KEY = 'test-123'; const PEM_PATH = './test/fixtures/private.pem'; const STUB_PROJECT = 'my-awesome-project'; @@ -199,20 +200,22 @@ describe('googleauth', () => { createLinuxWellKnownStream = () => fs.createReadStream(filePath); } - function nockIsGCE() { + function nockIsGCE(opts = {universeDomain: 'my-universe.com'}) { const primary = nock(host).get(instancePath).reply(200, {}, HEADERS); const secondary = nock(SECONDARY_HOST_ADDRESS) .get(instancePath) .reply(200, {}, HEADERS); + const universeDomain = nock(HOST_ADDRESS) + .get(universeDomainPath) + .reply(200, opts.universeDomain, HEADERS); return { done: () => { - try { - primary.done(); - secondary.done(); - } catch (_err) { - // secondary can sometimes complete prior to primary. - } + return Promise.allSettled([ + primary.done(), + secondary.done(), + universeDomain.done(), + ]); }, }; } @@ -1128,25 +1131,19 @@ describe('googleauth', () => { }); it('getCredentials should get metadata from the server when running on GCE', async () => { - const response = { - default: { - email: 'test-creds@test-creds.iam.gserviceaccount.com', - private_key: null, - }, - }; + const clientEmail = 'test-creds@test-creds.iam.gserviceaccount.com'; + const universeDomain = 'my-amazing-universe.com'; const scopes = [ - nockIsGCE(), + nockIsGCE({universeDomain}), createGetProjectIdNock(), - nock(host).get(svcAccountPath).reply(200, response, HEADERS), + nock(host).get(svcAccountPath).reply(200, clientEmail, HEADERS), ]; await auth._checkIsGCE(); assert.strictEqual(true, auth.isGCE); const body = await auth.getCredentials(); assert.ok(body); - assert.strictEqual( - body.client_email, - 'test-creds@test-creds.iam.gserviceaccount.com' - ); + assert.strictEqual(body.client_email, clientEmail); + assert.strictEqual(body.universe_domain, universeDomain); assert.strictEqual(body.private_key, undefined); scopes.forEach(s => s.done()); }); @@ -1415,9 +1412,7 @@ describe('googleauth', () => { const data = 'abc123'; scopes.push( nock(iamUri).post(iamPath).reply(200, {signedBlob}), - nock(host) - .get(svcAccountPath) - .reply(200, {default: {email, private_key: privateKey}}, HEADERS) + nock(host).get(svcAccountPath).reply(200, email, HEADERS) ); const value = await auth.sign(data); scopes.forEach(x => x.done()); From 10a1f407c24bd7da846e70f9824c62dfa1f861a0 Mon Sep 17 00:00:00 2001 From: Daniel Bankhead Date: Wed, 15 Nov 2023 14:19:22 -0800 Subject: [PATCH 3/6] refactor: Fallback to returning default universe --- src/auth/googleauth.ts | 77 ++++++++++++++++++++++------------------- test/test.googleauth.ts | 17 +++++++++ 2 files changed, 58 insertions(+), 36 deletions(-) diff --git a/src/auth/googleauth.ts b/src/auth/googleauth.ts index 23a8389d..90a65aaa 100644 --- a/src/auth/googleauth.ts +++ b/src/auth/googleauth.ts @@ -14,7 +14,7 @@ import {exec} from 'child_process'; import * as fs from 'fs'; -import {GaxiosOptions, GaxiosResponse} from 'gaxios'; +import {GaxiosError, GaxiosOptions, GaxiosResponse} from 'gaxios'; import * as gcpMetadata from 'gcp-metadata'; import * as os from 'os'; import * as path from 'path'; @@ -47,7 +47,7 @@ import { EXTERNAL_ACCOUNT_TYPE, BaseExternalAccountClient, } from './baseexternalclient'; -import {AuthClient, AuthClientOptions} from './authclient'; +import {AuthClient, AuthClientOptions, DEFAULT_UNIVERSE} from './authclient'; import { EXTERNAL_ACCOUNT_AUTHORIZED_USER_TYPE, ExternalAccountAuthorizedUserClient, @@ -297,22 +297,40 @@ export class GoogleAuth { return this._findProjectIdPromise; } + async #getUniverseFromMetadataServer() { + if (!(await this._checkIsGCE())) return; + + let universeDomain: string; + + try { + universeDomain = await gcpMetadata.universe('universe_domain'); + universeDomain ||= DEFAULT_UNIVERSE; + } catch (e) { + if (e instanceof GaxiosError && e.status === 404) { + universeDomain = DEFAULT_UNIVERSE; + } else { + throw e; + } + } + + return universeDomain; + } + + /** + * Retrieves, caches, and returns the universe domain in the following order + * of precedence: + * - The universe domain in {@link GoogleAuth.clientOptions} + * - {@link gcpMetadata.universe} + * + * @returns The universe domain + */ async getUniverseDomain(): Promise { - // Get the universe from `clientOptions`, if available this.#universeDomain ??= originalOrCamelOptions(this.clientOptions).get( 'universe_domain' ); + this.#universeDomain ??= await this.#getUniverseFromMetadataServer(); - if (await this._checkIsGCE()) { - // Get the universe from the metadata server, if available - this.#universeDomain ??= await gcpMetadata.universe('universe_domain'); - } - - if (!this.#universeDomain) { - throw new Error(GoogleAuthExceptionMessages.NO_UNIVERSE_DOMAIN_FOUND); - } - - return this.#universeDomain; + return this.#universeDomain || DEFAULT_UNIVERSE; } /** @@ -399,34 +417,21 @@ export class GoogleAuth { } // Determine if we're running on GCE. - let isGCE; - try { - isGCE = await this._checkIsGCE(); - } catch (e) { - if (e instanceof Error) { - e.message = `Unexpected error determining execution environment: ${e.message}`; + if (await this._checkIsGCE()) { + // set universe domain for Compute client + if (!originalOrCamelOptions(options).get('universe_domain')) { + options.universeDomain = await this.getUniverseDomain(); } - throw e; - } - - if (!isGCE) { - // We failed to find the default credentials. Bail out with an error. - throw new Error( - 'Could not load the default credentials. Browse to https://cloud.google.com/docs/authentication/getting-started for more information.' + (options as ComputeOptions).scopes = this.getAnyScopes(); + return await this.prepareAndCacheADC( + new Compute(options), + quotaProjectIdOverride ); } - if (!originalOrCamelOptions(options).get('universe_domain')) { - options.universeDomain = await this.getUniverseDomain(); - } - - // For GCE, just return a default ComputeClient. It will take care of - // the rest. - (options as ComputeOptions).scopes = this.getAnyScopes(); - return await this.prepareAndCacheADC( - new Compute(options), - quotaProjectIdOverride + throw new Error( + 'Could not load the default credentials. Browse to https://cloud.google.com/docs/authentication/getting-started for more information.' ); } diff --git a/test/test.googleauth.ts b/test/test.googleauth.ts index b0ce97d3..99044253 100644 --- a/test/test.googleauth.ts +++ b/test/test.googleauth.ts @@ -1551,6 +1551,23 @@ describe('googleauth', () => { assert.fail('failed to throw'); }); + describe('getUniverseDomain', () => { + it('should prefer `clientOptions` > metadata service when available', async () => { + const universeDomain = 'my.universe.com'; + const auth = new GoogleAuth({clientOptions: {universeDomain}}); + + assert.equal(await auth.getUniverseDomain(), universeDomain); + }); + + it('should use the metadata service if on GCP', async () => { + const universeDomain = 'my.universe.com'; + const scope = nockIsGCE({universeDomain}); + + assert.equal(await auth.getUniverseDomain(), universeDomain); + await scope.done(); + }); + }); + function mockApplicationDefaultCredentials(path: string) { // Fake a home directory in our fixtures path. mockEnvVar('GCLOUD_PROJECT', 'my-fake-project'); From 36de7a671471487a3c13f85ee2dbc6a12278451c Mon Sep 17 00:00:00 2001 From: Daniel Bankhead Date: Wed, 15 Nov 2023 14:25:57 -0800 Subject: [PATCH 4/6] fix: transparent error message --- test/test.googleauth.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/test/test.googleauth.ts b/test/test.googleauth.ts index 99044253..6ba7f4e9 100644 --- a/test/test.googleauth.ts +++ b/test/test.googleauth.ts @@ -1088,11 +1088,10 @@ describe('googleauth', () => { // * Well-known file is not set. // * Running on GCE is set to true. mockWindows(); - sandbox.stub(auth, '_checkIsGCE').rejects('🤮'); - await assert.rejects( - auth.getApplicationDefault(), - /Unexpected error determining execution environment/ - ); + const e = new Error('abc'); + + sandbox.stub(auth, '_checkIsGCE').rejects(e); + await assert.rejects(auth.getApplicationDefault(), e); }); it('getApplicationDefault should also get project ID', async () => { From 6be0271463fddc7ccd54500064b6873ae7b58f0d Mon Sep 17 00:00:00 2001 From: Daniel Bankhead Date: Wed, 15 Nov 2023 14:32:31 -0800 Subject: [PATCH 5/6] chore: cleanup --- src/auth/googleauth.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/auth/googleauth.ts b/src/auth/googleauth.ts index 90a65aaa..7b125c28 100644 --- a/src/auth/googleauth.ts +++ b/src/auth/googleauth.ts @@ -931,17 +931,19 @@ export class GoogleAuth { if (client instanceof BaseExternalAccountClient) { const serviceAccountEmail = client.getServiceAccountEmail(); if (serviceAccountEmail) { - return {client_email: serviceAccountEmail}; + return { + client_email: serviceAccountEmail, + universe_domain: client.universeDomain, + }; } } if (this.jsonContent) { - const credential: CredentialBody = { + return { client_email: (this.jsonContent as JWTInput).client_email, private_key: (this.jsonContent as JWTInput).private_key, universe_domain: this.jsonContent.universe_domain, }; - return credential; } if (await this._checkIsGCE()) { From eddc126811fdbcc1e33774feffc08fb616927879 Mon Sep 17 00:00:00 2001 From: Daniel Bankhead Date: Wed, 15 Nov 2023 14:37:32 -0800 Subject: [PATCH 6/6] docs: Minor doc change --- src/auth/googleauth.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/auth/googleauth.ts b/src/auth/googleauth.ts index 7b125c28..13ab18a4 100644 --- a/src/auth/googleauth.ts +++ b/src/auth/googleauth.ts @@ -177,6 +177,11 @@ export class GoogleAuth { private scopes?: string | string[]; private clientOptions?: AuthClientOptions; + /** + * The cached universe domain. + * + * @see {@link GoogleAuth.getUniverseDomain} + */ #universeDomain?: string = undefined; /**