diff --git a/src/auth/jwtclient.ts b/src/auth/jwtclient.ts index 0374b1f5..ca12b332 100644 --- a/src/auth/jwtclient.ts +++ b/src/auth/jwtclient.ts @@ -83,7 +83,10 @@ export class JWT extends OAuth2Client { optionsOrEmail && typeof optionsOrEmail === 'object' ? optionsOrEmail : {email: optionsOrEmail, keyFile, key, keyId, scopes, subject}; - super({eagerRefreshThresholdMillis: opts.eagerRefreshThresholdMillis}); + super({ + eagerRefreshThresholdMillis: opts.eagerRefreshThresholdMillis, + forceRefreshOnFailure: opts.forceRefreshOnFailure, + }); this.email = opts.email; this.keyFile = opts.keyFile; this.key = opts.key; diff --git a/src/auth/oauth2client.ts b/src/auth/oauth2client.ts index 2b04c2a5..65539562 100644 --- a/src/auth/oauth2client.ts +++ b/src/auth/oauth2client.ts @@ -354,6 +354,12 @@ export interface RefreshOptions { // milliseconds from expiring". // Defaults to a value of 300000 (5 minutes). eagerRefreshThresholdMillis?: number; + + // Whether to attempt to lazily refresh tokens on 401/403 responses + // even if an attempt is made to refresh the token preemptively based + // on the expiry_date. + // Defaults to false. + forceRefreshOnFailure?: boolean; } export class OAuth2Client extends AuthClient { @@ -375,6 +381,8 @@ export class OAuth2Client extends AuthClient { eagerRefreshThresholdMillis: number; + forceRefreshOnFailure: boolean; + /** * Handles OAuth2 flow for Google APIs. * @@ -402,6 +410,7 @@ export class OAuth2Client extends AuthClient { this.redirectUri = opts.redirectUri; this.eagerRefreshThresholdMillis = opts.eagerRefreshThresholdMillis || 5 * 60 * 1000; + this.forceRefreshOnFailure = !!opts.forceRefreshOnFailure; } protected static readonly GOOGLE_TOKEN_INFO_URL = @@ -896,15 +905,18 @@ export class OAuth2Client extends AuthClient { // - 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 - // - An access_token and refresh_token were available, but no - // expiry_date was availabe. This can happen when developers stash - // the access_token and refresh_token for later use, but the - // access_token fails on the first try because it's expired. + // - An access_token and refresh_token were available, but either no + // expiry_date was available or the forceRefreshOnFailure flag is set. + // The absent expiry_date case can happen when developers stash the + // access_token and refresh_token for later use, but 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.credentials.expiry_date || this.forceRefreshOnFailure); const isReadableStream = res.config.data instanceof stream.Readable; const isAuthErr = statusCode === 401 || statusCode === 403; if (!retry && isAuthErr && !isReadableStream && mayRequireRefresh) { diff --git a/src/auth/refreshclient.ts b/src/auth/refreshclient.ts index 4809b39a..802f934a 100644 --- a/src/auth/refreshclient.ts +++ b/src/auth/refreshclient.ts @@ -44,7 +44,8 @@ export class UserRefreshClient extends OAuth2Client { optionsOrClientId?: string | UserRefreshClientOptions, clientSecret?: string, refreshToken?: string, - eagerRefreshThresholdMillis?: number + eagerRefreshThresholdMillis?: number, + forceRefreshOnFailure?: boolean ) { const opts = optionsOrClientId && typeof optionsOrClientId === 'object' @@ -54,11 +55,13 @@ export class UserRefreshClient extends OAuth2Client { clientSecret, refreshToken, eagerRefreshThresholdMillis, + forceRefreshOnFailure, }; super({ clientId: opts.clientId, clientSecret: opts.clientSecret, eagerRefreshThresholdMillis: opts.eagerRefreshThresholdMillis, + forceRefreshOnFailure: opts.forceRefreshOnFailure, }); this._refreshToken = opts.refreshToken; } diff --git a/test/test.oauth2.ts b/test/test.oauth2.ts index 677df9b5..8d6a4912 100644 --- a/test/test.oauth2.ts +++ b/test/test.oauth2.ts @@ -1094,6 +1094,32 @@ describe(__filename, () => { done(); }); }); + + it(`should refresh token if the server returns ${code} with forceRefreshOnFailure`, done => { + const client = new OAuth2Client({ + clientId: CLIENT_ID, + clientSecret: CLIENT_SECRET, + redirectUri: REDIRECT_URI, + forceRefreshOnFailure: true, + }); + const scope = nock('http://example.com') + .get('/access') + .reply(code, { + error: {code, message: 'Invalid Credentials'}, + }); + const scopes = mockExample(); + client.credentials = { + access_token: 'initial-access-token', + refresh_token: 'refresh-token-placeholder', + expiry_date: new Date().getTime() + 500000, + }; + client.request({url: 'http://example.com/access'}, err => { + scope.done(); + scopes[0].done(); + assert.strictEqual('abc123', client.credentials.access_token); + done(); + }); + }); }); it('should not retry requests with streaming data', done => {