diff --git a/src/auth/baseexternalclient.ts b/src/auth/baseexternalclient.ts index af93a74d..ca2ca7e6 100644 --- a/src/auth/baseexternalclient.ts +++ b/src/auth/baseexternalclient.ts @@ -377,13 +377,19 @@ export abstract class BaseExternalAccountClient extends AuthClient { this.cachedAccessToken = await this.getImpersonatedAccessToken( stsResponse.access_token ); - } else { + } else if (stsResponse.expires_in) { // Save response in cached access token. this.cachedAccessToken = { access_token: stsResponse.access_token, expiry_date: new Date().getTime() + stsResponse.expires_in * 1000, res: stsResponse.res, }; + } else { + // Save response in cached access token. + this.cachedAccessToken = { + access_token: stsResponse.access_token, + res: stsResponse.res, + }; } // Save credentials. diff --git a/src/auth/downscopedclient.ts b/src/auth/downscopedclient.ts index bbd3d147..0c668029 100644 --- a/src/auth/downscopedclient.ts +++ b/src/auth/downscopedclient.ts @@ -262,10 +262,22 @@ export class DownscopedClient extends AuthClient { this.credentialAccessBoundary ); + /** + * The STS endpoint will only return the expiration time for the downscoped + * access token if the original access token represents a service account. + * The downscoped token's expiration time will always match the source + * credential expiration. When no expires_in is returned, we can copy the + * source credential's expiration time. + */ + const sourceCredExpireDate = + this.authClient.credentials?.expiry_date || null; + const expiryDate = stsResponse.expires_in + ? new Date().getTime() + stsResponse.expires_in * 1000 + : sourceCredExpireDate; // Save response in cached access token. this.cachedDownscopedAccessToken = { access_token: stsResponse.access_token, - expiry_date: new Date().getTime() + stsResponse.expires_in * 1000, + expiry_date: expiryDate, res: stsResponse.res, }; diff --git a/src/auth/stscredentials.ts b/src/auth/stscredentials.ts index 3fe2b2ee..497b1e76 100644 --- a/src/auth/stscredentials.ts +++ b/src/auth/stscredentials.ts @@ -120,7 +120,7 @@ export interface StsSuccessfulResponse { access_token: string; issued_token_type: string; token_type: string; - expires_in: number; + expires_in?: number; refresh_token?: string; scope?: string; res?: GaxiosResponse | null; diff --git a/test/test.baseexternalclient.ts b/test/test.baseexternalclient.ts index a5ede057..60599031 100644 --- a/test/test.baseexternalclient.ts +++ b/test/test.baseexternalclient.ts @@ -333,6 +333,61 @@ describe('BaseExternalAccountClient', () => { 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[] = []; + delete stsSuccessfulResponse2.expires_in; + + const scope = mockStsTokenExchange([ + { + statusCode: 200, + response: stsSuccessfulResponse2, + 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', + }, + }, + ]); + + const client = new TestExternalAccountClient( + externalAccountOptionsWithCreds + ); + // Listen to tokens events. On every event, push to list of + // emittedEvents. + client.on('tokens', tokens => { + emittedEvents.push(tokens); + }); + const actualResponse = await client.getAccessToken(); + + // tokens event should be triggered once with expected event. + assert.strictEqual(emittedEvents.length, 1); + assert.deepStrictEqual(emittedEvents[0], { + refresh_token: null, + expiry_date: undefined, + access_token: stsSuccessfulResponse.access_token, + token_type: 'Bearer', + id_token: null, + }); + + // Confirm raw GaxiosResponse appended to response. + assertGaxiosResponsePresent(actualResponse); + delete actualResponse.res; + assert.deepStrictEqual(actualResponse, { + token: stsSuccessfulResponse2.access_token, + }); + assert.deepStrictEqual(client.credentials.expiry_date, undefined); + assert.deepStrictEqual( + client.credentials.access_token, + stsSuccessfulResponse2.access_token + ); + scope.done(); + }); + it('should handle underlying token exchange errors', async () => { const errorResponse: OAuthErrorResponse = { error: 'invalid_request', diff --git a/test/test.downscopedclient.ts b/test/test.downscopedclient.ts index b857aac8..c41a0945 100644 --- a/test/test.downscopedclient.ts +++ b/test/test.downscopedclient.ts @@ -18,6 +18,7 @@ import * as nock from 'nock'; import * as sinon from 'sinon'; import {GaxiosOptions, GaxiosPromise} from 'gaxios'; +import {Credentials} from '../src/auth/credentials'; import {StsSuccessfulResponse} from '../src/auth/stscredentials'; import { DownscopedClient, @@ -49,6 +50,10 @@ class TestAuthClient extends AuthClient { throw new Error('Cannot get subject token.'); } + set expirationTime(expirationTime: number | undefined | null) { + this.credentials.expiry_date = expirationTime; + } + async getRequestHeaders(url?: string): Promise { throw new Error('Not implemented.'); } @@ -380,6 +385,7 @@ describe('DownscopedClient', () => { it('should refresh a new DownscopedClient access when cached one gets expired', async () => { const now = new Date().getTime(); clock = sinon.useFakeTimers(now); + const emittedEvents: Credentials[] = []; const credentials = { access_token: 'DOWNSCOPED_CLIENT_ACCESS_TOKEN', expiry_date: now + ONE_HOUR_IN_SECS * 1000, @@ -403,18 +409,40 @@ describe('DownscopedClient', () => { client, testClientAccessBoundary ); + // Listen to tokens events. On every event, push to list of + // emittedEvents. + downscopedClient.on('tokens', tokens => { + emittedEvents.push(tokens); + }); downscopedClient.setCredentials(credentials); clock.tick(ONE_HOUR_IN_SECS * 1000 - EXPIRATION_TIME_OFFSET - 1); const tokenResponse = await downscopedClient.getAccessToken(); + + // No new event should be triggered since the cached access token is + // returned. + assert.strictEqual(emittedEvents.length, 0); assert.deepStrictEqual(tokenResponse.token, credentials.access_token); clock.tick(1); const refreshedTokenResponse = await downscopedClient.getAccessToken(); + + const responseExpiresIn = stsSuccessfulResponse.expires_in as number; const expectedExpirationTime = credentials.expiry_date + - stsSuccessfulResponse.expires_in * 1000 - + responseExpiresIn * 1000 - EXPIRATION_TIME_OFFSET; + + // tokens event should be triggered once with expected event. + assert.strictEqual(emittedEvents.length, 1); + assert.deepStrictEqual(emittedEvents[0], { + refresh_token: null, + expiry_date: expectedExpirationTime, + access_token: stsSuccessfulResponse.access_token, + token_type: 'Bearer', + id_token: null, + }); + assert.deepStrictEqual( refreshedTokenResponse.token, stsSuccessfulResponse.access_token @@ -534,12 +562,124 @@ describe('DownscopedClient', () => { it('should throw when the source AuthClient rejects on token request', async () => { const expectedError = new Error('Cannot get subject token.'); client.throwError = true; + const downscopedClient = new DownscopedClient( client, testClientAccessBoundary ); await assert.rejects(downscopedClient.getAccessToken(), expectedError); }); + + it('should copy source cred expiry time if STS response does not return expiry time', async () => { + const now = new Date().getTime(); + const expireDate = now + ONE_HOUR_IN_SECS * 1000; + const stsSuccessfulResponseWithoutExpireInField = Object.assign( + {}, + stsSuccessfulResponse + ); + const emittedEvents: Credentials[] = []; + delete stsSuccessfulResponseWithoutExpireInField.expires_in; + const scope = mockStsTokenExchange([ + { + statusCode: 200, + response: stsSuccessfulResponseWithoutExpireInField, + request: { + grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange', + 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:access_token', + options: JSON.stringify(testClientAccessBoundary), + }, + }, + ]); + + client.expirationTime = expireDate; + const downscopedClient = new DownscopedClient( + client, + testClientAccessBoundary + ); + // Listen to tokens events. On every event, push to list of + // emittedEvents. + downscopedClient.on('tokens', tokens => { + emittedEvents.push(tokens); + }); + + const tokenResponse = await downscopedClient.getAccessToken(); + + // tokens event should be triggered once with expected event. + assert.strictEqual(emittedEvents.length, 1); + assert.deepStrictEqual(emittedEvents[0], { + refresh_token: null, + expiry_date: expireDate, + access_token: stsSuccessfulResponseWithoutExpireInField.access_token, + token_type: 'Bearer', + id_token: null, + }); + assert.deepStrictEqual(tokenResponse.expirationTime, expireDate); + assert.deepStrictEqual( + tokenResponse.token, + stsSuccessfulResponseWithoutExpireInField.access_token + ); + assert.strictEqual(downscopedClient.credentials.expiry_date, expireDate); + assert.strictEqual( + downscopedClient.credentials.access_token, + stsSuccessfulResponseWithoutExpireInField.access_token + ); + scope.done(); + }); + + it('should have no expiry date if source cred has no expiry time and STS response does not return one', async () => { + const stsSuccessfulResponseWithoutExpireInField = Object.assign( + {}, + stsSuccessfulResponse + ); + const emittedEvents: Credentials[] = []; + delete stsSuccessfulResponseWithoutExpireInField.expires_in; + const scope = mockStsTokenExchange([ + { + statusCode: 200, + response: stsSuccessfulResponseWithoutExpireInField, + request: { + grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange', + 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:access_token', + options: JSON.stringify(testClientAccessBoundary), + }, + }, + ]); + + const downscopedClient = new DownscopedClient( + client, + testClientAccessBoundary + ); + // Listen to tokens events. On every event, push to list of + // emittedEvents. + downscopedClient.on('tokens', tokens => { + emittedEvents.push(tokens); + }); + + const tokenResponse = await downscopedClient.getAccessToken(); + + // tokens event should be triggered once with expected event. + assert.strictEqual(emittedEvents.length, 1); + assert.deepStrictEqual(emittedEvents[0], { + refresh_token: null, + expiry_date: null, + access_token: stsSuccessfulResponseWithoutExpireInField.access_token, + token_type: 'Bearer', + id_token: null, + }); + assert.deepStrictEqual( + tokenResponse.token, + stsSuccessfulResponseWithoutExpireInField.access_token + ); + assert.deepStrictEqual(tokenResponse.expirationTime, null); + assert.deepStrictEqual(downscopedClient.credentials.expiry_date, null); + scope.done(); + }); }); describe('getRequestHeader()', () => {