Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Implement support for exposing refreshHandler callback to handle token refresh. #1213

Merged
merged 13 commits into from
Aug 27, 2021
Merged
60 changes: 35 additions & 25 deletions src/auth/oauth2client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,6 @@ import {BodyResponseCallback} from '../transporters';
import {AuthClient} from './authclient';
import {CredentialRequest, Credentials} from './credentials';
import {LoginTicket, TokenPayload} from './loginticket';
import {getErrorFromOAuthErrorResponse} from './oauth2common';
/**
* The results from the `generateCodeVerifierAsync` method. To learn more,
* See the sample:
Expand Down Expand Up @@ -304,7 +303,7 @@ export interface AccessTokenResponse {
expiry_date: number;
}

export interface GetRefreshHandlerCallack {
export interface GetRefreshHandlerCallback {
(): Promise<AccessTokenResponse>;
}

Expand Down Expand Up @@ -437,7 +436,7 @@ export class OAuth2Client extends AuthClient {

forceRefreshOnFailure: boolean;

refreshHandlerCallback?: GetRefreshHandlerCallack;
refreshHandler?: GetRefreshHandlerCallback;

/**
* Handles OAuth2 flow for Google APIs.
Expand Down Expand Up @@ -761,14 +760,17 @@ export class OAuth2Client extends AuthClient {
!this.credentials.access_token || this.isTokenExpiring();
if (shouldRefresh) {
if (!this.credentials.refresh_token) {
if (this.refreshHandlerCallback && this.credentials.access_token) {
const refreshedAccessToken = await this.refreshHandler();
if (refreshedAccessToken && refreshedAccessToken.access_token) {
if (this.refreshHandler) {
const refreshedAccessToken =
await this.processAndValidateRefreshHandler();
if (refreshedAccessToken?.access_token) {
this.setCredentials(refreshedAccessToken);
return {token: this.credentials.access_token};
}
} else {
throw new Error('No refresh token is set.');
throw new Error(
'No refresh token or refresh handler callback is set.'
);
}
}

Expand Down Expand Up @@ -805,9 +807,11 @@ export class OAuth2Client extends AuthClient {
!thisCreds.access_token &&
!thisCreds.refresh_token &&
!this.apiKey &&
!this.refreshHandlerCallback
!this.refreshHandler
) {
throw new Error('No access, refresh token or API key is set.');
throw new Error(
'No access, refresh token or API key or refresh handler callback is set.'
xil222 marked this conversation as resolved.
Show resolved Hide resolved
);
}

if (thisCreds.access_token && !this.isTokenExpiring()) {
Expand All @@ -818,10 +822,11 @@ export class OAuth2Client extends AuthClient {
return {headers: this.addSharedMetadataHeaders(headers)};
}

xil222 marked this conversation as resolved.
Show resolved Hide resolved
// If refreshHandlerCallback exists, refresh
if (this.refreshHandlerCallback) {
const refreshedAccessToken = await this.refreshHandler();
if (refreshedAccessToken && refreshedAccessToken.access_token) {
// If refreshHandler exists, call processAndValidateRefreshHandler().
if (this.refreshHandler) {
const refreshedAccessToken =
await this.processAndValidateRefreshHandler();
if (refreshedAccessToken?.access_token) {
this.setCredentials(refreshedAccessToken);
const headers = {
Authorization: 'Bearer ' + this.credentials.access_token,
Expand Down Expand Up @@ -1001,7 +1006,8 @@ export class OAuth2Client extends AuthClient {
this.credentials &&
this.credentials.access_token &&
!this.credentials.refresh_token &&
(!this.credentials.expiry_date || this.forceRefreshOnFailure);
(!this.credentials.expiry_date || this.forceRefreshOnFailure) &&
this.refreshHandler;
const isReadableStream = res.config.data instanceof stream.Readable;
const isAuthErr = statusCode === 401 || statusCode === 403;
if (!retry && isAuthErr && !isReadableStream && mayRequireRefresh) {
Expand All @@ -1011,11 +1017,11 @@ export class OAuth2Client extends AuthClient {
!retry &&
isAuthErr &&
!isReadableStream &&
mayRequireRefreshWithNoRefreshToken &&
this.refreshHandler
mayRequireRefreshWithNoRefreshToken
) {
const refreshedAccessToken = await this.refreshHandler();
if (refreshedAccessToken && refreshedAccessToken.access_token) {
const refreshedAccessToken =
await this.processAndValidateRefreshHandler();
if (refreshedAccessToken?.access_token) {
this.setCredentials(refreshedAccessToken);
}
return this.requestAsync<T>(opts, true);
Expand Down Expand Up @@ -1381,15 +1387,19 @@ export class OAuth2Client extends AuthClient {
}

/**
* Returns a Promise that resolves with AccessTokenResponse type if
* refreshHandlerCallBack is defined.
* If it is undefined, nothing returned.
* Returns a promise that resolves with AccessTokenResponse type if
* refreshHandler is defined.
* If not, nothing is returned.
*/
async refreshHandler(): Promise<AccessTokenResponse | void> {
if (this.refreshHandlerCallback) {
const accessTokenResponse = await this.refreshHandlerCallback();
private async processAndValidateRefreshHandler(): Promise<
AccessTokenResponse | undefined
> {
if (this.refreshHandler) {
const accessTokenResponse = await this.refreshHandler();
if (!accessTokenResponse.access_token) {
throw new Error('There is no access token being returned');
throw new Error(
'No access token is returned by the refreshHandler callback.'
);
}
return accessTokenResponse;
}
Expand Down
99 changes: 57 additions & 42 deletions test/test.oauth2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,6 @@ import * as sinon from 'sinon';

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

nock.disableNetConnect();

Expand Down Expand Up @@ -894,7 +893,7 @@ describe('oauth2', () => {
client.request({}, (err, result) => {
assert.strictEqual(
err!.message,
'No access, refresh token or API key is set.'
'No access, refresh token or API key or refresh handler callback is set.'
);
assert.strictEqual(result, undefined);
done();
Expand Down Expand Up @@ -1151,24 +1150,28 @@ describe('oauth2', () => {
});
});

xil222 marked this conversation as resolved.
Show resolved Hide resolved
it('should call refreshHandler and set credential if token expires in request()', async () => {
it('should call refreshHandler in request() on token expiration and no refresh token available', async () => {
xil222 marked this conversation as resolved.
Show resolved Hide resolved
const scope = nock('http://example.com')
.get('/access')
xil222 marked this conversation as resolved.
Show resolved Hide resolved
.reply(code, {
error: {code, message: 'Invalid Credentials'},
});
const expectedRefreshedAccessToken = {
access_token: 'access_token',
expiry_date: 123456789,
expiry_date: new Date().getTime() + 3600 * 1000,
};
client.refreshHandlerCallback = async () => {
const expectedMetadata = {
Authorization: 'Bearer access_token',
};
client.refreshHandler = async () => {
return expectedRefreshedAccessToken;
};
client.setCredentials({
access_token: 'initial-access-token',
expiry_date: new Date().getTime() - 1000,
});
client.request({url: 'http://example.com'}, () => {

client.request({url: 'http://example.com/access'}, () => {
scope.done();
assert.strictEqual(
client.credentials.access_token,
Expand All @@ -1179,6 +1182,12 @@ describe('oauth2', () => {
expectedRefreshedAccessToken.expiry_date
);
});

// Check authorization header has been added.
const requestMetaData = await client.getRequestHeaders(
xil222 marked this conversation as resolved.
Show resolved Hide resolved
'http://example.com/access'
);
assert.deepStrictEqual(requestMetaData, expectedMetadata);
});
});

Expand Down Expand Up @@ -1369,12 +1378,12 @@ describe('oauth2', () => {
assert.deepStrictEqual(info.scopes, tokenInfo.scope.split(' '));
});

it('should refresh request header when refreshHandler is available', async () => {
it('should call refreshHandler in getRequestHeaders() on token expiration and refreshHandler is available', async () => {
const expectedRefreshedAccessToken = {
access_token: 'access_token',
expiry_date: 123456789,
expiry_date: new Date().getTime() + 3600 * 1000,
};
client.refreshHandlerCallback = async () => {
client.refreshHandler = async () => {
return expectedRefreshedAccessToken;
};
client.setCredentials({
Expand All @@ -1384,73 +1393,79 @@ describe('oauth2', () => {
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 () => {
it('should return authorization header on current access token if it has not expired', async () => {
xil222 marked this conversation as resolved.
Show resolved Hide resolved
client.setCredentials({
access_token: 'initial-access-token',
expiry_date: new Date().getTime() - 1000,
expiry_date: new Date().getTime() + 3600 * 1000,
});
await assert.rejects(
client.getRequestHeaders('http://example.com'),
/No refresh token is set./
const expectedMetadata = {
Authorization: 'Bearer initial-access-token',
};

const requestMetaData = await client.getRequestHeaders(
'http://example.com'
);

assert.deepStrictEqual(requestMetaData, expectedMetadata);
});

it('should throw error is refreshHandler does not return token', async () => {
const expectedRefreshedAccessToken = {
access_token: '',
expiry_date: 123456789,
};
client.refreshHandlerCallback = async () => {
return expectedRefreshedAccessToken;
};
it('should throw if tries to refresh but no refreshHandler callback or refresh token is available', async () => {
xil222 marked this conversation as resolved.
Show resolved Hide resolved
client.setCredentials({
access_token: 'initial-access-token',
expiry_date: new Date().getTime() - 1000,
});

await assert.rejects(
client.refreshHandler(),
'There is no access token being returned.'
client.getRequestHeaders('http://example.com'),
/No refresh token is set./
);
});

it('should call refreshHandler and set credential if providing refresh handler callback', async () => {
it('should call refreshHandler in getAccessToken() on expiration and no refresh token available', async () => {
const expectedRefreshedAccessToken = {
access_token: 'access_token',
expiry_date: 123456789,
expiry_date: new Date().getTime() + 3600 * 1000,
};
client.refreshHandlerCallback = async () => {
client.refreshHandler = async () => {
return expectedRefreshedAccessToken;
};
const refreshedAccessToken =
(await client.refreshHandler()) as AccessTokenResponse;
client.setCredentials({
xil222 marked this conversation as resolved.
Show resolved Hide resolved
access_token: 'initial-access-token',
expiry_date: new Date().getTime() - 1000,
});

const refreshedAccessToken = await client.getAccessToken();

assert.strictEqual(
refreshedAccessToken.access_token,
refreshedAccessToken.token,
expectedRefreshedAccessToken.access_token
);
assert.strictEqual(
refreshedAccessToken.expiry_date,
expectedRefreshedAccessToken.expiry_date
);
});

it('should refresh credential when refreshHandler is available in getAccessToken()', async () => {
it('should throw error if refreshHandler callback response is missing an access token', async () => {
const expectedRefreshedAccessToken = {
access_token: 'access_token',
expiry_date: 123456789,
access_token: '',
expiry_date: new Date().getTime() + 3600 * 1000,
};
client.refreshHandlerCallback = async () => {
client.refreshHandler = async () => {
return expectedRefreshedAccessToken;
};
client.setCredentials({
xil222 marked this conversation as resolved.
Show resolved Hide resolved
access_token: 'initial-access-token',
expiry_date: new Date().getTime() - 1000,
});
const refreshedAccessToken = await client.getAccessToken();
assert.strictEqual(
refreshedAccessToken.token,
expectedRefreshedAccessToken.access_token

await assert.rejects(
client.getAccessToken(),
/No access token is returned by the refreshHandler callback./
);
});
});
Expand Down