Skip to content

Commit

Permalink
feat: Retrieve universe_domain for Compute clients (#1692)
Browse files Browse the repository at this point in the history
* feat: Get `universe_domain` for Compute clients

* refactor: streamline

* refactor: Fallback to returning default universe

* fix: transparent error message

* chore: cleanup

* docs: Minor doc change
  • Loading branch information
d-goog authored Nov 29, 2023
1 parent bf219c8 commit a735ec5
Show file tree
Hide file tree
Showing 3 changed files with 118 additions and 68 deletions.
2 changes: 2 additions & 0 deletions src/auth/credentials.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ export interface JWTInput {
client_secret?: string;
refresh_token?: string;
quota_project_id?: string;
universe_domain?: string;
}

export interface ImpersonatedJWTInput {
Expand All @@ -88,4 +89,5 @@ export interface ImpersonatedJWTInput {
export interface CredentialBody {
client_email?: string;
private_key?: string;
universe_domain?: string;
}
117 changes: 77 additions & 40 deletions src/auth/googleauth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<T extends AuthClient = JSONClient> {
Expand Down Expand Up @@ -168,6 +177,13 @@ export class GoogleAuth<T extends AuthClient = JSONClient> {
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.
*/
Expand Down Expand Up @@ -286,6 +302,42 @@ export class GoogleAuth<T extends AuthClient = JSONClient> {
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<string> {
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.
Expand Down Expand Up @@ -370,30 +422,21 @@ export class GoogleAuth<T extends AuthClient = JSONClient> {
}

// 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.'
);
}

Expand Down Expand Up @@ -893,37 +936,31 @@ export class GoogleAuth<T extends AuthClient = JSONClient> {
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);
}

/**
Expand Down
67 changes: 39 additions & 28 deletions test/test.googleauth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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(),
]);
},
};
}
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -1128,25 +1130,19 @@ describe('googleauth', () => {
});

it('getCredentials should get metadata from the server when running on GCE', async () => {
const response = {
default: {
email: '[email protected]',
private_key: null,
},
};
const clientEmail = '[email protected]';
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,
'[email protected]'
);
assert.strictEqual(body.client_email, clientEmail);
assert.strictEqual(body.universe_domain, universeDomain);
assert.strictEqual(body.private_key, undefined);
scopes.forEach(s => s.done());
});
Expand Down Expand Up @@ -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());
Expand Down Expand Up @@ -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');
Expand Down

0 comments on commit a735ec5

Please sign in to comment.