Home Reference Source

src/salte-auth.utilities.js

import assign from 'lodash/assign';
import debug from 'debug';

/** @ignore */
const logger = debug('@salte-auth/salte-auth:utilities');

/**
 * Basic utilities to support the authentication flow
 */
class SalteAuthUtilities {
  /**
   * Wraps all XHR and Fetch (if available) requests to allow promise interceptors
   * @param {Config} config configuration for salte auth
   */
  constructor(config) {
    /** @ignore */
    this.$$config = config;

    /** @ignore */
    this.$interceptors = {
      fetch: [],
      xhr: []
    };

    logger('Setting up wrappers for XMLHttpRequest...');
    (function(open) {
      XMLHttpRequest.prototype.open = function(method, url) {
        /** @ignore */
        this.$url = url;
        return open.call(this, method, url);
      };
    })(XMLHttpRequest.prototype.open);

    const self = this;
    (function(send) {
      XMLHttpRequest.prototype.send = function(data) {
        const promises = [];
        for (let i = 0; i < self.$interceptors.xhr.length; i++) {
          const interceptor = self.$interceptors.xhr[i];
          promises.push(interceptor(this, data));
        }
        Promise.all(promises).then(() => {
          send.call(this, data);
        }).catch((error) => {
          const event = document.createEvent('Event');
          event.initEvent('error', false, true);
          event.detail = error;
          this.dispatchEvent(event);
        });
      };
    })(XMLHttpRequest.prototype.send);

    if (window.fetch) {
      logger('Fetch detected, setting up wrappers...');
      (function(fetch) {
        window.fetch = function(input, options) {
          const request = input instanceof Request ? input : new Request(input, options);

          const promises = [];
          for (let i = 0; i < self.$interceptors.fetch.length; i++) {
            const interceptor = self.$interceptors.fetch[i];
            promises.push(interceptor(request));
          }
          return Promise.all(promises).then(() => {
            return fetch.call(this, request);
          });
        };
      })(fetch);
    }
  }

  /**
   * Creates a URL using a base url and a queryParams object
   * @param {String} baseUrl the base url to attach the queryParams to
   * @param {Object} queryParams the queryParams to attach to the baseUrl
   * @return {String} the url with the request queryParams
   */
  createUrl(baseUrl, queryParams = {}) {
    let url = baseUrl;

    Object.keys(queryParams).forEach((key) => {
      const value = queryParams[key];
      if ([undefined, null, ''].indexOf(value) === -1) {
        url += `${url.indexOf('?') === -1 ? '?' : '&'}${key}=${encodeURIComponent(value)}`;
      }
    });

    return url;
  }

  /**
   * Converts a url to an absolute url
   * @param {String} path the url path to resolve to an absolute url
   * @return {String} the absolutely resolved url
   */
  resolveUrl(path) {
    if (!this.$$urlDocument) {
      /** @ignore */
      this.$$urlDocument = document.implementation.createHTMLDocument('url');
      /** @ignore */
      this.$$urlBase = this.$$urlDocument.createElement('base');
      /** @ignore */
      this.$$urlAnchor = this.$$urlDocument.createElement('a');
      this.$$urlDocument.head.appendChild(this.$$urlBase);
    }
    this.$$urlBase.href = window.location.protocol + '//' + window.location.host;
    this.$$urlAnchor.href = path.replace(/ /g, '%20');
    return this.$$urlAnchor.href.replace(/\/$/, '');
  }

  /**
   * Checks if the given url matches any of the test urls
   * @param {String} url The url to test
   * @param {Array<String|RegExp>} tests The urls to match the test url against
   * @return {Boolean} true if the url matches one of the tests
   */
  checkForMatchingUrl(url, tests = []) {
    const resolvedUrl = this.resolveUrl(url);
    for (let i = 0; i < tests.length; i++) {
      const test = tests[i];
      if (test instanceof RegExp) {
        if (resolvedUrl.match(test)) return true;
      } else {
        if (resolvedUrl.indexOf(this.resolveUrl(test)) === 0) return true;
      }
    }

    return false;
  }

  /**
   * Determines if the given route is a secured route
   * @param {String} route the route to verify
   * @param {Boolean|Array<String>} securedRoutes a list of routes that require authentication
   * @return {Boolean} true if the route provided is a secured route
   */
  isRouteSecure(route, securedRoutes) {
    if (securedRoutes === true) {
      return true;
    } else if (securedRoutes instanceof Array) {
      return this.checkForMatchingUrl(route, securedRoutes);
    }
    return false;
  }

  /**
   * Opens a popup window in the middle of the viewport
   * @param {String} url the url to be loaded
   * @param {String} name the name of the window
   * @param {Number} height the height of the window
   * @param {Number} width the width of the window
   * @return {Promise} resolves when the popup is closed
   */
  openPopup(url, name = 'salte-auth', height = 600, width = 400) {
    const top = ((window.innerHeight / 2) - (height / 2)) + window.screenTop;
    const left = ((window.innerWidth / 2) - (width / 2)) + window.screenLeft;
    const popupWindow = window.open(url, name, `height=${height}, width=${width}, status=yes, toolbar=no, menubar=no, location=no, top=${top}, left=${left}`);
    if (!popupWindow) {
      return Promise.reject(new ReferenceError('We were unable to open the popup window, its likely that the request was blocked.'));
    }

    popupWindow.focus();
    // TODO: Find a better way of tracking when a Window closes.
    return new Promise((resolve) => {
      const checker = setInterval(() => {
        try {
          if (!popupWindow.closed) {
            // This could throw cross-domain errors, so we need to silence them.
            const loginUrl = this.$$config.redirectUrl && this.$$config.redirectUrl.loginUrl || this.$$config.redirectUrl;
            const logoutUrl = this.$$config.redirectUrl && this.$$config.redirectUrl.logoutUrl || this.$$config.redirectUrl;
            if (popupWindow.location.href.indexOf(loginUrl) !== 0 || popupWindow.location.href.indexOf(logoutUrl) !== 0) return;

            location.hash = popupWindow.location.hash;
            popupWindow.close();
          }
          clearInterval(checker);
          setTimeout(resolve);
        } catch (e) {}
      }, 100);
    });
  }

  /**
   * Opens a new tab
   * @param {String} url the url to be loaded
   * @return {Promise} resolves when the tab is closed
   */
  openNewTab(url) {
    const tabWindow = window.open(url, '_blank');
    if (!tabWindow) {
      return Promise.reject(new ReferenceError('We were unable to open the new tab, its likely that the request was blocked.'));
    }

    tabWindow.name = 'salte-auth';
    tabWindow.focus();
    // TODO: Find a better way of tracking when a Window closes.
    return new Promise((resolve) => {
      const checker = setInterval(() => {
        try {
          if (!tabWindow.closed) {
            // This could throw cross-domain errors, so we need to silence them.
            const loginUrl = this.$$config.redirectUrl && this.$$config.redirectUrl.loginUrl || this.$$config.redirectUrl;
            const logoutUrl = this.$$config.redirectUrl && this.$$config.redirectUrl.logoutUrl || this.$$config.redirectUrl;
            if (tabWindow.location.href.indexOf(loginUrl) !== 0 || tabWindow.location.href.indexOf(logoutUrl) !== 0) return;

            location.hash = tabWindow.location.hash;
            tabWindow.close();
          }
          clearInterval(checker);
          setTimeout(resolve);
        } catch (e) {}
      }, 100);
    });
  }

  /**
   * Opens an iframe in the background
   * @param {String} url the url to be loaded
   * @param {Boolean} show whether the iframe should be visible
   * @param {Number} timeout duration to wait before rejecting the request
   * @return {Promise} resolves when the iframe is closed
   */
  createIframe(url, show, timeout) {
    const iframe = document.createElement('iframe');
    iframe.setAttribute('owner', 'salte-auth');
    if (show) {
      assign(iframe.style, {
        position: 'fixed',
        top: 0,
        bottom: 0,
        left: 0,
        right: 0,
        height: '100%',
        width: '100%',
        zIndex: 9999,
        border: 'none',

        opacity: 0,
        transition: '0.5s opacity'
      });

      setTimeout(() => {
        iframe.style.opacity = 1;
      });
    } else {
      iframe.style.display = 'none';
    }
    iframe.src = url;
    document.body.appendChild(iframe);
    return new Promise((resolve, reject) => {
      const autoReject = timeout && setTimeout(() => {
        reject(new Error('Iframe failed to respond in time.'));
      }, timeout);
      iframe.addEventListener('DOMNodeRemoved', () => {
        setTimeout(resolve);
        clearTimeout(autoReject);
      }, { passive: true });
    });
  }

  /**
   * Adds a XMLHttpRequest interceptor
   * @param {Function} interceptor the interceptor function
   */
  addXHRInterceptor(interceptor) {
    this.$interceptors.xhr.push(interceptor);
  }

  /**
   * Adds a fetch interceptor
   * @param {Function} interceptor the interceptor function
   */
  addFetchInterceptor(interceptor) {
    this.$interceptors.fetch.push(interceptor);
  }

  /**
   * Checks if the current window is an iframe
   * @return {HTMLIFrameElement} true if the current window is an iframe.
   * @private
   */
  get $iframe() {
    if (window.self === window.top) {
      return null;
    }
    return parent.document.querySelector('body > iframe[owner="salte-auth"]');
  }

  /**
   * Determines if the current window is a popup window opened by salte auth
   * @return {Window} the window object
   * @private
   */
  get $popup() {
    if (window.opener && window.name === 'salte-auth') {
      return window;
    }
    return null;
  }

  /**
   * Determines if the page is currently hidden
   * @return {Boolean} true if the page is hidden
   * @private
   */
  get $hidden() {
    return document.hidden;
  }

  /**
   * Navigates to the url provided.
   * @param {String} url the url to navigate to
   * @private
   */
  /* istanbul ignore next */
  $navigate(url) {
    location.href = url;
  }
}

export { SalteAuthUtilities };
export default SalteAuthUtilities;