b2g/components/SignInToWebsite.jsm

changeset 0
6474c204b198
     1.1 --- /dev/null	Thu Jan 01 00:00:00 1970 +0000
     1.2 +++ b/b2g/components/SignInToWebsite.jsm	Wed Dec 31 06:09:35 2014 +0100
     1.3 @@ -0,0 +1,444 @@
     1.4 +/* This Source Code Form is subject to the terms of the Mozilla Public
     1.5 + * License, v. 2.0. If a copy of the MPL was not distributed with this file,
     1.6 + * You can obtain one at http://mozilla.org/MPL/2.0/. */
     1.7 +
     1.8 +/*
     1.9 + * SignInToWebsite.jsm - UX Controller and means for accessing identity
    1.10 + * cookies on behalf of relying parties.
    1.11 + *
    1.12 + * Currently, the b2g security architecture isolates web applications
    1.13 + * so that each window has access only to a local cookie jar:
    1.14 + *
    1.15 + *     To prevent Web apps from interfering with one another, each one is
    1.16 + *     hosted on a separate domain, and therefore may only access the
    1.17 + *     resources associated with its domain. These resources include
    1.18 + *     things such as IndexedDB databases, cookies, offline storage,
    1.19 + *     and so forth.
    1.20 + *
    1.21 + *     -- https://developer.mozilla.org/en-US/docs/Mozilla/Firefox_OS/Security/Security_model
    1.22 + *
    1.23 + * As a result, an authentication system like Persona cannot share its
    1.24 + * cookie jar with multiple relying parties, and so would require a
    1.25 + * fresh login request in every window.  This would not be a good
    1.26 + * experience.
    1.27 + *
    1.28 + *
    1.29 + * In order for navigator.id.request() to maintain state in a single
    1.30 + * cookie jar, we cause all Persona interactions to take place in a
    1.31 + * content context that is launched by the system application, with the
    1.32 + * result that Persona has a single cookie jar that all Relying
    1.33 + * Parties can use.  Since of course those Relying Parties cannot
    1.34 + * reach into the system cookie jar, the Controller in this module
    1.35 + * provides a way to get messages and data to and fro between the
    1.36 + * Relying Party in its window context, and the Persona internal api
    1.37 + * in its context.
    1.38 + *
    1.39 + * On the Relying Party's side, say a web page invokes
    1.40 + * navigator.id.watch(), to register callbacks, and then
    1.41 + * navigator.id.request() to request an assertion.  The navigator.id
    1.42 + * calls are provided by nsDOMIdentity.  nsDOMIdentity messages down
    1.43 + * to the privileged DOMIdentity code (using cpmm and ppmm message
    1.44 + * managers).  DOMIdentity stores the state of Relying Party flows
    1.45 + * using an Identity service (MinimalIdentity.jsm), and emits messages
    1.46 + * requesting Persona functions (doWatch, doReady, doLogout).
    1.47 + *
    1.48 + * The Identity service sends these observer messages to the
    1.49 + * Controller in this module, which in turn triggers content to open a
    1.50 + * window to host the Persona js.  If user interaction is required,
    1.51 + * content will open the trusty UI.  If user interaction is not required,
    1.52 + * and we only need to get to Persona functions, content will open a
    1.53 + * hidden iframe.  In either case, a window is opened into which the
    1.54 + * controller causes the script identity.js to be injected.  This
    1.55 + * script provides the glue between the in-page javascript and the
    1.56 + * pipe back down to the Controller, translating navigator.internal
    1.57 + * function callbacks into messages sent back to the Controller.
    1.58 + *
    1.59 + * As a result, a navigator.internal function in the hosted popup or
    1.60 + * iframe can call back to the injected identity.js (doReady, doLogin,
    1.61 + * or doLogout).  identity.js callbacks send messages back through the
    1.62 + * pipe to the Controller.  The controller invokes the corresponding
    1.63 + * function on the Identity Service (doReady, doLogin, or doLogout).
    1.64 + * The IdentityService calls the corresponding callback for the
    1.65 + * correct Relying Party, which causes DOMIdentity to send a message
    1.66 + * up to the Relying Party through nsDOMIdentity
    1.67 + * (Identity:RP:Watch:OnLogin etc.), and finally, nsDOMIdentity
    1.68 + * receives these messages and calls the original callback that the
    1.69 + * Relying Party registered (navigator.id.watch(),
    1.70 + * navigator.id.request(), or navigator.id.logout()).
    1.71 + */
    1.72 +
    1.73 +"use strict";
    1.74 +
    1.75 +this.EXPORTED_SYMBOLS = ["SignInToWebsiteController"];
    1.76 +
    1.77 +const Ci = Components.interfaces;
    1.78 +const Cu = Components.utils;
    1.79 +
    1.80 +Cu.import("resource://gre/modules/Services.jsm");
    1.81 +Cu.import("resource://gre/modules/XPCOMUtils.jsm");
    1.82 +
    1.83 +XPCOMUtils.defineLazyModuleGetter(this, "getRandomId",
    1.84 +                                  "resource://gre/modules/identity/IdentityUtils.jsm");
    1.85 +
    1.86 +XPCOMUtils.defineLazyModuleGetter(this, "IdentityService",
    1.87 +                                  "resource://gre/modules/identity/MinimalIdentity.jsm");
    1.88 +
    1.89 +XPCOMUtils.defineLazyModuleGetter(this, "Logger",
    1.90 +                                  "resource://gre/modules/identity/LogUtils.jsm");
    1.91 +
    1.92 +XPCOMUtils.defineLazyModuleGetter(this, "SystemAppProxy",
    1.93 +                                  "resource://gre/modules/SystemAppProxy.jsm");
    1.94 +
    1.95 +// The default persona uri; can be overwritten with toolkit.identity.uri pref.
    1.96 +// Do this if you want to repoint to a different service for testing.
    1.97 +// There's no point in setting up an observer to monitor the pref, as b2g prefs
    1.98 +// can only be overwritten when the profie is recreated.  So just get the value
    1.99 +// on start-up.
   1.100 +let kPersonaUri = "https://firefoxos.persona.org";
   1.101 +try {
   1.102 +  kPersonaUri = Services.prefs.getCharPref("toolkit.identity.uri");
   1.103 +} catch(noSuchPref) {
   1.104 +  // stick with the default value
   1.105 +}
   1.106 +
   1.107 +// JS shim that contains the callback functions that
   1.108 +// live within the identity UI provisioning frame.
   1.109 +const kIdentityShimFile = "chrome://b2g/content/identity.js";
   1.110 +
   1.111 +// Type of MozChromeEvents to handle id dialogs.
   1.112 +const kOpenIdentityDialog = "id-dialog-open";
   1.113 +const kDoneIdentityDialog = "id-dialog-done";
   1.114 +const kCloseIdentityDialog = "id-dialog-close-iframe";
   1.115 +
   1.116 +// Observer messages to communicate to shim
   1.117 +const kIdentityDelegateWatch = "identity-delegate-watch";
   1.118 +const kIdentityDelegateRequest = "identity-delegate-request";
   1.119 +const kIdentityDelegateLogout = "identity-delegate-logout";
   1.120 +const kIdentityDelegateFinished = "identity-delegate-finished";
   1.121 +const kIdentityDelegateReady = "identity-delegate-ready";
   1.122 +
   1.123 +const kIdentityControllerDoMethod = "identity-controller-doMethod";
   1.124 +
   1.125 +function log(...aMessageArgs) {
   1.126 +  Logger.log.apply(Logger, ["SignInToWebsiteController"].concat(aMessageArgs));
   1.127 +}
   1.128 +
   1.129 +log("persona uri =", kPersonaUri);
   1.130 +
   1.131 +function sendChromeEvent(details) {
   1.132 +  details.uri = kPersonaUri;
   1.133 +  SystemAppProxy.dispatchEvent(details);
   1.134 +}
   1.135 +
   1.136 +function Pipe() {
   1.137 +  this._watchers = [];
   1.138 +}
   1.139 +
   1.140 +Pipe.prototype = {
   1.141 +  init: function pipe_init() {
   1.142 +    Services.obs.addObserver(this, "identity-child-process-shutdown", false);
   1.143 +    Services.obs.addObserver(this, "identity-controller-unwatch", false);
   1.144 +  },
   1.145 +
   1.146 +  uninit: function pipe_uninit() {
   1.147 +    Services.obs.removeObserver(this, "identity-child-process-shutdown");
   1.148 +    Services.obs.removeObserver(this, "identity-controller-unwatch");
   1.149 +  },
   1.150 +
   1.151 +  observe: function Pipe_observe(aSubject, aTopic, aData) {
   1.152 +    let options = {};
   1.153 +    if (aSubject) {
   1.154 +      options = aSubject.wrappedJSObject;
   1.155 +    }
   1.156 +    switch (aTopic) {
   1.157 +      case "identity-child-process-shutdown":
   1.158 +        log("pipe removing watchers by message manager");
   1.159 +        this._removeWatchers(null, options.messageManager);
   1.160 +        break;
   1.161 +
   1.162 +      case "identity-controller-unwatch":
   1.163 +        log("unwatching", options.id);
   1.164 +        this._removeWatchers(options.id, options.messageManager);
   1.165 +        break;
   1.166 +    }
   1.167 +  },
   1.168 +
   1.169 +  _addWatcher: function Pipe__addWatcher(aId, aMm) {
   1.170 +    log("Adding watcher with id", aId);
   1.171 +    for (let i = 0; i < this._watchers.length; ++i) {
   1.172 +      let watcher = this._watchers[i];
   1.173 +      if (this._watcher.id === aId) {
   1.174 +        watcher.count++;
   1.175 +        return;
   1.176 +      }
   1.177 +    }
   1.178 +    this._watchers.push({id: aId, count: 1, mm: aMm});
   1.179 +  },
   1.180 +
   1.181 +  _removeWatchers: function Pipe__removeWatcher(aId, aMm) {
   1.182 +    let checkId = aId !== null;
   1.183 +    let index = -1;
   1.184 +    for (let i = 0; i < this._watchers.length; ++i) {
   1.185 +      let watcher = this._watchers[i];
   1.186 +      if (watcher.mm === aMm &&
   1.187 +          (!checkId || (checkId && watcher.id === aId))) {
   1.188 +        index = i;
   1.189 +        break;
   1.190 +      }
   1.191 +    }
   1.192 +
   1.193 +    if (index !== -1) {
   1.194 +      if (checkId) {
   1.195 +        if (--(this._watchers[index].count) === 0) {
   1.196 +          this._watchers.splice(index, 1);
   1.197 +        }
   1.198 +      } else {
   1.199 +        this._watchers.splice(index, 1);
   1.200 +      }
   1.201 +    }
   1.202 +
   1.203 +    if (this._watchers.length === 0) {
   1.204 +      log("No more watchers; clean up persona host iframe");
   1.205 +      let detail = {
   1.206 +        type: kCloseIdentityDialog
   1.207 +      };
   1.208 +      log('telling content to close the dialog');
   1.209 +      // tell content to close the dialog
   1.210 +      sendChromeEvent(detail);
   1.211 +    }
   1.212 +  },
   1.213 +
   1.214 +  communicate: function(aRpOptions, aContentOptions, aMessageCallback) {
   1.215 +    let rpID = aRpOptions.id;
   1.216 +    let rpMM = aRpOptions.mm;
   1.217 +    if (rpMM) {
   1.218 +      this._addWatcher(rpID, rpMM);
   1.219 +    }
   1.220 +
   1.221 +    log("RP options:", aRpOptions, "\n  content options:", aContentOptions);
   1.222 +
   1.223 +    // This content variable is injected into the scope of
   1.224 +    // kIdentityShimFile, where it is used to access the BrowserID object
   1.225 +    // and its internal API.
   1.226 +    let mm = null;
   1.227 +    let uuid = getRandomId();
   1.228 +    let self = this;
   1.229 +
   1.230 +    function removeMessageListeners() {
   1.231 +      if (mm) {
   1.232 +        mm.removeMessageListener(kIdentityDelegateFinished, identityDelegateFinished);
   1.233 +        mm.removeMessageListener(kIdentityControllerDoMethod, aMessageCallback);
   1.234 +      }
   1.235 +    }
   1.236 +
   1.237 +    function identityDelegateFinished() {
   1.238 +      removeMessageListeners();
   1.239 +
   1.240 +      let detail = {
   1.241 +        type: kDoneIdentityDialog,
   1.242 +        showUI: aContentOptions.showUI || false,
   1.243 +        id: kDoneIdentityDialog + "-" + uuid,
   1.244 +        requestId: aRpOptions.id
   1.245 +      };
   1.246 +      log('received delegate finished; telling content to close the dialog');
   1.247 +      sendChromeEvent(detail);
   1.248 +      self._removeWatchers(rpID, rpMM);
   1.249 +    }
   1.250 +
   1.251 +    SystemAppProxy.addEventListener("mozContentEvent", function getAssertion(evt) {
   1.252 +      let msg = evt.detail;
   1.253 +      if (!msg.id.match(uuid)) {
   1.254 +        return;
   1.255 +      }
   1.256 +
   1.257 +      switch (msg.id) {
   1.258 +        case kOpenIdentityDialog + '-' + uuid:
   1.259 +          if (msg.type === 'cancel') {
   1.260 +            // The user closed the dialog.  Clean up and call cancel.
   1.261 +            SystemAppProxy.removeEventListener("mozContentEvent", getAssertion);
   1.262 +            removeMessageListeners();
   1.263 +            aMessageCallback({json: {method: "cancel"}});
   1.264 +          } else {
   1.265 +            // The window has opened.  Inject the identity shim file containing
   1.266 +            // the callbacks in the content script.  This could be either the
   1.267 +            // visible popup that the user interacts with, or it could be an
   1.268 +            // invisible frame.
   1.269 +            let frame = evt.detail.frame;
   1.270 +            let frameLoader = frame.QueryInterface(Ci.nsIFrameLoaderOwner).frameLoader;
   1.271 +            mm = frameLoader.messageManager;
   1.272 +            try {
   1.273 +              mm.loadFrameScript(kIdentityShimFile, true, true);
   1.274 +              log("Loaded shim", kIdentityShimFile);
   1.275 +            } catch (e) {
   1.276 +              log("Error loading", kIdentityShimFile, "as a frame script:", e);
   1.277 +            }
   1.278 +
   1.279 +            // There are two messages that the delegate can send back: a "do
   1.280 +            // method" event, and a "finished" event.  We pass the do-method
   1.281 +            // events straight to the caller for interpretation and handling.
   1.282 +            // If we receive a "finished" event, then the delegate is done, so
   1.283 +            // we shut down the pipe and clean up.
   1.284 +            mm.addMessageListener(kIdentityControllerDoMethod, aMessageCallback);
   1.285 +            mm.addMessageListener(kIdentityDelegateFinished, identityDelegateFinished);
   1.286 +
   1.287 +            mm.sendAsyncMessage(aContentOptions.message, aRpOptions);
   1.288 +          }
   1.289 +          break;
   1.290 +
   1.291 +        case kDoneIdentityDialog + '-' + uuid:
   1.292 +          // Received our assertion.  The message manager callbacks will handle
   1.293 +          // communicating back to the IDService.  All we have to do is remove
   1.294 +          // this listener.
   1.295 +          SystemAppProxy.removeEventListener("mozContentEvent", getAssertion);
   1.296 +          break;
   1.297 +
   1.298 +        default:
   1.299 +          log("ERROR - Unexpected message: id=" + msg.id + ", type=" + msg.type + ", errorMsg=" + msg.errorMsg);
   1.300 +          break;
   1.301 +      }
   1.302 +
   1.303 +    });
   1.304 +
   1.305 +    // Tell content to open the identity iframe or trusty popup. The parameter
   1.306 +    // showUI signals whether user interaction is needed.  If it is, content will
   1.307 +    // open a dialog; if not, a hidden iframe.  In each case, BrowserID is
   1.308 +    // available in the context.
   1.309 +    let detail = {
   1.310 +      type: kOpenIdentityDialog,
   1.311 +      showUI: aContentOptions.showUI || false,
   1.312 +      id: kOpenIdentityDialog + "-" + uuid,
   1.313 +      requestId: aRpOptions.id
   1.314 +    };
   1.315 +
   1.316 +    sendChromeEvent(detail);
   1.317 +  }
   1.318 +
   1.319 +};
   1.320 +
   1.321 +/*
   1.322 + * The controller sits between the IdentityService used by DOMIdentity
   1.323 + * and a content process launches an (invisible) iframe or (visible)
   1.324 + * trusty UI.  Using an injected js script (identity.js), the
   1.325 + * controller enables the content window to access the persona identity
   1.326 + * storage in the system cookie jar and send events back via the
   1.327 + * controller into IdentityService and DOM, and ultimately up to the
   1.328 + * Relying Party, which is open in a different window context.
   1.329 + */
   1.330 +this.SignInToWebsiteController = {
   1.331 +
   1.332 +  /*
   1.333 +   * Initialize the controller.  To use a different content communication pipe,
   1.334 +   * such as when mocking it in tests, pass aOptions.pipe.
   1.335 +   */
   1.336 +  init: function SignInToWebsiteController_init(aOptions) {
   1.337 +    aOptions = aOptions || {};
   1.338 +    this.pipe = aOptions.pipe || new Pipe();
   1.339 +    Services.obs.addObserver(this, "identity-controller-watch", false);
   1.340 +    Services.obs.addObserver(this, "identity-controller-request", false);
   1.341 +    Services.obs.addObserver(this, "identity-controller-logout", false);
   1.342 +  },
   1.343 +
   1.344 +  uninit: function SignInToWebsiteController_uninit() {
   1.345 +    Services.obs.removeObserver(this, "identity-controller-watch");
   1.346 +    Services.obs.removeObserver(this, "identity-controller-request");
   1.347 +    Services.obs.removeObserver(this, "identity-controller-logout");
   1.348 +  },
   1.349 +
   1.350 +  observe: function SignInToWebsiteController_observe(aSubject, aTopic, aData) {
   1.351 +    log("observe: received", aTopic, "with", aData, "for", aSubject);
   1.352 +    let options = null;
   1.353 +    if (aSubject) {
   1.354 +      options = aSubject.wrappedJSObject;
   1.355 +    }
   1.356 +    switch (aTopic) {
   1.357 +      case "identity-controller-watch":
   1.358 +        this.doWatch(options);
   1.359 +        break;
   1.360 +      case "identity-controller-request":
   1.361 +        this.doRequest(options);
   1.362 +        break;
   1.363 +      case "identity-controller-logout":
   1.364 +        this.doLogout(options);
   1.365 +        break;
   1.366 +      default:
   1.367 +        Logger.reportError("SignInToWebsiteController", "Unknown observer notification:", aTopic);
   1.368 +        break;
   1.369 +    }
   1.370 +  },
   1.371 +
   1.372 +  /*
   1.373 +   * options:    method          required - name of method to invoke
   1.374 +   *             assertion       optional
   1.375 +   */
   1.376 +  _makeDoMethodCallback: function SignInToWebsiteController__makeDoMethodCallback(aRpId) {
   1.377 +    return function SignInToWebsiteController_methodCallback(aOptions) {
   1.378 +      let message = aOptions.json;
   1.379 +      if (typeof message === 'string') {
   1.380 +        message = JSON.parse(message);
   1.381 +      }
   1.382 +
   1.383 +      switch (message.method) {
   1.384 +        case "ready":
   1.385 +          IdentityService.doReady(aRpId);
   1.386 +          break;
   1.387 +
   1.388 +        case "login":
   1.389 +           if (message._internalParams) {
   1.390 +             IdentityService.doLogin(aRpId, message.assertion, message._internalParams);
   1.391 +           } else {
   1.392 +             IdentityService.doLogin(aRpId, message.assertion);
   1.393 +           }
   1.394 +          break;
   1.395 +
   1.396 +        case "logout":
   1.397 +          IdentityService.doLogout(aRpId);
   1.398 +          break;
   1.399 +
   1.400 +        case "cancel":
   1.401 +          IdentityService.doCancel(aRpId);
   1.402 +          break;
   1.403 +
   1.404 +        default:
   1.405 +          log("WARNING: wonky method call:", message.method);
   1.406 +          break;
   1.407 +      }
   1.408 +    };
   1.409 +  },
   1.410 +
   1.411 +  doWatch: function SignInToWebsiteController_doWatch(aRpOptions) {
   1.412 +    // dom prevents watch from  being called twice
   1.413 +    let contentOptions = {
   1.414 +      message: kIdentityDelegateWatch,
   1.415 +      showUI: false
   1.416 +    };
   1.417 +    this.pipe.communicate(aRpOptions, contentOptions,
   1.418 +        this._makeDoMethodCallback(aRpOptions.id));
   1.419 +  },
   1.420 +
   1.421 +  /**
   1.422 +   * The website is requesting login so the user must choose an identity to use.
   1.423 +   */
   1.424 +  doRequest: function SignInToWebsiteController_doRequest(aRpOptions) {
   1.425 +    log("doRequest", aRpOptions);
   1.426 +    let contentOptions = {
   1.427 +      message: kIdentityDelegateRequest,
   1.428 +      showUI: true
   1.429 +    };
   1.430 +    this.pipe.communicate(aRpOptions, contentOptions,
   1.431 +        this._makeDoMethodCallback(aRpOptions.id));
   1.432 +  },
   1.433 +
   1.434 +  /*
   1.435 +   *
   1.436 +   */
   1.437 +  doLogout: function SignInToWebsiteController_doLogout(aRpOptions) {
   1.438 +    log("doLogout", aRpOptions);
   1.439 +    let contentOptions = {
   1.440 +      message: kIdentityDelegateLogout,
   1.441 +      showUI: false
   1.442 +    };
   1.443 +    this.pipe.communicate(aRpOptions, contentOptions,
   1.444 +        this._makeDoMethodCallback(aRpOptions.id));
   1.445 +  }
   1.446 +
   1.447 +};

mercurial