Thu, 22 Jan 2015 13:21:57 +0100
Incorporate requested changes from Mozilla in review:
https://bugzilla.mozilla.org/show_bug.cgi?id=1123480#c6
1 /* This Source Code Form is subject to the terms of the Mozilla Public
2 * License, v. 2.0. If a copy of the MPL was not distributed with this file,
3 * You can obtain one at http://mozilla.org/MPL/2.0/. */
5 /*
6 * SignInToWebsite.jsm - UX Controller and means for accessing identity
7 * cookies on behalf of relying parties.
8 *
9 * Currently, the b2g security architecture isolates web applications
10 * so that each window has access only to a local cookie jar:
11 *
12 * To prevent Web apps from interfering with one another, each one is
13 * hosted on a separate domain, and therefore may only access the
14 * resources associated with its domain. These resources include
15 * things such as IndexedDB databases, cookies, offline storage,
16 * and so forth.
17 *
18 * -- https://developer.mozilla.org/en-US/docs/Mozilla/Firefox_OS/Security/Security_model
19 *
20 * As a result, an authentication system like Persona cannot share its
21 * cookie jar with multiple relying parties, and so would require a
22 * fresh login request in every window. This would not be a good
23 * experience.
24 *
25 *
26 * In order for navigator.id.request() to maintain state in a single
27 * cookie jar, we cause all Persona interactions to take place in a
28 * content context that is launched by the system application, with the
29 * result that Persona has a single cookie jar that all Relying
30 * Parties can use. Since of course those Relying Parties cannot
31 * reach into the system cookie jar, the Controller in this module
32 * provides a way to get messages and data to and fro between the
33 * Relying Party in its window context, and the Persona internal api
34 * in its context.
35 *
36 * On the Relying Party's side, say a web page invokes
37 * navigator.id.watch(), to register callbacks, and then
38 * navigator.id.request() to request an assertion. The navigator.id
39 * calls are provided by nsDOMIdentity. nsDOMIdentity messages down
40 * to the privileged DOMIdentity code (using cpmm and ppmm message
41 * managers). DOMIdentity stores the state of Relying Party flows
42 * using an Identity service (MinimalIdentity.jsm), and emits messages
43 * requesting Persona functions (doWatch, doReady, doLogout).
44 *
45 * The Identity service sends these observer messages to the
46 * Controller in this module, which in turn triggers content to open a
47 * window to host the Persona js. If user interaction is required,
48 * content will open the trusty UI. If user interaction is not required,
49 * and we only need to get to Persona functions, content will open a
50 * hidden iframe. In either case, a window is opened into which the
51 * controller causes the script identity.js to be injected. This
52 * script provides the glue between the in-page javascript and the
53 * pipe back down to the Controller, translating navigator.internal
54 * function callbacks into messages sent back to the Controller.
55 *
56 * As a result, a navigator.internal function in the hosted popup or
57 * iframe can call back to the injected identity.js (doReady, doLogin,
58 * or doLogout). identity.js callbacks send messages back through the
59 * pipe to the Controller. The controller invokes the corresponding
60 * function on the Identity Service (doReady, doLogin, or doLogout).
61 * The IdentityService calls the corresponding callback for the
62 * correct Relying Party, which causes DOMIdentity to send a message
63 * up to the Relying Party through nsDOMIdentity
64 * (Identity:RP:Watch:OnLogin etc.), and finally, nsDOMIdentity
65 * receives these messages and calls the original callback that the
66 * Relying Party registered (navigator.id.watch(),
67 * navigator.id.request(), or navigator.id.logout()).
68 */
70 "use strict";
72 this.EXPORTED_SYMBOLS = ["SignInToWebsiteController"];
74 const Ci = Components.interfaces;
75 const Cu = Components.utils;
77 Cu.import("resource://gre/modules/Services.jsm");
78 Cu.import("resource://gre/modules/XPCOMUtils.jsm");
80 XPCOMUtils.defineLazyModuleGetter(this, "getRandomId",
81 "resource://gre/modules/identity/IdentityUtils.jsm");
83 XPCOMUtils.defineLazyModuleGetter(this, "IdentityService",
84 "resource://gre/modules/identity/MinimalIdentity.jsm");
86 XPCOMUtils.defineLazyModuleGetter(this, "Logger",
87 "resource://gre/modules/identity/LogUtils.jsm");
89 XPCOMUtils.defineLazyModuleGetter(this, "SystemAppProxy",
90 "resource://gre/modules/SystemAppProxy.jsm");
92 // The default persona uri; can be overwritten with toolkit.identity.uri pref.
93 // Do this if you want to repoint to a different service for testing.
94 // There's no point in setting up an observer to monitor the pref, as b2g prefs
95 // can only be overwritten when the profie is recreated. So just get the value
96 // on start-up.
97 let kPersonaUri = "https://firefoxos.persona.org";
98 try {
99 kPersonaUri = Services.prefs.getCharPref("toolkit.identity.uri");
100 } catch(noSuchPref) {
101 // stick with the default value
102 }
104 // JS shim that contains the callback functions that
105 // live within the identity UI provisioning frame.
106 const kIdentityShimFile = "chrome://b2g/content/identity.js";
108 // Type of MozChromeEvents to handle id dialogs.
109 const kOpenIdentityDialog = "id-dialog-open";
110 const kDoneIdentityDialog = "id-dialog-done";
111 const kCloseIdentityDialog = "id-dialog-close-iframe";
113 // Observer messages to communicate to shim
114 const kIdentityDelegateWatch = "identity-delegate-watch";
115 const kIdentityDelegateRequest = "identity-delegate-request";
116 const kIdentityDelegateLogout = "identity-delegate-logout";
117 const kIdentityDelegateFinished = "identity-delegate-finished";
118 const kIdentityDelegateReady = "identity-delegate-ready";
120 const kIdentityControllerDoMethod = "identity-controller-doMethod";
122 function log(...aMessageArgs) {
123 Logger.log.apply(Logger, ["SignInToWebsiteController"].concat(aMessageArgs));
124 }
126 log("persona uri =", kPersonaUri);
128 function sendChromeEvent(details) {
129 details.uri = kPersonaUri;
130 SystemAppProxy.dispatchEvent(details);
131 }
133 function Pipe() {
134 this._watchers = [];
135 }
137 Pipe.prototype = {
138 init: function pipe_init() {
139 Services.obs.addObserver(this, "identity-child-process-shutdown", false);
140 Services.obs.addObserver(this, "identity-controller-unwatch", false);
141 },
143 uninit: function pipe_uninit() {
144 Services.obs.removeObserver(this, "identity-child-process-shutdown");
145 Services.obs.removeObserver(this, "identity-controller-unwatch");
146 },
148 observe: function Pipe_observe(aSubject, aTopic, aData) {
149 let options = {};
150 if (aSubject) {
151 options = aSubject.wrappedJSObject;
152 }
153 switch (aTopic) {
154 case "identity-child-process-shutdown":
155 log("pipe removing watchers by message manager");
156 this._removeWatchers(null, options.messageManager);
157 break;
159 case "identity-controller-unwatch":
160 log("unwatching", options.id);
161 this._removeWatchers(options.id, options.messageManager);
162 break;
163 }
164 },
166 _addWatcher: function Pipe__addWatcher(aId, aMm) {
167 log("Adding watcher with id", aId);
168 for (let i = 0; i < this._watchers.length; ++i) {
169 let watcher = this._watchers[i];
170 if (this._watcher.id === aId) {
171 watcher.count++;
172 return;
173 }
174 }
175 this._watchers.push({id: aId, count: 1, mm: aMm});
176 },
178 _removeWatchers: function Pipe__removeWatcher(aId, aMm) {
179 let checkId = aId !== null;
180 let index = -1;
181 for (let i = 0; i < this._watchers.length; ++i) {
182 let watcher = this._watchers[i];
183 if (watcher.mm === aMm &&
184 (!checkId || (checkId && watcher.id === aId))) {
185 index = i;
186 break;
187 }
188 }
190 if (index !== -1) {
191 if (checkId) {
192 if (--(this._watchers[index].count) === 0) {
193 this._watchers.splice(index, 1);
194 }
195 } else {
196 this._watchers.splice(index, 1);
197 }
198 }
200 if (this._watchers.length === 0) {
201 log("No more watchers; clean up persona host iframe");
202 let detail = {
203 type: kCloseIdentityDialog
204 };
205 log('telling content to close the dialog');
206 // tell content to close the dialog
207 sendChromeEvent(detail);
208 }
209 },
211 communicate: function(aRpOptions, aContentOptions, aMessageCallback) {
212 let rpID = aRpOptions.id;
213 let rpMM = aRpOptions.mm;
214 if (rpMM) {
215 this._addWatcher(rpID, rpMM);
216 }
218 log("RP options:", aRpOptions, "\n content options:", aContentOptions);
220 // This content variable is injected into the scope of
221 // kIdentityShimFile, where it is used to access the BrowserID object
222 // and its internal API.
223 let mm = null;
224 let uuid = getRandomId();
225 let self = this;
227 function removeMessageListeners() {
228 if (mm) {
229 mm.removeMessageListener(kIdentityDelegateFinished, identityDelegateFinished);
230 mm.removeMessageListener(kIdentityControllerDoMethod, aMessageCallback);
231 }
232 }
234 function identityDelegateFinished() {
235 removeMessageListeners();
237 let detail = {
238 type: kDoneIdentityDialog,
239 showUI: aContentOptions.showUI || false,
240 id: kDoneIdentityDialog + "-" + uuid,
241 requestId: aRpOptions.id
242 };
243 log('received delegate finished; telling content to close the dialog');
244 sendChromeEvent(detail);
245 self._removeWatchers(rpID, rpMM);
246 }
248 SystemAppProxy.addEventListener("mozContentEvent", function getAssertion(evt) {
249 let msg = evt.detail;
250 if (!msg.id.match(uuid)) {
251 return;
252 }
254 switch (msg.id) {
255 case kOpenIdentityDialog + '-' + uuid:
256 if (msg.type === 'cancel') {
257 // The user closed the dialog. Clean up and call cancel.
258 SystemAppProxy.removeEventListener("mozContentEvent", getAssertion);
259 removeMessageListeners();
260 aMessageCallback({json: {method: "cancel"}});
261 } else {
262 // The window has opened. Inject the identity shim file containing
263 // the callbacks in the content script. This could be either the
264 // visible popup that the user interacts with, or it could be an
265 // invisible frame.
266 let frame = evt.detail.frame;
267 let frameLoader = frame.QueryInterface(Ci.nsIFrameLoaderOwner).frameLoader;
268 mm = frameLoader.messageManager;
269 try {
270 mm.loadFrameScript(kIdentityShimFile, true, true);
271 log("Loaded shim", kIdentityShimFile);
272 } catch (e) {
273 log("Error loading", kIdentityShimFile, "as a frame script:", e);
274 }
276 // There are two messages that the delegate can send back: a "do
277 // method" event, and a "finished" event. We pass the do-method
278 // events straight to the caller for interpretation and handling.
279 // If we receive a "finished" event, then the delegate is done, so
280 // we shut down the pipe and clean up.
281 mm.addMessageListener(kIdentityControllerDoMethod, aMessageCallback);
282 mm.addMessageListener(kIdentityDelegateFinished, identityDelegateFinished);
284 mm.sendAsyncMessage(aContentOptions.message, aRpOptions);
285 }
286 break;
288 case kDoneIdentityDialog + '-' + uuid:
289 // Received our assertion. The message manager callbacks will handle
290 // communicating back to the IDService. All we have to do is remove
291 // this listener.
292 SystemAppProxy.removeEventListener("mozContentEvent", getAssertion);
293 break;
295 default:
296 log("ERROR - Unexpected message: id=" + msg.id + ", type=" + msg.type + ", errorMsg=" + msg.errorMsg);
297 break;
298 }
300 });
302 // Tell content to open the identity iframe or trusty popup. The parameter
303 // showUI signals whether user interaction is needed. If it is, content will
304 // open a dialog; if not, a hidden iframe. In each case, BrowserID is
305 // available in the context.
306 let detail = {
307 type: kOpenIdentityDialog,
308 showUI: aContentOptions.showUI || false,
309 id: kOpenIdentityDialog + "-" + uuid,
310 requestId: aRpOptions.id
311 };
313 sendChromeEvent(detail);
314 }
316 };
318 /*
319 * The controller sits between the IdentityService used by DOMIdentity
320 * and a content process launches an (invisible) iframe or (visible)
321 * trusty UI. Using an injected js script (identity.js), the
322 * controller enables the content window to access the persona identity
323 * storage in the system cookie jar and send events back via the
324 * controller into IdentityService and DOM, and ultimately up to the
325 * Relying Party, which is open in a different window context.
326 */
327 this.SignInToWebsiteController = {
329 /*
330 * Initialize the controller. To use a different content communication pipe,
331 * such as when mocking it in tests, pass aOptions.pipe.
332 */
333 init: function SignInToWebsiteController_init(aOptions) {
334 aOptions = aOptions || {};
335 this.pipe = aOptions.pipe || new Pipe();
336 Services.obs.addObserver(this, "identity-controller-watch", false);
337 Services.obs.addObserver(this, "identity-controller-request", false);
338 Services.obs.addObserver(this, "identity-controller-logout", false);
339 },
341 uninit: function SignInToWebsiteController_uninit() {
342 Services.obs.removeObserver(this, "identity-controller-watch");
343 Services.obs.removeObserver(this, "identity-controller-request");
344 Services.obs.removeObserver(this, "identity-controller-logout");
345 },
347 observe: function SignInToWebsiteController_observe(aSubject, aTopic, aData) {
348 log("observe: received", aTopic, "with", aData, "for", aSubject);
349 let options = null;
350 if (aSubject) {
351 options = aSubject.wrappedJSObject;
352 }
353 switch (aTopic) {
354 case "identity-controller-watch":
355 this.doWatch(options);
356 break;
357 case "identity-controller-request":
358 this.doRequest(options);
359 break;
360 case "identity-controller-logout":
361 this.doLogout(options);
362 break;
363 default:
364 Logger.reportError("SignInToWebsiteController", "Unknown observer notification:", aTopic);
365 break;
366 }
367 },
369 /*
370 * options: method required - name of method to invoke
371 * assertion optional
372 */
373 _makeDoMethodCallback: function SignInToWebsiteController__makeDoMethodCallback(aRpId) {
374 return function SignInToWebsiteController_methodCallback(aOptions) {
375 let message = aOptions.json;
376 if (typeof message === 'string') {
377 message = JSON.parse(message);
378 }
380 switch (message.method) {
381 case "ready":
382 IdentityService.doReady(aRpId);
383 break;
385 case "login":
386 if (message._internalParams) {
387 IdentityService.doLogin(aRpId, message.assertion, message._internalParams);
388 } else {
389 IdentityService.doLogin(aRpId, message.assertion);
390 }
391 break;
393 case "logout":
394 IdentityService.doLogout(aRpId);
395 break;
397 case "cancel":
398 IdentityService.doCancel(aRpId);
399 break;
401 default:
402 log("WARNING: wonky method call:", message.method);
403 break;
404 }
405 };
406 },
408 doWatch: function SignInToWebsiteController_doWatch(aRpOptions) {
409 // dom prevents watch from being called twice
410 let contentOptions = {
411 message: kIdentityDelegateWatch,
412 showUI: false
413 };
414 this.pipe.communicate(aRpOptions, contentOptions,
415 this._makeDoMethodCallback(aRpOptions.id));
416 },
418 /**
419 * The website is requesting login so the user must choose an identity to use.
420 */
421 doRequest: function SignInToWebsiteController_doRequest(aRpOptions) {
422 log("doRequest", aRpOptions);
423 let contentOptions = {
424 message: kIdentityDelegateRequest,
425 showUI: true
426 };
427 this.pipe.communicate(aRpOptions, contentOptions,
428 this._makeDoMethodCallback(aRpOptions.id));
429 },
431 /*
432 *
433 */
434 doLogout: function SignInToWebsiteController_doLogout(aRpOptions) {
435 log("doLogout", aRpOptions);
436 let contentOptions = {
437 message: kIdentityDelegateLogout,
438 showUI: false
439 };
440 this.pipe.communicate(aRpOptions, contentOptions,
441 this._makeDoMethodCallback(aRpOptions.id));
442 }
444 };