michael@0: /* This Source Code Form is subject to the terms of the Mozilla Public michael@0: * License, v. 2.0. If a copy of the MPL was not distributed with this file, michael@0: * You can obtain one at http://mozilla.org/MPL/2.0/. */ michael@0: michael@0: /* michael@0: * SignInToWebsite.jsm - UX Controller and means for accessing identity michael@0: * cookies on behalf of relying parties. michael@0: * michael@0: * Currently, the b2g security architecture isolates web applications michael@0: * so that each window has access only to a local cookie jar: michael@0: * michael@0: * To prevent Web apps from interfering with one another, each one is michael@0: * hosted on a separate domain, and therefore may only access the michael@0: * resources associated with its domain. These resources include michael@0: * things such as IndexedDB databases, cookies, offline storage, michael@0: * and so forth. michael@0: * michael@0: * -- https://developer.mozilla.org/en-US/docs/Mozilla/Firefox_OS/Security/Security_model michael@0: * michael@0: * As a result, an authentication system like Persona cannot share its michael@0: * cookie jar with multiple relying parties, and so would require a michael@0: * fresh login request in every window. This would not be a good michael@0: * experience. michael@0: * michael@0: * michael@0: * In order for navigator.id.request() to maintain state in a single michael@0: * cookie jar, we cause all Persona interactions to take place in a michael@0: * content context that is launched by the system application, with the michael@0: * result that Persona has a single cookie jar that all Relying michael@0: * Parties can use. Since of course those Relying Parties cannot michael@0: * reach into the system cookie jar, the Controller in this module michael@0: * provides a way to get messages and data to and fro between the michael@0: * Relying Party in its window context, and the Persona internal api michael@0: * in its context. michael@0: * michael@0: * On the Relying Party's side, say a web page invokes michael@0: * navigator.id.watch(), to register callbacks, and then michael@0: * navigator.id.request() to request an assertion. The navigator.id michael@0: * calls are provided by nsDOMIdentity. nsDOMIdentity messages down michael@0: * to the privileged DOMIdentity code (using cpmm and ppmm message michael@0: * managers). DOMIdentity stores the state of Relying Party flows michael@0: * using an Identity service (MinimalIdentity.jsm), and emits messages michael@0: * requesting Persona functions (doWatch, doReady, doLogout). michael@0: * michael@0: * The Identity service sends these observer messages to the michael@0: * Controller in this module, which in turn triggers content to open a michael@0: * window to host the Persona js. If user interaction is required, michael@0: * content will open the trusty UI. If user interaction is not required, michael@0: * and we only need to get to Persona functions, content will open a michael@0: * hidden iframe. In either case, a window is opened into which the michael@0: * controller causes the script identity.js to be injected. This michael@0: * script provides the glue between the in-page javascript and the michael@0: * pipe back down to the Controller, translating navigator.internal michael@0: * function callbacks into messages sent back to the Controller. michael@0: * michael@0: * As a result, a navigator.internal function in the hosted popup or michael@0: * iframe can call back to the injected identity.js (doReady, doLogin, michael@0: * or doLogout). identity.js callbacks send messages back through the michael@0: * pipe to the Controller. The controller invokes the corresponding michael@0: * function on the Identity Service (doReady, doLogin, or doLogout). michael@0: * The IdentityService calls the corresponding callback for the michael@0: * correct Relying Party, which causes DOMIdentity to send a message michael@0: * up to the Relying Party through nsDOMIdentity michael@0: * (Identity:RP:Watch:OnLogin etc.), and finally, nsDOMIdentity michael@0: * receives these messages and calls the original callback that the michael@0: * Relying Party registered (navigator.id.watch(), michael@0: * navigator.id.request(), or navigator.id.logout()). michael@0: */ michael@0: michael@0: "use strict"; michael@0: michael@0: this.EXPORTED_SYMBOLS = ["SignInToWebsiteController"]; michael@0: michael@0: const Ci = Components.interfaces; michael@0: const Cu = Components.utils; michael@0: michael@0: Cu.import("resource://gre/modules/Services.jsm"); michael@0: Cu.import("resource://gre/modules/XPCOMUtils.jsm"); michael@0: michael@0: XPCOMUtils.defineLazyModuleGetter(this, "getRandomId", michael@0: "resource://gre/modules/identity/IdentityUtils.jsm"); michael@0: michael@0: XPCOMUtils.defineLazyModuleGetter(this, "IdentityService", michael@0: "resource://gre/modules/identity/MinimalIdentity.jsm"); michael@0: michael@0: XPCOMUtils.defineLazyModuleGetter(this, "Logger", michael@0: "resource://gre/modules/identity/LogUtils.jsm"); michael@0: michael@0: XPCOMUtils.defineLazyModuleGetter(this, "SystemAppProxy", michael@0: "resource://gre/modules/SystemAppProxy.jsm"); michael@0: michael@0: // The default persona uri; can be overwritten with toolkit.identity.uri pref. michael@0: // Do this if you want to repoint to a different service for testing. michael@0: // There's no point in setting up an observer to monitor the pref, as b2g prefs michael@0: // can only be overwritten when the profie is recreated. So just get the value michael@0: // on start-up. michael@0: let kPersonaUri = "https://firefoxos.persona.org"; michael@0: try { michael@0: kPersonaUri = Services.prefs.getCharPref("toolkit.identity.uri"); michael@0: } catch(noSuchPref) { michael@0: // stick with the default value michael@0: } michael@0: michael@0: // JS shim that contains the callback functions that michael@0: // live within the identity UI provisioning frame. michael@0: const kIdentityShimFile = "chrome://b2g/content/identity.js"; michael@0: michael@0: // Type of MozChromeEvents to handle id dialogs. michael@0: const kOpenIdentityDialog = "id-dialog-open"; michael@0: const kDoneIdentityDialog = "id-dialog-done"; michael@0: const kCloseIdentityDialog = "id-dialog-close-iframe"; michael@0: michael@0: // Observer messages to communicate to shim michael@0: const kIdentityDelegateWatch = "identity-delegate-watch"; michael@0: const kIdentityDelegateRequest = "identity-delegate-request"; michael@0: const kIdentityDelegateLogout = "identity-delegate-logout"; michael@0: const kIdentityDelegateFinished = "identity-delegate-finished"; michael@0: const kIdentityDelegateReady = "identity-delegate-ready"; michael@0: michael@0: const kIdentityControllerDoMethod = "identity-controller-doMethod"; michael@0: michael@0: function log(...aMessageArgs) { michael@0: Logger.log.apply(Logger, ["SignInToWebsiteController"].concat(aMessageArgs)); michael@0: } michael@0: michael@0: log("persona uri =", kPersonaUri); michael@0: michael@0: function sendChromeEvent(details) { michael@0: details.uri = kPersonaUri; michael@0: SystemAppProxy.dispatchEvent(details); michael@0: } michael@0: michael@0: function Pipe() { michael@0: this._watchers = []; michael@0: } michael@0: michael@0: Pipe.prototype = { michael@0: init: function pipe_init() { michael@0: Services.obs.addObserver(this, "identity-child-process-shutdown", false); michael@0: Services.obs.addObserver(this, "identity-controller-unwatch", false); michael@0: }, michael@0: michael@0: uninit: function pipe_uninit() { michael@0: Services.obs.removeObserver(this, "identity-child-process-shutdown"); michael@0: Services.obs.removeObserver(this, "identity-controller-unwatch"); michael@0: }, michael@0: michael@0: observe: function Pipe_observe(aSubject, aTopic, aData) { michael@0: let options = {}; michael@0: if (aSubject) { michael@0: options = aSubject.wrappedJSObject; michael@0: } michael@0: switch (aTopic) { michael@0: case "identity-child-process-shutdown": michael@0: log("pipe removing watchers by message manager"); michael@0: this._removeWatchers(null, options.messageManager); michael@0: break; michael@0: michael@0: case "identity-controller-unwatch": michael@0: log("unwatching", options.id); michael@0: this._removeWatchers(options.id, options.messageManager); michael@0: break; michael@0: } michael@0: }, michael@0: michael@0: _addWatcher: function Pipe__addWatcher(aId, aMm) { michael@0: log("Adding watcher with id", aId); michael@0: for (let i = 0; i < this._watchers.length; ++i) { michael@0: let watcher = this._watchers[i]; michael@0: if (this._watcher.id === aId) { michael@0: watcher.count++; michael@0: return; michael@0: } michael@0: } michael@0: this._watchers.push({id: aId, count: 1, mm: aMm}); michael@0: }, michael@0: michael@0: _removeWatchers: function Pipe__removeWatcher(aId, aMm) { michael@0: let checkId = aId !== null; michael@0: let index = -1; michael@0: for (let i = 0; i < this._watchers.length; ++i) { michael@0: let watcher = this._watchers[i]; michael@0: if (watcher.mm === aMm && michael@0: (!checkId || (checkId && watcher.id === aId))) { michael@0: index = i; michael@0: break; michael@0: } michael@0: } michael@0: michael@0: if (index !== -1) { michael@0: if (checkId) { michael@0: if (--(this._watchers[index].count) === 0) { michael@0: this._watchers.splice(index, 1); michael@0: } michael@0: } else { michael@0: this._watchers.splice(index, 1); michael@0: } michael@0: } michael@0: michael@0: if (this._watchers.length === 0) { michael@0: log("No more watchers; clean up persona host iframe"); michael@0: let detail = { michael@0: type: kCloseIdentityDialog michael@0: }; michael@0: log('telling content to close the dialog'); michael@0: // tell content to close the dialog michael@0: sendChromeEvent(detail); michael@0: } michael@0: }, michael@0: michael@0: communicate: function(aRpOptions, aContentOptions, aMessageCallback) { michael@0: let rpID = aRpOptions.id; michael@0: let rpMM = aRpOptions.mm; michael@0: if (rpMM) { michael@0: this._addWatcher(rpID, rpMM); michael@0: } michael@0: michael@0: log("RP options:", aRpOptions, "\n content options:", aContentOptions); michael@0: michael@0: // This content variable is injected into the scope of michael@0: // kIdentityShimFile, where it is used to access the BrowserID object michael@0: // and its internal API. michael@0: let mm = null; michael@0: let uuid = getRandomId(); michael@0: let self = this; michael@0: michael@0: function removeMessageListeners() { michael@0: if (mm) { michael@0: mm.removeMessageListener(kIdentityDelegateFinished, identityDelegateFinished); michael@0: mm.removeMessageListener(kIdentityControllerDoMethod, aMessageCallback); michael@0: } michael@0: } michael@0: michael@0: function identityDelegateFinished() { michael@0: removeMessageListeners(); michael@0: michael@0: let detail = { michael@0: type: kDoneIdentityDialog, michael@0: showUI: aContentOptions.showUI || false, michael@0: id: kDoneIdentityDialog + "-" + uuid, michael@0: requestId: aRpOptions.id michael@0: }; michael@0: log('received delegate finished; telling content to close the dialog'); michael@0: sendChromeEvent(detail); michael@0: self._removeWatchers(rpID, rpMM); michael@0: } michael@0: michael@0: SystemAppProxy.addEventListener("mozContentEvent", function getAssertion(evt) { michael@0: let msg = evt.detail; michael@0: if (!msg.id.match(uuid)) { michael@0: return; michael@0: } michael@0: michael@0: switch (msg.id) { michael@0: case kOpenIdentityDialog + '-' + uuid: michael@0: if (msg.type === 'cancel') { michael@0: // The user closed the dialog. Clean up and call cancel. michael@0: SystemAppProxy.removeEventListener("mozContentEvent", getAssertion); michael@0: removeMessageListeners(); michael@0: aMessageCallback({json: {method: "cancel"}}); michael@0: } else { michael@0: // The window has opened. Inject the identity shim file containing michael@0: // the callbacks in the content script. This could be either the michael@0: // visible popup that the user interacts with, or it could be an michael@0: // invisible frame. michael@0: let frame = evt.detail.frame; michael@0: let frameLoader = frame.QueryInterface(Ci.nsIFrameLoaderOwner).frameLoader; michael@0: mm = frameLoader.messageManager; michael@0: try { michael@0: mm.loadFrameScript(kIdentityShimFile, true, true); michael@0: log("Loaded shim", kIdentityShimFile); michael@0: } catch (e) { michael@0: log("Error loading", kIdentityShimFile, "as a frame script:", e); michael@0: } michael@0: michael@0: // There are two messages that the delegate can send back: a "do michael@0: // method" event, and a "finished" event. We pass the do-method michael@0: // events straight to the caller for interpretation and handling. michael@0: // If we receive a "finished" event, then the delegate is done, so michael@0: // we shut down the pipe and clean up. michael@0: mm.addMessageListener(kIdentityControllerDoMethod, aMessageCallback); michael@0: mm.addMessageListener(kIdentityDelegateFinished, identityDelegateFinished); michael@0: michael@0: mm.sendAsyncMessage(aContentOptions.message, aRpOptions); michael@0: } michael@0: break; michael@0: michael@0: case kDoneIdentityDialog + '-' + uuid: michael@0: // Received our assertion. The message manager callbacks will handle michael@0: // communicating back to the IDService. All we have to do is remove michael@0: // this listener. michael@0: SystemAppProxy.removeEventListener("mozContentEvent", getAssertion); michael@0: break; michael@0: michael@0: default: michael@0: log("ERROR - Unexpected message: id=" + msg.id + ", type=" + msg.type + ", errorMsg=" + msg.errorMsg); michael@0: break; michael@0: } michael@0: michael@0: }); michael@0: michael@0: // Tell content to open the identity iframe or trusty popup. The parameter michael@0: // showUI signals whether user interaction is needed. If it is, content will michael@0: // open a dialog; if not, a hidden iframe. In each case, BrowserID is michael@0: // available in the context. michael@0: let detail = { michael@0: type: kOpenIdentityDialog, michael@0: showUI: aContentOptions.showUI || false, michael@0: id: kOpenIdentityDialog + "-" + uuid, michael@0: requestId: aRpOptions.id michael@0: }; michael@0: michael@0: sendChromeEvent(detail); michael@0: } michael@0: michael@0: }; michael@0: michael@0: /* michael@0: * The controller sits between the IdentityService used by DOMIdentity michael@0: * and a content process launches an (invisible) iframe or (visible) michael@0: * trusty UI. Using an injected js script (identity.js), the michael@0: * controller enables the content window to access the persona identity michael@0: * storage in the system cookie jar and send events back via the michael@0: * controller into IdentityService and DOM, and ultimately up to the michael@0: * Relying Party, which is open in a different window context. michael@0: */ michael@0: this.SignInToWebsiteController = { michael@0: michael@0: /* michael@0: * Initialize the controller. To use a different content communication pipe, michael@0: * such as when mocking it in tests, pass aOptions.pipe. michael@0: */ michael@0: init: function SignInToWebsiteController_init(aOptions) { michael@0: aOptions = aOptions || {}; michael@0: this.pipe = aOptions.pipe || new Pipe(); michael@0: Services.obs.addObserver(this, "identity-controller-watch", false); michael@0: Services.obs.addObserver(this, "identity-controller-request", false); michael@0: Services.obs.addObserver(this, "identity-controller-logout", false); michael@0: }, michael@0: michael@0: uninit: function SignInToWebsiteController_uninit() { michael@0: Services.obs.removeObserver(this, "identity-controller-watch"); michael@0: Services.obs.removeObserver(this, "identity-controller-request"); michael@0: Services.obs.removeObserver(this, "identity-controller-logout"); michael@0: }, michael@0: michael@0: observe: function SignInToWebsiteController_observe(aSubject, aTopic, aData) { michael@0: log("observe: received", aTopic, "with", aData, "for", aSubject); michael@0: let options = null; michael@0: if (aSubject) { michael@0: options = aSubject.wrappedJSObject; michael@0: } michael@0: switch (aTopic) { michael@0: case "identity-controller-watch": michael@0: this.doWatch(options); michael@0: break; michael@0: case "identity-controller-request": michael@0: this.doRequest(options); michael@0: break; michael@0: case "identity-controller-logout": michael@0: this.doLogout(options); michael@0: break; michael@0: default: michael@0: Logger.reportError("SignInToWebsiteController", "Unknown observer notification:", aTopic); michael@0: break; michael@0: } michael@0: }, michael@0: michael@0: /* michael@0: * options: method required - name of method to invoke michael@0: * assertion optional michael@0: */ michael@0: _makeDoMethodCallback: function SignInToWebsiteController__makeDoMethodCallback(aRpId) { michael@0: return function SignInToWebsiteController_methodCallback(aOptions) { michael@0: let message = aOptions.json; michael@0: if (typeof message === 'string') { michael@0: message = JSON.parse(message); michael@0: } michael@0: michael@0: switch (message.method) { michael@0: case "ready": michael@0: IdentityService.doReady(aRpId); michael@0: break; michael@0: michael@0: case "login": michael@0: if (message._internalParams) { michael@0: IdentityService.doLogin(aRpId, message.assertion, message._internalParams); michael@0: } else { michael@0: IdentityService.doLogin(aRpId, message.assertion); michael@0: } michael@0: break; michael@0: michael@0: case "logout": michael@0: IdentityService.doLogout(aRpId); michael@0: break; michael@0: michael@0: case "cancel": michael@0: IdentityService.doCancel(aRpId); michael@0: break; michael@0: michael@0: default: michael@0: log("WARNING: wonky method call:", message.method); michael@0: break; michael@0: } michael@0: }; michael@0: }, michael@0: michael@0: doWatch: function SignInToWebsiteController_doWatch(aRpOptions) { michael@0: // dom prevents watch from being called twice michael@0: let contentOptions = { michael@0: message: kIdentityDelegateWatch, michael@0: showUI: false michael@0: }; michael@0: this.pipe.communicate(aRpOptions, contentOptions, michael@0: this._makeDoMethodCallback(aRpOptions.id)); michael@0: }, michael@0: michael@0: /** michael@0: * The website is requesting login so the user must choose an identity to use. michael@0: */ michael@0: doRequest: function SignInToWebsiteController_doRequest(aRpOptions) { michael@0: log("doRequest", aRpOptions); michael@0: let contentOptions = { michael@0: message: kIdentityDelegateRequest, michael@0: showUI: true michael@0: }; michael@0: this.pipe.communicate(aRpOptions, contentOptions, michael@0: this._makeDoMethodCallback(aRpOptions.id)); michael@0: }, michael@0: michael@0: /* michael@0: * michael@0: */ michael@0: doLogout: function SignInToWebsiteController_doLogout(aRpOptions) { michael@0: log("doLogout", aRpOptions); michael@0: let contentOptions = { michael@0: message: kIdentityDelegateLogout, michael@0: showUI: false michael@0: }; michael@0: this.pipe.communicate(aRpOptions, contentOptions, michael@0: this._makeDoMethodCallback(aRpOptions.id)); michael@0: } michael@0: michael@0: };