Source: util/config.ts

/**
 * @fileoverview Configuration Object
 */

import assert = require('assert');
import * as https from 'https';
import * as url from 'url';
import { Readable } from 'stream';

// ------------------------------------------------------------------------------
// Requirements
// ------------------------------------------------------------------------------
const merge = require('merge-options'),
  sdkVersion = require('../../package.json').version;

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

const nodeVersion = process.version;

/**
 * Configuration for App Auth
 * @typedef {Object} AppAuthConfig
 * @property {string} keyID The ID of the public key used for app auth
 * @property {string|Buffer} privateKey The private key used for app auth
 * @property {string} passphrase The passphrase associated with the private key
 * @property {string} [algorithm=RS256] The signing algorithm to use, "RS256", "RS384", or "RS512"
 * @property {int} [expirationTime=30] Number of seconds the JWT should live for
 * @property {boolean} [verifyTimestamp=false] Whether the timestamp when the auth token is created should be validated
 */
type AppAuthConfig = {
  keyID: string;
  privateKey: string | Buffer;
  passphrase: string;
  algorithm: 'RS256' | 'RS384' | 'RS512';
  expirationTime: number;
  verifyTimestamp: boolean;
};

/**
 * Configuration settings used to initialize and customize the SDK
 *
 * @typedef {Object} UserConfigurationOptions
 * @property {string} clientID Client ID of your Box Application
 * @property {string} clientSecret Client secret of your Box Application
 * @property {string} [apiRootURL] The root URL to Box [Default: 'https://api.box.com']
 * @property {string} [uploadAPIRootURL] The root URL to Box for uploads [Default: 'https://upload.box.com/api']
 * @property {string} [authorizeRootURL] The root URL for the authorization screen [Default: 'https://account.box.com/api']
 * @property {int} [uploadRequestTimeoutMS] Timeout after which an upload request is aborted [Default: 60000]
 * @property {int} [retryIntervalMS] Time between auto-retries of the API call on a temp failure [Default: 2000]
 * @property {int} [numMaxRetries] Max # of times a temporarily-failed request should be retried before propagating a permanent failure [Default: 5]
 * @property {int} [expiredBufferMS] Time before expiration, in milliseconds, when we begin to treat tokens as expired [Default: 3 min.]
 * @property {Object} [request] Request options
 * @property {boolean} [request.strictSSL] Set to false to disable strict SSL checking, which allows using Dev APIs [Default: true]
 * @property {?AppAuthConfig} appAuth Optional configuration for App Auth
 */
type UserConfigurationOptions = {
  clientID: string;
  clientSecret: string;
  apiRootURL: string;
  uploadAPIRootURL: string;
  authorizeRootURL: string;
  uploadRequestTimeoutMS: number;
  retryIntervalMS: number;
  numMaxRetries: number;
  expiredBufferMS: number;
  request: {
    agentClass: any /* FIXME */;
    agentOptions: any /* FIXME */;
    strictSSL: boolean;
  };
  appAuth?: AppAuthConfig;
  proxy?: {
    url: string;
    username: string;
    password: string;
  };
};

var defaults = {
  clientID: null,
  clientSecret: null,
  apiRootURL: 'https://api.box.com',
  uploadAPIRootURL: 'https://upload.box.com/api',
  authorizeRootURL: 'https://account.box.com/api',
  apiVersion: '2.0',
  uploadRequestTimeoutMS: 60000,
  retryIntervalMS: 2000,
  numMaxRetries: 5,
  retryStrategy: null,
  expiredBufferMS: 180000,
  appAuth: undefined,
  iterators: false,
  enterpriseID: undefined,
  analyticsClient: null,
  disableStreamPassThrough: false,
  proxy: {
    url: null,
    username: null,
    password: null,
  },
  request: {
    // By default, require API SSL cert to be valid
    strictSSL: true,
    // Use an agent with keep-alive enabled to avoid performing SSL handshake per connection
    agentClass: https.Agent,
    agentOptions: {
      keepAlive: true,
    },
    // Encode requests as JSON. Encode the response as well if JSON is returned.
    json: true,
    // Do not encode the response as a string, since the response could be a file. return Buffers instead.
    encoding: null,
    // A redirect is usually information we want to handle, so don't automatically follow
    followRedirect: false,
    // By default, we attach a version-specific user-agent string to SDK requests
    headers: {
      'User-Agent': `Box Node.js SDK v${sdkVersion} (Node ${nodeVersion})`,
    },
  },
};

var appAuthDefaults = {
  algorithm: 'RS256',
  expirationTime: 30,
  verifyTimestamp: false,
};

/**
 * Validate the basic Config values needed for the SDK to function
 * @param {UserConfigurationOptions} params The user-supplied config values
 * @returns {void}
 * @throws {AssertionError}
 * @private
 */
function validateBasicParams(params: UserConfigurationOptions) {
  // Assert that the given params valid, and that required values are present
  assert(
    typeof params.clientID === 'string',
    '"clientID" must be set via init() before using the SDK.'
  );
  assert(
    typeof params.clientSecret === 'string',
    '"clientSecret" must be set via init() before using the SDK.'
  );
}

/**
 * Validate app auth-specific Config values
 * @param {Object} appAuth The user-supplied app auth values
 * @returns {void}
 * @throws {AssertionError}
 * @private
 */
function validateAppAuthParams(appAuth: AppAuthConfig) {
  assert(
    typeof appAuth.keyID === 'string',
    'Key ID must be provided in app auth params'
  );
  assert(
    typeof appAuth.privateKey === 'string' ||
      appAuth.privateKey instanceof Buffer,
    'Private key must be provided in app auth params'
  );
  assert(
    typeof appAuth.passphrase === 'string' && appAuth.passphrase.length > 0,
    'Passphrase must be provided in app auth params'
  );

  var validAlgorithms = ['RS256', 'RS384', 'RS512'];
  if (typeof appAuth.algorithm !== 'undefined') {
    assert(
      validAlgorithms.indexOf(appAuth.algorithm) > -1,
      `Algorithm in app auth params must be one of: ${validAlgorithms.join(
        ', '
      )}`
    );
  }

  if (typeof appAuth.expirationTime !== 'undefined') {
    assert(
      Number.isInteger(appAuth.expirationTime) &&
        appAuth.expirationTime > 0 &&
        appAuth.expirationTime <= 60,
      'Valid token expiration time (0 - 60) must be provided in app auth params'
    );
  }
}

/**
 * Update the agentClass based on the proxy config values passed in by the user
 * @param {UserConfigurationOptions} params The current Config values
 * @returns {void}
 * @private
 */
function updateRequestAgent(
  params: UserConfigurationOptions &
    Required<Pick<UserConfigurationOptions, 'proxy'>>
) {
  if (params.proxy.url) {
    let proxyUrl = params.proxy.url;
    if (params.proxy.username && params.proxy.password) {
      proxyUrl = proxyUrl.replace(
        '://',
        `://${params.proxy.username}:${params.proxy.password}@`
      );
    }

    const ProxyAgent = require('proxy-agent').ProxyAgent;
    params.request.agentClass = ProxyAgent;
    params.request.agentOptions = Object.assign(
      {},
      params.request.agentOptions,
      {
        getProxyForUrl: (url: string) => proxyUrl,
      }
    );
  }
}

/**
 * Deep freeze an object and all nested objects within it. It doesn't go deep on
 * Buffers and Readable streams so can be used on objects containing requests.
 * @param {Object} obj The object to freeze
 * @returns {Object} The frozen object
 */
function deepFreezeWithRequest(obj: any) {
  Object.freeze(obj);

  Object.getOwnPropertyNames(obj).forEach(function (name) {
    const prop = obj[name];

    if (
      prop !== null &&
      typeof prop === 'object' &&
      obj.hasOwnProperty(name) &&
      !Object.isFrozen(prop) &&
      !(prop instanceof Buffer) &&
      !(prop instanceof Readable)
    ) {
      deepFreezeWithRequest(obj[name]);
    }
  });

  return obj;
}

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

/**
 * A Config Object holds the configuration options of the current setup. These are all
 * customizable by the user, and will default if no value is specified in the given params
 * object. The object is frozen on initialization, so that no values can be changed after
 * setup.
 *
 * @param {UserConfigurationOptions} params - The config options set by the user
 * @constructor
 */
class Config {
  _params: Required<UserConfigurationOptions>;
  [key: string]: any;

  constructor(params: UserConfigurationOptions) {
    validateBasicParams(params);
    if (typeof params.appAuth === 'object') {
      validateAppAuthParams(params.appAuth);
      params.appAuth = merge({}, appAuthDefaults, params.appAuth);
    }

    // Ensure that we don't accidentally assign over Config methods
    assert(
      !params.hasOwnProperty('extend'),
      'Config params may not override Config methods'
    );
    assert(
      !params.hasOwnProperty('_params'),
      'Config params may not override Config methods'
    );

    // Set the given params or default value if params property is missing
    this._params = merge(defaults, params);
    updateRequestAgent(this._params);
    Object.assign(this, this._params);
    // Freeze the object so that configuration options cannot be modified
    deepFreezeWithRequest(this);
  }

  /**
   * Extend the current config into a new config with new params overriding old ones
   * @param {UserConfigurationOptions} params The override options
   * @returns {Config} The extended configuration
   */
  extend(params: UserConfigurationOptions) {
    var newParams = merge({}, this._params, params);
    delete newParams.extend;
    delete newParams._params;
    return new Config(newParams);
  }
}

/**
 * @module box-node-sdk/lib/util/config
 * @see {@Link Config}
 */
export = Config;