Skip to content

Commit

Permalink
Implements exposing refreshHandler callback to handle token refresh.
Browse files Browse the repository at this point in the history
  • Loading branch information
xil222 committed Aug 5, 2021
1 parent 6799239 commit 207386a
Show file tree
Hide file tree
Showing 2 changed files with 145 additions and 2 deletions.
56 changes: 54 additions & 2 deletions src/auth/oauth2client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -298,6 +298,11 @@ export interface GenerateAuthUrlOpts {
code_challenge?: string;
}

export interface DownscopedAccessTokenResponse {
access_token: string | null;
expiry_date: number | null;
}

export interface GetTokenCallback {
(
err: GaxiosError | null,
Expand Down Expand Up @@ -749,7 +754,13 @@ export class OAuth2Client extends AuthClient {
!this.credentials.access_token || this.isTokenExpiring();
if (shouldRefresh) {
if (!this.credentials.refresh_token) {
throw new Error('No refresh token is set.');
const downscopedCreds = await this.refreshHandler();
if (downscopedCreds) {
this.setCredentials(downscopedCreds);
return {token: this.credentials.access_token};
} else {
throw new Error('No refresh token is set.');
}
}

const r = await this.refreshAccessTokenAsync();
Expand Down Expand Up @@ -793,6 +804,15 @@ export class OAuth2Client extends AuthClient {
return {headers: this.addSharedMetadataHeaders(headers)};
}

const downscopedCreds = await this.refreshHandler();
if (downscopedCreds) {
this.setCredentials(downscopedCreds);
const headers = {
Authorization: 'Bearer ' + this.credentials.access_token,
};
return {headers: this.addSharedMetadataHeaders(headers)};
}

if (this.apiKey) {
return {headers: {'X-Goog-Api-Key': this.apiKey}};
}
Expand Down Expand Up @@ -934,7 +954,7 @@ export class OAuth2Client extends AuthClient {
const res = (e as GaxiosError).response;
if (res) {
const statusCode = res.status;
// Retry the request for metadata if the following criteria are true:
// Retry the request for metadata if the following criterias are true:
// - We haven't already retried. It only makes sense to retry once.
// - The response was a 401 or a 403
// - The request didn't send a readableStream
Expand All @@ -945,16 +965,41 @@ export class OAuth2Client extends AuthClient {
// fails on the first try because it's expired. Some developers may
// choose to enable forceRefreshOnFailure to mitigate time-related
// errors.
// Or the following criterias are true:
// - We haven't already retried. It only makes sense to retry once.
// - The response was a 401 or a 403
// - The request didn't send a readableStream
// - No refresh_token was available
// - An access_token and a refreshHandler callback were available, but
// either no expiry_date was available or the forceRefreshOnFailure
// flag is set. The access_token fails on the first try because it's
// expired. Some developers may choose to enable forceRefreshOnFailure
// to mitigate time-related errors.
const mayRequireRefresh =
this.credentials &&
this.credentials.access_token &&
this.credentials.refresh_token &&
(!this.credentials.expiry_date || this.forceRefreshOnFailure);
const mayRequireRefreshWithNoRefreshToken =
this.credentials &&
this.credentials.access_token &&
!this.credentials.refresh_token &&
(!this.credentials.expiry_date || this.forceRefreshOnFailure);
const downscopedCreds = await this.refreshHandler();
const isReadableStream = res.config.data instanceof stream.Readable;
const isAuthErr = statusCode === 401 || statusCode === 403;
if (!retry && isAuthErr && !isReadableStream && mayRequireRefresh) {
await this.refreshAccessTokenAsync();
return this.requestAsync<T>(opts, true);
} else if (
!retry &&
isAuthErr &&
!isReadableStream &&
mayRequireRefreshWithNoRefreshToken &&
downscopedCreds
) {
this.setCredentials(downscopedCreds);
return this.requestAsync<T>(opts, true);
}
}
throw e;
Expand Down Expand Up @@ -1316,6 +1361,13 @@ export class OAuth2Client extends AuthClient {
return new LoginTicket(envelope, payload);
}

/**
* Returns a Promise that resolves with DownscopedAccessTokenResponse
* type if refreshHandler() is defined.
* If there is no refresh handler callback set up, Promise resolves void.
*/
async refreshHandler(): Promise<DownscopedAccessTokenResponse | void> {}

/**
* Returns true if a token is expired or will expire within
* eagerRefreshThresholdMillismilliseconds.
Expand Down
91 changes: 91 additions & 0 deletions test/test.oauth2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import * as sinon from 'sinon';

import {CodeChallengeMethod, OAuth2Client} from '../src';
import {LoginTicket} from '../src/auth/loginticket';
import {DownscopedAccessTokenResponse} from '../src/auth/oauth2client';

nock.disableNetConnect();

Expand Down Expand Up @@ -1149,6 +1150,36 @@ describe('oauth2', () => {
done();
});
});

it('should call refreshHandler and set credential if token expires in request()', async () => {
const scope = nock('http://example.com')
.get('/access')
.reply(code, {
error: {code, message: 'Invalid Credentials'},
});
const expectedDownscopedCred = {
access_token: 'access_token',
expiry_date: 123456789,
};
client.refreshHandler = async () => {
return expectedDownscopedCred;
};
client.setCredentials({
access_token: 'initial-access-token',
expiry_date: new Date().getTime() - 1000,
});
await client.request({url: 'http://example.com'}, () => {
scope.done();
assert.strictEqual(
client.credentials.access_token,
expectedDownscopedCred.access_token
);
assert.strictEqual(
client.credentials.expiry_date,
expectedDownscopedCred.expiry_date
);
});
});
});

it('should not retry requests with streaming data', done => {
Expand Down Expand Up @@ -1338,6 +1369,27 @@ describe('oauth2', () => {
assert.deepStrictEqual(info.scopes, tokenInfo.scope.split(' '));
});

it('should refresh request header when refreshHandler is available', async () => {
const expectedDownscopedCred = {
access_token: 'access_token',
expiry_date: 123456789,
};
client.refreshHandler = async () => {
return expectedDownscopedCred;
};
client.setCredentials({
access_token: 'initial-access-token',
expiry_date: new Date().getTime() - 1000,
});
const expectedMetadata = {
Authorization: 'Bearer access_token',
};
const requestMetaData = await client.getRequestHeaders(
'http://example.com'
);
assert.deepStrictEqual(requestMetaData, expectedMetadata);
});

it('should throw if tries to refresh but no refresh token is available', async () => {
client.setCredentials({
access_token: 'initial-access-token',
Expand All @@ -1348,5 +1400,44 @@ describe('oauth2', () => {
/No refresh token is set./
);
});

it('should call refreshHandler and set credential if providing refresh handler callback', async () => {
const expectedDownscopedCred = {
access_token: 'access_token',
expiry_date: 123456789,
};
client.refreshHandler = async () => {
return expectedDownscopedCred;
};
const downscopedCred =
(await client.refreshHandler()) as DownscopedAccessTokenResponse;
assert.strictEqual(
downscopedCred.access_token,
expectedDownscopedCred.access_token
);
assert.strictEqual(
downscopedCred.expiry_date,
expectedDownscopedCred.expiry_date
);
});

it('should refresh credential when refreshHandler is available in getAccessToken()', async () => {
const expectedDownscopedCred = {
access_token: 'access_token',
expiry_date: 123456789,
};
client.refreshHandler = async () => {
return expectedDownscopedCred;
};
client.setCredentials({
access_token: 'initial-access-token',
expiry_date: new Date().getTime() - 1000,
});
const downscopedCred = await client.getAccessToken();
assert.strictEqual(
downscopedCred.token,
expectedDownscopedCred.access_token
);
});
});
});

0 comments on commit 207386a

Please sign in to comment.