From e7f759e2d603e4ce6d9b263ba428632e99642620 Mon Sep 17 00:00:00 2001 From: Daniel Bankhead Date: Fri, 26 Jan 2024 13:37:17 -0800 Subject: [PATCH 1/9] feat: Impersonated w/ Universe Support --- src/auth/authclient.ts | 3 +++ src/auth/impersonated.ts | 3 ++- test/test.impersonated.ts | 44 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 49 insertions(+), 1 deletion(-) diff --git a/src/auth/authclient.ts b/src/auth/authclient.ts index 12033203..e9a50db5 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/impersonated.ts b/src/auth/impersonated.ts index ac24fd7a..751f03d1 100644 --- a/src/auth/impersonated.ts +++ b/src/auth/impersonated.ts @@ -124,7 +124,8 @@ 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'; + this.endpoint = + options.endpoint ?? `https://iamcredentials.${this.universeDomain}`; } /** diff --git a/test/test.impersonated.ts b/test/test.impersonated.ts index d8bc2b06..097019b8 100644 --- a/test/test.impersonated.ts +++ b/test/test.impersonated.ts @@ -97,6 +97,50 @@ describe('impersonated', () => { scopes.forEach(s => s.done()); }); + it('should use a `universeDomain` for its endpoint', 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 impersonated = new Impersonated({ + sourceClient: createSampleJWTClient(), + targetPrincipal: 'target@project.iam.gserviceaccount.com', + lifetime: 30, + delegates: [], + targetScopes: ['https://www.googleapis.com/auth/cloud-platform'], + universeDomain, + }); + + await impersonated.request({url}); + assert.strictEqual(impersonated.credentials.access_token, 'universe-token'); + + scopes.forEach(s => s.done()); + }); + it('should not request impersonated credentials on second request', async () => { const tomorrow = new Date(); tomorrow.setDate(tomorrow.getDate() + 1); From f592c8e03ff5280f4c8d79d9e59d6f2234cc9afe Mon Sep 17 00:00:00 2001 From: Daniel Bankhead Date: Tue, 1 Oct 2024 11:33:53 -0700 Subject: [PATCH 2/9] docs: jsdoc/tsdoc fix --- src/auth/googleauth.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/auth/googleauth.ts b/src/auth/googleauth.ts index 3d08ec7f..e2c03f6d 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: From 4f184ac242d031f0d80f92d9107ba1202002963d Mon Sep 17 00:00:00 2001 From: Daniel Bankhead Date: Tue, 1 Oct 2024 12:10:03 -0700 Subject: [PATCH 3/9] feat: `useEmailAzp` --- src/auth/impersonated.ts | 4 +++- test/test.impersonated.ts | 2 ++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/auth/impersonated.ts b/src/auth/impersonated.ts index dd988a3d..665a962e 100644 --- a/src/auth/impersonated.ts +++ b/src/auth/impersonated.ts @@ -133,7 +133,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(); @@ -225,6 +226,7 @@ export class Impersonated extends OAuth2Client implements IdTokenProvider { delegates: this.delegates, audience: targetAudience, includeEmail: options?.includeEmail ?? true, + useEmailAzp: true, }; const res = await this.sourceClient.request({ ...Impersonated.RETRY_CONFIG, diff --git a/test/test.impersonated.ts b/test/test.impersonated.ts index 097019b8..110f62c3 100644 --- a/test/test.impersonated.ts +++ b/test/test.impersonated.ts @@ -427,10 +427,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; } ) From cbd89db34760db929b8781555b93929a5a4dd25b Mon Sep 17 00:00:00 2001 From: Daniel Bankhead Date: Tue, 1 Oct 2024 12:10:16 -0700 Subject: [PATCH 4/9] chore: compodoc nonsense --- package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/package.json b/package.json index 037d9fe6..acd6d927 100644 --- a/package.json +++ b/package.json @@ -56,6 +56,7 @@ "ncp": "^2.0.0", "nock": "^13.0.0", "null-loader": "^4.0.0", + "pdfmake": "^0.2.12", "puppeteer": "^21.0.0", "sinon": "^18.0.0", "ts-loader": "^8.0.0", From d3d3569556e8b93ee38a8069650cbed94c2b6b9b Mon Sep 17 00:00:00 2001 From: Daniel Bankhead Date: Tue, 1 Oct 2024 12:13:02 -0700 Subject: [PATCH 5/9] chore: for compodoc nonsense --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index acd6d927..45ded0e6 100644 --- a/package.json +++ b/package.json @@ -56,7 +56,7 @@ "ncp": "^2.0.0", "nock": "^13.0.0", "null-loader": "^4.0.0", - "pdfmake": "^0.2.12", + "pdfmake": "0.2.12", "puppeteer": "^21.0.0", "sinon": "^18.0.0", "ts-loader": "^8.0.0", From 310fe18aa8a54b7bfff481b6d6d3a3a9e5595ae1 Mon Sep 17 00:00:00 2001 From: Daniel Bankhead Date: Thu, 3 Oct 2024 11:49:52 -0700 Subject: [PATCH 6/9] chore: typo --- src/auth/googleauth.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/auth/googleauth.ts b/src/auth/googleauth.ts index e2c03f6d..c788179c 100644 --- a/src/auth/googleauth.ts +++ b/src/auth/googleauth.ts @@ -652,7 +652,7 @@ export class GoogleAuth { ); } - // Extreact service account from service_account_impersonation_url + // Extract service account from service_account_impersonation_url const targetPrincipal = /(?[^/]+):generateAccessToken$/.exec( json.service_account_impersonation_url )?.groups?.target; From 2d156ca846415213121b3b15ec90cbd21602eb59 Mon Sep 17 00:00:00 2001 From: Daniel Bankhead Date: Tue, 22 Oct 2024 10:44:25 -0700 Subject: [PATCH 7/9] refactor: Explicit Universe Domains should throw for `Impersonated` --- src/auth/impersonated.ts | 15 +++++++++++++++ test/test.impersonated.ts | 32 +++++++++++++++++++++++++++++--- 2 files changed, 44 insertions(+), 3 deletions(-) diff --git a/src/auth/impersonated.ts b/src/auth/impersonated.ts index 665a962e..7630f6f9 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,6 +125,20 @@ export class Impersonated extends OAuth2Client implements IdTokenProvider { this.delegates = options.delegates ?? []; this.targetScopes = options.targetScopes ?? []; this.lifetime = options.lifetime ?? 3600; + + 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 and not matching the source + 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}`; } diff --git a/test/test.impersonated.ts b/test/test.impersonated.ts index 110f62c3..12ca0e65 100644 --- a/test/test.impersonated.ts +++ b/test/test.impersonated.ts @@ -97,7 +97,7 @@ describe('impersonated', () => { scopes.forEach(s => s.done()); }); - it('should use a `universeDomain` for its endpoint', async () => { + it('should inherit a `universeDomain` from the source client', async () => { const universeDomain = 'my.universe.com'; const tomorrow = new Date(); @@ -126,13 +126,20 @@ describe('impersonated', () => { }), ]; + 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: createSampleJWTClient(), + sourceClient, targetPrincipal: 'target@project.iam.gserviceaccount.com', lifetime: 30, delegates: [], targetScopes: ['https://www.googleapis.com/auth/cloud-platform'], - universeDomain, }); await impersonated.request({url}); @@ -141,6 +148,25 @@ describe('impersonated', () => { 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); From 056c066354d47ab152cb8b36cc2a565955b28365 Mon Sep 17 00:00:00 2001 From: Daniel Bankhead Date: Tue, 29 Oct 2024 16:12:42 -0700 Subject: [PATCH 8/9] feat: Support `external_account` in `fromImpersonatedJSON` --- src/auth/googleauth.ts | 15 ++++++++++++--- src/auth/impersonated.ts | 1 + src/auth/refreshclient.ts | 11 +++++++++++ 3 files changed, 24 insertions(+), 3 deletions(-) diff --git a/src/auth/googleauth.ts b/src/auth/googleauth.ts index b5ab3553..df3af0ce 100644 --- a/src/auth/googleauth.ts +++ b/src/auth/googleauth.ts @@ -632,9 +632,18 @@ export class GoogleAuth { ); } - // Create source client for impersonation - const sourceClient = new UserRefreshClient(); - sourceClient.fromJSON(json.source_credentials); + let sourceClient: AuthClient; + + switch (json.source_credentials.type) { + case 'external_account': + sourceClient = ExternalAccountClient.fromJSON( + json.source_credentials as ExternalAccountClientOptions + )!; + break; + case 'authorized_user': + default: + sourceClient = UserRefreshClient.fromJSON(json.source_credentials); + } if (json.service_account_impersonation_url?.length > 256) { /** diff --git a/src/auth/impersonated.ts b/src/auth/impersonated.ts index 7630f6f9..3d8214ff 100644 --- a/src/auth/impersonated.ts +++ b/src/auth/impersonated.ts @@ -243,6 +243,7 @@ export class Impersonated extends OAuth2Client implements IdTokenProvider { includeEmail: options?.includeEmail ?? true, useEmailAzp: 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; + } } From dc043cea4f87eeb642c165b9781586996f09854d Mon Sep 17 00:00:00 2001 From: Daniel Bankhead Date: Thu, 31 Oct 2024 14:07:16 -0700 Subject: [PATCH 9/9] feat: Improve `Impersonated` Support --- src/auth/googleauth.ts | 30 +++++--------- src/auth/impersonated.ts | 4 +- test/test.googleauth.ts | 88 +++++++++++++++++++++++++++++++++++++++- 3 files changed, 99 insertions(+), 23 deletions(-) diff --git a/src/auth/googleauth.ts b/src/auth/googleauth.ts index df3af0ce..90ecf49b 100644 --- a/src/auth/googleauth.ts +++ b/src/auth/googleauth.ts @@ -632,18 +632,7 @@ export class GoogleAuth { ); } - let sourceClient: AuthClient; - - switch (json.source_credentials.type) { - case 'external_account': - sourceClient = ExternalAccountClient.fromJSON( - json.source_credentials as ExternalAccountClientOptions - )!; - break; - case 'authorized_user': - default: - sourceClient = UserRefreshClient.fromJSON(json.source_credentials); - } + const sourceClient = this.fromJSON(json.source_credentials); if (json.service_account_impersonation_url?.length > 256) { /** @@ -656,9 +645,10 @@ export class GoogleAuth { } // Extract service account from service_account_impersonation_url - const targetPrincipal = /(?[^/]+):generateAccessToken$/.exec( - json.service_account_impersonation_url - )?.groups?.target; + const targetPrincipal = + /(?[^/]+):(generateAccessToken|generateIdToken)$/.exec( + json.service_account_impersonation_url + )?.groups?.target; if (!targetPrincipal) { throw new RangeError( @@ -668,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 3d8214ff..0dc0e7ec 100644 --- a/src/auth/impersonated.ts +++ b/src/auth/impersonated.ts @@ -133,7 +133,7 @@ export class Impersonated extends OAuth2Client implements IdTokenProvider { // override the default universe with the source's universe this.universeDomain = this.sourceClient.universeDomain; } else if (this.sourceClient.universeDomain !== this.universeDomain) { - // non-default and not matching the source + // 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.` ); @@ -241,7 +241,7 @@ export class Impersonated extends OAuth2Client implements IdTokenProvider { delegates: this.delegates, audience: targetAudience, includeEmail: options?.includeEmail ?? true, - useEmailAzp: true, + useEmailAzp: options?.includeEmail ?? true, }; const res = await this.sourceClient.request({ 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 = {