|
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/. */ |
|
4 |
|
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 */ |
|
69 |
|
70 "use strict"; |
|
71 |
|
72 this.EXPORTED_SYMBOLS = ["SignInToWebsiteController"]; |
|
73 |
|
74 const Ci = Components.interfaces; |
|
75 const Cu = Components.utils; |
|
76 |
|
77 Cu.import("resource://gre/modules/Services.jsm"); |
|
78 Cu.import("resource://gre/modules/XPCOMUtils.jsm"); |
|
79 |
|
80 XPCOMUtils.defineLazyModuleGetter(this, "getRandomId", |
|
81 "resource://gre/modules/identity/IdentityUtils.jsm"); |
|
82 |
|
83 XPCOMUtils.defineLazyModuleGetter(this, "IdentityService", |
|
84 "resource://gre/modules/identity/MinimalIdentity.jsm"); |
|
85 |
|
86 XPCOMUtils.defineLazyModuleGetter(this, "Logger", |
|
87 "resource://gre/modules/identity/LogUtils.jsm"); |
|
88 |
|
89 XPCOMUtils.defineLazyModuleGetter(this, "SystemAppProxy", |
|
90 "resource://gre/modules/SystemAppProxy.jsm"); |
|
91 |
|
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 } |
|
103 |
|
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"; |
|
107 |
|
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"; |
|
112 |
|
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"; |
|
119 |
|
120 const kIdentityControllerDoMethod = "identity-controller-doMethod"; |
|
121 |
|
122 function log(...aMessageArgs) { |
|
123 Logger.log.apply(Logger, ["SignInToWebsiteController"].concat(aMessageArgs)); |
|
124 } |
|
125 |
|
126 log("persona uri =", kPersonaUri); |
|
127 |
|
128 function sendChromeEvent(details) { |
|
129 details.uri = kPersonaUri; |
|
130 SystemAppProxy.dispatchEvent(details); |
|
131 } |
|
132 |
|
133 function Pipe() { |
|
134 this._watchers = []; |
|
135 } |
|
136 |
|
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 }, |
|
142 |
|
143 uninit: function pipe_uninit() { |
|
144 Services.obs.removeObserver(this, "identity-child-process-shutdown"); |
|
145 Services.obs.removeObserver(this, "identity-controller-unwatch"); |
|
146 }, |
|
147 |
|
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; |
|
158 |
|
159 case "identity-controller-unwatch": |
|
160 log("unwatching", options.id); |
|
161 this._removeWatchers(options.id, options.messageManager); |
|
162 break; |
|
163 } |
|
164 }, |
|
165 |
|
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 }, |
|
177 |
|
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 } |
|
189 |
|
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 } |
|
199 |
|
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 }, |
|
210 |
|
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 } |
|
217 |
|
218 log("RP options:", aRpOptions, "\n content options:", aContentOptions); |
|
219 |
|
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; |
|
226 |
|
227 function removeMessageListeners() { |
|
228 if (mm) { |
|
229 mm.removeMessageListener(kIdentityDelegateFinished, identityDelegateFinished); |
|
230 mm.removeMessageListener(kIdentityControllerDoMethod, aMessageCallback); |
|
231 } |
|
232 } |
|
233 |
|
234 function identityDelegateFinished() { |
|
235 removeMessageListeners(); |
|
236 |
|
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 } |
|
247 |
|
248 SystemAppProxy.addEventListener("mozContentEvent", function getAssertion(evt) { |
|
249 let msg = evt.detail; |
|
250 if (!msg.id.match(uuid)) { |
|
251 return; |
|
252 } |
|
253 |
|
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 } |
|
275 |
|
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); |
|
283 |
|
284 mm.sendAsyncMessage(aContentOptions.message, aRpOptions); |
|
285 } |
|
286 break; |
|
287 |
|
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; |
|
294 |
|
295 default: |
|
296 log("ERROR - Unexpected message: id=" + msg.id + ", type=" + msg.type + ", errorMsg=" + msg.errorMsg); |
|
297 break; |
|
298 } |
|
299 |
|
300 }); |
|
301 |
|
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 }; |
|
312 |
|
313 sendChromeEvent(detail); |
|
314 } |
|
315 |
|
316 }; |
|
317 |
|
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 = { |
|
328 |
|
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 }, |
|
340 |
|
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 }, |
|
346 |
|
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 }, |
|
368 |
|
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 } |
|
379 |
|
380 switch (message.method) { |
|
381 case "ready": |
|
382 IdentityService.doReady(aRpId); |
|
383 break; |
|
384 |
|
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; |
|
392 |
|
393 case "logout": |
|
394 IdentityService.doLogout(aRpId); |
|
395 break; |
|
396 |
|
397 case "cancel": |
|
398 IdentityService.doCancel(aRpId); |
|
399 break; |
|
400 |
|
401 default: |
|
402 log("WARNING: wonky method call:", message.method); |
|
403 break; |
|
404 } |
|
405 }; |
|
406 }, |
|
407 |
|
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 }, |
|
417 |
|
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 }, |
|
430 |
|
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 } |
|
443 |
|
444 }; |