Manual Reference Source Test

src/modules/identity.js

/**
 * Identity SDK version
 *
 * Note: This require is replaced by an inline version string by babel-plugin-inline-package-json
 *
 * @type {String}
 */
const version = require('../../package.json').version; // eslint-disable-line

import {checkIsLocal} from '../helpers/local';
import IdentityAPI from './identity-api';
import IdentityConfig from './identity-config';
import IdentityCookie from './identity-cookie';
import IdentityNative from './identity-native';
import IdentityServer from './identity-server';
import IdentityUser from './identity-user';
import Logger from './logger';
import Observable from './observable';
import IdentityRecord from './identity-record';
import IdentityRedirect from './identity-redirect';
import IdentityCrossApp from './identity-crossapp';
import IdentityWindow from './identity-window';
import IdentityLaunchdarkly from './identity-launchdarkly';

/**
 * NBC Identity SDK class
 *
 * Note: The default export of this SDK is an instance of this class
 * which is globally exposed as 'Identity' in the browser.
 */
class Identity extends Observable {
  /**
   * Identity constructor
   */
  constructor() {
    super();

    /**
     * Read-only properties exposed by getters below
     * @type {String}
     */
    this._state = 'uninitialized';
    /**
     * @type {String}
     */
    this._native = false;
    /**
     * @type {Object}
     */
    this._nativeController = null;
    /**
     * @type {String}
     */
    this._result = null;
    /**
     * @type {String}
     */
    this._message = null;
    /**
     * @type {String}
     */
    this._token = null;
    /**
     * @type {Boolean}
     */
    this._topbar = true;
    /**
     * @type {Object}
     */
    this._user = null;
    /**
     * @type {Object}
     */
    this._redirectController = null;
    /**
     * @type {Object}
     */
    this._popUpController = null;
    /**
    * @type {Object}
    */
    this._iframeController = null;
    /**
     * @type {Object}
     */
    this._cookieHelper = null;

    /**
     * Private properties
     * @type {Logger}
     */
    this._logger = new Logger('Identity');
    /**
     * @type {String}
     */
    this._env = null;
    /**
     * @type {String}
     */
    this._key = null;
    /**
     * @type {IdentityConfig}
     */
    this._config = null;
    /**
     * @type {IdentityServer}
     */
    this._server = null;
    /**
     * @type {IdentityAPI}
     */
    this._api = null;
    /**
     * @type {Boolean}
     */
    this._useCookie = /(\.nbc\.com)/.test(location.hostname) || checkIsLocal();
    /**
     * @type {Boolean}
     */
    this._additionalDisabled = false;
    /**
     * @type {Boolean}
     */
    this._isVPPARemoved = false;
    /**
     * @type {Boolean}
     */
    this._enablePopUp = false;
    /**
    * @type {Boolean}
    */
    this._enableIframe = false;
    /**
     * @type {Boolean}
     */
    this._initEnablePopUp = false;
    /**
     * @type {Array}
     */
    this._optIns = [];

    // Bind this to be used elsewhere
    this._updateSession = this._updateSession.bind(this);
    this._onResult = this._onResult.bind(this);
    this._onPopUpMessage = this._onPopUpMessage.bind(this);
    this._onIframeMessage = this._onIframeMessage.bind(this);
    this._onPopUpClose = this._onPopUpClose.bind(this);
    this._onIframeClose = this._onIframeClose.bind(this);
    this._ldOnChange = this._ldOnChange.bind(this);
  }

  /**
   * Identity SDK version
   *
   * @type {String}
   */
  get version() {
    return version;
  }

  /**
   * Identity SDK state
   *
   * Can be one of:
   * - 'uninitialized'
   * - 'initializing'
   * - 'unauthenticated'
   * - 'authenticated'
   * - 'unauthenticating'
   * - 'unableToFetchUserData'
   * - 'cross-app-unavailable'
   * - 'pop-up-blocked'
   *
   * Changes to this value will be published as 'state' events.
   *
   * @type {String}
   */
  get state() {
    return this._state;
  }

  /**
   * Identity SDK state message
   *
   * @type {String}
   */
  get message() {
    return this._message;
  }

  /**
   * Authenticate result
   *
   * Can be one of:
   * - null - No result
   * - 'registered' - Registered as a new user
   * - 'logged in' - Logged in as existing user
   * - 'completed' - User profile has been completed
   * - 'cancelled' - Authentication was cancelled by user
   * - 'logged out' - Logged out user
   * - 'logout cancelled' - Logout cancelled by user
   * - 'cross app login cancelled' - Cross app login cancelled by user
   *
   * @type {String}
   */
  get result() {
    return this._result;
  }

  /**
   * Current Identity user session token, or null
   *
   * @type {String}
   */
  get token() {
    return this._token;
  }

  /**
   * Current authenticated Identity user, or null (read-only)
   *
   * @type {IdentityUser}
   */
  get user() {
    return this._user;
  }

  /**
   * Additional Information bypass state
   *
   * @type {Boolean}
   */
  get additionalInformationDisabled() {
    return this._additionalDisabled;
  }

  /**
   * Log arguments with Identity prefix
   */
  _log(...args) {
    this._logger.log(...args);
  }

  /**
   * Set Identity state and notify state observers of new value
   *
   * Optionally also set result, token and/or user before notifying observers of state change.
   * This ensures a consistent set of Identity property values when observers are called.
   *
   * @param {String} newState - New Identity state
   * @param {Object} [options] - Options
   * @param {Boolean} [options.result] - New Identity result
   * @param {Boolean} [options.token] - New Identity token
   * @param {Boolean} [options.user] - New Identity user
   */
  _setState(newState, options) {
    this._log(`state: ${newState}`, options);
    this._state = newState;
    if (options) {
      if ('message' in options) {
        this._message = options.message;
      }
      if ('result' in options) {
        this._result = options.result;
      }
      if ('token' in options) {
        this._token = options.token;
      }
      if ('user' in options) {
        this._user = options.user;
      }
    }
    this.notify('state', newState);
  }

  /**
   * Set Identity result and notify result observers of new value
   *
   * @param {String} result - New Identity result
   */
  _setResult(result) {
    this._log(`result: ${result}`);
    this._result = result;
    this.notify('result', result);
  }

  /**
   * Set Identity token and notify token observers of new value
   *
   * @param {String} token - New Identity token
   */
  _setToken(token) {
    this._log(`token: ${token}`);
    this._token = token;
    this.notify('token', token);
  }

  /**
   * Set Identity profile and notify profile observers of new value
   *
   * @param {String} user - New Identity profile
   */
  _setProfile(user) {
    this._log('profile: ', user);
    this._profile = user;
    this.notify('profile', user);
  }

  /**
   * Update Identity session token, user and state
   *
   * @param {String} [newToken=null] - New Identity session token
   * @param {IdentityUser} [storedUser=null] - Restored Identity user
   * @param {Boolean} [setState=true] - set the Identity state
   * @return {Promise<Identity, Error>} - Promise of Identity with updated token, user and state
   */
  _updateSession(newToken = null, storedUser = null, setState = true) {
    const handleAuth = (token, user) => {
      const userData = {...user};
      this._optIns.forEach((optIn) => userData[optIn] = true);
      const storedUser = new IdentityUser(this._config, userData);

      this._setState('authenticated', {
        token,
        user: storedUser,
      });
      this._log('Stored', IdentityRecord.store(this._env, this._key, this._token, this._user));
      return this;
    };

    if (!newToken) {
      IdentityRecord.clear();
      if (setState && this._state !== 'unauthenticated') {
        this._setState('unauthenticated', {
          token: null,
          user: null,
        });
      }
      return Promise.resolve(this);
    }

    if (storedUser) {
      this._setState('authenticated', {
        token: newToken,
        user: storedUser,
      });
      return Promise.resolve(this);
    }

    const unableToFetch = () => {
      this._setState('unableToFetchUserData', {
        token: null,
        user: null,
      });
      return this._updateSession(null, null, false);
    };

    switch (this._result) {
      case 'registered':
      case 'logged in':
      case 'completed':
      case 'user cancelled':
      case 'logout cancelled': {
        const storedProfile = this._profile;
        this._profile = null;
        const profileCookie = this._cookieHelper.getCookie('profile');
        if (profileCookie) this._cookieHelper.removeCookie('profile');
        const userProfile = storedProfile || profileCookie;
        if (!userProfile) break;
        const profileData = (typeof userProfile === 'string') ? JSON.parse(userProfile) : userProfile;
        if (!profileData._id) break;
        return handleAuth(newToken, profileData);
      }
    }

    return this._apiCall('getUserInfo', {
      newToken,
      userData: null,
    }).then((newUser) => {
      return handleAuth(newToken, newUser);
    }).catch((error) => {
      // Fallback to updating session to unauthenticated (e.g: when token is invalid)
      this._log(`${error.name} fetching user by token: ${error.message}`, newToken, error);
      // Remove the token cookie
      this._cookieHelper.removeCookie('token');
      // Update state and session
      return unableToFetch();
    });
  }

  /**
   * Restore the user session if possible
   *
   * @param {Object} config
   * @param {String} env
   * @param {String} key
   * @param {String} token = null
   * @return {Promise}
   */
  _restoreRecord(config, env, key, token = null) {
    const record = IdentityRecord.restore(config, env, key, token);

    if (record) {
      this._log('Restored', record);
      return this._updateSession(record.token, record.user);
    } else {
      return this._updateSession(token);
    }
  }

  /**
   * Handle receiving messages from pop-up window
   *
   * @param {String} data - JSON data passed by the pop-up
   */
  _onPopUpMessage(data) {
    if (!data) return;

    const {
      token,
      result,
      user,
    } = data;

    if (token) this._setToken(token);
    if (user) this._setProfile(user);
    if (result) this._setResult(result);
  }

  /**
   * Fire callback when pop-up closes
   *
   * @param {Boolean} fromWindow
   */
  _onPopUpClose(fromWindow) {
    this._log('Pop-up closed');

    if (!fromWindow &&
      !(this.result === 'logged in' || this.result === 'registered')) this._setResult('user cancelled');
    if (!this._enablePopUp) this._popUpController.remove();
  }

  /**
   * Handle receiving messages from iframe window
   *
   * @param {String} data - JSON data passed by the iframe
   */
  _onIframeMessage(data) {
    if (!data) return;

    const {
      token,
      result,
      user,
    } = data;

    if (token) this._setToken(token);
    if (user) this._setProfile(user);
    if (result) this._setResult(result);
  }

  /**
   * Fire callback when iframe closes
   *
   * @param {Boolean} fromWindow
   */
  _onIframeClose(fromWindow) {
    this._log('Iframe closed');

    this._iframeController.closeIframe();
    if (!fromWindow &&
      !(this.result === 'logged in' || this.result === 'registered')) this._setResult('user cancelled');
    if (!this._enableIframe) this._iframeController.remove();
  }

  /**
   * Result change handler
   *
   * @param {Boolean} tokenIsParam - Whether the Identity token is passed as a parameter
   * @return {Promise<Identity, Error>} - Promise of Identity with updated token, user and state
   */
  _onResult(tokenIsParam = false) {
    // Remove user session
    if (this._result === 'logged out') {
      this._log('Remove user session');
      // Remove the token cookie
      this._cookieHelper.removeCookie('token');
      return this._updateSession();
    }

    // Update user session
    const notCancelled = this._result && !/cancelled/.test(this._result);
    if (this._token && (notCancelled || tokenIsParam)) {
      this._log('Update user session');
      return this._updateSession(this._token);
    }

    // Restore user session
    if (this._token || !this._useCookie) {
      this._log('Restore user session');
      return this._restoreRecord(this._config, this._env, this._key, this._token);
    }

    // No user session
    this._log('No user session available');
    return this._updateSession();
  }

  /**
   * Check Launch Darkly Popup Feature flag and populate local flag
   *
   * @param {String} name - Launch Darkly flag key
   * @return {Boolean}
   */
  _ldCheckFlag(name) {
    const ldFlag = this._launchdarklyController.getFlag(name);

    switch (name) {
      case 'popup-feature':
        this._enablePopUp = (typeof ldFlag === 'boolean') ? ldFlag : this._initEnablePopUp;
        break;
      case 'additional-information-screen-show':
        this._additionalDisabled = (typeof ldFlag === 'boolean') ? !ldFlag : false;
        break;
      case 'remove-registration-vppa-checkbox':
        this._isVPPARemoved = (typeof ldFlag === 'boolean') ? ldFlag : false;
        break;
    }

    return ldFlag;
  }

  /**
   * React to Launch Darkly changes
   */
  _ldOnChange() {
    this._shouldEnablePopUp(this._enablePopUp, true);
    this._ldCheckFlag('additional-information-screen-show');
    this._ldCheckFlag('remove-registration-vppa-checkbox');
  }

  /**
   * Make sure pop-up should be used
   *
   * @param {Boolean} enablePopUp
   * @param {Boolean} checkLD
   * @return {Boolean}
   */
  _shouldEnablePopUp(enablePopUp, checkLD = false) {
    if (this._native) return false;

    const initPopUp = () => {
      if (this._popUpController && !this._popUpController.isInit) {
        this._popUpController.init();
      }
    };

    if (checkLD) {
      const ldPopupFeature = this._ldCheckFlag('popup-feature');
      if (ldPopupFeature) initPopUp();
      if (typeof ldPopupFeature === 'boolean') return ldPopupFeature;
    }

    if (enablePopUp) initPopUp();
    return enablePopUp;
  }

  /**
   * Check user agent to see if iFrame modal is supported
   * iOS 12 and lower is not supported
   * If version is not found in the userAgent check if its iPhone or iPad and don't support those
   * If neither version nor iPhone / iPad are found, set version to 13 to pass
   *
   * @return {Boolean}
   */
  _isIframeSupported() {
    const getIOSVersion = () => {
      const {userAgent} = window.navigator;
      const start = userAgent.indexOf('OS ');
      const isIOS = userAgent.indexOf('iPhone') > -1 || userAgent.indexOf('iPad') > -1;
      const version = (isIOS && start > -1) ? window.Number(userAgent.substr(start + 3, 3).replace('_', '.')) : null;

      if (version) return version;
      if (isIOS) return 12;

      return 13;
    };

    const version = getIOSVersion();
    return version && version > 12;
  }

  /**
   * Make sure iframe should be used
   *
   * @param {Boolean} enableIframe
   * @param {Boolean} checkLD
   * @return {Boolean}
   */
  _shouldEnableIframe(enableIframe, checkLD = false) {
    const initIframe = () => {
      if (this._iframeController && !this._iframeController.isInit) {
        this._iframeController.init();
      }
    };

    if (enableIframe) initIframe();
    return enableIframe;
  }

  /**
   * Determine the enabled user flow
   *
   * @param {Object} [properties]
   * @param {Boolean} [properties.enableIframe]
   * @param {Boolean} [properties.enablePopUp]
   * @param {Boolean} isInit
   * @return {String} User flow: 'redirect' | 'popup' | 'iframe'
   */
  _determineUserFlow(properties, isInit = false) {
    const isIframeSupported = this._isIframeSupported();
    const shouldEnableIframe = this._shouldEnableIframe(properties.enableIframe);
    this._enableIframe = isIframeSupported && shouldEnableIframe;

    // Fallback to popup if iFrame is not supported
    const enablePopUp = (shouldEnableIframe && !isIframeSupported) ? true : properties.enablePopUp;
    const shouldEnablePopUp = this._shouldEnablePopUp(enablePopUp, true);
    this._enablePopUp = shouldEnablePopUp;
    this._initEnablePopUp = shouldEnablePopUp;

    if (shouldEnablePopUp) return 'popup';
    if (this._enableIframe) {
      if (!isInit) {
        const topbarEnabled = this._isTopbarEnabled(true);
        this._redirectController.updateGlobalProp('topbar', topbarEnabled);
      }

      return 'iframe';
    }

    return 'redirect';
  }

  /**
   * Make sure properties should be requested
   *
   * @param {Array} properties
   * @return {Boolean}
   */
  _shouldRequestProperties(properties = []) {
    const allowedProperties = [];

    properties.forEach((name) => {
      const ldPropertyCheck = this._ldCheckFlag(`additional-information-${name}`);

      if (ldPropertyCheck === false) return;
      allowedProperties.push(name);
    });

    return allowedProperties;
  }

  /**
   * Check if topbar is enabled based on topbar and enableIframe attributes
   *
   * @param {Boolean} enableIframe
   *
   * @return {Boolean}
   */
  _isTopbarEnabled(enableIframe = this._enableIframe) {
    return this._topbar || (this._topbar === undefined && !enableIframe);
  }

  /**
   * Send API call with specific headers
   * @param {String} name
   * @param {Object} args
   *
   * @return {Promise}
   */
  _apiCall(name, args) {
    if (!this._api || !this._api[name]) return Promise.reject();
    if (!args.options) args.options = {};
    if (!args.options.headers) args.options.headers = {};
    if (this._isVPPARemoved) {
      args.options.headers['X-IDM-Bypass-VPPA'] = true;
      delete args.options.headers['vppa_re_opt_in'];
    }
    this._log('API Call', name);
    return this._api[name](...Object.values(args));
  }

  /**
   * Initialize SDK for use
   *
   * This will start by fetching the {@link IdentityConfig} based on the specified env.
   *
   * If the token option is specified then that value will be used.
   *
   * Else the URL fragment will be parsed for the token, result and hash values passed to this web app upon returning
   * from authentication and the original URL fragment (hash) value will be restored.
   *
   * If no token is specified then token and user will be restored from local storage, provided the data has not
   * timed out according to the {@link IdentityConfig#sessionTimeout} and/or {@link IdentityConfig#userTimeout}.
   *
   * If a token is available but no user data (e.g: upon authentication or user data cache timeout)
   * then the user data will be fetched from the IDM server and persisted before resolving this promise.
   *
   * After initialization the Identity properties (e.g: {@link Identity#state}, {@link Identity#result},
   * {@link Identity#token} and {@link Identity#user}) can be examined.
   *
   * @param {String} key - NBC App key, e.g: example
   * @param {Object} [options={}] - Options
   * @param {Boolean} [options.debug=false] - If true then enable logging
   * @param {String} [options.env='production'] - Environment to use, e.g: dev/stage/acc/production
   * @param {Boolean} [options.useLocalConfig=false] - Flag that loads local config file if true
   * @param {Boolean} [options.native=false] - Enable native analytics bridge
   * @param {String} [options.token=null] - Identity user session token
   * @param {String} [options.topbar=true] - If false then the topbar is not displayed
   * @param {Boolean} [options.useLocalConfig=false] - Load config from local directory
   * @param {Boolean} [options.enablePopUp=false] - Open authenticate in windowed pop-up instead of redirect
   *                                               on all authenticate and unauthenticate calls
   * @param {Boolean} [options.disableCookie=false] - Disable the use of the standard cookie logic
   * @param {String} [options.configLocationHost] - Call config from custom host location
   * @return {Promise<Identity, Error>} - Promise of initialized Identity
   */
  initialize(key, {
    debug = false,
    env = 'production',
    native = false,
    token = null,
    topbar,
    useLocalConfig = false,
    enablePopUp = false,
    enableIframe = false,
    disableCookie = false,
    configLocationHost = null,
  } = {}) {
    this._logger.enabled = debug;
    this._env = env;
    this._key = key;
    this._native = native;
    if (this._native) this._nativeController = new IdentityNative();
    this._server = new IdentityServer({sdkVersion: version, env, useLocalConfig});

    this._launchdarklyController = new IdentityLaunchdarkly({env});
    this._topbar = topbar;
    this._useLocalConfig = useLocalConfig;
    if (disableCookie) this._useCookie = !disableCookie;
    this._log({version, env, key, debug});
    this._setState('initializing');

    return IdentityConfig.request({server: this._server, key, configLocationHost}).then((config) => {
      this._log('config', config);
      this._config = config;
      this._server.setConfig(config);
      this._api = new IdentityAPI({server: this._server, config, useCookie: this._useCookie});
      this._cookieHelper = new IdentityCookie(config);
      this._cookieHelper.setEnabled(this._useCookie);

      this._popUpController = new IdentityWindow({
        debug,
        onMessage: this._onPopUpMessage,
        onClose: this._onPopUpClose,
      });

      this._iframeController = new IdentityWindow({
        debug,
        config,
        onMessage: this._onIframeMessage,
        onClose: this._onIframeClose,
      });

      const userFlow = this._determineUserFlow({enableIframe, enablePopUp}, true);

      const topbarEnabled = this._isTopbarEnabled(userFlow === 'iframe');

      this._redirectController = new IdentityRedirect({
        debug,
        env,
        key,
        native,
        topbar: topbarEnabled,
        useLocalConfig,
        version,
        configLocationHost,
      }, this._server, this._popUpController, this._iframeController);

      // Token was passed through the initialize parameter
      const tokenIsParam = token !== null;

      // Set the cookie to the passed token
      if (tokenIsParam) this._cookieHelper.setCookie('token', token);

      // Grab token from cookie
      // Remove token for testing
      // this._cookieHelper.removeCookie('token');
      token = this._cookieHelper.getCookie('token');

      // Start the cookie observer
      this._cookieSub = this._cookieHelper.subscribe('token', (newToken) => {
        if (newToken === this.token && this.state === 'authenticated') return;

        this._updateSession(newToken);
      });

      // No token specified, parse parameters from URL fragment
      if (!tokenIsParam) {
        const params = this._redirectController.parseUrlParameters();
        if (params) {
          if (params.token) token = params.token;
          this._result = params.result;
          this._optIns = params.optIns;
        }
      }


      this._token = token;
      this.on('result', this._onResult);

      // Initialize Launch Darkly
      if (config.launchdarkly && config.launchdarkly.enabled) {
        this._launchdarklyController.init(key, config.launchdarkly, {}, this._ldOnChange).then(() => {
          this._shouldEnablePopUp(this._enablePopUp, true);
          this._ldCheckFlag('additional-information-screen-show');
          this._ldCheckFlag('remove-registration-vppa-checkbox');
          this._onResult(tokenIsParam);
        }).catch(() => {
          this._onResult(tokenIsParam);
        });
      }

      return this._onResult(tokenIsParam);
    }).catch((error) => {
      this._log(`${error.name} initializing: ${error.message}`, error);
      this._setState('uninitialized');
      throw error;
    });
  }

  /**
   * Authenticate user
   *
   * This will redirect the web browser or view to https://id.nbc.com/sdk/authenticate/ where an attempt will be made
   * to take all necessary steps to result in a user with the specified properties after being redirected back.
   *
   * If no properties are specified then all properties that have "request: true" in the config will be requested.
   * If the user had already previously submitted valid property values for all of those however,
   * the additional properties screen will be skipped, to allow for quick re-authentication.
   * If some, but not all of the values were previously submitted then those will shown besides the invalid ones.
   *
   * If properties are specified in this call then only those specific properties will be requested.
   * In that case, even if the user had already previously submitted valid property values they will be re-requested,
   * allowing the user to either specify or review and change those values.
   * Only properties defined by the App's IdentityConfig can be requested.
   *
   * If an Identity or Facebook token is specified then the authentication page will attempt to use those.
   *
   * After the Authenticate page redirects back and this App re-initializes {@link Identity#result} can be used
   * to determine the authentication result.
   *
   * @example
   * // Default authentication based on App IdentityConfig:
   * Identity.authenticate();
   *
   * // Or alternatively (e.g: opt-in at a later time) request specific properties:
   * Identity.authenticate({
   *   properties: ['sponsorOptIn']
   * });
   *
   * @param {Object} [options={}] - Options
   * @param {String[]} [options.properties] - Keys of specific user properties to request
   * @param {String} [options.origin] - Auth origin, e.g: NBC Login, Vote, BAT, ...
   * @param {String} [options.redirectUrl=location.href] - URL to redirect back to after authentication
   * @param {String} [options.token=this.token] - Identity token to (try to) use
   * @param {String} [options.facebookToken] - Facebook access token to (try to) use
   * @param {String} [options.marketingReferrer] - Referrer string based on show phase, e.g: S16_The Voice_BAT
   * @param {String} [options.propertyEdit] - Request that the passed properties be editable
   * @param {String} [options.enablePopUp] - Open authenticate in windowed pop-up instead of redirect.
   *                                         Overwrites initialize param
   * @param {String} [options.defaultPage] - Open authenticate to a specific page e.g: signUp, signupWithEmail, signIn
   *
   * @return {Error}
   */
  authenticate({
    properties = [],
    origin = null,
    redirectUrl = location.href,
    token = this.token,
    facebookToken,
    marketingReferrer = null,
    propertyEdit = false,
    enableIframe = this._enableIframe,
    enablePopUp = this._enablePopUp,
    defaultPage = null,
  } = {}) {
    // Default page whitelist
    const validDefaultPages = [
      'signUp',
      'signupWithEmail',
      'signIn',
    ];

    if (defaultPage && validDefaultPages.indexOf(defaultPage) === -1) {
      return window.console.error(
        // eslint-disable-next-line max-len
        `Identity.authenticate error: make sure the defaultPage property is set to one of the following: ${validDefaultPages.join(', ')}`,
      );
    }

    const userFlow = this._determineUserFlow({enableIframe, enablePopUp});

    // Redirect user to authenticate
    this._redirectController.trigger({
      properties: this._shouldRequestProperties(properties),
      origin,
      redirectUrl,
      token,
      facebookToken,
      marketingReferrer,
      propertyEdit,
      enableIframe: userFlow === 'iframe',
      enablePopUp: userFlow === 'popup',
      requestPrompt: !this._native && !this._useCookie && !this._token,
      defaultPage,
    });
  }

  /**
   * ManageProfile user
   * @param {Object} [options={}] - Options
   * @param {String[]} [options.properties] - Keys of specific user properties to request
   * @param {String} [options.marketingReferrer] - Referrer string based on show phase, e.g: S16_The Voice_BAT
   * @param {String} [options.propertyEdit] - Request that the passed properties be editable
   * @param {String} [options.defaultPage] - Open authenticate to a specific page e.g: signUp, signupWithEmail, signIn
   *
   */
  manageProfile({
    marketingReferrer = null,
    defaultPage = null,
  } = {}) {
    this.authenticate({
      properties: ['email', 'firstName', 'lastName', 'gender', 'phone', 'zipCode'],
      marketingReferrer,
      defaultPage,
      propertyEdit: true,
    });
  }

  /**
   * Unauthenticate user
   * @param {Object} [options={}] - Options
   * @param {String} [options.origin] - Auth origin, e.g: NBC Login, Vote, BAT, ...
   * @param {String} [options.redirectUrl=location.href] - URL to redirect back to after authentication
   * @param {String} [options.token=this.token] - Identity token to (try to) use
   * @param {Boolean} [options.redirect] - Flag to redirect user to the auth app on unauthenticate
   * @param {String} [options.enablePopUp] - Open authenticate in windowed pop-up instead of redirect.
   *                                         Overwrites initialize param
   * @return {Promise<Identity, Error>} - Promise of unauthenticated Identity
   */
  unauthenticate({
    origin = null,
    redirectUrl = location.href,
    token = this.token,
    redirect = true,
    enablePopUp = this._enablePopUp,
    enableIframe = this._enableIframe,
  } = {}) {
    this._setState('unauthenticating', {result: null});

    // Unauthenticate without redirect
    if (!redirect) {
      return this._apiCall('unauthenticate', {
          token: this._token,
          native: this._nativeController,
      }).catch((error) => {
        this._log('Unauthenticate failed', error);
        // Continue regardless; this should be a non fatal error
      }).then(
        () => this._updateSession()
      );
    }

    const userFlow = this._determineUserFlow({enableIframe, enablePopUp});

    // Redirect user to logout screen
    this._redirectController.trigger({
      logout: true,
      origin,
      redirectUrl,
      token,
      enableIframe: userFlow === 'iframe',
      enablePopUp: userFlow === 'popup',
    });
  }

  /**
   * Current status of the IDM system
   *
   * Example usage:
   * Identity.getHealth('dev', 2000).then(status => {
   *   console.log(status);
   * });
   *
   * @param {String} env - Environment to use, e.g: dev/stage/acc/production
   * @param {number} timeout - Timeout limit on call response
   *
   * @return {Promise<Status, Error>} - Promise of IDM status boolean
   */
  getHealth(env = null, timeout = 10000) {
    const environment = env||this._env;
    let server = this._server;

    return new Promise((resolve) => {
      if (!server) {
        if (!environment) {
          return resolve('env parameter missing');
        }

        server = new IdentityServer({sdkVersion: version, env: environment});
      }

      const endpoint = `https://${environment !== 'production' ? `${environment}-` : ''}id.nbc.com/status/`;

      const limit = setTimeout(() => resolve(false), timeout);

      server.call(endpoint, {
        method: 'GET',
      }).then((response) => {
        clearTimeout(limit);
        const status = ('status' in response) ? response.status : false;
        return resolve(status);
      }).catch(() => {
        clearTimeout(limit);
        return resolve(false);
      });
    });
  }

  /**
   * Initialize the native bridge to check for cross application credentials
   * When credentials exist, redirect to the authenticate app to ask the user to sign in using those saved credentials
   * When credentials don't exist, update state and do nothing
   *
   * @param {Object} [options={}]
   * @param {String} [options.origin]
   * @param {String} [options.redirectUrl=location.href]
   */
  crossAppPrompt({
    origin = null,
    redirectUrl = location.href,
    enablePopUp = this._enablePopUp,
    enableIframe = this._enableIframe,
  } = {}) {
    this._origin = origin;
    this._redirectUrl = redirectUrl;

    if (this._nativeController) {
      // Initialize the cross app bridge
      this._crossAppBridge = new IdentityCrossApp(this._nativeController);

      if (this._crossAppInitialized) {
        // Check the keychain for a user session token
        this._crossAppBridge.command('get', {});
      } else {
        this._crossAppBridge.init().then(() => {
          this._crossAppInitialized = true;
          const {nativeWrapper} = window;

          if (nativeWrapper.os === 'iOS') {
            this._crossAppBridge.on('get', (message) => {
              const {token, value} = message.payload;

              this._crossAppToken = token;

              const getPromise = new Promise((resolve, reject) => {
                if (token && value) {
                  // Validate the token by fetching user info
                  this._apiCall('getUserInfo', {
                    token: this._crossAppToken,
                    userData: null,
                  }).then((userData) => {
                    // Save userData to a cookie to use in authenticate app
                    this._cookieHelper.setCookie('userData', JSON.stringify(userData));
                    return resolve(this._crossAppToken);
                  }).catch(() => {
                    // Remove the invalid token and data from the keychain
                    this._removeKeychainData(this._crossAppToken);
                    return reject('Invalid token.');
                  });
                } else {
                  // The keychain does not contain a payload
                  return reject('The keychain is empty.');
                }
              });

              getPromise.then((token) => {
                // Store the crossApp credentials in a cookie to use on authenticate
                this._cookieHelper.setCookie('crossAppToken', token);
                this._cookieHelper.setCookie('crossApp', JSON.stringify(message.payload));

                const userFlow = this._determineUserFlow({enableIframe, enablePopUp});

                // Redirect user to authenticate
                this._redirectController.trigger({
                  crossApp: true,
                  iframeEnabled: userFlow === 'iframe',
                  enablePopUp: userFlow === 'popup',
                  origin: this._origin,
                  redirectUrl: this._redirectUrl,
                });
              }).catch((message) => this._setState('cross-app-unavailable', {
                message,
              }));
            });

            // Check the keychain for a user session token
            this._crossAppBridge.command('get', {});
          } else {
            this._setState('cross-app-unavailable', {
              message: 'crossAppPrompt call should only be used on iOS devices.',
            });
          }
        }).catch(() => this._setState('cross-app-unavailable', {
          message: 'The native bridge could not be initialized.',
        }));
      }
    }
  }

  /**
   * Clear the user data from localStorage
   */
  clearUser() {
    IdentityRecord.clear();
  }
}

export default Identity;