Skip to content

Commit

Permalink
feat: Add ability to refresh token automatically before expiration. (#…
Browse files Browse the repository at this point in the history
  • Loading branch information
nolanmar511 authored and JustinBeckwith committed Jan 16, 2018
1 parent 64fb34d commit 7e9a390
Show file tree
Hide file tree
Showing 9 changed files with 387 additions and 65 deletions.
8 changes: 5 additions & 3 deletions src/auth/computeclient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,9 @@ import {AxiosError, AxiosPromise, AxiosRequestConfig, AxiosResponse} from 'axios

import {RequestError} from './../transporters';
import {CredentialRequest, Credentials} from './credentials';
import {GetTokenResponse, OAuth2Client} from './oauth2client';
import {GetTokenResponse, OAuth2Client, RefreshOptions} from './oauth2client';

export interface ComputeOptions extends RefreshOptions {}

export class Compute extends OAuth2Client {
/**
Expand All @@ -33,8 +35,8 @@ export class Compute extends OAuth2Client {
* Retrieve access token from the metadata server.
* See: https://developers.google.com/compute/docs/authentication
*/
constructor() {
super();
constructor(options?: ComputeOptions) {
super(options);
// Start with an expired refresh token, which will automatically be
// refreshed before the first API call is made.
this.credentials = {expiry_date: 1, refresh_token: 'compute-placeholder'};
Expand Down
93 changes: 62 additions & 31 deletions src/auth/googleauth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ import {JWTInput} from './credentials';
import {IAMAuth} from './iam';
import {JWTAccess} from './jwtaccess';
import {JWT} from './jwtclient';
import {OAuth2Client} from './oauth2client';
import {OAuth2Client, RefreshOptions} from './oauth2client';
import {UserRefreshClient} from './refreshclient';

export interface ProjectIdCallback {
Expand Down Expand Up @@ -165,17 +165,28 @@ export class GoogleAuth {
*/
getApplicationDefault(): Promise<ADCResponse>;
getApplicationDefault(callback: ADCCallback): void;
getApplicationDefault(callback?: ADCCallback): void|Promise<ADCResponse> {
getApplicationDefault(options: RefreshOptions): Promise<ADCResponse>;
getApplicationDefault(options: RefreshOptions, callback: ADCCallback): void;
getApplicationDefault(
optionsOrCallback: ADCCallback|RefreshOptions = {},
callback?: ADCCallback): void|Promise<ADCResponse> {
let options: RefreshOptions|undefined;
if (typeof optionsOrCallback === 'function') {
callback = optionsOrCallback;
} else {
options = optionsOrCallback;
}
if (callback) {
this.getApplicationDefaultAsync()
.then(r => callback(null, r.credential, r.projectId))
this.getApplicationDefaultAsync(options)
.then(r => callback!(null, r.credential, r.projectId))
.catch(callback);
} else {
return this.getApplicationDefaultAsync();
return this.getApplicationDefaultAsync(options);
}
}

private async getApplicationDefaultAsync(): Promise<ADCResponse> {
private async getApplicationDefaultAsync(options?: RefreshOptions):
Promise<ADCResponse> {
// If we've already got a cached credential, just return it.
if (this.cachedCredential) {
return {
Expand All @@ -190,15 +201,17 @@ export class GoogleAuth {
// location of the credential file. This is typically used in local
// developer scenarios.
credential =
await this._tryGetApplicationCredentialsFromEnvironmentVariable();
await this._tryGetApplicationCredentialsFromEnvironmentVariable(
options);
if (credential) {
this.cachedCredential = credential;
projectId = await this.getDefaultProjectId();
return {credential, projectId};
}

// Look in the well-known credential file location.
credential = await this._tryGetApplicationCredentialsFromWellKnownFile();
credential =
await this._tryGetApplicationCredentialsFromWellKnownFile(options);
if (credential) {
this.cachedCredential = credential;
projectId = await this.getDefaultProjectId();
Expand All @@ -212,7 +225,7 @@ export class GoogleAuth {
// For GCE, just return a default ComputeClient. It will take care of
// the rest.
// TODO: cache the result
return {projectId: null, credential: new Compute()};
return {projectId: null, credential: new Compute(options)};
} else {
// We failed to find the default credentials. Bail out with an error.
throw new Error(
Expand Down Expand Up @@ -266,14 +279,15 @@ export class GoogleAuth {
* @returns Promise that resolves with the OAuth2Client or null.
* @api private
*/
async _tryGetApplicationCredentialsFromEnvironmentVariable():
Promise<JWT|UserRefreshClient|null> {
async _tryGetApplicationCredentialsFromEnvironmentVariable(
options?: RefreshOptions): Promise<JWT|UserRefreshClient|null> {
const credentialsPath = this._getEnv('GOOGLE_APPLICATION_CREDENTIALS');
if (!credentialsPath || credentialsPath.length === 0) {
return null;
}
try {
return this._getApplicationCredentialsFromFilePath(credentialsPath);
return this._getApplicationCredentialsFromFilePath(
credentialsPath, options);
} catch (e) {
throw this.createError(
'Unable to read the credential file specified by the GOOGLE_APPLICATION_CREDENTIALS environment variable.',
Expand All @@ -286,8 +300,8 @@ export class GoogleAuth {
* @return Promise that resolves with the OAuth2Client or null.
* @api private
*/
async _tryGetApplicationCredentialsFromWellKnownFile():
Promise<JWT|UserRefreshClient|null> {
async _tryGetApplicationCredentialsFromWellKnownFile(
options?: RefreshOptions): Promise<JWT|UserRefreshClient|null> {
// First, figure out the location of the file, depending upon the OS type.
let location = null;
if (this._isWindows()) {
Expand Down Expand Up @@ -316,7 +330,7 @@ export class GoogleAuth {
return null;
}
// The file seems to exist. Try to use it.
return this._getApplicationCredentialsFromFilePath(location);
return this._getApplicationCredentialsFromFilePath(location, options);
}

/**
Expand All @@ -325,8 +339,9 @@ export class GoogleAuth {
* @returns Promise that resolves with the OAuth2Client
* @api private
*/
async _getApplicationCredentialsFromFilePath(filePath: string):
Promise<JWT|UserRefreshClient> {
async _getApplicationCredentialsFromFilePath(
filePath: string,
options: RefreshOptions = {}): Promise<JWT|UserRefreshClient> {
// Make sure the path looks like a string.
if (!filePath || filePath.length === 0) {
throw new Error('The file path is invalid.');
Expand All @@ -352,7 +367,7 @@ export class GoogleAuth {
// Now open a read stream on the file, and parse it.
try {
const readStream = this._createReadStream(filePath);
return this.fromStream(readStream);
return this.fromStream(readStream, options);
} catch (err) {
throw this.createError(
util.format('Unable to read the file at %s.', filePath), err);
Expand All @@ -364,17 +379,18 @@ export class GoogleAuth {
* @param {object=} json The input object.
* @returns JWT or UserRefresh Client with data
*/
fromJSON(json: JWTInput): JWT|UserRefreshClient {
fromJSON(json: JWTInput, options?: RefreshOptions): JWT|UserRefreshClient {
let client: UserRefreshClient|JWT;
if (!json) {
throw new Error(
'Must pass in a JSON object containing the Google auth settings.');
}
this.jsonContent = json;
options = options || {};
if (json.type === 'authorized_user') {
client = new UserRefreshClient();
client = new UserRefreshClient(options);
} else {
client = new JWT();
client = new JWT(options);
}
client.fromJSON(json);
return client;
Expand All @@ -387,19 +403,33 @@ export class GoogleAuth {
*/
fromStream(inputStream: stream.Readable): Promise<JWT|UserRefreshClient>;
fromStream(inputStream: stream.Readable, callback: CredentialCallback): void;
fromStream(inputStream: stream.Readable, callback?: CredentialCallback):
Promise<JWT|UserRefreshClient>|void {
fromStream(inputStream: stream.Readable, options: RefreshOptions):
Promise<JWT|UserRefreshClient>;
fromStream(
inputStream: stream.Readable, options: RefreshOptions,
callback: CredentialCallback): void;
fromStream(
inputStream: stream.Readable,
optionsOrCallback: RefreshOptions|CredentialCallback = {},
callback?: CredentialCallback): Promise<JWT|UserRefreshClient>|void {
let options: RefreshOptions = {};
if (typeof optionsOrCallback === 'function') {
callback = optionsOrCallback;
} else {
options = optionsOrCallback;
}
if (callback) {
this.fromStreamAsync(inputStream)
.then(r => callback(null, r))
this.fromStreamAsync(inputStream, options)
.then(r => callback!(null, r))
.catch(callback);
} else {
return this.fromStreamAsync(inputStream);
return this.fromStreamAsync(inputStream, options);
}
}

private fromStreamAsync(inputStream: stream.Readable):
Promise<JWT|UserRefreshClient> {
private fromStreamAsync(
inputStream: stream.Readable,
options?: RefreshOptions): Promise<JWT|UserRefreshClient> {
return new Promise((resolve, reject) => {
if (!inputStream) {
throw new Error(
Expand All @@ -413,7 +443,7 @@ export class GoogleAuth {
inputStream.on('end', () => {
try {
const data = JSON.parse(s);
const r = this.fromJSON(data);
const r = this.fromJSON(data, options);
return resolve(r);
} catch (err) {
return reject(err);
Expand All @@ -427,8 +457,9 @@ export class GoogleAuth {
* @param {string} - The API key string
* @returns A JWT loaded from the key
*/
fromAPIKey(apiKey: string): JWT {
const client = new JWT();
fromAPIKey(apiKey: string, options?: RefreshOptions): JWT {
options = options || {};
const client = new JWT(options);
client.fromAPIKey(apiKey);
return client;
}
Expand Down
6 changes: 3 additions & 3 deletions src/auth/jwtclient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,11 @@ import * as stream from 'stream';

import {Credentials, JWTInput} from './credentials';
import {JWTAccess} from './jwtaccess';
import {GetTokenResponse, OAuth2Client, RequestMetadataResponse} from './oauth2client';
import {GetTokenResponse, OAuth2Client, RefreshOptions, RequestMetadataResponse} from './oauth2client';

const isString = require('lodash.isstring');

export interface JWTOptions {
export interface JWTOptions extends RefreshOptions {
email?: string;
keyFile?: string;
key?: string;
Expand Down Expand Up @@ -59,10 +59,10 @@ export class JWT extends OAuth2Client {
constructor(
optionsOrEmail?: string|JWTOptions, keyFile?: string, key?: string,
scopes?: string|string[], subject?: string) {
super();
const opts = (optionsOrEmail && typeof optionsOrEmail === 'object') ?
optionsOrEmail :
{email: optionsOrEmail, keyFile, key, scopes, subject};
super({eagerRefreshThresholdMillis: opts.eagerRefreshThresholdMillis});
this.email = opts.email;
this.keyFile = opts.keyFile;
this.key = opts.key;
Expand Down
46 changes: 28 additions & 18 deletions src/auth/oauth2client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -216,14 +216,21 @@ export interface VerifyIdTokenOptions {
maxExpiry?: number;
}

export interface OAuth2ClientOptions {
export interface OAuth2ClientOptions extends RefreshOptions {
clientId?: string;
clientSecret?: string;
redirectUri?: string;
authBaseUrl?: string;
tokenUrl?: string;
}

export interface RefreshOptions {
// Eagerly refresh unexpired tokens when they are within this many
// milliseconds from expiring".
// Defaults to a value of 300000 (5 minutes).
eagerRefreshThresholdMillis?: number;
}

export class OAuth2Client extends AuthClient {
private redirectUri?: string;
private certificateCache: {}|null|undefined = null;
Expand All @@ -241,6 +248,8 @@ export class OAuth2Client extends AuthClient {

projectId?: string;

eagerRefreshThresholdMillis: number;

/**
* Handles OAuth2 flow for Google APIs.
*
Expand All @@ -250,7 +259,7 @@ export class OAuth2Client extends AuthClient {
* @param {Object=} opts optional options for overriding the given parameters.
* @constructor
*/
constructor(options: OAuth2ClientOptions);
constructor(options?: OAuth2ClientOptions);
constructor(
clientId?: string, clientSecret?: string, redirectUri?: string,
opts?: AuthClientOpts);
Expand All @@ -273,6 +282,8 @@ export class OAuth2Client extends AuthClient {
this.authBaseUrl = opts.authBaseUrl;
this.tokenUrl = opts.tokenUrl;
this.credentials = {};
this.eagerRefreshThresholdMillis =
opts.eagerRefreshThresholdMillis || 5 * 60 * 1000;
}

/**
Expand Down Expand Up @@ -494,16 +505,8 @@ export class OAuth2Client extends AuthClient {
}

private async getAccessTokenAsync(): Promise<GetAccessTokenResponse> {
const expiryDate = this.credentials.expiry_date;

// if no expiry time, assume it's not expired
const isTokenExpired =
expiryDate ? expiryDate <= (new Date()).getTime() : false;
if (!this.credentials.access_token && !this.credentials.refresh_token) {
throw new Error('No access or refresh token is set.');
}

const shouldRefresh = !this.credentials.access_token || isTokenExpired;
const shouldRefresh =
!this.credentials.access_token || this.isTokenExpiring();
if (shouldRefresh && this.credentials.refresh_token) {
if (!this.credentials.refresh_token) {
throw new Error('No refresh token is set.');
Expand Down Expand Up @@ -554,12 +557,7 @@ export class OAuth2Client extends AuthClient {
throw new Error('No access, refresh token or API key is set.');
}

// if no expiry time, assume it's not expired
const expiryDate = thisCreds.expiry_date;
const isTokenExpired =
expiryDate ? expiryDate <= (new Date()).getTime() : false;

if (thisCreds.access_token && !isTokenExpired) {
if (thisCreds.access_token && !this.isTokenExpiring()) {
thisCreds.token_type = thisCreds.token_type || 'Bearer';
const headers = {
Authorization: thisCreds.token_type + ' ' + thisCreds.access_token
Expand Down Expand Up @@ -936,4 +934,16 @@ export class OAuth2Client extends AuthClient {
const buffer = new Buffer(b64String, 'base64');
return buffer.toString('utf8');
}

/**
* Returns true if a token is expired or will expire within
* eagerRefreshThresholdMillismilliseconds.
* If there is no expiry time, assumes the token is not expired or expiring.
*/
protected isTokenExpiring(): boolean {
const expiryDate = this.credentials.expiry_date;
return expiryDate ? expiryDate <=
((new Date()).getTime() + this.eagerRefreshThresholdMillis) :
false;
}
}
Loading

0 comments on commit 7e9a390

Please sign in to comment.