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 +};