b2g/components/SignInToWebsite.jsm
author Mark Finkle <mfinkle@mozilla.com>
Thu, 12 Dec 2013 23:09:16 -0500
changeset 175531 8cc47d24d9f9a947db0fcc2250056b1161ef3663
parent 174350 874788e2239c90171780b64c577380d3a63996ed
child 177935 ff4cb698555c74559d87be7f5e01071aaf5423a1
permissions -rw-r--r--
Bug 949639 - Move CanAddURI to nsAndroidHistory. r=blassey, a=lsblakk

/* This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this file,
 * You can obtain one at http://mozilla.org/MPL/2.0/. */

/*
 * SignInToWebsite.jsm - UX Controller and means for accessing identity
 * cookies on behalf of relying parties.
 *
 * Currently, the b2g security architecture isolates web applications
 * so that each window has access only to a local cookie jar:
 *
 *     To prevent Web apps from interfering with one another, each one is
 *     hosted on a separate domain, and therefore may only access the
 *     resources associated with its domain. These resources include
 *     things such as IndexedDB databases, cookies, offline storage,
 *     and so forth.
 *
 *     -- https://developer.mozilla.org/en-US/docs/Mozilla/Firefox_OS/Security/Security_model
 *
 * As a result, an authentication system like Persona cannot share its
 * cookie jar with multiple relying parties, and so would require a
 * fresh login request in every window.  This would not be a good
 * experience.
 *
 *
 * In order for navigator.id.request() to maintain state in a single
 * cookie jar, we cause all Persona interactions to take place in a
 * content context that is launched by the system application, with the
 * result that Persona has a single cookie jar that all Relying
 * Parties can use.  Since of course those Relying Parties cannot
 * reach into the system cookie jar, the Controller in this module
 * provides a way to get messages and data to and fro between the
 * Relying Party in its window context, and the Persona internal api
 * in its context.
 *
 * On the Relying Party's side, say a web page invokes
 * navigator.id.watch(), to register callbacks, and then
 * navigator.id.request() to request an assertion.  The navigator.id
 * calls are provided by nsDOMIdentity.  nsDOMIdentity messages down
 * to the privileged DOMIdentity code (using cpmm and ppmm message
 * managers).  DOMIdentity stores the state of Relying Party flows
 * using an Identity service (MinimalIdentity.jsm), and emits messages
 * requesting Persona functions (doWatch, doReady, doLogout).
 *
 * The Identity service sends these observer messages to the
 * Controller in this module, which in turn triggers content to open a
 * window to host the Persona js.  If user interaction is required,
 * content will open the trusty UI.  If user interaction is not required,
 * and we only need to get to Persona functions, content will open a
 * hidden iframe.  In either case, a window is opened into which the
 * controller causes the script identity.js to be injected.  This
 * script provides the glue between the in-page javascript and the
 * pipe back down to the Controller, translating navigator.internal
 * function callbacks into messages sent back to the Controller.
 *
 * As a result, a navigator.internal function in the hosted popup or
 * iframe can call back to the injected identity.js (doReady, doLogin,
 * or doLogout).  identity.js callbacks send messages back through the
 * pipe to the Controller.  The controller invokes the corresponding
 * function on the Identity Service (doReady, doLogin, or doLogout).
 * The IdentityService calls the corresponding callback for the
 * correct Relying Party, which causes DOMIdentity to send a message
 * up to the Relying Party through nsDOMIdentity
 * (Identity:RP:Watch:OnLogin etc.), and finally, nsDOMIdentity
 * receives these messages and calls the original callback that the
 * Relying Party registered (navigator.id.watch(),
 * navigator.id.request(), or navigator.id.logout()).
 */

"use strict";

this.EXPORTED_SYMBOLS = ["SignInToWebsiteController"];

const Ci = Components.interfaces;
const Cu = Components.utils;

Cu.import("resource://gre/modules/Services.jsm");
Cu.import("resource://gre/modules/XPCOMUtils.jsm");

XPCOMUtils.defineLazyModuleGetter(this, "getRandomId",
                                  "resource://gre/modules/identity/IdentityUtils.jsm");

XPCOMUtils.defineLazyModuleGetter(this, "IdentityService",
                                  "resource://gre/modules/identity/MinimalIdentity.jsm");

XPCOMUtils.defineLazyModuleGetter(this, "Logger",
                                  "resource://gre/modules/identity/LogUtils.jsm");

// The default persona uri; can be overwritten with toolkit.identity.uri pref.
// Do this if you want to repoint to a different service for testing.
// There's no point in setting up an observer to monitor the pref, as b2g prefs
// can only be overwritten when the profie is recreated.  So just get the value
// on start-up.
let kPersonaUri = "https://firefoxos.persona.org";
try {
  kPersonaUri = Services.prefs.getCharPref("toolkit.identity.uri");
} catch(noSuchPref) {
  // stick with the default value
}

// JS shim that contains the callback functions that
// live within the identity UI provisioning frame.
const kIdentityShimFile = "chrome://browser/content/identity.js";

// Type of MozChromeEvents to handle id dialogs.
const kOpenIdentityDialog = "id-dialog-open";
const kDoneIdentityDialog = "id-dialog-done";
const kCloseIdentityDialog = "id-dialog-close-iframe";

// Observer messages to communicate to shim
const kIdentityDelegateWatch = "identity-delegate-watch";
const kIdentityDelegateRequest = "identity-delegate-request";
const kIdentityDelegateLogout = "identity-delegate-logout";
const kIdentityDelegateFinished = "identity-delegate-finished";
const kIdentityDelegateReady = "identity-delegate-ready";

const kIdentityControllerDoMethod = "identity-controller-doMethod";

function log(...aMessageArgs) {
  Logger.log.apply(Logger, ["SignInToWebsiteController"].concat(aMessageArgs));
}

log("persona uri =", kPersonaUri);

/*
 * ContentInterface encapsulates the our content functions.  There are only two:
 *
 * getContent       - return the current content window
 * sendChromeEvent  - send a chromeEvent from the browser shell
 */
let ContentInterface = {
  _getBrowser: function SignInToWebsiteController__getBrowser() {
    return Services.wm.getMostRecentWindow("navigator:browser");
  },

  getContent: function SignInToWebsiteController_getContent() {
    return this._getBrowser().getContentWindow();
  },

  sendChromeEvent: function SignInToWebsiteController_sendChromeEvent(detail) {
    detail.uri = kPersonaUri;
    this._getBrowser().shell.sendChromeEvent(detail);
  }
};

function Pipe() {
  this._watchers = [];
}

Pipe.prototype = {
  init: function pipe_init() {
    Services.obs.addObserver(this, "identity-child-process-shutdown", false);
    Services.obs.addObserver(this, "identity-controller-unwatch", false);
  },

  uninit: function pipe_uninit() {
    Services.obs.removeObserver(this, "identity-child-process-shutdown");
    Services.obs.removeObserver(this, "identity-controller-unwatch");
  },

  observe: function Pipe_observe(aSubject, aTopic, aData) {
    let options = {};
    if (aSubject) {
      options = aSubject.wrappedJSObject;
    }
    switch (aTopic) {
      case "identity-child-process-shutdown":
        log("pipe removing watchers by message manager");
        this._removeWatchers(null, options.messageManager);
        break;

      case "identity-controller-unwatch":
        log("unwatching", options.id);
        this._removeWatchers(options.id, options.messageManager);
        break;
    }
  },

  _addWatcher: function Pipe__addWatcher(aId, aMm) {
    log("Adding watcher with id", aId);
    for (let i = 0; i < this._watchers.length; ++i) {
      let watcher = this._watchers[i];
      if (this._watcher.id === aId) {
        watcher.count++;
        return;
      }
    }
    this._watchers.push({id: aId, count: 1, mm: aMm});
  },

  _removeWatchers: function Pipe__removeWatcher(aId, aMm) {
    let checkId = aId !== null;
    let index = -1;
    for (let i = 0; i < this._watchers.length; ++i) {
      let watcher = this._watchers[i];
      if (watcher.mm === aMm &&
          (!checkId || (checkId && watcher.id === aId))) {
        index = i;
        break;
      }
    }

    if (index !== -1) {
      if (checkId) {
        if (--(this._watchers[index].count) === 0) {
          this._watchers.splice(index, 1);
        }
      } else {
        this._watchers.splice(index, 1);
      }
    }

    if (this._watchers.length === 0) {
      log("No more watchers; clean up persona host iframe");
      let detail = {
        type: kCloseIdentityDialog
      };
      log('telling content to close the dialog');
      // tell content to close the dialog
      ContentInterface.sendChromeEvent(detail);
    }
  },

  communicate: function(aRpOptions, aContentOptions, aMessageCallback) {
    let rpID = aRpOptions.id;
    let rpMM = aRpOptions.mm;
    if (rpMM) {
      this._addWatcher(rpID, rpMM);
    }

    log("RP options:", aRpOptions, "\n  content options:", aContentOptions);

    // This content variable is injected into the scope of
    // kIdentityShimFile, where it is used to access the BrowserID object
    // and its internal API.
    let content = ContentInterface.getContent();
    let mm = null;
    let uuid = getRandomId();
    let self = this;

    if (!content) {
      log("ERROR: what the what? no content window?");
      // aErrorCb.onresult("NO_CONTENT_WINDOW");
      return;
    }

    function removeMessageListeners() {
      if (mm) {
        mm.removeMessageListener(kIdentityDelegateFinished, identityDelegateFinished);
        mm.removeMessageListener(kIdentityControllerDoMethod, aMessageCallback);
      }
    }

    function identityDelegateFinished() {
      removeMessageListeners();

      let detail = {
        type: kDoneIdentityDialog,
        showUI: aContentOptions.showUI || false,
        id: kDoneIdentityDialog + "-" + uuid,
        requestId: aRpOptions.id
      };
      log('received delegate finished; telling content to close the dialog');
      ContentInterface.sendChromeEvent(detail);
      self._removeWatchers(rpID, rpMM);
    }

    content.addEventListener("mozContentEvent", function getAssertion(evt) {
      let msg = evt.detail;
      if (!msg.id.match(uuid)) {
        return;
      }

      switch (msg.id) {
        case kOpenIdentityDialog + '-' + uuid:
          if (msg.type === 'cancel') {
            // The user closed the dialog.  Clean up and call cancel.
            content.removeEventListener("mozContentEvent", getAssertion);
            removeMessageListeners();
            aMessageCallback({json: {method: "cancel"}});
          } else {
            // The window has opened.  Inject the identity shim file containing
            // the callbacks in the content script.  This could be either the
            // visible popup that the user interacts with, or it could be an
            // invisible frame.
            let frame = evt.detail.frame;
            let frameLoader = frame.QueryInterface(Ci.nsIFrameLoaderOwner).frameLoader;
            mm = frameLoader.messageManager;
            try {
              mm.loadFrameScript(kIdentityShimFile, true);
              log("Loaded shim", kIdentityShimFile);
            } catch (e) {
              log("Error loading", kIdentityShimFile, "as a frame script:", e);
            }

            // There are two messages that the delegate can send back: a "do
            // method" event, and a "finished" event.  We pass the do-method
            // events straight to the caller for interpretation and handling.
            // If we receive a "finished" event, then the delegate is done, so
            // we shut down the pipe and clean up.
            mm.addMessageListener(kIdentityControllerDoMethod, aMessageCallback);
            mm.addMessageListener(kIdentityDelegateFinished, identityDelegateFinished);

            mm.sendAsyncMessage(aContentOptions.message, aRpOptions);
          }
          break;

        case kDoneIdentityDialog + '-' + uuid:
          // Received our assertion.  The message manager callbacks will handle
          // communicating back to the IDService.  All we have to do is remove
          // this listener.
          content.removeEventListener("mozContentEvent", getAssertion);
          break;

        default:
          log("ERROR - Unexpected message: id=" + msg.id + ", type=" + msg.type + ", errorMsg=" + msg.errorMsg);
          break;
      }

    });

    // Tell content to open the identity iframe or trusty popup. The parameter
    // showUI signals whether user interaction is needed.  If it is, content will
    // open a dialog; if not, a hidden iframe.  In each case, BrowserID is
    // available in the context.
    let detail = {
      type: kOpenIdentityDialog,
      showUI: aContentOptions.showUI || false,
      id: kOpenIdentityDialog + "-" + uuid,
      requestId: aRpOptions.id
    };

    ContentInterface.sendChromeEvent(detail);
  }

};

/*
 * The controller sits between the IdentityService used by DOMIdentity
 * and a content process launches an (invisible) iframe or (visible)
 * trusty UI.  Using an injected js script (identity.js), the
 * controller enables the content window to access the persona identity
 * storage in the system cookie jar and send events back via the
 * controller into IdentityService and DOM, and ultimately up to the
 * Relying Party, which is open in a different window context.
 */
this.SignInToWebsiteController = {

  /*
   * Initialize the controller.  To use a different content communication pipe,
   * such as when mocking it in tests, pass aOptions.pipe.
   */
  init: function SignInToWebsiteController_init(aOptions) {
    aOptions = aOptions || {};
    this.pipe = aOptions.pipe || new Pipe();
    Services.obs.addObserver(this, "identity-controller-watch", false);
    Services.obs.addObserver(this, "identity-controller-request", false);
    Services.obs.addObserver(this, "identity-controller-logout", false);
  },

  uninit: function SignInToWebsiteController_uninit() {
    Services.obs.removeObserver(this, "identity-controller-watch");
    Services.obs.removeObserver(this, "identity-controller-request");
    Services.obs.removeObserver(this, "identity-controller-logout");
  },

  observe: function SignInToWebsiteController_observe(aSubject, aTopic, aData) {
    log("observe: received", aTopic, "with", aData, "for", aSubject);
    let options = null;
    if (aSubject) {
      options = aSubject.wrappedJSObject;
    }
    switch (aTopic) {
      case "identity-controller-watch":
        this.doWatch(options);
        break;
      case "identity-controller-request":
        this.doRequest(options);
        break;
      case "identity-controller-logout":
        this.doLogout(options);
        break;
      default:
        Logger.reportError("SignInToWebsiteController", "Unknown observer notification:", aTopic);
        break;
    }
  },

  /*
   * options:    method          required - name of method to invoke
   *             assertion       optional
   */
  _makeDoMethodCallback: function SignInToWebsiteController__makeDoMethodCallback(aRpId) {
    return function SignInToWebsiteController_methodCallback(aOptions) {
      let message = aOptions.json;
      if (typeof message === 'string') {
        message = JSON.parse(message);
      }

      switch (message.method) {
        case "ready":
          IdentityService.doReady(aRpId);
          break;

        case "login":
           if (message._internalParams) {
             IdentityService.doLogin(aRpId, message.assertion, message._internalParams);
           } else {
             IdentityService.doLogin(aRpId, message.assertion);
           }
          break;

        case "logout":
          IdentityService.doLogout(aRpId);
          break;

        case "cancel":
          IdentityService.doCancel(aRpId);
          break;

        default:
          log("WARNING: wonky method call:", message.method);
          break;
      }
    };
  },

  doWatch: function SignInToWebsiteController_doWatch(aRpOptions) {
    // dom prevents watch from  being called twice
    let contentOptions = {
      message: kIdentityDelegateWatch,
      showUI: false
    };
    this.pipe.communicate(aRpOptions, contentOptions,
        this._makeDoMethodCallback(aRpOptions.id));
  },

  /**
   * The website is requesting login so the user must choose an identity to use.
   */
  doRequest: function SignInToWebsiteController_doRequest(aRpOptions) {
    log("doRequest", aRpOptions);
    let contentOptions = {
      message: kIdentityDelegateRequest,
      showUI: true
    };
    this.pipe.communicate(aRpOptions, contentOptions,
        this._makeDoMethodCallback(aRpOptions.id));
  },

  /*
   *
   */
  doLogout: function SignInToWebsiteController_doLogout(aRpOptions) {
    log("doLogout", aRpOptions);
    let contentOptions = {
      message: kIdentityDelegateLogout,
      showUI: false
    };
    this.pipe.communicate(aRpOptions, contentOptions,
        this._makeDoMethodCallback(aRpOptions.id));
  }

};