Source: client/util/auth/ClientAuthenticationManager.js

/* global WP_DEFINE_DEVELOPMENT */
import debugLib from 'debug';
import EventEmitter from 'eventemitter3';
import loadOidcClient from 'bundle-loader?lazy!oidc-client';
import mockedWindow from 'storage-mock';
import serviceConfig from '../../../app/config/service.configdefinitions';
import * as AuthConstants from '../../../app/data/AuthConstants';

const debug = debugLib('SlimmingWorld:ClientAuthenticationManager');

/**
 * Wrapper class for the oidc-client.js library that handles client-side implicit authentication.
 */
class ClientAuthenticationManager extends EventEmitter {
  /**
   * When a login is executed or has completed executing, this will be set to a promise
   * that resolves when the login is complete. If there is no login and we're not currently
   * in the process of logging in, this will be set to null.
   * @member executingSignInPromise
   * @type {Promise}
   * @private
   */
  executingSignInPromise = null;

  /**
   * If true, will initiate a redirect callback
   * @member enableRedirectOnSilentFail
   * @type {boolean}
   * @private
   */
  enableRedirectOnSilentFail = false;

  /**
   * Constructor for authentication manager
   * @constructor
   * @param oidcConfig The oidc section of configuration object as parsed by the 'config' module
   * from the configuration jsons
   *
   * @fires authtoken
   */
  constructor(oidcConfig) {
    super();
    window.ClientAuthenticationManager = this;

    this.firstRenderComplete = new Promise(resolve => {
      this.setFirstRenderComplete = resolve;
    });

    this.initialized = this.setupUserManager(oidcConfig).then(() => {
      this.userManager.clearStaleState();
      this.userManager.events.addSilentRenewError(() => {
        debug('Error during silent renew. Redirecting to login page...');
        this.executeRedirectSignin();
      });

      this.userManager.events.addAccessTokenExpired(() => {
        this.executingSignInPromise = null;
      });

      this.onSigninSuccess = (user, log) => {
        this.enableRedirectOnSilentFail = false;

        debug(log);
        // add callback for userLoaded. This is for when the user session is renewed
        this.userManager.events.addUserLoaded(this.handleUserLoaded);
        // for signin, we call setAuthToken ourselves because we need to return the promise
        // this.setAuthTokensCallback(user.id_token, user.access_token);
        this.emit('authtoken', { idToken: user.id_token, accessToken: user.access_token });
        return user;
      };
    });
  }

  handleUserLoaded = user => {
    this.executingSignInPromise = Promise.resolve(user);
    // this.setAuthTokensCallback(user.id_token, user.access_token);
    this.emit('authtoken', { idToken: user.id_token, accessToken: user.access_token });
  };

  /**
   * Initializes the UserManager from the oidc-client module.
   * @param oidcConfig The oidc section of configuration object as parsed by the 'config' module
   * from the configuration jsons
   * @return {Promise} A Promise that resolves when setup has completed.
   */
  setupUserManager(oidcConfig) {
    /* eslint-disable */
    return new Promise(resolve => {
      loadOidcClient(({ Log, UserManager, WebStorageStateStore }) => {
        Log.logger = console;
        Log.level = WP_DEFINE_DEVELOPMENT ? Log.WARN : Log.ERROR;

        const webroot = `${window.location.protocol}//${window.location.host}`;
        const oidcSettings = {
          authority: `${oidcConfig.authority}/.well-known/openid-configuration?${document.location.hostname}`,
          client_id: oidcConfig.implicit[serviceConfig.webHost].client_id,
          redirect_uri: `${webroot}${AuthConstants.IMPLICIT_ROUTE_CALLBACK}`,
          response_type: 'id_token token',
          post_logout_redirect_uri: webroot,
          scope: ['openid'].concat(AuthConstants.OID_CLIENT_SCOPES).join(' '),
          silent_redirect_uri: `${webroot}${AuthConstants.IMPLICIT_ROUTE_SILENT_RENEW}`,
          automaticSilentRenew: true,
          loadUserInfo: false,
          ...(serviceConfig.persistClientAuthentication
            ? {}
            : { userStore: new WebStorageStateStore({ store: mockedWindow.sessionStorage }) }),
          metadata: {
            issuer: oidcConfig.authority,
            userinfo_endpoint: `${oidcConfig.authority}/connect/userinfo`,
            jwks_uri: `${oidcConfig.authority}/.well-known/openid-configuration/jwks?${document.location.hostname}`,
            end_session_endpoint: `${oidcConfig.authority}/connect/endsession`,
            authorization_endpoint: `${oidcConfig.authority}/connect/authorize`,
          },
        };

        this.userManager = new UserManager(oidcSettings);
        resolve();
      });
    });
    /* eslint-enable */
  }

  /**
   * Logout the user from Node.JS, the browser and the single sign-on account server
   *
   * The argument is used in the oidc-client->signoutRedirect
   */
  logout(postLogoutRedirectUri) {
    this.initialized.then(() => {
      invalidateServerAuthToken();
      this.userManager.signoutRedirect({
        post_logout_redirect_uri: postLogoutRedirectUri,
      });
    });
  }

  /**
   * Attempts to login the user using a silent sign-in. This method should only be called
   * if another login is not already in process.
   * @return {Promise} A Promise that resolves when signin has completed.
   * https://raw.githubusercontent.com/IdentityModel/oidc-client-js/dev/lib/oidc-client.js
   */
  executeSilentSignin() {
    if (this.executingSignInPromise !== null) {
      debug('Executing silent sign-in while sign-in is already in progress');
    }

    this.executingSignInPromise = this.initialized
      .then(() => this.userManager.getUser())
      // This will fix the difference between the client and server at the first render
      .then(() => this.firstRenderComplete)
      .then(userInSession => {
        if (userInSession && !userInSession.expired) {
          return this.onSigninSuccess(userInSession, 'user already authenticated');
        }

        // Temporarily remove the event listener for when the user had loaded
        this.userManager.events.removeUserLoaded(this.handleUserLoaded);

        return this.userManager
          .signinSilent()
          .then(user => this.onSigninSuccess(user, 'silent signin completed'))
          .catch(() => {
            if (this.enableRedirectOnSilentFail) {
              debug('Error during silent signin. Redirecting to login page...');
              return this.executeRedirectSignin();
            }

            // Silent signin failed but not required. Set promise to null so we trigger
            // silent signin again on the next route change
            this.executingSignInPromise = null;
            return null;
          });
      });

    return this.executingSignInPromise;
  }

  /**
   * Callback that should be called whenever the route changes. Should also be called
   * on the first page render. Parses the login information from the url fragment, if
   * present. Otherwise, triggers a silent sign-in if that is not done yet.
   * @param requiresAuth A boolean that indicates if authentication is required for this
   * page.
   */
  onRouteChange = (requiresAuth = true) => {
    if (requiresAuth) {
      this.enableRedirectOnSilentFail = true;
    }
    if (window.location.hash && window.location.hash.indexOf('access_token') >= 0) {
      this.executingSignInPromise = this.initialized.then(() => {
        this.userManager.events.removeUserLoaded(this.handleUserLoaded);
        return this.userManager
          .signinRedirectCallback()
          .then(user => this.onSigninSuccess(user, 'signin redirect callback received'))
          .catch(e => {
            debug(`Error processing redirect callback: ${e}`);

            this.executingSignInPromise = null;
            this.executeSilentSignin();
          });
      });
    } else if (this.executingSignInPromise === null) {
      const route = window.location.pathname.split('/');
      const blacklist = ['login', 'logout', 'logged-out'];
      const isBlackList = blacklist.some(key => route.includes(key));

      if (!isBlackList) {
        this.executeSilentSignin();
      }
    }
  };

  /**
   * Executes a sign-in using a redirect rather than silently via an iframe. This will be
   * called when sign-in is required but the silent method failed.
   * @returns {Promise} A Promise that resolves when the sign-in has completed
   */
  executeRedirectSignin = () => {
    /* eslint-disable dot-notation */
    if (window['sessionStorage']) {
      try {
        window.sessionStorage[
          AuthConstants.LOGIN_RETURN_SESSION_PROP
        ] = `${window.location.pathname}${window.location.search}`;
      } catch (e) {
        debug(`skipping executeRedirectSignin error: ${e}`);
      }
    }
    /* eslint-enable */
    return this.initialized.then(() => this.userManager.signinRedirect());
  };

  /**
   * Executes a silent sign-in if it is not already running. Returns a Promise that resolves
   * with the logged in user.
   * @returns {Promise}
   */
  getUser = async () => {
    await this.executingSignInPromise;
    if (this.executingSignInPromise === null) {
      this.executeSilentSignin();
    }
    return this.executingSignInPromise.then(user => {
      if (user) {
        if (Date.now() > user.expires_at * 1000) {
          debug('Token expired. Executing silent signin...');
          this.executingSignInPromise = null;
          this.executeSilentSignin();
          return this.executingSignInPromise;
        }
      }

      return user;
    });
  };

  /**
   * Clears any current authentication and executes a new silent sign-in.
   */
  refreshLogin = () => {
    const signInExecuting = this.executingSignInPromise || this.initialized.then(() => null);
    return signInExecuting
      .then(user => {
        invalidateServerAuthToken();
        if (user) {
          return this.userManager.removeUser();
        }
        return null;
      })
      .then(() => {
        this.executingSignInPromise = null;
        this.enableRedirectOnSilentFail = true;
        return this.executeSilentSignin();
      });
  };

  removeUserState = () => this.userManager.removeUser();
}

export default ClientAuthenticationManager;

/**
 * Logs the user out from Node.JS by removing the cookie that contains the authentication
 * token. This will not logout the user from the account server.
 * @private
 */
function invalidateServerAuthToken() {
  [
    AuthConstants.STATE_COOKIE,
    AuthConstants.INITIAL_USER_STATE_COOKIE,
    AuthConstants.TOKEN_COOKIE,
  ].forEach(cookieName => {
    document.cookie = `${cookieName}=;expires=Thu, 01 Jan 1970 00:00:01 GMT;path=/`;
  });
}

/**
 * Will be emitted whenever new auth tokens are set.
 *
 * @event authtoken
 * @type {object}
 * @property {object} idToken The new id token
 * @property {object} accessToken The new access token
 */