Source: sessions/persistent-session.ts

/**
 * @fileoverview A Persistent Box API Session.
 */

// ------------------------------------------------------------------------------
// Requirements
// ------------------------------------------------------------------------------

import assert from 'assert';
import { Promise } from 'bluebird';
import httpStatusCodes from 'http-status';
import errors from '../util/errors';

// ------------------------------------------------------------------------------
// Typedefs
// ------------------------------------------------------------------------------

type TokenInfo = any /* FIXME */;
type TokenStore = any /* FIXME */;
type Config = any /* FIXME */;
type TokenManager = any /* FIXME */;
type TokenRequestOptions = Record<string, any> /* FIXME */;

// ------------------------------------------------------------------------------
// Private
// ------------------------------------------------------------------------------

/**
 * Validate that an object is a valid TokenInfo object
 *
 * @param {Object} obj The object to validate
 * @returns {boolean} True if the passed in object is a valid TokenInfo object that
 *  has all the expected properties, false otherwise
 * @private
 */
function isObjectValidTokenInfo(obj: Record<string, any>) {
	return Boolean(
		obj &&
			obj.accessToken &&
			obj.refreshToken &&
			obj.accessTokenTTLMS &&
			obj.acquiredAtMS
	);
}

/**
 * Validate that an object is a valid TokenStore object
 *
 * @param {Object} obj the object to validate
 * @returns {boolean} returns true if the passed in object is a valid TokenStore object that
 * has all the expected properties. false otherwise.
 * @private
 */
function isObjectValidTokenStore(obj: Record<string, any>) {
	return Boolean(obj && obj.read && obj.write && obj.clear);
}

// ------------------------------------------------------------------------------
// Public
// ------------------------------------------------------------------------------

/**
 * A Persistent API Session has the ability to refresh its access token once it becomes expired.
 * It takes in a full tokenInfo object for authentication. It can detect when its tokens have
 * expired and will request new, valid tokens if needed. It can also interface with a token
 * data-store if one is provided.
 *
 * Persistent API Session a good choice for long-running applications or web servers that
 * must remember users across sessions.
 *
 * @param {TokenInfo} tokenInfo A valid TokenInfo object. Will throw if improperly formatted.
 * @param {TokenStore} [tokenStore] A valid TokenStore object. Will throw if improperly formatted.
 * @param {Config} config The SDK configuration options
 * @param {TokenManager} tokenManager The token manager
 * @constructor
 */
class PersistentSession {
	_config: Config;
	_refreshPromise: Promise<any> | null;
	_tokenManager: TokenManager;
	_tokenStore: TokenStore;
	_tokenInfo: TokenInfo;

	constructor(
		tokenInfo: TokenInfo,
		tokenStore: TokenStore,
		config: Config,
		tokenManager: TokenManager
	) {
		this._config = config;
		this._tokenManager = tokenManager;

		// Keeps track of if tokens are currently being refreshed
		this._refreshPromise = null;

		// Set valid PersistentSession credentials. Throw if expected credentials are invalid or not given.
		assert(
			isObjectValidTokenInfo(tokenInfo),
			'tokenInfo is improperly formatted. Properties required: accessToken, refreshToken, accessTokenTTLMS and acquiredAtMS.'
		);
		this._setTokenInfo(tokenInfo);

		// If tokenStore was provided, set the persistent data & current store operations
		if (tokenStore) {
			assert(
				isObjectValidTokenStore(tokenStore),
				'Token store provided but is improperly formatted. Methods required: read(), write(), clear().'
			);
			this._tokenStore = Promise.promisifyAll(tokenStore);
		}
	}

	/**
	 * Sets all relevant token info for this client.
	 *
	 * @param {TokenInfo} tokenInfo A valid TokenInfo object.
	 * @returns {void}
	 * @private
	 */
	_setTokenInfo(tokenInfo: TokenStore) {
		this._tokenInfo = {
			accessToken: tokenInfo.accessToken,
			refreshToken: tokenInfo.refreshToken,
			accessTokenTTLMS: tokenInfo.accessTokenTTLMS,
			acquiredAtMS: tokenInfo.acquiredAtMS,
		};
	}

	/**
	 * Attempts to refresh tokens for the client.
	 * Will use the Box refresh token grant to complete the refresh. On refresh failure, we'll
	 * check the token store for more recently updated tokens and load them if found. Otherwise
	 * an error will be propagated.
	 *
	 * @param {TokenRequestOptions} [options] - Sets optional behavior for the token grant
	 * @returns {Promise<string>} Promise resolving to the access token
	 * @private
	 */
	_refreshTokens(options?: TokenRequestOptions) {
		// If not already refreshing, kick off a token refresh request and set a lock so that additional
		// client requests don't try as well
		if (!this._refreshPromise) {
			this._refreshPromise = this._tokenManager
				.getTokensRefreshGrant(this._tokenInfo.refreshToken, options)
				.catch((err: any) => {
					// If we got an error response from Box API, but it was 400 invalid_grant, it indicates we may have just
					// made the request with an invalidated refresh token. Since only a max of 2 refresh tokens can be valid
					// at any point in time, and a horizontally scaled app could have multiple Node instances running in parallel,
					// it is possible to hit cases where too many servers all refresh a user's tokens at once
					// and cause this server's token to become invalidated. However, the user should still be alive, but
					// we'll need to check the central data store for the latest valid tokens that some other server in the app
					// cluster would have received. So, instead pull tokens from the central store and attempt to use them.
					if (
						err.statusCode === httpStatusCodes.BAD_REQUEST &&
						this._tokenStore
					) {
						var invalidGrantError = err;

						// Check the tokenStore to see if tokens have been updated recently. If they have, then another
						// instance of the session may have already refreshed the user tokens, which would explain why
						// we couldn't refresh.
						return this._tokenStore
							.readAsync()
							.catch((e: any) => errors.unwrapAndThrow(e))
							.then((storeTokenInfo: TokenStore) => {
								// if the tokens we got from the central store are the same as the tokens we made the failed request with
								// already, then we can be sure that no other servers have valid tokens for this server either.
								// Thus, this user truly has an expired refresh token. So, propagate an "Expired Tokens" error.
								if (
									!storeTokenInfo ||
									storeTokenInfo.refreshToken === this._tokenInfo.refreshToken
								) {
									throw errors.buildAuthError(invalidGrantError.response);
								}

								// Propagate the fresh tokens that we found in the session
								return storeTokenInfo;
							});
					}

					// Box API returned a permanent error that is not retryable and we can't recover.
					// We have no usable tokens for the user and no way to refresh them - propagate a permanent error.
					throw err;
				})
				.then((tokenInfo: TokenInfo) => {
					// Success! We got back a TokenInfo object from the API.
					// If we have a token store, we'll write it there now before finishing up the request.
					if (this._tokenStore) {
						return this._tokenStore
							.writeAsync(tokenInfo)
							.catch((e: any) => errors.unwrapAndThrow(e))
							.then(() => tokenInfo);
					}

					// If no token store, Set and propagate the access token immediately
					return tokenInfo;
				})
				.then((tokenInfo: TokenInfo) => {
					// Set and propagate the new access token
					this._setTokenInfo(tokenInfo);
					return tokenInfo.accessToken;
				})
				.catch((err: any) => this.handleExpiredTokensError(err))
				.finally(() => {
					// Refresh complete, clear promise
					this._refreshPromise = null;
				});
		}

		return this._refreshPromise as Promise<any>;
	}

	// ------------------------------------------------------------------------------
	// Public Instance
	// ------------------------------------------------------------------------------

	/**
	 * Returns the clients access token.
	 *
	 * If tokens don't yet exist, first attempt to retrieve them.
	 * If tokens are expired, first attempt to refresh them.
	 *
	 * @param {TokenRequestOptions} [options] - Sets optional behavior for the token grant
	 * @returns {Promise<string>} Promise resolving to the access token
	 */
	getAccessToken(options?: TokenRequestOptions) {
		// If our tokens are not fresh, we need to refresh them
		const expirationBuffer = this._config.expiredBufferMS;
		if (
			!this._tokenManager.isAccessTokenValid(this._tokenInfo, expirationBuffer)
		) {
			return this._refreshTokens(options);
		}

		// Current access token is still valid. Return it.
		return Promise.resolve(this._tokenInfo.accessToken);
	}

	/**
	 * Revokes the session's tokens. If the session has a refresh token we'll use that,
	 * since it is more likely to be up to date. Otherwise, we'll revoke the accessToken.
	 * Revoking either one will disable the other as well.
	 *
	 * @param {TokenRequestOptions} [options] - Sets optional behavior for the token grant
	 * @returns {Promise} Promise that resolves when the revoke succeeds
	 */
	revokeTokens(options?: TokenRequestOptions) {
		return this._tokenManager.revokeTokens(
			this._tokenInfo.refreshToken,
			options
		);
	}

	/**
	 * Exchange the client access token for one with lower scope
	 * @param {string|string[]} scopes The scope(s) requested for the new token
	 * @param {string} [resource] The absolute URL of an API resource to scope the new token to
	 * @param {Object} [options] - Optional parameters
	 * @param {TokenRequestOptions} [options.tokenRequestOptions] - Sets optional behavior for the token grant
	 * @returns {void}
	 */
	exchangeToken(
		scopes: string | string[],
		resource?: string,
		options?: {
			tokenRequestOptions?: TokenRequestOptions;
		}
	) {
		return this.getAccessToken(options).then((accessToken) =>
			this._tokenManager.exchangeToken(accessToken, scopes, resource, options)
		);
	}

	/**
	 * Handle an an "Expired Tokens" Error. If our tokens are expired, we need to clear the token
	 * store (if present) before continuing.
	 *
	 * @param {Errors~ExpiredTokensError} err An "expired tokens" error including information
	 *  about the request/response.
	 * @returns {Promise<Error>} Promise resolving to an error.  This will
	 *  usually be the original response error, but could an error from trying to access the
	 *  token store as well.
	 */
	handleExpiredTokensError(err: any /* FIXME */) {
		if (!this._tokenStore) {
			return Promise.resolve(err);
		}

		// If a token store is available, clear the store and throw either error
		// eslint-disable-next-line promise/no-promise-in-callback
		return this._tokenStore
			.clearAsync()
			.catch((e: any) => errors.unwrapAndThrow(e))
			.then(() => {
				throw err;
			});
	}
}

/**
 * @module box-node-sdk/lib/sessions/persistent-session
 * @see {@Link PersistentSession}
 */
export = PersistentSession;