diff --git a/src/auth/awsclient.ts b/src/auth/awsclient.ts index a189c659..f55824d3 100644 --- a/src/auth/awsclient.ts +++ b/src/auth/awsclient.ts @@ -20,12 +20,13 @@ import { } from './baseexternalclient'; import {AuthClientOptions} from './authclient'; import {DefaultAwsSecurityCredentialsSupplier} from './defaultawssecuritycredentialssupplier'; +import {originalOrCamelOptions, SnakeToCamelObject} from '../util'; /** * AWS credentials JSON interface. This is used for AWS workloads. */ export interface AwsClientOptions extends BaseExternalAccountClientOptions { - credential_source: { + credential_source?: { environment_id: string; // Region can also be determined from the AWS_REGION or AWS_DEFAULT_REGION // environment variables. @@ -43,6 +44,7 @@ export interface AwsClientOptions extends BaseExternalAccountClientOptions { // The session token is required for IMDSv2 but optional for IMDSv1 imdsv2_session_token_url?: string; }; + aws_security_credentials_supplier?: AwsSecurityCredentialsSupplier; } /** @@ -82,12 +84,15 @@ export interface AwsSecurityCredentialsSupplier { * GCP access token. */ export class AwsClient extends BaseExternalAccountClient { - private readonly environmentId: string; + private readonly environmentId?: string; private readonly awsSecurityCredentialsSupplier: AwsSecurityCredentialsSupplier; private readonly regionalCredVerificationUrl: string; private awsRequestSigner: AwsRequestSigner | null; private region: string; + static #DEFAULT_AWS_REGIONAL_CREDENTIAL_VERIFICATION_URL = + 'https://sts.{region}.amazonaws.com?Action=GetCallerIdentity&Version=2011-06-15'; + /** * @deprecated AWS client no validates the EC2 metadata address. **/ @@ -109,34 +114,61 @@ export class AwsClient extends BaseExternalAccountClient { * on 401/403 API request errors. */ constructor( - options: AwsClientOptions, + options: AwsClientOptions | SnakeToCamelObject, additionalOptions?: AuthClientOptions ) { super(options, additionalOptions); - this.environmentId = options.credential_source.environment_id; - // This is only required if the AWS region is not available in the - // AWS_REGION or AWS_DEFAULT_REGION environment variables. - const regionUrl = options.credential_source.region_url; - // This is only required if AWS security credentials are not available in - // environment variables. - const securityCredentialsUrl = options.credential_source.url; - const imdsV2SessionTokenUrl = - options.credential_source.imdsv2_session_token_url; - this.awsSecurityCredentialsSupplier = - new DefaultAwsSecurityCredentialsSupplier({ - regionUrl: regionUrl, - securityCredentialsUrl: securityCredentialsUrl, - imdsV2SessionTokenUrl: imdsV2SessionTokenUrl, - }); + const opts = originalOrCamelOptions(options as AwsClientOptions); + const credentialSource = opts.get('credential_source'); + const awsSecurityCredentialsSupplier = opts.get( + 'aws_security_credentials_supplier' + ); + // Validate credential sourcing configuration. + if (!credentialSource && !awsSecurityCredentialsSupplier) { + throw new Error( + 'A credential source or AWS security credentials supplier must be specified.' + ); + } + if (credentialSource && awsSecurityCredentialsSupplier) { + throw new Error( + 'Only one of credential source or AWS security credentials supplier can be specified.' + ); + } + + if (awsSecurityCredentialsSupplier) { + this.awsSecurityCredentialsSupplier = awsSecurityCredentialsSupplier; + this.regionalCredVerificationUrl = + AwsClient.#DEFAULT_AWS_REGIONAL_CREDENTIAL_VERIFICATION_URL; + this.credentialSourceType = 'programmatic'; + } else { + const credentialSourceOpts = originalOrCamelOptions(credentialSource); + this.environmentId = credentialSourceOpts.get('environment_id'); + // This is only required if the AWS region is not available in the + // AWS_REGION or AWS_DEFAULT_REGION environment variables. + const regionUrl = credentialSourceOpts.get('region_url'); + // This is only required if AWS security credentials are not available in + // environment variables. + const securityCredentialsUrl = credentialSourceOpts.get('url'); + const imdsV2SessionTokenUrl = credentialSourceOpts.get( + 'imdsv2_session_token_url' + ); + this.awsSecurityCredentialsSupplier = + new DefaultAwsSecurityCredentialsSupplier({ + regionUrl: regionUrl, + securityCredentialsUrl: securityCredentialsUrl, + imdsV2SessionTokenUrl: imdsV2SessionTokenUrl, + }); - this.regionalCredVerificationUrl = - options.credential_source.regional_cred_verification_url; + this.regionalCredVerificationUrl = credentialSourceOpts.get( + 'regional_cred_verification_url' + ); + this.credentialSourceType = 'aws'; + + // Data validators. + this.validateEnvironmentId(); + } this.awsRequestSigner = null; this.region = ''; - this.credentialSourceType = 'aws'; - - // Data validators. - this.validateEnvironmentId(); } private validateEnvironmentId() { diff --git a/src/auth/baseexternalclient.ts b/src/auth/baseexternalclient.ts index 52af5464..c7b81710 100644 --- a/src/auth/baseexternalclient.ts +++ b/src/auth/baseexternalclient.ts @@ -64,6 +64,7 @@ export const CLOUD_RESOURCE_MANAGER = /** The workforce audience pattern. */ const WORKFORCE_AUDIENCE_PATTERN = '//iam\\.googleapis\\.com/locations/[^/]+/workforcePools/[^/]+/providers/.+'; +const DEFAULT_TOKEN_URL = 'https://sts.{universeDomain}/v1/token'; // eslint-disable-next-line @typescript-eslint/no-var-requires const pkg = require('../../../package.json'); @@ -75,7 +76,7 @@ export {DEFAULT_UNIVERSE} from './authclient'; export interface SharedExternalAccountClientOptions extends AuthClientOptions { audience: string; - token_url: string; + token_url?: string; } /** @@ -108,7 +109,7 @@ export interface ExternalAccountSupplierContext { */ export interface BaseExternalAccountClientOptions extends SharedExternalAccountClientOptions { - type: string; + type?: string; subject_token_type: string; service_account_impersonation_url?: string; service_account_impersonation?: { @@ -217,7 +218,8 @@ export abstract class BaseExternalAccountClient extends AuthClient { options as BaseExternalAccountClientOptions ); - if (opts.get('type') !== EXTERNAL_ACCOUNT_TYPE) { + const type = opts.get('type'); + if (type && type !== EXTERNAL_ACCOUNT_TYPE) { throw new Error( `Expected "${EXTERNAL_ACCOUNT_TYPE}" type but ` + `received "${options.type}"` @@ -226,7 +228,9 @@ export abstract class BaseExternalAccountClient extends AuthClient { const clientId = opts.get('client_id'); const clientSecret = opts.get('client_secret'); - const tokenUrl = opts.get('token_url'); + const tokenUrl = + opts.get('token_url') ?? + DEFAULT_TOKEN_URL.replace('{universeDomain}', this.universeDomain); const subjectTokenType = opts.get('subject_token_type'); const workforcePoolUserProject = opts.get('workforce_pool_user_project'); const serviceAccountImpersonationUrl = opts.get( diff --git a/src/auth/externalAccountAuthorizedUserClient.ts b/src/auth/externalAccountAuthorizedUserClient.ts index c9534440..734005c0 100644 --- a/src/auth/externalAccountAuthorizedUserClient.ts +++ b/src/auth/externalAccountAuthorizedUserClient.ts @@ -39,6 +39,7 @@ import { */ export const EXTERNAL_ACCOUNT_AUTHORIZED_USER_TYPE = 'external_account_authorized_user'; +const DEFAULT_TOKEN_URL = 'https://sts.{universeDomain}/v1/oauthtoken'; /** * External Account Authorized User Credentials JSON interface. @@ -171,6 +172,9 @@ export class ExternalAccountAuthorizedUserClient extends AuthClient { additionalOptions?: AuthClientOptions ) { super({...options, ...additionalOptions}); + if (options.universe_domain) { + this.universeDomain = options.universe_domain; + } this.refreshToken = options.refresh_token; const clientAuth = { confidentialClientType: 'basic', @@ -179,7 +183,8 @@ export class ExternalAccountAuthorizedUserClient extends AuthClient { } as ClientAuthentication; this.externalAccountAuthorizedUserHandler = new ExternalAccountAuthorizedUserHandler( - options.token_url, + options.token_url ?? + DEFAULT_TOKEN_URL.replace('{universeDomain}', this.universeDomain), this.transporter, clientAuth ); @@ -197,10 +202,6 @@ export class ExternalAccountAuthorizedUserClient extends AuthClient { .eagerRefreshThresholdMillis as number; } this.forceRefreshOnFailure = !!additionalOptions?.forceRefreshOnFailure; - - if (options.universe_domain) { - this.universeDomain = options.universe_domain; - } } async getAccessToken(): Promise<{ diff --git a/src/auth/identitypoolclient.ts b/src/auth/identitypoolclient.ts index 145cd1f0..54f58fcc 100644 --- a/src/auth/identitypoolclient.ts +++ b/src/auth/identitypoolclient.ts @@ -53,7 +53,7 @@ export interface SubjectTokenSupplier { */ export interface IdentityPoolClientOptions extends BaseExternalAccountClientOptions { - credential_source: { + credential_source?: { file?: string; url?: string; headers?: { @@ -64,6 +64,7 @@ export interface IdentityPoolClientOptions subject_token_field_name?: string; }; }; + subject_token_supplier?: SubjectTokenSupplier; } /** @@ -97,53 +98,71 @@ export class IdentityPoolClient extends BaseExternalAccountClient { const opts = originalOrCamelOptions(options as IdentityPoolClientOptions); const credentialSource = opts.get('credential_source'); - const credentialSourceOpts = originalOrCamelOptions(credentialSource); - - const formatOpts = originalOrCamelOptions( - credentialSourceOpts.get('format') - ); - - // Text is the default format type. - const formatType = formatOpts.get('type') || 'text'; - const formatSubjectTokenFieldName = formatOpts.get( - 'subject_token_field_name' - ); - - if (formatType !== 'json' && formatType !== 'text') { - throw new Error(`Invalid credential_source format "${formatType}"`); - } - if (formatType === 'json' && !formatSubjectTokenFieldName) { + const subjectTokenSupplier = opts.get('subject_token_supplier'); + // Validate credential sourcing configuration. + if (!credentialSource && !subjectTokenSupplier) { throw new Error( - 'Missing subject_token_field_name for JSON credential_source format' + 'A credential source or subject token supplier must be specified.' ); } - - const file = credentialSourceOpts.get('file'); - const url = credentialSourceOpts.get('url'); - const headers = credentialSourceOpts.get('headers'); - if (file && url) { + if (credentialSource && subjectTokenSupplier) { throw new Error( - 'No valid Identity Pool "credential_source" provided, must be either file or url.' + 'Only one of credential source or subject token supplier can be specified.' ); - } else if (file && !url) { - this.credentialSourceType = 'file'; - this.subjectTokenSupplier = new FileSubjectTokenSupplier({ - filePath: file, - formatType: formatType, - subjectTokenFieldName: formatSubjectTokenFieldName, - }); - } else if (!file && url) { - this.credentialSourceType = 'url'; - this.subjectTokenSupplier = new UrlSubjectTokenSupplier({ - url: url, - formatType: formatType, - subjectTokenFieldName: formatSubjectTokenFieldName, - headers: headers, - }); + } + + if (subjectTokenSupplier) { + this.subjectTokenSupplier = subjectTokenSupplier; + this.credentialSourceType = 'programmatic'; } else { - throw new Error( - 'No valid Identity Pool "credential_source" provided, must be either file or url.' + const credentialSourceOpts = originalOrCamelOptions(credentialSource); + + const formatOpts = originalOrCamelOptions( + credentialSourceOpts.get('format') + ); + + // Text is the default format type. + const formatType = formatOpts.get('type') || 'text'; + const formatSubjectTokenFieldName = formatOpts.get( + 'subject_token_field_name' ); + + if (formatType !== 'json' && formatType !== 'text') { + throw new Error(`Invalid credential_source format "${formatType}"`); + } + if (formatType === 'json' && !formatSubjectTokenFieldName) { + throw new Error( + 'Missing subject_token_field_name for JSON credential_source format' + ); + } + + const file = credentialSourceOpts.get('file'); + const url = credentialSourceOpts.get('url'); + const headers = credentialSourceOpts.get('headers'); + if (file && url) { + throw new Error( + 'No valid Identity Pool "credential_source" provided, must be either file or url.' + ); + } else if (file && !url) { + this.credentialSourceType = 'file'; + this.subjectTokenSupplier = new FileSubjectTokenSupplier({ + filePath: file, + formatType: formatType, + subjectTokenFieldName: formatSubjectTokenFieldName, + }); + } else if (!file && url) { + this.credentialSourceType = 'url'; + this.subjectTokenSupplier = new UrlSubjectTokenSupplier({ + url: url, + formatType: formatType, + subjectTokenFieldName: formatSubjectTokenFieldName, + headers: headers, + }); + } else { + throw new Error( + 'No valid Identity Pool "credential_source" provided, must be either file or url.' + ); + } } } diff --git a/test/test.awsclient.ts b/test/test.awsclient.ts index 800f670b..c83a2745 100644 --- a/test/test.awsclient.ts +++ b/test/test.awsclient.ts @@ -16,9 +16,12 @@ import * as assert from 'assert'; import {describe, it, afterEach, beforeEach} from 'mocha'; import * as nock from 'nock'; import * as sinon from 'sinon'; -import {AwsClient} from '../src/auth/awsclient'; +import {AwsClient, AwsSecurityCredentialsSupplier} from '../src/auth/awsclient'; import {StsSuccessfulResponse} from '../src/auth/stscredentials'; -import {BaseExternalAccountClient} from '../src/auth/baseexternalclient'; +import { + BaseExternalAccountClient, + ExternalAccountSupplierContext, +} from '../src/auth/baseexternalclient'; import { assertGaxiosResponsePresent, getAudience, @@ -28,6 +31,7 @@ import { mockStsTokenExchange, getExpectedExternalAccountMetricsHeaderValue, } from './externalclienthelper'; +import {AwsSecurityCredentials} from '../src/auth/awsrequestsigner'; nock.disableNetConnect(); @@ -265,6 +269,36 @@ describe('AwsClient', () => { assert.throws(() => new AwsClient(invalidOptions), expectedError); }); + it('should throw when both a credential source and supplier are provided', () => { + const expectedError = new Error( + 'Only one of credential source or AWS security credentials supplier can be specified.' + ); + const invalidOptions = { + type: 'external_account', + audience, + subject_token_type: 'urn:ietf:params:aws:token-type:aws4_request', + token_url: getTokenUrl(), + credential_source: awsCredentialSource, + aws_security_credentials_supplier: new TestAwsSupplier({}), + }; + + assert.throws(() => new AwsClient(invalidOptions), expectedError); + }); + + it('should throw when neither a credential source or supplier are provided', () => { + const expectedError = new Error( + 'A credential source or AWS security credentials supplier must be specified.' + ); + const invalidOptions = { + type: 'external_account', + audience, + subject_token_type: 'urn:ietf:params:aws:token-type:aws4_request', + token_url: getTokenUrl(), + }; + + assert.throws(() => new AwsClient(invalidOptions), expectedError); + }); + it('should not throw when valid AWS options are provided', () => { assert.doesNotThrow(() => { return new AwsClient(awsOptions); @@ -1042,4 +1076,239 @@ describe('AwsClient', () => { }); }); }); + + describe('for custom supplier retrieved tokens', () => { + describe('retrieveSubjectToken()', () => { + it('should resolve on success for permanent creds', async () => { + const supplier = new TestAwsSupplier({ + credentials: { + accessKeyId: accessKeyId, + secretAccessKey: secretAccessKey, + }, + region: awsRegion, + }); + const options = { + aws_security_credentials_supplier: supplier, + audience: audience, + subject_token_type: 'urn:ietf:params:aws:token-type:aws4_request', + }; + + const client = new AwsClient(options); + const subjectToken = await client.retrieveSubjectToken(); + + assert.deepEqual(subjectToken, expectedSubjectTokenNoToken); + }); + + it('should resolve on success for temporary creds', async () => { + const supplier = new TestAwsSupplier({ + credentials: { + accessKeyId: accessKeyId, + secretAccessKey: secretAccessKey, + token: token, + }, + region: awsRegion, + }); + const options = { + aws_security_credentials_supplier: supplier, + audience: audience, + subject_token_type: 'urn:ietf:params:aws:token-type:aws4_request', + }; + + const client = new AwsClient(options); + const subjectToken = await client.retrieveSubjectToken(); + + assert.deepEqual(subjectToken, expectedSubjectToken); + }); + + it('should reject when getAwsRegion() throws an error', async () => { + const expectedError = new Error('expected error message'); + const supplier = new TestAwsSupplier({ + regionError: expectedError, + }); + const options = { + aws_security_credentials_supplier: supplier, + audience: audience, + subject_token_type: 'urn:ietf:params:aws:token-type:aws4_request', + }; + + const client = new AwsClient(options); + + await assert.rejects(client.retrieveSubjectToken(), expectedError); + }); + + it('should reject when getAwsSecurityCredentials() throws an error', async () => { + const expectedError = new Error('expected error message'); + const supplier = new TestAwsSupplier({ + region: awsRegion, + credentialsError: expectedError, + }); + const options = { + aws_security_credentials_supplier: supplier, + audience: audience, + subject_token_type: 'urn:ietf:params:aws:token-type:aws4_request', + }; + + const client = new AwsClient(options); + + await assert.rejects(client.retrieveSubjectToken(), expectedError); + }); + }); + + describe('getAccessToken()', () => { + it('should resolve on retrieveSubjectToken success', async () => { + const scopes: nock.Scope[] = []; + scopes.push( + mockStsTokenExchange([ + { + statusCode: 200, + response: stsSuccessfulResponse, + request: { + grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange', + audience, + scope: 'https://www.googleapis.com/auth/cloud-platform', + requested_token_type: + 'urn:ietf:params:oauth:token-type:access_token', + subject_token: expectedSubjectTokenNoToken, + subject_token_type: + 'urn:ietf:params:aws:token-type:aws4_request', + }, + }, + ]) + ); + const supplier = new TestAwsSupplier({ + credentials: { + accessKeyId: accessKeyId, + secretAccessKey: secretAccessKey, + }, + region: awsRegion, + }); + const options = { + aws_security_credentials_supplier: supplier, + audience: audience, + subject_token_type: 'urn:ietf:params:aws:token-type:aws4_request', + }; + + const client = new AwsClient(options); + const actualResponse = await client.getAccessToken(); + + // Confirm raw GaxiosResponse appended to response. + assertGaxiosResponsePresent(actualResponse); + delete actualResponse.res; + assert.deepStrictEqual(actualResponse, { + token: stsSuccessfulResponse.access_token, + }); + scopes.forEach(scope => scope.done()); + }); + + it('should reject on retrieveSubjectToken error', async () => { + const expectedError = new Error('expected error message'); + const supplier = new TestAwsSupplier({ + region: awsRegion, + credentialsError: expectedError, + }); + const options = { + aws_security_credentials_supplier: supplier, + audience: audience, + subject_token_type: 'urn:ietf:params:aws:token-type:aws4_request', + }; + + const client = new AwsClient(options); + + await assert.rejects(client.getAccessToken(), expectedError); + }); + + it('should set x-goog-api-client header correctly', async () => { + const scopes: nock.Scope[] = []; + scopes.push( + mockStsTokenExchange( + [ + { + statusCode: 200, + response: stsSuccessfulResponse, + request: { + grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange', + audience, + scope: 'https://www.googleapis.com/auth/cloud-platform', + requested_token_type: + 'urn:ietf:params:oauth:token-type:access_token', + subject_token: expectedSubjectTokenNoToken, + subject_token_type: + 'urn:ietf:params:aws:token-type:aws4_request', + }, + }, + ], + { + 'x-goog-api-client': getExpectedExternalAccountMetricsHeaderValue( + 'programmatic', + false, + false + ), + } + ) + ); + const supplier = new TestAwsSupplier({ + credentials: { + accessKeyId: accessKeyId, + secretAccessKey: secretAccessKey, + }, + region: awsRegion, + }); + const options = { + aws_security_credentials_supplier: supplier, + audience: audience, + subject_token_type: 'urn:ietf:params:aws:token-type:aws4_request', + }; + + const client = new AwsClient(options); + const actualResponse = await client.getAccessToken(); + + // Confirm raw GaxiosResponse appended to response. + assertGaxiosResponsePresent(actualResponse); + delete actualResponse.res; + assert.deepStrictEqual(actualResponse, { + token: stsSuccessfulResponse.access_token, + }); + scopes.forEach(scope => scope.done()); + }); + }); + }); }); + +interface TestAwsSupplierOptions { + credentials?: AwsSecurityCredentials; + region?: string; + credentialsError?: Error; + regionError?: Error; +} + +class TestAwsSupplier implements AwsSecurityCredentialsSupplier { + private readonly credentials?: AwsSecurityCredentials; + private readonly region?: string; + private readonly credentialsError?: Error; + private readonly regionError?: Error; + + constructor(options: TestAwsSupplierOptions) { + this.credentials = options.credentials; + this.region = options.region; + this.credentialsError = options.credentialsError; + this.regionError = options.regionError; + } + + async getAwsRegion(context: ExternalAccountSupplierContext): Promise { + if (this.regionError) { + throw this.regionError; + } else { + return this.region ?? ''; + } + } + + async getAwsSecurityCredentials( + context: ExternalAccountSupplierContext + ): Promise { + if (this.credentialsError) { + throw this.credentialsError; + } else { + return this.credentials ?? {accessKeyId: '', secretAccessKey: ''}; + } + } +} diff --git a/test/test.baseexternalclient.ts b/test/test.baseexternalclient.ts index 96ea57ce..46b80359 100644 --- a/test/test.baseexternalclient.ts +++ b/test/test.baseexternalclient.ts @@ -82,6 +82,14 @@ describe('BaseExternalAccountClient', () => { file: '/var/run/secrets/goog.id/token', }, }; + const externalAccountOptionsNoUrl = { + type: 'external_account', + audience, + subject_token_type: 'urn:ietf:params:oauth:token-type:jwt', + credential_source: { + file: '/var/run/secrets/goog.id/token', + }, + }; const externalAccountOptionsWithCreds = { type: 'external_account', audience, @@ -262,6 +270,62 @@ describe('BaseExternalAccountClient', () => { refreshOptions.eagerRefreshThresholdMillis ); }); + + it('should set default token url', async () => { + const client = new TestExternalAccountClient(externalAccountOptionsNoUrl); + const scope = mockStsTokenExchange([ + { + statusCode: 200, + response: stsSuccessfulResponse, + request: { + grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange', + audience, + 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:jwt', + }, + }, + ]); + + await client.getAccessToken(); + + scope.done(); + }); + + it('should set universe domain on default token url', async() => { + const options: BaseExternalAccountClientOptions = { + ...externalAccountOptionsNoUrl, + universe_domain: 'test.com', + }; + + const client = new TestExternalAccountClient(options); + + const scope = mockStsTokenExchange( + [ + { + statusCode: 200, + response: stsSuccessfulResponse, + request: { + grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange', + audience, + 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:jwt', + }, + }, + ], + {}, + 'https://sts.test.com' + ); + + await client.getAccessToken(); + + scope.done(); + }); }); describe('projectNumber', () => { diff --git a/test/test.externalaccountauthorizeduserclient.ts b/test/test.externalaccountauthorizeduserclient.ts index c97e4b03..d106c4d6 100644 --- a/test/test.externalaccountauthorizeduserclient.ts +++ b/test/test.externalaccountauthorizeduserclient.ts @@ -95,6 +95,14 @@ describe('ExternalAccountAuthorizedUserClient', () => { token_url: TOKEN_REFRESH_URL, token_info_url: TOKEN_INFO_URL, } as ExternalAccountAuthorizedUserClientOptions; + const externalAccountAuthorizedUserCredentialOptionsNoToken = { + type: EXTERNAL_ACCOUNT_AUTHORIZED_USER_TYPE, + audience: audience, + client_id: 'clientId', + client_secret: 'clientSecret', + refresh_token: 'refreshToken', + token_info_url: TOKEN_INFO_URL, + } as ExternalAccountAuthorizedUserClientOptions; const successfulRefreshResponse = { access_token: 'newAccessToken', refresh_token: 'newRefreshToken', @@ -133,6 +141,44 @@ describe('ExternalAccountAuthorizedUserClient', () => { assert(client.eagerRefreshThresholdMillis === EXPIRATION_TIME_OFFSET); }); + it('should set default token url', async () => { + const scope = mockStsTokenRefresh(BASE_URL, REFRESH_PATH, [ + { + statusCode: 200, + response: successfulRefreshResponse, + request: { + grant_type: 'refresh_token', + refresh_token: 'refreshToken', + }, + }, + ]); + + const client = new ExternalAccountAuthorizedUserClient( + externalAccountAuthorizedUserCredentialOptionsNoToken + ); + await client.getAccessToken(); + scope.done(); + }); + + it('should set universe domain token url', async () => { + const scope = mockStsTokenRefresh('https://sts.test.com', REFRESH_PATH, [ + { + statusCode: 200, + response: successfulRefreshResponse, + request: { + grant_type: 'refresh_token', + refresh_token: 'refreshToken', + }, + }, + ]); + const client = new ExternalAccountAuthorizedUserClient({ + ...externalAccountAuthorizedUserCredentialOptionsNoToken, + universe_domain: 'test.com', + }); + await client.getAccessToken(); + scope.done(); + }); + it('should set custom RefreshOptions', () => { const refreshOptions = { eagerRefreshThresholdMillis: 5000, diff --git a/test/test.identitypoolclient.ts b/test/test.identitypoolclient.ts index 1faa5bdd..ac9317c7 100644 --- a/test/test.identitypoolclient.ts +++ b/test/test.identitypoolclient.ts @@ -20,9 +20,13 @@ import {createCrypto} from '../src/crypto/crypto'; import { IdentityPoolClient, IdentityPoolClientOptions, + SubjectTokenSupplier, } from '../src/auth/identitypoolclient'; import {StsSuccessfulResponse} from '../src/auth/stscredentials'; -import {BaseExternalAccountClient} from '../src/auth/baseexternalclient'; +import { + BaseExternalAccountClient, + ExternalAccountSupplierContext, +} from '../src/auth/baseexternalclient'; import { assertGaxiosResponsePresent, getAudience, @@ -302,6 +306,42 @@ describe('IdentityPoolClient', () => { } ); + it('should throw when neither a credential source or a supplier is provided', () => { + const expectedError = new Error( + 'A credential source or subject token supplier must be specified.' + ); + const invalidOptions = { + type: 'external_account', + audience, + subject_token_type: 'urn:ietf:params:oauth:token-type:jwt', + token_url: getTokenUrl(), + }; + + assert.throws(() => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return new IdentityPoolClient(invalidOptions as any); + }, expectedError); + }); + + it('should throw when both a credential source and a supplier is provided', () => { + const expectedError = new Error( + 'Only one of credential source or subject token supplier can be specified.' + ); + const invalidOptions = { + type: 'external_account', + audience, + subject_token_type: 'urn:ietf:params:oauth:token-type:jwt', + token_url: getTokenUrl(), + credential_source: {}, + subject_token_supplier: new TestSubjectTokenSupplier({}), + }; + + assert.throws(() => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return new IdentityPoolClient(invalidOptions as any); + }, expectedError); + }); + it('should not throw when valid file-sourced options are provided', () => { assert.doesNotThrow(() => { return new IdentityPoolClient(fileSourcedOptions); @@ -314,10 +354,21 @@ describe('IdentityPoolClient', () => { }); }); + it('should not throw when subject token supplier is provided', () => { + const options = { + audience: audience, + subject_token_type: 'urn:ietf:params:oauth:token-type:jwt', + subject_token_supplier: new TestSubjectTokenSupplier({}), + }; + assert.doesNotThrow(() => { + return new IdentityPoolClient(options); + }); + }); + it('should not throw on headerless url-sourced options', () => { const urlSourcedOptionsNoHeaders = Object.assign({}, urlSourcedOptions); urlSourcedOptionsNoHeaders.credential_source = { - url: urlSourcedOptions.credential_source.url, + url: urlSourcedOptions.credential_source?.url, }; assert.doesNotThrow(() => { return new IdentityPoolClient(urlSourcedOptionsNoHeaders); @@ -865,7 +916,7 @@ describe('IdentityPoolClient', () => { // Create options without headers. const urlSourcedOptionsNoHeaders = Object.assign({}, urlSourcedOptions); urlSourcedOptionsNoHeaders.credential_source = { - url: urlSourcedOptions.credential_source.url, + url: urlSourcedOptions.credential_source?.url, }; const externalSubjectToken = 'SUBJECT_TOKEN_1'; const scope = nock(metadataBaseUrl) @@ -1150,4 +1201,219 @@ describe('IdentityPoolClient', () => { }); }); }); + + describe('for supplier-sourced subject tokens', () => { + describe('retrieveSubjectToken()', () => { + it('should resolve when the subject token is returned', async () => { + const options = { + audience: audience, + subject_token_type: 'urn:ietf:params:oauth:token-type:jwt', + subject_token_supplier: new TestSubjectTokenSupplier({ + subjectToken: 'TestTokenValue', + }), + }; + const client = new IdentityPoolClient(options); + const subjectToken = await client.retrieveSubjectToken(); + + assert.deepEqual(subjectToken, 'TestTokenValue'); + }); + + it('should return when the an error is returned', async () => { + const expectedError = new Error('Test error from supplier.'); + const options = { + audience: audience, + subject_token_type: 'urn:ietf:params:oauth:token-type:jwt', + subject_token_supplier: new TestSubjectTokenSupplier({ + error: expectedError, + }), + }; + const client = new IdentityPoolClient(options); + + await assert.rejects(client.retrieveSubjectToken(), expectedError); + }); + }); + + describe('getAccessToken()', () => { + it('should resolve on retrieveSubjectToken success', async () => { + const externalSubjectToken = 'SUBJECT_TOKEN_1'; + const scopes: nock.Scope[] = []; + scopes.push( + mockStsTokenExchange([ + { + statusCode: 200, + response: stsSuccessfulResponse, + request: { + grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange', + audience, + scope: 'https://www.googleapis.com/auth/cloud-platform', + requested_token_type: + 'urn:ietf:params:oauth:token-type:access_token', + // Subject token retrieved from url should be used. + subject_token: externalSubjectToken, + subject_token_type: 'urn:ietf:params:oauth:token-type:jwt', + }, + }, + ]) + ); + + const options = { + audience: audience, + subject_token_type: 'urn:ietf:params:oauth:token-type:jwt', + subject_token_supplier: new TestSubjectTokenSupplier({ + subjectToken: externalSubjectToken, + }), + }; + const client = new IdentityPoolClient(options); + const actualResponse = await client.getAccessToken(); + + // Confirm raw GaxiosResponse appended to response. + assertGaxiosResponsePresent(actualResponse); + delete actualResponse.res; + assert.deepStrictEqual(actualResponse, { + token: stsSuccessfulResponse.access_token, + }); + scopes.forEach(scope => scope.done()); + }); + + it('should handle service account access token', async () => { + const now = new Date().getTime(); + const saSuccessResponse = { + accessToken: 'SA_ACCESS_TOKEN', + expireTime: new Date(now + ONE_HOUR_IN_SECS * 1000).toISOString(), + }; + const externalSubjectToken = 'SUBJECT_TOKEN_1'; + const scopes: nock.Scope[] = []; + scopes.push( + mockStsTokenExchange([ + { + statusCode: 200, + response: stsSuccessfulResponse, + request: { + grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange', + audience, + scope: 'https://www.googleapis.com/auth/cloud-platform', + requested_token_type: + 'urn:ietf:params:oauth:token-type:access_token', + // Subject token retrieved from url should be used. + subject_token: externalSubjectToken, + subject_token_type: 'urn:ietf:params:oauth:token-type:jwt', + }, + }, + ]), + mockGenerateAccessToken({ + statusCode: 200, + response: saSuccessResponse, + token: stsSuccessfulResponse.access_token, + scopes: ['https://www.googleapis.com/auth/cloud-platform'], + }) + ); + + const options = { + audience: audience, + subject_token_type: 'urn:ietf:params:oauth:token-type:jwt', + service_account_impersonation_url: + getServiceAccountImpersonationUrl(), + subject_token_supplier: new TestSubjectTokenSupplier({ + subjectToken: externalSubjectToken, + }), + }; + const client = new IdentityPoolClient(options); + 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 reject with retrieveSubjectToken error', async () => { + const expectedError = new Error('Test error from supplier.'); + const options = { + audience: audience, + subject_token_type: 'urn:ietf:params:oauth:token-type:jwt', + subject_token_supplier: new TestSubjectTokenSupplier({ + error: expectedError, + }), + }; + const client = new IdentityPoolClient(options); + + await assert.rejects(client.getAccessToken(), expectedError); + }); + + it('should send the correct x-goog-api-client header value', async () => { + const externalSubjectToken = 'SUBJECT_TOKEN_1'; + const scopes: nock.Scope[] = []; + scopes.push( + mockStsTokenExchange( + [ + { + statusCode: 200, + response: stsSuccessfulResponse, + request: { + grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange', + audience, + scope: 'https://www.googleapis.com/auth/cloud-platform', + requested_token_type: + 'urn:ietf:params:oauth:token-type:access_token', + // Subject token retrieved from url should be used. + subject_token: externalSubjectToken, + subject_token_type: 'urn:ietf:params:oauth:token-type:jwt', + }, + }, + ], + { + 'x-goog-api-client': getExpectedExternalAccountMetricsHeaderValue( + 'programmatic', + false, + false + ), + } + ) + ); + + const options = { + audience: audience, + subject_token_type: 'urn:ietf:params:oauth:token-type:jwt', + subject_token_supplier: new TestSubjectTokenSupplier({ + subjectToken: externalSubjectToken, + }), + }; + const client = new IdentityPoolClient(options); + const actualResponse = await client.getAccessToken(); + + // Confirm raw GaxiosResponse appended to response. + assertGaxiosResponsePresent(actualResponse); + delete actualResponse.res; + assert.deepStrictEqual(actualResponse, { + token: stsSuccessfulResponse.access_token, + }); + scopes.forEach(scope => scope.done()); + }); + }); + }); }); + +interface TestSubjectTokenSupplierOptions { + subjectToken?: string; + error?: Error; +} + +class TestSubjectTokenSupplier implements SubjectTokenSupplier { + private readonly subjectToken: string; + private readonly error?: Error; + + constructor(options: TestSubjectTokenSupplierOptions) { + this.subjectToken = options.subjectToken ?? ''; + this.error = options.error; + } + + getSubjectToken(context: ExternalAccountSupplierContext): Promise { + if (this.error) { + throw this.error; + } + return Promise.resolve(this.subjectToken); + } +}