/* -*- Mode: js2; js2-basic-offset: 2; indent-tabs-mode: nil; -*- */ /* vim: set ft=javascript ts=2 et sw=2 tw=80: */ /* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this file, * You can obtain one at http://mozilla.org/MPL/2.0/. */ "use strict"; const Cu = Components.utils; const Ci = Components.interfaces; const Cc = Components.classes; const Cr = Components.results; Cu.import("resource://gre/modules/XPCOMUtils.jsm"); Cu.import("resource://gre/modules/Services.jsm"); Cu.import("resource://gre/modules/identity/LogUtils.jsm"); Cu.import("resource://gre/modules/identity/IdentityStore.jsm"); const EXPORTED_SYMBOLS = ["RelyingParty"]; XPCOMUtils.defineLazyModuleGetter(this, "jwcrypto", "resource://gre/modules/identity/jwcrypto.jsm"); function log(...aMessageArgs) { Logger.log.apply(Logger, ["RP"].concat(aMessageArgs)); } function reportError(...aMessageArgs) { Logger.reportError.apply(Logger, ["RP"].concat(aMessageArgs)); } function IdentityRelyingParty() { // The store is a singleton shared among Identity, RelyingParty, and // IdentityProvider. The Identity module takes care of resetting // state in the _store on shutdown. this._store = IdentityStore; this.reset(); } IdentityRelyingParty.prototype = { QueryInterface: XPCOMUtils.generateQI([Ci.nsISupports, Ci.nsIObserver]), observe: function observe(aSubject, aTopic, aData) { switch (aTopic) { case "quit-application-granted": Services.obs.removeObserver(this, "quit-application-granted"); this.shutdown(); break; } }, reset: function RP_reset() { // Forget all documents that call in. (These are sometimes // referred to as callers.) this._rpFlows = {}; }, shutdown: function RP_shutdown() { this.reset(); Services.obs.removeObserver(this, "quit-application-granted"); }, /** * Register a listener for a given windowID as a result of a call to * navigator.id.watch(). * * @param aCaller * (Object) an object that represents the caller document, and * is expected to have properties: * - id (unique, e.g. uuid) * - loggedInEmail (string or null) * - origin (string) * * and a bunch of callbacks * - doReady() * - doLogin() * - doLogout() * - doError() * - doCancel() * */ watch: function watch(aRpCaller) { this._rpFlows[aRpCaller.id] = aRpCaller; let origin = aRpCaller.origin; let state = this._store.getLoginState(origin) || { isLoggedIn: false, email: null }; log("watch: rpId:", aRpCaller.id, "origin:", origin, "loggedInEmail:", aRpCaller.loggedInEmail, "loggedIn:", state.isLoggedIn, "email:", state.email); // If the user is already logged in, then there are three cases // to deal with: // // 1. the email is valid and unchanged: 'ready' // 2. the email is null: 'login'; 'ready' // 3. the email has changed: 'login'; 'ready' if (state.isLoggedIn) { if (state.email && aRpCaller.loggedInEmail === state.email) { this._notifyLoginStateChanged(aRpCaller.id, state.email); return aRpCaller.doReady(); } else if (aRpCaller.loggedInEmail === null) { // Generate assertion for existing login let options = {loggedInEmail: state.email, origin: origin}; return this._doLogin(aRpCaller, options); } else { // A loggedInEmail different from state.email has been specified. // Change login identity. let options = {loggedInEmail: state.email, origin: origin}; return this._doLogin(aRpCaller, options); } // If the user is not logged in, there are two cases: // // 1. a logged in email was provided: 'ready'; 'logout' // 2. not logged in, no email given: 'ready'; } else { if (aRpCaller.loggedInEmail) { return this._doLogout(aRpCaller, {origin: origin}); } else { return aRpCaller.doReady(); } } }, /** * A utility for watch() to set state and notify the dom * on login * * Note that this calls _getAssertion */ _doLogin: function _doLogin(aRpCaller, aOptions, aAssertion) { log("_doLogin: rpId:", aRpCaller.id, "origin:", aOptions.origin); let loginWithAssertion = function loginWithAssertion(assertion) { this._store.setLoginState(aOptions.origin, true, aOptions.loggedInEmail); this._notifyLoginStateChanged(aRpCaller.id, aOptions.loggedInEmail); aRpCaller.doLogin(assertion); aRpCaller.doReady(); }.bind(this); if (aAssertion) { loginWithAssertion(aAssertion); } else { this._getAssertion(aOptions, function gotAssertion(err, assertion) { if (err) { reportError("_doLogin:", "Failed to get assertion on login attempt:", err); this._doLogout(aRpCaller); } else { loginWithAssertion(assertion); } }.bind(this)); } }, /** * A utility for watch() to set state and notify the dom * on logout. */ _doLogout: function _doLogout(aRpCaller, aOptions) { log("_doLogout: rpId:", aRpCaller.id, "origin:", aOptions.origin); let state = this._store.getLoginState(aOptions.origin) || {}; state.isLoggedIn = false; this._notifyLoginStateChanged(aRpCaller.id, null); aRpCaller.doLogout(); aRpCaller.doReady(); }, /** * For use with login or logout, emit 'identity-login-state-changed' * * The notification will send the rp caller id in the properties, * and the email of the user in the message. * * @param aRpCallerId * (integer) The id of the RP caller * * @param aIdentity * (string) The email of the user whose login state has changed */ _notifyLoginStateChanged: function _notifyLoginStateChanged(aRpCallerId, aIdentity) { log("_notifyLoginStateChanged: rpId:", aRpCallerId, "identity:", aIdentity); let options = {rpId: aRpCallerId}; Services.obs.notifyObservers({wrappedJSObject: options}, "identity-login-state-changed", aIdentity); }, /** * Initiate a login with user interaction as a result of a call to * navigator.id.request(). * * @param aRPId * (integer) the id of the doc object obtained in .watch() * * @param aOptions * (Object) options including privacyPolicy, termsOfService */ request: function request(aRPId, aOptions) { log("request: rpId:", aRPId); // Notify UX to display identity picker. // Pass the doc id to UX so it can pass it back to us later. let options = {rpId: aRPId}; // Append URLs after resolving let rp = this._rpFlows[aRPId]; let baseURI = Services.io.newURI(rp.origin, null, null); for (let optionName of ["privacyPolicy", "termsOfService"]) { if (aOptions[optionName]) { options[optionName] = baseURI.resolve(aOptions[optionName]); } } Services.obs.notifyObservers({wrappedJSObject: options}, "identity-request", null); }, /** * Invoked when a user wishes to logout of a site (for instance, when clicking * on an in-content logout button). * * @param aRpCallerId * (integer) the id of the doc object obtained in .watch() * */ logout: function logout(aRpCallerId) { log("logout: RP caller id:", aRpCallerId); let rp = this._rpFlows[aRpCallerId]; if (rp && rp.origin) { let origin = rp.origin; log("logout: origin:", origin); this._doLogout(rp, {origin: origin}); } else { log("logout: no RP found with id:", aRpCallerId); } // We don't delete this._rpFlows[aRpCallerId], because // the user might log back in again. }, getDefaultEmailForOrigin: function getDefaultEmailForOrigin(aOrigin) { let identities = this.getIdentitiesForSite(aOrigin); let result = identities.lastUsed || null; log("getDefaultEmailForOrigin:", aOrigin, "->", result); return result; }, /** * Return the list of identities a user may want to use to login to aOrigin. */ getIdentitiesForSite: function getIdentitiesForSite(aOrigin) { let rv = { result: [] }; for (let id in this._store.getIdentities()) { rv.result.push(id); } let loginState = this._store.getLoginState(aOrigin); if (loginState && loginState.email) rv.lastUsed = loginState.email; return rv; }, /** * Obtain a BrowserID assertion with the specified characteristics. * * @param aCallback * (Function) Callback to be called with (err, assertion) where 'err' * can be an Error or NULL, and 'assertion' can be NULL or a valid * BrowserID assertion. If no callback is provided, an exception is * thrown. * * @param aOptions * (Object) An object that may contain the following properties: * * "audience" : The audience for which the assertion is to be * issued. If this property is not set an exception * will be thrown. * * Any properties not listed above will be ignored. */ _getAssertion: function _getAssertion(aOptions, aCallback) { let audience = aOptions.origin; let email = aOptions.loggedInEmail || this.getDefaultEmailForOrigin(audience); log("_getAssertion: audience:", audience, "email:", email); if (!audience) { throw "audience required for _getAssertion"; } // We might not have any identity info for this email if (!this._store.fetchIdentity(email)) { this._store.addIdentity(email, null, null); } let cert = this._store.fetchIdentity(email)['cert']; if (cert) { this._generateAssertion(audience, email, function generatedAssertion(err, assertion) { if (err) { log("ERROR: _getAssertion:", err); } log("_getAssertion: generated assertion:", assertion); return aCallback(err, assertion); }); } }, /** * Generate an assertion, including provisioning via IdP if necessary, * but no user interaction, so if provisioning fails, aCallback is invoked * with an error. * * @param aAudience * (string) web origin * * @param aIdentity * (string) the email we're logging in with * * @param aCallback * (function) callback to invoke on completion * with first-positional parameter the error. */ _generateAssertion: function _generateAssertion(aAudience, aIdentity, aCallback) { log("_generateAssertion: audience:", aAudience, "identity:", aIdentity); let id = this._store.fetchIdentity(aIdentity); if (! (id && id.cert)) { let errStr = "Cannot generate an assertion without a certificate"; log("ERROR: _generateAssertion:", errStr); aCallback(errStr); return; } let kp = id.keyPair; if (!kp) { let errStr = "Cannot generate an assertion without a keypair"; log("ERROR: _generateAssertion:", errStr); aCallback(errStr); return; } jwcrypto.generateAssertion(id.cert, kp, aAudience, aCallback); }, /** * Clean up references to the provisioning flow for the specified RP. */ _cleanUpProvisionFlow: function RP_cleanUpProvisionFlow(aRPId, aProvId) { let rp = this._rpFlows[aRPId]; if (rp) { delete rp['provId']; } else { log("Error: Couldn't delete provision flow ", aProvId, " for RP ", aRPId); } }, }; let RelyingParty = new IdentityRelyingParty();