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..13ab18a4 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,12 +47,13 @@ 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, ExternalAccountAuthorizedUserClientOptions, } from './externalAccountAuthorizedUserClient'; +import {originalOrCamelOptions} from '../util'; /** * Defines all types of explicit clients that are determined via ADC JSON @@ -131,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 { @@ -168,6 +177,13 @@ export class GoogleAuth { private scopes?: string | string[]; private clientOptions?: AuthClientOptions; + /** + * The cached universe domain. + * + * @see {@link GoogleAuth.getUniverseDomain} + */ + #universeDomain?: string = undefined; + /** * Export DefaultTransporter as a static property of the class. */ @@ -286,6 +302,42 @@ 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 { + this.#universeDomain ??= originalOrCamelOptions(this.clientOptions).get( + 'universe_domain' + ); + this.#universeDomain ??= await this.#getUniverseFromMetadataServer(); + + return this.#universeDomain || DEFAULT_UNIVERSE; + } + /** * @returns Any scopes (user-specified or default scopes specified by the * client library) that need to be set on the current Auth client. @@ -370,30 +422,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 ); } - // 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.' ); } @@ -893,37 +936,31 @@ 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; - } - - const isGCE = await this._checkIsGCE(); - if (!isGCE) { - throw new Error('Unknown error.'); } - // 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.instance({ - property: 'service-accounts/', - params: {recursive: 'true'}, - }); + if (await this._checkIsGCE()) { + const [client_email, universe_domain] = await Promise.all([ + gcpMetadata.instance('service-accounts/default/email'), + this.getUniverseDomain(), + ]); - if (!data || !data.default || !data.default.email) { - throw new Error('Failure from metadata server.'); + return {client_email, universe_domain}; } - return {client_email: data.default.email}; + throw new Error(GoogleAuthExceptionMessages.NO_CREDENTIALS_FOUND); } /** diff --git a/test/test.googleauth.ts b/test/test.googleauth.ts index e22556d8..6ba7f4e9 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(), + ]); }, }; } @@ -1085,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 () => { @@ -1128,25 +1130,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 +1411,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()); @@ -1556,6 +1550,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');