diff --git a/src/auth/baseexternalclient.ts b/src/auth/baseexternalclient.ts index f407ad23..691e3964 100644 --- a/src/auth/baseexternalclient.ts +++ b/src/auth/baseexternalclient.ts @@ -57,6 +57,9 @@ export const EXTERNAL_ACCOUNT_TYPE = 'external_account'; /** Cloud resource manager URL used to retrieve project information. */ export const CLOUD_RESOURCE_MANAGER = 'https://cloudresourcemanager.googleapis.com/v1/projects/'; +/** The workforce audience pattern. */ +const WORKFORCE_AUDIENCE_PATTERN = + '//iam.googleapis.com/locations/[^/]+/workforcePools/[^/]+/providers/.+'; /** * Base external account credentials json interface. @@ -71,6 +74,7 @@ export interface BaseExternalAccountClientOptions { client_id?: string; client_secret?: string; quota_project_id?: string; + workforce_pool_user_project?: string; } /** @@ -127,6 +131,8 @@ export abstract class BaseExternalAccountClient extends AuthClient { private readonly subjectTokenType: string; private readonly serviceAccountImpersonationUrl?: string; private readonly stsCredential: sts.StsCredentials; + private readonly clientAuth?: ClientAuthentication; + private readonly workforcePoolUserProject?: string; public projectId: string | null; public projectNumber: string | null; public readonly eagerRefreshThresholdMillis: number; @@ -152,7 +158,7 @@ export abstract class BaseExternalAccountClient extends AuthClient { `received "${options.type}"` ); } - const clientAuth = options.client_id + this.clientAuth = options.client_id ? ({ confidentialClientType: 'basic', clientId: options.client_id, @@ -162,13 +168,27 @@ export abstract class BaseExternalAccountClient extends AuthClient { if (!this.validateGoogleAPIsUrl('sts', options.token_url)) { throw new Error(`"${options.token_url}" is not a valid token url.`); } - this.stsCredential = new sts.StsCredentials(options.token_url, clientAuth); + this.stsCredential = new sts.StsCredentials( + options.token_url, + this.clientAuth + ); // Default OAuth scope. This could be overridden via public property. this.scopes = [DEFAULT_OAUTH_SCOPE]; this.cachedAccessToken = null; this.audience = options.audience; this.subjectTokenType = options.subject_token_type; this.quotaProjectId = options.quota_project_id; + this.workforcePoolUserProject = options.workforce_pool_user_project; + const workforceAudiencePattern = new RegExp(WORKFORCE_AUDIENCE_PATTERN); + if ( + this.workforcePoolUserProject && + !this.audience.match(workforceAudiencePattern) + ) { + throw new Error( + 'workforcePoolUserProject should not be set for non-workforce pool ' + + 'credentials.' + ); + } if ( typeof options.service_account_impersonation_url !== 'undefined' && !this.validateGoogleAPIsUrl( @@ -290,8 +310,9 @@ export abstract class BaseExternalAccountClient extends AuthClient { /** * @return A promise that resolves with the project ID corresponding to the - * current workload identity pool. When not determinable, this resolves with - * null. + * current workload identity pool or current workforce pool if + * determinable. For workforce pool credential, it returns the project ID + * corresponding to the workforcePoolUserProject. * This is introduced to match the current pattern of using the Auth * library: * const projectId = await auth.getProjectId(); @@ -303,15 +324,16 @@ export abstract class BaseExternalAccountClient extends AuthClient { * https://cloud.google.com/resource-manager/reference/rest/v1/projects/get#authorization-scopes */ async getProjectId(): Promise { + const projectNumber = this.projectNumber || this.workforcePoolUserProject; if (this.projectId) { // Return previously determined project ID. return this.projectId; - } else if (this.projectNumber) { + } else if (projectNumber) { // Preferable not to use request() to avoid retrial policies. const headers = await this.getRequestHeaders(); const response = await this.transporter.request({ headers, - url: `${CLOUD_RESOURCE_MANAGER}${this.projectNumber}`, + url: `${CLOUD_RESOURCE_MANAGER}${projectNumber}`, responseType: 'json', }); this.projectId = response.data.projectId; @@ -401,8 +423,16 @@ export abstract class BaseExternalAccountClient extends AuthClient { }; // Exchange the external credentials for a GCP access token. + // Client auth is prioritized over passing the workforcePoolUserProject + // parameter for STS token exchange. + const additionalOptions = + !this.clientAuth && this.workforcePoolUserProject + ? {userProject: this.workforcePoolUserProject} + : undefined; const stsResponse = await this.stsCredential.exchangeToken( - stsCredentialsOptions + stsCredentialsOptions, + undefined, + additionalOptions ); if (this.serviceAccountImpersonationUrl) { diff --git a/src/auth/identitypoolclient.ts b/src/auth/identitypoolclient.ts index 33badafa..eed39b1c 100644 --- a/src/auth/identitypoolclient.ts +++ b/src/auth/identitypoolclient.ts @@ -70,7 +70,8 @@ export class IdentityPoolClient extends BaseExternalAccountClient { * Instantiate an IdentityPoolClient instance using the provided JSON * object loaded from an external account credentials file. * An error is thrown if the credential is not a valid file-sourced or - * url-sourced credential. + * url-sourced credential or a workforce pool user project is provided + * with a non workforce audience. * @param options The external account options object typically loaded * from the external account JSON credential file. * @param additionalOptions Optional additional behavior customization diff --git a/test/test.baseexternalclient.ts b/test/test.baseexternalclient.ts index 9961529a..72acd595 100644 --- a/test/test.baseexternalclient.ts +++ b/test/test.baseexternalclient.ts @@ -82,6 +82,24 @@ describe('BaseExternalAccountClient', () => { client_id: 'CLIENT_ID', client_secret: 'SECRET', }; + const externalAccountOptionsWorkforceUserProject = Object.assign( + {}, + externalAccountOptions, + { + workforce_pool_user_project: 'workforce_pool_user_project', + audience: + '//iam.googleapis.com/locations/global/workforcePools/pool/providers/provider', + subject_token_type: 'urn:ietf:params:oauth:token-type:id_token', + } + ); + const externalAccountOptionsWithClientAuthAndWorkforceUserProject = + Object.assign( + { + client_id: 'CLIENT_ID', + client_secret: 'SECRET', + }, + externalAccountOptionsWorkforceUserProject + ); const basicAuthCreds = `${externalAccountOptionsWithCreds.client_id}:` + `${externalAccountOptionsWithCreds.client_secret}`; @@ -104,6 +122,12 @@ describe('BaseExternalAccountClient', () => { }, externalAccountOptionsWithCreds ); + const externalAccountOptionsWithWorkforceUserProjectAndSA = Object.assign( + { + service_account_impersonation_url: getServiceAccountImpersonationUrl(), + }, + externalAccountOptionsWorkforceUserProject + ); const indeterminableProjectIdAudiences = [ // Legacy K8s audience format. 'identitynamespace:1f12345:my_provider', @@ -245,6 +269,64 @@ describe('BaseExternalAccountClient', () => { } }); + const invalidWorkforceAudiences = [ + '//iam.googleapis.com/locations/global/workloadIdentityPools/pool/providers/provider', + '//iam.googleapis.com/locations/global/workforcepools/pool/providers/provider', + '//iam.googleapis.com/locations/global/workforcePools//providers/provider', + '//iam.googleapis.com/locations/global/workforcePools/providers/provider', + '//iam.googleapis.com/locations/global/workloadIdentityPools/workforcePools/pool/providers/provider', + '//iam.googleapis.com//locations/global/workforcePools/pool/providers/provider', + '//iam.googleapis.com/project/123/locations/global/workforcePools/pool/providers/provider', + '//iam.googleapis.com/locations/global/workforcePools/pool/providers', + '//iam.googleapis.com/locations/global/workforcePools/workloadIdentityPools/pool/providers/provider', + '//iam.googleapis.com/locations/global/workforcePools/pool/providers/', + '//iam.googleapis.com/locations//workforcePools/pool/providers/provider', + '//iam.googleapis.com/locations/workforcePools/pool/providers/provider', + ]; + const invalidExternalAccountOptionsWorkforceUserProject = Object.assign( + {}, + externalAccountOptionsWorkforceUserProject + ); + const expectedWorkforcePoolUserProjectError = new Error( + 'workforcePoolUserProject should not be set for non-workforce pool ' + + 'credentials.' + ); + + invalidWorkforceAudiences.forEach(invalidWorkforceAudience => { + it(`should throw given audience ${invalidWorkforceAudience} with user project defined in options`, () => { + invalidExternalAccountOptionsWorkforceUserProject.audience = + invalidWorkforceAudience; + + assert.throws(() => { + return new TestExternalAccountClient( + invalidExternalAccountOptionsWorkforceUserProject + ); + }, expectedWorkforcePoolUserProjectError); + }); + }); + + it('should not throw on valid workforce audience configs', () => { + const validWorkforceAudiences = [ + '//iam.googleapis.com/locations/global/workforcePools/workforcePools/providers/provider', + '//iam.googleapis.com/locations/global/workforcePools/pool/providers/provider', + '//iam.googleapis.com/locations/global/workforcePools/workloadPools/providers/oidc', + ]; + const validExternalAccountOptionsWorkforceUserProject = Object.assign( + {}, + externalAccountOptionsWorkforceUserProject + ); + for (const validWorkforceAudience of validWorkforceAudiences) { + validExternalAccountOptionsWorkforceUserProject.audience = + validWorkforceAudience; + + assert.doesNotThrow(() => { + return new TestExternalAccountClient( + validExternalAccountOptionsWorkforceUserProject + ); + }); + } + }); + it('should not throw on valid options', () => { assert.doesNotThrow(() => { return new TestExternalAccountClient(externalAccountOptions); @@ -280,6 +362,16 @@ describe('BaseExternalAccountClient', () => { }); describe('projectNumber', () => { + it('should return null for workforce pools with workforce_pool_user_project', () => { + const options = Object.assign( + {}, + externalAccountOptionsWorkforceUserProject + ); + const client = new TestExternalAccountClient(options); + + assert(client.projectNumber === null); + }); + it('should be set if determinable', () => { const projectNumber = 'my-proj-number'; const options = Object.assign({}, externalAccountOptions); @@ -342,6 +434,65 @@ describe('BaseExternalAccountClient', () => { }); describe('getProjectId()', () => { + it('should resolve for workforce pools when workforce_pool_user_project is provided', async () => { + const options = Object.assign( + {}, + externalAccountOptionsWorkforceUserProject + ); + const projectNumber = options.workforce_pool_user_project; + const projectId = 'my-proj-id'; + const response = { + projectNumber, + projectId, + lifecycleState: 'ACTIVE', + name: 'project-name', + createTime: '2018-11-06T04:42:54.109Z', + parent: { + type: 'folder', + id: '12345678901', + }, + }; + const scopes = [ + mockStsTokenExchange([ + { + statusCode: 200, + response: stsSuccessfulResponse, + request: { + grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange', + audience: + '//iam.googleapis.com/locations/global/workforcePools/pool/providers/provider', + scope: 'https://www.googleapis.com/auth/cloud-platform', + requested_token_type: + 'urn:ietf:params:oauth:token-type:access_token', + subject_token: 'subject_token_0', + subject_token_type: 'urn:ietf:params:oauth:token-type:id_token', + options: JSON.stringify({ + userProject: options.workforce_pool_user_project, + }), + }, + }, + ]), + mockCloudResourceManager( + options.workforce_pool_user_project, + stsSuccessfulResponse.access_token, + 200, + response + ), + ]; + + const client = new TestExternalAccountClient(options); + const actualProjectId = await client.getProjectId(); + + assert.strictEqual(actualProjectId, projectId); + assert.strictEqual(client.projectId, projectId); + + // Next call should return cached result. + const cachedProjectId = await client.getProjectId(); + + assert.strictEqual(cachedProjectId, projectId); + scopes.forEach(scope => scope.done()); + }); + it('should resolve with projectId when determinable', async () => { const projectNumber = 'my-proj-number'; const projectId = 'my-proj-id'; @@ -484,6 +635,122 @@ describe('BaseExternalAccountClient', () => { scope.done(); }); + it('should use client auth over passing the workforce user project when both are provided', async () => { + const scope = mockStsTokenExchange([ + { + statusCode: 200, + response: stsSuccessfulResponse, + request: { + grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange', + audience: + '//iam.googleapis.com/locations/global/workforcePools/pool/providers/provider', + scope: 'https://www.googleapis.com/auth/cloud-platform', + requested_token_type: + 'urn:ietf:params:oauth:token-type:access_token', + subject_token: 'subject_token_0', + subject_token_type: 'urn:ietf:params:oauth:token-type:id_token', + }, + additionalHeaders: { + Authorization: `Basic ${crypto.encodeBase64StringUtf8( + basicAuthCreds + )}`, + }, + }, + ]); + + const client = new TestExternalAccountClient( + externalAccountOptionsWithClientAuthAndWorkforceUserProject + ); + const actualResponse = await client.getAccessToken(); + + // Confirm raw GaxiosResponse appended to response. + assertGaxiosResponsePresent(actualResponse); + delete actualResponse.res; + assert.deepStrictEqual(actualResponse, { + token: stsSuccessfulResponse.access_token, + }); + scope.done(); + }); + + it('should pass the workforce user project on workforce configs when client auth is not provided ', async () => { + const scope = mockStsTokenExchange([ + { + statusCode: 200, + response: stsSuccessfulResponse, + request: { + grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange', + audience: + '//iam.googleapis.com/locations/global/workforcePools/pool/providers/provider', + scope: 'https://www.googleapis.com/auth/cloud-platform', + requested_token_type: + 'urn:ietf:params:oauth:token-type:access_token', + subject_token: 'subject_token_0', + subject_token_type: 'urn:ietf:params:oauth:token-type:id_token', + options: JSON.stringify({ + userProject: + externalAccountOptionsWorkforceUserProject.workforce_pool_user_project, + }), + }, + }, + ]); + + const client = new TestExternalAccountClient( + externalAccountOptionsWorkforceUserProject + ); + const actualResponse = await client.getAccessToken(); + + // Confirm raw GaxiosResponse appended to response. + assertGaxiosResponsePresent(actualResponse); + delete actualResponse.res; + assert.deepStrictEqual(actualResponse, { + token: stsSuccessfulResponse.access_token, + }); + scope.done(); + }); + + it('should not throw if client auth is provided but workforce user project is not', async () => { + const scope = mockStsTokenExchange([ + { + statusCode: 200, + response: stsSuccessfulResponse, + request: { + grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange', + audience: + '//iam.googleapis.com/locations/global/workforcePools/pool/providers/provider', + scope: 'https://www.googleapis.com/auth/cloud-platform', + requested_token_type: + 'urn:ietf:params:oauth:token-type:access_token', + subject_token: 'subject_token_0', + subject_token_type: 'urn:ietf:params:oauth:token-type:id_token', + }, + additionalHeaders: { + Authorization: `Basic ${crypto.encodeBase64StringUtf8( + basicAuthCreds + )}`, + }, + }, + ]); + const externalAccountOptionsWithClientAuth: BaseExternalAccountClientOptions = + Object.assign( + {}, + externalAccountOptionsWithClientAuthAndWorkforceUserProject + ); + delete externalAccountOptionsWithClientAuth.workforce_pool_user_project; + + const client = new TestExternalAccountClient( + externalAccountOptionsWithClientAuth + ); + const actualResponse = await client.getAccessToken(); + + // Confirm raw GaxiosResponse appended to response. + assertGaxiosResponsePresent(actualResponse); + delete actualResponse.res; + assert.deepStrictEqual(actualResponse, { + token: stsSuccessfulResponse.access_token, + }); + scope.done(); + }); + it('should return credential with no expiry date if STS response does not return one', async () => { const stsSuccessfulResponse2 = Object.assign({}, stsSuccessfulResponse); const emittedEvents: Credentials[] = []; @@ -1307,6 +1574,55 @@ describe('BaseExternalAccountClient', () => { }); scopes.forEach(scope => scope.done()); }); + + it('should still pass workforce user project when no client auth is used', async () => { + const scopes: nock.Scope[] = []; + scopes.push( + mockStsTokenExchange([ + { + statusCode: 200, + response: stsSuccessfulResponse, + request: { + grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange', + audience: + '//iam.googleapis.com/locations/global/workforcePools/pool/providers/provider', + scope: 'https://www.googleapis.com/auth/cloud-platform', + requested_token_type: + 'urn:ietf:params:oauth:token-type:access_token', + subject_token: 'subject_token_0', + subject_token_type: 'urn:ietf:params:oauth:token-type:id_token', + options: JSON.stringify({ + userProject: + externalAccountOptionsWithWorkforceUserProjectAndSA.workforce_pool_user_project, + }), + }, + }, + ]) + ); + scopes.push( + mockGenerateAccessToken([ + { + statusCode: 200, + response: saSuccessResponse, + token: stsSuccessfulResponse.access_token, + scopes: ['https://www.googleapis.com/auth/cloud-platform'], + }, + ]) + ); + + const client = new TestExternalAccountClient( + externalAccountOptionsWithWorkforceUserProjectAndSA + ); + const actualResponse = await client.getAccessToken(); + + // Confirm raw GaxiosResponse appended to response. + assertGaxiosResponsePresent(actualResponse); + delete actualResponse.res; + assert.deepStrictEqual(actualResponse, { + token: saSuccessResponse.accessToken, + }); + scopes.forEach(scope => scope.done()); + }); }); }); diff --git a/test/test.externalclient.ts b/test/test.externalclient.ts index 4d1f95fe..f341d343 100644 --- a/test/test.externalclient.ts +++ b/test/test.externalclient.ts @@ -77,6 +77,21 @@ describe('ExternalAccountClient', () => { forceRefreshOnFailure: true, }; + const invalidWorkforceIdentityPoolClientAudiences = [ + '//iam.googleapis.com/locations/global/workloadIdentityPools/pool/providers/oidc', + '//iam.googleapis.com/locations/global/workforcepools/pool/providers/oidc', + '//iam.googleapis.com/locations/global/workforcePools//providers/oidc', + '//iam.googleapis.com/locations/global/workforcePools/providers/oidc', + '//iam.googleapis.com/locations/global/workloadIdentityPools/workforcePools/pool/providers/oidc', + '//iam.googleapis.com//locations/global/workforcePools/pool/providers/oidc', + '//iam.googleapis.com/project/123/locations/global/workforcePools/pool/providers/oidc', + '//iam.googleapis.com/locations/global/workforcePools/workloadIdentityPools/pool/providers/oidc', + '//iam.googleapis.com/locations/global/workforcePools/pool/providers', + '//iam.googleapis.com/locations/global/workforcePools/pool/providers/', + '//iam.googleapis.com/locations//workforcePools/pool/providers/oidc', + '//iam.googleapis.com/locations/workforcePools/pool/providers/oidc', + ]; + it('should return IdentityPoolClient on IdentityPoolClientOptions', () => { const expectedClient = new IdentityPoolClient(fileSourcedOptions); @@ -116,6 +131,57 @@ describe('ExternalAccountClient', () => { ); }); + it('should return an IdentityPoolClient with a workforce config', () => { + const validWorkforceIdentityPoolClientAudiences = [ + '//iam.googleapis.com/locations/global/workforcePools/workforcePools/providers/provider', + '//iam.googleapis.com/locations/global/workforcePools/pool/providers/provider', + '//iam.googleapis.com/locations/global/workforcePools/workloadPools/providers/oidc', + ]; + const workforceFileSourcedOptions = Object.assign( + {}, + fileSourcedOptions, + { + workforce_pool_user_project: 'workforce_pool_user_project', + subject_token_type: 'urn:ietf:params:oauth:token-type:id_token', + } + ); + for (const validWorkforceIdentityPoolClientAudience of validWorkforceIdentityPoolClientAudiences) { + workforceFileSourcedOptions.audience = + validWorkforceIdentityPoolClientAudience; + const expectedClient = new IdentityPoolClient( + workforceFileSourcedOptions + ); + + assert.deepStrictEqual( + ExternalAccountClient.fromJSON(workforceFileSourcedOptions), + expectedClient + ); + } + }); + + invalidWorkforceIdentityPoolClientAudiences.forEach( + invalidWorkforceIdentityPoolClientAudience => { + const workforceIdentityPoolClientInvalidOptions = Object.assign( + {}, + fileSourcedOptions, + { + workforce_pool_user_project: 'workforce_pool_user_project', + subject_token_type: 'urn:ietf:params:oauth:token-type:id_token', + } + ); + it(`should throw an error when an invalid workforce audience ${invalidWorkforceIdentityPoolClientAudience} is provided with a workforce user project`, () => { + workforceIdentityPoolClientInvalidOptions.audience = + invalidWorkforceIdentityPoolClientAudience; + + assert.throws(() => { + return ExternalAccountClient.fromJSON( + workforceIdentityPoolClientInvalidOptions + ); + }); + }); + } + ); + it('should return null when given non-ExternalAccountClientOptions', () => { assert( // eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/test/test.identitypoolclient.ts b/test/test.identitypoolclient.ts index 9543e8b7..69b97cc8 100644 --- a/test/test.identitypoolclient.ts +++ b/test/test.identitypoolclient.ts @@ -16,7 +16,7 @@ import * as assert from 'assert'; import {describe, it} from 'mocha'; import * as fs from 'fs'; import * as nock from 'nock'; - +import {createCrypto} from '../src/crypto/crypto'; import { IdentityPoolClient, IdentityPoolClientOptions, @@ -48,6 +48,7 @@ describe('IdentityPoolClient', () => { 'utf-8' ); const audience = getAudience(); + const crypto = createCrypto(); const fileSourcedOptions = { type: 'external_account', audience, @@ -63,6 +64,32 @@ describe('IdentityPoolClient', () => { }, fileSourcedOptions ); + const fileSourcedOptionsWithWorkforceUserProject = Object.assign( + {}, + fileSourcedOptions, + { + workforce_pool_user_project: 'workforce_pool_user_project', + audience: + '//iam.googleapis.com/locations/global/workforcePools/pool/providers/oidc', + subject_token_type: 'urn:ietf:params:oauth:token-type:id_token', + } + ); + const fileSourcedOptionsWithClientAuthAndWorkforceUserProject = Object.assign( + { + client_id: 'CLIENT_ID', + client_secret: 'SECRET', + }, + fileSourcedOptionsWithWorkforceUserProject + ); + const fileSourcedOptionsWithWorkforceUserProjectAndSA = Object.assign( + { + service_account_impersonation_url: getServiceAccountImpersonationUrl(), + }, + fileSourcedOptionsWithWorkforceUserProject + ); + const basicAuthCreds = + `${fileSourcedOptionsWithClientAuthAndWorkforceUserProject.client_id}:` + + `${fileSourcedOptionsWithClientAuthAndWorkforceUserProject.client_secret}`; const jsonFileSourcedOptions: IdentityPoolClientOptions = { type: 'external_account', audience, @@ -147,6 +174,29 @@ describe('IdentityPoolClient', () => { }); describe('Constructor', () => { + const invalidWorkforceIdentityPoolClientAudiences = [ + '//iam.googleapis.com/locations/global/workloadIdentityPools/pool/providers/oidc', + '//iam.googleapis.com/locations/global/workforcepools/pool/providers/oidc', + '//iam.googleapis.com/locations/global/workforcePools//providers/oidc', + '//iam.googleapis.com/locations/global/workforcePools/providers/oidc', + '//iam.googleapis.com/locations/global/workloadIdentityPools/workforcePools/pool/providers/oidc', + '//iam.googleapis.com//locations/global/workforcePools/pool/providers/oidc', + '//iam.googleapis.com/project/123/locations/global/workforcePools/pool/providers/oidc', + '//iam.googleapis.com/locations/global/workforcePools/workloadIdentityPools/pool/providers/oidc', + '//iam.googleapis.com/locations/global/workforcePools/pool/providers', + '//iam.googleapis.com/locations/global/workforcePools/pool/providers/', + '//iam.googleapis.com/locations//workforcePools/pool/providers/oidc', + '//iam.googleapis.com/locations/workforcePools/pool/providers/oidc', + ]; + const invalidWorkforceIdentityPoolFileSourceOptions = Object.assign( + {}, + fileSourcedOptionsWithWorkforceUserProject + ); + const expectedWorkforcePoolUserProjectError = new Error( + 'workforcePoolUserProject should not be set for non-workforce pool ' + + 'credentials.' + ); + it('should throw when invalid options are provided', () => { const expectedError = new Error( 'No valid Identity Pool "credential_source" provided' @@ -211,6 +261,21 @@ describe('IdentityPoolClient', () => { }, expectedError); }); + invalidWorkforceIdentityPoolClientAudiences.forEach( + invalidWorkforceIdentityPoolClientAudience => { + it(`should throw given audience ${invalidWorkforceIdentityPoolClientAudience} with user project defined in IdentityPoolClientOptions`, () => { + invalidWorkforceIdentityPoolFileSourceOptions.audience = + invalidWorkforceIdentityPoolClientAudience; + + assert.throws(() => { + return new IdentityPoolClient( + invalidWorkforceIdentityPoolFileSourceOptions + ); + }, expectedWorkforcePoolUserProjectError); + }); + } + ); + it('should not throw when valid file-sourced options are provided', () => { assert.doesNotThrow(() => { return new IdentityPoolClient(fileSourcedOptions); @@ -232,6 +297,28 @@ describe('IdentityPoolClient', () => { return new IdentityPoolClient(urlSourcedOptionsNoHeaders); }); }); + + it('should not throw on valid workforce audience configs', () => { + const validWorkforceIdentityPoolClientAudiences = [ + '//iam.googleapis.com/locations/global/workforcePools/workforcePools/providers/provider', + '//iam.googleapis.com/locations/global/workforcePools/pool/providers/provider', + '//iam.googleapis.com/locations/global/workforcePools/workloadPools/providers/oidc', + ]; + const validWorkforceIdentityPoolFileSourceOptions = Object.assign( + {}, + fileSourcedOptionsWithWorkforceUserProject + ); + for (const validWorkforceIdentityPoolClientAudience of validWorkforceIdentityPoolClientAudiences) { + validWorkforceIdentityPoolFileSourceOptions.audience = + validWorkforceIdentityPoolClientAudience; + + assert.doesNotThrow(() => { + return new IdentityPoolClient( + validWorkforceIdentityPoolFileSourceOptions + ); + }); + } + }); }); describe('for file-sourced subject tokens', () => { @@ -337,6 +424,176 @@ describe('IdentityPoolClient', () => { scope.done(); }); + it('should resolve with the expected response on workforce configs with client auth', async () => { + const scope = mockStsTokenExchange([ + { + statusCode: 200, + response: stsSuccessfulResponse, + request: { + grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange', + audience: + '//iam.googleapis.com/locations/global/workforcePools/pool/providers/oidc', + scope: 'https://www.googleapis.com/auth/cloud-platform', + requested_token_type: + 'urn:ietf:params:oauth:token-type:access_token', + // Subject token loaded from file should be used. + subject_token: fileSubjectToken, + subject_token_type: 'urn:ietf:params:oauth:token-type:id_token', + }, + additionalHeaders: { + Authorization: `Basic ${crypto.encodeBase64StringUtf8( + basicAuthCreds + )}`, + }, + }, + ]); + + const client = new IdentityPoolClient( + fileSourcedOptionsWithClientAuthAndWorkforceUserProject + ); + const actualResponse = await client.getAccessToken(); + + // Confirm raw GaxiosResponse appended to response. + assertGaxiosResponsePresent(actualResponse); + delete actualResponse.res; + assert.deepStrictEqual(actualResponse, { + token: stsSuccessfulResponse.access_token, + }); + scope.done(); + }); + + it('should resolve with the expected response on workforce configs without client auth', async () => { + const scope = mockStsTokenExchange([ + { + statusCode: 200, + response: stsSuccessfulResponse, + request: { + grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange', + audience: + '//iam.googleapis.com/locations/global/workforcePools/pool/providers/oidc', + scope: 'https://www.googleapis.com/auth/cloud-platform', + requested_token_type: + 'urn:ietf:params:oauth:token-type:access_token', + // Subject token loaded from file should be used. + subject_token: fileSubjectToken, + subject_token_type: 'urn:ietf:params:oauth:token-type:id_token', + options: JSON.stringify({ + userProject: + fileSourcedOptionsWithWorkforceUserProject.workforce_pool_user_project, + }), + }, + }, + ]); + + const client = new IdentityPoolClient( + fileSourcedOptionsWithWorkforceUserProject + ); + const actualResponse = await client.getAccessToken(); + + // Confirm raw GaxiosResponse appended to response. + assertGaxiosResponsePresent(actualResponse); + delete actualResponse.res; + assert.deepStrictEqual(actualResponse, { + token: stsSuccessfulResponse.access_token, + }); + scope.done(); + }); + + it('should not throw if client auth is provided but workforce user project is not', async () => { + const scope = mockStsTokenExchange([ + { + statusCode: 200, + response: stsSuccessfulResponse, + request: { + grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange', + audience: + '//iam.googleapis.com/locations/global/workforcePools/pool/providers/oidc', + scope: 'https://www.googleapis.com/auth/cloud-platform', + requested_token_type: + 'urn:ietf:params:oauth:token-type:access_token', + subject_token: fileSubjectToken, + subject_token_type: 'urn:ietf:params:oauth:token-type:id_token', + }, + additionalHeaders: { + Authorization: `Basic ${crypto.encodeBase64StringUtf8( + basicAuthCreds + )}`, + }, + }, + ]); + const fileSourcedOptionsWithClientAuth: IdentityPoolClientOptions = + Object.assign( + {}, + fileSourcedOptionsWithClientAuthAndWorkforceUserProject + ); + delete fileSourcedOptionsWithClientAuth.workforce_pool_user_project; + + const client = new IdentityPoolClient(fileSourcedOptionsWithClientAuth); + const actualResponse = await client.getAccessToken(); + + // Confirm raw GaxiosResponse appended to response. + assertGaxiosResponsePresent(actualResponse); + delete actualResponse.res; + assert.deepStrictEqual(actualResponse, { + token: stsSuccessfulResponse.access_token, + }); + scope.done(); + }); + + it('should still pass workforce user project when impersonation and no client auth are used', async () => { + const now = new Date().getTime(); + const saSuccessResponse = { + accessToken: 'SA_ACCESS_TOKEN', + expireTime: new Date(now + ONE_HOUR_IN_SECS * 1000).toISOString(), + }; + const scopes: nock.Scope[] = []; + scopes.push( + mockStsTokenExchange([ + { + statusCode: 200, + response: stsSuccessfulResponse, + request: { + grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange', + audience: + '//iam.googleapis.com/locations/global/workforcePools/pool/providers/oidc', + scope: 'https://www.googleapis.com/auth/cloud-platform', + requested_token_type: + 'urn:ietf:params:oauth:token-type:access_token', + subject_token: fileSubjectToken, + subject_token_type: 'urn:ietf:params:oauth:token-type:id_token', + options: JSON.stringify({ + userProject: + fileSourcedOptionsWithWorkforceUserProjectAndSA.workforce_pool_user_project, + }), + }, + }, + ]) + ); + scopes.push( + mockGenerateAccessToken([ + { + statusCode: 200, + response: saSuccessResponse, + token: stsSuccessfulResponse.access_token, + scopes: ['https://www.googleapis.com/auth/cloud-platform'], + }, + ]) + ); + + const client = new IdentityPoolClient( + fileSourcedOptionsWithWorkforceUserProjectAndSA + ); + const actualResponse = await client.getAccessToken(); + + // Confirm raw GaxiosResponse appended to response. + assertGaxiosResponsePresent(actualResponse); + delete actualResponse.res; + assert.deepStrictEqual(actualResponse, { + token: saSuccessResponse.accessToken, + }); + scopes.forEach(scope => scope.done()); + }); + it('should handle service account access token for text format', async () => { const now = new Date().getTime(); const saSuccessResponse = {