Home Reference Source

packages/skygear-sso/lib/oauth.js

/* global window:false document:false */
/* eslint camelcase: 0 */
import cookies from 'js-cookie';
import { atob } from 'Base64';
import { NewWindowObserver, WindowMessageObserver } from './observer';
import { errorResponseFromMessage } from './util';

/**
 * Login oauth provider with popup window
 *
 * @injectTo {AuthContainer} as loginOAuthProviderWithPopup
 * @param  {String} provider - name of provider, e.g. google, facebook
 * @param  {Object} options - options for generating auth_url
 * @param  {String} options.callbackURL - target url for the popup window to
 *                                        post the result message
 * @param  {Array.<String>} options.scope - oauth scopes params
 * @param  {Object} options.options - add extra query params to provider auth
 *                                    url
 * @return {Promise} promise
 *
 * @example
 * skygear.auth.loginOAuthProviderWithPopup('google').then(...);
 */
export async function loginOAuthProviderWithPopup(provider, options) {
  const auth = this.container.auth;
  return _oauthFlowWithPopup.bind(this)(
    provider, options, 'login', auth._authResolve.bind(auth)
  );
}

/**
 * Login oauth provider with redirect
 *
 * @injectTo {AuthContainer} as loginOAuthProviderWithRedirect
 * @param  {String} provider - name of provider, e.g. google, facebook
 * @param  {Object} options - options for generating auth_url
 * @param  {String} options.callbackURL - target url for the popup window to
 *                                        post the result message
 * @param  {Array.<String>} options.scope - oauth scopes params
 * @param  {Object} options.options - add extra query params to provider auth
 *                                    url
 * @return {Promise} promise
 *
 * @example
 * skygear.auth.loginOAuthProviderWithRedirect('google').then(...);
 */
export function loginOAuthProviderWithRedirect(provider, options) {
  return _oauthFlowWithRedirect.bind(this)(provider, options, 'login');
}

/**
 * Link oauth provider with popup window
 *
 * @injectTo {AuthContainer} as linkOAuthProviderWithPopup
 * @param  {String} provider - name of provider, e.g. google, facebook
 * @param  {Object} options - options for generating auth_url
 * @param  {String} options.callbackURL - target url for the popup window to
 *                                        post the result message
 * @param  {Array.<String>} options.scope - oauth scopes params
 * @param  {Object} options.options - add extra query params to provider auth
 *                                    url
 * @return {Promise} promise
 *
 * @example
 * skygear.auth.linkOAuthProviderWithPopup('google').then(...);
 */
export async function linkOAuthProviderWithPopup(provider, options) {
  return _oauthFlowWithPopup.bind(this)(
    provider, options, 'link', Promise.resolve.bind(Promise)
  );
}

/**
 * Link oauth provider with redirect
 *
 * @injectTo {AuthContainer} as linkOAuthProviderWithRedirect
 * @param  {String} provider - name of provider, e.g. google, facebook
 * @param  {Object} options - options for generating auth_url
 * @param  {String} options.callbackURL - target url for the popup window to
 *                                        post the result message
 * @param  {Array.<String>} options.scope - oauth scopes params
 * @param  {Object} options.options - add extra query params to provider auth
 *                                    url
 * @return {Promise} promise
 *
 * @example
 * skygear.auth.linkOAuthProviderWithRedirect('google').then(...);
 */
export function linkOAuthProviderWithRedirect(provider, options) {
  return _oauthFlowWithRedirect.bind(this)(provider, options, 'link');
}

/**
 * Get redirect login result, return user from redirect based login flow
 * if login success, promise resolve with logged in user.
 * if login fail, promise fail with error.
 * if no redirect flow was called, promise resolve with empty result.
 *
 * @injectTo {AuthContainer} as getLoginRedirectResult
 * @return {Promise} promise
 *
 * @example
 * skygear.auth.getLoginRedirectResult().then(...);
 */
export function getLoginRedirectResult() {
  const auth = this.container.auth;
  return _getRedirectResult.bind(this)('login', auth._authResolve.bind(auth));
}

/**
 * Get redirect link result, return user from redirect based login flow
 * if link success, promise resolve with result { result: 'OK' }.
 * if link fail, promise fail with error.
 * if no redirect flow was called, promise resolve with empty result.
 *
 * @injectTo {AuthContainer} as getLinkRedirectResult
 * @return {Promise} promise
 *
 * @example
 * skygear.auth.getLinkRedirectResult().then(...);
 */
export function getLinkRedirectResult() {
  return _getRedirectResult.bind(this)('link', Promise.resolve.bind(Promise));
}

/**
 * Auth flow handler script
 *
 * @injectTo {AuthContainer} as authHandler
 * @return {Promise} promise
 *
 * @example
 * skygear.auth.oauthHandler().then(...);
 */
export async function oauthHandler() {
  const data = await this.container.lambda('sso/config');
  let authorizedURLs = data.authorized_urls;
  if (window.opener) {
    // popup
    _postSSOResultMessageToWindow(window.opener, authorizedURLs);
  } else {
    throw errorResponseFromMessage('Fail to find opener');
  }
}


/**
 * Iframe handler script. When getLoginRedirectResult is called, sdk will
 * inject an iframe in the document with plugin iframe handler endpoint
 * the endpoint will call this handler. Handler will get the sso result from
 * browser session and post the result back to parnet
 *
 * @injectTo {AuthContainer} as iframeHandler
 * @return {Promise} promise
 *
 * @example
 * skygear.auth.iframeHandler().then(...);
 */
export async function iframeHandler() {
  const data = await this.container.lambda('sso/config');
  let authorizedURLs = data.authorized_urls;
  _postSSOResultMessageToWindow(window.parent, authorizedURLs);
}

/**
 * Login oauth provider with access token
 *
 * @injectTo {AuthContainer} as loginOAuthProviderWithAccessToken
 * @param  {String} provider - name of provider, e.g. google, facebook
 * @param  {String} accessToken - provider app access token
 * @return {Promise} promise
 *
 * @example
 * skygear.auth.loginOAuthProviderWithAccessToken(provider, accessToken).then(...);
 */
export async function loginOAuthProviderWithAccessToken(provider, accessToken) {
  const lambdaName = _getAuthWithAccessTokenURL(provider, 'login');
  const result = await this.container.lambda(lambdaName, {
    access_token: accessToken
  });
  return this.container.auth._authResolve(result);
}

/**
 * Link oauth provider with access token
 *
 * @injectTo {AuthContainer} as linkOAuthProviderWithAccessToken
 * @param  {String} provider - name of provider, e.g. google, facebook
 * @param  {String} accessToken - provider app access token
 * @return {Promise} promise
 *
 * @example
 * skygear.auth.linkOAuthProviderWithAccessToken(provider, accessToken).then(...);
 */
export async function linkOAuthProviderWithAccessToken(provider, accessToken) {
  const lambdaName = _getAuthWithAccessTokenURL(provider, 'link');
  return this.container.lambda(lambdaName, {
    access_token: accessToken
  });
}

/**
 * Unlink oauth provider
 *
 * @injectTo {AuthContainer} as unlinkOAuthProvider
 * @param  {String} provider - name of provider, e.g. google, facebook
 * @return {Promise} promise
 *
 * @example
 * skygear.auth.unlinkOAuthProvider(provider).then(...);
 */
export async function unlinkOAuthProvider(provider) {
  return this.container.lambda(`sso/${provider}/unlink`);
}

/**
 * Get current user's provider profiles, can use for determine user logged in
 * provider
 *
 * @injectTo {AuthContainer} as getOAuthProviderProfiles
 * @return {Promise} promise
 *
 * @example
 * skygear.auth.getOAuthProviderProfiles().then(...);
 */
export async function getOAuthProviderProfiles() {
  return this.container.lambda('sso/provider_profiles');
}

/**
 * @private
 *
 * Start oauth flow with popup window
 *
 * @param  {String} provider - name of provider, e.g. google, facebook
 * @param  {Object} options - options for generating auth_url
 * @param  {String} action - login or link
 * @param  {Function} resolvePromise - function that return promise which will
 *                                     be called when oauth flow resolve
 * @return {Promise} promise
 */
async function _oauthFlowWithPopup(provider, options, action, resolvePromise) {
  var newWindow = window.open('', '_blank', 'height=700,width=500');
  this._oauthWindowObserver = this._oauthWindowObserver ||
    new NewWindowObserver();
  this._oauthResultObserver = this._oauthResultObserver ||
    new WindowMessageObserver(this.container.endPoint);
  const params = _genAuthURLParams('web_popup', options);
  const lambdaName = _getAuthURL(provider, action);
  try {
    const data = await this.container.lambda(lambdaName, params);
    newWindow.location.href = data.auth_url;
    let result = await Promise.race([
      this._oauthWindowObserver.subscribe(newWindow),
      this._oauthResultObserver.subscribe()
    ]);

    result = await _ssoResultMessageResolve(result);
    return resolvePromise(result);
  } finally {
    newWindow.close();
    this._oauthWindowObserver.unsubscribe();
    this._oauthResultObserver.unsubscribe();
  }
}

/**
 * @private
 *
 * Start oauth flow with redirect
 *
 * @param  {String} provider - name of provider, e.g. google, facebook
 * @param  {Object} options - options for generating auth_url
 * @param  {String} action - login or link
 * @return {Promise} promise
 *
 */
async function _oauthFlowWithRedirect(provider, options, action) {
  const params = _genAuthURLParams('web_redirect', options);
  const lambdaName = _getAuthURL(provider, action);
  const data = await this.container.lambda(lambdaName, params);
  const store = this.container.store;
  window.location.href = data.auth_url; //eslint-disable-line
  return store.setItem('skygear-oauth-redirect-action', action);
}


/**
 *
 * @private
 *
 * Get redirect login result, return user from redirect based login flow
 * if login success, promise resolve with logged in user.
 * if login fail, promise fail with error.
 * if no redirect flow was called, promise resolve with empty result.
 *
 * @injectTo {AuthContainer} as getLoginRedirectResult
 * @param  {String}   action - login or link
 * @param  {Function} resolvePromise - function that return promise which will
 *                                     be called when oauth flow resolve
 * @return {Promise} promise
 *
 */
async function _getRedirectResult(action, resolvePromise) {
  this._oauthResultObserver = this._oauthResultObserver ||
    new WindowMessageObserver(this.container.endPoint);

  let oauthIframe;
  try {
    const lastRedirectAction = await this.container.store.getItem(
      'skygear-oauth-redirect-action'
    );

    if (lastRedirectAction !== action) {
      return;
    }
    const subscribeOauthResult = this._oauthResultObserver.subscribe();
    const resetRedirectActionStore = this.container.store
      .removeItem('skygear-oauth-redirect-action');

    // add the iframe and wait for the receive message
    oauthIframe = document.createElement('iframe');
    oauthIframe.style.display = 'none';
    oauthIframe.src = this.container.url + 'sso/iframe_handler';
    document.body.appendChild(oauthIframe);

    let result = await Promise.all([
      subscribeOauthResult,
      resetRedirectActionStore
    ]);
    let oauthResult = result[0];
    result = await _ssoResultMessageResolve(oauthResult);
    return resolvePromise(result);
  } finally {
    if (oauthIframe) {
      this._oauthResultObserver.unsubscribe();
      document.body.removeChild(oauthIframe);
    }
  }
}

function _getAuthURL(provider, action) {
  return {
    login: `sso/${provider}/login_auth_url`,
    link: `sso/${provider}/link_auth_url`
  }[action];
}

function _getAuthWithAccessTokenURL(provider, action) {
  return {
    login: `sso/${provider}/login`,
    link: `sso/${provider}/link`
  }[action];
}

function _genAuthURLParams(uxMode, options) {
  const params = {
    ux_mode: uxMode,
    callback_url: window.location.href
  };

  if (options) {
    if (options.callbackURL) {
      params.callback_url = options.callbackURL;
    }
    if (options.scope) {
      params.scope = options.scope;
    }
    if (options.options) {
      params.options = options.options;
    }
  }
  return params;
}

/**
 * Posting sso result to given window (opener or parent)
 * There are 3 type messages
 * { "type": "result", "result": { ...result object } }
 * { "type": "error", "error": { ...error object } }
 * { "type": "end" }
 * `error` and `end` will send to all target origin *
 * and `result` will send to the given callback URL only
 *
 * when the observer successfully get the message, it will remove the message
 * listener, if user provide an incorrect callback url. The observer will still
 * be able to get the `end` message here
 *
 * @private
 *
 */
function _postSSOResultMessageToWindow(window, authorizedURLs) {
  let resultStr = cookies.get('sso_data');
  cookies.remove('sso_data');
  let data = resultStr && JSON.parse(atob(resultStr));
  let callbackURL = data && data.callback_url;
  let result = data && data.result;
  var error = null;
  if (!result) {
    error = 'Fail to retrieve result';
  } else if (!callbackURL) {
    error = 'Fail to retrieve callbackURL';
  } else if (!_validateCallbackURL(callbackURL, authorizedURLs)) {
    error = `Unauthorized domain: ${callbackURL}`;
  }

  if (error) {
    window.postMessage({
      type: 'error',
      error
    }, '*');
  } else {
    window.postMessage({
      type: 'result',
      result
    }, callbackURL);
  }
  window.postMessage({
    type: 'end'
  }, '*');
}

async function _ssoResultMessageResolve(message) {
  switch (message.type) {
  case 'error':
    throw message.error;
  case 'result':
    const result = message.result;
    // server error
    if (result.error) {
      throw result;
    }
    return result;
  case 'end':
    throw errorResponseFromMessage(`Fail to retrieve result.
Please check the callback_url params in function and
authorized callback urls list in portal.`);
  default:
    throw errorResponseFromMessage('Unknown message type');
  }
}

function _validateCallbackURL(url, authorizedURLs) {
  if (!url) {
    return false;
  }

  // if no authorized urls are set, all domain is allowed
  if (authorizedURLs.length === 0) {
    return true;
  }

  for (let u of authorizedURLs) {
    let regex = new RegExp(`^${u}`, 'i');
    if (url && url.match(regex)) {
      return true;
    }
  }

  return false;
}