Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Retrieve universe_domain for Compute clients #1692

Merged
merged 8 commits into from
Nov 29, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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' +
aeitzman marked this conversation as resolved.
Show resolved Hide resolved
'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) {
aeitzman marked this conversation as resolved.
Show resolved Hide resolved
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();
aeitzman marked this conversation as resolved.
Show resolved Hide resolved

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
Loading