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: Add ability to refresh token automatically before expiration. #242

Merged
merged 10 commits into from
Jan 16, 2018
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 {}

This comment was marked as spam.

This comment was marked as spam.

This comment was marked as spam.

This comment was marked as spam.

This comment was marked as spam.


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