diff --git a/src/auth/awsclient.ts b/src/auth/awsclient.ts index 78575e07..4ab39182 100644 --- a/src/auth/awsclient.ts +++ b/src/auth/awsclient.ts @@ -100,6 +100,7 @@ export class AwsClient extends BaseExternalAccountClient { options.credential_source.imdsv2_session_token_url; this.awsRequestSigner = null; this.region = ''; + this.credentialSourceType = 'aws'; // Data validators. this.validateEnvironmentId(); diff --git a/src/auth/baseexternalclient.ts b/src/auth/baseexternalclient.ts index 1aab7123..f29c2787 100644 --- a/src/auth/baseexternalclient.ts +++ b/src/auth/baseexternalclient.ts @@ -59,6 +59,9 @@ export const CLOUD_RESOURCE_MANAGER = const WORKFORCE_AUDIENCE_PATTERN = '//iam.googleapis.com/locations/[^/]+/workforcePools/[^/]+/providers/.+'; +// eslint-disable-next-line @typescript-eslint/no-var-requires +const pkg = require('../../../package.json'); + /** * Base external account credentials json interface. */ @@ -141,6 +144,8 @@ export abstract class BaseExternalAccountClient extends AuthClient { public projectNumber: string | null; public readonly eagerRefreshThresholdMillis: number; public readonly forceRefreshOnFailure: boolean; + private readonly configLifetimeRequested: boolean; + protected credentialSourceType?: string; /** * Instantiate a BaseExternalAccountClient instance using the provided JSON * object loaded from an external account credentials file. @@ -191,9 +196,15 @@ export abstract class BaseExternalAccountClient extends AuthClient { } this.serviceAccountImpersonationUrl = options.service_account_impersonation_url; + this.serviceAccountImpersonationLifetime = - options.service_account_impersonation?.token_lifetime_seconds ?? - DEFAULT_TOKEN_LIFESPAN; + options.service_account_impersonation?.token_lifetime_seconds; + if (this.serviceAccountImpersonationLifetime) { + this.configLifetimeRequested = true; + } else { + this.configLifetimeRequested = false; + this.serviceAccountImpersonationLifetime = DEFAULT_TOKEN_LIFESPAN; + } // As threshold could be zero, // eagerRefreshThresholdMillis || EXPIRATION_TIME_OFFSET will override the // zero value. @@ -421,9 +432,12 @@ export abstract class BaseExternalAccountClient extends AuthClient { !this.clientAuth && this.workforcePoolUserProject ? {userProject: this.workforcePoolUserProject} : undefined; + const additionalHeaders: Headers = { + 'x-goog-api-client': this.getMetricsHeaderValue(), + }; const stsResponse = await this.stsCredential.exchangeToken( stsCredentialsOptions, - undefined, + additionalHeaders, additionalOptions ); @@ -544,4 +558,13 @@ export abstract class BaseExternalAccountClient extends AuthClient { return this.scopes; } } + + private getMetricsHeaderValue(): string { + const nodeVersion = process.version.replace(/^v/, ''); + const saImpersonation = this.serviceAccountImpersonationUrl !== undefined; + const credentialSourceType = this.credentialSourceType + ? this.credentialSourceType + : 'unknown'; + return `gl-node/${nodeVersion} auth/${pkg.version} google-byoid-sdk source/${credentialSourceType} sa-impersonation/${saImpersonation} config-lifetime/${this.configLifetimeRequested}`; + } } diff --git a/src/auth/identitypoolclient.ts b/src/auth/identitypoolclient.ts index 102025ed..38968bd5 100644 --- a/src/auth/identitypoolclient.ts +++ b/src/auth/identitypoolclient.ts @@ -86,8 +86,18 @@ export class IdentityPoolClient extends BaseExternalAccountClient { this.file = options.credential_source.file; this.url = options.credential_source.url; this.headers = options.credential_source.headers; - if (!this.file && !this.url) { - throw new Error('No valid Identity Pool "credential_source" provided'); + if (this.file && this.url) { + throw new Error( + 'No valid Identity Pool "credential_source" provided, must be either file or url.' + ); + } else if (this.file && !this.url) { + this.credentialSourceType = 'file'; + } else if (!this.file && this.url) { + this.credentialSourceType = 'url'; + } else { + throw new Error( + 'No valid Identity Pool "credential_source" provided, must be either file or url.' + ); } // Text is the default format type. this.formatType = options.credential_source.format?.type || 'text'; diff --git a/src/auth/pluggable-auth-client.ts b/src/auth/pluggable-auth-client.ts index d7be6ca8..8fb5a8f7 100644 --- a/src/auth/pluggable-auth-client.ts +++ b/src/auth/pluggable-auth-client.ts @@ -228,6 +228,8 @@ export class PluggableAuthClient extends BaseExternalAccountClient { timeoutMillis: this.timeoutMillis, outputFile: this.outputFile, }); + + this.credentialSourceType = 'executable'; } /** diff --git a/test/externalclienthelper.ts b/test/externalclienthelper.ts index ea96872c..81d190ef 100644 --- a/test/externalclienthelper.ts +++ b/test/externalclienthelper.ts @@ -56,6 +56,9 @@ export const saEmail = 'service-1234@service-name.iam.gserviceaccount.com'; const saBaseUrl = 'https://iamcredentials.googleapis.com'; const saPath = `/v1/projects/-/serviceAccounts/${saEmail}:generateAccessToken`; +// eslint-disable-next-line @typescript-eslint/no-var-requires +const pkg = require('../../package.json'); + export function mockStsTokenExchange( nockParams: NockMockStsToken[], additionalHeaders?: {[key: string]: string} @@ -132,3 +135,12 @@ export function mockCloudResourceManager( .get(`/v1/projects/${projectNumber}`) .reply(statusCode, response); } + +export function getExpectedExternalAccountMetricsHeaderValue( + expectedSource: string, + expectedSaImpersonation: boolean, + expectedConfigLifetime: boolean +): string { + const languageVersion = process.version.replace(/^v/, ''); + return `gl-node/${languageVersion} auth/${pkg.version} google-byoid-sdk source/${expectedSource} sa-impersonation/${expectedSaImpersonation} config-lifetime/${expectedConfigLifetime}`; +} diff --git a/test/test.awsclient.ts b/test/test.awsclient.ts index 2d83126e..9a99685c 100644 --- a/test/test.awsclient.ts +++ b/test/test.awsclient.ts @@ -26,6 +26,7 @@ import { getServiceAccountImpersonationUrl, mockGenerateAccessToken, mockStsTokenExchange, + getExpectedExternalAccountMetricsHeaderValue, } from './externalclienthelper'; nock.disableNetConnect(); @@ -989,6 +990,55 @@ describe('AwsClient', () => { }); scope.done(); }); + + 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( + 'aws', + false, + false + ), + } + ) + ); + scopes.push( + nock(metadataBaseUrl) + .get('/latest/meta-data/placement/availability-zone') + .reply(200, `${awsRegion}b`) + ); + process.env.AWS_ACCESS_KEY_ID = accessKeyId; + process.env.AWS_SECRET_ACCESS_KEY = secretAccessKey; + + const client = new AwsClient(awsOptions); + 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()); + }); }); }); }); diff --git a/test/test.baseexternalclient.ts b/test/test.baseexternalclient.ts index f409ad58..4aab6ec3 100644 --- a/test/test.baseexternalclient.ts +++ b/test/test.baseexternalclient.ts @@ -37,7 +37,9 @@ import { mockCloudResourceManager, mockGenerateAccessToken, mockStsTokenExchange, + getExpectedExternalAccountMetricsHeaderValue, } from './externalclienthelper'; +import {RefreshOptions} from '../src'; nock.disableNetConnect(); @@ -50,6 +52,14 @@ interface SampleResponse { class TestExternalAccountClient extends BaseExternalAccountClient { private counter = 0; + constructor( + options: BaseExternalAccountClientOptions, + additionalOptions?: RefreshOptions + ) { + super(options, additionalOptions); + this.credentialSourceType = 'test'; + } + async retrieveSubjectToken(): Promise { // Increment subject_token counter each time this is called. return `subject_token_${this.counter++}`; @@ -1020,6 +1030,44 @@ describe('BaseExternalAccountClient', () => { }); scope.done(); }); + + it('should send the correct x-goog-api-client header', async () => { + 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', + }, + }, + ], + { + 'x-goog-api-client': getExpectedExternalAccountMetricsHeaderValue( + 'test', + false, + false + ), + } + ); + + const client = new TestExternalAccountClient(externalAccountOptions); + 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(); + }); }); describe('with service account impersonation', () => { @@ -1567,6 +1615,118 @@ describe('BaseExternalAccountClient', () => { }); scopes.forEach(scope => scope.done()); }); + + it('should send correct x-goog-api-client header', 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: 'subject_token_0', + subject_token_type: 'urn:ietf:params:oauth:token-type:jwt', + }, + }, + ], + { + 'x-goog-api-client': getExpectedExternalAccountMetricsHeaderValue( + 'test', + true, + false + ), + } + ) + ); + scopes.push( + mockGenerateAccessToken({ + statusCode: 200, + response: saSuccessResponse, + token: stsSuccessfulResponse.access_token, + scopes: ['https://www.googleapis.com/auth/cloud-platform'], + }) + ); + + const client = new TestExternalAccountClient( + externalAccountOptionsWithSA + ); + 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 set correct x-goog-api-client header for custom token lifetime', 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: 'subject_token_0', + subject_token_type: 'urn:ietf:params:oauth:token-type:jwt', + }, + }, + ], + { + 'x-goog-api-client': getExpectedExternalAccountMetricsHeaderValue( + 'test', + true, + true + ), + } + ) + ); + scopes.push( + mockGenerateAccessToken({ + statusCode: 200, + response: saSuccessResponse, + token: stsSuccessfulResponse.access_token, + lifetime: 2800, + scopes: ['https://www.googleapis.com/auth/cloud-platform'], + }) + ); + + const externalAccountOptionsWithSATokenLifespan = Object.assign( + { + service_account_impersonation: { + token_lifetime_seconds: 2800, + }, + }, + externalAccountOptionsWithSA + ); + + const client = new TestExternalAccountClient( + externalAccountOptionsWithSATokenLifespan + ); + 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.identitypoolclient.ts b/test/test.identitypoolclient.ts index c7383757..1faa5bdd 100644 --- a/test/test.identitypoolclient.ts +++ b/test/test.identitypoolclient.ts @@ -30,6 +30,7 @@ import { getServiceAccountImpersonationUrl, mockGenerateAccessToken, mockStsTokenExchange, + getExpectedExternalAccountMetricsHeaderValue, } from './externalclienthelper'; nock.disableNetConnect(); @@ -201,9 +202,9 @@ describe('IdentityPoolClient', () => { 'credentials.' ); - it('should throw when invalid options are provided', () => { + it('should throw when neither file or url sources are provided', () => { const expectedError = new Error( - 'No valid Identity Pool "credential_source" provided' + 'No valid Identity Pool "credential_source" provided, must be either file or url.' ); const invalidOptions = { type: 'external_account', @@ -221,6 +222,27 @@ describe('IdentityPoolClient', () => { }, expectedError); }); + it('should throw when both file and url options are provided', () => { + const expectedError = new Error( + 'No valid Identity Pool "credential_source" provided, must be either file or url.' + ); + const invalidOptions = { + type: 'external_account', + audience, + subject_token_type: 'urn:ietf:params:oauth:token-type:jwt', + token_url: getTokenUrl(), + credential_source: { + file: 'filesource', + url: 'urlsource.com', + }, + }; + + assert.throws(() => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return new IdentityPoolClient(invalidOptions as any); + }, expectedError); + }); + it('should throw on invalid credential_source.format.type', () => { const expectedError = new Error('Invalid credential_source format "xml"'); const invalidOptions = { @@ -729,6 +751,45 @@ describe('IdentityPoolClient', () => { ) ); }); + + it('should send the correct x-goog-api-client header value', async () => { + 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 loaded from file should be used. + subject_token: fileSubjectToken, + subject_token_type: 'urn:ietf:params:oauth:token-type:jwt', + }, + }, + ], + { + 'x-goog-api-client': getExpectedExternalAccountMetricsHeaderValue( + 'file', + false, + false + ), + } + ); + + const client = new IdentityPoolClient(fileSourcedOptions); + 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(); + }); }); }); @@ -1037,6 +1098,56 @@ describe('IdentityPoolClient', () => { }); scope.done(); }); + + 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( + 'url', + false, + false + ), + } + ) + ); + scopes.push( + nock(metadataBaseUrl, { + reqheaders: metadataHeaders, + }) + .get(metadataPath) + .reply(200, externalSubjectToken) + ); + + const client = new IdentityPoolClient(urlSourcedOptions); + 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()); + }); }); }); }); diff --git a/test/test.pluggableauthclient.ts b/test/test.pluggableauthclient.ts index aea51776..17ecf351 100644 --- a/test/test.pluggableauthclient.ts +++ b/test/test.pluggableauthclient.ts @@ -17,11 +17,14 @@ import { ExecutableError, PluggableAuthClient, } from '../src/auth/pluggable-auth-client'; -import {BaseExternalAccountClient} from '../src'; +import {BaseExternalAccountClient, IdentityPoolClient} from '../src'; import { + assertGaxiosResponsePresent, getAudience, + getExpectedExternalAccountMetricsHeaderValue, getServiceAccountImpersonationUrl, getTokenUrl, + mockStsTokenExchange, saEmail, } from './externalclienthelper'; import {beforeEach} from 'mocha'; @@ -32,6 +35,7 @@ import { InvalidExpirationTimeFieldError, } from '../src/auth/executable-response'; import {PluggableAuthHandler} from '../src/auth/pluggable-auth-handler'; +import {StsSuccessfulResponse} from '../src/auth/stscredentials'; const OIDC_SUBJECT_TOKEN_TYPE1 = 'urn:ietf:params:oauth:token-type:id_token'; const SAML_SUBJECT_TOKEN_TYPE = 'urn:ietf:params:oauth:token-type:saml2'; @@ -92,6 +96,50 @@ describe('PluggableAuthClient', () => { credential_source: pluggableAuthCredentialSourceNoTimeout, }; + const sandbox = sinon.createSandbox(); + let clock: sinon.SinonFakeTimers; + const referenceTime = Date.now(); + let responseJson: ExecutableResponseJson; + let fileStub: sinon.SinonStub<[], Promise>; + let executableStub: sinon.SinonStub< + [envMap: Map], + Promise + >; + + beforeEach(() => { + // Set Allow Executables environment variables to 1 + const envVars = Object.assign({}, process.env, { + GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES: '1', + }); + sandbox.stub(process, 'env').value(envVars); + clock = sinon.useFakeTimers({now: referenceTime}); + + responseJson = { + success: true, + version: 1, + token_type: SAML_SUBJECT_TOKEN_TYPE, + saml_response: 'response', + expiration_time: referenceTime / 1000 + 10, + } as ExecutableResponseJson; + + fileStub = sandbox.stub( + PluggableAuthHandler.prototype, + 'retrieveCachedResponse' + ); + + executableStub = sandbox.stub( + PluggableAuthHandler.prototype, + 'retrieveResponseFromExecutable' + ); + }); + + afterEach(() => { + sandbox.restore(); + if (clock) { + clock.restore(); + } + }); + it('should be a subclass of ExternalAccountClient', () => { assert(PluggableAuthClient.prototype instanceof BaseExternalAccountClient); }); @@ -198,50 +246,6 @@ describe('PluggableAuthClient', () => { }); describe('RetrieveSubjectToken', () => { - const sandbox = sinon.createSandbox(); - let clock: sinon.SinonFakeTimers; - const referenceTime = Date.now(); - let responseJson: ExecutableResponseJson; - let fileStub: sinon.SinonStub<[], Promise>; - let executableStub: sinon.SinonStub< - [envMap: Map], - Promise - >; - - beforeEach(() => { - // Set Allow Executables environment variables to 1 - const envVars = Object.assign({}, process.env, { - GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES: '1', - }); - sandbox.stub(process, 'env').value(envVars); - clock = sinon.useFakeTimers({now: referenceTime}); - - responseJson = { - success: true, - version: 1, - token_type: SAML_SUBJECT_TOKEN_TYPE, - saml_response: 'response', - expiration_time: referenceTime / 1000 + 10, - } as ExecutableResponseJson; - - fileStub = sandbox.stub( - PluggableAuthHandler.prototype, - 'retrieveCachedResponse' - ); - - executableStub = sandbox.stub( - PluggableAuthHandler.prototype, - 'retrieveResponseFromExecutable' - ); - }); - - afterEach(() => { - sandbox.restore(); - if (clock) { - clock.restore(); - } - }); - it('should throw when allow executables environment variables is not 1', async () => { process.env.GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES = '0'; const expectedError = new Error( @@ -476,4 +480,59 @@ describe('PluggableAuthClient', () => { sinon.assert.calledOnceWithExactly(executableStub, expectedEnvMap); }); }); + + describe('GetAccessToken', () => { + const stsSuccessfulResponse: StsSuccessfulResponse = { + access_token: 'ACCESS_TOKEN', + issued_token_type: 'urn:ietf:params:oauth:token-type:access_token', + token_type: 'Bearer', + expires_in: 3600, + scope: 'scope1 scope2', + }; + + it('should set x-goog-api-client header correctly', async () => { + 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 loaded from file should be used. + subject_token: 'subject_token', + subject_token_type: OIDC_SUBJECT_TOKEN_TYPE1, + }, + }, + ], + { + 'x-goog-api-client': getExpectedExternalAccountMetricsHeaderValue( + 'executable', + false, + false + ), + } + ); + + const client = new PluggableAuthClient(pluggableAuthOptionsOIDC); + responseJson.id_token = 'subject_token'; + responseJson.token_type = OIDC_SUBJECT_TOKEN_TYPE1; + responseJson.saml_response = undefined; + fileStub.resolves(undefined); + executableStub.resolves(new ExecutableResponse(responseJson)); + + 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(); + }); + }); });