Source: session.js

import delay from 'lodash.delay';
import differenceInMilliseconds from 'date-fns/difference_in_milliseconds';

export const NO_OAUTH_SECRET_PROVIDED = `
No oAuth client secret was provided.
Provide a base64 secret to allow oAuth logins.
`;

/**
 * Manages the end user authentication session against (currently) pauth token.
 *
 * <p>This class is not intended to be used directly. You should instead use {@link Client#login},
 * {@link Client#logout} and [Client's session property]{@link Client}</p>
 *
 * <p>See the {@link https://identity-reference.sequoia.piksel.com/docs|Sequoia Documentation} for more information.</p>
 *
 * @param {Transport} transport - Transport instance to use for fetching
 * @param {string} directory - The directory (sometimes refered to as 'domain') that the user belongs to.
 * This is prepended to the username on login requests (POSTs to /pauth/token)
 * @param {Registry} registry - a stored registry reference to query
 * @param {string?} identityUri - The endpoint URI for the sequoia identity service e.g. https://identity-sandbox.sequoia.piksel.com
 *
 * @property {string} directory - Stored from the initial `directory` parameter
 * @property {string?} identityUri - Stored from the initial `identityUri` parameter
 * @property {string?} token - The access_token returned from the call to
 * {@link https://identity-reference.sequoia.piksel.com/docs/routes/pauth-token|/pauth/token} after logging in
 * @property {Object[]} tenants - Stored array of tenants the user has access to
 * See the {@link https://identity-reference.sequoia.piksel.com/docs/routes/pauth-tenants|tenants documentation} for more information.
 * @property {Object} access - Stored access information for the logged in user.
 * See the {@link https://identity-reference.sequoia.piksel.com/docs/routes/pauth-access|access documentation} for more information.
 *
 * @example
 * import Session from '@pikselpalette/sequoia-js-client-sdk/lib/session';
 *
 * const session = new Session('piksel', 'https://identity-sandbox.sequoia.piksel.com');
 * session.authenticateWithCredentials('username', 'password').then((session) => this.registry.fetch(session.currentOwner())).then(() => this.session)
 *
 */
class Session {
  constructor(transport, directory, registry, identityUri) {
    this.transport = transport;
    this.directory = directory;
    this.registry = registry;
    this.identityUri = identityUri;
    this.token = null;
    this.tenants = [];
    this.access = {};
    this.currentTenantName = null;
    this.token = null;
    this.expiryWarningRef = null;
  }

  getIdentityUri() {
    const identityUri = this.registry.getServiceLocation('identity');

    if (identityUri !== null) {
      this.identityUri = identityUri;
    }

    // We either had the identityUri supplied via the `client` constructor
    // or we had a pre-populated registry, so return it
    if (this.identityUri) {
      return Promise.resolve(this.identityUri);
    }

    // We don't know about the location of identity, so ask the registry
    const tenancy = this.currentTenant
      ? this.currentTenant.name
      : this.directory;

    return this.registry.fetch(tenancy).then(() => {
      this.identityUri = this.registry.getServiceLocation('identity');
    });
  }

  /**
   * @typedef {Object} AuthenticationOptions
   * @property {string?} strategy - 'pauth' or 'oauth'
   * @property {string?} secret - [base64 clientId and clientSecret]{@link https://identity-euw1shared.sequoia.piksel.com/docs/groups/oauth}
   * @property {string?} url - Override the URL to hit for authentication. Useful
   * for providing custom endpoints to provide an access_token
   */

  /**
   * Create a session based on end user credentials
   *
   * @param {string?} username
   * @param {string?} password
   * @param {AuthenticationOptions?} options
   *
   * @see {Client#login}
   * @returns {Promise}
   */
  authenticateWithCredentials(
    username,
    password,
    options = { strategy: 'pauth' }
  ) {
    return this.getIdentityUri().then(() => {
      const { strategy, url, secret } = options;
      let endpoint = `${this.identityUri}/pauth/token`;
      let body = JSON.stringify({
        username: `${this.directory}\\${username}`,
        password
      });
      let headers;

      if (strategy === 'oauth') {
        if (secret === undefined) {
          return Promise.reject(NO_OAUTH_SECRET_PROVIDED);
        }

        endpoint = `${this.identityUri}/oauth/token`;

        if (typeof username === 'string' && typeof password === 'string') {
          body = `grant_type=password&username=${
            this.directory
          }\\${username}&password=${encodeURIComponent(password)}`;
        } else {
          body = 'grant_type=client_credentials';
        }

        headers = {
          authorization: `Basic ${secret}`,
          'Content-Type': 'application/x-www-form-urlencoded'
        };
      }

      // The `url` option overrides
      if (url) {
        endpoint = url;
      }

      delete options.url;
      delete options.strategy;
      delete options.secret;

      const fetchOptions = { body };

      // Just { body, headers } leaves options.headers as undefined and therefor
      // overrides the defaults in transport.js
      if (headers) {
        fetchOptions.headers = headers;
      }
      return (
        this.transport
          .post(endpoint, Object.assign({}, fetchOptions, options))
          // eslint-disable-next-line camelcase
          .then(({ access_token }) => this.authenticateWithToken(access_token))
          .then(() => this)
      );
    });
  }

  /**
   * Create a session based on an existing bearer token
   *
   * Use this if you acquire an access token via other means,
   * i.e. an existing oauth mechanism for Sequoia
   *
   * @param {string} token
   *
   * @returns {Promise}
   */
  authenticateWithToken(token) {
    if (token) {
      this.token = token;
      this.transport.defaults.headers.authorization = `Bearer ${this.token}`;
    }

    this.clearExpiryWarning();

    return this.populateTenants()
      .then(() => this.populateAccess())
      .then(() => {
        this.startOnExpiryWarningTimer();
        return this;
      });
  }

  /**
   * Change the password of the user. Piksel Auth only.
   *
   * @param {string} oldPassword
   * @param {string} newPassword
   */
  changePassword(username, password, newPassword) {
    return this.getIdentityUri().then(identityUrl => (
      this.transport.post(`${identityUrl}/pauth/passwords`, {
        body: JSON.stringify({
          username,
          password,
          newPassword
        })
      })
    ));
  }

  /**
   * Reset the password of the user. Piksel Auth only.
   *
   * @param {string} username
   */
  resetPassword(username) {
    return this.getIdentityUri().then(identityUrl => (
      this.transport.post(`${identityUrl}/pauth/password-resets`, {
        body: JSON.stringify({
          username
        })
      })
    ));
  }

  /**
   * Set the directory after initialisation
   *
   * Use this if you want to cache your client, but want to
   * login under a different directory / domain
   *
   * @param {string} directory
   */
  setDirectory(directory) {
    this.directory = directory;
  }

  /**
   * Set the expiry warning callback and threshold.
   * @param {Function?} callback Callback invoked when token is expiring
   * @param {Number?} threshold Threshold before expiry on which to invoke the callback, default 60000ms
   */
  setOnExpiryWarning(callback, threshold = 60000) {
    this.clearExpiryWarning();

    if (callback) {
      this.onExpiryWarning = callback;
      this.expiryThreshold = threshold;
    }

    this.startOnExpiryWarningTimer();
  }

  /**
   * Clear the expiry warning callback and cancel any timers.
   */
  clearExpiryWarning() {
    this.onExpiryWarning = null;
    this.expiryThreshold = null;

    // Clear any existing timers
    if (this.expiryWarningRef) {
      clearTimeout(this.expiryWarningRef);
      this.expiryWarningRef = null;
    }
  }

  /**
   * Start the expiry warning timer.
   */
  startOnExpiryWarningTimer() {
    if (this.access && this.onExpiryWarning) {
      const expiryWarning = differenceInMilliseconds(this.access.expiresAt, new Date()) - this.expiryThreshold;
      this.expiryWarningRef = delay(
        expiringAccess => this.tokenExpiring(expiringAccess),
        expiryWarning,
        this.access
      );
    }
  }

  /**
   * Called when the token is about to expire.
   * Calls the callback if registered.
   * @param  {AccessSession} access The Sequoia access session
   */
  tokenExpiring(access) {
    if (this.onExpiryWarning) {
      this.onExpiryWarning(access);
    }
  }

  /**
   * Populate the `tenants` properties of `Session` with
   * data returned from Sequoia.
   *
   * @private
   *
   * @returns {Promise}
   */
  populateTenants() {
    if (this.token === null) {
      // Not authenticated, so do nothing
      return Promise.resolve();
    }

    return this.getIdentityUri()
      .then(() => this.transport.get(`${this.identityUri}/pauth/tenants`))
      .then((json) => {
        this.tenants = json.tenants;
      });
  }

  /**
   * Populate the `access` properties of `Session` with
   * data returned from Sequoia.
   *
   * @private
   *
   * @returns {Promise}
   */
  populateAccess() {
    if (this.token === null) {
      // Not authenticated, so do nothing
      return Promise.resolve();
    }

    return this.getIdentityUri()
      .then(() => {
        let accessUri = `${this.identityUri}/pauth/access`;

        if (this.currentOwner()) {
          accessUri = `${accessUri}?tenants=${this.currentOwner()}`;
        }

        return this.transport.get(accessUri);
      })
      .then((json) => {
        this.access = json;
      });
  }

  /**
   * Returns whether there is a currently active logged in session
   *
   * @returns {boolean}
   */
  isActive() {
    return this.token !== null;
  }

  /**
   * Find a tenant by name in the current set of user available tenants
   *
   * @returns {Object}
   */
  findTenant(tenantName) {
    return this.tenants.find(item => item.name === tenantName);
  }

  /**
   * The current tenant being used in this session.
   *
   * When getting this propery, it will default to the first tenant in the {@link Session.tenants} array
   * if this property has not been explicitly set
   *
   * Will return null if there are no tenants available (e.g. the session is inactive)
   * @type {string?}
   */
  get currentTenant() {
    if (!this.tenants.length) {
      return null;
    }

    return this.currentTenantName === null
      ? this.tenants[0]
      : this.findTenant(this.currentTenantName);
  }

  set currentTenant(tenantName) {
    this.currentTenantName = tenantName;
  }

  /**
   * Get the name (owner) of the current tenant
   *
   * @returns {string}
   */
  currentOwner() {
    return this.currentTenant
      ? this.currentTenant.name
      : this.currentTenantName;
  }

  /**
   * Log out an end user
   *
   * @todo Should this make a call to some service to revoke the auth token?
   *
   * @returns {Session}
   */
  destroy() {
    this.token = null;
    this.tenants = [];
    this.access = {};
    this.currentTenantName = null;

    delete this.transport.defaults.headers.Authorization;

    if (this.expiryWarningRef) {
      clearTimeout(this.expiryWarningRef);
      this.expiryWarningRef = null;
    }

    return this;
  }
}

export default Session;