diff options
Diffstat (limited to 'toolkit/identity')
44 files changed, 6011 insertions, 0 deletions
diff --git a/toolkit/identity/FirefoxAccounts.jsm b/toolkit/identity/FirefoxAccounts.jsm new file mode 100644 index 000000000..54efaf3b4 --- /dev/null +++ b/toolkit/identity/FirefoxAccounts.jsm @@ -0,0 +1,320 @@ +/* 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"; + +this.EXPORTED_SYMBOLS = ["FirefoxAccounts"]; + +const {classes: Cc, interfaces: Ci, utils: Cu} = Components; + +Cu.import("resource://gre/modules/Log.jsm"); +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); +Cu.import("resource://gre/modules/Services.jsm"); +Cu.import("resource://gre/modules/identity/LogUtils.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, "objectCopy", + "resource://gre/modules/identity/IdentityUtils.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, "makeMessageObject", + "resource://gre/modules/identity/IdentityUtils.jsm"); + +// loglevel preference should be one of: "FATAL", "ERROR", "WARN", "INFO", +// "CONFIG", "DEBUG", "TRACE" or "ALL". We will be logging error messages by +// default. +const PREF_LOG_LEVEL = "identity.fxaccounts.loglevel"; +try { + this.LOG_LEVEL = + Services.prefs.getPrefType(PREF_LOG_LEVEL) == Ci.nsIPrefBranch.PREF_STRING + && Services.prefs.getCharPref(PREF_LOG_LEVEL); +} catch (e) { + this.LOG_LEVEL = Log.Level.Error; +} + +var log = Log.repository.getLogger("Identity.FxAccounts"); +log.level = LOG_LEVEL; +log.addAppender(new Log.ConsoleAppender(new Log.BasicFormatter())); + +#ifdef MOZ_B2G +XPCOMUtils.defineLazyModuleGetter(this, "FxAccountsManager", + "resource://gre/modules/FxAccountsManager.jsm", + "FxAccountsManager"); +Cu.import("resource://gre/modules/FxAccountsCommon.js"); +#else +log.warn("The FxAccountsManager is only functional in B2G at this time."); +var FxAccountsManager = null; +var ONVERIFIED_NOTIFICATION = null; +var ONLOGIN_NOTIFICATION = null; +var ONLOGOUT_NOTIFICATION = null; +#endif + +function FxAccountsService() { + Services.obs.addObserver(this, "quit-application-granted", false); + if (ONVERIFIED_NOTIFICATION) { + Services.obs.addObserver(this, ONVERIFIED_NOTIFICATION, false); + Services.obs.addObserver(this, ONLOGIN_NOTIFICATION, false); + Services.obs.addObserver(this, ONLOGOUT_NOTIFICATION, false); + } + + // Maintain interface parity with Identity.jsm and MinimalIdentity.jsm + this.RP = this; + + this._rpFlows = new Map(); + + // Enable us to mock FxAccountsManager service in testing + this.fxAccountsManager = FxAccountsManager; +} + +FxAccountsService.prototype = { + QueryInterface: XPCOMUtils.generateQI([Ci.nsISupports, Ci.nsIObserver]), + + observe: function observe(aSubject, aTopic, aData) { + switch (aTopic) { + case null: + // Guard against matching null ON*_NOTIFICATION + break; + case ONVERIFIED_NOTIFICATION: + log.debug("Received " + ONVERIFIED_NOTIFICATION + "; firing request()s"); + for (let [rpId,] of this._rpFlows) { + this.request(rpId); + } + break; + case ONLOGIN_NOTIFICATION: + log.debug("Received " + ONLOGIN_NOTIFICATION + "; doLogin()s fired"); + for (let [rpId,] of this._rpFlows) { + this.request(rpId); + } + break; + case ONLOGOUT_NOTIFICATION: + log.debug("Received " + ONLOGOUT_NOTIFICATION + "; doLogout()s fired"); + for (let [rpId,] of this._rpFlows) { + this.doLogout(rpId); + } + break; + case "quit-application-granted": + Services.obs.removeObserver(this, "quit-application-granted"); + if (ONVERIFIED_NOTIFICATION) { + Services.obs.removeObserver(this, ONVERIFIED_NOTIFICATION); + Services.obs.removeObserver(this, ONLOGIN_NOTIFICATION); + Services.obs.removeObserver(this, ONLOGOUT_NOTIFICATION); + } + break; + } + }, + + cleanupRPRequest: function(aRp) { + aRp.pendingRequest = false; + this._rpFlows.set(aRp.id, aRp); + }, + + /** + * Register a listener for a given windowID as a result of a call to + * navigator.id.watch(). + * + * @param aRPCaller + * (Object) an object that represents the caller document, and + * is expected to have properties: + * - id (unique, e.g. uuid) + * - origin (string) + * + * and a bunch of callbacks + * - doReady() + * - doLogin() + * - doLogout() + * - doError() + * - doCancel() + * + */ + watch: function watch(aRpCaller) { + this._rpFlows.set(aRpCaller.id, aRpCaller); + log.debug("watch: " + aRpCaller.id); + log.debug("Current rp flows: " + this._rpFlows.size); + + // Log the user in, if possible, and then call ready(). + let runnable = { + run: () => { + this.fxAccountsManager.getAssertion(aRpCaller.audience, + aRpCaller.principal, + { silent:true }).then( + data => { + if (data) { + this.doLogin(aRpCaller.id, data); + } else { + this.doLogout(aRpCaller.id); + } + this.doReady(aRpCaller.id); + }, + error => { + log.error("get silent assertion failed: " + JSON.stringify(error)); + this.doError(aRpCaller.id, error); + } + ); + } + }; + Services.tm.currentThread.dispatch(runnable, + Ci.nsIThread.DISPATCH_NORMAL); + }, + + /** + * Delete the flow when the screen is unloaded + */ + unwatch: function(aRpCallerId, aTargetMM) { + log.debug("unwatching: " + aRpCallerId); + this._rpFlows.delete(aRpCallerId); + }, + + /** + * 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) { + aOptions = aOptions || {}; + let rp = this._rpFlows.get(aRPId); + if (!rp) { + log.error("request() called before watch()"); + return; + } + + // We check if we already have a pending request for this RP and in that + // case we just bail out. We don't want duplicated onlogin or oncancel + // events. + if (rp.pendingRequest) { + log.debug("request() already called"); + return; + } + + // Otherwise, we set the RP flow with the pending request flag. + rp.pendingRequest = true; + this._rpFlows.set(rp.id, rp); + + let options = makeMessageObject(rp); + objectCopy(aOptions, options); + + log.debug("get assertion for " + rp.audience); + + this.fxAccountsManager.getAssertion(rp.audience, rp.principal, options) + .then( + data => { + log.debug("got assertion for " + rp.audience + ": " + data); + this.doLogin(aRPId, data); + }, + error => { + log.debug("get assertion failed: " + JSON.stringify(error)); + // Cancellation is passed through an error channel; here we reroute. + if ((error.error && (error.error.details == "DIALOG_CLOSED_BY_USER")) || + (error.details == "DIALOG_CLOSED_BY_USER")) { + return this.doCancel(aRPId); + } + this.doError(aRPId, error); + } + ) + .then( + () => { + this.cleanupRPRequest(rp); + } + ) + .catch( + () => { + this.cleanupRPRequest(rp); + } + ); + }, + + /** + * 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) { + // XXX Bug 945363 - Resolve the SSO story for FXA and implement + // logout accordingly. + // + // For now, it makes no sense to logout from a specific RP in + // Firefox Accounts, so just directly call the logout callback. + if (!this._rpFlows.has(aRpCallerId)) { + log.error("logout() called before watch()"); + return; + } + + // Call logout() on the next tick + let runnable = { + run: () => { + this.fxAccountsManager.signOut().then(() => { + this.doLogout(aRpCallerId); + }); + } + }; + Services.tm.currentThread.dispatch(runnable, + Ci.nsIThread.DISPATCH_NORMAL); + }, + + childProcessShutdown: function childProcessShutdown(messageManager) { + for (let [key,] of this._rpFlows) { + if (this._rpFlows.get(key)._mm === messageManager) { + this._rpFlows.delete(key); + } + } + }, + + doLogin: function doLogin(aRpCallerId, aAssertion) { + let rp = this._rpFlows.get(aRpCallerId); + if (!rp) { + log.warn("doLogin found no rp to go with callerId " + aRpCallerId); + return; + } + + rp.doLogin(aAssertion); + }, + + doLogout: function doLogout(aRpCallerId) { + let rp = this._rpFlows.get(aRpCallerId); + if (!rp) { + log.warn("doLogout found no rp to go with callerId " + aRpCallerId); + return; + } + + rp.doLogout(); + }, + + doReady: function doReady(aRpCallerId) { + let rp = this._rpFlows.get(aRpCallerId); + if (!rp) { + log.warn("doReady found no rp to go with callerId " + aRpCallerId); + return; + } + + rp.doReady(); + }, + + doCancel: function doCancel(aRpCallerId) { + let rp = this._rpFlows.get(aRpCallerId); + if (!rp) { + log.warn("doCancel found no rp to go with callerId " + aRpCallerId); + return; + } + + rp.doCancel(); + }, + + doError: function doError(aRpCallerId, aError) { + let rp = this._rpFlows.get(aRpCallerId); + if (!rp) { + log.warn("doError found no rp to go with callerId " + aRpCallerId); + return; + } + + rp.doError(aError); + } +}; + +this.FirefoxAccounts = new FxAccountsService(); + diff --git a/toolkit/identity/Identity.jsm b/toolkit/identity/Identity.jsm new file mode 100644 index 000000000..a27b7c93d --- /dev/null +++ b/toolkit/identity/Identity.jsm @@ -0,0 +1,309 @@ +/* -*- js-indent-level: 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"; + +this.EXPORTED_SYMBOLS = ["IdentityService"]; + +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"); +Cu.import("resource://gre/modules/identity/RelyingParty.jsm"); +Cu.import("resource://gre/modules/identity/IdentityProvider.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, + "jwcrypto", + "resource://gre/modules/identity/jwcrypto.jsm"); + +function log(...aMessageArgs) { + Logger.log.apply(Logger, ["core"].concat(aMessageArgs)); +} +function reportError(...aMessageArgs) { + Logger.reportError.apply(Logger, ["core"].concat(aMessageArgs)); +} + +function IDService() { + Services.obs.addObserver(this, "quit-application-granted", false); + Services.obs.addObserver(this, "identity-auth-complete", false); + + this._store = IdentityStore; + this.RP = RelyingParty; + this.IDP = IdentityProvider; +} + +IDService.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; + case "identity-auth-complete": + if (!aSubject || !aSubject.wrappedJSObject) + break; + let subject = aSubject.wrappedJSObject; + log("Auth complete:", aSubject.wrappedJSObject); + // We have authenticated in order to provision an identity. + // So try again. + this.selectIdentity(subject.rpId, subject.identity); + break; + } + }, + + reset: function reset() { + // Explicitly call reset() on our RP and IDP classes. + // This is here to make testing easier. When the + // quit-application-granted signal is emitted, reset() will be + // called here, on RP, on IDP, and on the store. So you don't + // need to use this :) + this._store.reset(); + this.RP.reset(); + this.IDP.reset(); + }, + + shutdown: function shutdown() { + log("shutdown"); + Services.obs.removeObserver(this, "identity-auth-complete"); + // try to prevent abort/crash during shutdown of mochitest-browser2... + try { + Services.obs.removeObserver(this, "quit-application-granted"); + } catch (e) {} + }, + + /** + * Parse an email into username and domain if it is valid, else return null + */ + parseEmail: function parseEmail(email) { + var match = email.match(/^([^@]+)@([^@^/]+.[a-z]+)$/); + if (match) { + return { + username: match[1], + domain: match[2] + }; + } + return null; + }, + + /** + * The UX wants to add a new identity + * often followed by selectIdentity() + * + * @param aIdentity + * (string) the email chosen for login + */ + addIdentity: function addIdentity(aIdentity) { + if (this._store.fetchIdentity(aIdentity) === null) { + this._store.addIdentity(aIdentity, null, null); + } + }, + + /** + * The UX comes back and calls selectIdentity once the user has picked + * an identity. + * + * @param aRPId + * (integer) the id of the doc object obtained in .watch() and + * passed to the UX component. + * + * @param aIdentity + * (string) the email chosen for login + */ + selectIdentity: function selectIdentity(aRPId, aIdentity) { + log("selectIdentity: RP id:", aRPId, "identity:", aIdentity); + + // Get the RP that was stored when watch() was invoked. + let rp = this.RP._rpFlows[aRPId]; + if (!rp) { + reportError("selectIdentity", "Invalid RP id: ", aRPId); + return; + } + + // It's possible that we are in the process of provisioning an + // identity. + let provId = rp.provId; + + let rpLoginOptions = { + loggedInUser: aIdentity, + origin: rp.origin + }; + log("selectIdentity: provId:", provId, "origin:", rp.origin); + + // Once we have a cert, and once the user is authenticated with the + // IdP, we can generate an assertion and deliver it to the doc. + let self = this; + this.RP._generateAssertion(rp.origin, aIdentity, function hadReadyAssertion(err, assertion) { + if (!err && assertion) { + self.RP._doLogin(rp, rpLoginOptions, assertion); + return; + + } + // Need to provision an identity first. Begin by discovering + // the user's IdP. + self._discoverIdentityProvider(aIdentity, function gotIDP(err, idpParams) { + if (err) { + rp.doError(err); + return; + } + + // The idpParams tell us where to go to provision and authenticate + // the identity. + self.IDP._provisionIdentity(aIdentity, idpParams, provId, function gotID(err, aProvId) { + + // Provision identity may have created a new provision flow + // for us. To make it easier to relate provision flows with + // RP callers, we cross index the two here. + rp.provId = aProvId; + self.IDP._provisionFlows[aProvId].rpId = aRPId; + + // At this point, we already have a cert. If the user is also + // already authenticated with the IdP, then we can try again + // to generate an assertion and login. + if (err) { + // We are not authenticated. If we have already tried to + // authenticate and failed, then this is a "hard fail" and + // we give up. Otherwise we try to authenticate with the + // IdP. + + if (self.IDP._provisionFlows[aProvId].didAuthentication) { + self.IDP._cleanUpProvisionFlow(aProvId); + self.RP._cleanUpProvisionFlow(aRPId, aProvId); + log("ERROR: selectIdentity: authentication hard fail"); + rp.doError("Authentication fail."); + return; + } + // Try to authenticate with the IdP. Note that we do + // not clean up the provision flow here. We will continue + // to use it. + self.IDP._doAuthentication(aProvId, idpParams); + return; + } + + // Provisioning flows end when a certificate has been registered. + // Thus IdentityProvider's registerCertificate() cleans up the + // current provisioning flow. We only do this here on error. + self.RP._generateAssertion(rp.origin, aIdentity, function gotAssertion(err, assertion) { + if (err) { + rp.doError(err); + return; + } + self.RP._doLogin(rp, rpLoginOptions, assertion); + self.RP._cleanUpProvisionFlow(aRPId, aProvId); + return; + }); + }); + }); + }); + }, + + // methods for chrome and add-ons + + /** + * Discover the IdP for an identity + * + * @param aIdentity + * (string) the email we're logging in with + * + * @param aCallback + * (function) callback to invoke on completion + * with first-positional parameter the error. + */ + _discoverIdentityProvider: function _discoverIdentityProvider(aIdentity, aCallback) { + // XXX bug 767610 - validate email address call + // When that is available, we can remove this custom parser + var parsedEmail = this.parseEmail(aIdentity); + if (parsedEmail === null) { + aCallback("Could not parse email: " + aIdentity); + return; + } + log("_discoverIdentityProvider: identity:", aIdentity, "domain:", parsedEmail.domain); + + this._fetchWellKnownFile(parsedEmail.domain, function fetchedWellKnown(err, idpParams) { + // idpParams includes the pk, authorization url, and + // provisioning url. + + // XXX bug 769861 follow any authority delegations + // if no well-known at any point in the delegation + // fall back to browserid.org as IdP + return aCallback(err, idpParams); + }); + }, + + /** + * Fetch the well-known file from the domain. + * + * @param aDomain + * + * @param aScheme + * (string) (optional) Protocol to use. Default is https. + * This is necessary because we are unable to test + * https. + * + * @param aCallback + * + */ + _fetchWellKnownFile: function _fetchWellKnownFile(aDomain, aCallback, aScheme='https') { + // XXX bug 769854 make tests https and remove aScheme option + let url = aScheme + '://' + aDomain + "/.well-known/browserid"; + log("_fetchWellKnownFile:", url); + + // this appears to be a more successful way to get at xmlhttprequest (which supposedly will close with a window + let req = Cc["@mozilla.org/xmlextras/xmlhttprequest;1"] + .createInstance(Ci.nsIXMLHttpRequest); + + // XXX bug 769865 gracefully handle being off-line + // XXX bug 769866 decide on how to handle redirects + req.open("GET", url, true); + req.responseType = "json"; + req.mozBackgroundRequest = true; + req.onload = function _fetchWellKnownFile_onload() { + if (req.status < 200 || req.status >= 400) { + log("_fetchWellKnownFile", url, ": server returned status:", req.status); + return aCallback("Error"); + } + try { + let idpParams = req.response; + + // Verify that the IdP returned a valid configuration + if (! (idpParams.provisioning && + idpParams.authentication && + idpParams['public-key'])) { + let errStr= "Invalid well-known file from: " + aDomain; + log("_fetchWellKnownFile:", errStr); + return aCallback(errStr); + } + + let callbackObj = { + domain: aDomain, + idpParams: idpParams, + }; + log("_fetchWellKnownFile result: ", callbackObj); + // Yay. Valid IdP configuration for the domain. + return aCallback(null, callbackObj); + + } catch (err) { + reportError("_fetchWellKnownFile", "Bad configuration from", aDomain, err); + return aCallback(err.toString()); + } + }; + req.onerror = function _fetchWellKnownFile_onerror() { + log("_fetchWellKnownFile", "ERROR:", req.status, req.statusText); + log("ERROR: _fetchWellKnownFile:", err); + return aCallback("Error"); + }; + req.send(null); + }, + +}; + +this.IdentityService = new IDService(); diff --git a/toolkit/identity/IdentityCryptoService.cpp b/toolkit/identity/IdentityCryptoService.cpp new file mode 100644 index 000000000..cafe07999 --- /dev/null +++ b/toolkit/identity/IdentityCryptoService.cpp @@ -0,0 +1,571 @@ +/* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* vim: set 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/. */ + +#include "nsIIdentityCryptoService.h" +#include "mozilla/ModuleUtils.h" +#include "nsServiceManagerUtils.h" +#include "nsNSSShutDown.h" +#include "nsIThread.h" +#include "nsThreadUtils.h" +#include "nsCOMPtr.h" +#include "nsProxyRelease.h" +#include "nsString.h" +#include "mozilla/ArrayUtils.h" // ArrayLength +#include "mozilla/Base64.h" +#include "ScopedNSSTypes.h" +#include "NSSErrorsService.h" + +#include "nss.h" +#include "pk11pub.h" +#include "secmod.h" +#include "secerr.h" +#include "keyhi.h" +#include "cryptohi.h" + +#include <limits.h> + +using namespace mozilla; + +namespace { + +void +HexEncode(const SECItem * it, nsACString & result) +{ + const char * digits = "0123456789ABCDEF"; + result.SetCapacity((it->len * 2) + 1); + result.SetLength(it->len * 2); + char * p = result.BeginWriting(); + for (unsigned int i = 0; i < it->len; ++i) { + *p++ = digits[it->data[i] >> 4]; + *p++ = digits[it->data[i] & 0x0f]; + } +} + +#define DSA_KEY_TYPE_STRING (NS_LITERAL_CSTRING("DS160")) +#define RSA_KEY_TYPE_STRING (NS_LITERAL_CSTRING("RS256")) + +class KeyPair : public nsIIdentityKeyPair, public nsNSSShutDownObject +{ +public: + NS_DECL_THREADSAFE_ISUPPORTS + NS_DECL_NSIIDENTITYKEYPAIR + + KeyPair(SECKEYPrivateKey* aPrivateKey, SECKEYPublicKey* aPublicKey, + nsIEventTarget* aOperationThread); + +private: + ~KeyPair() + { + nsNSSShutDownPreventionLock locker; + if (isAlreadyShutDown()) { + return; + } + destructorSafeDestroyNSSReference(); + shutdown(ShutdownCalledFrom::Object); + } + + void virtualDestroyNSSReference() override + { + destructorSafeDestroyNSSReference(); + } + + void destructorSafeDestroyNSSReference() + { + SECKEY_DestroyPrivateKey(mPrivateKey); + mPrivateKey = nullptr; + SECKEY_DestroyPublicKey(mPublicKey); + mPublicKey = nullptr; + } + + SECKEYPrivateKey * mPrivateKey; + SECKEYPublicKey * mPublicKey; + nsCOMPtr<nsIEventTarget> mThread; + + KeyPair(const KeyPair &) = delete; + void operator=(const KeyPair &) = delete; +}; + +NS_IMPL_ISUPPORTS(KeyPair, nsIIdentityKeyPair) + +class KeyGenRunnable : public Runnable, public nsNSSShutDownObject +{ +public: + NS_DECL_NSIRUNNABLE + + KeyGenRunnable(KeyType keyType, nsIIdentityKeyGenCallback * aCallback, + nsIEventTarget* aOperationThread); + +private: + ~KeyGenRunnable() + { + nsNSSShutDownPreventionLock locker; + if (isAlreadyShutDown()) { + return; + } + destructorSafeDestroyNSSReference(); + shutdown(ShutdownCalledFrom::Object); + } + + virtual void virtualDestroyNSSReference() override + { + destructorSafeDestroyNSSReference(); + } + + void destructorSafeDestroyNSSReference() + { + } + + const KeyType mKeyType; // in + nsMainThreadPtrHandle<nsIIdentityKeyGenCallback> mCallback; // in + nsresult mRv; // out + nsCOMPtr<nsIIdentityKeyPair> mKeyPair; // out + nsCOMPtr<nsIEventTarget> mThread; + + KeyGenRunnable(const KeyGenRunnable &) = delete; + void operator=(const KeyGenRunnable &) = delete; +}; + +class SignRunnable : public Runnable, public nsNSSShutDownObject +{ +public: + NS_DECL_NSIRUNNABLE + + SignRunnable(const nsACString & textToSign, SECKEYPrivateKey * privateKey, + nsIIdentitySignCallback * aCallback); + +private: + ~SignRunnable() + { + nsNSSShutDownPreventionLock locker; + if (isAlreadyShutDown()) { + return; + } + destructorSafeDestroyNSSReference(); + shutdown(ShutdownCalledFrom::Object); + } + + void virtualDestroyNSSReference() override + { + destructorSafeDestroyNSSReference(); + } + + void destructorSafeDestroyNSSReference() + { + SECKEY_DestroyPrivateKey(mPrivateKey); + mPrivateKey = nullptr; + } + + const nsCString mTextToSign; // in + SECKEYPrivateKey* mPrivateKey; // in + nsMainThreadPtrHandle<nsIIdentitySignCallback> mCallback; // in + nsresult mRv; // out + nsCString mSignature; // out + +private: + SignRunnable(const SignRunnable &) = delete; + void operator=(const SignRunnable &) = delete; +}; + +class IdentityCryptoService final : public nsIIdentityCryptoService +{ +public: + NS_DECL_THREADSAFE_ISUPPORTS + NS_DECL_NSIIDENTITYCRYPTOSERVICE + + IdentityCryptoService() { } + nsresult Init() + { + nsresult rv; + nsCOMPtr<nsISupports> dummyUsedToEnsureNSSIsInitialized + = do_GetService("@mozilla.org/psm;1", &rv); + NS_ENSURE_SUCCESS(rv, rv); + + nsCOMPtr<nsIThread> thread; + rv = NS_NewNamedThread("IdentityCrypto", getter_AddRefs(thread)); + NS_ENSURE_SUCCESS(rv, rv); + + mThread = thread.forget(); + + return NS_OK; + } + +private: + ~IdentityCryptoService() { } + IdentityCryptoService(const KeyPair &) = delete; + void operator=(const IdentityCryptoService &) = delete; + + nsCOMPtr<nsIEventTarget> mThread; +}; + +NS_IMPL_ISUPPORTS(IdentityCryptoService, nsIIdentityCryptoService) + +NS_IMETHODIMP +IdentityCryptoService::GenerateKeyPair( + const nsACString & keyTypeString, nsIIdentityKeyGenCallback * callback) +{ + KeyType keyType; + if (keyTypeString.Equals(RSA_KEY_TYPE_STRING)) { + keyType = rsaKey; + } else if (keyTypeString.Equals(DSA_KEY_TYPE_STRING)) { + keyType = dsaKey; + } else { + return NS_ERROR_UNEXPECTED; + } + + nsCOMPtr<nsIRunnable> r = new KeyGenRunnable(keyType, callback, mThread); + nsresult rv = mThread->Dispatch(r.forget(), NS_DISPATCH_NORMAL); + NS_ENSURE_SUCCESS(rv, rv); + + return NS_OK; +} + +NS_IMETHODIMP +IdentityCryptoService::Base64UrlEncode(const nsACString & utf8Input, + nsACString & result) +{ + return Base64URLEncode(utf8Input.Length(), + reinterpret_cast<const uint8_t*>(utf8Input.BeginReading()), + Base64URLEncodePaddingPolicy::Include, result); +} + +KeyPair::KeyPair(SECKEYPrivateKey * privateKey, SECKEYPublicKey * publicKey, + nsIEventTarget* operationThread) + : mPrivateKey(privateKey) + , mPublicKey(publicKey) + , mThread(operationThread) +{ + MOZ_ASSERT(!NS_IsMainThread()); +} + +NS_IMETHODIMP +KeyPair::GetHexRSAPublicKeyExponent(nsACString & result) +{ + MOZ_ASSERT(NS_IsMainThread()); + NS_ENSURE_TRUE(mPublicKey, NS_ERROR_NOT_AVAILABLE); + NS_ENSURE_TRUE(mPublicKey->keyType == rsaKey, NS_ERROR_NOT_AVAILABLE); + HexEncode(&mPublicKey->u.rsa.publicExponent, result); + return NS_OK; +} + +NS_IMETHODIMP +KeyPair::GetHexRSAPublicKeyModulus(nsACString & result) +{ + MOZ_ASSERT(NS_IsMainThread()); + NS_ENSURE_TRUE(mPublicKey, NS_ERROR_NOT_AVAILABLE); + NS_ENSURE_TRUE(mPublicKey->keyType == rsaKey, NS_ERROR_NOT_AVAILABLE); + HexEncode(&mPublicKey->u.rsa.modulus, result); + return NS_OK; +} + +NS_IMETHODIMP +KeyPair::GetHexDSAPrime(nsACString & result) +{ + MOZ_ASSERT(NS_IsMainThread()); + NS_ENSURE_TRUE(mPublicKey, NS_ERROR_NOT_AVAILABLE); + NS_ENSURE_TRUE(mPublicKey->keyType == dsaKey, NS_ERROR_NOT_AVAILABLE); + HexEncode(&mPublicKey->u.dsa.params.prime, result); + return NS_OK; +} + +NS_IMETHODIMP +KeyPair::GetHexDSASubPrime(nsACString & result) +{ + MOZ_ASSERT(NS_IsMainThread()); + NS_ENSURE_TRUE(mPublicKey, NS_ERROR_NOT_AVAILABLE); + NS_ENSURE_TRUE(mPublicKey->keyType == dsaKey, NS_ERROR_NOT_AVAILABLE); + HexEncode(&mPublicKey->u.dsa.params.subPrime, result); + return NS_OK; +} + +NS_IMETHODIMP +KeyPair::GetHexDSAGenerator(nsACString & result) +{ + MOZ_ASSERT(NS_IsMainThread()); + NS_ENSURE_TRUE(mPublicKey, NS_ERROR_NOT_AVAILABLE); + NS_ENSURE_TRUE(mPublicKey->keyType == dsaKey, NS_ERROR_NOT_AVAILABLE); + HexEncode(&mPublicKey->u.dsa.params.base, result); + return NS_OK; +} + +NS_IMETHODIMP +KeyPair::GetHexDSAPublicValue(nsACString & result) +{ + MOZ_ASSERT(NS_IsMainThread()); + NS_ENSURE_TRUE(mPublicKey, NS_ERROR_NOT_AVAILABLE); + NS_ENSURE_TRUE(mPublicKey->keyType == dsaKey, NS_ERROR_NOT_AVAILABLE); + HexEncode(&mPublicKey->u.dsa.publicValue, result); + return NS_OK; +} + +NS_IMETHODIMP +KeyPair::GetKeyType(nsACString & result) +{ + MOZ_ASSERT(NS_IsMainThread()); + NS_ENSURE_TRUE(mPublicKey, NS_ERROR_NOT_AVAILABLE); + + switch (mPublicKey->keyType) { + case rsaKey: result = RSA_KEY_TYPE_STRING; return NS_OK; + case dsaKey: result = DSA_KEY_TYPE_STRING; return NS_OK; + default: return NS_ERROR_UNEXPECTED; + } +} + +NS_IMETHODIMP +KeyPair::Sign(const nsACString & textToSign, + nsIIdentitySignCallback* callback) +{ + MOZ_ASSERT(NS_IsMainThread()); + nsCOMPtr<nsIRunnable> r = new SignRunnable(textToSign, mPrivateKey, + callback); + + return mThread->Dispatch(r, NS_DISPATCH_NORMAL); +} + +KeyGenRunnable::KeyGenRunnable(KeyType keyType, + nsIIdentityKeyGenCallback * callback, + nsIEventTarget* operationThread) + : mKeyType(keyType) + , mCallback(new nsMainThreadPtrHolder<nsIIdentityKeyGenCallback>(callback)) + , mRv(NS_ERROR_NOT_INITIALIZED) + , mThread(operationThread) +{ +} + +MOZ_MUST_USE nsresult +GenerateKeyPair(PK11SlotInfo * slot, + SECKEYPrivateKey ** privateKey, + SECKEYPublicKey ** publicKey, + CK_MECHANISM_TYPE mechanism, + void * params) +{ + *publicKey = nullptr; + *privateKey = PK11_GenerateKeyPair(slot, mechanism, params, publicKey, + PR_FALSE /*isPerm*/, + PR_TRUE /*isSensitive*/, + nullptr /*&pwdata*/); + if (!*privateKey) { + MOZ_ASSERT(!*publicKey); + return mozilla::psm::GetXPCOMFromNSSError(PR_GetError()); + } + if (!*publicKey) { + SECKEY_DestroyPrivateKey(*privateKey); + *privateKey = nullptr; + MOZ_CRASH("PK11_GnerateKeyPair returned private key without public key"); + } + + return NS_OK; +} + + +MOZ_MUST_USE nsresult +GenerateRSAKeyPair(PK11SlotInfo * slot, + SECKEYPrivateKey ** privateKey, + SECKEYPublicKey ** publicKey) +{ + MOZ_ASSERT(!NS_IsMainThread()); + + PK11RSAGenParams rsaParams; + rsaParams.keySizeInBits = 2048; + rsaParams.pe = 0x10001; + return GenerateKeyPair(slot, privateKey, publicKey, CKM_RSA_PKCS_KEY_PAIR_GEN, + &rsaParams); +} + +MOZ_MUST_USE nsresult +GenerateDSAKeyPair(PK11SlotInfo * slot, + SECKEYPrivateKey ** privateKey, + SECKEYPublicKey ** publicKey) +{ + MOZ_ASSERT(!NS_IsMainThread()); + + // XXX: These could probably be static const arrays, but this way we avoid + // compiler warnings and also we avoid having to worry much about whether the + // functions that take these inputs will (unexpectedly) modify them. + + // Using NIST parameters. Some other BrowserID components require that these + // exact parameters are used. + uint8_t P[] = { + 0xFF,0x60,0x04,0x83,0xDB,0x6A,0xBF,0xC5,0xB4,0x5E,0xAB,0x78, + 0x59,0x4B,0x35,0x33,0xD5,0x50,0xD9,0xF1,0xBF,0x2A,0x99,0x2A, + 0x7A,0x8D,0xAA,0x6D,0xC3,0x4F,0x80,0x45,0xAD,0x4E,0x6E,0x0C, + 0x42,0x9D,0x33,0x4E,0xEE,0xAA,0xEF,0xD7,0xE2,0x3D,0x48,0x10, + 0xBE,0x00,0xE4,0xCC,0x14,0x92,0xCB,0xA3,0x25,0xBA,0x81,0xFF, + 0x2D,0x5A,0x5B,0x30,0x5A,0x8D,0x17,0xEB,0x3B,0xF4,0xA0,0x6A, + 0x34,0x9D,0x39,0x2E,0x00,0xD3,0x29,0x74,0x4A,0x51,0x79,0x38, + 0x03,0x44,0xE8,0x2A,0x18,0xC4,0x79,0x33,0x43,0x8F,0x89,0x1E, + 0x22,0xAE,0xEF,0x81,0x2D,0x69,0xC8,0xF7,0x5E,0x32,0x6C,0xB7, + 0x0E,0xA0,0x00,0xC3,0xF7,0x76,0xDF,0xDB,0xD6,0x04,0x63,0x8C, + 0x2E,0xF7,0x17,0xFC,0x26,0xD0,0x2E,0x17 + }; + + uint8_t Q[] = { + 0xE2,0x1E,0x04,0xF9,0x11,0xD1,0xED,0x79,0x91,0x00,0x8E,0xCA, + 0xAB,0x3B,0xF7,0x75,0x98,0x43,0x09,0xC3 + }; + + uint8_t G[] = { + 0xC5,0x2A,0x4A,0x0F,0xF3,0xB7,0xE6,0x1F,0xDF,0x18,0x67,0xCE, + 0x84,0x13,0x83,0x69,0xA6,0x15,0x4F,0x4A,0xFA,0x92,0x96,0x6E, + 0x3C,0x82,0x7E,0x25,0xCF,0xA6,0xCF,0x50,0x8B,0x90,0xE5,0xDE, + 0x41,0x9E,0x13,0x37,0xE0,0x7A,0x2E,0x9E,0x2A,0x3C,0xD5,0xDE, + 0xA7,0x04,0xD1,0x75,0xF8,0xEB,0xF6,0xAF,0x39,0x7D,0x69,0xE1, + 0x10,0xB9,0x6A,0xFB,0x17,0xC7,0xA0,0x32,0x59,0x32,0x9E,0x48, + 0x29,0xB0,0xD0,0x3B,0xBC,0x78,0x96,0xB1,0x5B,0x4A,0xDE,0x53, + 0xE1,0x30,0x85,0x8C,0xC3,0x4D,0x96,0x26,0x9A,0xA8,0x90,0x41, + 0xF4,0x09,0x13,0x6C,0x72,0x42,0xA3,0x88,0x95,0xC9,0xD5,0xBC, + 0xCA,0xD4,0xF3,0x89,0xAF,0x1D,0x7A,0x4B,0xD1,0x39,0x8B,0xD0, + 0x72,0xDF,0xFA,0x89,0x62,0x33,0x39,0x7A + }; + + static_assert(MOZ_ARRAY_LENGTH(P) == 1024 / CHAR_BIT, "bad DSA P"); + static_assert(MOZ_ARRAY_LENGTH(Q) == 160 / CHAR_BIT, "bad DSA Q"); + static_assert(MOZ_ARRAY_LENGTH(G) == 1024 / CHAR_BIT, "bad DSA G"); + + PQGParams pqgParams = { + nullptr /*arena*/, + { siBuffer, P, static_cast<unsigned int>(mozilla::ArrayLength(P)) }, + { siBuffer, Q, static_cast<unsigned int>(mozilla::ArrayLength(Q)) }, + { siBuffer, G, static_cast<unsigned int>(mozilla::ArrayLength(G)) } + }; + + return GenerateKeyPair(slot, privateKey, publicKey, CKM_DSA_KEY_PAIR_GEN, + &pqgParams); +} + +NS_IMETHODIMP +KeyGenRunnable::Run() +{ + if (!NS_IsMainThread()) { + nsNSSShutDownPreventionLock locker; + if (isAlreadyShutDown()) { + mRv = NS_ERROR_NOT_AVAILABLE; + } else { + // We always want to use the internal slot for BrowserID; in particular, + // we want to avoid smartcard slots. + PK11SlotInfo *slot = PK11_GetInternalSlot(); + if (!slot) { + mRv = NS_ERROR_UNEXPECTED; + } else { + SECKEYPrivateKey *privk = nullptr; + SECKEYPublicKey *pubk = nullptr; + + switch (mKeyType) { + case rsaKey: + mRv = GenerateRSAKeyPair(slot, &privk, &pubk); + break; + case dsaKey: + mRv = GenerateDSAKeyPair(slot, &privk, &pubk); + break; + default: + MOZ_CRASH("unknown key type"); + } + + PK11_FreeSlot(slot); + + if (NS_SUCCEEDED(mRv)) { + MOZ_ASSERT(privk); + MOZ_ASSERT(pubk); + // mKeyPair will take over ownership of privk and pubk + mKeyPair = new KeyPair(privk, pubk, mThread); + } + } + } + + NS_DispatchToMainThread(this); + } else { + // Back on Main Thread + (void) mCallback->GenerateKeyPairFinished(mRv, mKeyPair); + } + return NS_OK; +} + +SignRunnable::SignRunnable(const nsACString & aText, + SECKEYPrivateKey * privateKey, + nsIIdentitySignCallback * aCallback) + : mTextToSign(aText) + , mPrivateKey(SECKEY_CopyPrivateKey(privateKey)) + , mCallback(new nsMainThreadPtrHolder<nsIIdentitySignCallback>(aCallback)) + , mRv(NS_ERROR_NOT_INITIALIZED) +{ +} + +NS_IMETHODIMP +SignRunnable::Run() +{ + if (!NS_IsMainThread()) { + nsNSSShutDownPreventionLock locker; + if (isAlreadyShutDown()) { + mRv = NS_ERROR_NOT_AVAILABLE; + } else { + // We need the output in PKCS#11 format, not DER encoding, so we must use + // PK11_HashBuf and PK11_Sign instead of SEC_SignData. + + SECItem sig = { siBuffer, nullptr, 0 }; + int sigLength = PK11_SignatureLen(mPrivateKey); + if (sigLength <= 0) { + mRv = mozilla::psm::GetXPCOMFromNSSError(PR_GetError()); + } else if (!SECITEM_AllocItem(nullptr, &sig, sigLength)) { + mRv = mozilla::psm::GetXPCOMFromNSSError(PR_GetError()); + } else { + uint8_t hash[32]; // big enough for SHA-1 or SHA-256 + SECOidTag hashAlg = mPrivateKey->keyType == dsaKey ? SEC_OID_SHA1 + : SEC_OID_SHA256; + SECItem hashItem = { siBuffer, hash, + hashAlg == SEC_OID_SHA1 ? 20u : 32u }; + + mRv = MapSECStatus(PK11_HashBuf(hashAlg, hash, + const_cast<uint8_t*>(reinterpret_cast<const uint8_t *>( + mTextToSign.get())), + mTextToSign.Length())); + if (NS_SUCCEEDED(mRv)) { + mRv = MapSECStatus(PK11_Sign(mPrivateKey, &sig, &hashItem)); + } + if (NS_SUCCEEDED(mRv)) { + mRv = Base64URLEncode(sig.len, sig.data, + Base64URLEncodePaddingPolicy::Include, + mSignature); + } + SECITEM_FreeItem(&sig, false); + } + } + + NS_DispatchToMainThread(this); + } else { + // Back on Main Thread + (void) mCallback->SignFinished(mRv, mSignature); + } + + return NS_OK; +} + +// XPCOM module registration + +NS_GENERIC_FACTORY_CONSTRUCTOR_INIT(IdentityCryptoService, Init) + +#define NS_IDENTITYCRYPTOSERVICE_CID \ + {0xbea13a3a, 0x44e8, 0x4d7f, {0xa0, 0xa2, 0x2c, 0x67, 0xf8, 0x4e, 0x3a, 0x97}} + +NS_DEFINE_NAMED_CID(NS_IDENTITYCRYPTOSERVICE_CID); + +const mozilla::Module::CIDEntry kCIDs[] = { + { &kNS_IDENTITYCRYPTOSERVICE_CID, false, nullptr, IdentityCryptoServiceConstructor }, + { nullptr } +}; + +const mozilla::Module::ContractIDEntry kContracts[] = { + { "@mozilla.org/identity/crypto-service;1", &kNS_IDENTITYCRYPTOSERVICE_CID }, + { nullptr } +}; + +const mozilla::Module kModule = { + mozilla::Module::kVersion, + kCIDs, + kContracts +}; + +} // unnamed namespace + +NSMODULE_DEFN(identity) = &kModule; diff --git a/toolkit/identity/IdentityProvider.jsm b/toolkit/identity/IdentityProvider.jsm new file mode 100644 index 000000000..11529bfba --- /dev/null +++ b/toolkit/identity/IdentityProvider.jsm @@ -0,0 +1,496 @@ +/* -*- js-indent-level: 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/Sandbox.jsm"); + +this.EXPORTED_SYMBOLS = ["IdentityProvider"]; +const FALLBACK_PROVIDER = "browserid.org"; + +XPCOMUtils.defineLazyModuleGetter(this, + "jwcrypto", + "resource://gre/modules/identity/jwcrypto.jsm"); + +function log(...aMessageArgs) { + Logger.log.apply(Logger, ["IDP"].concat(aMessageArgs)); +} +function reportError(...aMessageArgs) { + Logger.reportError.apply(Logger, ["IDP"].concat(aMessageArgs)); +} + + +function IdentityProviderService() { + XPCOMUtils.defineLazyModuleGetter(this, + "_store", + "resource://gre/modules/identity/IdentityStore.jsm", + "IdentityStore"); + + this.reset(); +} + +IdentityProviderService.prototype = { + QueryInterface: XPCOMUtils.generateQI([Ci.nsISupports, Ci.nsIObserver]), + _sandboxConfigured: false, + + observe: function observe(aSubject, aTopic, aData) { + switch (aTopic) { + case "quit-application-granted": + Services.obs.removeObserver(this, "quit-application-granted"); + this.shutdown(); + break; + } + }, + + reset: function IDP_reset() { + // Clear the provisioning flows. Provision flows contain an + // identity, idpParams (how to reach the IdP to provision and + // authenticate), a callback (a completion callback for when things + // are done), and a provisioningFrame (which is the provisioning + // sandbox). Additionally, two callbacks will be attached: + // beginProvisioningCallback and genKeyPairCallback. + this._provisionFlows = {}; + + // Clear the authentication flows. Authentication flows attach + // to provision flows. In the process of provisioning an id, it + // may be necessary to authenticate with an IdP. The authentication + // flow maintains the state of that authentication process. + this._authenticationFlows = {}; + }, + + getProvisionFlow: function getProvisionFlow(aProvId, aErrBack) { + let provFlow = this._provisionFlows[aProvId]; + if (provFlow) { + return provFlow; + } + + let err = "No provisioning flow found with id " + aProvId; + log("ERROR:", err); + if (typeof aErrBack === 'function') { + aErrBack(err); + } + + return undefined; + }, + + shutdown: function RP_shutdown() { + this.reset(); + + if (this._sandboxConfigured) { + // Tear down message manager listening on the hidden window + Cu.import("resource://gre/modules/DOMIdentity.jsm"); + DOMIdentity._configureMessages(Services.appShell.hiddenDOMWindow, false); + this._sandboxConfigured = false; + } + + Services.obs.removeObserver(this, "quit-application-granted"); + }, + + get securityLevel() { + return 1; + }, + + get certDuration() { + switch (this.securityLevel) { + default: + return 3600; + } + }, + + /** + * Provision an Identity + * + * @param aIdentity + * (string) the email we're logging in with + * + * @param aIDPParams + * (object) parameters of the IdP + * + * @param aCallback + * (function) callback to invoke on completion + * with first-positional parameter the error. + */ + _provisionIdentity: function _provisionIdentity(aIdentity, aIDPParams, aProvId, aCallback) { + let provPath = aIDPParams.idpParams.provisioning; + let url = Services.io.newURI("https://" + aIDPParams.domain, null, null).resolve(provPath); + log("_provisionIdentity: identity:", aIdentity, "url:", url); + + // If aProvId is not null, then we already have a flow + // with a sandbox. Otherwise, get a sandbox and create a + // new provision flow. + + if (aProvId) { + // Re-use an existing sandbox + log("_provisionIdentity: re-using sandbox in provisioning flow with id:", aProvId); + this._provisionFlows[aProvId].provisioningSandbox.reload(); + + } else { + this._createProvisioningSandbox(url, function createdSandbox(aSandbox) { + // create a provisioning flow, using the sandbox id, and + // stash callback associated with this provisioning workflow. + + let provId = aSandbox.id; + this._provisionFlows[provId] = { + identity: aIdentity, + idpParams: aIDPParams, + securityLevel: this.securityLevel, + provisioningSandbox: aSandbox, + callback: function doCallback(aErr) { + aCallback(aErr, provId); + }, + }; + + log("_provisionIdentity: Created sandbox and provisioning flow with id:", provId); + // XXX bug 769862 - provisioning flow should timeout after N seconds + + }.bind(this)); + } + }, + + // DOM Methods + /** + * the provisioning iframe sandbox has called navigator.id.beginProvisioning() + * + * @param aCaller + * (object) the iframe sandbox caller with all callbacks and + * other information. Callbacks include: + * - doBeginProvisioningCallback(id, duration_s) + * - doGenKeyPairCallback(pk) + */ + beginProvisioning: function beginProvisioning(aCaller) { + log("beginProvisioning:", aCaller.id); + + // Expect a flow for this caller already to be underway. + let provFlow = this.getProvisionFlow(aCaller.id, aCaller.doError); + + // keep the caller object around + provFlow.caller = aCaller; + + let identity = provFlow.identity; + let frame = provFlow.provisioningFrame; + + // Determine recommended length of cert. + let duration = this.certDuration; + + // Make a record that we have begun provisioning. This is required + // for genKeyPair. + provFlow.didBeginProvisioning = true; + + // Let the sandbox know to invoke the callback to beginProvisioning with + // the identity and cert length. + return aCaller.doBeginProvisioningCallback(identity, duration); + }, + + /** + * the provisioning iframe sandbox has called + * navigator.id.raiseProvisioningFailure() + * + * @param aProvId + * (int) the identifier of the provisioning flow tied to that sandbox + * @param aReason + */ + raiseProvisioningFailure: function raiseProvisioningFailure(aProvId, aReason) { + reportError("Provisioning failure", aReason); + + // look up the provisioning caller and its callback + let provFlow = this.getProvisionFlow(aProvId); + + // Sandbox is deleted in _cleanUpProvisionFlow in case we re-use it. + + // This may be either a "soft" or "hard" fail. If it's a + // soft fail, we'll flow through setAuthenticationFlow, where + // the provision flow data will be copied into a new auth + // flow. If it's a hard fail, then the callback will be + // responsible for cleaning up the now defunct provision flow. + + // invoke the callback with an error. + provFlow.callback(aReason); + }, + + /** + * When navigator.id.genKeyPair is called from provisioning iframe sandbox. + * Generates a keypair for the current user being provisioned. + * + * @param aProvId + * (int) the identifier of the provisioning caller tied to that sandbox + * + * It is an error to call genKeypair without receiving the callback for + * the beginProvisioning() call first. + */ + genKeyPair: function genKeyPair(aProvId) { + // Look up the provisioning caller and make sure it's valid. + let provFlow = this.getProvisionFlow(aProvId); + + if (!provFlow.didBeginProvisioning) { + let errStr = "ERROR: genKeyPair called before beginProvisioning"; + log(errStr); + provFlow.callback(errStr); + return; + } + + // Ok generate a keypair + jwcrypto.generateKeyPair(jwcrypto.ALGORITHMS.DS160, function gkpCb(err, kp) { + log("in gkp callback"); + if (err) { + log("ERROR: genKeyPair:", err); + provFlow.callback(err); + return; + } + + provFlow.kp = kp; + + // Serialize the publicKey of the keypair and send it back to the + // sandbox. + log("genKeyPair: generated keypair for provisioning flow with id:", aProvId); + provFlow.caller.doGenKeyPairCallback(provFlow.kp.serializedPublicKey); + }.bind(this)); + }, + + /** + * When navigator.id.registerCertificate is called from provisioning iframe + * sandbox. + * + * Sets the certificate for the user for which a certificate was requested + * via a preceding call to beginProvisioning (and genKeypair). + * + * @param aProvId + * (integer) the identifier of the provisioning caller tied to that + * sandbox + * + * @param aCert + * (String) A JWT representing the signed certificate for the user + * being provisioned, provided by the IdP. + */ + registerCertificate: function registerCertificate(aProvId, aCert) { + log("registerCertificate:", aProvId, aCert); + + // look up provisioning caller, make sure it's valid. + let provFlow = this.getProvisionFlow(aProvId); + + if (!provFlow.caller) { + reportError("registerCertificate", "No provision flow or caller"); + return; + } + if (!provFlow.kp) { + let errStr = "Cannot register a certificate without a keypair"; + reportError("registerCertificate", errStr); + provFlow.callback(errStr); + return; + } + + // store the keypair and certificate just provided in IDStore. + this._store.addIdentity(provFlow.identity, provFlow.kp, aCert); + + // Great success! + provFlow.callback(null); + + // Clean up the flow. + this._cleanUpProvisionFlow(aProvId); + }, + + /** + * Begin the authentication process with an IdP + * + * @param aProvId + * (int) the identifier of the provisioning flow which failed + * + * @param aCallback + * (function) to invoke upon completion, with + * first-positional-param error. + */ + _doAuthentication: function _doAuthentication(aProvId, aIDPParams) { + log("_doAuthentication: provId:", aProvId, "idpParams:", aIDPParams); + // create an authentication caller and its identifier AuthId + // stash aIdentity, idpparams, and callback in it. + + // extract authentication URL from idpParams + let authPath = aIDPParams.idpParams.authentication; + let authURI = Services.io.newURI("https://" + aIDPParams.domain, null, null).resolve(authPath); + + // beginAuthenticationFlow causes the "identity-auth" topic to be + // observed. Since it's sending a notification to the DOM, there's + // no callback. We wait for the DOM to trigger the next phase of + // provisioning. + this._beginAuthenticationFlow(aProvId, authURI); + + // either we bind the AuthID to the sandbox ourselves, or UX does that, + // in which case we need to tell UX the AuthId. + // Currently, the UX creates the UI and gets the AuthId from the window + // and sets is with setAuthenticationFlow + }, + + /** + * The authentication frame has called navigator.id.beginAuthentication + * + * IMPORTANT: the aCaller is *always* non-null, even if this is called from + * a regular content page. We have to make sure, on every DOM call, that + * aCaller is an expected authentication-flow identifier. If not, we throw + * an error or something. + * + * @param aCaller + * (object) the authentication caller + * + */ + beginAuthentication: function beginAuthentication(aCaller) { + log("beginAuthentication: caller id:", aCaller.id); + + // Begin the authentication flow after having concluded a provisioning + // flow. The aCaller that the DOM gives us will have the same ID as + // the provisioning flow we just concluded. (see setAuthenticationFlow) + let authFlow = this._authenticationFlows[aCaller.id]; + if (!authFlow) { + return aCaller.doError("beginAuthentication: no flow for caller id", aCaller.id); + } + + authFlow.caller = aCaller; + + let identity = this._provisionFlows[authFlow.provId].identity; + + // tell the UI to start the authentication process + log("beginAuthentication: authFlow:", aCaller.id, "identity:", identity); + return authFlow.caller.doBeginAuthenticationCallback(identity); + }, + + /** + * The auth frame has called navigator.id.completeAuthentication + * + * @param aAuthId + * (int) the identifier of the authentication caller tied to that sandbox + * + */ + completeAuthentication: function completeAuthentication(aAuthId) { + log("completeAuthentication:", aAuthId); + + // look up the AuthId caller, and get its callback. + let authFlow = this._authenticationFlows[aAuthId]; + if (!authFlow) { + reportError("completeAuthentication", "No auth flow with id", aAuthId); + return; + } + let provId = authFlow.provId; + + // delete caller + delete authFlow['caller']; + delete this._authenticationFlows[aAuthId]; + + let provFlow = this.getProvisionFlow(provId); + provFlow.didAuthentication = true; + let subject = { + rpId: provFlow.rpId, + identity: provFlow.identity, + }; + Services.obs.notifyObservers({ wrappedJSObject: subject }, "identity-auth-complete", aAuthId); + }, + + /** + * The auth frame has called navigator.id.cancelAuthentication + * + * @param aAuthId + * (int) the identifier of the authentication caller + * + */ + cancelAuthentication: function cancelAuthentication(aAuthId) { + log("cancelAuthentication:", aAuthId); + + // look up the AuthId caller, and get its callback. + let authFlow = this._authenticationFlows[aAuthId]; + if (!authFlow) { + reportError("cancelAuthentication", "No auth flow with id:", aAuthId); + return; + } + let provId = authFlow.provId; + + // delete caller + delete authFlow['caller']; + delete this._authenticationFlows[aAuthId]; + + let provFlow = this.getProvisionFlow(provId); + provFlow.didAuthentication = true; + Services.obs.notifyObservers(null, "identity-auth-complete", aAuthId); + + // invoke callback with ERROR. + let errStr = "Authentication canceled by IDP"; + log("ERROR: cancelAuthentication:", errStr); + provFlow.callback(errStr); + }, + + /** + * Called by the UI to set the ID and caller for the authentication flow after it gets its ID + */ + setAuthenticationFlow: function(aAuthId, aProvId) { + // this is the transition point between the two flows, + // provision and authenticate. We tell the auth flow which + // provisioning flow it is started from. + log("setAuthenticationFlow: authId:", aAuthId, "provId:", aProvId); + this._authenticationFlows[aAuthId] = { provId: aProvId }; + this._provisionFlows[aProvId].authId = aAuthId; + }, + + /** + * Load the provisioning URL in a hidden frame to start the provisioning + * process. + */ + _createProvisioningSandbox: function _createProvisioningSandbox(aURL, aCallback) { + log("_createProvisioningSandbox:", aURL); + + if (!this._sandboxConfigured) { + // Configure message manager listening on the hidden window + Cu.import("resource://gre/modules/DOMIdentity.jsm"); + DOMIdentity._configureMessages(Services.appShell.hiddenDOMWindow, true); + this._sandboxConfigured = true; + } + + new Sandbox(aURL, aCallback); + }, + + /** + * Load the authentication UI to start the authentication process. + */ + _beginAuthenticationFlow: function _beginAuthenticationFlow(aProvId, aURL) { + log("_beginAuthenticationFlow:", aProvId, aURL); + let propBag = {provId: aProvId}; + + Services.obs.notifyObservers({wrappedJSObject:propBag}, "identity-auth", aURL); + }, + + /** + * Clean up a provision flow and the authentication flow and sandbox + * that may be attached to it. + */ + _cleanUpProvisionFlow: function _cleanUpProvisionFlow(aProvId) { + log('_cleanUpProvisionFlow:', aProvId); + let prov = this._provisionFlows[aProvId]; + + // Clean up the sandbox, if there is one. + if (prov.provisioningSandbox) { + let sandbox = this._provisionFlows[aProvId]['provisioningSandbox']; + if (sandbox.free) { + log('_cleanUpProvisionFlow: freeing sandbox'); + sandbox.free(); + } + delete this._provisionFlows[aProvId]['provisioningSandbox']; + } + + // Clean up a related authentication flow, if there is one. + if (this._authenticationFlows[prov.authId]) { + delete this._authenticationFlows[prov.authId]; + } + + // Finally delete the provision flow + delete this._provisionFlows[aProvId]; + } + +}; + +this.IdentityProvider = new IdentityProviderService(); diff --git a/toolkit/identity/IdentityStore.jsm b/toolkit/identity/IdentityStore.jsm new file mode 100644 index 000000000..1827a839e --- /dev/null +++ b/toolkit/identity/IdentityStore.jsm @@ -0,0 +1,97 @@ +/* -*- js-indent-level: 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"); + +this.EXPORTED_SYMBOLS = ["IdentityStore"]; + +// the data store for IDService +// written as a separate thing so it can easily be mocked +function IDServiceStore() { + this.reset(); +} + +// Note: eventually these methods may be async, but we haven no need for this +// for now, since we're not storing to disk. +IDServiceStore.prototype = { + addIdentity: function addIdentity(aEmail, aKeyPair, aCert) { + this._identities[aEmail] = {keyPair: aKeyPair, cert: aCert}; + }, + fetchIdentity: function fetchIdentity(aEmail) { + return aEmail in this._identities ? this._identities[aEmail] : null; + }, + removeIdentity: function removeIdentity(aEmail) { + let data = this._identities[aEmail]; + delete this._identities[aEmail]; + return data; + }, + getIdentities: function getIdentities() { + // XXX - should clone? + return this._identities; + }, + clearCert: function clearCert(aEmail) { + // XXX - should remove key from store? + this._identities[aEmail].cert = null; + this._identities[aEmail].keyPair = null; + }, + + /** + * set the login state for a given origin + * + * @param aOrigin + * (string) a web origin + * + * @param aState + * (boolean) whether or not the user is logged in + * + * @param aEmail + * (email) the email address the user is logged in with, + * or, if not logged in, the default email for that origin. + */ + setLoginState: function setLoginState(aOrigin, aState, aEmail) { + if (aState && !aEmail) { + throw "isLoggedIn cannot be set to true without an email"; + } + return this._loginStates[aOrigin] = {isLoggedIn: aState, email: aEmail}; + }, + getLoginState: function getLoginState(aOrigin) { + return aOrigin in this._loginStates ? this._loginStates[aOrigin] : null; + }, + clearLoginState: function clearLoginState(aOrigin) { + delete this._loginStates[aOrigin]; + }, + + reset: function Store_reset() { + // _identities associates emails with keypairs and certificates + this._identities = {}; + + // _loginStates associates. remote origins with a login status and + // the email the user has chosen as his or her identity when logging + // into that origin. + this._loginStates = {}; + }, + + 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.reset(); + break; + } + }, +}; + +this.IdentityStore = new IDServiceStore(); diff --git a/toolkit/identity/IdentityUtils.jsm b/toolkit/identity/IdentityUtils.jsm new file mode 100644 index 000000000..a34c0b133 --- /dev/null +++ b/toolkit/identity/IdentityUtils.jsm @@ -0,0 +1,111 @@ +/* -*- js-indent-level: 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/. */ + +// functions common to Identity.jsm and MinimalIdentity.jsm + +"use strict"; + +this.EXPORTED_SYMBOLS = [ + "checkDeprecated", + "checkRenamed", + "getRandomId", + "objectCopy", + "makeMessageObject", +]; + +const Cu = Components.utils; + +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); + +XPCOMUtils.defineLazyServiceGetter(this, "uuidgen", + "@mozilla.org/uuid-generator;1", + "nsIUUIDGenerator"); + +XPCOMUtils.defineLazyModuleGetter(this, "Logger", + "resource://gre/modules/identity/LogUtils.jsm"); + +function log(...aMessageArgs) { + Logger.log.apply(Logger, ["Identity"].concat(aMessageArgs)); +} + +function defined(item) { + return typeof item !== 'undefined'; +} + +var checkDeprecated = this.checkDeprecated = function checkDeprecated(aOptions, aField) { + if (defined(aOptions[aField])) { + log("WARNING: field is deprecated:", aField); + return true; + } + return false; +}; + +this.checkRenamed = function checkRenamed(aOptions, aOldName, aNewName) { + if (defined(aOptions[aOldName]) && + defined(aOptions[aNewName])) { + let err = "You cannot provide both " + aOldName + " and " + aNewName; + Logger.reportError(err); + throw new Error(err); + } + + if (checkDeprecated(aOptions, aOldName)) { + aOptions[aNewName] = aOptions[aOldName]; + delete(aOptions[aOldName]); + } +}; + +this.getRandomId = function getRandomId() { + return uuidgen.generateUUID().toString(); +}; + +/* + * copy source object into target, excluding private properties + * (those whose names begin with an underscore) + */ +this.objectCopy = function objectCopy(source, target) { + let desc; + Object.getOwnPropertyNames(source).forEach(function(name) { + if (name[0] !== '_') { + desc = Object.getOwnPropertyDescriptor(source, name); + Object.defineProperty(target, name, desc); + } + }); +}; + +this.makeMessageObject = function makeMessageObject(aRpCaller) { + let options = {}; + + options.id = aRpCaller.id; + options.origin = aRpCaller.origin; + + // Backwards compatibility with Persona beta: + // loggedInUser can be undefined, null, or a string + options.loggedInUser = aRpCaller.loggedInUser; + + // Special flag for internal calls for Persona in b2g + options._internal = aRpCaller._internal; + + Object.keys(aRpCaller).forEach(function(option) { + // Duplicate the callerobject, scrubbing out functions and other + // internal variables (like _mm, the message manager object) + if (!Object.hasOwnProperty(this, option) + && option[0] !== '_' + && typeof aRpCaller[option] !== 'function') { + options[option] = aRpCaller[option]; + } + }); + + // check validity of message structure + if ((typeof options.id === 'undefined') || + (typeof options.origin === 'undefined')) { + let err = "id and origin required in relying-party message: " + JSON.stringify(options); + reportError(err); + throw new Error(err); + } + + return options; +} + diff --git a/toolkit/identity/LogUtils.jsm b/toolkit/identity/LogUtils.jsm new file mode 100644 index 000000000..ec0f4c420 --- /dev/null +++ b/toolkit/identity/LogUtils.jsm @@ -0,0 +1,103 @@ +/* -*- js-indent-level: 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"; + +this.EXPORTED_SYMBOLS = ["Logger"]; +const PREF_DEBUG = "toolkit.identity.debug"; + +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"); + +function IdentityLogger() { + Services.prefs.addObserver(PREF_DEBUG, this, false); + this._debug = Services.prefs.getBoolPref(PREF_DEBUG); + return this; +} + +IdentityLogger.prototype = { + QueryInterface: XPCOMUtils.generateQI([Ci.nsISupports, Ci.nsIObserver]), + + observe: function observe(aSubject, aTopic, aData) { + switch (aTopic) { + case "nsPref:changed": + this._debug = Services.prefs.getBoolPref(PREF_DEBUG); + break; + + case "quit-application-granted": + Services.prefs.removeObserver(PREF_DEBUG, this); + break; + + default: + this.log("Logger observer", "Unknown topic:", aTopic); + break; + } + }, + + _generateLogMessage: function _generateLogMessage(aPrefix, args) { + // create a string representation of a list of arbitrary things + let strings = []; + + // XXX bug 770418 - args look like flattened array, not list of strings + + args.forEach(function(arg) { + if (typeof arg === 'string') { + strings.push(arg); + } else if (typeof arg === 'undefined') { + strings.push('undefined'); + } else if (arg === null) { + strings.push('null'); + } else { + try { + strings.push(JSON.stringify(arg, null, 2)); + } catch (err) { + strings.push("<<something>>"); + } + } + }); + return 'Identity ' + aPrefix + ': ' + strings.join(' '); + }, + + /** + * log() - utility function to print a list of arbitrary things + * + * Enable with about:config pref toolkit.identity.debug + */ + log: function log(aPrefix, ...args) { + if (!this._debug) { + return; + } + let output = this._generateLogMessage(aPrefix, args); + dump(output + "\n"); + + // Additionally, make the output visible in the Error Console + Services.console.logStringMessage(output); + }, + + /** + * reportError() - report an error through component utils as well as + * our log function + */ + reportError: function reportError(aPrefix, ...aArgs) { + let prefix = aPrefix + ' ERROR'; + + // Report the error in the browser + let output = this._generateLogMessage(aPrefix, aArgs); + Cu.reportError(output); + dump("ERROR: " + output + "\n"); + for (let frame = Components.stack.caller; frame; frame = frame.caller) { + dump(frame + "\n"); + } + } + +}; + +this.Logger = new IdentityLogger(); diff --git a/toolkit/identity/MinimalIdentity.jsm b/toolkit/identity/MinimalIdentity.jsm new file mode 100644 index 000000000..bceb65659 --- /dev/null +++ b/toolkit/identity/MinimalIdentity.jsm @@ -0,0 +1,242 @@ +/* -*- js-indent-level: 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/. */ + +/* + * This alternate implementation of IdentityService provides just the + * channels for navigator.id, leaving the certificate storage to a + * server-provided app. + * + * On b2g, the messages identity-controller-watch, -request, and + * -logout, are observed by the component SignInToWebsite.jsm. + */ + +"use strict"; + +this.EXPORTED_SYMBOLS = ["IdentityService"]; + +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"); + +XPCOMUtils.defineLazyModuleGetter(this, "objectCopy", + "resource://gre/modules/identity/IdentityUtils.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, "makeMessageObject", + "resource://gre/modules/identity/IdentityUtils.jsm"); + +function log(...aMessageArgs) { + Logger.log.apply(Logger, ["minimal core"].concat(aMessageArgs)); +} +function reportError(...aMessageArgs) { + Logger.reportError.apply(Logger, ["core"].concat(aMessageArgs)); +} + +function IDService() { + Services.obs.addObserver(this, "quit-application-granted", false); + + // simplify, it's one object + this.RP = this; + this.IDP = this; + + // keep track of flows + this._rpFlows = {}; + this._authFlows = {}; + this._provFlows = {}; +} + +IDService.prototype = { + QueryInterface: XPCOMUtils.generateQI([Ci.nsISupports, Ci.nsIObserver]), + + observe: function observe(aSubject, aTopic, aData) { + switch (aTopic) { + case "quit-application-granted": + this.shutdown(); + break; + } + }, + + shutdown: function() { + Services.obs.removeObserver(this, "quit-application-granted"); + }, + + /** + * Parse an email into username and domain if it is valid, else return null + */ + parseEmail: function parseEmail(email) { + var match = email.match(/^([^@]+)@([^@^/]+.[a-z]+)$/); + if (match) { + return { + username: match[1], + domain: match[2] + }; + } + return null; + }, + + /** + * 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) + * - loggedInUser (string or null) + * - origin (string) + * + * and a bunch of callbacks + * - doReady() + * - doLogin() + * - doLogout() + * - doError() + * - doCancel() + * + */ + watch: function watch(aRpCaller) { + // store the caller structure and notify the UI observers + this._rpFlows[aRpCaller.id] = aRpCaller; + + log("flows:", Object.keys(this._rpFlows).join(', ')); + + let options = makeMessageObject(aRpCaller); + log("sending identity-controller-watch:", options); + Services.obs.notifyObservers({wrappedJSObject: options}, "identity-controller-watch", null); + }, + + /* + * The RP has gone away; remove handles to the hidden iframe. + * It's probable that the frame will already have been cleaned up. + */ + unwatch: function unwatch(aRpId, aTargetMM) { + let rp = this._rpFlows[aRpId]; + if (!rp) { + return; + } + + let options = makeMessageObject({ + id: aRpId, + origin: rp.origin, + messageManager: aTargetMM + }); + log("sending identity-controller-unwatch for id", options.id, options.origin); + Services.obs.notifyObservers({wrappedJSObject: options}, "identity-controller-unwatch", null); + + // Stop sending messages to this window + delete this._rpFlows[aRpId]; + }, + + /** + * 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) { + let rp = this._rpFlows[aRPId]; + if (!rp) { + reportError("request() called before watch()"); + return; + } + + // Notify UX to display identity picker. + // Pass the doc id to UX so it can pass it back to us later. + let options = makeMessageObject(rp); + objectCopy(aOptions, options); + Services.obs.notifyObservers({wrappedJSObject: options}, "identity-controller-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) { + let rp = this._rpFlows[aRpCallerId]; + if (!rp) { + reportError("logout() called before watch()"); + return; + } + + let options = makeMessageObject(rp); + Services.obs.notifyObservers({wrappedJSObject: options}, "identity-controller-logout", null); + }, + + childProcessShutdown: function childProcessShutdown(messageManager) { + Object.keys(this._rpFlows).forEach(function(key) { + if (this._rpFlows[key]._mm === messageManager) { + log("child process shutdown for rp", key, "- deleting flow"); + delete this._rpFlows[key]; + } + }, this); + }, + + /* + * once the UI-and-display-logic components have received + * notifications, they call back with direct invocation of the + * following functions (doLogin, doLogout, or doReady) + */ + + doLogin: function doLogin(aRpCallerId, aAssertion, aInternalParams) { + let rp = this._rpFlows[aRpCallerId]; + if (!rp) { + log("WARNING: doLogin found no rp to go with callerId " + aRpCallerId); + return; + } + + rp.doLogin(aAssertion, aInternalParams); + }, + + doLogout: function doLogout(aRpCallerId) { + let rp = this._rpFlows[aRpCallerId]; + if (!rp) { + log("WARNING: doLogout found no rp to go with callerId " + aRpCallerId); + return; + } + + // Logout from every site with the same origin + let origin = rp.origin; + Object.keys(this._rpFlows).forEach(function(key) { + let rp = this._rpFlows[key]; + if (rp.origin === origin) { + rp.doLogout(); + } + }.bind(this)); + }, + + doReady: function doReady(aRpCallerId) { + let rp = this._rpFlows[aRpCallerId]; + if (!rp) { + log("WARNING: doReady found no rp to go with callerId " + aRpCallerId); + return; + } + + rp.doReady(); + }, + + doCancel: function doCancel(aRpCallerId) { + let rp = this._rpFlows[aRpCallerId]; + if (!rp) { + log("WARNING: doCancel found no rp to go with callerId " + aRpCallerId); + return; + } + + rp.doCancel(); + } +}; + +this.IdentityService = new IDService(); diff --git a/toolkit/identity/RelyingParty.jsm b/toolkit/identity/RelyingParty.jsm new file mode 100644 index 000000000..5996383ca --- /dev/null +++ b/toolkit/identity/RelyingParty.jsm @@ -0,0 +1,367 @@ +/* -*- js-indent-level: 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"); + +this.EXPORTED_SYMBOLS = ["RelyingParty"]; + +XPCOMUtils.defineLazyModuleGetter(this, "objectCopy", + "resource://gre/modules/identity/IdentityUtils.jsm"); + +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) + * - loggedInUser (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, + "loggedInUser:", aRpCaller.loggedInUser, + "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.loggedInUser === state.email) { + this._notifyLoginStateChanged(aRpCaller.id, state.email); + return aRpCaller.doReady(); + + } else if (aRpCaller.loggedInUser === null) { + // Generate assertion for existing login + let options = {loggedInUser: state.email, origin: origin}; + return this._doLogin(aRpCaller, options); + } + // A loggedInUser different from state.email has been specified. + // Change login identity. + + let options = {loggedInUser: 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'; + + } + if (aRpCaller.loggedInUser) { + return this._doLogout(aRpCaller, {origin: origin}); + } + 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.loggedInUser); + this._notifyLoginStateChanged(aRpCaller.id, aOptions.loggedInUser); + 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); + let rp = this._rpFlows[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, origin: rp.origin}; + objectCopy(aOptions, options); + + // Append URLs after resolving + 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.loggedInUser || 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); + } + }, + +}; + +this.RelyingParty = new IdentityRelyingParty(); diff --git a/toolkit/identity/Sandbox.jsm b/toolkit/identity/Sandbox.jsm new file mode 100644 index 000000000..68757c212 --- /dev/null +++ b/toolkit/identity/Sandbox.jsm @@ -0,0 +1,152 @@ +/* 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"; + +this.EXPORTED_SYMBOLS = ["Sandbox"]; + +const {classes: Cc, interfaces: Ci, utils: Cu} = Components; + +const XHTML_NS = "http://www.w3.org/1999/xhtml"; + +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); +Cu.import("resource://gre/modules/Services.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, + "Logger", + "resource://gre/modules/identity/LogUtils.jsm"); + +/** + * An object that represents a sandbox in an iframe loaded with aURL. The + * callback provided to the constructor will be invoked when the sandbox is + * ready to be used. The callback will receive this object as its only argument. + * + * You must call free() when you are finished with the sandbox to explicitly + * free up all associated resources. + * + * @param aURL + * (string) URL to load in the sandbox. + * + * @param aCallback + * (function) Callback to be invoked with a Sandbox, when ready. + */ +this.Sandbox = function Sandbox(aURL, aCallback) { + // Normalize the URL so the comparison in _makeSandboxContentLoaded works + this._url = Services.io.newURI(aURL, null, null).spec; + this._log("Creating sandbox for:", this._url); + this._createFrame(); + this._createSandbox(aCallback); +}; + +this.Sandbox.prototype = { + + /** + * Use the outer window ID as the identifier of the sandbox. + */ + get id() { + return this._frame.contentWindow.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIDOMWindowUtils).outerWindowID; + }, + + /** + * Reload the URL in the sandbox. This is useful to reuse a Sandbox (same + * id and URL). + */ + reload: function Sandbox_reload(aCallback) { + this._log("reload:", this.id, ":", this._url); + this._createSandbox(function createdSandbox(aSandbox) { + this._log("reloaded sandbox id:", aSandbox.id); + aCallback(aSandbox); + }.bind(this)); + }, + + /** + * Frees the sandbox and releases the iframe created to host it. + */ + free: function Sandbox_free() { + this._log("free:", this.id); + this._container.removeChild(this._frame); + this._frame = null; + this._container = null; + this._url = null; + }, + + /** + * Creates an empty, hidden iframe and sets it to the _frame + * property of this object. + */ + _createFrame: function Sandbox__createFrame() { + let hiddenWindow = Services.appShell.hiddenDOMWindow; + let doc = hiddenWindow.document; + + // Insert iframe in to create docshell. + let frame = doc.createElementNS(XHTML_NS, "iframe"); + frame.setAttribute("mozframetype", "content"); + frame.sandbox = "allow-forms allow-scripts allow-same-origin"; + frame.style.visibility = "collapse"; + doc.documentElement.appendChild(frame); + + let docShell = frame.contentWindow.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIWebNavigation) + .QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIDocShell); + + // Stop about:blank from being loaded. + docShell.stop(Ci.nsIWebNavigation.STOP_NETWORK); + + // Disable some types of content + docShell.allowAuth = false; + docShell.allowPlugins = false; + docShell.allowImages = false; + docShell.allowMedia = false; + docShell.allowWindowControl = false; + + // Disable stylesheet loading since the document is not visible. + let markupDocViewer = docShell.contentViewer; + markupDocViewer.authorStyleDisabled = true; + + // Set instance properties. + this._frame = frame; + this._container = doc.documentElement; + }, + + _createSandbox: function Sandbox__createSandbox(aCallback) { + let self = this; + function _makeSandboxContentLoaded(event) { + self._log("_makeSandboxContentLoaded:", self.id, + event.target.location.toString()); + if (event.target != self._frame.contentDocument) { + return; + } + self._frame.removeEventListener( + "DOMWindowCreated", _makeSandboxContentLoaded, true + ); + + aCallback(self); + } + + this._frame.addEventListener("DOMWindowCreated", + _makeSandboxContentLoaded, + true); + + // Load the iframe. + let webNav = this._frame.contentWindow + .QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIWebNavigation); + + webNav.loadURI( + this._url, + Ci.nsIWebNavigation.LOAD_FLAGS_BYPASS_CACHE, + null, // referrer + null, // postData + null // headers + ); + + }, + + _log: function Sandbox__log(...aMessageArgs) { + Logger.log.apply(Logger, ["sandbox"].concat(aMessageArgs)); + }, + +}; diff --git a/toolkit/identity/jwcrypto.jsm b/toolkit/identity/jwcrypto.jsm new file mode 100644 index 000000000..4bcba730f --- /dev/null +++ b/toolkit/identity/jwcrypto.jsm @@ -0,0 +1,180 @@ +/* -*- js-indent-level: 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"); + +XPCOMUtils.defineLazyServiceGetter(this, + "IdentityCryptoService", + "@mozilla.org/identity/crypto-service;1", + "nsIIdentityCryptoService"); + +this.EXPORTED_SYMBOLS = ["jwcrypto"]; + +const ALGORITHMS = { RS256: "RS256", DS160: "DS160" }; +const DURATION_MS = 1000 * 60 * 2; // 2 minutes default assertion lifetime + +function log(...aMessageArgs) { + Logger.log.apply(Logger, ["jwcrypto"].concat(aMessageArgs)); +} + +function generateKeyPair(aAlgorithmName, aCallback) { + log("Generate key pair; alg =", aAlgorithmName); + + IdentityCryptoService.generateKeyPair(aAlgorithmName, function(rv, aKeyPair) { + if (!Components.isSuccessCode(rv)) { + return aCallback("key generation failed"); + } + + var publicKey; + + switch (aKeyPair.keyType) { + case ALGORITHMS.RS256: + publicKey = { + algorithm: "RS", + exponent: aKeyPair.hexRSAPublicKeyExponent, + modulus: aKeyPair.hexRSAPublicKeyModulus + }; + break; + + case ALGORITHMS.DS160: + publicKey = { + algorithm: "DS", + y: aKeyPair.hexDSAPublicValue, + p: aKeyPair.hexDSAPrime, + q: aKeyPair.hexDSASubPrime, + g: aKeyPair.hexDSAGenerator + }; + break; + + default: + return aCallback("unknown key type"); + } + + let keyWrapper = { + serializedPublicKey: JSON.stringify(publicKey), + _kp: aKeyPair + }; + + return aCallback(null, keyWrapper); + }); +} + +function sign(aPayload, aKeypair, aCallback) { + aKeypair._kp.sign(aPayload, function(rv, signature) { + if (!Components.isSuccessCode(rv)) { + log("ERROR: signer.sign failed"); + return aCallback("Sign failed"); + } + log("signer.sign: success"); + return aCallback(null, signature); + }); +} + +function jwcryptoClass() +{ +} + +jwcryptoClass.prototype = { + /* + * Determine the expiration of the assertion. Returns expiry date + * in milliseconds as integer. + * + * @param localtimeOffsetMsec (optional) + * The number of milliseconds that must be added to the local clock + * for it to agree with the server. For example, if the local clock + * if two minutes fast, localtimeOffsetMsec would be -120000 + * + * @param now (options) + * Current date in milliseconds. Useful for mocking clock + * skew in testing. + */ + getExpiration: function(duration=DURATION_MS, localtimeOffsetMsec=0, now=Date.now()) { + return now + localtimeOffsetMsec + duration; + }, + + isCertValid: function(aCert, aCallback) { + // XXX check expiration, bug 769850 + aCallback(true); + }, + + generateKeyPair: function(aAlgorithmName, aCallback) { + log("generating"); + generateKeyPair(aAlgorithmName, aCallback); + }, + + /* + * Generate an assertion and return it through the provided callback. + * + * @param aCert + * Identity certificate + * + * @param aKeyPair + * KeyPair object + * + * @param aAudience + * Audience of the assertion + * + * @param aOptions (optional) + * Can include: + * { + * localtimeOffsetMsec: <clock offset in milliseconds>, + * now: <current date in milliseconds> + * duration: <validity duration for this assertion in milliseconds> + * } + * + * localtimeOffsetMsec is the number of milliseconds that need to be + * added to the local clock time to make it concur with the server. + * For example, if the local clock is two minutes fast, the offset in + * milliseconds would be -120000. + * + * @param aCallback + * Function to invoke with resulting assertion. Assertion + * will be string or null on failure. + */ + generateAssertion: function(aCert, aKeyPair, aAudience, aOptions, aCallback) { + if (typeof aOptions == "function") { + aCallback = aOptions; + aOptions = { }; + } + + // for now, we hack the algorithm name + // XXX bug 769851 + var header = {"alg": "DS128"}; + var headerBytes = IdentityCryptoService.base64UrlEncode( + JSON.stringify(header)); + + var payload = { + exp: this.getExpiration( + aOptions.duration, aOptions.localtimeOffsetMsec, aOptions.now), + aud: aAudience + }; + var payloadBytes = IdentityCryptoService.base64UrlEncode( + JSON.stringify(payload)); + + log("payload bytes", payload, payloadBytes); + sign(headerBytes + "." + payloadBytes, aKeyPair, function(err, signature) { + if (err) + return aCallback(err); + + var signedAssertion = headerBytes + "." + payloadBytes + "." + signature; + return aCallback(null, aCert + "~" + signedAssertion); + }); + } + +}; + +this.jwcrypto = new jwcryptoClass(); +this.jwcrypto.ALGORITHMS = ALGORITHMS; diff --git a/toolkit/identity/moz.build b/toolkit/identity/moz.build new file mode 100644 index 000000000..4c0dc8190 --- /dev/null +++ b/toolkit/identity/moz.build @@ -0,0 +1,36 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# 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/. + +MOCHITEST_CHROME_MANIFESTS += ['tests/chrome/chrome.ini'] +XPCSHELL_TESTS_MANIFESTS += ['tests/unit/xpcshell.ini'] + +XPIDL_SOURCES += [ + 'nsIIdentityCryptoService.idl', +] + +XPIDL_MODULE = 'identity' + +SOURCES += [ + 'IdentityCryptoService.cpp', +] + +EXTRA_JS_MODULES.identity += [ + 'Identity.jsm', + 'IdentityProvider.jsm', + 'IdentityStore.jsm', + 'IdentityUtils.jsm', + 'jwcrypto.jsm', + 'LogUtils.jsm', + 'MinimalIdentity.jsm', + 'RelyingParty.jsm', + 'Sandbox.jsm', +] + +EXTRA_PP_JS_MODULES.identity += [ + 'FirefoxAccounts.jsm', +] + +FINAL_LIBRARY = 'xul' diff --git a/toolkit/identity/nsIIdentityCryptoService.idl b/toolkit/identity/nsIIdentityCryptoService.idl new file mode 100644 index 000000000..90149e2e8 --- /dev/null +++ b/toolkit/identity/nsIIdentityCryptoService.idl @@ -0,0 +1,106 @@ +/* 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/. */ + +#include "nsISupports.idl" + +interface nsIURI; +interface nsIIdentityKeyGenCallback; +interface nsIIdentitySignCallback; + +/* Naming and calling conventions: + * + * A"hex" prefix means "hex-encoded string representation of a byte sequence" + * e.g. "ae34bcdf123" + * + * A "base64url" prefix means "base-64-URL-encoded string repressentation of a + * byte sequence. + * e.g. "eyJhbGciOiJSUzI1NiJ9" + * http://en.wikipedia.org/wiki/Base64#Variants_summary_table + * we use the padded approach to base64-url-encoding + * + * Callbacks take an "in nsresult rv" argument that indicates whether the async + * operation succeeded. On success, rv will be a success code + * (NS_SUCCEEDED(rv) / Components.isSuccessCode(rv)) and the remaining + * arguments are as defined in the documentation for the callback. When the + * operation fails, rv will be a failure code (NS_FAILED(rv) / + * !Components.isSuccessCode(rv)) and the values of the remaining arguments will + * be unspecified. + * + * Key Types: + * + * "RS256": RSA + SHA-256. + * + * "DS160": DSA with SHA-1. A 1024-bit prime and a 160-bit subprime with SHA-1. + * + * we use these abbreviated algorithm names as per the JWA spec + * http://tools.ietf.org/html/draft-ietf-jose-json-web-algorithms-02 + */ + +// "@mozilla.org/identity/crypto-service;1" +[scriptable, builtinclass, uuid(f087e6bc-dd33-4f6c-a106-dd786e052ee9)] +interface nsIIdentityCryptoService : nsISupports +{ + void generateKeyPair(in AUTF8String algorithm, + in nsIIdentityKeyGenCallback callback); + + ACString base64UrlEncode(in AUTF8String toEncode); +}; + +/** + * This interface provides a keypair and signing interface for Identity functionality + */ +[scriptable, uuid(73962dc7-8ee7-4346-a12b-b039e1d9b54d)] +interface nsIIdentityKeyPair : nsISupports +{ + readonly attribute AUTF8String keyType; + + // RSA properties, only accessible when keyType == "RS256" + + readonly attribute AUTF8String hexRSAPublicKeyExponent; + readonly attribute AUTF8String hexRSAPublicKeyModulus; + + // DSA properties, only accessible when keyType == "DS128" + readonly attribute AUTF8String hexDSAPrime; // p + readonly attribute AUTF8String hexDSASubPrime; // q + readonly attribute AUTF8String hexDSAGenerator; // g + readonly attribute AUTF8String hexDSAPublicValue; // y + + void sign(in AUTF8String aText, + in nsIIdentitySignCallback callback); + + // XXX implement verification bug 769856 + // AUTF8String verify(in AUTF8String aSignature, in AUTF8String encodedPublicKey); + +}; + +/** + * This interface provides a JavaScript callback object used to collect the + * nsIIdentityServeKeyPair when the keygen operation is complete + * + * though there is discussion as to whether we need the nsresult, + * we keep it so we can track deeper crypto errors. + */ +[scriptable, function, uuid(90f24ca2-2b05-4ca9-8aec-89d38e2f905a)] +interface nsIIdentityKeyGenCallback : nsISupports +{ + void generateKeyPairFinished(in nsresult rv, + in nsIIdentityKeyPair keyPair); +}; + +/** + * This interface provides a JavaScript callback object used to collect the + * AUTF8String signature + */ +[scriptable, function, uuid(2d3e5036-374b-4b47-a430-1196b67b890f)] +interface nsIIdentitySignCallback : nsISupports +{ + /** On success, base64urlSignature is the base-64-URL-encoded signature + * + * For RS256 signatures, XXX bug 769858 + * + * For DSA128 signatures, the signature is the r value concatenated with the + * s value, each component padded with leading zeroes as necessary. + */ + void signFinished(in nsresult rv, in ACString base64urlSignature); +}; diff --git a/toolkit/identity/tests/chrome/.eslintrc.js b/toolkit/identity/tests/chrome/.eslintrc.js new file mode 100644 index 000000000..2c669d844 --- /dev/null +++ b/toolkit/identity/tests/chrome/.eslintrc.js @@ -0,0 +1,7 @@ +"use strict"; + +module.exports = { + "extends": [ + "../../../../testing/mochitest/chrome.eslintrc.js" + ] +}; diff --git a/toolkit/identity/tests/chrome/chrome.ini b/toolkit/identity/tests/chrome/chrome.ini new file mode 100644 index 000000000..ffaff0fb1 --- /dev/null +++ b/toolkit/identity/tests/chrome/chrome.ini @@ -0,0 +1,13 @@ +[DEFAULT] +skip-if = buildapp == 'b2g' || os == 'android' +support-files = + sandbox_content.html + sandbox_content.sjs + sandbox_content_alert.html + sandbox_content_framed.html + sandbox_content_perms.html + sandbox_content_popup.html + sandbox_content_redirect.html + sandbox_content_redirect.html^headers^ + +[test_sandbox.xul] diff --git a/toolkit/identity/tests/chrome/sandbox_content.html b/toolkit/identity/tests/chrome/sandbox_content.html new file mode 100644 index 000000000..9a9b63ac2 --- /dev/null +++ b/toolkit/identity/tests/chrome/sandbox_content.html @@ -0,0 +1,32 @@ +<!DOCTYPE html> +<html> +<!-- Any copyright is dedicated to the Public Domain. + - http://creativecommons.org/publicdomain/zero/1.0/ --> +<head> +<meta charset="utf-8"> +<title>Page testing blocked content in the Sandbox</title> + +<link rel="stylesheet" src="sandbox_content.sjs?text/css"/> + +<script src="sandbox_content.sjs?application/javascript"></script> + +</head> + +<body> + +<img src="sandbox_content.sjs?image/jpeg"/> + +<!-- media --> +<video src="sandbox_content.sjs?video/webm" autoplay="true"></video> +<audio src="sandbox_content.sjs?audio/ogg" autoplay="true"></audio> + +<!-- plugins --> +<embed src="sandbox_content.sjs?application/x-test"/> +<object data="sandbox_content.sjs?application/x-test"></object> +<applet code="sandbox_content.sjs?application/x-java-applet"></applet> + +<iframe src="sandbox_content.sjs?text/html"></iframe> + +</body> + +</html> diff --git a/toolkit/identity/tests/chrome/sandbox_content.sjs b/toolkit/identity/tests/chrome/sandbox_content.sjs new file mode 100644 index 000000000..2f562f214 --- /dev/null +++ b/toolkit/identity/tests/chrome/sandbox_content.sjs @@ -0,0 +1,36 @@ +/* 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/. */ + +function handleRequest(request, response) { + response.setHeader("Cache-Control", "no-cache", false); + + let loadedStateKey = "sandbox_content_loaded"; + switch(request.queryString) { + case "reset": { + setState(loadedStateKey, ""); + response.write("reset"); + break; + } + case "get_loaded": { + response.setHeader("Content-Type", "text/plain", false); + let loaded = getState(loadedStateKey); + if (loaded) + response.write(loaded); + else + response.write("NOTHING"); + break; + } + default: { + let contentType = decodeURIComponent(request.queryString); + // set the Content-Type equal to the query string + response.setHeader("Content-Type", contentType, false); + // If any content is loaded, append it's content type in state + let loaded = getState(loadedStateKey); + if (loaded) + loaded += ","; + setState(loadedStateKey, loaded + contentType); + break; + } + } +} diff --git a/toolkit/identity/tests/chrome/sandbox_content_alert.html b/toolkit/identity/tests/chrome/sandbox_content_alert.html new file mode 100644 index 000000000..f07308e84 --- /dev/null +++ b/toolkit/identity/tests/chrome/sandbox_content_alert.html @@ -0,0 +1,20 @@ +<!DOCTYPE html> +<html> +<!-- Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ --> +<head> +<meta charset="utf-8"> +<title>Page creating an alert inside the Sandbox</title> + +<script> + +alert("The user shouldn't see this"); + +</script> + +</head> + +<body> + +</body> +</html> diff --git a/toolkit/identity/tests/chrome/sandbox_content_framed.html b/toolkit/identity/tests/chrome/sandbox_content_framed.html new file mode 100644 index 000000000..72b0c49d0 --- /dev/null +++ b/toolkit/identity/tests/chrome/sandbox_content_framed.html @@ -0,0 +1,17 @@ +<!DOCTYPE html> +<html> +<!-- Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ --> +<head> +<meta charset="utf-8"> +<title>Page testing blocked content in an iframe inside the Sandbox</title> + +</head> + +<body> + +<iframe src="sandbox_content.html"></iframe> + +</body> + +</html> diff --git a/toolkit/identity/tests/chrome/sandbox_content_perms.html b/toolkit/identity/tests/chrome/sandbox_content_perms.html new file mode 100644 index 000000000..d24c683f8 --- /dev/null +++ b/toolkit/identity/tests/chrome/sandbox_content_perms.html @@ -0,0 +1,64 @@ +<!DOCTYPE html> +<html> + <!-- Any copyright is dedicated to the Public Domain. + - http://creativecommons.org/publicdomain/zero/1.0/ --> + <head> + <meta charset="utf-8"> + <title>Page testing content in the Sandbox can't escape</title> + <script type="application/javascript;version=1.8"> + const TEST_BASE = "http://mochi.test:8888/chrome/toolkit/identity/tests/chrome/" + const Ci = SpecialPowers.Ci; + + function expectException(aFunc) { + try { + aFunc(); + } catch (ex) { + return true; + } + return false; + } + + function CcNotPresent() { + if (typeof Components === 'undefined') + return true; + // Components shim doesn't define Components.classes. + try { + return typeof Components.classes === 'undefined'; + } catch (e) { + return false; + } + } + + // Build an object with test results (true = pass) + let results = { + windowTop: window.top == window, + + qiWindow: expectException(function() { + let isForced = window.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIDOMWindowUtils) + .docCharsetIsForced; + }), + + ccAccess: !!CcNotPresent(), + }; + + let resultsJSON = JSON.stringify(results); + + // Send the results to the mochitest server so the test file can retrieve them. + let stateURL = TEST_BASE + "sandbox_content.sjs" + let xhr = new XMLHttpRequest(); + xhr.open("GET", stateURL + "?" + encodeURIComponent(resultsJSON), true); + xhr.onload = function() { + if (xhr.status != 200) { + dump("Failed sending results\n"); + } + }; + xhr.send(); + + </script> + </head> + + <body> + + </body> +</html> diff --git a/toolkit/identity/tests/chrome/sandbox_content_popup.html b/toolkit/identity/tests/chrome/sandbox_content_popup.html new file mode 100644 index 000000000..cb21f706f --- /dev/null +++ b/toolkit/identity/tests/chrome/sandbox_content_popup.html @@ -0,0 +1,25 @@ +<!DOCTYPE html> +<html> +<!-- Any copyright is dedicated to the Public Domain. + - http://creativecommons.org/publicdomain/zero/1.0/ --> +<head> +<meta charset="utf-8"> +<title>Page creating an popup inside the Sandbox</title> + +<script> + +var strWindowFeatures = "menubar=yes,location=yes,resizable=yes,scrollbars=yes,status=yes"; + +var uri = "data:text/html,"; +uri += encodeURI("<body onload='setTimeout(window.close, 1000)'>"); + +var win = window.open(uri, "sandbox_popup", strWindowFeatures); + +</script> + +</head> + +<body> + +</body> +</html> diff --git a/toolkit/identity/tests/chrome/sandbox_content_redirect.html b/toolkit/identity/tests/chrome/sandbox_content_redirect.html new file mode 100644 index 000000000..7570ffad8 --- /dev/null +++ b/toolkit/identity/tests/chrome/sandbox_content_redirect.html @@ -0,0 +1,2 @@ +<!-- Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ --> diff --git a/toolkit/identity/tests/chrome/sandbox_content_redirect.html^headers^ b/toolkit/identity/tests/chrome/sandbox_content_redirect.html^headers^ new file mode 100644 index 000000000..7c06340b9 --- /dev/null +++ b/toolkit/identity/tests/chrome/sandbox_content_redirect.html^headers^ @@ -0,0 +1,2 @@ +HTTP 302 Found +Location: http://mochi.test:8888/chrome/toolkit/identity/tests/chrome/sandbox_content.html diff --git a/toolkit/identity/tests/chrome/test_sandbox.xul b/toolkit/identity/tests/chrome/test_sandbox.xul new file mode 100644 index 000000000..2b353c53b --- /dev/null +++ b/toolkit/identity/tests/chrome/test_sandbox.xul @@ -0,0 +1,324 @@ +<?xml version="1.0"?> +<?xml-stylesheet type="text/css" href="chrome://global/skin"?> +<?xml-stylesheet type="text/css" href="/tests/SimpleTest/test.css"?> +<!-- Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ --> +<!-- +https://bugzilla.mozilla.org/show_bug.cgi?id=762993 +--> +<window title="Mozilla Bug 762993" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + onload="run_next_test();"> + <script type="application/javascript" + src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"/> + + <!-- test results are displayed in the html:body --> + <body xmlns="http://www.w3.org/1999/xhtml"> + <a href="https://bugzilla.mozilla.org/show_bug.cgi?id=762993" + target="_blank">Mozilla Bug 762993</a> + </body> + + <!-- test code goes here --> + <script type="application/javascript;version=1.8"> + <![CDATA[ + + /** Test for Bug 762993 **/ + +"use strict"; + +SimpleTest.expectAssertions(1); + +SimpleTest.waitForExplicitFinish(); + +var Cc = Components.classes; +var Ci = Components.interfaces; +var Cu = Components.utils; + +const secMan = Cc["@mozilla.org/scriptsecuritymanager;1"].getService(Ci.nsIScriptSecurityManager); + +const TEST_URL_1 = "https://example.com/"; +// No trailing slash plus port to test normalization +const TEST_URL_2 = "https://example.com:443"; + +const TEST_BASE = "http://mochi.test:8888/chrome/toolkit/identity/tests/chrome/" +const STATE_URL = TEST_BASE + "sandbox_content.sjs" + +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); +Cu.import("resource://gre/modules/Services.jsm"); + +Services.prefs.setBoolPref("toolkit.identity.debug", true); + +XPCOMUtils.defineLazyModuleGetter(this, "Sandbox", + "resource://gre/modules/identity/Sandbox.jsm"); + +function check_sandbox(aSandbox, aURL) { + ok(aSandbox.id > 0, "valid ID"); + is(aSandbox._url, aURL, "matching URL (with normalization)"); + isnot(aSandbox._frame, null, "frame"); + isnot(aSandbox._container, null, "container"); + let docPrincipal = aSandbox._frame.contentDocument.nodePrincipal; + is(secMan.isSystemPrincipal(docPrincipal), false, + "principal must not be system"); +} + +/** + * Free the sandbox and make sure all properties that are not booleans, + * functions or numbers were freed. + */ +function free_and_check_sandbox(aSandbox) { + SimpleTest.executeSoon(function() { + aSandbox.free(); + + for(let prop in aSandbox) { + // Don't trigger the "id" getter when the frame is supposed to be freed already + if (prop == "id") + continue; + let propType = typeof(aSandbox[prop]); + if (propType == "boolean" || propType == "function" || propType == "number") + continue; + is(aSandbox[prop], null, "freed " + prop); + } + run_next_test(); + }); +} + +function reset_server_state() { + // Now reset the server state + let resetReq = new XMLHttpRequest(); + resetReq.open("GET", STATE_URL + "?reset", false); + resetReq.send(); +} + +function test_creation() { + new Sandbox(TEST_URL_1, function sandboxCB(aSandbox) { + check_sandbox(aSandbox, TEST_URL_1); + free_and_check_sandbox(aSandbox); + }); +} + +function test_reload() { + new Sandbox(TEST_URL_1, function sandboxCB(aSandbox) { + check_sandbox(aSandbox, TEST_URL_1); + let originalId = aSandbox.id; + + aSandbox.reload(function sandboxReloadCB(aSandbox) { + check_sandbox(aSandbox, TEST_URL_1); + is(aSandbox.id, originalId, "Sandbox ID should be the same after reload"); + free_and_check_sandbox(aSandbox); + }); + }); +} + +function test_url_normalization() { + new Sandbox(TEST_URL_2, function sandboxCB(aSandbox) { + // TEST_URL_2 should be normalized into the form of TEST_URL_1 + check_sandbox(aSandbox, TEST_URL_1); + free_and_check_sandbox(aSandbox); + }); +} + +/** + * Check with the server's state to see what content was loaded then reset it. + */ +function check_loaded_content(aSandbox, aNothingShouldLoad, aCallback) { + + let xhr = new XMLHttpRequest(); + xhr.open("GET", STATE_URL + "?get_loaded", true); + xhr.onload = function() { + let res = xhr.responseText; + is(xhr.status, 200, "Check successful response"); + + if (aNothingShouldLoad) { + is(res, "NOTHING", "Check that nothing was loaded on the server"); + } else { + let allowedTypes = [ "application/javascript", "text/html", "application/x-test" ]; + let loadedTypes = res == "NOTHING" ? [] : res.split(","); + + for (let loadedType of loadedTypes) { + isnot(allowedTypes.indexOf(loadedType), -1, "Check that " + loadedType + " was expected to load"); // TODO + } + + isnot(loadedTypes.indexOf("application/javascript"), -1, "Check JS was loaded"); + isnot(loadedTypes.indexOf("text/html"), -1, "Check iframe was loaded"); + is(loadedTypes.indexOf("video/webm"), -1, "Check webm was not loaded"); + is(loadedTypes.indexOf("audio/ogg"), -1, "Check ogg was not loaded"); + + // Check that no plugin tags have a type other than TYPE_NULL (failed load) + // -- + // Checking if a channel was opened is not sufficient for plugin tags -- + // An object tag may still be allowed to load a sub-document, but not a + // plugin, so it will open a channel but then abort when it gets a + // plugin-type. + let doc = aSandbox._frame.contentDocument; + let nullType = Components.interfaces.nsIObjectLoadingContent.TYPE_NULL; + for (let tag of doc.querySelectorAll("embed, object, applet")) { + tag instanceof Components.interfaces.nsIObjectLoadingContent; + is(tag.displayedType, nullType, "Check that plugin did not load content"); + } + } + + reset_server_state(); + + aCallback(); + }; + xhr.send(); +} + +/** + * Helper to check that only certain content is loaded on creation and during reload. + */ +function check_disabled_content(aSandboxURL, aNothingShouldLoad = false) { + new Sandbox(aSandboxURL, function sandboxCB(aSandbox) { + check_sandbox(aSandbox, aSandboxURL); + let originalId = aSandbox.id; + + setTimeout(function() { + check_loaded_content(aSandbox, aNothingShouldLoad, function checkFinished() { + + info("reload the sandbox content"); + aSandbox.reload(function sandboxReloadCB(aSandbox) { + check_sandbox(aSandbox, aSandboxURL); + is(aSandbox.id, originalId, "Sandbox ID should be the same after reload"); + + setTimeout(function() { + check_loaded_content(aSandbox, aNothingShouldLoad, function reloadCheckFinished() { + free_and_check_sandbox(aSandbox); + }); + }, 5000); + }); + }); + }, 5000); + }); +} + +function test_disabled_content() { + let url = TEST_BASE + "sandbox_content.html"; + check_disabled_content(url); +} + +// Same as test above but with content in an iframe. +function test_disabled_content_framed() { + let url = TEST_BASE + "sandbox_content_framed.html"; + check_disabled_content(url); +} + +function test_redirect() { + let url = TEST_BASE + "sandbox_content_redirect.html"; + check_disabled_content(url); +} + +function WindowObserver(aCallback) { + this.observe = function(aSubject, aTopic, aData) { + if (aTopic != "domwindowopened") { + return; + } + Services.ww.unregisterNotification(this); + + let domWin = aSubject.QueryInterface(Ci.nsIDOMWindow); + ok(!domWin, "No window should be opened"); + SimpleTest.executeSoon(function() { + info("Closing opened window"); + domWin.close(); + aCallback(); + }); + } +} + +// Can the sandbox call window.alert() or popup other UI? +function test_alert() { + let alertURL = TEST_BASE + "sandbox_content_alert.html"; + + new Sandbox(alertURL, function sandboxCB(aSandbox) { + check_sandbox(aSandbox, alertURL); + setTimeout(function() { + + let win = Services.wm.getMostRecentWindow(null); + isnot(win.document.documentElement.getAttribute("id"), "commonDialog", + "Make sure most recent window is not a dialog"); + if (win.document.documentElement.getAttribute("id") == "commonDialog") { + // If a dialog did open, close it so we don't interfere with future tests + win.close() + } + + free_and_check_sandbox(aSandbox); + }, 1000); + }); +} + +// Can the sandboxed page open a popup with window.open? +function test_popup() { + let alertURL = TEST_BASE + "sandbox_content_popup.html"; + let theSandbox; + function continueTest() { + // avoid double-free + if (!theSandbox) + return; + free_and_check_sandbox(theSandbox); + theSandbox = null; + } + let winObs = new WindowObserver(continueTest); + Services.ww.registerNotification(winObs); + new Sandbox(alertURL, function sandboxCB(aSandbox) { + theSandbox = aSandbox; + check_sandbox(aSandbox, alertURL); + // Wait 5 seconds to see if the window is going to open. + setTimeout(function() { + Services.ww.unregisterNotification(winObs); + continueTest(); + }, 5000); + }); +} + +// Loading a page with a bad cert +function test_bad_cert() { + let url = TEST_BASE + "sandbox_content.sjs?text/html"; + url = url.replace("http://mochi.test:8888", "https://untrusted.example.com"); + check_disabled_content(url, /*nothingShouldLoad=*/true); +} + +// Loading a page to check window.top and other permissions. +function test_frame_perms() { + let url = TEST_BASE + "sandbox_content_perms.html"; + new Sandbox(url, function sandboxCB(aSandbox) { + check_sandbox(aSandbox, url); + + // Give the content time to load + setTimeout(function() { + let xhr = new XMLHttpRequest(); + xhr.open("GET", STATE_URL + "?get_loaded", true); + xhr.responseType = "json"; + xhr.onload = function() { + is(xhr.status, 200, "Check successful response"); + is(typeof(xhr.response), "object", "Check response is object"); + is(Object.keys(xhr.response).length, 3, "Check the number of perm. tests"); + for (let test in xhr.response) { + ok(xhr.response[test], "Check result of " + test); + } + + reset_server_state(); + free_and_check_sandbox(aSandbox); + }; + xhr.send(); + }, 3000); + }); +} + +let TESTS = [test_creation, test_reload, test_url_normalization]; +TESTS.push(test_disabled_content, test_disabled_content_framed); +TESTS.push(test_alert, test_popup, test_bad_cert); +TESTS.push(test_redirect, test_frame_perms); + +function run_next_test() { + if (TESTS.length) { + let test = TESTS.shift(); + info(test.name); + test(); + } else { + Services.prefs.clearUserPref("toolkit.identity.debug"); + SimpleTest.finish(); + } +} + + ]]> + </script> +</window> diff --git a/toolkit/identity/tests/unit/.eslintrc.js b/toolkit/identity/tests/unit/.eslintrc.js new file mode 100644 index 000000000..fee088c17 --- /dev/null +++ b/toolkit/identity/tests/unit/.eslintrc.js @@ -0,0 +1,7 @@ +"use strict"; + +module.exports = { + "extends": [ + "../../../../testing/xpcshell/xpcshell.eslintrc.js" + ] +}; diff --git a/toolkit/identity/tests/unit/data/idp_1/.well-known/browserid b/toolkit/identity/tests/unit/data/idp_1/.well-known/browserid new file mode 100644 index 000000000..c7390457d --- /dev/null +++ b/toolkit/identity/tests/unit/data/idp_1/.well-known/browserid @@ -0,0 +1,5 @@ +{ + "public-key": {"algorithm":"RS","n":"65718905405105134410187227495885391609221288015566078542117409373192106382993306537273677557482085204736975067567111831005921322991127165013340443563713385983456311886801211241492470711576322130577278575529202840052753612576061450560588102139907846854501252327551303482213505265853706269864950437458242988327","e":"65537"}, + "authentication": "/browserid/sign_in.html", + "provisioning": "/browserid/provision.html" +} diff --git a/toolkit/identity/tests/unit/data/idp_invalid_1/.well-known/browserid b/toolkit/identity/tests/unit/data/idp_invalid_1/.well-known/browserid new file mode 100644 index 000000000..6bcd9de91 --- /dev/null +++ b/toolkit/identity/tests/unit/data/idp_invalid_1/.well-known/browserid @@ -0,0 +1,5 @@ +{ + "public-key": {"algorithm":"RS","n":"65718905405105134410187227495885391609221288015566078542117409373192106382993306537273677557482085204736975067567111831005921322991127165013340443563713385983456311886801211241492470711576322130577278575529202840052753612576061450560588102139907846854501252327551303482213505265853706269864950437458242988327","e":"65537"}, + "authentication": "/browserid/sign_in.html", + // missing "provisioning" +} diff --git a/toolkit/identity/tests/unit/head_identity.js b/toolkit/identity/tests/unit/head_identity.js new file mode 100644 index 000000000..a266e7aee --- /dev/null +++ b/toolkit/identity/tests/unit/head_identity.js @@ -0,0 +1,256 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +var Cc = Components.classes; +var Ci = Components.interfaces; +var Cu = Components.utils; +var Cr = Components.results; + +Cu.import("resource://testing-common/httpd.js"); + +// XXX until bug 937114 is fixed +Cu.importGlobalProperties(["atob"]); + +// The following boilerplate makes sure that XPCOM calls +// that use the profile directory work. + +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); +Cu.import("resource://gre/modules/Services.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, "jwcrypto", + "resource://gre/modules/identity/jwcrypto.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, "IDService", + "resource://gre/modules/identity/Identity.jsm", + "IdentityService"); + +XPCOMUtils.defineLazyModuleGetter(this, + "IdentityStore", + "resource://gre/modules/identity/IdentityStore.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, + "Logger", + "resource://gre/modules/identity/LogUtils.jsm"); + +XPCOMUtils.defineLazyServiceGetter(this, + "uuidGenerator", + "@mozilla.org/uuid-generator;1", + "nsIUUIDGenerator"); + +const TEST_MESSAGE_MANAGER = "Mr McFeeley"; +const TEST_URL = "https://myfavoritebacon.com"; +const TEST_URL2 = "https://myfavoritebaconinacan.com"; +const TEST_USER = "user@mozilla.com"; +const TEST_PRIVKEY = "fake-privkey"; +const TEST_CERT = "fake-cert"; +const TEST_ASSERTION = "fake-assertion"; +const TEST_IDPPARAMS = { + domain: "myfavoriteflan.com", + authentication: "/foo/authenticate.html", + provisioning: "/foo/provision.html" +}; + +// The following are utility functions for Identity testing + +function log(...aMessageArgs) { + Logger.log.apply(Logger, ["test"].concat(aMessageArgs)); +} + +function get_idstore() { + return IdentityStore; +} + +function partial(fn) { + let args = Array.prototype.slice.call(arguments, 1); + return function() { + return fn.apply(this, args.concat(Array.prototype.slice.call(arguments))); + }; +} + +function uuid() { + return uuidGenerator.generateUUID().toString(); +} + +function base64UrlDecode(s) { + s = s.replace(/-/g, "+"); + s = s.replace(/_/g, "/"); + + // Replace padding if it was stripped by the sender. + // See http://tools.ietf.org/html/rfc4648#section-4 + switch (s.length % 4) { + case 0: + break; // No pad chars in this case + case 2: + s += "=="; + break; // Two pad chars + case 3: + s += "="; + break; // One pad char + default: + throw new InputException("Illegal base64url string!"); + } + + // With correct padding restored, apply the standard base64 decoder + return atob(s); +} + +// create a mock "doc" object, which the Identity Service +// uses as a pointer back into the doc object +function mock_doc(aIdentity, aOrigin, aDoFunc) { + let mockedDoc = {}; + mockedDoc.id = uuid(); + mockedDoc.loggedInUser = aIdentity; + mockedDoc.origin = aOrigin; + mockedDoc["do"] = aDoFunc; + mockedDoc._mm = TEST_MESSAGE_MANAGER; + mockedDoc.doReady = partial(aDoFunc, "ready"); + mockedDoc.doLogin = partial(aDoFunc, "login"); + mockedDoc.doLogout = partial(aDoFunc, "logout"); + mockedDoc.doError = partial(aDoFunc, "error"); + mockedDoc.doCancel = partial(aDoFunc, "cancel"); + mockedDoc.doCoffee = partial(aDoFunc, "coffee"); + mockedDoc.childProcessShutdown = partial(aDoFunc, "child-process-shutdown"); + + mockedDoc.RP = mockedDoc; + + return mockedDoc; +} + +function mock_fxa_rp(aIdentity, aOrigin, aDoFunc) { + let mockedDoc = {}; + mockedDoc.id = uuid(); + mockedDoc.emailHint = aIdentity; + mockedDoc.origin = aOrigin; + mockedDoc.wantIssuer = "firefox-accounts"; + mockedDoc._mm = TEST_MESSAGE_MANAGER; + + mockedDoc.doReady = partial(aDoFunc, "ready"); + mockedDoc.doLogin = partial(aDoFunc, "login"); + mockedDoc.doLogout = partial(aDoFunc, "logout"); + mockedDoc.doError = partial(aDoFunc, "error"); + mockedDoc.doCancel = partial(aDoFunc, "cancel"); + mockedDoc.childProcessShutdown = partial(aDoFunc, "child-process-shutdown"); + + mockedDoc.RP = mockedDoc; + + return mockedDoc; +} + +// mimicking callback funtionality for ease of testing +// this observer auto-removes itself after the observe function +// is called, so this is meant to observe only ONE event. +function makeObserver(aObserveTopic, aObserveFunc) { + let observer = { + // nsISupports provides type management in C++ + // nsIObserver is to be an observer + QueryInterface: XPCOMUtils.generateQI([Ci.nsISupports, Ci.nsIObserver]), + + observe: function (aSubject, aTopic, aData) { + if (aTopic == aObserveTopic) { + aObserveFunc(aSubject, aTopic, aData); + Services.obs.removeObserver(observer, aObserveTopic); + } + } + }; + + Services.obs.addObserver(observer, aObserveTopic, false); +} + +// set up the ID service with an identity with keypair and all +// when ready, invoke callback with the identity +function setup_test_identity(identity, cert, cb) { + // set up the store so that we're supposed to be logged in + let store = get_idstore(); + + function keyGenerated(err, kpo) { + store.addIdentity(identity, kpo, cert); + cb(); + } + + jwcrypto.generateKeyPair("DS160", keyGenerated); +} + +// takes a list of functions and returns a function that +// when called the first time, calls the first func, +// then the next time the second, etc. +function call_sequentially() { + let numCalls = 0; + let funcs = arguments; + + return function() { + if (!funcs[numCalls]) { + let argString = Array.prototype.slice.call(arguments).join(","); + do_throw("Too many calls: " + argString); + return; + } + funcs[numCalls].apply(funcs[numCalls], arguments); + numCalls += 1; + }; +} + +/* + * Setup a provisioning workflow with appropriate callbacks + * + * identity is the email we're provisioning. + * + * afterSetupCallback is required. + * + * doneProvisioningCallback is optional, if the caller + * wants to be notified when the whole provisioning workflow is done + * + * frameCallbacks is optional, contains the callbacks that the sandbox + * frame would provide in response to DOM calls. + */ +function setup_provisioning(identity, afterSetupCallback, doneProvisioningCallback, callerCallbacks) { + IDService.reset(); + + let provId = uuid(); + IDService.IDP._provisionFlows[provId] = { + identity : identity, + idpParams: TEST_IDPPARAMS, + callback: function(err) { + if (doneProvisioningCallback) + doneProvisioningCallback(err); + }, + sandbox: { + // Emulate the free() method on the iframe sandbox + free: function() {} + } + }; + + let caller = {}; + caller.id = provId; + caller.doBeginProvisioningCallback = function(id, duration_s) { + if (callerCallbacks && callerCallbacks.beginProvisioningCallback) + callerCallbacks.beginProvisioningCallback(id, duration_s); + }; + caller.doGenKeyPairCallback = function(pk) { + if (callerCallbacks && callerCallbacks.genKeyPairCallback) + callerCallbacks.genKeyPairCallback(pk); + }; + + afterSetupCallback(caller); +} + +// Switch debug messages on by default +var initialPrefDebugValue = false; +try { + initialPrefDebugValue = Services.prefs.getBoolPref("toolkit.identity.debug"); +} catch (noPref) {} +Services.prefs.setBoolPref("toolkit.identity.debug", true); + +// Switch on firefox accounts +var initialPrefFXAValue = false; +try { + initialPrefFXAValue = Services.prefs.getBoolPref("identity.fxaccounts.enabled"); +} catch (noPref) {} +Services.prefs.setBoolPref("identity.fxaccounts.enabled", true); + +// after execution, restore prefs +do_register_cleanup(function() { + log("restoring prefs to their initial values"); + Services.prefs.setBoolPref("toolkit.identity.debug", initialPrefDebugValue); + Services.prefs.setBoolPref("identity.fxaccounts.enabled", initialPrefFXAValue); +}); + + diff --git a/toolkit/identity/tests/unit/tail_identity.js b/toolkit/identity/tests/unit/tail_identity.js new file mode 100644 index 000000000..c263f8369 --- /dev/null +++ b/toolkit/identity/tests/unit/tail_identity.js @@ -0,0 +1,8 @@ + +// pre-emptively shut down to clear resources +if (typeof IdentityService !== "undefined") { + IdentityService.shutdown(); +} else if (typeof IDService !== "undefined") { + IDService.shutdown(); +} + diff --git a/toolkit/identity/tests/unit/test_authentication.js b/toolkit/identity/tests/unit/test_authentication.js new file mode 100644 index 000000000..3f24e2e37 --- /dev/null +++ b/toolkit/identity/tests/unit/test_authentication.js @@ -0,0 +1,159 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +XPCOMUtils.defineLazyModuleGetter(this, "IDService", + "resource://gre/modules/identity/Identity.jsm", + "IdentityService"); + +XPCOMUtils.defineLazyModuleGetter(this, "jwcrypto", + "resource://gre/modules/identity/jwcrypto.jsm"); + +function test_begin_authentication_flow() { + do_test_pending(); + let _provId = null; + + // set up a watch, to be consistent + let mockedDoc = mock_doc(null, TEST_URL, function(action, params) {}); + IDService.RP.watch(mockedDoc); + + // The identity-auth notification is sent up to the UX from the + // _doAuthentication function. Be ready to receive it and call + // beginAuthentication + makeObserver("identity-auth", function (aSubject, aTopic, aData) { + do_check_neq(aSubject, null); + + do_check_eq(aSubject.wrappedJSObject.provId, _provId); + + do_test_finished(); + run_next_test(); + }); + + setup_provisioning( + TEST_USER, + function(caller) { + _provId = caller.id; + IDService.IDP.beginProvisioning(caller); + }, function() {}, + { + beginProvisioningCallback: function(email, duration_s) { + + // let's say this user needs to authenticate + IDService.IDP._doAuthentication(_provId, {idpParams:TEST_IDPPARAMS}); + } + } + ); +} + +function test_complete_authentication_flow() { + do_test_pending(); + let _provId = null; + let _authId = null; + let id = TEST_USER; + + let callbacksFired = false; + let loginStateChanged = false; + let identityAuthComplete = false; + + // The result of authentication should be a successful login + IDService.reset(); + + setup_test_identity(id, TEST_CERT, function() { + // set it up so we're supposed to be logged in to TEST_URL + + get_idstore().setLoginState(TEST_URL, true, id); + + // When we authenticate, our ready callback will be fired. + // At the same time, a separate topic will be sent up to the + // the observer in the UI. The test is complete when both + // events have occurred. + let mockedDoc = mock_doc(id, TEST_URL, call_sequentially( + function(action, params) { + do_check_eq(action, 'ready'); + do_check_eq(params, undefined); + + // if notification already received by observer, test is done + callbacksFired = true; + if (loginStateChanged && identityAuthComplete) { + do_test_finished(); + run_next_test(); + } + } + )); + + makeObserver("identity-auth-complete", function(aSubject, aTopic, aData) { + identityAuthComplete = true; + do_test_finished(); + run_next_test(); + }); + + makeObserver("identity-login-state-changed", function (aSubject, aTopic, aData) { + do_check_neq(aSubject, null); + + do_check_eq(aSubject.wrappedJSObject.rpId, mockedDoc.id); + do_check_eq(aData, id); + + // if callbacks in caller doc already fired, test is done. + loginStateChanged = true; + if (callbacksFired && identityAuthComplete) { + do_test_finished(); + run_next_test(); + } + }); + + IDService.RP.watch(mockedDoc); + + // Create a provisioning flow for our auth flow to attach to + setup_provisioning( + TEST_USER, + function(provFlow) { + _provId = provFlow.id; + + IDService.IDP.beginProvisioning(provFlow); + }, function() {}, + { + beginProvisioningCallback: function(email, duration_s) { + // let's say this user needs to authenticate + IDService.IDP._doAuthentication(_provId, {idpParams:TEST_IDPPARAMS}); + + // test_begin_authentication_flow verifies that the right + // message is sent to the UI. So that works. Moving on, + // the UI calls setAuthenticationFlow ... + _authId = uuid(); + IDService.IDP.setAuthenticationFlow(_authId, _provId); + + // ... then the UI calls beginAuthentication ... + authCaller.id = _authId; + IDService.IDP._provisionFlows[_provId].caller = authCaller; + IDService.IDP.beginAuthentication(authCaller); + } + } + ); + }); + + // A mock calling context + let authCaller = { + doBeginAuthenticationCallback: function doBeginAuthenticationCallback(identity) { + do_check_eq(identity, TEST_USER); + // completeAuthentication will emit "identity-auth-complete" + IDService.IDP.completeAuthentication(_authId); + }, + + doError: function(err) { + log("OW! My doError callback hurts!", err); + }, + }; + +} + +var TESTS = []; + +TESTS.push(test_begin_authentication_flow); +TESTS.push(test_complete_authentication_flow); + +TESTS.forEach(add_test); + +function run_test() { + run_next_test(); +} diff --git a/toolkit/identity/tests/unit/test_crypto_service.js b/toolkit/identity/tests/unit/test_crypto_service.js new file mode 100644 index 000000000..561c3804a --- /dev/null +++ b/toolkit/identity/tests/unit/test_crypto_service.js @@ -0,0 +1,122 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +Cu.import("resource://gre/modules/Services.jsm"); +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); +Cu.import('resource://gre/modules/identity/LogUtils.jsm'); + +const idService = Cc["@mozilla.org/identity/crypto-service;1"] + .getService(Ci.nsIIdentityCryptoService); + +const ALG_DSA = "DS160"; +const ALG_RSA = "RS256"; + +const BASE64_URL_ENCODINGS = [ + // The vectors from RFC 4648 are very silly, but we may as well include them. + ["", ""], + ["f", "Zg=="], + ["fo", "Zm8="], + ["foo", "Zm9v"], + ["foob", "Zm9vYg=="], + ["fooba", "Zm9vYmE="], + ["foobar", "Zm9vYmFy"], + + // It's quite likely you could get a string like this in an assertion audience + ["i-like-pie.com", "aS1saWtlLXBpZS5jb20="], + + // A few extra to be really sure + ["andré@example.com", "YW5kcsOpQGV4YW1wbGUuY29t"], + ["πόλλ' οἶδ' ἀλώπηξ, ἀλλ' ἐχῖνος ἓν μέγα", + "z4DPjM67zrsnIM6_4by2zrQnIOG8gM67z47PgM63zr4sIOG8gM67zrsnIOG8kM-H4b-Wzr3Ov8-CIOG8k869IM68zq3Os86x"], +]; + +// When the output of an operation is a +function do_check_eq_or_slightly_less(x, y) { + do_check_true(x >= y - (3 * 8)); +} + +function test_base64_roundtrip() { + let message = "Attack at dawn!"; + let encoded = idService.base64UrlEncode(message); + let decoded = base64UrlDecode(encoded); + do_check_neq(message, encoded); + do_check_eq(decoded, message); + run_next_test(); +} + +function test_dsa() { + idService.generateKeyPair(ALG_DSA, function (rv, keyPair) { + log("DSA generateKeyPair finished ", rv); + do_check_true(Components.isSuccessCode(rv)); + do_check_eq(typeof keyPair.sign, "function"); + do_check_eq(keyPair.keyType, ALG_DSA); + do_check_eq_or_slightly_less(keyPair.hexDSAGenerator.length, 1024 / 8 * 2); + do_check_eq_or_slightly_less(keyPair.hexDSAPrime.length, 1024 / 8 * 2); + do_check_eq_or_slightly_less(keyPair.hexDSASubPrime.length, 160 / 8 * 2); + do_check_eq_or_slightly_less(keyPair.hexDSAPublicValue.length, 1024 / 8 * 2); + // XXX: test that RSA parameters throw the correct error + + log("about to sign with DSA key"); + keyPair.sign("foo", function (rv2, signature) { + log("DSA sign finished ", rv2, signature); + do_check_true(Components.isSuccessCode(rv2)); + do_check_true(signature.length > 1); + // TODO: verify the signature with the public key + run_next_test(); + }); + }); +} + +function test_rsa() { + idService.generateKeyPair(ALG_RSA, function (rv, keyPair) { + log("RSA generateKeyPair finished ", rv); + do_check_true(Components.isSuccessCode(rv)); + do_check_eq(typeof keyPair.sign, "function"); + do_check_eq(keyPair.keyType, ALG_RSA); + do_check_eq_or_slightly_less(keyPair.hexRSAPublicKeyModulus.length, + 2048 / 8); + do_check_true(keyPair.hexRSAPublicKeyExponent.length > 1); + + log("about to sign with RSA key"); + keyPair.sign("foo", function (rv2, signature) { + log("RSA sign finished ", rv2, signature); + do_check_true(Components.isSuccessCode(rv2)); + do_check_true(signature.length > 1); + run_next_test(); + }); + }); +} + +function test_base64UrlEncode() { + for (let [source, target] of BASE64_URL_ENCODINGS) { + do_check_eq(target, idService.base64UrlEncode(source)); + } + run_next_test(); +} + +function test_base64UrlDecode() { + let utf8Converter = Cc["@mozilla.org/intl/scriptableunicodeconverter"] + .createInstance(Ci.nsIScriptableUnicodeConverter); + utf8Converter.charset = "UTF-8"; + + // We know the encoding of our inputs - on conversion back out again, make + // sure they're the same. + for (let [source, target] of BASE64_URL_ENCODINGS) { + let result = utf8Converter.ConvertToUnicode(base64UrlDecode(target)); + result += utf8Converter.Finish(); + do_check_eq(source, result); + } + run_next_test(); +} + +add_test(test_base64_roundtrip); +add_test(test_dsa); +add_test(test_rsa); +add_test(test_base64UrlEncode); +add_test(test_base64UrlDecode); + +function run_test() { + run_next_test(); +} diff --git a/toolkit/identity/tests/unit/test_firefox_accounts.js b/toolkit/identity/tests/unit/test_firefox_accounts.js new file mode 100644 index 000000000..c0c63deb6 --- /dev/null +++ b/toolkit/identity/tests/unit/test_firefox_accounts.js @@ -0,0 +1,270 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +Cu.import("resource://gre/modules/Promise.jsm"); +Cu.import("resource://gre/modules/DOMIdentity.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, "FirefoxAccounts", + "resource://gre/modules/identity/FirefoxAccounts.jsm"); + +// Make the profile dir available; this is necessary so that +// services/fxaccounts/FxAccounts.jsm can read and write its signed-in user +// data. +do_get_profile(); + +function MockFXAManager() { + this.signedInUser = true; +} +MockFXAManager.prototype = { + getAssertion: function(audience) { + let result = this.signedInUser ? TEST_ASSERTION : null; + return Promise.resolve(result); + }, + + signOut: function() { + this.signedInUser = false; + return Promise.resolve(null); + }, + + signIn: function(user) { + this.signedInUser = user; + return Promise.resolve(user); + }, +} + +var originalManager = FirefoxAccounts.fxAccountsManager; +FirefoxAccounts.fxAccountsManager = new MockFXAManager(); +do_register_cleanup(() => { + log("restoring fxaccountsmanager"); + FirefoxAccounts.fxAccountsManager = originalManager; +}); + +function withNobodySignedIn() { + return FirefoxAccounts.fxAccountsManager.signOut(); +} + +function withSomebodySignedIn() { + return FirefoxAccounts.fxAccountsManager.signIn('Pertelote'); +} + +function test_overall() { + do_check_neq(FirefoxAccounts, null); + run_next_test(); +} + +function test_mock() { + do_test_pending(); + + withSomebodySignedIn().then(() => { + FirefoxAccounts.fxAccountsManager.getAssertion().then(assertion => { + do_check_eq(assertion, TEST_ASSERTION); + do_test_finished(); + run_next_test(); + }); + }); +} + +function test_watch_signed_in() { + do_test_pending(); + + let received = []; + + let mockedRP = mock_fxa_rp(null, TEST_URL, function(method, data) { + received.push([method, data]); + + if (method == "ready") { + // confirm that we were signed in and then ready was called + do_check_eq(received.length, 2); + do_check_eq(received[0][0], "login"); + do_check_eq(received[0][1], TEST_ASSERTION); + do_check_eq(received[1][0], "ready"); + do_test_finished(); + run_next_test(); + } + }); + + withSomebodySignedIn().then(() => { + FirefoxAccounts.RP.watch(mockedRP); + }); +} + +function test_watch_signed_out() { + do_test_pending(); + + let received = []; + + let mockedRP = mock_fxa_rp(null, TEST_URL, function(method) { + received.push(method); + + if (method == "ready") { + // confirm that we were signed out and then ready was called + do_check_eq(received.length, 2); + do_check_eq(received[0], "logout"); + do_check_eq(received[1], "ready"); + + do_test_finished(); + run_next_test(); + } + }); + + withNobodySignedIn().then(() => { + FirefoxAccounts.RP.watch(mockedRP); + }); +} + +function test_request() { + do_test_pending(); + + let received = []; + + let mockedRP = mock_fxa_rp(null, TEST_URL, function(method, data) { + received.push([method, data]); + + // On watch(), we are signed out. Then we call request(). + if (received.length === 2) { + do_check_eq(received[0][0], "logout"); + do_check_eq(received[1][0], "ready"); + + // Pretend request() showed ux and the user signed in + withSomebodySignedIn().then(() => { + FirefoxAccounts.RP.request(mockedRP.id); + }); + } + + if (received.length === 3) { + do_check_eq(received[2][0], "login"); + do_check_eq(received[2][1], TEST_ASSERTION); + + do_test_finished(); + run_next_test(); + } + }); + + // First, call watch() with nobody signed in + withNobodySignedIn().then(() => { + FirefoxAccounts.RP.watch(mockedRP); + }); +} + +function test_logout() { + do_test_pending(); + + let received = []; + + let mockedRP = mock_fxa_rp(null, TEST_URL, function(method) { + received.push(method); + + // At first, watch() signs us in automatically. Then we sign out. + if (received.length === 2) { + do_check_eq(received[0], "login"); + do_check_eq(received[1], "ready"); + + FirefoxAccounts.RP.logout(mockedRP.id); + } + + if (received.length === 3) { + do_check_eq(received[2], "logout"); + do_test_finished(); + run_next_test(); + } + }); + + // First, call watch() + withSomebodySignedIn().then(() => { + FirefoxAccounts.RP.watch(mockedRP); + }); +} + +function test_error() { + do_test_pending(); + + let received = []; + + // Mock the fxAccountsManager so that getAssertion rejects its promise and + // triggers our onerror handler. (This is the method that's used internally + // by FirefoxAccounts.RP.request().) + let originalGetAssertion = FirefoxAccounts.fxAccountsManager.getAssertion; + FirefoxAccounts.fxAccountsManager.getAssertion = function(audience) { + return Promise.reject(new Error("barf!")); + }; + + let mockedRP = mock_fxa_rp(null, TEST_URL, function(method, message) { + // We will immediately receive an error, due to watch()'s attempt + // to getAssertion(). + do_check_eq(method, "error"); + do_check_true(/barf/.test(message)); + + // Put things back the way they were + FirefoxAccounts.fxAccountsManager.getAssertion = originalGetAssertion; + + do_test_finished(); + run_next_test(); + }); + + // First, call watch() + withSomebodySignedIn().then(() => { + FirefoxAccounts.RP.watch(mockedRP); + }); +} + +function test_child_process_shutdown() { + do_test_pending(); + let rpCount = FirefoxAccounts.RP._rpFlows.size; + + makeObserver("identity-child-process-shutdown", (aTopic, aSubject, aData) => { + // Last of all, the shutdown observer message will be fired. + // This takes place after the RP has a chance to delete flows + // and clean up. + do_check_eq(FirefoxAccounts.RP._rpFlows.size, rpCount); + do_test_finished(); + run_next_test(); + }); + + let mockedRP = mock_fxa_rp(null, TEST_URL, (method) => { + // We should enter this function for 'ready' and 'child-process-shutdown'. + // After we have a chance to do our thing, the shutdown observer message + // will fire and be caught by the function above. + do_check_eq(FirefoxAccounts.RP._rpFlows.size, rpCount + 1); + switch (method) { + case "ready": + DOMIdentity._childProcessShutdown("my message manager"); + break; + + case "child-process-shutdown": + // We have to call this explicitly because there's no real + // dom window here. + FirefoxAccounts.RP.childProcessShutdown(mockedRP._mm); + break; + + default: + break; + } + }); + + mockedRP._mm = "my message manager"; + withSomebodySignedIn().then(() => { + FirefoxAccounts.RP.watch(mockedRP); + }); + + // fake a dom window context + DOMIdentity.newContext(mockedRP, mockedRP._mm); +} + +var TESTS = [ + test_overall, + test_mock, + test_watch_signed_in, + test_watch_signed_out, + test_request, + test_logout, + test_error, + test_child_process_shutdown, +]; + +TESTS.forEach(add_test); + +function run_test() { + run_next_test(); +} diff --git a/toolkit/identity/tests/unit/test_identity.js b/toolkit/identity/tests/unit/test_identity.js new file mode 100644 index 000000000..5e2206c2a --- /dev/null +++ b/toolkit/identity/tests/unit/test_identity.js @@ -0,0 +1,114 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +XPCOMUtils.defineLazyModuleGetter(this, "IDService", + "resource://gre/modules/identity/Identity.jsm", + "IdentityService"); + +function test_overall() { + do_check_neq(IDService, null); + run_next_test(); +} + +function test_mock_doc() { + do_test_pending(); + let mockedDoc = mock_doc(null, TEST_URL, function(action, params) { + do_check_eq(action, 'coffee'); + do_test_finished(); + run_next_test(); + }); + + mockedDoc.doCoffee(); +} + +function test_add_identity() { + IDService.reset(); + + IDService.addIdentity(TEST_USER); + + let identities = IDService.RP.getIdentitiesForSite(TEST_URL); + do_check_eq(identities.result.length, 1); + do_check_eq(identities.result[0], TEST_USER); + + run_next_test(); +} + +function test_select_identity() { + do_test_pending(); + + IDService.reset(); + + let id = "ishtar@mockmyid.com"; + setup_test_identity(id, TEST_CERT, function() { + let gotAssertion = false; + let mockedDoc = mock_doc(null, TEST_URL, call_sequentially( + function(action, params) { + // ready emitted from first watch() call + do_check_eq(action, 'ready'); + do_check_null(params); + }, + // first the login call + function(action, params) { + do_check_eq(action, 'login'); + do_check_neq(params, null); + + // XXX - check that the assertion is for the right email + + gotAssertion = true; + }, + // then the ready call + function(action, params) { + do_check_eq(action, 'ready'); + do_check_null(params); + + // we should have gotten the assertion already + do_check_true(gotAssertion); + + do_test_finished(); + run_next_test(); + })); + + // register the callbacks + IDService.RP.watch(mockedDoc); + + // register the request UX observer + makeObserver("identity-request", function (aSubject, aTopic, aData) { + // do the select identity + // we expect this to succeed right away because of test_identity + // so we don't mock network requests or otherwise + IDService.selectIdentity(aSubject.wrappedJSObject.rpId, id); + }); + + // do the request + IDService.RP.request(mockedDoc.id, {}); + }); +} + +function test_parse_good_email() { + var parsed = IDService.parseEmail('prime-minister@jed.gov'); + do_check_eq(parsed.username, 'prime-minister'); + do_check_eq(parsed.domain, 'jed.gov'); + run_next_test(); +} + +function test_parse_bogus_emails() { + do_check_eq(null, IDService.parseEmail('@evil.org')); + do_check_eq(null, IDService.parseEmail('foo@bar@baz.com')); + do_check_eq(null, IDService.parseEmail('you@wellsfargo.com/accounts/transfer?to=dolske&amt=all')); + run_next_test(); +} + +var TESTS = [test_overall, test_mock_doc]; + +TESTS.push(test_add_identity); +TESTS.push(test_select_identity); +TESTS.push(test_parse_good_email); +TESTS.push(test_parse_bogus_emails); + +TESTS.forEach(add_test); + +function run_test() { + run_next_test(); +} diff --git a/toolkit/identity/tests/unit/test_identity_utils.js b/toolkit/identity/tests/unit/test_identity_utils.js new file mode 100644 index 000000000..6ccc4e311 --- /dev/null +++ b/toolkit/identity/tests/unit/test_identity_utils.js @@ -0,0 +1,46 @@ + +"use strict"; + +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); +Cu.import('resource://gre/modules/Services.jsm'); +Cu.import('resource://gre/modules/identity/IdentityUtils.jsm'); + +function test_check_deprecated() { + let options = { + id: 123, + loggedInEmail: "jed@foo.com", + pies: 42 + }; + + do_check_true(checkDeprecated(options, "loggedInEmail")); + do_check_false(checkDeprecated(options, "flans")); + + run_next_test(); +} + +function test_check_renamed() { + let options = { + id: 123, + loggedInEmail: "jed@foo.com", + pies: 42 + }; + + checkRenamed(options, "loggedInEmail", "loggedInUser"); + + // It moves loggedInEmail to loggedInUser + do_check_false(!!options.loggedInEmail); + do_check_eq(options.loggedInUser, "jed@foo.com"); + + run_next_test(); +} + +var TESTS = [ + test_check_deprecated, + test_check_renamed +]; + +TESTS.forEach(add_test); + +function run_test() { + run_next_test(); +} diff --git a/toolkit/identity/tests/unit/test_jwcrypto.js b/toolkit/identity/tests/unit/test_jwcrypto.js new file mode 100644 index 000000000..f8fe82453 --- /dev/null +++ b/toolkit/identity/tests/unit/test_jwcrypto.js @@ -0,0 +1,281 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict" + +Cu.import('resource://gre/modules/identity/LogUtils.jsm'); + +XPCOMUtils.defineLazyModuleGetter(this, "IDService", + "resource://gre/modules/identity/Identity.jsm", + "IdentityService"); + +XPCOMUtils.defineLazyModuleGetter(this, "jwcrypto", + "resource://gre/modules/identity/jwcrypto.jsm"); + +XPCOMUtils.defineLazyServiceGetter(this, + "CryptoService", + "@mozilla.org/identity/crypto-service;1", + "nsIIdentityCryptoService"); + +const RP_ORIGIN = "http://123done.org"; +const INTERNAL_ORIGIN = "browserid://"; + +const SECOND_MS = 1000; +const MINUTE_MS = SECOND_MS * 60; +const HOUR_MS = MINUTE_MS * 60; + +function test_sanity() { + do_test_pending(); + + jwcrypto.generateKeyPair("DS160", function(err, kp) { + do_check_null(err); + + do_test_finished(); + run_next_test(); + }); +} + +function test_generate() { + do_test_pending(); + jwcrypto.generateKeyPair("DS160", function(err, kp) { + do_check_null(err); + do_check_neq(kp, null); + + do_test_finished(); + run_next_test(); + }); +} + +function test_get_assertion() { + do_test_pending(); + + jwcrypto.generateKeyPair( + "DS160", + function(err, kp) { + jwcrypto.generateAssertion("fake-cert", kp, RP_ORIGIN, (err2, backedAssertion) => { + do_check_null(err2); + + do_check_eq(backedAssertion.split("~").length, 2); + do_check_eq(backedAssertion.split(".").length, 3); + + do_test_finished(); + run_next_test(); + }); + }); +} + +function test_rsa() { + do_test_pending(); + function checkRSA(err, kpo) { + do_check_neq(kpo, undefined); + log(kpo.serializedPublicKey); + let pk = JSON.parse(kpo.serializedPublicKey); + do_check_eq(pk.algorithm, "RS"); +/* TODO + do_check_neq(kpo.sign, null); + do_check_eq(typeof kpo.sign, "function"); + do_check_neq(kpo.userID, null); + do_check_neq(kpo.url, null); + do_check_eq(kpo.url, INTERNAL_ORIGIN); + do_check_neq(kpo.exponent, null); + do_check_neq(kpo.modulus, null); + + // TODO: should sign be async? + let sig = kpo.sign("This is a message to sign"); + + do_check_neq(sig, null); + do_check_eq(typeof sig, "string"); + do_check_true(sig.length > 1); +*/ + do_test_finished(); + run_next_test(); + } + + jwcrypto.generateKeyPair("RS256", checkRSA); +} + +function test_dsa() { + do_test_pending(); + function checkDSA(err, kpo) { + do_check_neq(kpo, undefined); + log(kpo.serializedPublicKey); + let pk = JSON.parse(kpo.serializedPublicKey); + do_check_eq(pk.algorithm, "DS"); +/* TODO + do_check_neq(kpo.sign, null); + do_check_eq(typeof kpo.sign, "function"); + do_check_neq(kpo.userID, null); + do_check_neq(kpo.url, null); + do_check_eq(kpo.url, INTERNAL_ORIGIN); + do_check_neq(kpo.generator, null); + do_check_neq(kpo.prime, null); + do_check_neq(kpo.subPrime, null); + do_check_neq(kpo.publicValue, null); + + let sig = kpo.sign("This is a message to sign"); + + do_check_neq(sig, null); + do_check_eq(typeof sig, "string"); + do_check_true(sig.length > 1); +*/ + do_test_finished(); + run_next_test(); + } + + jwcrypto.generateKeyPair("DS160", checkDSA); +} + +function test_get_assertion_with_offset() { + do_test_pending(); + + + // Use an arbitrary date in the past to ensure we don't accidentally pass + // this test with current dates, missing offsets, etc. + let serverMsec = Date.parse("Tue Oct 31 2000 00:00:00 GMT-0800"); + + // local clock skew + // clock is 12 hours fast; -12 hours offset must be applied + let localtimeOffsetMsec = -1 * 12 * HOUR_MS; + let localMsec = serverMsec - localtimeOffsetMsec; + + jwcrypto.generateKeyPair( + "DS160", + function(err, kp) { + jwcrypto.generateAssertion("fake-cert", kp, RP_ORIGIN, + { duration: MINUTE_MS, + localtimeOffsetMsec: localtimeOffsetMsec, + now: localMsec}, + function(err2, backedAssertion) { + do_check_null(err2); + + // properly formed + let cert; + let assertion; + [cert, assertion] = backedAssertion.split("~"); + + do_check_eq(cert, "fake-cert"); + do_check_eq(assertion.split(".").length, 3); + + let components = extractComponents(assertion); + + // Expiry is within two minutes, corrected for skew + let exp = parseInt(components.payload.exp, 10); + do_check_true(exp - serverMsec === MINUTE_MS); + + do_test_finished(); + run_next_test(); + } + ); + } + ); +} + +function test_assertion_lifetime() { + do_test_pending(); + + jwcrypto.generateKeyPair( + "DS160", + function(err, kp) { + jwcrypto.generateAssertion("fake-cert", kp, RP_ORIGIN, + {duration: MINUTE_MS}, + function(err2, backedAssertion) { + do_check_null(err2); + + // properly formed + let cert; + let assertion; + [cert, assertion] = backedAssertion.split("~"); + + do_check_eq(cert, "fake-cert"); + do_check_eq(assertion.split(".").length, 3); + + let components = extractComponents(assertion); + + // Expiry is within one minute, as we specified above + let exp = parseInt(components.payload.exp, 10); + do_check_true(Math.abs(Date.now() - exp) > 50 * SECOND_MS); + do_check_true(Math.abs(Date.now() - exp) <= MINUTE_MS); + + do_test_finished(); + run_next_test(); + } + ); + } + ); +} + +function test_audience_encoding_bug972582() { + let audience = "i-like-pie.com"; + + jwcrypto.generateKeyPair( + "DS160", + function(err, kp) { + do_check_null(err); + jwcrypto.generateAssertion("fake-cert", kp, audience, + function(err2, backedAssertion) { + do_check_null(err2); + + let [cert, assertion] = backedAssertion.split("~"); + let components = extractComponents(assertion); + do_check_eq(components.payload.aud, audience); + + do_test_finished(); + run_next_test(); + } + ); + } + ); +} + +// End of tests +// Helper function follow + +function extractComponents(signedObject) { + if (typeof(signedObject) != 'string') { + throw new Error("malformed signature " + typeof(signedObject)); + } + + let parts = signedObject.split("."); + if (parts.length != 3) { + throw new Error("signed object must have three parts, this one has " + parts.length); + } + + let headerSegment = parts[0]; + let payloadSegment = parts[1]; + let cryptoSegment = parts[2]; + + let header = JSON.parse(base64UrlDecode(headerSegment)); + let payload = JSON.parse(base64UrlDecode(payloadSegment)); + + // Ensure well-formed header + do_check_eq(Object.keys(header).length, 1); + do_check_true(!!header.alg); + + // Ensure well-formed payload + for (let field of ["exp", "aud"]) { + do_check_true(!!payload[field]); + } + + return {header: header, + payload: payload, + headerSegment: headerSegment, + payloadSegment: payloadSegment, + cryptoSegment: cryptoSegment}; +} + +var TESTS = [ + test_sanity, + test_generate, + test_get_assertion, + test_get_assertion_with_offset, + test_assertion_lifetime, + test_audience_encoding_bug972582, +]; + +TESTS = TESTS.concat([test_rsa, test_dsa]); + +TESTS.forEach(add_test); + +function run_test() { + run_next_test(); +} diff --git a/toolkit/identity/tests/unit/test_load_modules.js b/toolkit/identity/tests/unit/test_load_modules.js new file mode 100644 index 000000000..4c531312c --- /dev/null +++ b/toolkit/identity/tests/unit/test_load_modules.js @@ -0,0 +1,20 @@ +/* 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/. */ + +const modules = [ + "Identity.jsm", + "IdentityProvider.jsm", + "IdentityStore.jsm", + "jwcrypto.jsm", + "RelyingParty.jsm", + "Sandbox.jsm", +]; + +function run_test() { + for (let m of modules) { + let resource = "resource://gre/modules/identity/" + m; + Components.utils.import(resource, {}); + do_print("loaded " + resource); + } +} diff --git a/toolkit/identity/tests/unit/test_log_utils.js b/toolkit/identity/tests/unit/test_log_utils.js new file mode 100644 index 000000000..ac43c297d --- /dev/null +++ b/toolkit/identity/tests/unit/test_log_utils.js @@ -0,0 +1,74 @@ + +"use strict"; + +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); +Cu.import('resource://gre/modules/Services.jsm'); +Cu.import('resource://gre/modules/identity/LogUtils.jsm'); + +function toggle_debug() { + do_test_pending(); + + function Wrapper() { + this.init(); + } + Wrapper.prototype = { + QueryInterface: XPCOMUtils.generateQI([Ci.nsISupports, Ci.nsIObserver]), + + observe: function observe(aSubject, aTopic, aData) { + if (aTopic === "nsPref:changed") { + // race condition? + do_check_eq(Logger._debug, true); + do_test_finished(); + run_next_test(); + } + }, + + init: function() { + Services.prefs.addObserver('toolkit.identity.debug', this, false); + } + }; + + var wrapper = new Wrapper(); + Services.prefs.setBoolPref('toolkit.identity.debug', true); +} + +// test that things don't break + +function logAlias(...args) { + Logger.log.apply(Logger, ["log alias"].concat(args)); +} +function reportErrorAlias(...args) { + Logger.reportError.apply(Logger, ["report error alias"].concat(args)); +} + +function test_log() { + Logger.log("log test", "I like pie"); + do_test_finished(); + run_next_test(); +} + +function test_reportError() { + Logger.reportError("log test", "We are out of pies!!!"); + do_test_finished(); + run_next_test(); +} + +function test_wrappers() { + logAlias("I like potatoes"); + do_test_finished(); + reportErrorAlias("Too much red bull"); +} + +var TESTS = [ +// XXX fix me +// toggle_debug, + test_log, + test_reportError, + test_wrappers, +]; + +TESTS.forEach(add_test); + +function run_test() { + run_next_test(); +} diff --git a/toolkit/identity/tests/unit/test_minimalidentity.js b/toolkit/identity/tests/unit/test_minimalidentity.js new file mode 100644 index 000000000..77c30c84f --- /dev/null +++ b/toolkit/identity/tests/unit/test_minimalidentity.js @@ -0,0 +1,223 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +XPCOMUtils.defineLazyModuleGetter(this, "MinimalIDService", + "resource://gre/modules/identity/MinimalIdentity.jsm", + "IdentityService"); + +Cu.import("resource://gre/modules/identity/LogUtils.jsm"); +Cu.import("resource://gre/modules/DOMIdentity.jsm"); + +function log(...aMessageArgs) { + Logger.log.apply(Logger, ["test_minimalidentity"].concat(aMessageArgs)); +} + +function test_overall() { + do_check_neq(MinimalIDService, null); + run_next_test(); +} + +function test_mock_doc() { + do_test_pending(); + let mockedDoc = mock_doc(null, TEST_URL, function(action, params) { + do_check_eq(action, 'coffee'); + do_test_finished(); + run_next_test(); + }); + + mockedDoc.doCoffee(); +} + +/* + * Test that the "identity-controller-watch" signal is emitted correctly + */ +function test_watch() { + do_test_pending(); + + let mockedDoc = mock_doc(null, TEST_URL); + makeObserver("identity-controller-watch", function (aSubject, aTopic, aData) { + do_check_eq(aSubject.wrappedJSObject.id, mockedDoc.id); + do_check_eq(aSubject.wrappedJSObject.origin, TEST_URL); + do_test_finished(); + run_next_test(); + }); + + MinimalIDService.RP.watch(mockedDoc); +} + +/* + * Test that the "identity-controller-request" signal is emitted correctly + */ +function test_request() { + do_test_pending(); + + let mockedDoc = mock_doc(null, TEST_URL); + makeObserver("identity-controller-request", function (aSubject, aTopic, aData) { + do_check_eq(aSubject.wrappedJSObject.id, mockedDoc.id); + do_check_eq(aSubject.wrappedJSObject.origin, TEST_URL); + do_test_finished(); + run_next_test(); + }); + + MinimalIDService.RP.watch(mockedDoc); + MinimalIDService.RP.request(mockedDoc.id, {}); +} + +/* + * Test that the forceAuthentication flag can be sent + */ +function test_request_forceAuthentication() { + do_test_pending(); + + let mockedDoc = mock_doc(null, TEST_URL); + makeObserver("identity-controller-request", function (aSubject, aTopic, aData) { + do_check_eq(aSubject.wrappedJSObject.id, mockedDoc.id); + do_check_eq(aSubject.wrappedJSObject.origin, TEST_URL); + do_check_eq(aSubject.wrappedJSObject.forceAuthentication, true); + do_test_finished(); + run_next_test(); + }); + + MinimalIDService.RP.watch(mockedDoc); + MinimalIDService.RP.request(mockedDoc.id, {forceAuthentication: true}); +} + +/* + * Test that the issuer can be forced + */ +function test_request_forceIssuer() { + do_test_pending(); + + let mockedDoc = mock_doc(null, TEST_URL); + makeObserver("identity-controller-request", function (aSubject, aTopic, aData) { + do_check_eq(aSubject.wrappedJSObject.id, mockedDoc.id); + do_check_eq(aSubject.wrappedJSObject.origin, TEST_URL); + do_check_eq(aSubject.wrappedJSObject.issuer, "https://jed.gov"); + do_test_finished(); + run_next_test(); + }); + + MinimalIDService.RP.watch(mockedDoc); + MinimalIDService.RP.request(mockedDoc.id, {issuer: "https://jed.gov"}); +} + +/* + * Test that the "identity-controller-logout" signal is emitted correctly + */ +function test_logout() { + do_test_pending(); + + let mockedDoc = mock_doc(null, TEST_URL); + makeObserver("identity-controller-logout", function (aSubject, aTopic, aData) { + do_check_eq(aSubject.wrappedJSObject.id, mockedDoc.id); + do_test_finished(); + run_next_test(); + }); + + MinimalIDService.RP.watch(mockedDoc); + MinimalIDService.RP.logout(mockedDoc.id, {}); +} + +/* + * Test that logout() before watch() fails gently + */ + +function test_logoutBeforeWatch() { + do_test_pending(); + + let mockedDoc = mock_doc(null, TEST_URL); + makeObserver("identity-controller-logout", function() { + do_throw("How can we logout when watch was not called?"); + }); + + MinimalIDService.RP.logout(mockedDoc.id, {}); + do_test_finished(); + run_next_test(); +} + +/* + * Test that request() before watch() fails gently + */ + +function test_requestBeforeWatch() { + do_test_pending(); + + let mockedDoc = mock_doc(null, TEST_URL); + makeObserver("identity-controller-request", function() { + do_throw("How can we request when watch was not called?"); + }); + + MinimalIDService.RP.request(mockedDoc.id, {}); + do_test_finished(); + run_next_test(); +} + +/* + * Test that internal unwatch() before watch() fails gently + */ + +function test_unwatchBeforeWatch() { + do_test_pending(); + + let mockedDoc = mock_doc(null, TEST_URL); + + MinimalIDService.RP.unwatch(mockedDoc.id, {}); + do_test_finished(); + run_next_test(); +} + +/* + * Test that the RP flow is cleaned up on child process shutdown + */ + +function test_childProcessShutdown() { + do_test_pending(); + let UNIQUE_MESSAGE_MANAGER = "i am a beautiful snowflake"; + let initialRPCount = Object.keys(MinimalIDService.RP._rpFlows).length; + + let mockedDoc = mock_doc(null, TEST_URL, (action, params) => { + if (action == "child-process-shutdown") { + // since there's no actual dom window connection, we have to + // do this bit manually here. + MinimalIDService.RP.childProcessShutdown(UNIQUE_MESSAGE_MANAGER); + } + }); + mockedDoc._mm = UNIQUE_MESSAGE_MANAGER; + + makeObserver("identity-controller-watch", function (aSubject, aTopic, aData) { + DOMIdentity._childProcessShutdown(UNIQUE_MESSAGE_MANAGER); + }); + + makeObserver("identity-child-process-shutdown", (aTopic, aSubject, aData) => { + do_check_eq(Object.keys(MinimalIDService.RP._rpFlows).length, initialRPCount); + do_test_finished(); + run_next_test(); + }); + + // fake a dom window context + DOMIdentity.newContext(mockedDoc, UNIQUE_MESSAGE_MANAGER); + + MinimalIDService.RP.watch(mockedDoc); +} + +var TESTS = [ + test_overall, + test_mock_doc, + test_watch, + test_request, + test_request_forceAuthentication, + test_request_forceIssuer, + test_logout, + test_logoutBeforeWatch, + test_requestBeforeWatch, + test_unwatchBeforeWatch, + test_childProcessShutdown, +]; + +TESTS.forEach(add_test); + +function run_test() { + run_next_test(); +} diff --git a/toolkit/identity/tests/unit/test_observer_topics.js b/toolkit/identity/tests/unit/test_observer_topics.js new file mode 100644 index 000000000..8e5a89c91 --- /dev/null +++ b/toolkit/identity/tests/unit/test_observer_topics.js @@ -0,0 +1,114 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * By their nature, these tests duplicate some of the functionality of + * other tests for Identity, RelyingParty, and IdentityProvider. + * + * In particular, "identity-auth-complete" and + * "identity-login-state-changed" are tested in test_authentication.js + */ + +"use strict"; + +XPCOMUtils.defineLazyModuleGetter(this, "IDService", + "resource://gre/modules/identity/Identity.jsm", + "IdentityService"); + +function test_smoke() { + do_check_neq(IDService, null); + run_next_test(); +} + +function test_identity_request() { + // In response to navigator.id.request(), initiate a login with user + // interaction by notifying observers of 'identity-request' + + do_test_pending(); + + IDService.reset(); + + let id = "landru@mockmyid.com"; + setup_test_identity(id, TEST_CERT, function() { + // deliberately adding a trailing final slash on the domain + // to test path composition + let mockedDoc = mock_doc(null, "http://jed.gov/", function() {}); + + // by calling watch() we create an rp flow. + IDService.RP.watch(mockedDoc); + + // register the request UX observer + makeObserver("identity-request", function (aSubject, aTopic, aData) { + do_check_eq(aTopic, "identity-request"); + do_check_eq(aData, null); + + // check that all the URLs are properly resolved + let subj = aSubject.wrappedJSObject; + do_check_eq(subj.privacyPolicy, "http://jed.gov/pp.html"); + do_check_eq(subj.termsOfService, "http://jed.gov/tos.html"); + + do_test_finished(); + run_next_test(); + }); + + let requestOptions = { + privacyPolicy: "/pp.html", + termsOfService: "/tos.html" + }; + IDService.RP.request(mockedDoc.id, requestOptions); + }); + +} + +function test_identity_auth() { + // see test_authentication.js for "identity-auth-complete" + // and "identity-login-state-changed" + + do_test_pending(); + let _provId = "bogus"; + + // Simulate what would be returned by IDService._fetchWellKnownFile + // for a given domain. + let idpParams = { + domain: "myfavoriteflan.com", + idpParams: { + authentication: "/foo/authenticate.html", + provisioning: "/foo/provision.html" + } + }; + + // Create an RP flow + let mockedDoc = mock_doc(null, TEST_URL, function(action, params) {}); + IDService.RP.watch(mockedDoc); + + // The identity-auth notification is sent up to the UX from the + // _doAuthentication function. Be ready to receive it and call + // beginAuthentication + makeObserver("identity-auth", function (aSubject, aTopic, aData) { + do_check_neq(aSubject, null); + do_check_eq(aTopic, "identity-auth"); + do_check_eq(aData, "https://myfavoriteflan.com/foo/authenticate.html"); + + do_check_eq(aSubject.wrappedJSObject.provId, _provId); + do_test_finished(); + run_next_test(); + }); + + // Even though our provisioning flow id is bogus, IdentityProvider + // won't look at it until farther along in the authentication + // process. So this test can pass with a fake provId. + IDService.IDP._doAuthentication(_provId, idpParams); +} + +var TESTS = [ + test_smoke, + test_identity_request, + test_identity_auth, + ]; + + +TESTS.forEach(add_test); + +function run_test() { + run_next_test(); +} diff --git a/toolkit/identity/tests/unit/test_provisioning.js b/toolkit/identity/tests/unit/test_provisioning.js new file mode 100644 index 000000000..c05805bef --- /dev/null +++ b/toolkit/identity/tests/unit/test_provisioning.js @@ -0,0 +1,242 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +Cu.import("resource://gre/modules/identity/IdentityProvider.jsm"); + +function check_provision_flow_done(provId) { + do_check_null(IdentityProvider._provisionFlows[provId]); +} + +function test_begin_provisioning() { + do_test_pending(); + + setup_provisioning( + TEST_USER, + function(caller) { + // call .beginProvisioning() + IdentityProvider.beginProvisioning(caller); + }, function() {}, + { + beginProvisioningCallback: function(email, duration_s) { + do_check_eq(email, TEST_USER); + do_check_true(duration_s > 0); + do_check_true(duration_s <= (24 * 3600)); + + do_test_finished(); + run_next_test(); + } + }); +} + +function test_raise_provisioning_failure() { + do_test_pending(); + let _callerId = null; + + setup_provisioning( + TEST_USER, + function(caller) { + // call .beginProvisioning() + _callerId = caller.id; + IdentityProvider.beginProvisioning(caller); + }, function(err) { + // this should be invoked with a populated error + do_check_neq(err, null); + do_check_true(err.indexOf("can't authenticate this email") > -1); + + do_test_finished(); + run_next_test(); + }, + { + beginProvisioningCallback: function(email, duration_s) { + // raise the failure as if we can't provision this email + IdentityProvider.raiseProvisioningFailure(_callerId, "can't authenticate this email"); + } + }); +} + +function test_genkeypair_before_begin_provisioning() { + do_test_pending(); + + setup_provisioning( + TEST_USER, + function(caller) { + // call genKeyPair without beginProvisioning + IdentityProvider.genKeyPair(caller.id); + }, + // expect this to be called with an error + function(err) { + do_check_neq(err, null); + + do_test_finished(); + run_next_test(); + }, + { + // this should not be called at all! + genKeyPairCallback: function(pk) { + // a test that will surely fail because we shouldn't be here. + do_check_true(false); + + do_test_finished(); + run_next_test(); + } + } + ); +} + +function test_genkeypair() { + do_test_pending(); + let _callerId = null; + + setup_provisioning( + TEST_USER, + function(caller) { + _callerId = caller.id; + IdentityProvider.beginProvisioning(caller); + }, + function(err) { + // should not be called! + do_check_true(false); + + do_test_finished(); + run_next_test(); + }, + { + beginProvisioningCallback: function(email, time_s) { + IdentityProvider.genKeyPair(_callerId); + }, + genKeyPairCallback: function(kp) { + do_check_neq(kp, null); + + // yay! + do_test_finished(); + run_next_test(); + } + } + ); +} + +// we've already ensured that genkeypair can't be called +// before beginProvisioning, so this test should be enough +// to ensure full sequential call of the 3 APIs. +function test_register_certificate_before_genkeypair() { + do_test_pending(); + let _callerID = null; + + setup_provisioning( + TEST_USER, + function(caller) { + // do the right thing for beginProvisioning + _callerID = caller.id; + IdentityProvider.beginProvisioning(caller); + }, + // expect this to be called with an error + function(err) { + do_check_neq(err, null); + + do_test_finished(); + run_next_test(); + }, + { + beginProvisioningCallback: function(email, duration_s) { + // now we try to register cert but no keygen has been done + IdentityProvider.registerCertificate(_callerID, "fake-cert"); + } + } + ); +} + +function test_register_certificate() { + do_test_pending(); + let _callerId = null; + + setup_provisioning( + TEST_USER, + function(caller) { + _callerId = caller.id; + IdentityProvider.beginProvisioning(caller); + }, + function(err) { + // we should be cool! + do_check_null(err); + + // check that the cert is there + let identity = get_idstore().fetchIdentity(TEST_USER); + do_check_neq(identity, null); + do_check_eq(identity.cert, "fake-cert-42"); + + do_execute_soon(function check_done() { + // cleanup will happen after the callback is called + check_provision_flow_done(_callerId); + + do_test_finished(); + run_next_test(); + }); + }, + { + beginProvisioningCallback: function(email, duration_s) { + IdentityProvider.genKeyPair(_callerId); + }, + genKeyPairCallback: function(pk) { + IdentityProvider.registerCertificate(_callerId, "fake-cert-42"); + } + } + ); +} + + +function test_get_assertion_after_provision() { + do_test_pending(); + let _callerId = null; + + setup_provisioning( + TEST_USER, + function(caller) { + _callerId = caller.id; + IdentityProvider.beginProvisioning(caller); + }, + function(err) { + // we should be cool! + do_check_null(err); + + // check that the cert is there + let identity = get_idstore().fetchIdentity(TEST_USER); + do_check_neq(identity, null); + do_check_eq(identity.cert, "fake-cert-42"); + + do_execute_soon(function check_done() { + // cleanup will happen after the callback is called + check_provision_flow_done(_callerId); + + do_test_finished(); + run_next_test(); + }); + }, + { + beginProvisioningCallback: function(email, duration_s) { + IdentityProvider.genKeyPair(_callerId); + }, + genKeyPairCallback: function(pk) { + IdentityProvider.registerCertificate(_callerId, "fake-cert-42"); + } + } + ); + +} + +var TESTS = []; + +TESTS.push(test_begin_provisioning); +TESTS.push(test_raise_provisioning_failure); +TESTS.push(test_genkeypair_before_begin_provisioning); +TESTS.push(test_genkeypair); +TESTS.push(test_register_certificate_before_genkeypair); +TESTS.push(test_register_certificate); +TESTS.push(test_get_assertion_after_provision); + +TESTS.forEach(add_test); + +function run_test() { + run_next_test(); +} diff --git a/toolkit/identity/tests/unit/test_relying_party.js b/toolkit/identity/tests/unit/test_relying_party.js new file mode 100644 index 000000000..e78d22779 --- /dev/null +++ b/toolkit/identity/tests/unit/test_relying_party.js @@ -0,0 +1,255 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +XPCOMUtils.defineLazyModuleGetter(this, "RelyingParty", + "resource://gre/modules/identity/RelyingParty.jsm"); + +function resetState() { + get_idstore().reset(); + RelyingParty.reset(); +} + +function test_watch_loggedin_ready() { + do_test_pending(); + + resetState(); + + let id = TEST_USER; + setup_test_identity(id, TEST_CERT, function() { + let store = get_idstore(); + + // set it up so we're supposed to be logged in to TEST_URL + store.setLoginState(TEST_URL, true, id); + RelyingParty.watch(mock_doc(id, TEST_URL, function(action, params) { + do_check_eq(action, 'ready'); + do_check_eq(params, undefined); + + do_test_finished(); + run_next_test(); + })); + }); +} + +function test_watch_loggedin_login() { + do_test_pending(); + + resetState(); + + let id = TEST_USER; + setup_test_identity(id, TEST_CERT, function() { + let store = get_idstore(); + + // set it up so we're supposed to be logged in to TEST_URL + store.setLoginState(TEST_URL, true, id); + + // check for first a login() call, then a ready() call + RelyingParty.watch(mock_doc(null, TEST_URL, call_sequentially( + function(action, params) { + do_check_eq(action, 'login'); + do_check_neq(params, null); + }, + function(action, params) { + do_check_eq(action, 'ready'); + do_check_null(params); + + do_test_finished(); + run_next_test(); + } + ))); + }); +} + +function test_watch_loggedin_logout() { + do_test_pending(); + + resetState(); + + let id = TEST_USER; + let other_id = "otherid@foo.com"; + setup_test_identity(other_id, TEST_CERT, function() { + setup_test_identity(id, TEST_CERT, function() { + let store = get_idstore(); + + // set it up so we're supposed to be logged in to TEST_URL + // with id, not other_id + store.setLoginState(TEST_URL, true, id); + + // this should cause a login with an assertion for id, + // not for other_id + RelyingParty.watch(mock_doc(other_id, TEST_URL, call_sequentially( + function(action, params) { + do_check_eq(action, 'login'); + do_check_neq(params, null); + }, + function(action, params) { + do_check_eq(action, 'ready'); + do_check_null(params); + + do_test_finished(); + run_next_test(); + } + ))); + }); + }); +} + +function test_watch_notloggedin_ready() { + do_test_pending(); + + resetState(); + + RelyingParty.watch(mock_doc(null, TEST_URL, function(action, params) { + do_check_eq(action, 'ready'); + do_check_eq(params, undefined); + + do_test_finished(); + run_next_test(); + })); +} + +function test_watch_notloggedin_logout() { + do_test_pending(); + + resetState(); + + RelyingParty.watch(mock_doc(TEST_USER, TEST_URL, call_sequentially( + function(action, params) { + do_check_eq(action, 'logout'); + do_check_eq(params, undefined); + + let store = get_idstore(); + do_check_null(store.getLoginState(TEST_URL)); + }, + function(action, params) { + do_check_eq(action, 'ready'); + do_check_eq(params, undefined); + do_test_finished(); + run_next_test(); + } + ))); +} + +function test_request() { + do_test_pending(); + + // set up a watch, to be consistent + let mockedDoc = mock_doc(null, TEST_URL, function(action, params) { + // this isn't going to be called for now + // XXX but it is called - is that bad? + }); + + RelyingParty.watch(mockedDoc); + + // be ready for the UX identity-request notification + makeObserver("identity-request", function (aSubject, aTopic, aData) { + do_check_neq(aSubject, null); + + do_check_eq(aSubject.wrappedJSObject.rpId, mockedDoc.id); + + do_test_finished(); + run_next_test(); + }); + + RelyingParty.request(mockedDoc.id, {}); +} + +/* + * ensure the forceAuthentication param can be passed through + */ +function test_request_forceAuthentication() { + do_test_pending(); + + let mockedDoc = mock_doc(null, TEST_URL, function(action, params) {}); + + RelyingParty.watch(mockedDoc); + + makeObserver("identity-request", function(aSubject, aTopic, aData) { + do_check_eq(aSubject.wrappedJSObject.rpId, mockedDoc.id); + do_check_eq(aSubject.wrappedJSObject.forceAuthentication, true); + do_test_finished(); + run_next_test(); + }); + + RelyingParty.request(mockedDoc.id, {forceAuthentication: true}); +} + +/* + * ensure the issuer can be forced + */ +function test_request_forceIssuer() { + do_test_pending(); + + let mockedDoc = mock_doc(null, TEST_URL, function(action, params) {}); + + RelyingParty.watch(mockedDoc); + + makeObserver("identity-request", function(aSubject, aTopic, aData) { + do_check_eq(aSubject.wrappedJSObject.rpId, mockedDoc.id); + do_check_eq(aSubject.wrappedJSObject.issuer, "https://ozten.co.uk"); + do_test_finished(); + run_next_test(); + }); + + RelyingParty.request(mockedDoc.id, {issuer: "https://ozten.co.uk"}); +} +function test_logout() { + do_test_pending(); + + resetState(); + + let id = TEST_USER; + setup_test_identity(id, TEST_CERT, function() { + let store = get_idstore(); + + // set it up so we're supposed to be logged in to TEST_URL + store.setLoginState(TEST_URL, true, id); + + let doLogout; + let mockedDoc = mock_doc(id, TEST_URL, call_sequentially( + function(action, params) { + do_check_eq(action, 'ready'); + do_check_eq(params, undefined); + + do_timeout(100, doLogout); + }, + function(action, params) { + do_check_eq(action, 'logout'); + do_check_eq(params, undefined); + }, + function(action, params) { + do_check_eq(action, 'ready'); + do_check_eq(params, undefined); + + do_test_finished(); + run_next_test(); + })); + + doLogout = function() { + RelyingParty.logout(mockedDoc.id); + do_check_false(store.getLoginState(TEST_URL).isLoggedIn); + do_check_eq(store.getLoginState(TEST_URL).email, TEST_USER); + }; + + RelyingParty.watch(mockedDoc); + }); +} + +var TESTS = [ + test_watch_loggedin_ready, + test_watch_loggedin_login, + test_watch_loggedin_logout, + test_watch_notloggedin_ready, + test_watch_notloggedin_logout, + test_request, + test_request_forceAuthentication, + test_request_forceIssuer, + test_logout, +]; + +TESTS.forEach(add_test); + +function run_test() { + run_next_test(); +} diff --git a/toolkit/identity/tests/unit/test_store.js b/toolkit/identity/tests/unit/test_store.js new file mode 100644 index 000000000..1cd9cc4dd --- /dev/null +++ b/toolkit/identity/tests/unit/test_store.js @@ -0,0 +1,64 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +XPCOMUtils.defineLazyModuleGetter(this, "IDService", + "resource://gre/modules/identity/Identity.jsm", + "IdentityService"); + +function test_id_store() { + // XXX - this is ugly, peaking in like this into IDService + // probably should instantiate our own. + var store = get_idstore(); + + // try adding an identity + store.addIdentity(TEST_USER, TEST_PRIVKEY, TEST_CERT); + do_check_neq(store.getIdentities()[TEST_USER], null); + do_check_eq(store.getIdentities()[TEST_USER].cert, TEST_CERT); + + // does fetch identity work? + do_check_neq(store.fetchIdentity(TEST_USER), null); + do_check_eq(store.fetchIdentity(TEST_USER).cert, TEST_CERT); + + // clear the cert should keep the identity but not the cert + store.clearCert(TEST_USER); + do_check_neq(store.getIdentities()[TEST_USER], null); + do_check_null(store.getIdentities()[TEST_USER].cert); + + // remove it should remove everything + store.removeIdentity(TEST_USER); + do_check_eq(store.getIdentities()[TEST_USER], undefined); + + // act like we're logged in to TEST_URL + store.setLoginState(TEST_URL, true, TEST_USER); + do_check_neq(store.getLoginState(TEST_URL), null); + do_check_true(store.getLoginState(TEST_URL).isLoggedIn); + do_check_eq(store.getLoginState(TEST_URL).email, TEST_USER); + + // log out + store.setLoginState(TEST_URL, false, TEST_USER); + do_check_neq(store.getLoginState(TEST_URL), null); + do_check_false(store.getLoginState(TEST_URL).isLoggedIn); + + // email is still set + do_check_eq(store.getLoginState(TEST_URL).email, TEST_USER); + + // not logged into other site + do_check_null(store.getLoginState(TEST_URL2)); + + // clear login state + store.clearLoginState(TEST_URL); + do_check_null(store.getLoginState(TEST_URL)); + do_check_null(store.getLoginState(TEST_URL2)); + + run_next_test(); +} + +var TESTS = [test_id_store, ]; + +TESTS.forEach(add_test); + +function run_test() { + run_next_test(); +} diff --git a/toolkit/identity/tests/unit/test_well-known.js b/toolkit/identity/tests/unit/test_well-known.js new file mode 100644 index 000000000..5e86f5ae4 --- /dev/null +++ b/toolkit/identity/tests/unit/test_well-known.js @@ -0,0 +1,90 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); +Cu.import("resource://gre/modules/Services.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, "IDService", + "resource://gre/modules/identity/Identity.jsm", + "IdentityService"); + +const WELL_KNOWN_PATH = "/.well-known/browserid"; + +var SERVER_PORT = 8080; + +// valid IDP +function test_well_known_1() { + do_test_pending(); + + let server = new HttpServer(); + server.registerFile(WELL_KNOWN_PATH, do_get_file("data/idp_1" + WELL_KNOWN_PATH)); + server.start(SERVER_PORT); + let hostPort = "localhost:" + SERVER_PORT; + + function check_well_known(aErr, aCallbackObj) { + do_check_null(aErr); + do_check_eq(aCallbackObj.domain, hostPort); + let idpParams = aCallbackObj.idpParams; + do_check_eq(idpParams['public-key'].algorithm, "RS"); + do_check_eq(idpParams.authentication, "/browserid/sign_in.html"); + do_check_eq(idpParams.provisioning, "/browserid/provision.html"); + + do_test_finished(); + server.stop(run_next_test); + } + + IDService._fetchWellKnownFile(hostPort, check_well_known, "http"); +} + +// valid domain, non-exixtent browserid file +function test_well_known_404() { + do_test_pending(); + + let server = new HttpServer(); + // Don't register the well-known file + // Change ports to avoid HTTP caching + SERVER_PORT++; + server.start(SERVER_PORT); + + let hostPort = "localhost:" + SERVER_PORT; + + function check_well_known_404(aErr, aCallbackObj) { + do_check_eq("Error", aErr); + do_check_eq(undefined, aCallbackObj); + do_test_finished(); + server.stop(run_next_test); + } + + IDService._fetchWellKnownFile(hostPort, check_well_known_404, "http"); +} + +// valid domain, invalid browserid file (no "provisioning" member) +function test_well_known_invalid_1() { + do_test_pending(); + + let server = new HttpServer(); + server.registerFile(WELL_KNOWN_PATH, do_get_file("data/idp_invalid_1" + WELL_KNOWN_PATH)); + // Change ports to avoid HTTP caching + SERVER_PORT++; + server.start(SERVER_PORT); + + let hostPort = "localhost:" + SERVER_PORT; + + function check_well_known_invalid_1(aErr, aCallbackObj) { + // check for an error message + do_check_true(aErr && aErr.length > 0); + do_check_eq(undefined, aCallbackObj); + do_test_finished(); + server.stop(run_next_test); + } + + IDService._fetchWellKnownFile(hostPort, check_well_known_invalid_1, "http"); +} + +var TESTS = [test_well_known_1, test_well_known_404, test_well_known_invalid_1]; + +TESTS.forEach(add_test); + +function run_test() { + run_next_test(); +} diff --git a/toolkit/identity/tests/unit/xpcshell.ini b/toolkit/identity/tests/unit/xpcshell.ini new file mode 100644 index 000000000..8ef9b79bc --- /dev/null +++ b/toolkit/identity/tests/unit/xpcshell.ini @@ -0,0 +1,24 @@ +[DEFAULT] +head = head_identity.js +tail = tail_identity.js +skip-if = (appname != "b2g" || toolkit == 'gonk') +support-files = + data/idp_1/.well-known/browserid + data/idp_invalid_1/.well-known/browserid + +# Test load modules first so syntax failures are caught early. +[test_load_modules.js] +[test_minimalidentity.js] +[test_firefox_accounts.js] + +[test_identity_utils.js] +[test_log_utils.js] +[test_authentication.js] +[test_crypto_service.js] +[test_identity.js] +[test_jwcrypto.js] +[test_observer_topics.js] +[test_provisioning.js] +[test_relying_party.js] +[test_store.js] +[test_well-known.js] |