Source: server/util/AuthenticationHelper/index.js

/* global WP_DEFINE_IS_NODE */
/**
 * Helper class that handles authentication for incoming express requests.
 */
import { randomBytes } from 'crypto';
import debugLib from 'debug';
import OidcClient from './OidcClient';
import { LOGIN_ENDPOINT, AUTH_TOKEN_EXPIRATION_OFFSET, RESET_AUTH_QUERY } from './constants';
import {
  getAuthenticationState,
  setAuthenticationState,
  clearAuthenticationState,
  persistUserPermissionState,
  getUserPermissionState,
  persistInitialUserState,
  hasResetAuthFlag,
  clearAuthTokens,
} from './stateUtils';
import { removeQueryParam } from './removeQueryParam';

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

class AuthenticationHelper {
  /**
   * @param {Object} config The configuration for the OpenID provider
   * @param {string} config.configuration_endpoint The url to the configuration endpoint of the
   * OpenID provider
   * @param {string} config.client_id The OpenID client ID.
   * @param {string} config.client_secret The OpenID client secret.
   * @param {string} config.callbackRoute The route that the OpenID provider will redirect to
   * once authentication is completed
   * @param {string[]} config.scopes The scopes to use in the authentication request to the
   * OpenID provider
   * @constructor
   */
  constructor(config, oidcDiscovery) {
    this.config = config;

    const required = ['client_id', 'client_secret', 'callbackRoute', 'configuration_endpoint'];
    if (!this.config || !required.every(name => !!this.config[name])) {
      throw new Error(`The required config settings are not present [${required.join(', ')}]`);
    }

    this.client = new OidcClient(config, oidcDiscovery);
  }

  /**
   * Create routes on an express instance to handle authentication
   * @param app The express() app
   */
  createRoutes(app) {
    app.use(this.config.callbackRoute, this.processCallback);
    app.use(LOGIN_ENDPOINT, this.authenticate);
  }

  /* eslint-enable */

  /**
   * Will redirect to the login endpoint with a generated token. This function
   * should be called when authentication is required but not present.
   * @method redirectToLogin
   * @memberOf AuthenticationHelper
   * @param req The express request object
   * @param res The express response object
   */
  redirectToLogin = (req, res) => {
    randomBytes(48, (err, buffer) => {
      const randomToken = buffer.toString('hex');

      const state = {
        state: randomToken,
        returnUrl: removeQueryParam(req.url, RESET_AUTH_QUERY),
      };

      setAuthenticationState(res, state);

      res.redirect(LOGIN_ENDPOINT);
    });
  };

  /**
   * Returns a Promise that resolves with the authentication tokens for the currently authenticated
   * user. If not authenticated, will resolve with 'null'
   * @function getAuthentication
   * @memberOf AuthenticationHelper
   * @param req The express request object
   * @param res The express response object
   */
  getAuthentication = (req, res) => {
    const shouldResetAuth = hasResetAuthFlag(req);
    const userPermissionState = getUserPermissionState(req);
    if (
      !shouldResetAuth &&
      userPermissionState &&
      typeof userPermissionState.accountState === 'number'
    ) {
      return Promise.resolve(userPermissionState);
    }

    // when this flag is present in the url, the server will clear the auth cookie and request
    // a new token. This is useful when returning from a page that updated user info.
    if (shouldResetAuth) {
      clearAuthTokens(res);
    }

    return Promise.resolve();
  };

  /**
   * Will initialize an authentication sequence on an incoming express request.
   *  - If the user is already authenticated, it will redirect the user back to the url it
   *  was initially redirected from (or back to the root if that url was not found).
   *  - If the user was not authenticated yet, it will redirect the user to the OpenID connect
   *  endpoint to retrieve new authentication tokens
   * @method authenticate
   * @memberOf AuthenticationHelper
   * @param req The express request object
   * @param res The express response object
   * @param next The express next function
   */
  authenticate = (req, res, next) => {
    const authState = getAuthenticationState(req);
    this.getAuthentication(req, res)
      .then(authentication => {
        if (authentication) {
          this.returnToUrl(res, authState ? authState.returnUrl : null);
        } else {
          if (!authState) {
            debug('No authentication state set on login endpoint');
            // If authentication state is not set, redirect to home
            return res.redirect('/');
          }

          return this.client.login(req, res, authState.state);
        }
        return null;
      })
      .catch(next);
  };

  /**
   * Handles a request on the route that the OpenID server redirects to after an
   * authentication. Will validate the authentication response that the OpenID server attached
   * as query parameters. If the response is valid, it will persist the new authentication
   * tokens in a cookie and redirect the user back to the url it came from.
   * @method processCallback
   * @memberOf AuthenticationHelper
   * @param req The express request object
   * @param res The express response object
   * @param next The express next function
   */
  processCallback = (req, res, next) => {
    if (req.query.error) {
      next(new Error(`Authentication error: ${req.query.error}`));
    } else if (req.query.code) {
      const authState = getAuthenticationState(req);
      if (!authState || !authState.state) {
        debug('No state cookie present when processing authorization endpoint callback data');
        res.redirect('/');
        return;
      }
      if (req.query.state !== authState.state) {
        throw new Error('State does not match session');
      }

      persistInitialUserState(req, res);

      this.client
        .getTokens(req)
        .then(tokens => {
          if (tokens) {
            const expireCookieIn = tokens.expires_in - AUTH_TOKEN_EXPIRATION_OFFSET;
            /* eslint-disable */
            persistUserPermissionState(
              req,
              res,
              {
                access_token: tokens.access_token,
              },
              expireCookieIn,
            );
            /* eslint-enable */
            clearAuthenticationState(res);

            this.returnToUrl(res, authState.returnUrl);
          } else {
            next();
          }
        })
        .catch(next);
    } else {
      next();
    }
  };

  /**
   * Redirects an express request back to an endpoint. Will do some checks to prevent ending
   * up in redirect loops.
   * @param res The express response object
   * @param returnUrl The url to return to
   */
  returnToUrl(res, returnUrl) {
    // prevent redirect loops
    if (returnUrl && returnUrl !== LOGIN_ENDPOINT && returnUrl !== this.config.callbackRoute) {
      res.redirect(returnUrl);
      return;
    }

    res.redirect('/');
  }
}

export default AuthenticationHelper;