diff --git a/src/auth/authclient.ts b/src/auth/authclient.ts index 2f2b384c..b9de5c95 100644 --- a/src/auth/authclient.ts +++ b/src/auth/authclient.ts @@ -55,6 +55,9 @@ interface AuthJSONOptions { /** * The default service domain for a given Cloud universe. + * + * @example + * 'googleapis.com' */ universe_domain: string; diff --git a/src/auth/googleauth.ts b/src/auth/googleauth.ts index b211d7c9..90ecf49b 100644 --- a/src/auth/googleauth.ts +++ b/src/auth/googleauth.ts @@ -290,7 +290,7 @@ export class GoogleAuth { } } - /* + /** * A private method for finding and caching a projectId. * * Supports environments in order of precedence: @@ -632,9 +632,7 @@ export class GoogleAuth { ); } - // Create source client for impersonation - const sourceClient = new UserRefreshClient(); - sourceClient.fromJSON(json.source_credentials); + const sourceClient = this.fromJSON(json.source_credentials); if (json.service_account_impersonation_url?.length > 256) { /** @@ -646,10 +644,11 @@ export class GoogleAuth { ); } - // Extreact service account from service_account_impersonation_url - const targetPrincipal = /(?[^/]+):generateAccessToken$/.exec( - json.service_account_impersonation_url - )?.groups?.target; + // Extract service account from service_account_impersonation_url + const targetPrincipal = + /(?[^/]+):(generateAccessToken|generateIdToken)$/.exec( + json.service_account_impersonation_url + )?.groups?.target; if (!targetPrincipal) { throw new RangeError( @@ -659,18 +658,18 @@ export class GoogleAuth { const targetScopes = this.getAnyScopes() ?? []; - const client = new Impersonated({ + return new Impersonated({ ...json, - delegates: json.delegates ?? [], - sourceClient: sourceClient, - targetPrincipal: targetPrincipal, + sourceClient, + targetPrincipal, targetScopes: Array.isArray(targetScopes) ? targetScopes : [targetScopes], }); - return client; } /** * Create a credentials instance using the given input options. + * This client is not cached. + * * @param json The input object. * @param options The JWT or UserRefresh options for the client * @returns JWT or UserRefresh Client with data diff --git a/src/auth/impersonated.ts b/src/auth/impersonated.ts index 2b4a2555..0dc0e7ec 100644 --- a/src/auth/impersonated.ts +++ b/src/auth/impersonated.ts @@ -23,6 +23,7 @@ import {AuthClient} from './authclient'; import {IdTokenProvider} from './idtokenclient'; import {GaxiosError} from 'gaxios'; import {SignBlobResponse} from './googleauth'; +import {originalOrCamelOptions} from '../util'; export interface ImpersonatedOptions extends OAuth2ClientOptions { /** @@ -124,7 +125,22 @@ export class Impersonated extends OAuth2Client implements IdTokenProvider { this.delegates = options.delegates ?? []; this.targetScopes = options.targetScopes ?? []; this.lifetime = options.lifetime ?? 3600; - this.endpoint = options.endpoint ?? 'https://iamcredentials.googleapis.com'; + + const usingExplicitUniverseDomain = + !!originalOrCamelOptions(options).get('universe_domain'); + + if (!usingExplicitUniverseDomain) { + // override the default universe with the source's universe + this.universeDomain = this.sourceClient.universeDomain; + } else if (this.sourceClient.universeDomain !== this.universeDomain) { + // non-default universe and is not matching the source - this could be a credential leak + throw new RangeError( + `Universe domain ${this.sourceClient.universeDomain} in source credentials does not match ${this.universeDomain} universe domain set for impersonated credentials.` + ); + } + + this.endpoint = + options.endpoint ?? `https://iamcredentials.${this.universeDomain}`; } /** @@ -132,7 +148,8 @@ export class Impersonated extends OAuth2Client implements IdTokenProvider { * * {@link https://cloud.google.com/iam/docs/reference/credentials/rest/v1/projects.serviceAccounts/signBlob Reference Documentation} * @param blobToSign String to sign. - * @return denoting the keyyID and signedBlob in base64 string + * + * @returns A {@link SignBlobResponse} denoting the keyID and signedBlob in base64 string */ async sign(blobToSign: string): Promise { await this.sourceClient.getAccessToken(); @@ -224,7 +241,9 @@ export class Impersonated extends OAuth2Client implements IdTokenProvider { delegates: this.delegates, audience: targetAudience, includeEmail: options?.includeEmail ?? true, + useEmailAzp: options?.includeEmail ?? true, }; + const res = await this.sourceClient.request({ ...Impersonated.RETRY_CONFIG, url: u, diff --git a/src/auth/refreshclient.ts b/src/auth/refreshclient.ts index eca95d1b..93c07d49 100644 --- a/src/auth/refreshclient.ts +++ b/src/auth/refreshclient.ts @@ -183,4 +183,15 @@ export class UserRefreshClient extends OAuth2Client { }); }); } + + /** + * Create a UserRefreshClient credentials instance using the given input + * options. + * @param json The input object. + */ + static fromJSON(json: JWTInput): UserRefreshClient { + const client = new UserRefreshClient(); + client.fromJSON(json); + return client; + } } diff --git a/test/test.googleauth.ts b/test/test.googleauth.ts index 09853af4..ebea50c0 100644 --- a/test/test.googleauth.ts +++ b/test/test.googleauth.ts @@ -41,6 +41,7 @@ import { ExternalAccountClientOptions, RefreshOptions, Impersonated, + IdentityPoolClient, } from '../src'; import {CredentialBody} from '../src/auth/credentials'; import * as envDetect from '../src/auth/envDetect'; @@ -52,11 +53,16 @@ import { mockStsTokenExchange, saEmail, } from './externalclienthelper'; -import {BaseExternalAccountClient} from '../src/auth/baseexternalclient'; +import { + BaseExternalAccountClient, + EXTERNAL_ACCOUNT_TYPE, +} from '../src/auth/baseexternalclient'; import {AuthClient, DEFAULT_UNIVERSE} from '../src/auth/authclient'; import {ExternalAccountAuthorizedUserClient} from '../src/auth/externalAccountAuthorizedUserClient'; import {stringify} from 'querystring'; import {GoogleAuthExceptionMessages} from '../src/auth/googleauth'; +import {IMPERSONATED_ACCOUNT_TYPE} from '../src/auth/impersonated'; +import {USER_REFRESH_ACCOUNT_TYPE} from '../src/auth/refreshclient'; nock.disableNetConnect(); @@ -1656,6 +1662,86 @@ describe('googleauth', () => { .reply(200, {}); } describe('for impersonated types', () => { + describe('source clients', () => { + it('should support a variety of source clients', async () => { + const serviceAccountImpersonationURLBase = + 'https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/test@test-project.iam.gserviceaccount.com:generateToken'; + const samples: { + creds: { + type: typeof IMPERSONATED_ACCOUNT_TYPE; + service_account_impersonation_url: string; + source_credentials: {}; + }; + expectedSource: typeof AuthClient; + }[] = [ + // USER_TO_SERVICE_ACCOUNT_JSON + { + creds: { + type: IMPERSONATED_ACCOUNT_TYPE, + service_account_impersonation_url: new URL( + './test@test-project.iam.gserviceaccount.com:generateAccessToken', + serviceAccountImpersonationURLBase + ).toString(), + source_credentials: { + client_id: 'client', + client_secret: 'secret', + refresh_token: 'refreshToken', + type: USER_REFRESH_ACCOUNT_TYPE, + }, + }, + expectedSource: UserRefreshClient, + }, + // SERVICE_ACCOUNT_TO_SERVICE_ACCOUNT_JSON + { + creds: { + type: IMPERSONATED_ACCOUNT_TYPE, + service_account_impersonation_url: new URL( + './test@test-project.iam.gserviceaccount.com:generateIdToken', + serviceAccountImpersonationURLBase + ).toString(), + source_credentials: { + type: 'service_account', + client_email: 'google@auth.library', + private_key: privateKey, + }, + }, + expectedSource: JWT, + }, + // EXTERNAL_ACCOUNT_TO_SERVICE_ACCOUNT_JSON + { + creds: { + type: IMPERSONATED_ACCOUNT_TYPE, + service_account_impersonation_url: new URL( + './test@test-project.iam.gserviceaccount.com:generateIdToken', + serviceAccountImpersonationURLBase + ).toString(), + source_credentials: { + type: EXTERNAL_ACCOUNT_TYPE, + audience: 'audience', + subject_token_type: 'access_token', + token_url: 'https://sts.googleapis.com/v1/token', + credential_source: {url: 'https://example.com/token'}, + }, + }, + expectedSource: IdentityPoolClient, + }, + ]; + + const auth = new GoogleAuth(); + for (const {creds, expectedSource} of samples) { + const client = auth.fromJSON(creds); + + assert(client instanceof Impersonated); + + // This is a private prop - we will refactor/remove in the future + assert( + (client as unknown as {sourceClient: {}}).sourceClient instanceof + expectedSource + ); + } + }); + }); + describe('for impersonated credentials signing', () => { const now = new Date().getTime(); const saSuccessResponse = { diff --git a/test/test.impersonated.ts b/test/test.impersonated.ts index d8bc2b06..12ca0e65 100644 --- a/test/test.impersonated.ts +++ b/test/test.impersonated.ts @@ -97,6 +97,76 @@ describe('impersonated', () => { scopes.forEach(s => s.done()); }); + it('should inherit a `universeDomain` from the source client', async () => { + const universeDomain = 'my.universe.com'; + + const tomorrow = new Date(); + tomorrow.setDate(tomorrow.getDate() + 1); + + const scopes = [ + nock(url).get('/').reply(200), + createGTokenMock({ + access_token: 'abc123', + }), + nock(`https://iamcredentials.${universeDomain}`) + .post( + '/v1/projects/-/serviceAccounts/target@project.iam.gserviceaccount.com:generateAccessToken', + (body: ImpersonatedCredentialRequest) => { + assert.strictEqual(body.lifetime, '30s'); + assert.deepStrictEqual(body.delegates, []); + assert.deepStrictEqual(body.scope, [ + 'https://www.googleapis.com/auth/cloud-platform', + ]); + return true; + } + ) + .reply(200, { + accessToken: 'universe-token', + expireTime: tomorrow.toISOString(), + }), + ]; + + const sourceClient = createSampleJWTClient(); + + // Use a simple API key for this test. No need to get too fancy. + sourceClient.apiKey = 'ABC'; + delete sourceClient.subject; + + sourceClient.universeDomain = universeDomain; + + const impersonated = new Impersonated({ + sourceClient, + targetPrincipal: 'target@project.iam.gserviceaccount.com', + lifetime: 30, + delegates: [], + targetScopes: ['https://www.googleapis.com/auth/cloud-platform'], + }); + + await impersonated.request({url}); + assert.strictEqual(impersonated.credentials.access_token, 'universe-token'); + + scopes.forEach(s => s.done()); + }); + + it("should throw if an explicit `universeDomain` does not equal the source's `universeDomain`", async () => { + const universeDomain = 'my.universe.com'; + const otherUniverseDomain = 'not-my.universe.com'; + + const sourceClient = createSampleJWTClient(); + sourceClient.universeDomain = otherUniverseDomain; + + assert.throws(() => { + new Impersonated({ + sourceClient, + targetPrincipal: 'target@project.iam.gserviceaccount.com', + lifetime: 30, + delegates: [], + targetScopes: ['https://www.googleapis.com/auth/cloud-platform'], + universeDomain, + }); + }, /does not match/); + }); + it('should not request impersonated credentials on second request', async () => { const tomorrow = new Date(); tomorrow.setDate(tomorrow.getDate() + 1); @@ -383,10 +453,12 @@ describe('impersonated', () => { delegates: string[]; audience: string; includeEmail: boolean; + useEmailAzp: true; }) => { assert.strictEqual(body.audience, expectedAudience); assert.strictEqual(body.includeEmail, expectedIncludeEmail); assert.deepStrictEqual(body.delegates, expectedDeligates); + assert.strictEqual(body.useEmailAzp, true); return true; } )