Skip to content

Commit

Permalink
feat: Adds support for STS response not returning expires_in field. (#…
Browse files Browse the repository at this point in the history
  • Loading branch information
xil222 committed Aug 4, 2021
1 parent 49b70bf commit 24bb456
Show file tree
Hide file tree
Showing 5 changed files with 217 additions and 4 deletions.
8 changes: 7 additions & 1 deletion src/auth/baseexternalclient.ts
Expand Up @@ -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.
Expand Down
14 changes: 13 additions & 1 deletion src/auth/downscopedclient.ts
Expand Up @@ -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,
};

Expand Down
2 changes: 1 addition & 1 deletion src/auth/stscredentials.ts
Expand Up @@ -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;
Expand Down
55 changes: 55 additions & 0 deletions test/test.baseexternalclient.ts
Expand Up @@ -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',
Expand Down
142 changes: 141 additions & 1 deletion test/test.downscopedclient.ts
Expand Up @@ -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,
Expand Down Expand Up @@ -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<Headers> {
throw new Error('Not implemented.');
}
Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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()', () => {
Expand Down

0 comments on commit 24bb456

Please sign in to comment.