Source: token-manager.ts

/**
 * @fileoverview Token Manager
 */

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

import Promise from 'bluebird';
import httpStatusCodes from 'http-status';
import jwt from 'jsonwebtoken';
import { v4 as uuidv4 } from 'uuid';
import APIRequestManager from './api-request-manager';
import errors from './util/errors';
import getRetryTimeout from './util/exponential-backoff';

// ------------------------------------------------------------------------------
// Typedefs and Callbacks
// ------------------------------------------------------------------------------

type Config = Record<string, any> /* FIXME */;

/**
 * Token request options. Set by the consumer to add/modify the params sent to the
 * request.
 *
 * @typedef {Object} TokenRequestOptions
 * @property {string} [ip] The IP Address of the requesting user. This IP will be reflected in authentication
 *                         notification emails sent to your users on login. Defaults to the IP address of the
 *                         server requesting the tokens.
 */
type TokenRequestOptions = {
  ip?: string;
};

/**
 * Parameters for creating a token using a Box shared link via token exchange
 * @typedef {Object} SharedLinkParams
 * @property {string} url Shared link URL
 */
type SharedLinkParams = {
  url: string;
};

/**
 * Parameters for creating an actor token via token exchange
 * @typedef {Object} ActorParams
 * @property {string} id The external identifier for the actor
 * @property {string} name The display name of the actor
 */
type ActorParams = {
  id: string;
  name: string;
};

/**
 * An object representing all token information for a single Box user.
 *
 * @typedef {Object} TokenInfo
 * @property {string} accessToken    The API access token. Used to authenticate API requests to a certain
 *                                   user and/or application.
 * @property {int} acquiredAtMS      The time that the tokens were acquired.
 * @property {int} accessTokenTTLMS  The TTL of the access token. Can be used with acquiredAtMS to
 *                                   calculate if the current access token has expired.
 * @property {string} [refreshToken] The API refresh token is a Longer-lasting than an access token, and can
 *                                   be used to gain a new access token if the current access token becomes
 *                                   expired. Grants like the 'client credentials' grant don't return a
 *                                   refresh token, and have no refresh capabilities.
 */
type TokenInfo = {
  accessToken: string;
  acquiredAtMS: number;
  accessTokenTTLMS: number;
  refreshToken?: string;
};

/**
 *	Determines whether a JWT auth error can be retried
 * @param {Error} err The JWT auth error
 * @returns {boolean} True if the error is retryable
 */
function isJWTAuthErrorRetryable(err: any /* FIXME */) {
  if (
    err.authExpired &&
    err.response.headers.date &&
    (err.response.body.error_description.indexOf('exp') > -1 ||
      err.response.body.error_description.indexOf('jti') > -1)
  ) {
    return true;
  } else if (err.statusCode === 429 || err.statusCode >= 500) {
    return true;
  }
  return false;
}

// ------------------------------------------------------------------------------
// Constants
// ------------------------------------------------------------------------------

/**
 * Collection of grant types that can be used to acquire tokens via OAuth2
 *
 * @readonly
 * @enum {string}
 */
var grantTypes = {
  AUTHORIZATION_CODE: 'authorization_code',
  REFRESH_TOKEN: 'refresh_token',
  CLIENT_CREDENTIALS: 'client_credentials',
  JWT: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
  TOKEN_EXCHANGE: 'urn:ietf:params:oauth:grant-type:token-exchange',
};

/**
 * Collection of paths to interact with Box OAuth2 tokening system
 *
 * @readonly
 * @enum {string}
 */
enum tokenPaths {
  ROOT = '/oauth2',
  GET = '/token',
  REVOKE = '/revoke',
}

// Timer used to track elapsed time starting with executing an async request and ending with emitting the response.
var asyncRequestTimer: any /* FIXME */;

// The XFF header label - Used to give the API better information for uploads, rate-limiting, etc.
const HEADER_XFF = 'X-Forwarded-For';
const ACCESS_TOKEN_TYPE = 'urn:ietf:params:oauth:token-type:access_token';
const ACTOR_TOKEN_TYPE = 'urn:ietf:params:oauth:token-type:id_token';
const BOX_JWT_AUDIENCE = 'https://api.box.com/oauth2/token';

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

/**
 * Parse the response body to create a new TokenInfo object.
 *
 * @param {Object} grantResponseBody - (Request lib) response body containing granted token info from API
 * @returns {TokenInfo} A TokenInfo object.
 * @private
 */
function getTokensFromGrantResponse(
  grantResponseBody: Record<string, any> /* FIXME */
) {
  return {
    // Set the access token & refresh token (if passed)
    accessToken: grantResponseBody.access_token,
    refreshToken: grantResponseBody.refresh_token,
    // Box API sends back expires_in in seconds, we convert to ms for consistency of keeping all time in ms
    accessTokenTTLMS: parseInt(grantResponseBody.expires_in, 10) * 1000,
    acquiredAtMS: Date.now(),
  };
}

/**
 * Determines if a given string could represent an authorization code or token.
 *
 * @param {string} codeOrToken The code or token to check.
 * @returns {boolean} True if codeOrToken is valid, false if not.
 * @private
 */
function isValidCodeOrToken(codeOrToken: string) {
  return typeof codeOrToken === 'string' && codeOrToken.length > 0;
}

/**
 * Determines if a token grant response is valid
 *
 * @param {string} grantType the type of token grant
 * @param {Object} responseBody the body of the response to check
 * @returns {boolean} True if response body has expected fields, false if not.
 * @private
 */
function isValidTokenResponse(
  grantType: string,
  responseBody: Record<string, any> /* FIXME */
) {
  if (!isValidCodeOrToken(responseBody.access_token)) {
    return false;
  }
  if (typeof responseBody.expires_in !== 'number') {
    return false;
  }
  // Check the refresh_token for certain types of grants
  if (grantType === 'authorization_code' || grantType === 'refresh_token') {
    if (!isValidCodeOrToken(responseBody.refresh_token)) {
      return false;
    }
  }
  return true;
}

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

/**
 * Manager for API access abd refresh tokens
 *
 * @param {Config} config The config object
 * @param {APIRequestManager} requestManager The API Request Manager
 * @constructor
 */
class TokenManager {
  config: Config;
  requestManager: APIRequestManager;
  oauthBaseURL: string;

  constructor(config: Config, requestManager: APIRequestManager) {
    this.config = config;
    this.oauthBaseURL = config.apiRootURL + tokenPaths.ROOT;
    this.requestManager = requestManager;
  }

  /**
   * Given a TokenInfo object, returns whether its access token is expired. An access token is considered
   * expired once its TTL surpasses the current time outside of the given buffer. This is a public method so
   * that other modules may check the validity of their tokens.
   *
   * @param {TokenInfo} tokenInfo the token info to be written
   * @param {int} [bufferMS] An optional buffer we'd like to test against. The greater this buffer, the more aggressively
   * we'll call a token invalid.
   * @returns {boolean} True if token is valid outside of buffer, otherwise false
   */
  isAccessTokenValid(tokenInfo: TokenInfo, bufferMS?: number) {
    if (
      typeof tokenInfo.acquiredAtMS === 'undefined' ||
      typeof tokenInfo.accessTokenTTLMS === 'undefined'
    ) {
      return false;
    }
    bufferMS = bufferMS || 0;
    var expireTime =
      tokenInfo.acquiredAtMS + tokenInfo.accessTokenTTLMS - bufferMS;
    return expireTime > Date.now();
  }

  /**
   * Acquires OAuth2 tokens using a grant type (authorization_code, password, refresh_token)
   *
   * @param {Object} formParams - should contain all params expected by Box OAuth2 token endpoint
   * @param {TokenRequestOptions} [options] - Sets optional behavior for the token grant, null for default behavior
   * @returns {Promise<TokenInfo>} Promise resolving to the token info
   * @private
   */
  getTokens(
    formParams: Record<string, any>,
    options?: TokenRequestOptions | null
  ) {
    var params = {
      method: 'POST',
      url: this.oauthBaseURL + tokenPaths.GET,
      headers: {} as Record<string, any>,
      form: formParams,
    };
    options = options || {};

    // add in app-specific id and secret to auth with Box
    params.form.client_id = this.config.clientID;
    params.form.client_secret = this.config.clientSecret;

    if (options.ip) {
      params.headers[HEADER_XFF] = options.ip;
    }

    return this.requestManager
      .makeRequest(params)
      .then((response: any /* FIXME */) => {
        // Response Error: The API is telling us that we attempted an invalid token grant. This
        // means that our refresh token or auth code has exipred, so propagate an "Expired Tokens"
        // error.
        if (
          response.body &&
          response.body.error &&
          response.body.error === 'invalid_grant'
        ) {
          var errDescription = response.body.error_description;
          var message = errDescription
            ? `Auth Error: ${errDescription}`
            : undefined;
          throw errors.buildAuthError(response, message);
        }

        // Unexpected Response: If the token request couldn't get a valid response, then we're
        // out of options. Build an "Unexpected Response" error and propagate it out for the
        // consumer to handle.
        if (
          response.statusCode !== httpStatusCodes.OK ||
          response.body instanceof Buffer
        ) {
          throw errors.buildUnexpectedResponseError(response);
        }

        // Check to see if token response is valid in case the API returns us a 200 with a malformed token
        if (!isValidTokenResponse(formParams.grant_type, response.body)) {
          throw errors.buildResponseError(
            response,
            'Token format from response invalid'
          );
        }

        // Got valid token response. Parse out the TokenInfo and propagate it back.
        return getTokensFromGrantResponse(response.body);
      });
  }

  /**
   * Acquires token info using an authorization code
   *
   * @param {string} authorizationCode - authorization code issued by Box
   * @param {TokenRequestOptions} [options] - Sets optional behavior for the token grant
   * @returns {Promise<TokenInfo>} Promise resolving to the token info
   */
  getTokensAuthorizationCodeGrant(
    authorizationCode: string,
    options?: TokenRequestOptions
  ) {
    if (!isValidCodeOrToken(authorizationCode)) {
      return Promise.reject(new Error('Invalid authorization code.'));
    }

    var params = {
      grant_type: grantTypes.AUTHORIZATION_CODE,
      code: authorizationCode,
    };

    return this.getTokens(params, options);
  }

  /**
   * Acquires token info using the client credentials grant.
   *
   * @param {TokenRequestOptions} [options] - Sets optional behavior for the token grant
   * @returns {Promise<TokenInfo>} Promise resolving to the token info
   */
  getTokensClientCredentialsGrant(options?: TokenRequestOptions) {
    var params = {
      grant_type: grantTypes.CLIENT_CREDENTIALS,
      box_subject_type: this.config.boxSubjectType,
      box_subject_id: this.config.boxSubjectId,
    };
    return this.getTokens(params, options);
  }

  /**
   * Refreshes the access and refresh tokens for a given refresh token.
   *
   * @param {string} refreshToken - A valid OAuth refresh token
   * @param {TokenRequestOptions} [options] - Sets optional behavior for the token grant
   * @returns {Promise<TokenInfo>} Promise resolving to the token info
   */
  getTokensRefreshGrant(refreshToken: string, options?: TokenRequestOptions) {
    if (!isValidCodeOrToken(refreshToken)) {
      return Promise.reject(new Error('Invalid refresh token.'));
    }

    var params = {
      grant_type: grantTypes.REFRESH_TOKEN,
      refresh_token: refreshToken,
    };

    return this.getTokens(params, options);
  }

  /**
   * Gets tokens for enterprise administration of app users
   * @param {string} type The type of token to create, "user" or "enterprise"
   * @param {string} id The ID of the enterprise to generate a token for
   * @param {TokenRequestOptions} [options] - Sets optional behavior for the token grant
   * @returns {Promise<TokenInfo>} Promise resolving to the token info
   */
  getTokensJWTGrant(type: string, id: string, options?: TokenRequestOptions) {
    if (!this.config.appAuth || !this.config.appAuth.keyID) {
      return Promise.reject(
        new Error('Must provide app auth configuration to use JWT Grant')
      );
    }

    var claims = {
      exp: Math.floor(Date.now() / 1000) + this.config.appAuth.expirationTime,
      box_sub_type: type,
    };
    var jwtOptions = {
      algorithm: this.config.appAuth.algorithm,
      audience: BOX_JWT_AUDIENCE,
      subject: id,
      issuer: this.config.clientID,
      jwtid: uuidv4(),
      noTimestamp: !this.config.appAuth.verifyTimestamp,
      keyid: this.config.appAuth.keyID,
    };
    var keyParams = {
      key: this.config.appAuth.privateKey,
      passphrase: this.config.appAuth.passphrase,
    };

    var assertion;
    try {
      assertion = jwt.sign(claims, keyParams, jwtOptions);
    } catch (jwtErr) {
      return Promise.reject(jwtErr);
    }

    var params = {
      grant_type: grantTypes.JWT,
      assertion,
    };
    // Start the request timer immediately before executing the async request
    asyncRequestTimer = process.hrtime();
    return this.getTokens(params, options).catch((err) =>
      this.retryJWTGrant(claims, jwtOptions, keyParams, params, options, err, 0)
    );
  }

  /**
   * Attempt a retry if possible and create a new JTI claim. If the request hasn't exceeded it's maximum number of retries,
   * re-execute the request (after the retry interval). Otherwise, propagate a new error.
   *
   * @param {Object} claims - JTI claims object
   * @param {Object} [jwtOptions] - JWT options for the signature
   * @param {Object} keyParams - Key JWT parameters object that contains the private key and the passphrase
   * @param {Object} params - Should contain all params expected by Box OAuth2 token endpoint
   * @param {TokenRequestOptions} [options] - Sets optional behavior for the token grant
   * @param {Error} error - Error from the previous JWT request
   * @param {int} numRetries - Number of retries attempted
   * @returns {Promise<TokenInfo>} Promise resolving to the token info
   */
  // eslint-disable-next-line max-params
  retryJWTGrant(
    claims: any /* FIXME */,
    jwtOptions: any /* FIXME */,
    keyParams: any /* FIXME */,
    params: any /* FIXME */,
    options: TokenRequestOptions | undefined,
    error: any /* FIXME */,
    numRetries: number
  ): any /* FIXME */ {
    if (
      numRetries < this.config.numMaxRetries &&
      isJWTAuthErrorRetryable(error)
    ) {
      var retryTimeoutinSeconds;
      numRetries += 1;
      // If the retry strategy is defined, then use it to determine the time (in ms) until the next retry or to
      // propagate an error to the user.
      if (this.config.retryStrategy) {
        // Get the total elapsed time so far since the request was executed
        var totalElapsedTime = process.hrtime(asyncRequestTimer);
        var totalElapsedTimeMS =
          totalElapsedTime[0] * 1000 + totalElapsedTime[1] / 1000000;
        var retryOptions = {
          error,
          numRetryAttempts: numRetries,
          numMaxRetries: this.config.numMaxRetries,
          retryIntervalMS: this.config.retryIntervalMS,
          totalElapsedTimeMS,
        };

        retryTimeoutinSeconds = this.config.retryStrategy(retryOptions);

        // If the retry strategy doesn't return a number/time in ms, then propagate the response error to the user.
        // However, if the retry strategy returns its own error, this will be propagated to the user instead.
        if (typeof retryTimeoutinSeconds !== 'number') {
          if (retryTimeoutinSeconds instanceof Error) {
            error = retryTimeoutinSeconds;
          }
          throw error;
        }
      } else if (
        error.hasOwnProperty('response') &&
        error.response.hasOwnProperty('headers') &&
        error.response.headers.hasOwnProperty('retry-after')
      ) {
        retryTimeoutinSeconds = error.response.headers['retry-after'];
      } else {
        retryTimeoutinSeconds = Math.ceil(
          getRetryTimeout(numRetries, this.config.retryIntervalMS) / 1000
        );
      }

      var time = Math.floor(Date.now() / 1000);
      if (error.response.headers.date) {
        time = Math.floor(Date.parse(error.response.headers.date) / 1000);
      }
      // Add length of retry timeout to current expiration time to calculate the expiration time for the JTI claim.
      claims.exp = Math.ceil(
        time + this.config.appAuth.expirationTime + retryTimeoutinSeconds
      );
      jwtOptions.jwtid = uuidv4();

      try {
        params.assertion = jwt.sign(claims, keyParams, jwtOptions);
      } catch (jwtErr) {
        throw jwtErr;
      }

      return Promise.delay(retryTimeoutinSeconds).then(() => {
        // Start the request timer immediately before executing the async request
        asyncRequestTimer = process.hrtime();
        return this.getTokens(params, options).catch((err) =>
          this.retryJWTGrant(
            claims,
            jwtOptions,
            keyParams,
            params,
            options,
            err,
            numRetries
          )
        );
      });
    } else if (numRetries >= this.config.numMaxRetries) {
      error.maxRetriesExceeded = true;
    }

    throw error;
  }

  /**
   * Exchange a valid access token for one with a lower scope, or delegated to
   * an external user identifier.
   *
   * @param {string} accessToken - The valid access token to exchange
   * @param {string|string[]} scopes - The scope(s) of the new access token
   * @param {string} [resource] - The absolute URL of an API resource to restrict the new token to
   * @param {Object} [options] - Optional parameters
   * @param {TokenRequestOptions} [options.tokenRequestOptions] - Sets optional behavior for the token grant
   * @param {ActorParams} [options.actor] - Optional actor parameters for creating annotator tokens
   * @param {SharedLinkParams} [options.sharedLink] - Optional shared link parameters for creating tokens using shared links
   * @returns {Promise<TokenInfo>} Promise resolving to the new token info
   */
  exchangeToken(
    accessToken: string,
    scopes: string | string[],
    resource?: string,
    options?: {
      tokenRequestOptions?: TokenRequestOptions;
      actor?: ActorParams;
      sharedLink?: SharedLinkParams;
    }
  ) {
    var params: {
      grant_type: string;
      subject_token_type: string;
      subject_token: string;
      scope: string;
      resource?: string;
      box_shared_link?: string;
      actor_token?: string;
      actor_token_type?: string;
    } = {
      grant_type: grantTypes.TOKEN_EXCHANGE,
      subject_token_type: ACCESS_TOKEN_TYPE,
      subject_token: accessToken,
      scope: typeof scopes === 'string' ? scopes : scopes.join(' '),
    };

    if (resource) {
      params.resource = resource;
    }

    if (options && options.sharedLink) {
      params.box_shared_link = options.sharedLink.url;
    }

    if (options && options.actor) {
      var payload = {
        iss: this.config.clientID,
        sub: options.actor.id,
        aud: BOX_JWT_AUDIENCE,
        box_sub_type: 'external',
        name: options.actor.name,
      };

      var jwtOptions = {
        algorithm: 'none',
        expiresIn: '1m',
        noTimestamp: true,
        jwtid: uuidv4(),
      };

      var token;
      try {
        token = jwt.sign(payload, 'UNUSED', jwtOptions as any /* FIXME */);
      } catch (jwtError) {
        return Promise.reject(jwtError);
      }

      params.actor_token = token;
      params.actor_token_type = ACTOR_TOKEN_TYPE;
    }

    return this.getTokens(
      params,
      options && options.tokenRequestOptions
        ? options.tokenRequestOptions
        : null
    );
  }

  /**
   * Revokes a token pair associated with a given access or refresh token.
   *
   * @param {string} token - A valid access or refresh token to revoke
   * @param {TokenRequestOptions} [options] - Sets optional behavior for the token grant
   * @returns {Promise} Promise resolving if the revoke succeeds
   */
  revokeTokens(token: string, options?: TokenRequestOptions) {
    var params: {
      method: string;
      url: string;
      form: Record<string, string>;
      headers?: Record<string, string>;
    } = {
      method: 'POST',
      url: this.oauthBaseURL + tokenPaths.REVOKE,
      form: {
        token,
        client_id: this.config.clientID,
        client_secret: this.config.clientSecret,
      },
    };

    if (options && options.ip) {
      params.headers = {};
      params.headers[HEADER_XFF] = options.ip;
    }

    return this.requestManager.makeRequest(params);
  }
}

/**
 * Provides interactions with Box OAuth2 tokening system.
 *
 * @module box-node-sdk/lib/token-manager
 */
export = TokenManager;