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 /* -*- Mode: js2; js2-basic-offset: 2; indent-tabs-mode: nil; -*- */
2 /* vim: set ft=javascript ts=2 et sw=2 tw=80: */
3 /* This Source Code Form is subject to the terms of the Mozilla Public
4 * License, v. 2.0. If a copy of the MPL was not distributed with this file,
5 * You can obtain one at http://mozilla.org/MPL/2.0/. */
7 "use strict";
9 const Cu = Components.utils;
10 const Ci = Components.interfaces;
11 const Cc = Components.classes;
12 const Cr = Components.results;
14 Cu.import("resource://gre/modules/XPCOMUtils.jsm");
15 Cu.import("resource://gre/modules/Services.jsm");
16 Cu.import("resource://gre/modules/identity/LogUtils.jsm");
17 Cu.import("resource://gre/modules/identity/IdentityStore.jsm");
19 this.EXPORTED_SYMBOLS = ["RelyingParty"];
21 XPCOMUtils.defineLazyModuleGetter(this, "objectCopy",
22 "resource://gre/modules/identity/IdentityUtils.jsm");
24 XPCOMUtils.defineLazyModuleGetter(this,
25 "jwcrypto",
26 "resource://gre/modules/identity/jwcrypto.jsm");
28 function log(...aMessageArgs) {
29 Logger.log.apply(Logger, ["RP"].concat(aMessageArgs));
30 }
31 function reportError(...aMessageArgs) {
32 Logger.reportError.apply(Logger, ["RP"].concat(aMessageArgs));
33 }
35 function IdentityRelyingParty() {
36 // The store is a singleton shared among Identity, RelyingParty, and
37 // IdentityProvider. The Identity module takes care of resetting
38 // state in the _store on shutdown.
39 this._store = IdentityStore;
41 this.reset();
42 }
44 IdentityRelyingParty.prototype = {
45 QueryInterface: XPCOMUtils.generateQI([Ci.nsISupports, Ci.nsIObserver]),
47 observe: function observe(aSubject, aTopic, aData) {
48 switch (aTopic) {
49 case "quit-application-granted":
50 Services.obs.removeObserver(this, "quit-application-granted");
51 this.shutdown();
52 break;
54 }
55 },
57 reset: function RP_reset() {
58 // Forget all documents that call in. (These are sometimes
59 // referred to as callers.)
60 this._rpFlows = {};
61 },
63 shutdown: function RP_shutdown() {
64 this.reset();
65 Services.obs.removeObserver(this, "quit-application-granted");
66 },
68 /**
69 * Register a listener for a given windowID as a result of a call to
70 * navigator.id.watch().
71 *
72 * @param aCaller
73 * (Object) an object that represents the caller document, and
74 * is expected to have properties:
75 * - id (unique, e.g. uuid)
76 * - loggedInUser (string or null)
77 * - origin (string)
78 *
79 * and a bunch of callbacks
80 * - doReady()
81 * - doLogin()
82 * - doLogout()
83 * - doError()
84 * - doCancel()
85 *
86 */
87 watch: function watch(aRpCaller) {
88 this._rpFlows[aRpCaller.id] = aRpCaller;
89 let origin = aRpCaller.origin;
90 let state = this._store.getLoginState(origin) || { isLoggedIn: false, email: null };
92 log("watch: rpId:", aRpCaller.id,
93 "origin:", origin,
94 "loggedInUser:", aRpCaller.loggedInUser,
95 "loggedIn:", state.isLoggedIn,
96 "email:", state.email);
98 // If the user is already logged in, then there are three cases
99 // to deal with:
100 //
101 // 1. the email is valid and unchanged: 'ready'
102 // 2. the email is null: 'login'; 'ready'
103 // 3. the email has changed: 'login'; 'ready'
104 if (state.isLoggedIn) {
105 if (state.email && aRpCaller.loggedInUser === state.email) {
106 this._notifyLoginStateChanged(aRpCaller.id, state.email);
107 return aRpCaller.doReady();
109 } else if (aRpCaller.loggedInUser === null) {
110 // Generate assertion for existing login
111 let options = {loggedInUser: state.email, origin: origin};
112 return this._doLogin(aRpCaller, options);
114 } else {
115 // A loggedInUser different from state.email has been specified.
116 // Change login identity.
118 let options = {loggedInUser: state.email, origin: origin};
119 return this._doLogin(aRpCaller, options);
120 }
122 // If the user is not logged in, there are two cases:
123 //
124 // 1. a logged in email was provided: 'ready'; 'logout'
125 // 2. not logged in, no email given: 'ready';
127 } else {
128 if (aRpCaller.loggedInUser) {
129 return this._doLogout(aRpCaller, {origin: origin});
131 } else {
132 return aRpCaller.doReady();
133 }
134 }
135 },
137 /**
138 * A utility for watch() to set state and notify the dom
139 * on login
140 *
141 * Note that this calls _getAssertion
142 */
143 _doLogin: function _doLogin(aRpCaller, aOptions, aAssertion) {
144 log("_doLogin: rpId:", aRpCaller.id, "origin:", aOptions.origin);
146 let loginWithAssertion = function loginWithAssertion(assertion) {
147 this._store.setLoginState(aOptions.origin, true, aOptions.loggedInUser);
148 this._notifyLoginStateChanged(aRpCaller.id, aOptions.loggedInUser);
149 aRpCaller.doLogin(assertion);
150 aRpCaller.doReady();
151 }.bind(this);
153 if (aAssertion) {
154 loginWithAssertion(aAssertion);
155 } else {
156 this._getAssertion(aOptions, function gotAssertion(err, assertion) {
157 if (err) {
158 reportError("_doLogin:", "Failed to get assertion on login attempt:", err);
159 this._doLogout(aRpCaller);
160 } else {
161 loginWithAssertion(assertion);
162 }
163 }.bind(this));
164 }
165 },
167 /**
168 * A utility for watch() to set state and notify the dom
169 * on logout.
170 */
171 _doLogout: function _doLogout(aRpCaller, aOptions) {
172 log("_doLogout: rpId:", aRpCaller.id, "origin:", aOptions.origin);
174 let state = this._store.getLoginState(aOptions.origin) || {};
176 state.isLoggedIn = false;
177 this._notifyLoginStateChanged(aRpCaller.id, null);
179 aRpCaller.doLogout();
180 aRpCaller.doReady();
181 },
183 /**
184 * For use with login or logout, emit 'identity-login-state-changed'
185 *
186 * The notification will send the rp caller id in the properties,
187 * and the email of the user in the message.
188 *
189 * @param aRpCallerId
190 * (integer) The id of the RP caller
191 *
192 * @param aIdentity
193 * (string) The email of the user whose login state has changed
194 */
195 _notifyLoginStateChanged: function _notifyLoginStateChanged(aRpCallerId, aIdentity) {
196 log("_notifyLoginStateChanged: rpId:", aRpCallerId, "identity:", aIdentity);
198 let options = {rpId: aRpCallerId};
199 Services.obs.notifyObservers({wrappedJSObject: options},
200 "identity-login-state-changed",
201 aIdentity);
202 },
204 /**
205 * Initiate a login with user interaction as a result of a call to
206 * navigator.id.request().
207 *
208 * @param aRPId
209 * (integer) the id of the doc object obtained in .watch()
210 *
211 * @param aOptions
212 * (Object) options including privacyPolicy, termsOfService
213 */
214 request: function request(aRPId, aOptions) {
215 log("request: rpId:", aRPId);
216 let rp = this._rpFlows[aRPId];
218 // Notify UX to display identity picker.
219 // Pass the doc id to UX so it can pass it back to us later.
220 let options = {rpId: aRPId, origin: rp.origin};
221 objectCopy(aOptions, options);
223 // Append URLs after resolving
224 let baseURI = Services.io.newURI(rp.origin, null, null);
225 for (let optionName of ["privacyPolicy", "termsOfService"]) {
226 if (aOptions[optionName]) {
227 options[optionName] = baseURI.resolve(aOptions[optionName]);
228 }
229 }
231 Services.obs.notifyObservers({wrappedJSObject: options}, "identity-request", null);
232 },
234 /**
235 * Invoked when a user wishes to logout of a site (for instance, when clicking
236 * on an in-content logout button).
237 *
238 * @param aRpCallerId
239 * (integer) the id of the doc object obtained in .watch()
240 *
241 */
242 logout: function logout(aRpCallerId) {
243 log("logout: RP caller id:", aRpCallerId);
244 let rp = this._rpFlows[aRpCallerId];
245 if (rp && rp.origin) {
246 let origin = rp.origin;
247 log("logout: origin:", origin);
248 this._doLogout(rp, {origin: origin});
249 } else {
250 log("logout: no RP found with id:", aRpCallerId);
251 }
252 // We don't delete this._rpFlows[aRpCallerId], because
253 // the user might log back in again.
254 },
256 getDefaultEmailForOrigin: function getDefaultEmailForOrigin(aOrigin) {
257 let identities = this.getIdentitiesForSite(aOrigin);
258 let result = identities.lastUsed || null;
259 log("getDefaultEmailForOrigin:", aOrigin, "->", result);
260 return result;
261 },
263 /**
264 * Return the list of identities a user may want to use to login to aOrigin.
265 */
266 getIdentitiesForSite: function getIdentitiesForSite(aOrigin) {
267 let rv = { result: [] };
268 for (let id in this._store.getIdentities()) {
269 rv.result.push(id);
270 }
271 let loginState = this._store.getLoginState(aOrigin);
272 if (loginState && loginState.email)
273 rv.lastUsed = loginState.email;
274 return rv;
275 },
277 /**
278 * Obtain a BrowserID assertion with the specified characteristics.
279 *
280 * @param aCallback
281 * (Function) Callback to be called with (err, assertion) where 'err'
282 * can be an Error or NULL, and 'assertion' can be NULL or a valid
283 * BrowserID assertion. If no callback is provided, an exception is
284 * thrown.
285 *
286 * @param aOptions
287 * (Object) An object that may contain the following properties:
288 *
289 * "audience" : The audience for which the assertion is to be
290 * issued. If this property is not set an exception
291 * will be thrown.
292 *
293 * Any properties not listed above will be ignored.
294 */
295 _getAssertion: function _getAssertion(aOptions, aCallback) {
296 let audience = aOptions.origin;
297 let email = aOptions.loggedInUser || this.getDefaultEmailForOrigin(audience);
298 log("_getAssertion: audience:", audience, "email:", email);
299 if (!audience) {
300 throw "audience required for _getAssertion";
301 }
303 // We might not have any identity info for this email
304 if (!this._store.fetchIdentity(email)) {
305 this._store.addIdentity(email, null, null);
306 }
308 let cert = this._store.fetchIdentity(email)['cert'];
309 if (cert) {
310 this._generateAssertion(audience, email, function generatedAssertion(err, assertion) {
311 if (err) {
312 log("ERROR: _getAssertion:", err);
313 }
314 log("_getAssertion: generated assertion:", assertion);
315 return aCallback(err, assertion);
316 });
317 }
318 },
320 /**
321 * Generate an assertion, including provisioning via IdP if necessary,
322 * but no user interaction, so if provisioning fails, aCallback is invoked
323 * with an error.
324 *
325 * @param aAudience
326 * (string) web origin
327 *
328 * @param aIdentity
329 * (string) the email we're logging in with
330 *
331 * @param aCallback
332 * (function) callback to invoke on completion
333 * with first-positional parameter the error.
334 */
335 _generateAssertion: function _generateAssertion(aAudience, aIdentity, aCallback) {
336 log("_generateAssertion: audience:", aAudience, "identity:", aIdentity);
338 let id = this._store.fetchIdentity(aIdentity);
339 if (! (id && id.cert)) {
340 let errStr = "Cannot generate an assertion without a certificate";
341 log("ERROR: _generateAssertion:", errStr);
342 aCallback(errStr);
343 return;
344 }
346 let kp = id.keyPair;
348 if (!kp) {
349 let errStr = "Cannot generate an assertion without a keypair";
350 log("ERROR: _generateAssertion:", errStr);
351 aCallback(errStr);
352 return;
353 }
355 jwcrypto.generateAssertion(id.cert, kp, aAudience, aCallback);
356 },
358 /**
359 * Clean up references to the provisioning flow for the specified RP.
360 */
361 _cleanUpProvisionFlow: function RP_cleanUpProvisionFlow(aRPId, aProvId) {
362 let rp = this._rpFlows[aRPId];
363 if (rp) {
364 delete rp['provId'];
365 } else {
366 log("Error: Couldn't delete provision flow ", aProvId, " for RP ", aRPId);
367 }
368 },
370 };
372 this.RelyingParty = new IdentityRelyingParty();