summaryrefslogtreecommitdiffstats
path: root/services/fxaccounts
diff options
context:
space:
mode:
Diffstat (limited to 'services/fxaccounts')
-rw-r--r--services/fxaccounts/Credentials.jsm136
-rw-r--r--services/fxaccounts/FxAccounts.jsm1735
-rw-r--r--services/fxaccounts/FxAccountsClient.jsm623
-rw-r--r--services/fxaccounts/FxAccountsCommon.js368
-rw-r--r--services/fxaccounts/FxAccountsComponents.manifest4
-rw-r--r--services/fxaccounts/FxAccountsConfig.jsm179
-rw-r--r--services/fxaccounts/FxAccountsManager.jsm654
-rw-r--r--services/fxaccounts/FxAccountsOAuthClient.jsm269
-rw-r--r--services/fxaccounts/FxAccountsOAuthGrantClient.jsm241
-rw-r--r--services/fxaccounts/FxAccountsProfile.jsm191
-rw-r--r--services/fxaccounts/FxAccountsProfileClient.jsm260
-rw-r--r--services/fxaccounts/FxAccountsPush.js240
-rw-r--r--services/fxaccounts/FxAccountsStorage.jsm609
-rw-r--r--services/fxaccounts/FxAccountsWebChannel.jsm474
-rw-r--r--services/fxaccounts/interfaces/moz.build11
-rw-r--r--services/fxaccounts/interfaces/nsIFxAccountsUIGlue.idl15
-rw-r--r--services/fxaccounts/moz.build35
-rw-r--r--services/fxaccounts/tests/mochitest/chrome.ini7
-rw-r--r--services/fxaccounts/tests/mochitest/file_invalidEmailCase.sjs80
-rw-r--r--services/fxaccounts/tests/mochitest/test_invalidEmailCase.html131
-rw-r--r--services/fxaccounts/tests/xpcshell/head.js18
-rw-r--r--services/fxaccounts/tests/xpcshell/test_accounts.js1531
-rw-r--r--services/fxaccounts/tests/xpcshell/test_accounts_device_registration.js526
-rw-r--r--services/fxaccounts/tests/xpcshell/test_client.js917
-rw-r--r--services/fxaccounts/tests/xpcshell/test_credentials.js110
-rw-r--r--services/fxaccounts/tests/xpcshell/test_loginmgr_storage.js214
-rw-r--r--services/fxaccounts/tests/xpcshell/test_oauth_client.js55
-rw-r--r--services/fxaccounts/tests/xpcshell/test_oauth_grant_client.js292
-rw-r--r--services/fxaccounts/tests/xpcshell/test_oauth_grant_client_server.js73
-rw-r--r--services/fxaccounts/tests/xpcshell/test_oauth_token_storage.js165
-rw-r--r--services/fxaccounts/tests/xpcshell/test_oauth_tokens.js251
-rw-r--r--services/fxaccounts/tests/xpcshell/test_profile.js409
-rw-r--r--services/fxaccounts/tests/xpcshell/test_profile_client.js411
-rw-r--r--services/fxaccounts/tests/xpcshell/test_push_service.js236
-rw-r--r--services/fxaccounts/tests/xpcshell/test_storage_manager.js477
-rw-r--r--services/fxaccounts/tests/xpcshell/test_web_channel.js499
-rw-r--r--services/fxaccounts/tests/xpcshell/xpcshell.ini23
37 files changed, 12469 insertions, 0 deletions
diff --git a/services/fxaccounts/Credentials.jsm b/services/fxaccounts/Credentials.jsm
new file mode 100644
index 000000000..56e8b3db7
--- /dev/null
+++ b/services/fxaccounts/Credentials.jsm
@@ -0,0 +1,136 @@
+/* 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 module implements client-side key stretching for use in Firefox
+ * Accounts account creation and login.
+ *
+ * See https://github.com/mozilla/fxa-auth-server/wiki/onepw-protocol
+ */
+
+"use strict";
+
+this.EXPORTED_SYMBOLS = ["Credentials"];
+
+const {utils: Cu, interfaces: Ci} = Components;
+
+Cu.import("resource://gre/modules/Log.jsm");
+Cu.import("resource://gre/modules/Services.jsm");
+Cu.import("resource://gre/modules/Promise.jsm");
+Cu.import("resource://services-crypto/utils.js");
+Cu.import("resource://services-common/utils.js");
+
+const PROTOCOL_VERSION = "identity.mozilla.com/picl/v1/";
+const PBKDF2_ROUNDS = 1000;
+const STRETCHED_PW_LENGTH_BYTES = 32;
+const HKDF_SALT = CommonUtils.hexToBytes("00");
+const HKDF_LENGTH = 32;
+const HMAC_ALGORITHM = Ci.nsICryptoHMAC.SHA256;
+const HMAC_LENGTH = 32;
+
+// 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()));
+
+this.Credentials = Object.freeze({
+ /**
+ * Make constants accessible to tests
+ */
+ constants: {
+ PROTOCOL_VERSION: PROTOCOL_VERSION,
+ PBKDF2_ROUNDS: PBKDF2_ROUNDS,
+ STRETCHED_PW_LENGTH_BYTES: STRETCHED_PW_LENGTH_BYTES,
+ HKDF_SALT: HKDF_SALT,
+ HKDF_LENGTH: HKDF_LENGTH,
+ HMAC_ALGORITHM: HMAC_ALGORITHM,
+ HMAC_LENGTH: HMAC_LENGTH,
+ },
+
+ /**
+ * KW function from https://github.com/mozilla/fxa-auth-server/wiki/onepw-protocol
+ *
+ * keyWord derivation for use as a salt.
+ *
+ *
+ * @param {String} context String for use in generating salt
+ *
+ * @return {bitArray} the salt
+ *
+ * Note that PROTOCOL_VERSION does not refer in any way to the version of the
+ * Firefox Accounts API.
+ */
+ keyWord: function(context) {
+ return CommonUtils.stringToBytes(PROTOCOL_VERSION + context);
+ },
+
+ /**
+ * KWE function from https://github.com/mozilla/fxa-auth-server/wiki/onepw-protocol
+ *
+ * keyWord extended with a name and an email.
+ *
+ * @param {String} name The name of the salt
+ * @param {String} email The email of the user.
+ *
+ * @return {bitArray} the salt combination with the namespace
+ *
+ * Note that PROTOCOL_VERSION does not refer in any way to the version of the
+ * Firefox Accounts API.
+ */
+ keyWordExtended: function(name, email) {
+ return CommonUtils.stringToBytes(PROTOCOL_VERSION + name + ':' + email);
+ },
+
+ setup: function(emailInput, passwordInput, options={}) {
+ let deferred = Promise.defer();
+ log.debug("setup credentials for " + emailInput);
+
+ let hkdfSalt = options.hkdfSalt || HKDF_SALT;
+ let hkdfLength = options.hkdfLength || HKDF_LENGTH;
+ let hmacLength = options.hmacLength || HMAC_LENGTH;
+ let hmacAlgorithm = options.hmacAlgorithm || HMAC_ALGORITHM;
+ let stretchedPWLength = options.stretchedPassLength || STRETCHED_PW_LENGTH_BYTES;
+ let pbkdf2Rounds = options.pbkdf2Rounds || PBKDF2_ROUNDS;
+
+ let result = {};
+
+ let password = CommonUtils.encodeUTF8(passwordInput);
+ let salt = this.keyWordExtended("quickStretch", emailInput);
+
+ let runnable = () => {
+ let start = Date.now();
+ let quickStretchedPW = CryptoUtils.pbkdf2Generate(
+ password, salt, pbkdf2Rounds, stretchedPWLength, hmacAlgorithm, hmacLength);
+
+ result.quickStretchedPW = quickStretchedPW;
+
+ result.authPW =
+ CryptoUtils.hkdf(quickStretchedPW, hkdfSalt, this.keyWord("authPW"), hkdfLength);
+
+ result.unwrapBKey =
+ CryptoUtils.hkdf(quickStretchedPW, hkdfSalt, this.keyWord("unwrapBkey"), hkdfLength);
+
+ log.debug("Credentials set up after " + (Date.now() - start) + " ms");
+ deferred.resolve(result);
+ }
+
+ Services.tm.currentThread.dispatch(runnable,
+ Ci.nsIThread.DISPATCH_NORMAL);
+ log.debug("Dispatched thread for credentials setup crypto work");
+
+ return deferred.promise;
+ }
+});
+
diff --git a/services/fxaccounts/FxAccounts.jsm b/services/fxaccounts/FxAccounts.jsm
new file mode 100644
index 000000000..5bed881ea
--- /dev/null
+++ b/services/fxaccounts/FxAccounts.jsm
@@ -0,0 +1,1735 @@
+/* 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 = ["fxAccounts", "FxAccounts"];
+
+const {classes: Cc, interfaces: Ci, utils: Cu} = Components;
+
+Cu.import("resource://gre/modules/Log.jsm");
+Cu.import("resource://gre/modules/Promise.jsm");
+Cu.import("resource://services-common/utils.js");
+Cu.import("resource://services-common/rest.js");
+Cu.import("resource://services-crypto/utils.js");
+Cu.import("resource://gre/modules/Services.jsm");
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+Cu.import("resource://gre/modules/Timer.jsm");
+Cu.import("resource://gre/modules/Task.jsm");
+Cu.import("resource://gre/modules/FxAccountsStorage.jsm");
+Cu.import("resource://gre/modules/FxAccountsCommon.js");
+
+XPCOMUtils.defineLazyModuleGetter(this, "FxAccountsClient",
+ "resource://gre/modules/FxAccountsClient.jsm");
+
+XPCOMUtils.defineLazyModuleGetter(this, "FxAccountsConfig",
+ "resource://gre/modules/FxAccountsConfig.jsm");
+
+XPCOMUtils.defineLazyModuleGetter(this, "jwcrypto",
+ "resource://gre/modules/identity/jwcrypto.jsm");
+
+XPCOMUtils.defineLazyModuleGetter(this, "FxAccountsOAuthGrantClient",
+ "resource://gre/modules/FxAccountsOAuthGrantClient.jsm");
+
+XPCOMUtils.defineLazyModuleGetter(this, "FxAccountsProfile",
+ "resource://gre/modules/FxAccountsProfile.jsm");
+
+XPCOMUtils.defineLazyModuleGetter(this, "Utils",
+ "resource://services-sync/util.js");
+
+// All properties exposed by the public FxAccounts API.
+var publicProperties = [
+ "accountStatus",
+ "checkVerificationStatus",
+ "getAccountsClient",
+ "getAssertion",
+ "getDeviceId",
+ "getKeys",
+ "getOAuthToken",
+ "getSignedInUser",
+ "getSignedInUserProfile",
+ "handleDeviceDisconnection",
+ "invalidateCertificate",
+ "loadAndPoll",
+ "localtimeOffsetMsec",
+ "notifyDevices",
+ "now",
+ "promiseAccountsChangeProfileURI",
+ "promiseAccountsForceSigninURI",
+ "promiseAccountsManageURI",
+ "promiseAccountsSignUpURI",
+ "promiseAccountsSignInURI",
+ "removeCachedOAuthToken",
+ "requiresHttps",
+ "resendVerificationEmail",
+ "resetCredentials",
+ "sessionStatus",
+ "setSignedInUser",
+ "signOut",
+ "updateDeviceRegistration",
+ "updateUserAccountData",
+ "whenVerified",
+];
+
+// An AccountState object holds all state related to one specific account.
+// Only one AccountState is ever "current" in the FxAccountsInternal object -
+// whenever a user logs out or logs in, the current AccountState is discarded,
+// making it impossible for the wrong state or state data to be accidentally
+// used.
+// In addition, it has some promise-related helpers to ensure that if an
+// attempt is made to resolve a promise on a "stale" state (eg, if an
+// operation starts, but a different user logs in before the operation
+// completes), the promise will be rejected.
+// It is intended to be used thusly:
+// somePromiseBasedFunction: function() {
+// let currentState = this.currentAccountState;
+// return someOtherPromiseFunction().then(
+// data => currentState.resolve(data)
+// );
+// }
+// If the state has changed between the function being called and the promise
+// being resolved, the .resolve() call will actually be rejected.
+var AccountState = this.AccountState = function(storageManager) {
+ this.storageManager = storageManager;
+ this.promiseInitialized = this.storageManager.getAccountData().then(data => {
+ this.oauthTokens = data && data.oauthTokens ? data.oauthTokens : {};
+ }).catch(err => {
+ log.error("Failed to initialize the storage manager", err);
+ // Things are going to fall apart, but not much we can do about it here.
+ });
+};
+
+AccountState.prototype = {
+ oauthTokens: null,
+ whenVerifiedDeferred: null,
+ whenKeysReadyDeferred: null,
+
+ // If the storage manager has been nuked then we are no longer current.
+ get isCurrent() {
+ return this.storageManager != null;
+ },
+
+ abort() {
+ if (this.whenVerifiedDeferred) {
+ this.whenVerifiedDeferred.reject(
+ new Error("Verification aborted; Another user signing in"));
+ this.whenVerifiedDeferred = null;
+ }
+
+ if (this.whenKeysReadyDeferred) {
+ this.whenKeysReadyDeferred.reject(
+ new Error("Verification aborted; Another user signing in"));
+ this.whenKeysReadyDeferred = null;
+ }
+
+ this.cert = null;
+ this.keyPair = null;
+ this.oauthTokens = null;
+ // Avoid finalizing the storageManager multiple times (ie, .signOut()
+ // followed by .abort())
+ if (!this.storageManager) {
+ return Promise.resolve();
+ }
+ let storageManager = this.storageManager;
+ this.storageManager = null;
+ return storageManager.finalize();
+ },
+
+ // Clobber all cached data and write that empty data to storage.
+ signOut() {
+ this.cert = null;
+ this.keyPair = null;
+ this.oauthTokens = null;
+ let storageManager = this.storageManager;
+ this.storageManager = null;
+ return storageManager.deleteAccountData().then(() => {
+ return storageManager.finalize();
+ });
+ },
+
+ // Get user account data. Optionally specify explicit field names to fetch
+ // (and note that if you require an in-memory field you *must* specify the
+ // field name(s).)
+ getUserAccountData(fieldNames = null) {
+ if (!this.isCurrent) {
+ return Promise.reject(new Error("Another user has signed in"));
+ }
+ return this.storageManager.getAccountData(fieldNames).then(result => {
+ return this.resolve(result);
+ });
+ },
+
+ updateUserAccountData(updatedFields) {
+ if (!this.isCurrent) {
+ return Promise.reject(new Error("Another user has signed in"));
+ }
+ return this.storageManager.updateAccountData(updatedFields);
+ },
+
+ resolve: function(result) {
+ if (!this.isCurrent) {
+ log.info("An accountState promise was resolved, but was actually rejected" +
+ " due to a different user being signed in. Originally resolved" +
+ " with", result);
+ return Promise.reject(new Error("A different user signed in"));
+ }
+ return Promise.resolve(result);
+ },
+
+ reject: function(error) {
+ // It could be argued that we should just let it reject with the original
+ // error - but this runs the risk of the error being (eg) a 401, which
+ // might cause the consumer to attempt some remediation and cause other
+ // problems.
+ if (!this.isCurrent) {
+ log.info("An accountState promise was rejected, but we are ignoring that" +
+ "reason and rejecting it due to a different user being signed in." +
+ "Originally rejected with", error);
+ return Promise.reject(new Error("A different user signed in"));
+ }
+ return Promise.reject(error);
+ },
+
+ // Abstractions for storage of cached tokens - these are all sync, and don't
+ // handle revocation etc - it's just storage (and the storage itself is async,
+ // but we don't return the storage promises, so it *looks* sync)
+ // These functions are sync simply so we can handle "token races" - when there
+ // are multiple in-flight requests for the same scope, we can detect this
+ // and revoke the redundant token.
+
+ // A preamble for the cache helpers...
+ _cachePreamble() {
+ if (!this.isCurrent) {
+ throw new Error("Another user has signed in");
+ }
+ },
+
+ // Set a cached token. |tokenData| must have a 'token' element, but may also
+ // have additional fields (eg, it probably specifies the server to revoke
+ // from). The 'get' functions below return the entire |tokenData| value.
+ setCachedToken(scopeArray, tokenData) {
+ this._cachePreamble();
+ if (!tokenData.token) {
+ throw new Error("No token");
+ }
+ let key = getScopeKey(scopeArray);
+ this.oauthTokens[key] = tokenData;
+ // And a background save...
+ this._persistCachedTokens();
+ },
+
+ // Return data for a cached token or null (or throws on bad state etc)
+ getCachedToken(scopeArray) {
+ this._cachePreamble();
+ let key = getScopeKey(scopeArray);
+ let result = this.oauthTokens[key];
+ if (result) {
+ // later we might want to check an expiry date - but we currently
+ // have no such concept, so just return it.
+ log.trace("getCachedToken returning cached token");
+ return result;
+ }
+ return null;
+ },
+
+ // Remove a cached token from the cache. Does *not* revoke it from anywhere.
+ // Returns the entire token entry if found, null otherwise.
+ removeCachedToken(token) {
+ this._cachePreamble();
+ let data = this.oauthTokens;
+ for (let [key, tokenValue] of Object.entries(data)) {
+ if (tokenValue.token == token) {
+ delete data[key];
+ // And a background save...
+ this._persistCachedTokens();
+ return tokenValue;
+ }
+ }
+ return null;
+ },
+
+ // A hook-point for tests. Returns a promise that's ignored in most cases
+ // (notable exceptions are tests and when we explicitly are saving the entire
+ // set of user data.)
+ _persistCachedTokens() {
+ this._cachePreamble();
+ return this.updateUserAccountData({ oauthTokens: this.oauthTokens }).catch(err => {
+ log.error("Failed to update cached tokens", err);
+ });
+ },
+}
+
+/* Given an array of scopes, make a string key by normalizing. */
+function getScopeKey(scopeArray) {
+ let normalizedScopes = scopeArray.map(item => item.toLowerCase());
+ return normalizedScopes.sort().join("|");
+}
+
+/**
+ * Copies properties from a given object to another object.
+ *
+ * @param from (object)
+ * The object we read property descriptors from.
+ * @param to (object)
+ * The object that we set property descriptors on.
+ * @param options (object) (optional)
+ * {keys: [...]}
+ * Lets the caller pass the names of all properties they want to be
+ * copied. Will copy all properties of the given source object by
+ * default.
+ * {bind: object}
+ * Lets the caller specify the object that will be used to .bind()
+ * all function properties we find to. Will bind to the given target
+ * object by default.
+ */
+function copyObjectProperties(from, to, opts = {}) {
+ let keys = (opts && opts.keys) || Object.keys(from);
+ let thisArg = (opts && opts.bind) || to;
+
+ for (let prop of keys) {
+ let desc = Object.getOwnPropertyDescriptor(from, prop);
+
+ if (typeof(desc.value) == "function") {
+ desc.value = desc.value.bind(thisArg);
+ }
+
+ if (desc.get) {
+ desc.get = desc.get.bind(thisArg);
+ }
+
+ if (desc.set) {
+ desc.set = desc.set.bind(thisArg);
+ }
+
+ Object.defineProperty(to, prop, desc);
+ }
+}
+
+function urlsafeBase64Encode(key) {
+ return ChromeUtils.base64URLEncode(new Uint8Array(key), { pad: false });
+}
+
+/**
+ * The public API's constructor.
+ */
+this.FxAccounts = function (mockInternal) {
+ let internal = new FxAccountsInternal();
+ let external = {};
+
+ // Copy all public properties to the 'external' object.
+ let prototype = FxAccountsInternal.prototype;
+ let options = {keys: publicProperties, bind: internal};
+ copyObjectProperties(prototype, external, options);
+
+ // Copy all of the mock's properties to the internal object.
+ if (mockInternal && !mockInternal.onlySetInternal) {
+ copyObjectProperties(mockInternal, internal);
+ }
+
+ if (mockInternal) {
+ // Exposes the internal object for testing only.
+ external.internal = internal;
+ }
+
+ if (!internal.fxaPushService) {
+ // internal.fxaPushService option is used in testing.
+ // Otherwise we load the service lazily.
+ XPCOMUtils.defineLazyGetter(internal, "fxaPushService", function () {
+ return Components.classes["@mozilla.org/fxaccounts/push;1"]
+ .getService(Components.interfaces.nsISupports)
+ .wrappedJSObject;
+ });
+ }
+
+ // wait until after the mocks are setup before initializing.
+ internal.initialize();
+
+ return Object.freeze(external);
+}
+
+/**
+ * The internal API's constructor.
+ */
+function FxAccountsInternal() {
+ // Make a local copy of this constant so we can mock it in testing
+ this.POLL_SESSION = POLL_SESSION;
+
+ // All significant initialization should be done in the initialize() method
+ // below as it helps with testing.
+}
+
+/**
+ * The internal API's prototype.
+ */
+FxAccountsInternal.prototype = {
+ // The timeout (in ms) we use to poll for a verified mail for the first 2 mins.
+ VERIFICATION_POLL_TIMEOUT_INITIAL: 15000, // 15 seconds
+ // And how often we poll after the first 2 mins.
+ VERIFICATION_POLL_TIMEOUT_SUBSEQUENT: 30000, // 30 seconds.
+ // The current version of the device registration, we use this to re-register
+ // devices after we update what we send on device registration.
+ DEVICE_REGISTRATION_VERSION: 2,
+
+ _fxAccountsClient: null,
+
+ // All significant initialization should be done in this initialize() method,
+ // as it's called after this object has been mocked for tests.
+ initialize() {
+ this.currentTimer = null;
+ this.currentAccountState = this.newAccountState();
+ },
+
+ get fxAccountsClient() {
+ if (!this._fxAccountsClient) {
+ this._fxAccountsClient = new FxAccountsClient();
+ }
+ return this._fxAccountsClient;
+ },
+
+ // The profile object used to fetch the actual user profile.
+ _profile: null,
+ get profile() {
+ if (!this._profile) {
+ let profileServerUrl = Services.urlFormatter.formatURLPref("identity.fxaccounts.remote.profile.uri");
+ this._profile = new FxAccountsProfile({
+ fxa: this,
+ profileServerUrl: profileServerUrl,
+ });
+ }
+ return this._profile;
+ },
+
+ // A hook-point for tests who may want a mocked AccountState or mocked storage.
+ newAccountState(credentials) {
+ let storage = new FxAccountsStorageManager();
+ storage.initialize(credentials);
+ return new AccountState(storage);
+ },
+
+ /**
+ * Send a message to a set of devices in the same account
+ *
+ * @return Promise
+ */
+ notifyDevices: function(deviceIds, payload, TTL) {
+ if (!Array.isArray(deviceIds)) {
+ deviceIds = [deviceIds];
+ }
+ return this.currentAccountState.getUserAccountData()
+ .then(data => {
+ if (!data) {
+ throw this._error(ERROR_NO_ACCOUNT);
+ }
+ if (!data.sessionToken) {
+ throw this._error(ERROR_AUTH_ERROR,
+ "notifyDevices called without a session token");
+ }
+ return this.fxAccountsClient.notifyDevices(data.sessionToken, deviceIds,
+ payload, TTL);
+ });
+ },
+
+ /**
+ * Return the current time in milliseconds as an integer. Allows tests to
+ * manipulate the date to simulate certificate expiration.
+ */
+ now: function() {
+ return this.fxAccountsClient.now();
+ },
+
+ getAccountsClient: function() {
+ return this.fxAccountsClient;
+ },
+
+ /**
+ * Return clock offset in milliseconds, as reported by the fxAccountsClient.
+ * This can be overridden for testing.
+ *
+ * The offset is the number of milliseconds that must be added to the client
+ * clock to make it equal to the server clock. For example, if the client is
+ * five minutes ahead of the server, the localtimeOffsetMsec will be -300000.
+ */
+ get localtimeOffsetMsec() {
+ return this.fxAccountsClient.localtimeOffsetMsec;
+ },
+
+ /**
+ * Ask the server whether the user's email has been verified
+ */
+ checkEmailStatus: function checkEmailStatus(sessionToken, options = {}) {
+ if (!sessionToken) {
+ return Promise.reject(new Error(
+ "checkEmailStatus called without a session token"));
+ }
+ return this.fxAccountsClient.recoveryEmailStatus(sessionToken,
+ options).catch(error => this._handleTokenError(error));
+ },
+
+ /**
+ * Once the user's email is verified, we can request the keys
+ */
+ fetchKeys: function fetchKeys(keyFetchToken) {
+ log.debug("fetchKeys: " + !!keyFetchToken);
+ if (logPII) {
+ log.debug("fetchKeys - the token is " + keyFetchToken);
+ }
+ return this.fxAccountsClient.accountKeys(keyFetchToken);
+ },
+
+ // set() makes sure that polling is happening, if necessary.
+ // get() does not wait for verification, and returns an object even if
+ // unverified. The caller of get() must check .verified .
+ // The "fxaccounts:onverified" event will fire only when the verified
+ // state goes from false to true, so callers must register their observer
+ // and then call get(). In particular, it will not fire when the account
+ // was found to be verified in a previous boot: if our stored state says
+ // the account is verified, the event will never fire. So callers must do:
+ // register notification observer (go)
+ // userdata = get()
+ // if (userdata.verified()) {go()}
+
+ /**
+ * Get the user currently signed in to Firefox Accounts.
+ *
+ * @return Promise
+ * The promise resolves to the credentials object of the signed-in user:
+ * {
+ * email: The user's email address
+ * uid: The user's unique id
+ * sessionToken: Session for the FxA server
+ * kA: An encryption key from the FxA server
+ * kB: An encryption key derived from the user's FxA password
+ * verified: email verification status
+ * authAt: The time (seconds since epoch) that this record was
+ * authenticated
+ * }
+ * or null if no user is signed in.
+ */
+ getSignedInUser: function getSignedInUser() {
+ let currentState = this.currentAccountState;
+ return currentState.getUserAccountData().then(data => {
+ if (!data) {
+ return null;
+ }
+ if (!this.isUserEmailVerified(data)) {
+ // If the email is not verified, start polling for verification,
+ // but return null right away. We don't want to return a promise
+ // that might not be fulfilled for a long time.
+ this.startVerifiedCheck(data);
+ }
+ return data;
+ }).then(result => currentState.resolve(result));
+ },
+
+ /**
+ * Set the current user signed in to Firefox Accounts.
+ *
+ * @param credentials
+ * The credentials object obtained by logging in or creating
+ * an account on the FxA server:
+ * {
+ * authAt: The time (seconds since epoch) that this record was
+ * authenticated
+ * email: The users email address
+ * keyFetchToken: a keyFetchToken which has not yet been used
+ * sessionToken: Session for the FxA server
+ * uid: The user's unique id
+ * unwrapBKey: used to unwrap kB, derived locally from the
+ * password (not revealed to the FxA server)
+ * verified: true/false
+ * }
+ * @return Promise
+ * The promise resolves to null when the data is saved
+ * successfully and is rejected on error.
+ */
+ setSignedInUser: function setSignedInUser(credentials) {
+ log.debug("setSignedInUser - aborting any existing flows");
+ return this.abortExistingFlow().then(() => {
+ let currentAccountState = this.currentAccountState = this.newAccountState(
+ Cu.cloneInto(credentials, {}) // Pass a clone of the credentials object.
+ );
+ // This promise waits for storage, but not for verification.
+ // We're telling the caller that this is durable now (although is that
+ // really something we should commit to? Why not let the write happen in
+ // the background? Already does for updateAccountData ;)
+ return currentAccountState.promiseInitialized.then(() => {
+ // Starting point for polling if new user
+ if (!this.isUserEmailVerified(credentials)) {
+ this.startVerifiedCheck(credentials);
+ }
+
+ return this.updateDeviceRegistration();
+ }).then(() => {
+ Services.telemetry.getHistogramById("FXA_CONFIGURED").add(1);
+ this.notifyObservers(ONLOGIN_NOTIFICATION);
+ }).then(() => {
+ return currentAccountState.resolve();
+ });
+ })
+ },
+
+ /**
+ * Update account data for the currently signed in user.
+ *
+ * @param credentials
+ * The credentials object containing the fields to be updated.
+ * This object must contain |email| and |uid| fields and they must
+ * match the currently signed in user.
+ */
+ updateUserAccountData(credentials) {
+ log.debug("updateUserAccountData called with fields", Object.keys(credentials));
+ if (logPII) {
+ log.debug("updateUserAccountData called with data", credentials);
+ }
+ let currentAccountState = this.currentAccountState;
+ return currentAccountState.promiseInitialized.then(() => {
+ return currentAccountState.getUserAccountData(["email", "uid"]);
+ }).then(existing => {
+ if (existing.email != credentials.email || existing.uid != credentials.uid) {
+ throw new Error("The specified credentials aren't for the current user");
+ }
+ // We need to nuke email and uid as storage will complain if we try and
+ // update them (even when the value is the same)
+ credentials = Cu.cloneInto(credentials, {}); // clone it first
+ delete credentials.email;
+ delete credentials.uid;
+ return currentAccountState.updateUserAccountData(credentials);
+ });
+ },
+
+ /**
+ * returns a promise that fires with the assertion. If there is no verified
+ * signed-in user, fires with null.
+ */
+ getAssertion: function getAssertion(audience) {
+ return this._getAssertion(audience);
+ },
+
+ // getAssertion() is "public" so screws with our mock story. This
+ // implementation method *can* be (and is) mocked by tests.
+ _getAssertion: function _getAssertion(audience) {
+ log.debug("enter getAssertion()");
+ let currentState = this.currentAccountState;
+ return currentState.getUserAccountData().then(data => {
+ if (!data) {
+ // No signed-in user
+ return null;
+ }
+ if (!this.isUserEmailVerified(data)) {
+ // Signed-in user has not verified email
+ return null;
+ }
+ if (!data.sessionToken) {
+ // can't get a signed certificate without a session token. This
+ // can happen if we request an assertion after clearing an invalid
+ // session token from storage.
+ throw this._error(ERROR_AUTH_ERROR, "getAssertion called without a session token");
+ }
+ return this.getKeypairAndCertificate(currentState).then(
+ ({keyPair, certificate}) => {
+ return this.getAssertionFromCert(data, keyPair, certificate, audience);
+ }
+ );
+ }).catch(err =>
+ this._handleTokenError(err)
+ ).then(result => currentState.resolve(result));
+ },
+
+ /**
+ * Invalidate the FxA certificate, so that it will be refreshed from the server
+ * the next time it is needed.
+ */
+ invalidateCertificate() {
+ return this.currentAccountState.updateUserAccountData({ cert: null });
+ },
+
+ getDeviceId() {
+ return this.currentAccountState.getUserAccountData()
+ .then(data => {
+ if (data) {
+ if (!data.deviceId || !data.deviceRegistrationVersion ||
+ data.deviceRegistrationVersion < this.DEVICE_REGISTRATION_VERSION) {
+ // There is no device id or the device registration is outdated.
+ // Either way, we should register the device with FxA
+ // before returning the id to the caller.
+ return this._registerOrUpdateDevice(data);
+ }
+
+ // Return the device id that we already registered with the server.
+ return data.deviceId;
+ }
+
+ // Without a signed-in user, there can be no device id.
+ return null;
+ });
+ },
+
+ /**
+ * Resend the verification email fot the currently signed-in user.
+ *
+ */
+ resendVerificationEmail: function resendVerificationEmail() {
+ let currentState = this.currentAccountState;
+ return this.getSignedInUser().then(data => {
+ // If the caller is asking for verification to be re-sent, and there is
+ // no signed-in user to begin with, this is probably best regarded as an
+ // error.
+ if (data) {
+ if (!data.sessionToken) {
+ return Promise.reject(new Error(
+ "resendVerificationEmail called without a session token"));
+ }
+ this.pollEmailStatus(currentState, data.sessionToken, "start");
+ return this.fxAccountsClient.resendVerificationEmail(
+ data.sessionToken).catch(err => this._handleTokenError(err));
+ }
+ throw new Error("Cannot resend verification email; no signed-in user");
+ });
+ },
+
+ /*
+ * Reset state such that any previous flow is canceled.
+ */
+ abortExistingFlow: function abortExistingFlow() {
+ if (this.currentTimer) {
+ log.debug("Polling aborted; Another user signing in");
+ clearTimeout(this.currentTimer);
+ this.currentTimer = 0;
+ }
+ if (this._profile) {
+ this._profile.tearDown();
+ this._profile = null;
+ }
+ // We "abort" the accountState and assume our caller is about to throw it
+ // away and replace it with a new one.
+ return this.currentAccountState.abort();
+ },
+
+ accountStatus: function accountStatus() {
+ return this.currentAccountState.getUserAccountData().then(data => {
+ if (!data) {
+ return false;
+ }
+ return this.fxAccountsClient.accountStatus(data.uid);
+ });
+ },
+
+ checkVerificationStatus: function() {
+ log.trace('checkVerificationStatus');
+ let currentState = this.currentAccountState;
+ return currentState.getUserAccountData().then(data => {
+ if (!data) {
+ log.trace("checkVerificationStatus - no user data");
+ return null;
+ }
+
+ // Always check the verification status, even if the local state indicates
+ // we're already verified. If the user changed their password, the check
+ // will fail, and we'll enter the reauth state.
+ log.trace("checkVerificationStatus - forcing verification status check");
+ return this.pollEmailStatus(currentState, data.sessionToken, "push");
+ });
+ },
+
+ _destroyOAuthToken: function(tokenData) {
+ let client = new FxAccountsOAuthGrantClient({
+ serverURL: tokenData.server,
+ client_id: FX_OAUTH_CLIENT_ID
+ });
+ return client.destroyToken(tokenData.token)
+ },
+
+ _destroyAllOAuthTokens: function(tokenInfos) {
+ // let's just destroy them all in parallel...
+ let promises = [];
+ for (let [key, tokenInfo] of Object.entries(tokenInfos || {})) {
+ promises.push(this._destroyOAuthToken(tokenInfo));
+ }
+ return Promise.all(promises);
+ },
+
+ signOut: function signOut(localOnly) {
+ let currentState = this.currentAccountState;
+ let sessionToken;
+ let tokensToRevoke;
+ let deviceId;
+ return currentState.getUserAccountData().then(data => {
+ // Save the session token, tokens to revoke and the
+ // device id for use in the call to signOut below.
+ if (data) {
+ sessionToken = data.sessionToken;
+ tokensToRevoke = data.oauthTokens;
+ deviceId = data.deviceId;
+ }
+ return this._signOutLocal();
+ }).then(() => {
+ // FxAccountsManager calls here, then does its own call
+ // to FxAccountsClient.signOut().
+ if (!localOnly) {
+ // Wrap this in a promise so *any* errors in signOut won't
+ // block the local sign out. This is *not* returned.
+ Promise.resolve().then(() => {
+ // This can happen in the background and shouldn't block
+ // the user from signing out. The server must tolerate
+ // clients just disappearing, so this call should be best effort.
+ if (sessionToken) {
+ return this._signOutServer(sessionToken, deviceId);
+ }
+ log.warn("Missing session token; skipping remote sign out");
+ }).catch(err => {
+ log.error("Error during remote sign out of Firefox Accounts", err);
+ }).then(() => {
+ return this._destroyAllOAuthTokens(tokensToRevoke);
+ }).catch(err => {
+ log.error("Error during destruction of oauth tokens during signout", err);
+ }).then(() => {
+ FxAccountsConfig.resetConfigURLs();
+ // just for testing - notifications are cheap when no observers.
+ this.notifyObservers("testhelper-fxa-signout-complete");
+ })
+ } else {
+ // We want to do this either way -- but if we're signing out remotely we
+ // need to wait until we destroy the oauth tokens if we want that to succeed.
+ FxAccountsConfig.resetConfigURLs();
+ }
+ }).then(() => {
+ this.notifyObservers(ONLOGOUT_NOTIFICATION);
+ });
+ },
+
+ /**
+ * This function should be called in conjunction with a server-side
+ * signOut via FxAccountsClient.
+ */
+ _signOutLocal: function signOutLocal() {
+ let currentAccountState = this.currentAccountState;
+ return currentAccountState.signOut().then(() => {
+ // this "aborts" this.currentAccountState but doesn't make a new one.
+ return this.abortExistingFlow();
+ }).then(() => {
+ this.currentAccountState = this.newAccountState();
+ return this.currentAccountState.promiseInitialized;
+ });
+ },
+
+ _signOutServer(sessionToken, deviceId) {
+ // For now we assume the service being logged out from is Sync, so
+ // we must tell the server to either destroy the device or sign out
+ // (if no device exists). We might need to revisit this when this
+ // FxA code is used in a context that isn't Sync.
+
+ const options = { service: "sync" };
+
+ if (deviceId) {
+ log.debug("destroying device and session");
+ return this.fxAccountsClient.signOutAndDestroyDevice(sessionToken, deviceId, options);
+ }
+
+ log.debug("destroying session");
+ return this.fxAccountsClient.signOut(sessionToken, options);
+ },
+
+ /**
+ * Check the status of the current session using cached credentials.
+ *
+ * @return Promise
+ * Resolves with a boolean indicating if the session is still valid
+ */
+ sessionStatus() {
+ return this.getSignedInUser().then(data => {
+ if (!data.sessionToken) {
+ return Promise.reject(new Error(
+ "sessionStatus called without a session token"));
+ }
+ return this.fxAccountsClient.sessionStatus(data.sessionToken);
+ });
+ },
+
+ /**
+ * Fetch encryption keys for the signed-in-user from the FxA API server.
+ *
+ * Not for user consumption. Exists to cause the keys to be fetch.
+ *
+ * Returns user data so that it can be chained with other methods.
+ *
+ * @return Promise
+ * The promise resolves to the credentials object of the signed-in user:
+ * {
+ * email: The user's email address
+ * uid: The user's unique id
+ * sessionToken: Session for the FxA server
+ * kA: An encryption key from the FxA server
+ * kB: An encryption key derived from the user's FxA password
+ * verified: email verification status
+ * }
+ * or null if no user is signed in
+ */
+ getKeys: function() {
+ let currentState = this.currentAccountState;
+ return currentState.getUserAccountData().then((userData) => {
+ if (!userData) {
+ throw new Error("Can't get keys; User is not signed in");
+ }
+ if (userData.kA && userData.kB) {
+ return userData;
+ }
+ if (!currentState.whenKeysReadyDeferred) {
+ currentState.whenKeysReadyDeferred = Promise.defer();
+ if (userData.keyFetchToken) {
+ this.fetchAndUnwrapKeys(userData.keyFetchToken).then(
+ (dataWithKeys) => {
+ if (!dataWithKeys.kA || !dataWithKeys.kB) {
+ currentState.whenKeysReadyDeferred.reject(
+ new Error("user data missing kA or kB")
+ );
+ return;
+ }
+ currentState.whenKeysReadyDeferred.resolve(dataWithKeys);
+ },
+ (err) => {
+ currentState.whenKeysReadyDeferred.reject(err);
+ }
+ );
+ } else {
+ currentState.whenKeysReadyDeferred.reject('No keyFetchToken');
+ }
+ }
+ return currentState.whenKeysReadyDeferred.promise;
+ }).catch(err =>
+ this._handleTokenError(err)
+ ).then(result => currentState.resolve(result));
+ },
+
+ fetchAndUnwrapKeys: function(keyFetchToken) {
+ if (logPII) {
+ log.debug("fetchAndUnwrapKeys: token: " + keyFetchToken);
+ }
+ let currentState = this.currentAccountState;
+ return Task.spawn(function* task() {
+ // Sign out if we don't have a key fetch token.
+ if (!keyFetchToken) {
+ log.warn("improper fetchAndUnwrapKeys() call: token missing");
+ yield this.signOut();
+ return null;
+ }
+
+ let {kA, wrapKB} = yield this.fetchKeys(keyFetchToken);
+
+ let data = yield currentState.getUserAccountData();
+
+ // Sanity check that the user hasn't changed out from under us
+ if (data.keyFetchToken !== keyFetchToken) {
+ throw new Error("Signed in user changed while fetching keys!");
+ }
+
+ // Next statements must be synchronous until we setUserAccountData
+ // so that we don't risk getting into a weird state.
+ let kB_hex = CryptoUtils.xor(CommonUtils.hexToBytes(data.unwrapBKey),
+ wrapKB);
+
+ if (logPII) {
+ log.debug("kB_hex: " + kB_hex);
+ }
+ let updateData = {
+ kA: CommonUtils.bytesAsHex(kA),
+ kB: CommonUtils.bytesAsHex(kB_hex),
+ keyFetchToken: null, // null values cause the item to be removed.
+ unwrapBKey: null,
+ }
+
+ log.debug("Keys Obtained: kA=" + !!updateData.kA + ", kB=" + !!updateData.kB);
+ if (logPII) {
+ log.debug("Keys Obtained: kA=" + updateData.kA + ", kB=" + updateData.kB);
+ }
+
+ yield currentState.updateUserAccountData(updateData);
+ // We are now ready for business. This should only be invoked once
+ // per setSignedInUser(), regardless of whether we've rebooted since
+ // setSignedInUser() was called.
+ this.notifyObservers(ONVERIFIED_NOTIFICATION);
+ return currentState.getUserAccountData();
+ }.bind(this)).then(result => currentState.resolve(result));
+ },
+
+ getAssertionFromCert: function(data, keyPair, cert, audience) {
+ log.debug("getAssertionFromCert");
+ let payload = {};
+ let d = Promise.defer();
+ let options = {
+ duration: ASSERTION_LIFETIME,
+ localtimeOffsetMsec: this.localtimeOffsetMsec,
+ now: this.now()
+ };
+ let currentState = this.currentAccountState;
+ // "audience" should look like "http://123done.org".
+ // The generated assertion will expire in two minutes.
+ jwcrypto.generateAssertion(cert, keyPair, audience, options, (err, signed) => {
+ if (err) {
+ log.error("getAssertionFromCert: " + err);
+ d.reject(err);
+ } else {
+ log.debug("getAssertionFromCert returning signed: " + !!signed);
+ if (logPII) {
+ log.debug("getAssertionFromCert returning signed: " + signed);
+ }
+ d.resolve(signed);
+ }
+ });
+ return d.promise.then(result => currentState.resolve(result));
+ },
+
+ getCertificateSigned: function(sessionToken, serializedPublicKey, lifetime) {
+ log.debug("getCertificateSigned: " + !!sessionToken + " " + !!serializedPublicKey);
+ if (logPII) {
+ log.debug("getCertificateSigned: " + sessionToken + " " + serializedPublicKey);
+ }
+ return this.fxAccountsClient.signCertificate(
+ sessionToken,
+ JSON.parse(serializedPublicKey),
+ lifetime
+ );
+ },
+
+ /**
+ * returns a promise that fires with {keyPair, certificate}.
+ */
+ getKeypairAndCertificate: Task.async(function* (currentState) {
+ // If the debugging pref to ignore cached authentication credentials is set for Sync,
+ // then don't use any cached key pair/certificate, i.e., generate a new
+ // one and get it signed.
+ // The purpose of this pref is to expedite any auth errors as the result of a
+ // expired or revoked FxA session token, e.g., from resetting or changing the FxA
+ // password.
+ let ignoreCachedAuthCredentials = false;
+ try {
+ ignoreCachedAuthCredentials = Services.prefs.getBoolPref("services.sync.debug.ignoreCachedAuthCredentials");
+ } catch(e) {
+ // Pref doesn't exist
+ }
+ let mustBeValidUntil = this.now() + ASSERTION_USE_PERIOD;
+ let accountData = yield currentState.getUserAccountData(["cert", "keyPair", "sessionToken"]);
+
+ let keyPairValid = !ignoreCachedAuthCredentials &&
+ accountData.keyPair &&
+ (accountData.keyPair.validUntil > mustBeValidUntil);
+ let certValid = !ignoreCachedAuthCredentials &&
+ accountData.cert &&
+ (accountData.cert.validUntil > mustBeValidUntil);
+ // TODO: get the lifetime from the cert's .exp field
+ if (keyPairValid && certValid) {
+ log.debug("getKeypairAndCertificate: already have keyPair and certificate");
+ return {
+ keyPair: accountData.keyPair.rawKeyPair,
+ certificate: accountData.cert.rawCert
+ }
+ }
+ // We are definately going to generate a new cert, either because it has
+ // already expired, or the keyPair has - and a new keyPair means we must
+ // generate a new cert.
+
+ // A keyPair has a longer lifetime than a cert, so it's possible we will
+ // have a valid keypair but an expired cert, which means we can skip
+ // keypair generation.
+ // Either way, the cert will require hitting the network, so bail now if
+ // we know that's going to fail.
+ if (Services.io.offline) {
+ throw new Error(ERROR_OFFLINE);
+ }
+
+ let keyPair;
+ if (keyPairValid) {
+ keyPair = accountData.keyPair;
+ } else {
+ let keyWillBeValidUntil = this.now() + KEY_LIFETIME;
+ keyPair = yield new Promise((resolve, reject) => {
+ jwcrypto.generateKeyPair("DS160", (err, kp) => {
+ if (err) {
+ return reject(err);
+ }
+ log.debug("got keyPair");
+ resolve({
+ rawKeyPair: kp,
+ validUntil: keyWillBeValidUntil,
+ });
+ });
+ });
+ }
+
+ // and generate the cert.
+ let certWillBeValidUntil = this.now() + CERT_LIFETIME;
+ let certificate = yield this.getCertificateSigned(accountData.sessionToken,
+ keyPair.rawKeyPair.serializedPublicKey,
+ CERT_LIFETIME);
+ log.debug("getCertificate got a new one: " + !!certificate);
+ if (certificate) {
+ // Cache both keypair and cert.
+ let toUpdate = {
+ keyPair,
+ cert: {
+ rawCert: certificate,
+ validUntil: certWillBeValidUntil,
+ },
+ };
+ yield currentState.updateUserAccountData(toUpdate);
+ }
+ return {
+ keyPair: keyPair.rawKeyPair,
+ certificate: certificate,
+ }
+ }),
+
+ getUserAccountData: function() {
+ return this.currentAccountState.getUserAccountData();
+ },
+
+ isUserEmailVerified: function isUserEmailVerified(data) {
+ return !!(data && data.verified);
+ },
+
+ /**
+ * Setup for and if necessary do email verification polling.
+ */
+ loadAndPoll: function() {
+ let currentState = this.currentAccountState;
+ return currentState.getUserAccountData()
+ .then(data => {
+ if (data) {
+ Services.telemetry.getHistogramById("FXA_CONFIGURED").add(1);
+ if (!this.isUserEmailVerified(data)) {
+ this.pollEmailStatus(currentState, data.sessionToken, "start");
+ }
+ }
+ return data;
+ });
+ },
+
+ startVerifiedCheck: function(data) {
+ log.debug("startVerifiedCheck", data && data.verified);
+ if (logPII) {
+ log.debug("startVerifiedCheck with user data", data);
+ }
+
+ // Get us to the verified state, then get the keys. This returns a promise
+ // that will fire when we are completely ready.
+ //
+ // Login is truly complete once keys have been fetched, so once getKeys()
+ // obtains and stores kA and kB, it will fire the onverified observer
+ // notification.
+
+ // The callers of startVerifiedCheck never consume a returned promise (ie,
+ // this is simply kicking off a background fetch) so we must add a rejection
+ // handler to avoid runtime warnings about the rejection not being handled.
+ this.whenVerified(data).then(
+ () => this.getKeys(),
+ err => log.info("startVerifiedCheck promise was rejected: " + err)
+ );
+ },
+
+ whenVerified: function(data) {
+ let currentState = this.currentAccountState;
+ if (data.verified) {
+ log.debug("already verified");
+ return currentState.resolve(data);
+ }
+ if (!currentState.whenVerifiedDeferred) {
+ log.debug("whenVerified promise starts polling for verified email");
+ this.pollEmailStatus(currentState, data.sessionToken, "start");
+ }
+ return currentState.whenVerifiedDeferred.promise.then(
+ result => currentState.resolve(result)
+ );
+ },
+
+ notifyObservers: function(topic, data) {
+ log.debug("Notifying observers of " + topic);
+ Services.obs.notifyObservers(null, topic, data);
+ },
+
+ // XXX - pollEmailStatus should maybe be on the AccountState object?
+ pollEmailStatus: function pollEmailStatus(currentState, sessionToken, why) {
+ log.debug("entering pollEmailStatus: " + why);
+ if (why == "start" || why == "push") {
+ if (this.currentTimer) {
+ log.debug("pollEmailStatus starting while existing timer is running");
+ clearTimeout(this.currentTimer);
+ this.currentTimer = null;
+ }
+
+ // If we were already polling, stop and start again. This could happen
+ // if the user requested the verification email to be resent while we
+ // were already polling for receipt of an earlier email.
+ this.pollStartDate = Date.now();
+ if (!currentState.whenVerifiedDeferred) {
+ currentState.whenVerifiedDeferred = Promise.defer();
+ // This deferred might not end up with any handlers (eg, if sync
+ // is yet to start up.) This might cause "A promise chain failed to
+ // handle a rejection" messages, so add an error handler directly
+ // on the promise to log the error.
+ currentState.whenVerifiedDeferred.promise.then(null, err => {
+ log.info("the wait for user verification was stopped: " + err);
+ });
+ }
+ }
+
+ // We return a promise for testing only. Other callers can ignore this,
+ // since verification polling continues in the background.
+ return this.checkEmailStatus(sessionToken, { reason: why })
+ .then((response) => {
+ log.debug("checkEmailStatus -> " + JSON.stringify(response));
+ if (response && response.verified) {
+ currentState.updateUserAccountData({ verified: true })
+ .then(() => {
+ return currentState.getUserAccountData();
+ })
+ .then(data => {
+ // Now that the user is verified, we can proceed to fetch keys
+ if (currentState.whenVerifiedDeferred) {
+ currentState.whenVerifiedDeferred.resolve(data);
+ delete currentState.whenVerifiedDeferred;
+ }
+ // Tell FxAccountsManager to clear its cache
+ this.notifyObservers(ON_FXA_UPDATE_NOTIFICATION, ONVERIFIED_NOTIFICATION);
+ });
+ } else {
+ // Poll email status again after a short delay.
+ this.pollEmailStatusAgain(currentState, sessionToken);
+ }
+ }, error => {
+ let timeoutMs = undefined;
+ if (error && error.retryAfter) {
+ // If the server told us to back off, back off the requested amount.
+ timeoutMs = (error.retryAfter + 3) * 1000;
+ }
+ // The server will return 401 if a request parameter is erroneous or
+ // if the session token expired. Let's continue polling otherwise.
+ if (!error || !error.code || error.code != 401) {
+ this.pollEmailStatusAgain(currentState, sessionToken, timeoutMs);
+ } else {
+ let error = new Error("Verification status check failed");
+ this._rejectWhenVerified(currentState, error);
+ }
+ });
+ },
+
+ _rejectWhenVerified(currentState, error) {
+ currentState.whenVerifiedDeferred.reject(error);
+ delete currentState.whenVerifiedDeferred;
+ },
+
+ // Poll email status using truncated exponential back-off.
+ pollEmailStatusAgain: function (currentState, sessionToken, timeoutMs) {
+ let ageMs = Date.now() - this.pollStartDate;
+ if (ageMs >= this.POLL_SESSION) {
+ if (currentState.whenVerifiedDeferred) {
+ let error = new Error("User email verification timed out.");
+ this._rejectWhenVerified(currentState, error);
+ }
+ log.debug("polling session exceeded, giving up");
+ return;
+ }
+ if (timeoutMs === undefined) {
+ let currentMinute = Math.ceil(ageMs / 60000);
+ timeoutMs = currentMinute <= 2 ? this.VERIFICATION_POLL_TIMEOUT_INITIAL
+ : this.VERIFICATION_POLL_TIMEOUT_SUBSEQUENT;
+ }
+ log.debug("polling with timeout = " + timeoutMs);
+ this.currentTimer = setTimeout(() => {
+ this.pollEmailStatus(currentState, sessionToken, "timer");
+ }, timeoutMs);
+ },
+
+ requiresHttps: function() {
+ let allowHttp = false;
+ try {
+ allowHttp = Services.prefs.getBoolPref("identity.fxaccounts.allowHttp");
+ } catch(e) {
+ // Pref doesn't exist
+ }
+ return allowHttp !== true;
+ },
+
+ promiseAccountsSignUpURI() {
+ return FxAccountsConfig.promiseAccountsSignUpURI();
+ },
+
+ promiseAccountsSignInURI() {
+ return FxAccountsConfig.promiseAccountsSignInURI();
+ },
+
+ // Returns a promise that resolves with the URL to use to force a re-signin
+ // of the current account.
+ promiseAccountsForceSigninURI: Task.async(function *() {
+ yield FxAccountsConfig.ensureConfigured();
+ let url = Services.urlFormatter.formatURLPref("identity.fxaccounts.remote.force_auth.uri");
+ if (this.requiresHttps() && !/^https:/.test(url)) { // Comment to un-break emacs js-mode highlighting
+ throw new Error("Firefox Accounts server must use HTTPS");
+ }
+ let currentState = this.currentAccountState;
+ // but we need to append the email address onto a query string.
+ return this.getSignedInUser().then(accountData => {
+ if (!accountData) {
+ return null;
+ }
+ let newQueryPortion = url.indexOf("?") == -1 ? "?" : "&";
+ newQueryPortion += "email=" + encodeURIComponent(accountData.email);
+ return url + newQueryPortion;
+ }).then(result => currentState.resolve(result));
+ }),
+
+ // Returns a promise that resolves with the URL to use to change
+ // the current account's profile image.
+ // if settingToEdit is set, the profile page should hightlight that setting
+ // for the user to edit.
+ promiseAccountsChangeProfileURI: function(entrypoint, settingToEdit = null) {
+ let url = Services.urlFormatter.formatURLPref("identity.fxaccounts.settings.uri");
+
+ if (settingToEdit) {
+ url += (url.indexOf("?") == -1 ? "?" : "&") +
+ "setting=" + encodeURIComponent(settingToEdit);
+ }
+
+ if (this.requiresHttps() && !/^https:/.test(url)) { // Comment to un-break emacs js-mode highlighting
+ throw new Error("Firefox Accounts server must use HTTPS");
+ }
+ let currentState = this.currentAccountState;
+ // but we need to append the email address onto a query string.
+ return this.getSignedInUser().then(accountData => {
+ if (!accountData) {
+ return null;
+ }
+ let newQueryPortion = url.indexOf("?") == -1 ? "?" : "&";
+ newQueryPortion += "email=" + encodeURIComponent(accountData.email);
+ newQueryPortion += "&uid=" + encodeURIComponent(accountData.uid);
+ if (entrypoint) {
+ newQueryPortion += "&entrypoint=" + encodeURIComponent(entrypoint);
+ }
+ return url + newQueryPortion;
+ }).then(result => currentState.resolve(result));
+ },
+
+ // Returns a promise that resolves with the URL to use to manage the current
+ // user's FxA acct.
+ promiseAccountsManageURI: function(entrypoint) {
+ let url = Services.urlFormatter.formatURLPref("identity.fxaccounts.settings.uri");
+ if (this.requiresHttps() && !/^https:/.test(url)) { // Comment to un-break emacs js-mode highlighting
+ throw new Error("Firefox Accounts server must use HTTPS");
+ }
+ let currentState = this.currentAccountState;
+ // but we need to append the uid and email address onto a query string
+ // (if the server has no matching uid it will offer to sign in with the
+ // email address)
+ return this.getSignedInUser().then(accountData => {
+ if (!accountData) {
+ return null;
+ }
+ let newQueryPortion = url.indexOf("?") == -1 ? "?" : "&";
+ newQueryPortion += "uid=" + encodeURIComponent(accountData.uid) +
+ "&email=" + encodeURIComponent(accountData.email);
+ if (entrypoint) {
+ newQueryPortion += "&entrypoint=" + encodeURIComponent(entrypoint);
+ }
+ return url + newQueryPortion;
+ }).then(result => currentState.resolve(result));
+ },
+
+ /**
+ * Get an OAuth token for the user
+ *
+ * @param options
+ * {
+ * scope: (string/array) the oauth scope(s) being requested. As a
+ * convenience, you may pass a string if only one scope is
+ * required, or an array of strings if multiple are needed.
+ * }
+ *
+ * @return Promise.<string | Error>
+ * The promise resolves the oauth token as a string or rejects with
+ * an error object ({error: ERROR, details: {}}) of the following:
+ * INVALID_PARAMETER
+ * NO_ACCOUNT
+ * UNVERIFIED_ACCOUNT
+ * NETWORK_ERROR
+ * AUTH_ERROR
+ * UNKNOWN_ERROR
+ */
+ getOAuthToken: Task.async(function* (options = {}) {
+ log.debug("getOAuthToken enter");
+ let scope = options.scope;
+ if (typeof scope === "string") {
+ scope = [scope];
+ }
+
+ if (!scope || !scope.length) {
+ throw this._error(ERROR_INVALID_PARAMETER, "Missing or invalid 'scope' option");
+ }
+
+ yield this._getVerifiedAccountOrReject();
+
+ // Early exit for a cached token.
+ let currentState = this.currentAccountState;
+ let cached = currentState.getCachedToken(scope);
+ if (cached) {
+ log.debug("getOAuthToken returning a cached token");
+ return cached.token;
+ }
+
+ // We are going to hit the server - this is the string we pass to it.
+ let scopeString = scope.join(" ");
+ let client = options.client;
+
+ if (!client) {
+ try {
+ let defaultURL = Services.urlFormatter.formatURLPref("identity.fxaccounts.remote.oauth.uri");
+ client = new FxAccountsOAuthGrantClient({
+ serverURL: defaultURL,
+ client_id: FX_OAUTH_CLIENT_ID
+ });
+ } catch (e) {
+ throw this._error(ERROR_INVALID_PARAMETER, e);
+ }
+ }
+ let oAuthURL = client.serverURL.href;
+
+ try {
+ log.debug("getOAuthToken fetching new token from", oAuthURL);
+ let assertion = yield this.getAssertion(oAuthURL);
+ let result = yield client.getTokenFromAssertion(assertion, scopeString);
+ let token = result.access_token;
+ // If we got one, cache it.
+ if (token) {
+ let entry = {token: token, server: oAuthURL};
+ // But before we do, check the cache again - if we find one now, it
+ // means someone else concurrently requested the same scope and beat
+ // us to the cache write. To be nice to the server, we revoke the one
+ // we just got and return the newly cached value.
+ let cached = currentState.getCachedToken(scope);
+ if (cached) {
+ log.debug("Detected a race for this token - revoking the new one.");
+ this._destroyOAuthToken(entry);
+ return cached.token;
+ }
+ currentState.setCachedToken(scope, entry);
+ }
+ return token;
+ } catch (err) {
+ throw this._errorToErrorClass(err);
+ }
+ }),
+
+ /**
+ * Remove an OAuth token from the token cache. Callers should call this
+ * after they determine a token is invalid, so a new token will be fetched
+ * on the next call to getOAuthToken().
+ *
+ * @param options
+ * {
+ * token: (string) A previously fetched token.
+ * }
+ * @return Promise.<undefined> This function will always resolve, even if
+ * an unknown token is passed.
+ */
+ removeCachedOAuthToken: Task.async(function* (options) {
+ if (!options.token || typeof options.token !== "string") {
+ throw this._error(ERROR_INVALID_PARAMETER, "Missing or invalid 'token' option");
+ }
+ let currentState = this.currentAccountState;
+ let existing = currentState.removeCachedToken(options.token);
+ if (existing) {
+ // background destroy.
+ this._destroyOAuthToken(existing).catch(err => {
+ log.warn("FxA failed to revoke a cached token", err);
+ });
+ }
+ }),
+
+ _getVerifiedAccountOrReject: Task.async(function* () {
+ let data = yield this.currentAccountState.getUserAccountData();
+ if (!data) {
+ // No signed-in user
+ throw this._error(ERROR_NO_ACCOUNT);
+ }
+ if (!this.isUserEmailVerified(data)) {
+ // Signed-in user has not verified email
+ throw this._error(ERROR_UNVERIFIED_ACCOUNT);
+ }
+ }),
+
+ /*
+ * Coerce an error into one of the general error cases:
+ * NETWORK_ERROR
+ * AUTH_ERROR
+ * UNKNOWN_ERROR
+ *
+ * These errors will pass through:
+ * INVALID_PARAMETER
+ * NO_ACCOUNT
+ * UNVERIFIED_ACCOUNT
+ */
+ _errorToErrorClass: function (aError) {
+ if (aError.errno) {
+ let error = SERVER_ERRNO_TO_ERROR[aError.errno];
+ return this._error(ERROR_TO_GENERAL_ERROR_CLASS[error] || ERROR_UNKNOWN, aError);
+ } else if (aError.message &&
+ (aError.message === "INVALID_PARAMETER" ||
+ aError.message === "NO_ACCOUNT" ||
+ aError.message === "UNVERIFIED_ACCOUNT" ||
+ aError.message === "AUTH_ERROR")) {
+ return aError;
+ }
+ return this._error(ERROR_UNKNOWN, aError);
+ },
+
+ _error: function(aError, aDetails) {
+ log.error("FxA rejecting with error ${aError}, details: ${aDetails}", {aError, aDetails});
+ let reason = new Error(aError);
+ if (aDetails) {
+ reason.details = aDetails;
+ }
+ return reason;
+ },
+
+ /**
+ * Get the user's account and profile data
+ *
+ * @param options
+ * {
+ * contentUrl: (string) Used by the FxAccountsWebChannel.
+ * Defaults to pref identity.fxaccounts.settings.uri
+ * profileServerUrl: (string) Used by the FxAccountsWebChannel.
+ * Defaults to pref identity.fxaccounts.remote.profile.uri
+ * }
+ *
+ * @return Promise.<object | Error>
+ * The promise resolves to an accountData object with extra profile
+ * information such as profileImageUrl, or rejects with
+ * an error object ({error: ERROR, details: {}}) of the following:
+ * INVALID_PARAMETER
+ * NO_ACCOUNT
+ * UNVERIFIED_ACCOUNT
+ * NETWORK_ERROR
+ * AUTH_ERROR
+ * UNKNOWN_ERROR
+ */
+ getSignedInUserProfile: function () {
+ let currentState = this.currentAccountState;
+ return this.profile.getProfile().then(
+ profileData => {
+ let profile = Cu.cloneInto(profileData, {});
+ return currentState.resolve(profile);
+ },
+ error => {
+ log.error("Could not retrieve profile data", error);
+ return currentState.reject(error);
+ }
+ ).catch(err => Promise.reject(this._errorToErrorClass(err)));
+ },
+
+ // Attempt to update the auth server with whatever device details are stored
+ // in the account data. Returns a promise that always resolves, never rejects.
+ // If the promise resolves to a value, that value is the device id.
+ updateDeviceRegistration() {
+ return this.getSignedInUser().then(signedInUser => {
+ if (signedInUser) {
+ return this._registerOrUpdateDevice(signedInUser);
+ }
+ }).catch(error => this._logErrorAndResetDeviceRegistrationVersion(error));
+ },
+
+ handleDeviceDisconnection(deviceId) {
+ return this.currentAccountState.getUserAccountData()
+ .then(data => data ? data.deviceId : null)
+ .then(localDeviceId => {
+ if (deviceId == localDeviceId) {
+ this.notifyObservers(ON_DEVICE_DISCONNECTED_NOTIFICATION, deviceId);
+ return this.signOut(true);
+ }
+ log.error(
+ "The device ID to disconnect doesn't match with the local device ID.\n"
+ + "Local: " + localDeviceId + ", ID to disconnect: " + deviceId);
+ });
+ },
+
+ /**
+ * Delete all the cached persisted credentials we store for FxA.
+ *
+ * @return Promise resolves when the user data has been persisted
+ */
+ resetCredentials() {
+ // Delete all fields except those required for the user to
+ // reauthenticate.
+ let updateData = {};
+ let clearField = field => {
+ if (!FXA_PWDMGR_REAUTH_WHITELIST.has(field)) {
+ updateData[field] = null;
+ }
+ }
+ FXA_PWDMGR_PLAINTEXT_FIELDS.forEach(clearField);
+ FXA_PWDMGR_SECURE_FIELDS.forEach(clearField);
+ FXA_PWDMGR_MEMORY_FIELDS.forEach(clearField);
+
+ let currentState = this.currentAccountState;
+ return currentState.updateUserAccountData(updateData);
+ },
+
+ // If you change what we send to the FxA servers during device registration,
+ // you'll have to bump the DEVICE_REGISTRATION_VERSION number to force older
+ // devices to re-register when Firefox updates
+ _registerOrUpdateDevice(signedInUser) {
+ try {
+ // Allow tests to skip device registration because:
+ // 1. It makes remote requests to the auth server.
+ // 2. _getDeviceName does not work from xpcshell.
+ // 3. The B2G tests fail when attempting to import services-sync/util.js.
+ if (Services.prefs.getBoolPref("identity.fxaccounts.skipDeviceRegistration")) {
+ return Promise.resolve();
+ }
+ } catch(ignore) {}
+
+ if (!signedInUser.sessionToken) {
+ return Promise.reject(new Error(
+ "_registerOrUpdateDevice called without a session token"));
+ }
+
+ return this.fxaPushService.registerPushEndpoint().then(subscription => {
+ const deviceName = this._getDeviceName();
+ let deviceOptions = {};
+
+ // if we were able to obtain a subscription
+ if (subscription && subscription.endpoint) {
+ deviceOptions.pushCallback = subscription.endpoint;
+ let publicKey = subscription.getKey('p256dh');
+ let authKey = subscription.getKey('auth');
+ if (publicKey && authKey) {
+ deviceOptions.pushPublicKey = urlsafeBase64Encode(publicKey);
+ deviceOptions.pushAuthKey = urlsafeBase64Encode(authKey);
+ }
+ }
+
+ if (signedInUser.deviceId) {
+ log.debug("updating existing device details");
+ return this.fxAccountsClient.updateDevice(
+ signedInUser.sessionToken, signedInUser.deviceId, deviceName, deviceOptions);
+ }
+
+ log.debug("registering new device details");
+ return this.fxAccountsClient.registerDevice(
+ signedInUser.sessionToken, deviceName, this._getDeviceType(), deviceOptions);
+ }).then(device =>
+ this.currentAccountState.updateUserAccountData({
+ deviceId: device.id,
+ deviceRegistrationVersion: this.DEVICE_REGISTRATION_VERSION
+ }).then(() => device.id)
+ ).catch(error => this._handleDeviceError(error, signedInUser.sessionToken));
+ },
+
+ _getDeviceName() {
+ return Utils.getDeviceName();
+ },
+
+ _getDeviceType() {
+ return Utils.getDeviceType();
+ },
+
+ _handleDeviceError(error, sessionToken) {
+ return Promise.resolve().then(() => {
+ if (error.code === 400) {
+ if (error.errno === ERRNO_UNKNOWN_DEVICE) {
+ return this._recoverFromUnknownDevice();
+ }
+
+ if (error.errno === ERRNO_DEVICE_SESSION_CONFLICT) {
+ return this._recoverFromDeviceSessionConflict(error, sessionToken);
+ }
+ }
+
+ // `_handleTokenError` re-throws the error.
+ return this._handleTokenError(error);
+ }).catch(error =>
+ this._logErrorAndResetDeviceRegistrationVersion(error)
+ ).catch(() => {});
+ },
+
+ _recoverFromUnknownDevice() {
+ // FxA did not recognise the device id. Handle it by clearing the device
+ // id on the account data. At next sync or next sign-in, registration is
+ // retried and should succeed.
+ log.warn("unknown device id, clearing the local device data");
+ return this.currentAccountState.updateUserAccountData({ deviceId: null })
+ .catch(error => this._logErrorAndResetDeviceRegistrationVersion(error));
+ },
+
+ _recoverFromDeviceSessionConflict(error, sessionToken) {
+ // FxA has already associated this session with a different device id.
+ // Perhaps we were beaten in a race to register. Handle the conflict:
+ // 1. Fetch the list of devices for the current user from FxA.
+ // 2. Look for ourselves in the list.
+ // 3. If we find a match, set the correct device id and device registration
+ // version on the account data and return the correct device id. At next
+ // sync or next sign-in, registration is retried and should succeed.
+ // 4. If we don't find a match, log the original error.
+ log.warn("device session conflict, attempting to ascertain the correct device id");
+ return this.fxAccountsClient.getDeviceList(sessionToken)
+ .then(devices => {
+ const matchingDevices = devices.filter(device => device.isCurrentDevice);
+ const length = matchingDevices.length;
+ if (length === 1) {
+ const deviceId = matchingDevices[0].id;
+ return this.currentAccountState.updateUserAccountData({
+ deviceId,
+ deviceRegistrationVersion: null
+ }).then(() => deviceId);
+ }
+ if (length > 1) {
+ log.error("insane server state, " + length + " devices for this session");
+ }
+ return this._logErrorAndResetDeviceRegistrationVersion(error);
+ }).catch(secondError => {
+ log.error("failed to recover from device-session conflict", secondError);
+ this._logErrorAndResetDeviceRegistrationVersion(error)
+ });
+ },
+
+ _logErrorAndResetDeviceRegistrationVersion(error) {
+ // Device registration should never cause other operations to fail.
+ // If we've reached this point, just log the error and reset the device
+ // registration version on the account data. At next sync or next sign-in,
+ // registration will be retried.
+ log.error("device registration failed", error);
+ return this.currentAccountState.updateUserAccountData({
+ deviceRegistrationVersion: null
+ }).catch(secondError => {
+ log.error(
+ "failed to reset the device registration version, device registration won't be retried",
+ secondError);
+ }).then(() => {});
+ },
+
+ _handleTokenError(err) {
+ if (!err || err.code != 401 || err.errno != ERRNO_INVALID_AUTH_TOKEN) {
+ throw err;
+ }
+ log.warn("recovering from invalid token error", err);
+ return this.accountStatus().then(exists => {
+ if (!exists) {
+ // Delete all local account data. Since the account no longer
+ // exists, we can skip the remote calls.
+ log.info("token invalidated because the account no longer exists");
+ return this.signOut(true);
+ }
+ log.info("clearing credentials to handle invalid token error");
+ return this.resetCredentials();
+ }).then(() => Promise.reject(err));
+ },
+};
+
+
+// A getter for the instance to export
+XPCOMUtils.defineLazyGetter(this, "fxAccounts", function() {
+ let a = new FxAccounts();
+
+ // XXX Bug 947061 - We need a strategy for resuming email verification after
+ // browser restart
+ a.loadAndPoll();
+
+ return a;
+});
diff --git a/services/fxaccounts/FxAccountsClient.jsm b/services/fxaccounts/FxAccountsClient.jsm
new file mode 100644
index 000000000..fbe8da2fe
--- /dev/null
+++ b/services/fxaccounts/FxAccountsClient.jsm
@@ -0,0 +1,623 @@
+/* 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.EXPORTED_SYMBOLS = ["FxAccountsClient"];
+
+const {classes: Cc, interfaces: Ci, utils: Cu} = Components;
+
+Cu.import("resource://gre/modules/Log.jsm");
+Cu.import("resource://gre/modules/Promise.jsm");
+Cu.import("resource://gre/modules/Services.jsm");
+Cu.import("resource://services-common/utils.js");
+Cu.import("resource://services-common/hawkclient.js");
+Cu.import("resource://services-common/hawkrequest.js");
+Cu.import("resource://services-crypto/utils.js");
+Cu.import("resource://gre/modules/FxAccountsCommon.js");
+Cu.import("resource://gre/modules/Credentials.jsm");
+
+const HOST_PREF = "identity.fxaccounts.auth.uri";
+
+const SIGNIN = "/account/login";
+const SIGNUP = "/account/create";
+
+this.FxAccountsClient = function(host = Services.prefs.getCharPref(HOST_PREF)) {
+ this.host = host;
+
+ // The FxA auth server expects requests to certain endpoints to be authorized
+ // using Hawk.
+ this.hawk = new HawkClient(host);
+ this.hawk.observerPrefix = "FxA:hawk";
+
+ // Manage server backoff state. C.f.
+ // https://github.com/mozilla/fxa-auth-server/blob/master/docs/api.md#backoff-protocol
+ this.backoffError = null;
+};
+
+this.FxAccountsClient.prototype = {
+
+ /**
+ * Return client clock offset, in milliseconds, as determined by hawk client.
+ * Provided because callers should not have to know about hawk
+ * implementation.
+ *
+ * The offset is the number of milliseconds that must be added to the client
+ * clock to make it equal to the server clock. For example, if the client is
+ * five minutes ahead of the server, the localtimeOffsetMsec will be -300000.
+ */
+ get localtimeOffsetMsec() {
+ return this.hawk.localtimeOffsetMsec;
+ },
+
+ /*
+ * Return current time in milliseconds
+ *
+ * Not used by this module, but made available to the FxAccounts.jsm
+ * that uses this client.
+ */
+ now: function() {
+ return this.hawk.now();
+ },
+
+ /**
+ * Common code from signIn and signUp.
+ *
+ * @param path
+ * Request URL path. Can be /account/create or /account/login
+ * @param email
+ * The email address for the account (utf8)
+ * @param password
+ * The user's password
+ * @param [getKeys=false]
+ * If set to true the keyFetchToken will be retrieved
+ * @param [retryOK=true]
+ * If capitalization of the email is wrong and retryOK is set to true,
+ * we will retry with the suggested capitalization from the server
+ * @return Promise
+ * Returns a promise that resolves to an object:
+ * {
+ * authAt: authentication time for the session (seconds since epoch)
+ * email: the primary email for this account
+ * keyFetchToken: a key fetch token (hex)
+ * sessionToken: a session token (hex)
+ * uid: the user's unique ID (hex)
+ * unwrapBKey: used to unwrap kB, derived locally from the
+ * password (not revealed to the FxA server)
+ * verified (optional): flag indicating verification status of the
+ * email
+ * }
+ */
+ _createSession: function(path, email, password, getKeys=false,
+ retryOK=true) {
+ return Credentials.setup(email, password).then((creds) => {
+ let data = {
+ authPW: CommonUtils.bytesAsHex(creds.authPW),
+ email: email,
+ };
+ let keys = getKeys ? "?keys=true" : "";
+
+ return this._request(path + keys, "POST", null, data).then(
+ // Include the canonical capitalization of the email in the response so
+ // the caller can set its signed-in user state accordingly.
+ result => {
+ result.email = data.email;
+ result.unwrapBKey = CommonUtils.bytesAsHex(creds.unwrapBKey);
+
+ return result;
+ },
+ error => {
+ log.debug("Session creation failed", error);
+ // If the user entered an email with different capitalization from
+ // what's stored in the database (e.g., Greta.Garbo@gmail.COM as
+ // opposed to greta.garbo@gmail.com), the server will respond with a
+ // errno 120 (code 400) and the expected capitalization of the email.
+ // We retry with this email exactly once. If successful, we use the
+ // server's version of the email as the signed-in-user's email. This
+ // is necessary because the email also serves as salt; so we must be
+ // in agreement with the server on capitalization.
+ //
+ // API reference:
+ // https://github.com/mozilla/fxa-auth-server/blob/master/docs/api.md
+ if (ERRNO_INCORRECT_EMAIL_CASE === error.errno && retryOK) {
+ if (!error.email) {
+ log.error("Server returned errno 120 but did not provide email");
+ throw error;
+ }
+ return this._createSession(path, error.email, password, getKeys,
+ false);
+ }
+ throw error;
+ }
+ );
+ });
+ },
+
+ /**
+ * Create a new Firefox Account and authenticate
+ *
+ * @param email
+ * The email address for the account (utf8)
+ * @param password
+ * The user's password
+ * @param [getKeys=false]
+ * If set to true the keyFetchToken will be retrieved
+ * @return Promise
+ * Returns a promise that resolves to an object:
+ * {
+ * uid: the user's unique ID (hex)
+ * sessionToken: a session token (hex)
+ * keyFetchToken: a key fetch token (hex),
+ * unwrapBKey: used to unwrap kB, derived locally from the
+ * password (not revealed to the FxA server)
+ * }
+ */
+ signUp: function(email, password, getKeys=false) {
+ return this._createSession(SIGNUP, email, password, getKeys,
+ false /* no retry */);
+ },
+
+ /**
+ * Authenticate and create a new session with the Firefox Account API server
+ *
+ * @param email
+ * The email address for the account (utf8)
+ * @param password
+ * The user's password
+ * @param [getKeys=false]
+ * If set to true the keyFetchToken will be retrieved
+ * @return Promise
+ * Returns a promise that resolves to an object:
+ * {
+ * authAt: authentication time for the session (seconds since epoch)
+ * email: the primary email for this account
+ * keyFetchToken: a key fetch token (hex)
+ * sessionToken: a session token (hex)
+ * uid: the user's unique ID (hex)
+ * unwrapBKey: used to unwrap kB, derived locally from the
+ * password (not revealed to the FxA server)
+ * verified: flag indicating verification status of the email
+ * }
+ */
+ signIn: function signIn(email, password, getKeys=false) {
+ return this._createSession(SIGNIN, email, password, getKeys,
+ true /* retry */);
+ },
+
+ /**
+ * Check the status of a session given a session token
+ *
+ * @param sessionTokenHex
+ * The session token encoded in hex
+ * @return Promise
+ * Resolves with a boolean indicating if the session is still valid
+ */
+ sessionStatus: function (sessionTokenHex) {
+ return this._request("/session/status", "GET",
+ deriveHawkCredentials(sessionTokenHex, "sessionToken")).then(
+ () => Promise.resolve(true),
+ error => {
+ if (isInvalidTokenError(error)) {
+ return Promise.resolve(false);
+ }
+ throw error;
+ }
+ );
+ },
+
+ /**
+ * Destroy the current session with the Firefox Account API server
+ *
+ * @param sessionTokenHex
+ * The session token encoded in hex
+ * @return Promise
+ */
+ signOut: function (sessionTokenHex, options = {}) {
+ let path = "/session/destroy";
+ if (options.service) {
+ path += "?service=" + encodeURIComponent(options.service);
+ }
+ return this._request(path, "POST",
+ deriveHawkCredentials(sessionTokenHex, "sessionToken"));
+ },
+
+ /**
+ * Check the verification status of the user's FxA email address
+ *
+ * @param sessionTokenHex
+ * The current session token encoded in hex
+ * @return Promise
+ */
+ recoveryEmailStatus: function (sessionTokenHex, options = {}) {
+ let path = "/recovery_email/status";
+ if (options.reason) {
+ path += "?reason=" + encodeURIComponent(options.reason);
+ }
+
+ return this._request(path, "GET",
+ deriveHawkCredentials(sessionTokenHex, "sessionToken"));
+ },
+
+ /**
+ * Resend the verification email for the user
+ *
+ * @param sessionTokenHex
+ * The current token encoded in hex
+ * @return Promise
+ */
+ resendVerificationEmail: function(sessionTokenHex) {
+ return this._request("/recovery_email/resend_code", "POST",
+ deriveHawkCredentials(sessionTokenHex, "sessionToken"));
+ },
+
+ /**
+ * Retrieve encryption keys
+ *
+ * @param keyFetchTokenHex
+ * A one-time use key fetch token encoded in hex
+ * @return Promise
+ * Returns a promise that resolves to an object:
+ * {
+ * kA: an encryption key for recevorable data (bytes)
+ * wrapKB: an encryption key that requires knowledge of the
+ * user's password (bytes)
+ * }
+ */
+ accountKeys: function (keyFetchTokenHex) {
+ let creds = deriveHawkCredentials(keyFetchTokenHex, "keyFetchToken");
+ let keyRequestKey = creds.extra.slice(0, 32);
+ let morecreds = CryptoUtils.hkdf(keyRequestKey, undefined,
+ Credentials.keyWord("account/keys"), 3 * 32);
+ let respHMACKey = morecreds.slice(0, 32);
+ let respXORKey = morecreds.slice(32, 96);
+
+ return this._request("/account/keys", "GET", creds).then(resp => {
+ if (!resp.bundle) {
+ throw new Error("failed to retrieve keys");
+ }
+
+ let bundle = CommonUtils.hexToBytes(resp.bundle);
+ let mac = bundle.slice(-32);
+
+ let hasher = CryptoUtils.makeHMACHasher(Ci.nsICryptoHMAC.SHA256,
+ CryptoUtils.makeHMACKey(respHMACKey));
+
+ let bundleMAC = CryptoUtils.digestBytes(bundle.slice(0, -32), hasher);
+ if (mac !== bundleMAC) {
+ throw new Error("error unbundling encryption keys");
+ }
+
+ let keyAWrapB = CryptoUtils.xor(respXORKey, bundle.slice(0, 64));
+
+ return {
+ kA: keyAWrapB.slice(0, 32),
+ wrapKB: keyAWrapB.slice(32)
+ };
+ });
+ },
+
+ /**
+ * Sends a public key to the FxA API server and returns a signed certificate
+ *
+ * @param sessionTokenHex
+ * The current session token encoded in hex
+ * @param serializedPublicKey
+ * A public key (usually generated by jwcrypto)
+ * @param lifetime
+ * The lifetime of the certificate
+ * @return Promise
+ * Returns a promise that resolves to the signed certificate.
+ * The certificate can be used to generate a Persona assertion.
+ * @throws a new Error
+ * wrapping any of these HTTP code/errno pairs:
+ * https://github.com/mozilla/fxa-auth-server/blob/master/docs/api.md#response-12
+ */
+ signCertificate: function (sessionTokenHex, serializedPublicKey, lifetime) {
+ let creds = deriveHawkCredentials(sessionTokenHex, "sessionToken");
+
+ let body = { publicKey: serializedPublicKey,
+ duration: lifetime };
+ return Promise.resolve()
+ .then(_ => this._request("/certificate/sign", "POST", creds, body))
+ .then(resp => resp.cert,
+ err => {
+ log.error("HAWK.signCertificate error: " + JSON.stringify(err));
+ throw err;
+ });
+ },
+
+ /**
+ * Determine if an account exists
+ *
+ * @param email
+ * The email address to check
+ * @return Promise
+ * The promise resolves to true if the account exists, or false
+ * if it doesn't. The promise is rejected on other errors.
+ */
+ accountExists: function (email) {
+ return this.signIn(email, "").then(
+ (cantHappen) => {
+ throw new Error("How did I sign in with an empty password?");
+ },
+ (expectedError) => {
+ switch (expectedError.errno) {
+ case ERRNO_ACCOUNT_DOES_NOT_EXIST:
+ return false;
+ break;
+ case ERRNO_INCORRECT_PASSWORD:
+ return true;
+ break;
+ default:
+ // not so expected, any more ...
+ throw expectedError;
+ break;
+ }
+ }
+ );
+ },
+
+ /**
+ * Given the uid of an existing account (not an arbitrary email), ask
+ * the server if it still exists via /account/status.
+ *
+ * Used for differentiating between password change and account deletion.
+ */
+ accountStatus: function(uid) {
+ return this._request("/account/status?uid="+uid, "GET").then(
+ (result) => {
+ return result.exists;
+ },
+ (error) => {
+ log.error("accountStatus failed with: " + error);
+ return Promise.reject(error);
+ }
+ );
+ },
+
+ /**
+ * Register a new device
+ *
+ * @method registerDevice
+ * @param sessionTokenHex
+ * Session token obtained from signIn
+ * @param name
+ * Device name
+ * @param type
+ * Device type (mobile|desktop)
+ * @param [options]
+ * Extra device options
+ * @param [options.pushCallback]
+ * `pushCallback` push endpoint callback
+ * @param [options.pushPublicKey]
+ * `pushPublicKey` push public key (URLSafe Base64 string)
+ * @param [options.pushAuthKey]
+ * `pushAuthKey` push auth secret (URLSafe Base64 string)
+ * @return Promise
+ * Resolves to an object:
+ * {
+ * id: Device identifier
+ * createdAt: Creation time (milliseconds since epoch)
+ * name: Name of device
+ * type: Type of device (mobile|desktop)
+ * }
+ */
+ registerDevice(sessionTokenHex, name, type, options = {}) {
+ let path = "/account/device";
+
+ let creds = deriveHawkCredentials(sessionTokenHex, "sessionToken");
+ let body = { name, type };
+
+ if (options.pushCallback) {
+ body.pushCallback = options.pushCallback;
+ }
+ if (options.pushPublicKey && options.pushAuthKey) {
+ body.pushPublicKey = options.pushPublicKey;
+ body.pushAuthKey = options.pushAuthKey;
+ }
+
+ return this._request(path, "POST", creds, body);
+ },
+
+ /**
+ * Sends a message to other devices. Must conform with the push payload schema:
+ * https://github.com/mozilla/fxa-auth-server/blob/master/docs/pushpayloads.schema.json
+ *
+ * @method notifyDevice
+ * @param sessionTokenHex
+ * Session token obtained from signIn
+ * @param deviceIds
+ * Devices to send the message to
+ * @param payload
+ * Data to send with the message
+ * @return Promise
+ * Resolves to an empty object:
+ * {}
+ */
+ notifyDevices(sessionTokenHex, deviceIds, payload, TTL = 0) {
+ const body = {
+ to: deviceIds,
+ payload,
+ TTL
+ };
+ return this._request("/account/devices/notify", "POST",
+ deriveHawkCredentials(sessionTokenHex, "sessionToken"), body);
+ },
+
+ /**
+ * Update the session or name for an existing device
+ *
+ * @method updateDevice
+ * @param sessionTokenHex
+ * Session token obtained from signIn
+ * @param id
+ * Device identifier
+ * @param name
+ * Device name
+ * @param [options]
+ * Extra device options
+ * @param [options.pushCallback]
+ * `pushCallback` push endpoint callback
+ * @param [options.pushPublicKey]
+ * `pushPublicKey` push public key (URLSafe Base64 string)
+ * @param [options.pushAuthKey]
+ * `pushAuthKey` push auth secret (URLSafe Base64 string)
+ * @return Promise
+ * Resolves to an object:
+ * {
+ * id: Device identifier
+ * name: Device name
+ * }
+ */
+ updateDevice(sessionTokenHex, id, name, options = {}) {
+ let path = "/account/device";
+
+ let creds = deriveHawkCredentials(sessionTokenHex, "sessionToken");
+ let body = { id, name };
+ if (options.pushCallback) {
+ body.pushCallback = options.pushCallback;
+ }
+ if (options.pushPublicKey && options.pushAuthKey) {
+ body.pushPublicKey = options.pushPublicKey;
+ body.pushAuthKey = options.pushAuthKey;
+ }
+
+ return this._request(path, "POST", creds, body);
+ },
+
+ /**
+ * Delete a device and its associated session token, signing the user
+ * out of the server.
+ *
+ * @method signOutAndDestroyDevice
+ * @param sessionTokenHex
+ * Session token obtained from signIn
+ * @param id
+ * Device identifier
+ * @param [options]
+ * Options object
+ * @param [options.service]
+ * `service` query parameter
+ * @return Promise
+ * Resolves to an empty object:
+ * {}
+ */
+ signOutAndDestroyDevice(sessionTokenHex, id, options = {}) {
+ let path = "/account/device/destroy";
+
+ if (options.service) {
+ path += "?service=" + encodeURIComponent(options.service);
+ }
+
+ let creds = deriveHawkCredentials(sessionTokenHex, "sessionToken");
+ let body = { id };
+
+ return this._request(path, "POST", creds, body);
+ },
+
+ /**
+ * Get a list of currently registered devices
+ *
+ * @method getDeviceList
+ * @param sessionTokenHex
+ * Session token obtained from signIn
+ * @return Promise
+ * Resolves to an array of objects:
+ * [
+ * {
+ * id: Device id
+ * isCurrentDevice: Boolean indicating whether the item
+ * represents the current device
+ * name: Device name
+ * type: Device type (mobile|desktop)
+ * },
+ * ...
+ * ]
+ */
+ getDeviceList(sessionTokenHex) {
+ let path = "/account/devices";
+ let creds = deriveHawkCredentials(sessionTokenHex, "sessionToken");
+
+ return this._request(path, "GET", creds, {});
+ },
+
+ _clearBackoff: function() {
+ this.backoffError = null;
+ },
+
+ /**
+ * A general method for sending raw API calls to the FxA auth server.
+ * All request bodies and responses are JSON.
+ *
+ * @param path
+ * API endpoint path
+ * @param method
+ * The HTTP request method
+ * @param credentials
+ * Hawk credentials
+ * @param jsonPayload
+ * A JSON payload
+ * @return Promise
+ * Returns a promise that resolves to the JSON response of the API call,
+ * or is rejected with an error. Error responses have the following properties:
+ * {
+ * "code": 400, // matches the HTTP status code
+ * "errno": 107, // stable application-level error number
+ * "error": "Bad Request", // string description of the error type
+ * "message": "the value of salt is not allowed to be undefined",
+ * "info": "https://docs.dev.lcip.og/errors/1234" // link to more info on the error
+ * }
+ */
+ _request: function hawkRequest(path, method, credentials, jsonPayload) {
+ let deferred = Promise.defer();
+
+ // We were asked to back off.
+ if (this.backoffError) {
+ log.debug("Received new request during backoff, re-rejecting.");
+ deferred.reject(this.backoffError);
+ return deferred.promise;
+ }
+
+ this.hawk.request(path, method, credentials, jsonPayload).then(
+ (response) => {
+ try {
+ let responseObj = JSON.parse(response.body);
+ deferred.resolve(responseObj);
+ } catch (err) {
+ log.error("json parse error on response: " + response.body);
+ deferred.reject({error: err});
+ }
+ },
+
+ (error) => {
+ log.error("error " + method + "ing " + path + ": " + JSON.stringify(error));
+ if (error.retryAfter) {
+ log.debug("Received backoff response; caching error as flag.");
+ this.backoffError = error;
+ // Schedule clearing of cached-error-as-flag.
+ CommonUtils.namedTimer(
+ this._clearBackoff,
+ error.retryAfter * 1000,
+ this,
+ "fxaBackoffTimer"
+ );
+ }
+ deferred.reject(error);
+ }
+ );
+
+ return deferred.promise;
+ },
+};
+
+function isInvalidTokenError(error) {
+ if (error.code != 401) {
+ return false;
+ }
+ switch (error.errno) {
+ case ERRNO_INVALID_AUTH_TOKEN:
+ case ERRNO_INVALID_AUTH_TIMESTAMP:
+ case ERRNO_INVALID_AUTH_NONCE:
+ return true;
+ }
+ return false;
+}
diff --git a/services/fxaccounts/FxAccountsCommon.js b/services/fxaccounts/FxAccountsCommon.js
new file mode 100644
index 000000000..71fe78a50
--- /dev/null
+++ b/services/fxaccounts/FxAccountsCommon.js
@@ -0,0 +1,368 @@
+/* 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/. */
+
+var { interfaces: Ci, utils: Cu } = Components;
+
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+Cu.import("resource://gre/modules/Services.jsm");
+Cu.import("resource://gre/modules/Log.jsm");
+
+// loglevel should be one of "Fatal", "Error", "Warn", "Info", "Config",
+// "Debug", "Trace" or "All". If none is specified, "Debug" will be used by
+// default. Note "Debug" is usually appropriate so that when this log is
+// included in the Sync file logs we get verbose output.
+const PREF_LOG_LEVEL = "identity.fxaccounts.loglevel";
+// The level of messages that will be dumped to the console. If not specified,
+// "Error" will be used.
+const PREF_LOG_LEVEL_DUMP = "identity.fxaccounts.log.appender.dump";
+
+// A pref that can be set so "sensitive" information (eg, personally
+// identifiable info, credentials, etc) will be logged.
+const PREF_LOG_SENSITIVE_DETAILS = "identity.fxaccounts.log.sensitive";
+
+var exports = Object.create(null);
+
+XPCOMUtils.defineLazyGetter(exports, 'log', function() {
+ let log = Log.repository.getLogger("FirefoxAccounts");
+ // We set the log level to debug, but the default dump appender is set to
+ // the level reflected in the pref. Other code that consumes FxA may then
+ // choose to add another appender at a different level.
+ log.level = Log.Level.Debug;
+ let appender = new Log.DumpAppender();
+ appender.level = Log.Level.Error;
+
+ log.addAppender(appender);
+ try {
+ // The log itself.
+ let level =
+ Services.prefs.getPrefType(PREF_LOG_LEVEL) == Ci.nsIPrefBranch.PREF_STRING
+ && Services.prefs.getCharPref(PREF_LOG_LEVEL);
+ log.level = Log.Level[level] || Log.Level.Debug;
+
+ // The appender.
+ level =
+ Services.prefs.getPrefType(PREF_LOG_LEVEL_DUMP) == Ci.nsIPrefBranch.PREF_STRING
+ && Services.prefs.getCharPref(PREF_LOG_LEVEL_DUMP);
+ appender.level = Log.Level[level] || Log.Level.Error;
+ } catch (e) {
+ log.error(e);
+ }
+
+ return log;
+});
+
+// A boolean to indicate if personally identifiable information (or anything
+// else sensitive, such as credentials) should be logged.
+XPCOMUtils.defineLazyGetter(exports, 'logPII', function() {
+ try {
+ return Services.prefs.getBoolPref(PREF_LOG_SENSITIVE_DETAILS);
+ } catch (_) {
+ return false;
+ }
+});
+
+exports.FXACCOUNTS_PERMISSION = "firefox-accounts";
+
+exports.DATA_FORMAT_VERSION = 1;
+exports.DEFAULT_STORAGE_FILENAME = "signedInUser.json";
+
+// Token life times.
+// Having this parameter be short has limited security value and can cause
+// spurious authentication values if the client's clock is skewed and
+// we fail to adjust. See Bug 983256.
+exports.ASSERTION_LIFETIME = 1000 * 3600 * 24 * 365 * 25; // 25 years
+// This is a time period we want to guarantee that the assertion will be
+// valid after we generate it (e.g., the signed cert won't expire in this
+// period).
+exports.ASSERTION_USE_PERIOD = 1000 * 60 * 5; // 5 minutes
+exports.CERT_LIFETIME = 1000 * 3600 * 6; // 6 hours
+exports.KEY_LIFETIME = 1000 * 3600 * 12; // 12 hours
+
+// After we start polling for account verification, we stop polling when this
+// many milliseconds have elapsed.
+exports.POLL_SESSION = 1000 * 60 * 20; // 20 minutes
+
+// Observer notifications.
+exports.ONLOGIN_NOTIFICATION = "fxaccounts:onlogin";
+exports.ONVERIFIED_NOTIFICATION = "fxaccounts:onverified";
+exports.ONLOGOUT_NOTIFICATION = "fxaccounts:onlogout";
+// Internal to services/fxaccounts only
+exports.ON_FXA_UPDATE_NOTIFICATION = "fxaccounts:update";
+exports.ON_DEVICE_DISCONNECTED_NOTIFICATION = "fxaccounts:device_disconnected";
+exports.ON_PASSWORD_CHANGED_NOTIFICATION = "fxaccounts:password_changed";
+exports.ON_PASSWORD_RESET_NOTIFICATION = "fxaccounts:password_reset";
+exports.ON_COLLECTION_CHANGED_NOTIFICATION = "sync:collection_changed";
+
+exports.FXA_PUSH_SCOPE_ACCOUNT_UPDATE = "chrome://fxa-device-update";
+
+exports.ON_PROFILE_CHANGE_NOTIFICATION = "fxaccounts:profilechange";
+exports.ON_ACCOUNT_STATE_CHANGE_NOTIFICATION = "fxaccounts:statechange";
+
+// UI Requests.
+exports.UI_REQUEST_SIGN_IN_FLOW = "signInFlow";
+exports.UI_REQUEST_REFRESH_AUTH = "refreshAuthentication";
+
+// The OAuth client ID for Firefox Desktop
+exports.FX_OAUTH_CLIENT_ID = "5882386c6d801776";
+
+// Firefox Accounts WebChannel ID
+exports.WEBCHANNEL_ID = "account_updates";
+
+// Server errno.
+// From https://github.com/mozilla/fxa-auth-server/blob/master/docs/api.md#response-format
+exports.ERRNO_ACCOUNT_ALREADY_EXISTS = 101;
+exports.ERRNO_ACCOUNT_DOES_NOT_EXIST = 102;
+exports.ERRNO_INCORRECT_PASSWORD = 103;
+exports.ERRNO_UNVERIFIED_ACCOUNT = 104;
+exports.ERRNO_INVALID_VERIFICATION_CODE = 105;
+exports.ERRNO_NOT_VALID_JSON_BODY = 106;
+exports.ERRNO_INVALID_BODY_PARAMETERS = 107;
+exports.ERRNO_MISSING_BODY_PARAMETERS = 108;
+exports.ERRNO_INVALID_REQUEST_SIGNATURE = 109;
+exports.ERRNO_INVALID_AUTH_TOKEN = 110;
+exports.ERRNO_INVALID_AUTH_TIMESTAMP = 111;
+exports.ERRNO_MISSING_CONTENT_LENGTH = 112;
+exports.ERRNO_REQUEST_BODY_TOO_LARGE = 113;
+exports.ERRNO_TOO_MANY_CLIENT_REQUESTS = 114;
+exports.ERRNO_INVALID_AUTH_NONCE = 115;
+exports.ERRNO_ENDPOINT_NO_LONGER_SUPPORTED = 116;
+exports.ERRNO_INCORRECT_LOGIN_METHOD = 117;
+exports.ERRNO_INCORRECT_KEY_RETRIEVAL_METHOD = 118;
+exports.ERRNO_INCORRECT_API_VERSION = 119;
+exports.ERRNO_INCORRECT_EMAIL_CASE = 120;
+exports.ERRNO_ACCOUNT_LOCKED = 121;
+exports.ERRNO_ACCOUNT_UNLOCKED = 122;
+exports.ERRNO_UNKNOWN_DEVICE = 123;
+exports.ERRNO_DEVICE_SESSION_CONFLICT = 124;
+exports.ERRNO_SERVICE_TEMP_UNAVAILABLE = 201;
+exports.ERRNO_PARSE = 997;
+exports.ERRNO_NETWORK = 998;
+exports.ERRNO_UNKNOWN_ERROR = 999;
+
+// Offset oauth server errnos so they don't conflict with auth server errnos
+exports.OAUTH_SERVER_ERRNO_OFFSET = 1000;
+
+// OAuth Server errno.
+exports.ERRNO_UNKNOWN_CLIENT_ID = 101 + exports.OAUTH_SERVER_ERRNO_OFFSET;
+exports.ERRNO_INCORRECT_CLIENT_SECRET = 102 + exports.OAUTH_SERVER_ERRNO_OFFSET;
+exports.ERRNO_INCORRECT_REDIRECT_URI = 103 + exports.OAUTH_SERVER_ERRNO_OFFSET;
+exports.ERRNO_INVALID_FXA_ASSERTION = 104 + exports.OAUTH_SERVER_ERRNO_OFFSET;
+exports.ERRNO_UNKNOWN_CODE = 105 + exports.OAUTH_SERVER_ERRNO_OFFSET;
+exports.ERRNO_INCORRECT_CODE = 106 + exports.OAUTH_SERVER_ERRNO_OFFSET;
+exports.ERRNO_EXPIRED_CODE = 107 + exports.OAUTH_SERVER_ERRNO_OFFSET;
+exports.ERRNO_OAUTH_INVALID_TOKEN = 108 + exports.OAUTH_SERVER_ERRNO_OFFSET;
+exports.ERRNO_INVALID_REQUEST_PARAM = 109 + exports.OAUTH_SERVER_ERRNO_OFFSET;
+exports.ERRNO_INVALID_RESPONSE_TYPE = 110 + exports.OAUTH_SERVER_ERRNO_OFFSET;
+exports.ERRNO_UNAUTHORIZED = 111 + exports.OAUTH_SERVER_ERRNO_OFFSET;
+exports.ERRNO_FORBIDDEN = 112 + exports.OAUTH_SERVER_ERRNO_OFFSET;
+exports.ERRNO_INVALID_CONTENT_TYPE = 113 + exports.OAUTH_SERVER_ERRNO_OFFSET;
+
+// Errors.
+exports.ERROR_ACCOUNT_ALREADY_EXISTS = "ACCOUNT_ALREADY_EXISTS";
+exports.ERROR_ACCOUNT_DOES_NOT_EXIST = "ACCOUNT_DOES_NOT_EXIST ";
+exports.ERROR_ACCOUNT_LOCKED = "ACCOUNT_LOCKED";
+exports.ERROR_ACCOUNT_UNLOCKED = "ACCOUNT_UNLOCKED";
+exports.ERROR_ALREADY_SIGNED_IN_USER = "ALREADY_SIGNED_IN_USER";
+exports.ERROR_DEVICE_SESSION_CONFLICT = "DEVICE_SESSION_CONFLICT";
+exports.ERROR_ENDPOINT_NO_LONGER_SUPPORTED = "ENDPOINT_NO_LONGER_SUPPORTED";
+exports.ERROR_INCORRECT_API_VERSION = "INCORRECT_API_VERSION";
+exports.ERROR_INCORRECT_EMAIL_CASE = "INCORRECT_EMAIL_CASE";
+exports.ERROR_INCORRECT_KEY_RETRIEVAL_METHOD = "INCORRECT_KEY_RETRIEVAL_METHOD";
+exports.ERROR_INCORRECT_LOGIN_METHOD = "INCORRECT_LOGIN_METHOD";
+exports.ERROR_INVALID_EMAIL = "INVALID_EMAIL";
+exports.ERROR_INVALID_AUDIENCE = "INVALID_AUDIENCE";
+exports.ERROR_INVALID_AUTH_TOKEN = "INVALID_AUTH_TOKEN";
+exports.ERROR_INVALID_AUTH_TIMESTAMP = "INVALID_AUTH_TIMESTAMP";
+exports.ERROR_INVALID_AUTH_NONCE = "INVALID_AUTH_NONCE";
+exports.ERROR_INVALID_BODY_PARAMETERS = "INVALID_BODY_PARAMETERS";
+exports.ERROR_INVALID_PASSWORD = "INVALID_PASSWORD";
+exports.ERROR_INVALID_VERIFICATION_CODE = "INVALID_VERIFICATION_CODE";
+exports.ERROR_INVALID_REFRESH_AUTH_VALUE = "INVALID_REFRESH_AUTH_VALUE";
+exports.ERROR_INVALID_REQUEST_SIGNATURE = "INVALID_REQUEST_SIGNATURE";
+exports.ERROR_INTERNAL_INVALID_USER = "INTERNAL_ERROR_INVALID_USER";
+exports.ERROR_MISSING_BODY_PARAMETERS = "MISSING_BODY_PARAMETERS";
+exports.ERROR_MISSING_CONTENT_LENGTH = "MISSING_CONTENT_LENGTH";
+exports.ERROR_NO_TOKEN_SESSION = "NO_TOKEN_SESSION";
+exports.ERROR_NO_SILENT_REFRESH_AUTH = "NO_SILENT_REFRESH_AUTH";
+exports.ERROR_NOT_VALID_JSON_BODY = "NOT_VALID_JSON_BODY";
+exports.ERROR_OFFLINE = "OFFLINE";
+exports.ERROR_PERMISSION_DENIED = "PERMISSION_DENIED";
+exports.ERROR_REQUEST_BODY_TOO_LARGE = "REQUEST_BODY_TOO_LARGE";
+exports.ERROR_SERVER_ERROR = "SERVER_ERROR";
+exports.ERROR_SYNC_DISABLED = "SYNC_DISABLED";
+exports.ERROR_TOO_MANY_CLIENT_REQUESTS = "TOO_MANY_CLIENT_REQUESTS";
+exports.ERROR_SERVICE_TEMP_UNAVAILABLE = "SERVICE_TEMPORARY_UNAVAILABLE";
+exports.ERROR_UI_ERROR = "UI_ERROR";
+exports.ERROR_UI_REQUEST = "UI_REQUEST";
+exports.ERROR_PARSE = "PARSE_ERROR";
+exports.ERROR_NETWORK = "NETWORK_ERROR";
+exports.ERROR_UNKNOWN = "UNKNOWN_ERROR";
+exports.ERROR_UNKNOWN_DEVICE = "UNKNOWN_DEVICE";
+exports.ERROR_UNVERIFIED_ACCOUNT = "UNVERIFIED_ACCOUNT";
+
+// OAuth errors.
+exports.ERROR_UNKNOWN_CLIENT_ID = "UNKNOWN_CLIENT_ID";
+exports.ERROR_INCORRECT_CLIENT_SECRET = "INCORRECT_CLIENT_SECRET";
+exports.ERROR_INCORRECT_REDIRECT_URI = "INCORRECT_REDIRECT_URI";
+exports.ERROR_INVALID_FXA_ASSERTION = "INVALID_FXA_ASSERTION";
+exports.ERROR_UNKNOWN_CODE = "UNKNOWN_CODE";
+exports.ERROR_INCORRECT_CODE = "INCORRECT_CODE";
+exports.ERROR_EXPIRED_CODE = "EXPIRED_CODE";
+exports.ERROR_OAUTH_INVALID_TOKEN = "OAUTH_INVALID_TOKEN";
+exports.ERROR_INVALID_REQUEST_PARAM = "INVALID_REQUEST_PARAM";
+exports.ERROR_INVALID_RESPONSE_TYPE = "INVALID_RESPONSE_TYPE";
+exports.ERROR_UNAUTHORIZED = "UNAUTHORIZED";
+exports.ERROR_FORBIDDEN = "FORBIDDEN";
+exports.ERROR_INVALID_CONTENT_TYPE = "INVALID_CONTENT_TYPE";
+
+// Additional generic error classes for external consumers
+exports.ERROR_NO_ACCOUNT = "NO_ACCOUNT";
+exports.ERROR_AUTH_ERROR = "AUTH_ERROR";
+exports.ERROR_INVALID_PARAMETER = "INVALID_PARAMETER";
+
+// Status code errors
+exports.ERROR_CODE_METHOD_NOT_ALLOWED = 405;
+exports.ERROR_MSG_METHOD_NOT_ALLOWED = "METHOD_NOT_ALLOWED";
+
+// FxAccounts has the ability to "split" the credentials between a plain-text
+// JSON file in the profile dir and in the login manager.
+// In order to prevent new fields accidentally ending up in the "wrong" place,
+// all fields stored are listed here.
+
+// The fields we save in the plaintext JSON.
+// See bug 1013064 comments 23-25 for why the sessionToken is "safe"
+exports.FXA_PWDMGR_PLAINTEXT_FIELDS = new Set(
+ ["email", "verified", "authAt", "sessionToken", "uid", "oauthTokens", "profile",
+ "deviceId", "deviceRegistrationVersion"]);
+
+// Fields we store in secure storage if it exists.
+exports.FXA_PWDMGR_SECURE_FIELDS = new Set(
+ ["kA", "kB", "keyFetchToken", "unwrapBKey", "assertion"]);
+
+// Fields we keep in memory and don't persist anywhere.
+exports.FXA_PWDMGR_MEMORY_FIELDS = new Set(
+ ["cert", "keyPair"]);
+
+// A whitelist of fields that remain in storage when the user needs to
+// reauthenticate. All other fields will be removed.
+exports.FXA_PWDMGR_REAUTH_WHITELIST = new Set(
+ ["email", "uid", "profile", "deviceId", "deviceRegistrationVersion", "verified"]);
+
+// The pseudo-host we use in the login manager
+exports.FXA_PWDMGR_HOST = "chrome://FirefoxAccounts";
+// The realm we use in the login manager.
+exports.FXA_PWDMGR_REALM = "Firefox Accounts credentials";
+
+// Error matching.
+exports.SERVER_ERRNO_TO_ERROR = {};
+
+// Error mapping
+exports.ERROR_TO_GENERAL_ERROR_CLASS = {};
+
+for (let id in exports) {
+ this[id] = exports[id];
+}
+
+// Allow this file to be imported via Components.utils.import().
+this.EXPORTED_SYMBOLS = Object.keys(exports);
+
+// Set these up now that everything has been loaded into |this|.
+SERVER_ERRNO_TO_ERROR[ERRNO_ACCOUNT_ALREADY_EXISTS] = ERROR_ACCOUNT_ALREADY_EXISTS;
+SERVER_ERRNO_TO_ERROR[ERRNO_ACCOUNT_DOES_NOT_EXIST] = ERROR_ACCOUNT_DOES_NOT_EXIST;
+SERVER_ERRNO_TO_ERROR[ERRNO_INCORRECT_PASSWORD] = ERROR_INVALID_PASSWORD;
+SERVER_ERRNO_TO_ERROR[ERRNO_UNVERIFIED_ACCOUNT] = ERROR_UNVERIFIED_ACCOUNT;
+SERVER_ERRNO_TO_ERROR[ERRNO_INVALID_VERIFICATION_CODE] = ERROR_INVALID_VERIFICATION_CODE;
+SERVER_ERRNO_TO_ERROR[ERRNO_NOT_VALID_JSON_BODY] = ERROR_NOT_VALID_JSON_BODY;
+SERVER_ERRNO_TO_ERROR[ERRNO_INVALID_BODY_PARAMETERS] = ERROR_INVALID_BODY_PARAMETERS;
+SERVER_ERRNO_TO_ERROR[ERRNO_MISSING_BODY_PARAMETERS] = ERROR_MISSING_BODY_PARAMETERS;
+SERVER_ERRNO_TO_ERROR[ERRNO_INVALID_REQUEST_SIGNATURE] = ERROR_INVALID_REQUEST_SIGNATURE;
+SERVER_ERRNO_TO_ERROR[ERRNO_INVALID_AUTH_TOKEN] = ERROR_INVALID_AUTH_TOKEN;
+SERVER_ERRNO_TO_ERROR[ERRNO_INVALID_AUTH_TIMESTAMP] = ERROR_INVALID_AUTH_TIMESTAMP;
+SERVER_ERRNO_TO_ERROR[ERRNO_MISSING_CONTENT_LENGTH] = ERROR_MISSING_CONTENT_LENGTH;
+SERVER_ERRNO_TO_ERROR[ERRNO_REQUEST_BODY_TOO_LARGE] = ERROR_REQUEST_BODY_TOO_LARGE;
+SERVER_ERRNO_TO_ERROR[ERRNO_TOO_MANY_CLIENT_REQUESTS] = ERROR_TOO_MANY_CLIENT_REQUESTS;
+SERVER_ERRNO_TO_ERROR[ERRNO_INVALID_AUTH_NONCE] = ERROR_INVALID_AUTH_NONCE;
+SERVER_ERRNO_TO_ERROR[ERRNO_ENDPOINT_NO_LONGER_SUPPORTED] = ERROR_ENDPOINT_NO_LONGER_SUPPORTED;
+SERVER_ERRNO_TO_ERROR[ERRNO_INCORRECT_LOGIN_METHOD] = ERROR_INCORRECT_LOGIN_METHOD;
+SERVER_ERRNO_TO_ERROR[ERRNO_INCORRECT_KEY_RETRIEVAL_METHOD] = ERROR_INCORRECT_KEY_RETRIEVAL_METHOD;
+SERVER_ERRNO_TO_ERROR[ERRNO_INCORRECT_API_VERSION] = ERROR_INCORRECT_API_VERSION;
+SERVER_ERRNO_TO_ERROR[ERRNO_INCORRECT_EMAIL_CASE] = ERROR_INCORRECT_EMAIL_CASE;
+SERVER_ERRNO_TO_ERROR[ERRNO_ACCOUNT_LOCKED] = ERROR_ACCOUNT_LOCKED;
+SERVER_ERRNO_TO_ERROR[ERRNO_ACCOUNT_UNLOCKED] = ERROR_ACCOUNT_UNLOCKED;
+SERVER_ERRNO_TO_ERROR[ERRNO_UNKNOWN_DEVICE] = ERROR_UNKNOWN_DEVICE;
+SERVER_ERRNO_TO_ERROR[ERRNO_DEVICE_SESSION_CONFLICT] = ERROR_DEVICE_SESSION_CONFLICT;
+SERVER_ERRNO_TO_ERROR[ERRNO_SERVICE_TEMP_UNAVAILABLE] = ERROR_SERVICE_TEMP_UNAVAILABLE;
+SERVER_ERRNO_TO_ERROR[ERRNO_UNKNOWN_ERROR] = ERROR_UNKNOWN;
+SERVER_ERRNO_TO_ERROR[ERRNO_NETWORK] = ERROR_NETWORK;
+
+// oauth
+SERVER_ERRNO_TO_ERROR[ERRNO_UNKNOWN_CLIENT_ID] = ERROR_UNKNOWN_CLIENT_ID;
+SERVER_ERRNO_TO_ERROR[ERRNO_INCORRECT_CLIENT_SECRET] = ERROR_INCORRECT_CLIENT_SECRET;
+SERVER_ERRNO_TO_ERROR[ERRNO_INCORRECT_REDIRECT_URI] = ERROR_INCORRECT_REDIRECT_URI;
+SERVER_ERRNO_TO_ERROR[ERRNO_INVALID_FXA_ASSERTION] = ERROR_INVALID_FXA_ASSERTION;
+SERVER_ERRNO_TO_ERROR[ERRNO_UNKNOWN_CODE] = ERROR_UNKNOWN_CODE;
+SERVER_ERRNO_TO_ERROR[ERRNO_INCORRECT_CODE] = ERROR_INCORRECT_CODE;
+SERVER_ERRNO_TO_ERROR[ERRNO_EXPIRED_CODE] = ERROR_EXPIRED_CODE;
+SERVER_ERRNO_TO_ERROR[ERRNO_OAUTH_INVALID_TOKEN] = ERROR_OAUTH_INVALID_TOKEN;
+SERVER_ERRNO_TO_ERROR[ERRNO_INVALID_REQUEST_PARAM] = ERROR_INVALID_REQUEST_PARAM;
+SERVER_ERRNO_TO_ERROR[ERRNO_INVALID_RESPONSE_TYPE] = ERROR_INVALID_RESPONSE_TYPE;
+SERVER_ERRNO_TO_ERROR[ERRNO_UNAUTHORIZED] = ERROR_UNAUTHORIZED;
+SERVER_ERRNO_TO_ERROR[ERRNO_FORBIDDEN] = ERROR_FORBIDDEN;
+SERVER_ERRNO_TO_ERROR[ERRNO_INVALID_CONTENT_TYPE] = ERROR_INVALID_CONTENT_TYPE;
+
+
+// Map internal errors to more generic error classes for consumers
+ERROR_TO_GENERAL_ERROR_CLASS[ERROR_ACCOUNT_ALREADY_EXISTS] = ERROR_AUTH_ERROR;
+ERROR_TO_GENERAL_ERROR_CLASS[ERROR_ACCOUNT_DOES_NOT_EXIST] = ERROR_AUTH_ERROR;
+ERROR_TO_GENERAL_ERROR_CLASS[ERROR_ACCOUNT_LOCKED] = ERROR_AUTH_ERROR;
+ERROR_TO_GENERAL_ERROR_CLASS[ERROR_ACCOUNT_UNLOCKED] = ERROR_AUTH_ERROR;
+ERROR_TO_GENERAL_ERROR_CLASS[ERROR_ALREADY_SIGNED_IN_USER] = ERROR_AUTH_ERROR;
+ERROR_TO_GENERAL_ERROR_CLASS[ERROR_DEVICE_SESSION_CONFLICT] = ERROR_AUTH_ERROR;
+ERROR_TO_GENERAL_ERROR_CLASS[ERROR_ENDPOINT_NO_LONGER_SUPPORTED] = ERROR_AUTH_ERROR;
+ERROR_TO_GENERAL_ERROR_CLASS[ERROR_INCORRECT_API_VERSION] = ERROR_AUTH_ERROR;
+ERROR_TO_GENERAL_ERROR_CLASS[ERROR_INCORRECT_EMAIL_CASE] = ERROR_AUTH_ERROR;
+ERROR_TO_GENERAL_ERROR_CLASS[ERROR_INCORRECT_KEY_RETRIEVAL_METHOD] = ERROR_AUTH_ERROR;
+ERROR_TO_GENERAL_ERROR_CLASS[ERROR_INCORRECT_LOGIN_METHOD] = ERROR_AUTH_ERROR;
+ERROR_TO_GENERAL_ERROR_CLASS[ERROR_INVALID_EMAIL] = ERROR_AUTH_ERROR;
+ERROR_TO_GENERAL_ERROR_CLASS[ERROR_INVALID_AUDIENCE] = ERROR_AUTH_ERROR;
+ERROR_TO_GENERAL_ERROR_CLASS[ERROR_INVALID_AUTH_TOKEN] = ERROR_AUTH_ERROR;
+ERROR_TO_GENERAL_ERROR_CLASS[ERROR_INVALID_AUTH_TIMESTAMP] = ERROR_AUTH_ERROR;
+ERROR_TO_GENERAL_ERROR_CLASS[ERROR_INVALID_AUTH_NONCE] = ERROR_AUTH_ERROR;
+ERROR_TO_GENERAL_ERROR_CLASS[ERROR_INVALID_BODY_PARAMETERS] = ERROR_AUTH_ERROR;
+ERROR_TO_GENERAL_ERROR_CLASS[ERROR_INVALID_PASSWORD] = ERROR_AUTH_ERROR;
+ERROR_TO_GENERAL_ERROR_CLASS[ERROR_INVALID_VERIFICATION_CODE] = ERROR_AUTH_ERROR;
+ERROR_TO_GENERAL_ERROR_CLASS[ERROR_INVALID_REFRESH_AUTH_VALUE] = ERROR_AUTH_ERROR;
+ERROR_TO_GENERAL_ERROR_CLASS[ERROR_INVALID_REQUEST_SIGNATURE] = ERROR_AUTH_ERROR;
+ERROR_TO_GENERAL_ERROR_CLASS[ERROR_INTERNAL_INVALID_USER] = ERROR_AUTH_ERROR;
+ERROR_TO_GENERAL_ERROR_CLASS[ERROR_MISSING_BODY_PARAMETERS] = ERROR_AUTH_ERROR;
+ERROR_TO_GENERAL_ERROR_CLASS[ERROR_MISSING_CONTENT_LENGTH] = ERROR_AUTH_ERROR;
+ERROR_TO_GENERAL_ERROR_CLASS[ERROR_NO_TOKEN_SESSION] = ERROR_AUTH_ERROR;
+ERROR_TO_GENERAL_ERROR_CLASS[ERROR_NO_SILENT_REFRESH_AUTH] = ERROR_AUTH_ERROR;
+ERROR_TO_GENERAL_ERROR_CLASS[ERROR_NOT_VALID_JSON_BODY] = ERROR_AUTH_ERROR;
+ERROR_TO_GENERAL_ERROR_CLASS[ERROR_PERMISSION_DENIED] = ERROR_AUTH_ERROR;
+ERROR_TO_GENERAL_ERROR_CLASS[ERROR_REQUEST_BODY_TOO_LARGE] = ERROR_AUTH_ERROR;
+ERROR_TO_GENERAL_ERROR_CLASS[ERROR_UNKNOWN_DEVICE] = ERROR_AUTH_ERROR;
+ERROR_TO_GENERAL_ERROR_CLASS[ERROR_UNVERIFIED_ACCOUNT] = ERROR_AUTH_ERROR;
+ERROR_TO_GENERAL_ERROR_CLASS[ERROR_UI_ERROR] = ERROR_AUTH_ERROR;
+ERROR_TO_GENERAL_ERROR_CLASS[ERROR_UI_REQUEST] = ERROR_AUTH_ERROR;
+ERROR_TO_GENERAL_ERROR_CLASS[ERROR_OFFLINE] = ERROR_NETWORK;
+ERROR_TO_GENERAL_ERROR_CLASS[ERROR_SERVER_ERROR] = ERROR_NETWORK;
+ERROR_TO_GENERAL_ERROR_CLASS[ERROR_TOO_MANY_CLIENT_REQUESTS] = ERROR_NETWORK;
+ERROR_TO_GENERAL_ERROR_CLASS[ERROR_SERVICE_TEMP_UNAVAILABLE] = ERROR_NETWORK;
+ERROR_TO_GENERAL_ERROR_CLASS[ERROR_PARSE] = ERROR_NETWORK;
+ERROR_TO_GENERAL_ERROR_CLASS[ERROR_NETWORK] = ERROR_NETWORK;
+
+// oauth
+ERROR_TO_GENERAL_ERROR_CLASS[ERROR_INCORRECT_CLIENT_SECRET] = ERROR_AUTH_ERROR;
+ERROR_TO_GENERAL_ERROR_CLASS[ERROR_INCORRECT_REDIRECT_URI] = ERROR_AUTH_ERROR;
+ERROR_TO_GENERAL_ERROR_CLASS[ERROR_INVALID_FXA_ASSERTION] = ERROR_AUTH_ERROR;
+ERROR_TO_GENERAL_ERROR_CLASS[ERROR_UNKNOWN_CODE] = ERROR_AUTH_ERROR;
+ERROR_TO_GENERAL_ERROR_CLASS[ERROR_INCORRECT_CODE] = ERROR_AUTH_ERROR;
+ERROR_TO_GENERAL_ERROR_CLASS[ERROR_EXPIRED_CODE] = ERROR_AUTH_ERROR;
+ERROR_TO_GENERAL_ERROR_CLASS[ERROR_OAUTH_INVALID_TOKEN] = ERROR_AUTH_ERROR;
+ERROR_TO_GENERAL_ERROR_CLASS[ERROR_INVALID_REQUEST_PARAM] = ERROR_AUTH_ERROR;
+ERROR_TO_GENERAL_ERROR_CLASS[ERROR_INVALID_RESPONSE_TYPE] = ERROR_AUTH_ERROR;
+ERROR_TO_GENERAL_ERROR_CLASS[ERROR_UNAUTHORIZED] = ERROR_AUTH_ERROR;
+ERROR_TO_GENERAL_ERROR_CLASS[ERROR_FORBIDDEN] = ERROR_AUTH_ERROR;
+ERROR_TO_GENERAL_ERROR_CLASS[ERROR_INVALID_CONTENT_TYPE] = ERROR_AUTH_ERROR;
diff --git a/services/fxaccounts/FxAccountsComponents.manifest b/services/fxaccounts/FxAccountsComponents.manifest
new file mode 100644
index 000000000..5069755bc
--- /dev/null
+++ b/services/fxaccounts/FxAccountsComponents.manifest
@@ -0,0 +1,4 @@
+# FxAccountsPush.js
+component {1b7db999-2ecd-4abf-bb95-a726896798ca} FxAccountsPush.js process=main
+contract @mozilla.org/fxaccounts/push;1 {1b7db999-2ecd-4abf-bb95-a726896798ca}
+category push chrome://fxa-device-update @mozilla.org/fxaccounts/push;1
diff --git a/services/fxaccounts/FxAccountsConfig.jsm b/services/fxaccounts/FxAccountsConfig.jsm
new file mode 100644
index 000000000..9dcf532ab
--- /dev/null
+++ b/services/fxaccounts/FxAccountsConfig.jsm
@@ -0,0 +1,179 @@
+/* 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 = ["FxAccountsConfig"];
+
+const {classes: Cc, interfaces: Ci, utils: Cu} = Components;
+
+Cu.import("resource://services-common/rest.js");
+Cu.import("resource://gre/modules/FxAccountsCommon.js");
+Cu.import("resource://gre/modules/Services.jsm");
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+Cu.import("resource://gre/modules/Task.jsm");
+
+XPCOMUtils.defineLazyModuleGetter(this, "fxAccounts",
+ "resource://gre/modules/FxAccounts.jsm");
+
+XPCOMUtils.defineLazyModuleGetter(this, "EnsureFxAccountsWebChannel",
+ "resource://gre/modules/FxAccountsWebChannel.jsm");
+
+const CONFIG_PREFS = [
+ "identity.fxaccounts.auth.uri",
+ "identity.fxaccounts.remote.oauth.uri",
+ "identity.fxaccounts.remote.profile.uri",
+ "identity.sync.tokenserver.uri",
+ "identity.fxaccounts.remote.webchannel.uri",
+ "identity.fxaccounts.settings.uri",
+ "identity.fxaccounts.remote.signup.uri",
+ "identity.fxaccounts.remote.signin.uri",
+ "identity.fxaccounts.remote.force_auth.uri",
+];
+
+this.FxAccountsConfig = {
+
+ // Returns a promise that resolves with the URI of the remote UI flows.
+ promiseAccountsSignUpURI: Task.async(function*() {
+ yield this.ensureConfigured();
+ let url = Services.urlFormatter.formatURLPref("identity.fxaccounts.remote.signup.uri");
+ if (fxAccounts.requiresHttps() && !/^https:/.test(url)) { // Comment to un-break emacs js-mode highlighting
+ throw new Error("Firefox Accounts server must use HTTPS");
+ }
+ return url;
+ }),
+
+ // Returns a promise that resolves with the URI of the remote UI flows.
+ promiseAccountsSignInURI: Task.async(function*() {
+ yield this.ensureConfigured();
+ let url = Services.urlFormatter.formatURLPref("identity.fxaccounts.remote.signin.uri");
+ if (fxAccounts.requiresHttps() && !/^https:/.test(url)) { // Comment to un-break emacs js-mode highlighting
+ throw new Error("Firefox Accounts server must use HTTPS");
+ }
+ return url;
+ }),
+
+ resetConfigURLs() {
+ let autoconfigURL = this.getAutoConfigURL();
+ if (!autoconfigURL) {
+ return;
+ }
+ // They have the autoconfig uri pref set, so we clear all the prefs that we
+ // will have initialized, which will leave them pointing at production.
+ for (let pref of CONFIG_PREFS) {
+ Services.prefs.clearUserPref(pref);
+ }
+ // Reset the webchannel.
+ EnsureFxAccountsWebChannel();
+ if (!Services.prefs.prefHasUserValue("webchannel.allowObject.urlWhitelist")) {
+ return;
+ }
+ let whitelistValue = Services.prefs.getCharPref("webchannel.allowObject.urlWhitelist");
+ if (whitelistValue.startsWith(autoconfigURL + " ")) {
+ whitelistValue = whitelistValue.slice(autoconfigURL.length + 1);
+ // Check and see if the value will be the default, and just clear the pref if it would
+ // to avoid it showing up as changed in about:config.
+ let defaultWhitelist;
+ try {
+ defaultWhitelist = Services.prefs.getDefaultBranch("webchannel.allowObject.").getCharPref("urlWhitelist");
+ } catch (e) {
+ // No default value ...
+ }
+
+ if (defaultWhitelist === whitelistValue) {
+ Services.prefs.clearUserPref("webchannel.allowObject.urlWhitelist");
+ } else {
+ Services.prefs.setCharPref("webchannel.allowObject.urlWhitelist", whitelistValue);
+ }
+ }
+ },
+
+ getAutoConfigURL() {
+ let pref;
+ try {
+ pref = Services.prefs.getCharPref("identity.fxaccounts.autoconfig.uri");
+ } catch (e) { /* no pref */ }
+ if (!pref) {
+ // no pref / empty pref means we don't bother here.
+ return "";
+ }
+ let rootURL = Services.urlFormatter.formatURL(pref);
+ if (rootURL.endsWith("/")) {
+ rootURL.slice(0, -1);
+ }
+ return rootURL;
+ },
+
+ ensureConfigured: Task.async(function*() {
+ let isSignedIn = !!(yield fxAccounts.getSignedInUser());
+ if (!isSignedIn) {
+ yield this.fetchConfigURLs();
+ }
+ }),
+
+ // Read expected client configuration from the fxa auth server
+ // (from `identity.fxaccounts.autoconfig.uri`/.well-known/fxa-client-configuration)
+ // and replace all the relevant our prefs with the information found there.
+ // This is only done before sign-in and sign-up, and even then only if the
+ // `identity.fxaccounts.autoconfig.uri` preference is set.
+ fetchConfigURLs: Task.async(function*() {
+ let rootURL = this.getAutoConfigURL();
+ if (!rootURL) {
+ return;
+ }
+ let configURL = rootURL + "/.well-known/fxa-client-configuration";
+ let jsonStr = yield new Promise((resolve, reject) => {
+ let request = new RESTRequest(configURL);
+ request.setHeader("Accept", "application/json");
+ request.get(error => {
+ if (error) {
+ log.error(`Failed to get configuration object from "${configURL}"`, error);
+ return reject(error);
+ }
+ if (!request.response.success) {
+ log.error(`Received HTTP response code ${request.response.status} from configuration object request`);
+ if (request.response && request.response.body) {
+ log.debug("Got error response", request.response.body);
+ }
+ return reject(request.response.status);
+ }
+ resolve(request.response.body);
+ });
+ });
+
+ log.debug("Got successful configuration response", jsonStr);
+ try {
+ // Update the prefs directly specified by the config.
+ let config = JSON.parse(jsonStr)
+ let authServerBase = config.auth_server_base_url;
+ if (!authServerBase.endsWith("/v1")) {
+ authServerBase += "/v1";
+ }
+ Services.prefs.setCharPref("identity.fxaccounts.auth.uri", authServerBase);
+ Services.prefs.setCharPref("identity.fxaccounts.remote.oauth.uri", config.oauth_server_base_url + "/v1");
+ Services.prefs.setCharPref("identity.fxaccounts.remote.profile.uri", config.profile_server_base_url + "/v1");
+ Services.prefs.setCharPref("identity.sync.tokenserver.uri", config.sync_tokenserver_base_url + "/1.0/sync/1.5");
+ // Update the prefs that are based off of the autoconfig url
+
+ let contextParam = encodeURIComponent(
+ Services.prefs.getCharPref("identity.fxaccounts.contextParam"));
+
+ Services.prefs.setCharPref("identity.fxaccounts.remote.webchannel.uri", rootURL);
+ Services.prefs.setCharPref("identity.fxaccounts.settings.uri", rootURL + "/settings?service=sync&context=" + contextParam);
+ Services.prefs.setCharPref("identity.fxaccounts.remote.signup.uri", rootURL + "/signup?service=sync&context=" + contextParam);
+ Services.prefs.setCharPref("identity.fxaccounts.remote.signin.uri", rootURL + "/signin?service=sync&context=" + contextParam);
+ Services.prefs.setCharPref("identity.fxaccounts.remote.force_auth.uri", rootURL + "/force_auth?service=sync&context=" + contextParam);
+
+ let whitelistValue = Services.prefs.getCharPref("webchannel.allowObject.urlWhitelist");
+ if (!whitelistValue.includes(rootURL)) {
+ whitelistValue = `${rootURL} ${whitelistValue}`;
+ Services.prefs.setCharPref("webchannel.allowObject.urlWhitelist", whitelistValue);
+ }
+ // Ensure the webchannel is pointed at the correct uri
+ EnsureFxAccountsWebChannel();
+ } catch (e) {
+ log.error("Failed to initialize configuration preferences from autoconfig object", e);
+ throw e;
+ }
+ }),
+
+};
diff --git a/services/fxaccounts/FxAccountsManager.jsm b/services/fxaccounts/FxAccountsManager.jsm
new file mode 100644
index 000000000..680310ff5
--- /dev/null
+++ b/services/fxaccounts/FxAccountsManager.jsm
@@ -0,0 +1,654 @@
+/* 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/. */
+
+/**
+ * Temporary abstraction layer for common Fx Accounts operations.
+ * For now, we will be using this module only from B2G but in the end we might
+ * want this to be merged with FxAccounts.jsm and let other products also use
+ * it.
+ */
+
+"use strict";
+
+this.EXPORTED_SYMBOLS = ["FxAccountsManager"];
+
+const { classes: Cc, interfaces: Ci, utils: Cu } = Components;
+
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+Cu.import("resource://gre/modules/Services.jsm");
+Cu.import("resource://gre/modules/FxAccounts.jsm");
+Cu.import("resource://gre/modules/Promise.jsm");
+Cu.import("resource://gre/modules/FxAccountsCommon.js");
+
+XPCOMUtils.defineLazyServiceGetter(this, "permissionManager",
+ "@mozilla.org/permissionmanager;1",
+ "nsIPermissionManager");
+
+this.FxAccountsManager = {
+
+ init: function() {
+ Services.obs.addObserver(this, ONLOGOUT_NOTIFICATION, false);
+ Services.obs.addObserver(this, ON_FXA_UPDATE_NOTIFICATION, false);
+ },
+
+ observe: function(aSubject, aTopic, aData) {
+ // Both topics indicate our cache is invalid
+ this._activeSession = null;
+
+ if (aData == ONVERIFIED_NOTIFICATION) {
+ log.debug("FxAccountsManager: cache cleared, broadcasting: " + aData);
+ Services.obs.notifyObservers(null, aData, null);
+ }
+ },
+
+ // We don't really need to save fxAccounts instance but this way we allow
+ // to mock FxAccounts from tests.
+ _fxAccounts: fxAccounts,
+
+ // We keep the session details here so consumers don't need to deal with
+ // session tokens and are only required to handle the email.
+ _activeSession: null,
+
+ // Are we refreshing our authentication? If so, allow attempts to sign in
+ // while we are already signed in.
+ _refreshing: false,
+
+ // We only expose the email and the verified status so far.
+ get _user() {
+ if (!this._activeSession || !this._activeSession.email) {
+ return null;
+ }
+
+ return {
+ email: this._activeSession.email,
+ verified: this._activeSession.verified,
+ profile: this._activeSession.profile,
+ }
+ },
+
+ _error: function(aError, aDetails) {
+ log.error(aError);
+ let reason = {
+ error: aError
+ };
+ if (aDetails) {
+ reason.details = aDetails;
+ }
+ return Promise.reject(reason);
+ },
+
+ _getError: function(aServerResponse) {
+ if (!aServerResponse || !aServerResponse.error || !aServerResponse.error.errno) {
+ return;
+ }
+ let error = SERVER_ERRNO_TO_ERROR[aServerResponse.error.errno];
+ return error;
+ },
+
+ _serverError: function(aServerResponse) {
+ let error = this._getError({ error: aServerResponse });
+ return this._error(error ? error : ERROR_SERVER_ERROR, aServerResponse);
+ },
+
+ // As with _fxAccounts, we don't really need this method, but this way we
+ // allow tests to mock FxAccountsClient. By default, we want to return the
+ // client used by the fxAccounts object because deep down they should have
+ // access to the same hawk request object which will enable them to share
+ // local clock skeq data.
+ _getFxAccountsClient: function() {
+ return this._fxAccounts.getAccountsClient();
+ },
+
+ _signInSignUp: function(aMethod, aEmail, aPassword, aFetchKeys) {
+ if (Services.io.offline) {
+ return this._error(ERROR_OFFLINE);
+ }
+
+ if (!aEmail) {
+ return this._error(ERROR_INVALID_EMAIL);
+ }
+
+ if (!aPassword) {
+ return this._error(ERROR_INVALID_PASSWORD);
+ }
+
+ // Check that there is no signed in account first.
+ if ((!this._refreshing) && this._activeSession) {
+ return this._error(ERROR_ALREADY_SIGNED_IN_USER, {
+ user: this._user
+ });
+ }
+
+ let client = this._getFxAccountsClient();
+ return this._fxAccounts.getSignedInUser().then(
+ user => {
+ if ((!this._refreshing) && user) {
+ return this._error(ERROR_ALREADY_SIGNED_IN_USER, {
+ user: this._user
+ });
+ }
+ return client[aMethod](aEmail, aPassword, aFetchKeys);
+ }
+ ).then(
+ user => {
+ let error = this._getError(user);
+ if (!user || !user.uid || !user.sessionToken || error) {
+ return this._error(error ? error : ERROR_INTERNAL_INVALID_USER, {
+ user: user
+ });
+ }
+
+ // If the user object includes an email field, it may differ in
+ // capitalization from what we sent down. This is the server's
+ // canonical capitalization and should be used instead.
+ user.email = user.email || aEmail;
+
+ // If we're using server-side sign to refreshAuthentication
+ // we don't need to update local state; also because of two
+ // interacting glitches we need to bypass an event emission.
+ // See https://bugzilla.mozilla.org/show_bug.cgi?id=1031580
+ if (this._refreshing) {
+ return Promise.resolve({user: this._user});
+ }
+
+ return this._fxAccounts.setSignedInUser(user).then(
+ () => {
+ this._activeSession = user;
+ log.debug("User signed in: " + JSON.stringify(this._user) +
+ " - Account created " + (aMethod == "signUp"));
+
+ // There is no way to obtain the key fetch token afterwards
+ // without login out the user and asking her to log in again.
+ // Also, key fetch tokens are designed to be short-lived, so
+ // we need to fetch kB as soon as we have the key fetch token.
+ if (aFetchKeys) {
+ this._fxAccounts.getKeys();
+ }
+
+ return this._fxAccounts.getSignedInUserProfile().catch(error => {
+ // Not fetching the profile is sad but the FxA logs will already
+ // have noise.
+ return null;
+ });
+ }
+ ).then(profile => {
+ if (profile) {
+ this._activeSession.profile = profile;
+ }
+
+ return Promise.resolve({
+ accountCreated: aMethod === "signUp",
+ user: this._user
+ });
+ });
+ },
+ reason => { return this._serverError(reason); }
+ );
+ },
+
+ /**
+ * Determine whether the incoming error means that the current account
+ * has new server-side state via deletion or password change, and if so,
+ * spawn the appropriate UI (sign in or refresh); otherwise re-reject.
+ *
+ * As of May 2014, the only HTTP call triggered by this._getAssertion()
+ * is to /certificate/sign via:
+ * FxAccounts.getAssertion()
+ * FxAccountsInternal.getCertificateSigned()
+ * FxAccountsClient.signCertificate()
+ * See the latter method for possible (error code, errno) pairs.
+ */
+ _handleGetAssertionError: function(reason, aAudience, aPrincipal) {
+ log.debug("FxAccountsManager._handleGetAssertionError()");
+ let errno = (reason ? reason.errno : NaN) || NaN;
+ // If the previously valid email/password pair is no longer valid ...
+ if (errno == ERRNO_INVALID_AUTH_TOKEN) {
+ return this._fxAccounts.accountStatus().then(
+ (exists) => {
+ // ... if the email still maps to an account, the password
+ // must have changed, so ask the user to enter the new one ...
+ if (exists) {
+ return this.getAccount().then(
+ (user) => {
+ return this._refreshAuthentication(aAudience, user.email,
+ aPrincipal,
+ true /* logoutOnFailure */);
+ }
+ );
+ }
+ // ... otherwise, the account was deleted, so ask for Sign In/Up
+ return this._localSignOut().then(
+ () => {
+ return this._uiRequest(UI_REQUEST_SIGN_IN_FLOW, aAudience,
+ aPrincipal);
+ },
+ (reason) => {
+ // reject primary problem, not signout failure
+ log.error("Signing out in response to server error threw: " +
+ reason);
+ return this._error(reason);
+ }
+ );
+ }
+ );
+ }
+ return Promise.reject(reason.message ? { error: reason.message } : reason);
+ },
+
+ _getAssertion: function(aAudience, aPrincipal) {
+ return this._fxAccounts.getAssertion(aAudience).then(
+ (result) => {
+ if (aPrincipal) {
+ this._addPermission(aPrincipal);
+ }
+ return result;
+ },
+ (reason) => {
+ return this._handleGetAssertionError(reason, aAudience, aPrincipal);
+ }
+ );
+ },
+
+ /**
+ * "Refresh authentication" means:
+ * Interactively demonstrate knowledge of the FxA password
+ * for the currently logged-in account.
+ * There are two very different scenarios:
+ * 1) The password has changed on the server. Failure should log
+ * the current account OUT.
+ * 2) The person typing can't prove knowledge of the password used
+ * to log in. Failure should do nothing.
+ */
+ _refreshAuthentication: function(aAudience, aEmail, aPrincipal,
+ logoutOnFailure=false) {
+ this._refreshing = true;
+ return this._uiRequest(UI_REQUEST_REFRESH_AUTH,
+ aAudience, aPrincipal, aEmail).then(
+ (assertion) => {
+ this._refreshing = false;
+ return assertion;
+ },
+ (reason) => {
+ this._refreshing = false;
+ if (logoutOnFailure) {
+ return this._signOut().then(
+ () => {
+ return this._error(reason);
+ }
+ );
+ }
+ return this._error(reason);
+ }
+ );
+ },
+
+ _localSignOut: function() {
+ return this._fxAccounts.signOut(true);
+ },
+
+ _signOut: function() {
+ if (!this._activeSession) {
+ return Promise.resolve();
+ }
+
+ // We clear the local session cache as soon as we get the onlogout
+ // notification triggered within FxAccounts.signOut, so we save the
+ // session token value to be able to remove the remote server session
+ // in case that we have network connection.
+ let sessionToken = this._activeSession.sessionToken;
+
+ return this._localSignOut().then(
+ () => {
+ // At this point the local session should already be removed.
+
+ // The client can create new sessions up to the limit (100?).
+ // Orphaned tokens on the server will eventually be garbage collected.
+ if (Services.io.offline) {
+ return Promise.resolve();
+ }
+ // Otherwise, we try to remove the remote session.
+ let client = this._getFxAccountsClient();
+ return client.signOut(sessionToken).then(
+ result => {
+ let error = this._getError(result);
+ if (error) {
+ return this._error(error, result);
+ }
+ log.debug("Signed out");
+ return Promise.resolve();
+ },
+ reason => {
+ return this._serverError(reason);
+ }
+ );
+ }
+ );
+ },
+
+ _uiRequest: function(aRequest, aAudience, aPrincipal, aParams) {
+ if (Services.io.offline) {
+ return this._error(ERROR_OFFLINE);
+ }
+ let ui = Cc["@mozilla.org/fxaccounts/fxaccounts-ui-glue;1"]
+ .createInstance(Ci.nsIFxAccountsUIGlue);
+ if (!ui[aRequest]) {
+ return this._error(ERROR_UI_REQUEST);
+ }
+
+ if (!aParams || !Array.isArray(aParams)) {
+ aParams = [aParams];
+ }
+
+ return ui[aRequest].apply(this, aParams).then(
+ result => {
+ // Even if we get a successful result from the UI, the account will
+ // most likely be unverified, so we cannot get an assertion.
+ if (result && result.verified) {
+ return this._getAssertion(aAudience, aPrincipal);
+ }
+
+ return this._error(ERROR_UNVERIFIED_ACCOUNT, {
+ user: result
+ });
+ },
+ error => {
+ return this._error(ERROR_UI_ERROR, error);
+ }
+ );
+ },
+
+ _addPermission: function(aPrincipal) {
+ // This will fail from tests cause we are running them in the child
+ // process until we have chrome tests in b2g. Bug 797164.
+ try {
+ permissionManager.addFromPrincipal(aPrincipal, FXACCOUNTS_PERMISSION,
+ Ci.nsIPermissionManager.ALLOW_ACTION);
+ } catch (e) {
+ log.warn("Could not add permission " + e);
+ }
+ },
+
+ // -- API --
+
+ signIn: function(aEmail, aPassword, aFetchKeys) {
+ return this._signInSignUp("signIn", aEmail, aPassword, aFetchKeys);
+ },
+
+ signUp: function(aEmail, aPassword, aFetchKeys) {
+ return this._signInSignUp("signUp", aEmail, aPassword, aFetchKeys);
+ },
+
+ signOut: function() {
+ if (!this._activeSession) {
+ // If there is no cached active session, we try to get it from the
+ // account storage.
+ return this.getAccount().then(
+ result => {
+ if (!result) {
+ return Promise.resolve();
+ }
+ return this._signOut();
+ }
+ );
+ }
+ return this._signOut();
+ },
+
+ resendVerificationEmail: function() {
+ return this._fxAccounts.resendVerificationEmail().then(
+ (result) => {
+ return result;
+ },
+ (error) => {
+ return this._error(ERROR_SERVER_ERROR, error);
+ }
+ );
+ },
+
+ getAccount: function() {
+ // We check first if we have session details cached.
+ if (this._activeSession) {
+ // If our cache says that the account is not yet verified,
+ // we kick off verification before returning what we have.
+ if (!this._activeSession.verified) {
+ this.verificationStatus(this._activeSession);
+ }
+ log.debug("Account " + JSON.stringify(this._user));
+ return Promise.resolve(this._user);
+ }
+
+ // If no cached information, we try to get it from the persistent storage.
+ return this._fxAccounts.getSignedInUser().then(
+ user => {
+ if (!user || !user.email) {
+ log.debug("No signed in account");
+ return Promise.resolve(null);
+ }
+
+ this._activeSession = user;
+ // If we get a stored information of a not yet verified account,
+ // we kick off verification before returning what we have.
+ if (!user.verified) {
+ this.verificationStatus(user);
+ // Trying to get the profile for unverified users will fail, so we
+ // don't even try in that case.
+ log.debug("Account ", this._user);
+ return Promise.resolve(this._user);
+ }
+
+ return this._fxAccounts.getSignedInUserProfile().then(profile => {
+ if (profile) {
+ this._activeSession.profile = profile;
+ }
+ log.debug("Account ", this._user);
+ return Promise.resolve(this._user);
+ }).catch(error => {
+ // FxAccounts logs already inform about the error.
+ log.debug("Account ", this._user);
+ return Promise.resolve(this._user);
+ });
+ }
+ );
+ },
+
+ queryAccount: function(aEmail) {
+ log.debug("queryAccount " + aEmail);
+ if (Services.io.offline) {
+ return this._error(ERROR_OFFLINE);
+ }
+
+ let deferred = Promise.defer();
+
+ if (!aEmail) {
+ return this._error(ERROR_INVALID_EMAIL);
+ }
+
+ let client = this._getFxAccountsClient();
+ return client.accountExists(aEmail).then(
+ result => {
+ log.debug("Account " + result ? "" : "does not" + " exists");
+ let error = this._getError(result);
+ if (error) {
+ return this._error(error, result);
+ }
+
+ return Promise.resolve({
+ registered: result
+ });
+ },
+ reason => { this._serverError(reason); }
+ );
+ },
+
+ verificationStatus: function() {
+ log.debug("verificationStatus");
+ if (!this._activeSession || !this._activeSession.sessionToken) {
+ this._error(ERROR_NO_TOKEN_SESSION);
+ }
+
+ // There is no way to unverify an already verified account, so we just
+ // return the account details of a verified account
+ if (this._activeSession.verified) {
+ log.debug("Account already verified");
+ return;
+ }
+
+ if (Services.io.offline) {
+ log.warn("Offline; skipping verification.");
+ return;
+ }
+
+ let client = this._getFxAccountsClient();
+ client.recoveryEmailStatus(this._activeSession.sessionToken).then(
+ data => {
+ let error = this._getError(data);
+ if (error) {
+ this._error(error, data);
+ }
+ // If the verification status has changed, update state.
+ if (this._activeSession.verified != data.verified) {
+ this._activeSession.verified = data.verified;
+ this._fxAccounts.setSignedInUser(this._activeSession);
+ this._fxAccounts.getSignedInUserProfile().then(profile => {
+ if (profile) {
+ this._activeSession.profile = profile;
+ }
+ }).catch(error => {
+ // FxAccounts logs already inform about the error.
+ });
+ }
+ log.debug(JSON.stringify(this._user));
+ },
+ reason => { this._serverError(reason); }
+ );
+ },
+
+ /*
+ * Try to get an assertion for the given audience. Here we implement
+ * the heart of the response to navigator.mozId.request() on device.
+ * (We can also be called via the IAC API, but it's request() that
+ * makes this method complex.) The state machine looks like this,
+ * ignoring simple errors:
+ * If no one is signed in, and we aren't suppressing the UI:
+ * trigger the sign in flow.
+ * else if we were asked to refresh and the grace period is up:
+ * trigger the refresh flow.
+ * else:
+ * request user permission to share an assertion if we don't have it
+ * already and ask the core code for an assertion, which might itself
+ * trigger either the sign in or refresh flows (if our account
+ * changed on the server).
+ *
+ * aOptions can include:
+ * refreshAuthentication - (bool) Force re-auth.
+ * silent - (bool) Prevent any UI interaction.
+ * I.e., try to get an automatic assertion.
+ */
+ getAssertion: function(aAudience, aPrincipal, aOptions) {
+ if (!aAudience) {
+ return this._error(ERROR_INVALID_AUDIENCE);
+ }
+
+ let principal = aPrincipal;
+ log.debug("FxAccountsManager.getAssertion() aPrincipal: ",
+ principal.origin, principal.appId,
+ principal.isInIsolatedMozBrowserElement);
+
+ return this.getAccount().then(
+ user => {
+ if (user) {
+ // Three have-user cases to consider. First: are we unverified?
+ if (!user.verified) {
+ return this._error(ERROR_UNVERIFIED_ACCOUNT, {
+ user: user
+ });
+ }
+ // Second case: do we need to refresh?
+ if (aOptions &&
+ (typeof(aOptions.refreshAuthentication) != "undefined")) {
+ let gracePeriod = aOptions.refreshAuthentication;
+ if (typeof(gracePeriod) !== "number" || isNaN(gracePeriod)) {
+ return this._error(ERROR_INVALID_REFRESH_AUTH_VALUE);
+ }
+ // Forcing refreshAuth to silent is a contradiction in terms,
+ // though it might succeed silently if we didn't reject here.
+ if (aOptions.silent) {
+ return this._error(ERROR_NO_SILENT_REFRESH_AUTH);
+ }
+ let secondsSinceAuth = (Date.now() / 1000) -
+ this._activeSession.authAt;
+ if (secondsSinceAuth > gracePeriod) {
+ return this._refreshAuthentication(aAudience, user.email,
+ principal,
+ false /* logoutOnFailure */);
+ }
+ }
+ // Third case: we are all set *locally*. Probably we just return
+ // the assertion, but the attempt might lead to the server saying
+ // we are deleted or have a new password, which will trigger a flow.
+ // Also we need to check if we have permission to get the assertion,
+ // otherwise we need to show the forceAuth UI to let the user know
+ // that the RP with no fxa permissions is trying to obtain an
+ // assertion. Once the user authenticates herself in the forceAuth UI
+ // the permission will be remembered by default.
+ let permission = permissionManager.testPermissionFromPrincipal(
+ principal,
+ FXACCOUNTS_PERMISSION
+ );
+ if (permission == Ci.nsIPermissionManager.PROMPT_ACTION &&
+ !this._refreshing) {
+ return this._refreshAuthentication(aAudience, user.email,
+ principal,
+ false /* logoutOnFailure */);
+ } else if (permission == Ci.nsIPermissionManager.DENY_ACTION &&
+ !this._refreshing) {
+ return this._error(ERROR_PERMISSION_DENIED);
+ } else if (this._refreshing) {
+ // If we are blocked asking for a password we should not continue
+ // the getAssertion process.
+ return Promise.resolve(null);
+ }
+ return this._getAssertion(aAudience, principal);
+ }
+ log.debug("No signed in user");
+ if (aOptions && aOptions.silent) {
+ return Promise.resolve(null);
+ }
+ return this._uiRequest(UI_REQUEST_SIGN_IN_FLOW, aAudience, principal);
+ }
+ );
+ },
+
+ getKeys: function() {
+ let syncEnabled = false;
+ try {
+ syncEnabled = Services.prefs.getBoolPref("services.sync.enabled");
+ } catch(e) {
+ dump("Sync is disabled, so you won't get the keys. " + e + "\n");
+ }
+
+ if (!syncEnabled) {
+ return Promise.reject(ERROR_SYNC_DISABLED);
+ }
+
+ return this.getAccount().then(
+ user => {
+ if (!user) {
+ log.debug("No signed in user");
+ return Promise.resolve(null);
+ }
+
+ if (!user.verified) {
+ return this._error(ERROR_UNVERIFIED_ACCOUNT, {
+ user: user
+ });
+ }
+
+ return this._fxAccounts.getKeys();
+ }
+ );
+ }
+};
+
+FxAccountsManager.init();
diff --git a/services/fxaccounts/FxAccountsOAuthClient.jsm b/services/fxaccounts/FxAccountsOAuthClient.jsm
new file mode 100644
index 000000000..c59f1a869
--- /dev/null
+++ b/services/fxaccounts/FxAccountsOAuthClient.jsm
@@ -0,0 +1,269 @@
+/* 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/. */
+
+/**
+ * Firefox Accounts OAuth browser login helper.
+ * Uses the WebChannel component to receive OAuth messages and complete login flows.
+ */
+
+this.EXPORTED_SYMBOLS = ["FxAccountsOAuthClient"];
+
+const {classes: Cc, interfaces: Ci, utils: Cu, results: Cr} = Components;
+
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+Cu.import("resource://gre/modules/Log.jsm");
+Cu.import("resource://gre/modules/Services.jsm");
+Cu.import("resource://gre/modules/FxAccountsCommon.js");
+XPCOMUtils.defineLazyModuleGetter(this, "WebChannel",
+ "resource://gre/modules/WebChannel.jsm");
+Cu.importGlobalProperties(["URL"]);
+
+/**
+ * Create a new FxAccountsOAuthClient for browser some service.
+ *
+ * @param {Object} options Options
+ * @param {Object} options.parameters
+ * Opaque alphanumeric token to be included in verification links
+ * @param {String} options.parameters.client_id
+ * OAuth id returned from client registration
+ * @param {String} options.parameters.state
+ * A value that will be returned to the client as-is upon redirection
+ * @param {String} options.parameters.oauth_uri
+ * The FxA OAuth server uri
+ * @param {String} options.parameters.content_uri
+ * The FxA Content server uri
+ * @param {String} [options.parameters.scope]
+ * Optional. A colon-separated list of scopes that the user has authorized
+ * @param {String} [options.parameters.action]
+ * Optional. If provided, should be either signup, signin or force_auth.
+ * @param {String} [options.parameters.email]
+ * Optional. Required if options.paramters.action is 'force_auth'.
+ * @param {Boolean} [options.parameters.keys]
+ * Optional. If true then relier-specific encryption keys will be
+ * available in the second argument to onComplete.
+ * @param [authorizationEndpoint] {String}
+ * Optional authorization endpoint for the OAuth server
+ * @constructor
+ */
+this.FxAccountsOAuthClient = function(options) {
+ this._validateOptions(options);
+ this.parameters = options.parameters;
+ this._configureChannel();
+
+ let authorizationEndpoint = options.authorizationEndpoint || "/authorization";
+
+ try {
+ this._fxaOAuthStartUrl = new URL(this.parameters.oauth_uri + authorizationEndpoint + "?");
+ } catch (e) {
+ throw new Error("Invalid OAuth Url");
+ }
+
+ let params = this._fxaOAuthStartUrl.searchParams;
+ params.append("client_id", this.parameters.client_id);
+ params.append("state", this.parameters.state);
+ params.append("scope", this.parameters.scope || "");
+ params.append("action", this.parameters.action || "signin");
+ params.append("webChannelId", this._webChannelId);
+ if (this.parameters.keys) {
+ params.append("keys", "true");
+ }
+ // Only append if we actually have a value.
+ if (this.parameters.email) {
+ params.append("email", this.parameters.email);
+ }
+};
+
+this.FxAccountsOAuthClient.prototype = {
+ /**
+ * Function that gets called once the OAuth flow is complete.
+ * The callback will receive an object with code and state properties.
+ * If the keys parameter was specified and true, the callback will receive
+ * a second argument with kAr and kBr properties.
+ */
+ onComplete: null,
+ /**
+ * Function that gets called if there is an error during the OAuth flow,
+ * for example due to a state mismatch.
+ * The callback will receive an Error object as its argument.
+ */
+ onError: null,
+ /**
+ * Configuration object that stores all OAuth parameters.
+ */
+ parameters: null,
+ /**
+ * WebChannel that is used to communicate with content page.
+ */
+ _channel: null,
+ /**
+ * Boolean to indicate if this client has completed an OAuth flow.
+ */
+ _complete: false,
+ /**
+ * The url that opens the Firefox Accounts OAuth flow.
+ */
+ _fxaOAuthStartUrl: null,
+ /**
+ * WebChannel id.
+ */
+ _webChannelId: null,
+ /**
+ * WebChannel origin, used to validate origin of messages.
+ */
+ _webChannelOrigin: null,
+ /**
+ * Opens a tab at "this._fxaOAuthStartUrl".
+ * Registers a WebChannel listener and sets up a callback if needed.
+ */
+ launchWebFlow: function () {
+ if (!this._channelCallback) {
+ this._registerChannel();
+ }
+
+ if (this._complete) {
+ throw new Error("This client already completed the OAuth flow");
+ } else {
+ let opener = Services.wm.getMostRecentWindow("navigator:browser").gBrowser;
+ opener.selectedTab = opener.addTab(this._fxaOAuthStartUrl.href);
+ }
+ },
+
+ /**
+ * Release all resources that are in use.
+ */
+ tearDown: function() {
+ this.onComplete = null;
+ this.onError = null;
+ this._complete = true;
+ this._channel.stopListening();
+ this._channel = null;
+ },
+
+ /**
+ * Configures WebChannel id and origin
+ *
+ * @private
+ */
+ _configureChannel: function() {
+ this._webChannelId = "oauth_" + this.parameters.client_id;
+
+ // if this.parameters.content_uri is present but not a valid URI, then this will throw an error.
+ try {
+ this._webChannelOrigin = Services.io.newURI(this.parameters.content_uri, null, null);
+ } catch (e) {
+ throw e;
+ }
+ },
+
+ /**
+ * Create a new channel with the WebChannelBroker, setup a callback listener
+ * @private
+ */
+ _registerChannel: function() {
+ /**
+ * Processes messages that are called back from the FxAccountsChannel
+ *
+ * @param webChannelId {String}
+ * Command webChannelId
+ * @param message {Object}
+ * Command message
+ * @param sendingContext {Object}
+ * Channel message event sendingContext
+ * @private
+ */
+ let listener = function (webChannelId, message, sendingContext) {
+ if (message) {
+ let command = message.command;
+ let data = message.data;
+ let target = sendingContext && sendingContext.browser;
+
+ switch (command) {
+ case "oauth_complete":
+ // validate the returned state and call onComplete or onError
+ let result = null;
+ let err = null;
+
+ if (this.parameters.state !== data.state) {
+ err = new Error("OAuth flow failed. State doesn't match");
+ } else if (this.parameters.keys && !data.keys) {
+ err = new Error("OAuth flow failed. Keys were not returned");
+ } else {
+ result = {
+ code: data.code,
+ state: data.state
+ };
+ }
+
+ // if the message asked to close the tab
+ if (data.closeWindow && target) {
+ // for e10s reasons the best way is to use the TabBrowser to close the tab.
+ let tabbrowser = target.getTabBrowser();
+
+ if (tabbrowser) {
+ let tab = tabbrowser.getTabForBrowser(target);
+
+ if (tab) {
+ tabbrowser.removeTab(tab);
+ log.debug("OAuth flow closed the tab.");
+ } else {
+ log.debug("OAuth flow failed to close the tab. Tab not found in TabBrowser.");
+ }
+ } else {
+ log.debug("OAuth flow failed to close the tab. TabBrowser not found.");
+ }
+ }
+
+ if (err) {
+ log.debug(err.message);
+ if (this.onError) {
+ this.onError(err);
+ }
+ } else {
+ log.debug("OAuth flow completed.");
+ if (this.onComplete) {
+ if (this.parameters.keys) {
+ this.onComplete(result, data.keys);
+ } else {
+ this.onComplete(result);
+ }
+ }
+ }
+
+ // onComplete will be called for this client only once
+ // calling onComplete again will result in a failure of the OAuth flow
+ this.tearDown();
+ break;
+ }
+ }
+ };
+
+ this._channelCallback = listener.bind(this);
+ this._channel = new WebChannel(this._webChannelId, this._webChannelOrigin);
+ this._channel.listen(this._channelCallback);
+ log.debug("Channel registered: " + this._webChannelId + " with origin " + this._webChannelOrigin.prePath);
+ },
+
+ /**
+ * Validates the required FxA OAuth parameters
+ *
+ * @param options {Object}
+ * OAuth client options
+ * @private
+ */
+ _validateOptions: function (options) {
+ if (!options || !options.parameters) {
+ throw new Error("Missing 'parameters' configuration option");
+ }
+
+ ["oauth_uri", "client_id", "content_uri", "state"].forEach(option => {
+ if (!options.parameters[option]) {
+ throw new Error("Missing 'parameters." + option + "' parameter");
+ }
+ });
+
+ if (options.parameters.action == "force_auth" && !options.parameters.email) {
+ throw new Error("parameters.email is required for action 'force_auth'");
+ }
+ },
+};
diff --git a/services/fxaccounts/FxAccountsOAuthGrantClient.jsm b/services/fxaccounts/FxAccountsOAuthGrantClient.jsm
new file mode 100644
index 000000000..4319a07ab
--- /dev/null
+++ b/services/fxaccounts/FxAccountsOAuthGrantClient.jsm
@@ -0,0 +1,241 @@
+/* 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/. */
+
+/**
+ * Firefox Accounts OAuth Grant Client allows clients to obtain
+ * an OAuth token from a BrowserID assertion. Only certain client
+ * IDs support this privilage.
+ */
+
+this.EXPORTED_SYMBOLS = ["FxAccountsOAuthGrantClient", "FxAccountsOAuthGrantClientError"];
+
+const {classes: Cc, interfaces: Ci, utils: Cu, results: Cr} = Components;
+
+Cu.import("resource://gre/modules/Promise.jsm");
+Cu.import("resource://gre/modules/Log.jsm");
+Cu.import("resource://gre/modules/FxAccountsCommon.js");
+Cu.import("resource://services-common/rest.js");
+
+Cu.importGlobalProperties(["URL"]);
+
+const AUTH_ENDPOINT = "/authorization";
+const DESTROY_ENDPOINT = "/destroy";
+
+/**
+ * Create a new FxAccountsOAuthClient for browser some service.
+ *
+ * @param {Object} options Options
+ * @param {Object} options.parameters
+ * @param {String} options.parameters.client_id
+ * OAuth id returned from client registration
+ * @param {String} options.parameters.serverURL
+ * The FxA OAuth server URL
+ * @param [authorizationEndpoint] {String}
+ * Optional authorization endpoint for the OAuth server
+ * @constructor
+ */
+this.FxAccountsOAuthGrantClient = function(options) {
+
+ this._validateOptions(options);
+ this.parameters = options;
+
+ try {
+ this.serverURL = new URL(this.parameters.serverURL);
+ } catch (e) {
+ throw new Error("Invalid 'serverURL'");
+ }
+
+ log.debug("FxAccountsOAuthGrantClient Initialized");
+};
+
+this.FxAccountsOAuthGrantClient.prototype = {
+
+ /**
+ * Retrieves an OAuth access token for the signed in user
+ *
+ * @param {Object} assertion BrowserID assertion
+ * @param {String} scope OAuth scope
+ * @return Promise
+ * Resolves: {Object} Object with access_token property
+ */
+ getTokenFromAssertion: function (assertion, scope) {
+ if (!assertion) {
+ throw new Error("Missing 'assertion' parameter");
+ }
+ if (!scope) {
+ throw new Error("Missing 'scope' parameter");
+ }
+ let params = {
+ scope: scope,
+ client_id: this.parameters.client_id,
+ assertion: assertion,
+ response_type: "token"
+ };
+
+ return this._createRequest(AUTH_ENDPOINT, "POST", params);
+ },
+
+ /**
+ * Destroys a previously fetched OAuth access token.
+ *
+ * @param {String} token The previously fetched token
+ * @return Promise
+ * Resolves: {Object} with the server response, which is typically
+ * ignored.
+ */
+ destroyToken: function (token) {
+ if (!token) {
+ throw new Error("Missing 'token' parameter");
+ }
+ let params = {
+ token: token,
+ };
+
+ return this._createRequest(DESTROY_ENDPOINT, "POST", params);
+ },
+
+ /**
+ * Validates the required FxA OAuth parameters
+ *
+ * @param options {Object}
+ * OAuth client options
+ * @private
+ */
+ _validateOptions: function (options) {
+ if (!options) {
+ throw new Error("Missing configuration options");
+ }
+
+ ["serverURL", "client_id"].forEach(option => {
+ if (!options[option]) {
+ throw new Error("Missing '" + option + "' parameter");
+ }
+ });
+ },
+
+ /**
+ * Interface for making remote requests.
+ */
+ _Request: RESTRequest,
+
+ /**
+ * Remote request helper
+ *
+ * @param {String} path
+ * Profile server path, i.e "/profile".
+ * @param {String} [method]
+ * Type of request, i.e "GET".
+ * @return Promise
+ * Resolves: {Object} Successful response from the Profile server.
+ * Rejects: {FxAccountsOAuthGrantClientError} Profile client error.
+ * @private
+ */
+ _createRequest: function(path, method = "POST", params) {
+ return new Promise((resolve, reject) => {
+ let profileDataUrl = this.serverURL + path;
+ let request = new this._Request(profileDataUrl);
+ method = method.toUpperCase();
+
+ request.setHeader("Accept", "application/json");
+ request.setHeader("Content-Type", "application/json");
+
+ request.onComplete = function (error) {
+ if (error) {
+ return reject(new FxAccountsOAuthGrantClientError({
+ error: ERROR_NETWORK,
+ errno: ERRNO_NETWORK,
+ message: error.toString(),
+ }));
+ }
+
+ let body = null;
+ try {
+ body = JSON.parse(request.response.body);
+ } catch (e) {
+ return reject(new FxAccountsOAuthGrantClientError({
+ error: ERROR_PARSE,
+ errno: ERRNO_PARSE,
+ code: request.response.status,
+ message: request.response.body,
+ }));
+ }
+
+ // "response.success" means status code is 200
+ if (request.response.success) {
+ return resolve(body);
+ }
+
+ if (typeof body.errno === 'number') {
+ // Offset oauth server errnos to avoid conflict with other FxA server errnos
+ body.errno += OAUTH_SERVER_ERRNO_OFFSET;
+ } else if (body.errno) {
+ body.errno = ERRNO_UNKNOWN_ERROR;
+ }
+ return reject(new FxAccountsOAuthGrantClientError(body));
+ };
+
+ if (method === "POST") {
+ request.post(params);
+ } else {
+ // method not supported
+ return reject(new FxAccountsOAuthGrantClientError({
+ error: ERROR_NETWORK,
+ errno: ERRNO_NETWORK,
+ code: ERROR_CODE_METHOD_NOT_ALLOWED,
+ message: ERROR_MSG_METHOD_NOT_ALLOWED,
+ }));
+ }
+ });
+ },
+
+};
+
+/**
+ * Normalized profile client errors
+ * @param {Object} [details]
+ * Error details object
+ * @param {number} [details.code]
+ * Error code
+ * @param {number} [details.errno]
+ * Error number
+ * @param {String} [details.error]
+ * Error description
+ * @param {String|null} [details.message]
+ * Error message
+ * @constructor
+ */
+this.FxAccountsOAuthGrantClientError = function(details) {
+ details = details || {};
+
+ this.name = "FxAccountsOAuthGrantClientError";
+ this.code = details.code || null;
+ this.errno = details.errno || ERRNO_UNKNOWN_ERROR;
+ this.error = details.error || ERROR_UNKNOWN;
+ this.message = details.message || null;
+};
+
+/**
+ * Returns error object properties
+ *
+ * @returns {{name: *, code: *, errno: *, error: *, message: *}}
+ * @private
+ */
+FxAccountsOAuthGrantClientError.prototype._toStringFields = function() {
+ return {
+ name: this.name,
+ code: this.code,
+ errno: this.errno,
+ error: this.error,
+ message: this.message,
+ };
+};
+
+/**
+ * String representation of a oauth grant client error
+ *
+ * @returns {String}
+ */
+FxAccountsOAuthGrantClientError.prototype.toString = function() {
+ return this.name + "(" + JSON.stringify(this._toStringFields()) + ")";
+};
diff --git a/services/fxaccounts/FxAccountsProfile.jsm b/services/fxaccounts/FxAccountsProfile.jsm
new file mode 100644
index 000000000..b63cd64c1
--- /dev/null
+++ b/services/fxaccounts/FxAccountsProfile.jsm
@@ -0,0 +1,191 @@
+/* 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";
+
+/**
+ * Firefox Accounts Profile helper.
+ *
+ * This class abstracts interaction with the profile server for an account.
+ * It will handle things like fetching profile data, listening for updates to
+ * the user's profile in open browser tabs, and cacheing/invalidating profile data.
+ */
+
+this.EXPORTED_SYMBOLS = ["FxAccountsProfile"];
+
+const {classes: Cc, interfaces: Ci, utils: Cu, results: Cr} = Components;
+
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+Cu.import("resource://gre/modules/Services.jsm");
+Cu.import("resource://gre/modules/FxAccountsCommon.js");
+Cu.import("resource://gre/modules/FxAccounts.jsm");
+
+XPCOMUtils.defineLazyModuleGetter(this, "FxAccountsProfileClient",
+ "resource://gre/modules/FxAccountsProfileClient.jsm");
+
+// Based off of deepEqual from Assert.jsm
+function deepEqual(actual, expected) {
+ if (actual === expected) {
+ return true;
+ } else if (typeof actual != "object" && typeof expected != "object") {
+ return actual == expected;
+ } else {
+ return objEquiv(actual, expected);
+ }
+}
+
+function isUndefinedOrNull(value) {
+ return value === null || value === undefined;
+}
+
+function objEquiv(a, b) {
+ if (isUndefinedOrNull(a) || isUndefinedOrNull(b)) {
+ return false;
+ }
+ if (a.prototype !== b.prototype) {
+ return false;
+ }
+ let ka, kb, key, i;
+ try {
+ ka = Object.keys(a);
+ kb = Object.keys(b);
+ } catch (e) {
+ return false;
+ }
+ if (ka.length != kb.length) {
+ return false;
+ }
+ ka.sort();
+ kb.sort();
+ for (i = ka.length - 1; i >= 0; i--) {
+ key = ka[i];
+ if (!deepEqual(a[key], b[key])) {
+ return false;
+ }
+ }
+ return true;
+}
+
+function hasChanged(oldData, newData) {
+ return !deepEqual(oldData, newData);
+}
+
+this.FxAccountsProfile = function (options = {}) {
+ this._cachedProfile = null;
+ this._cachedAt = 0; // when we saved the cached version.
+ this._currentFetchPromise = null;
+ this._isNotifying = false; // are we sending a notification?
+ this.fxa = options.fxa || fxAccounts;
+ this.client = options.profileClient || new FxAccountsProfileClient({
+ fxa: this.fxa,
+ serverURL: options.profileServerUrl,
+ });
+
+ // An observer to invalidate our _cachedAt optimization. We use a weak-ref
+ // just incase this.tearDown isn't called in some cases.
+ Services.obs.addObserver(this, ON_PROFILE_CHANGE_NOTIFICATION, true);
+ // for testing
+ if (options.channel) {
+ this.channel = options.channel;
+ }
+}
+
+this.FxAccountsProfile.prototype = {
+ // If we get subsequent requests for a profile within this period, don't bother
+ // making another request to determine if it is fresh or not.
+ PROFILE_FRESHNESS_THRESHOLD: 120000, // 2 minutes
+
+ observe(subject, topic, data) {
+ // If we get a profile change notification from our webchannel it means
+ // the user has just changed their profile via the web, so we want to
+ // ignore our "freshness threshold"
+ if (topic == ON_PROFILE_CHANGE_NOTIFICATION && !this._isNotifying) {
+ log.debug("FxAccountsProfile observed profile change");
+ this._cachedAt = 0;
+ }
+ },
+
+ tearDown: function () {
+ this.fxa = null;
+ this.client = null;
+ this._cachedProfile = null;
+ Services.obs.removeObserver(this, ON_PROFILE_CHANGE_NOTIFICATION);
+ },
+
+ _getCachedProfile: function () {
+ // The cached profile will end up back in the generic accountData
+ // once bug 1157529 is fixed.
+ return Promise.resolve(this._cachedProfile);
+ },
+
+ _notifyProfileChange: function (uid) {
+ this._isNotifying = true;
+ Services.obs.notifyObservers(null, ON_PROFILE_CHANGE_NOTIFICATION, uid);
+ this._isNotifying = false;
+ },
+
+ // Cache fetched data if it is different from what's in the cache.
+ // Send out a notification if it has changed so that UI can update.
+ _cacheProfile: function (profileData) {
+ if (!hasChanged(this._cachedProfile, profileData)) {
+ log.debug("fetched profile matches cached copy");
+ return Promise.resolve(null); // indicates no change (but only tests care)
+ }
+ this._cachedProfile = profileData;
+ this._cachedAt = Date.now();
+ return this.fxa.getSignedInUser()
+ .then(userData => {
+ log.debug("notifying profile changed for user ${uid}", userData);
+ this._notifyProfileChange(userData.uid);
+ return profileData;
+ });
+ },
+
+ _fetchAndCacheProfile: function () {
+ if (!this._currentFetchPromise) {
+ this._currentFetchPromise = this.client.fetchProfile().then(profile => {
+ return this._cacheProfile(profile).then(() => {
+ return profile;
+ });
+ }).then(profile => {
+ this._currentFetchPromise = null;
+ return profile;
+ }, err => {
+ this._currentFetchPromise = null;
+ throw err;
+ });
+ }
+ return this._currentFetchPromise
+ },
+
+ // Returns cached data right away if available, then fetches the latest profile
+ // data in the background. After data is fetched a notification will be sent
+ // out if the profile has changed.
+ getProfile: function () {
+ return this._getCachedProfile()
+ .then(cachedProfile => {
+ if (cachedProfile) {
+ if (Date.now() > this._cachedAt + this.PROFILE_FRESHNESS_THRESHOLD) {
+ // Note that _fetchAndCacheProfile isn't returned, so continues
+ // in the background.
+ this._fetchAndCacheProfile().catch(err => {
+ log.error("Background refresh of profile failed", err);
+ });
+ } else {
+ log.trace("not checking freshness of profile as it remains recent");
+ }
+ return cachedProfile;
+ }
+ return this._fetchAndCacheProfile();
+ })
+ .then(profile => {
+ return profile;
+ });
+ },
+
+ QueryInterface: XPCOMUtils.generateQI([
+ Ci.nsIObserver,
+ Ci.nsISupportsWeakReference,
+ ]),
+};
diff --git a/services/fxaccounts/FxAccountsProfileClient.jsm b/services/fxaccounts/FxAccountsProfileClient.jsm
new file mode 100644
index 000000000..37115a3fa
--- /dev/null
+++ b/services/fxaccounts/FxAccountsProfileClient.jsm
@@ -0,0 +1,260 @@
+/* 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/. */
+
+/**
+ * A client to fetch profile information for a Firefox Account.
+ */
+ "use strict;"
+
+this.EXPORTED_SYMBOLS = ["FxAccountsProfileClient", "FxAccountsProfileClientError"];
+
+const {classes: Cc, interfaces: Ci, utils: Cu, results: Cr} = Components;
+
+Cu.import("resource://gre/modules/Promise.jsm");
+Cu.import("resource://gre/modules/Log.jsm");
+Cu.import("resource://gre/modules/FxAccountsCommon.js");
+Cu.import("resource://gre/modules/FxAccounts.jsm");
+Cu.import("resource://gre/modules/Task.jsm");
+Cu.import("resource://services-common/rest.js");
+
+Cu.importGlobalProperties(["URL"]);
+
+/**
+ * Create a new FxAccountsProfileClient to be able to fetch Firefox Account profile information.
+ *
+ * @param {Object} options Options
+ * @param {String} options.serverURL
+ * The URL of the profile server to query.
+ * Example: https://profile.accounts.firefox.com/v1
+ * @param {String} options.token
+ * The bearer token to access the profile server
+ * @constructor
+ */
+this.FxAccountsProfileClient = function(options) {
+ if (!options || !options.serverURL) {
+ throw new Error("Missing 'serverURL' configuration option");
+ }
+
+ this.fxa = options.fxa || fxAccounts;
+ // This is a work-around for loop that manages its own oauth tokens.
+ // * If |token| is in options we use it and don't attempt any token refresh
+ // on 401. This is for loop.
+ // * If |token| doesn't exist we will fetch our own token. This is for the
+ // normal FxAccounts methods for obtaining the profile.
+ // We should nuke all |this.token| support once loop moves closer to FxAccounts.
+ this.token = options.token;
+
+ try {
+ this.serverURL = new URL(options.serverURL);
+ } catch (e) {
+ throw new Error("Invalid 'serverURL'");
+ }
+ this.oauthOptions = {
+ scope: "profile",
+ };
+ log.debug("FxAccountsProfileClient: Initialized");
+};
+
+this.FxAccountsProfileClient.prototype = {
+ /**
+ * {nsIURI}
+ * The server to fetch profile information from.
+ */
+ serverURL: null,
+
+ /**
+ * Interface for making remote requests.
+ */
+ _Request: RESTRequest,
+
+ /**
+ * Remote request helper which abstracts authentication away.
+ *
+ * @param {String} path
+ * Profile server path, i.e "/profile".
+ * @param {String} [method]
+ * Type of request, i.e "GET".
+ * @return Promise
+ * Resolves: {Object} Successful response from the Profile server.
+ * Rejects: {FxAccountsProfileClientError} Profile client error.
+ * @private
+ */
+ _createRequest: Task.async(function* (path, method = "GET") {
+ let token = this.token;
+ if (!token) {
+ // tokens are cached, so getting them each request is cheap.
+ token = yield this.fxa.getOAuthToken(this.oauthOptions);
+ }
+ try {
+ return (yield this._rawRequest(path, method, token));
+ } catch (ex) {
+ if (!ex instanceof FxAccountsProfileClientError || ex.code != 401) {
+ throw ex;
+ }
+ // If this object was instantiated with a token then we don't refresh it.
+ if (this.token) {
+ throw ex;
+ }
+ // it's an auth error - assume our token expired and retry.
+ log.info("Fetching the profile returned a 401 - revoking our token and retrying");
+ yield this.fxa.removeCachedOAuthToken({token});
+ token = yield this.fxa.getOAuthToken(this.oauthOptions);
+ // and try with the new token - if that also fails then we fail after
+ // revoking the token.
+ try {
+ return (yield this._rawRequest(path, method, token));
+ } catch (ex) {
+ if (!ex instanceof FxAccountsProfileClientError || ex.code != 401) {
+ throw ex;
+ }
+ log.info("Retry fetching the profile still returned a 401 - revoking our token and failing");
+ yield this.fxa.removeCachedOAuthToken({token});
+ throw ex;
+ }
+ }
+ }),
+
+ /**
+ * Remote "raw" request helper - doesn't handle auth errors and tokens.
+ *
+ * @param {String} path
+ * Profile server path, i.e "/profile".
+ * @param {String} method
+ * Type of request, i.e "GET".
+ * @param {String} token
+ * @return Promise
+ * Resolves: {Object} Successful response from the Profile server.
+ * Rejects: {FxAccountsProfileClientError} Profile client error.
+ * @private
+ */
+ _rawRequest: function(path, method, token) {
+ return new Promise((resolve, reject) => {
+ let profileDataUrl = this.serverURL + path;
+ let request = new this._Request(profileDataUrl);
+ method = method.toUpperCase();
+
+ request.setHeader("Authorization", "Bearer " + token);
+ request.setHeader("Accept", "application/json");
+
+ request.onComplete = function (error) {
+ if (error) {
+ return reject(new FxAccountsProfileClientError({
+ error: ERROR_NETWORK,
+ errno: ERRNO_NETWORK,
+ message: error.toString(),
+ }));
+ }
+
+ let body = null;
+ try {
+ body = JSON.parse(request.response.body);
+ } catch (e) {
+ return reject(new FxAccountsProfileClientError({
+ error: ERROR_PARSE,
+ errno: ERRNO_PARSE,
+ code: request.response.status,
+ message: request.response.body,
+ }));
+ }
+
+ // "response.success" means status code is 200
+ if (request.response.success) {
+ return resolve(body);
+ } else {
+ return reject(new FxAccountsProfileClientError({
+ error: body.error || ERROR_UNKNOWN,
+ errno: body.errno || ERRNO_UNKNOWN_ERROR,
+ code: request.response.status,
+ message: body.message || body,
+ }));
+ }
+ };
+
+ if (method === "GET") {
+ request.get();
+ } else {
+ // method not supported
+ return reject(new FxAccountsProfileClientError({
+ error: ERROR_NETWORK,
+ errno: ERRNO_NETWORK,
+ code: ERROR_CODE_METHOD_NOT_ALLOWED,
+ message: ERROR_MSG_METHOD_NOT_ALLOWED,
+ }));
+ }
+ });
+ },
+
+ /**
+ * Retrieve user's profile from the server
+ *
+ * @return Promise
+ * Resolves: {Object} Successful response from the '/profile' endpoint.
+ * Rejects: {FxAccountsProfileClientError} profile client error.
+ */
+ fetchProfile: function () {
+ log.debug("FxAccountsProfileClient: Requested profile");
+ return this._createRequest("/profile", "GET");
+ },
+
+ /**
+ * Retrieve user's profile from the server
+ *
+ * @return Promise
+ * Resolves: {Object} Successful response from the '/avatar' endpoint.
+ * Rejects: {FxAccountsProfileClientError} profile client error.
+ */
+ fetchProfileImage: function () {
+ log.debug("FxAccountsProfileClient: Requested avatar");
+ return this._createRequest("/avatar", "GET");
+ }
+};
+
+/**
+ * Normalized profile client errors
+ * @param {Object} [details]
+ * Error details object
+ * @param {number} [details.code]
+ * Error code
+ * @param {number} [details.errno]
+ * Error number
+ * @param {String} [details.error]
+ * Error description
+ * @param {String|null} [details.message]
+ * Error message
+ * @constructor
+ */
+this.FxAccountsProfileClientError = function(details) {
+ details = details || {};
+
+ this.name = "FxAccountsProfileClientError";
+ this.code = details.code || null;
+ this.errno = details.errno || ERRNO_UNKNOWN_ERROR;
+ this.error = details.error || ERROR_UNKNOWN;
+ this.message = details.message || null;
+};
+
+/**
+ * Returns error object properties
+ *
+ * @returns {{name: *, code: *, errno: *, error: *, message: *}}
+ * @private
+ */
+FxAccountsProfileClientError.prototype._toStringFields = function() {
+ return {
+ name: this.name,
+ code: this.code,
+ errno: this.errno,
+ error: this.error,
+ message: this.message,
+ };
+};
+
+/**
+ * String representation of a profile client error
+ *
+ * @returns {String}
+ */
+FxAccountsProfileClientError.prototype.toString = function() {
+ return this.name + "(" + JSON.stringify(this._toStringFields()) + ")";
+};
diff --git a/services/fxaccounts/FxAccountsPush.js b/services/fxaccounts/FxAccountsPush.js
new file mode 100644
index 000000000..358be06ee
--- /dev/null
+++ b/services/fxaccounts/FxAccountsPush.js
@@ -0,0 +1,240 @@
+/* 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 Cc = Components.classes;
+const Ci = Components.interfaces;
+const Cu = Components.utils;
+
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+Cu.import("resource://gre/modules/Services.jsm");
+Cu.import("resource://services-sync/util.js");
+Cu.import("resource://gre/modules/FxAccountsCommon.js");
+Cu.import("resource://gre/modules/Task.jsm");
+
+/**
+ * FxAccountsPushService manages Push notifications for Firefox Accounts in the browser
+ *
+ * @param [options]
+ * Object, custom options that used for testing
+ * @constructor
+ */
+function FxAccountsPushService(options = {}) {
+ this.log = log;
+
+ if (options.log) {
+ // allow custom log for testing purposes
+ this.log = options.log;
+ }
+
+ this.log.debug("FxAccountsPush loading service");
+ this.wrappedJSObject = this;
+ this.initialize(options);
+}
+
+FxAccountsPushService.prototype = {
+ /**
+ * Helps only initialize observers once.
+ */
+ _initialized: false,
+ /**
+ * Instance of the nsIPushService or a mocked object.
+ */
+ pushService: null,
+ /**
+ * Instance of FxAccounts or a mocked object.
+ */
+ fxAccounts: null,
+ /**
+ * Component ID of this service, helps register this component.
+ */
+ classID: Components.ID("{1b7db999-2ecd-4abf-bb95-a726896798ca}"),
+ /**
+ * Register used interfaces in this service
+ */
+ QueryInterface: XPCOMUtils.generateQI([Ci.nsIObserver]),
+ /**
+ * Initialize the service and register all the required observers.
+ *
+ * @param [options]
+ */
+ initialize(options) {
+ if (this._initialized) {
+ return false;
+ }
+
+ this._initialized = true;
+
+ if (options.pushService) {
+ this.pushService = options.pushService;
+ } else {
+ this.pushService = Cc["@mozilla.org/push/Service;1"].getService(Ci.nsIPushService);
+ }
+
+ if (options.fxAccounts) {
+ this.fxAccounts = options.fxAccounts;
+ } else {
+ XPCOMUtils.defineLazyModuleGetter(this, "fxAccounts",
+ "resource://gre/modules/FxAccounts.jsm");
+ }
+
+ // listen to new push messages, push changes and logout events
+ Services.obs.addObserver(this, this.pushService.pushTopic, false);
+ Services.obs.addObserver(this, this.pushService.subscriptionChangeTopic, false);
+ Services.obs.addObserver(this, ONLOGOUT_NOTIFICATION, false);
+
+ this.log.debug("FxAccountsPush initialized");
+ },
+ /**
+ * Registers a new endpoint with the Push Server
+ *
+ * @returns {Promise}
+ * Promise always resolves with a subscription or a null if failed to subscribe.
+ */
+ registerPushEndpoint() {
+ this.log.trace("FxAccountsPush registerPushEndpoint");
+
+ return new Promise((resolve) => {
+ this.pushService.subscribe(FXA_PUSH_SCOPE_ACCOUNT_UPDATE,
+ Services.scriptSecurityManager.getSystemPrincipal(),
+ (result, subscription) => {
+ if (Components.isSuccessCode(result)) {
+ this.log.debug("FxAccountsPush got subscription");
+ resolve(subscription);
+ } else {
+ this.log.warn("FxAccountsPush failed to subscribe", result);
+ resolve(null);
+ }
+ });
+ });
+ },
+ /**
+ * Standard observer interface to listen to push messages, changes and logout.
+ *
+ * @param subject
+ * @param topic
+ * @param data
+ * @returns {Promise}
+ */
+ _observe(subject, topic, data) {
+ this.log.trace(`observed topic=${topic}, data=${data}, subject=${subject}`);
+ switch (topic) {
+ case this.pushService.pushTopic:
+ if (data === FXA_PUSH_SCOPE_ACCOUNT_UPDATE) {
+ let message = subject.QueryInterface(Ci.nsIPushMessage);
+ return this._onPushMessage(message);
+ }
+ break;
+ case this.pushService.subscriptionChangeTopic:
+ if (data === FXA_PUSH_SCOPE_ACCOUNT_UPDATE) {
+ return this._onPushSubscriptionChange();
+ }
+ break;
+ case ONLOGOUT_NOTIFICATION:
+ // user signed out, we need to stop polling the Push Server
+ return this.unsubscribe().catch(err => {
+ this.log.error("Error during unsubscribe", err);
+ });
+ break;
+ default:
+ break;
+ }
+ },
+ /**
+ * Wrapper around _observe that catches errors
+ */
+ observe(subject, topic, data) {
+ Promise.resolve()
+ .then(() => this._observe(subject, topic, data))
+ .catch(err => this.log.error(err));
+ },
+ /**
+ * Fired when the Push server sends a notification.
+ *
+ * @private
+ * @returns {Promise}
+ */
+ _onPushMessage(message) {
+ this.log.trace("FxAccountsPushService _onPushMessage");
+ if (!message.data) {
+ // Use the empty signal to check the verification state of the account right away
+ this.log.debug("empty push message - checking account status");
+ return this.fxAccounts.checkVerificationStatus();
+ }
+ let payload = message.data.json();
+ this.log.debug(`push command: ${payload.command}`);
+ switch (payload.command) {
+ case ON_DEVICE_DISCONNECTED_NOTIFICATION:
+ return this.fxAccounts.handleDeviceDisconnection(payload.data.id);
+ break;
+ case ON_PASSWORD_CHANGED_NOTIFICATION:
+ case ON_PASSWORD_RESET_NOTIFICATION:
+ return this._onPasswordChanged();
+ break;
+ case ON_COLLECTION_CHANGED_NOTIFICATION:
+ Services.obs.notifyObservers(null, ON_COLLECTION_CHANGED_NOTIFICATION, payload.data.collections);
+ default:
+ this.log.warn("FxA Push command unrecognized: " + payload.command);
+ }
+ },
+ /**
+ * Check the FxA session status after a password change/reset event.
+ * If the session is invalid, reset credentials and notify listeners of
+ * ON_ACCOUNT_STATE_CHANGE_NOTIFICATION that the account may have changed
+ *
+ * @returns {Promise}
+ * @private
+ */
+ _onPasswordChanged: Task.async(function* () {
+ if (!(yield this.fxAccounts.sessionStatus())) {
+ yield this.fxAccounts.resetCredentials();
+ Services.obs.notifyObservers(null, ON_ACCOUNT_STATE_CHANGE_NOTIFICATION, null);
+ }
+ }),
+ /**
+ * Fired when the Push server drops a subscription, or the subscription identifier changes.
+ *
+ * https://developer.mozilla.org/en-US/docs/Mozilla/Tech/XPCOM/Reference/Interface/nsIPushService#Receiving_Push_Messages
+ *
+ * @returns {Promise}
+ * @private
+ */
+ _onPushSubscriptionChange() {
+ this.log.trace("FxAccountsPushService _onPushSubscriptionChange");
+ return this.fxAccounts.updateDeviceRegistration();
+ },
+ /**
+ * Unsubscribe from the Push server
+ *
+ * Ref: https://developer.mozilla.org/en-US/docs/Mozilla/Tech/XPCOM/Reference/Interface/nsIPushService#unsubscribe()
+ *
+ * @returns {Promise}
+ * @private
+ */
+ unsubscribe() {
+ this.log.trace("FxAccountsPushService unsubscribe");
+ return new Promise((resolve) => {
+ this.pushService.unsubscribe(FXA_PUSH_SCOPE_ACCOUNT_UPDATE,
+ Services.scriptSecurityManager.getSystemPrincipal(),
+ (result, ok) => {
+ if (Components.isSuccessCode(result)) {
+ if (ok === true) {
+ this.log.debug("FxAccountsPushService unsubscribed");
+ } else {
+ this.log.debug("FxAccountsPushService had no subscription to unsubscribe");
+ }
+ } else {
+ this.log.warn("FxAccountsPushService failed to unsubscribe", result);
+ }
+ return resolve(ok);
+ });
+ });
+ },
+};
+
+// Service registration below registers with FxAccountsComponents.manifest
+const components = [FxAccountsPushService];
+this.NSGetFactory = XPCOMUtils.generateNSGetFactory(components);
+
+// The following registration below helps with testing this service.
+this.EXPORTED_SYMBOLS=["FxAccountsPushService"];
diff --git a/services/fxaccounts/FxAccountsStorage.jsm b/services/fxaccounts/FxAccountsStorage.jsm
new file mode 100644
index 000000000..021763b92
--- /dev/null
+++ b/services/fxaccounts/FxAccountsStorage.jsm
@@ -0,0 +1,609 @@
+/* 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 = [
+ "FxAccountsStorageManagerCanStoreField",
+ "FxAccountsStorageManager",
+];
+
+const {classes: Cc, interfaces: Ci, utils: Cu} = Components;
+
+Cu.import("resource://gre/modules/AppConstants.jsm");
+Cu.import("resource://gre/modules/Services.jsm");
+Cu.import("resource://gre/modules/Task.jsm");
+Cu.import("resource://gre/modules/FxAccountsCommon.js");
+Cu.import("resource://gre/modules/osfile.jsm");
+Cu.import("resource://services-common/utils.js");
+
+// A helper function so code can check what fields are able to be stored by
+// the storage manager without having a reference to a manager instance.
+function FxAccountsStorageManagerCanStoreField(fieldName) {
+ return FXA_PWDMGR_MEMORY_FIELDS.has(fieldName) ||
+ FXA_PWDMGR_PLAINTEXT_FIELDS.has(fieldName) ||
+ FXA_PWDMGR_SECURE_FIELDS.has(fieldName);
+}
+
+// The storage manager object.
+this.FxAccountsStorageManager = function(options = {}) {
+ this.options = {
+ filename: options.filename || DEFAULT_STORAGE_FILENAME,
+ baseDir: options.baseDir || OS.Constants.Path.profileDir,
+ }
+ this.plainStorage = new JSONStorage(this.options);
+ // On b2g we have no loginManager for secure storage, and tests may want
+ // to pretend secure storage isn't available.
+ let useSecure = 'useSecure' in options ? options.useSecure : haveLoginManager;
+ if (useSecure) {
+ this.secureStorage = new LoginManagerStorage();
+ } else {
+ this.secureStorage = null;
+ }
+ this._clearCachedData();
+ // See .initialize() below - this protects against it not being called.
+ this._promiseInitialized = Promise.reject("initialize not called");
+ // A promise to avoid storage races - see _queueStorageOperation
+ this._promiseStorageComplete = Promise.resolve();
+}
+
+this.FxAccountsStorageManager.prototype = {
+ _initialized: false,
+ _needToReadSecure: true,
+
+ // An initialization routine that *looks* synchronous to the callers, but
+ // is actually async as everything else waits for it to complete.
+ initialize(accountData) {
+ if (this._initialized) {
+ throw new Error("already initialized");
+ }
+ this._initialized = true;
+ // If we just throw away our pre-rejected promise it is reported as an
+ // unhandled exception when it is GCd - so add an empty .catch handler here
+ // to prevent this.
+ this._promiseInitialized.catch(() => {});
+ this._promiseInitialized = this._initialize(accountData);
+ },
+
+ _initialize: Task.async(function* (accountData) {
+ log.trace("initializing new storage manager");
+ try {
+ if (accountData) {
+ // If accountData is passed we don't need to read any storage.
+ this._needToReadSecure = false;
+ // split it into the 2 parts, write it and we are done.
+ for (let [name, val] of Object.entries(accountData)) {
+ if (FXA_PWDMGR_PLAINTEXT_FIELDS.has(name)) {
+ this.cachedPlain[name] = val;
+ } else if (FXA_PWDMGR_SECURE_FIELDS.has(name)) {
+ this.cachedSecure[name] = val;
+ } else {
+ // Hopefully it's an "in memory" field. If it's not we log a warning
+ // but still treat it as such (so it will still be available in this
+ // session but isn't persisted anywhere.)
+ if (!FXA_PWDMGR_MEMORY_FIELDS.has(name)) {
+ log.warn("Unknown FxA field name in user data, treating as in-memory", name);
+ }
+ this.cachedMemory[name] = val;
+ }
+ }
+ // write it out and we are done.
+ yield this._write();
+ return;
+ }
+ // So we were initialized without account data - that means we need to
+ // read the state from storage. We try and read plain storage first and
+ // only attempt to read secure storage if the plain storage had a user.
+ this._needToReadSecure = yield this._readPlainStorage();
+ if (this._needToReadSecure && this.secureStorage) {
+ yield this._doReadAndUpdateSecure();
+ }
+ } finally {
+ log.trace("initializing of new storage manager done");
+ }
+ }),
+
+ finalize() {
+ // We can't throw this instance away while it is still writing or we may
+ // end up racing with the newly created one.
+ log.trace("StorageManager finalizing");
+ return this._promiseInitialized.then(() => {
+ return this._promiseStorageComplete;
+ }).then(() => {
+ this._promiseStorageComplete = null;
+ this._promiseInitialized = null;
+ this._clearCachedData();
+ log.trace("StorageManager finalized");
+ })
+ },
+
+ // We want to make sure we don't end up doing multiple storage requests
+ // concurrently - which has a small window for reads if the master-password
+ // is locked at initialization time and becomes unlocked later, and always
+ // has an opportunity for updates.
+ // We also want to make sure we finished writing when finalizing, so we
+ // can't accidentally end up with the previous user's write finishing after
+ // a signOut attempts to clear it.
+ // So all such operations "queue" themselves via this.
+ _queueStorageOperation(func) {
+ // |result| is the promise we return - it has no .catch handler, so callers
+ // of the storage operation still see failure as a normal rejection.
+ let result = this._promiseStorageComplete.then(func);
+ // But the promise we assign to _promiseStorageComplete *does* have a catch
+ // handler so that rejections in one storage operation does not prevent
+ // future operations from starting (ie, _promiseStorageComplete must never
+ // be in a rejected state)
+ this._promiseStorageComplete = result.catch(err => {
+ log.error("${func} failed: ${err}", {func, err});
+ });
+ return result;
+ },
+
+ // Get the account data by combining the plain and secure storage.
+ // If fieldNames is specified, it may be a string or an array of strings,
+ // and only those fields are returned. If not specified the entire account
+ // data is returned except for "in memory" fields. Note that not specifying
+ // field names will soon be deprecated/removed - we want all callers to
+ // specify the fields they care about.
+ getAccountData: Task.async(function* (fieldNames = null) {
+ yield this._promiseInitialized;
+ // We know we are initialized - this means our .cachedPlain is accurate
+ // and doesn't need to be read (it was read if necessary by initialize).
+ // So if there's no uid, there's no user signed in.
+ if (!('uid' in this.cachedPlain)) {
+ return null;
+ }
+ let result = {};
+ if (fieldNames === null) {
+ // The "old" deprecated way of fetching a logged in user.
+ for (let [name, value] of Object.entries(this.cachedPlain)) {
+ result[name] = value;
+ }
+ // But the secure data may not have been read, so try that now.
+ yield this._maybeReadAndUpdateSecure();
+ // .cachedSecure now has as much as it possibly can (which is possibly
+ // nothing if (a) secure storage remains locked and (b) we've never updated
+ // a field to be stored in secure storage.)
+ for (let [name, value] of Object.entries(this.cachedSecure)) {
+ result[name] = value;
+ }
+ // Note we don't return cachedMemory fields here - they must be explicitly
+ // requested.
+ return result;
+ }
+ // The new explicit way of getting attributes.
+ if (!Array.isArray(fieldNames)) {
+ fieldNames = [fieldNames];
+ }
+ let checkedSecure = false;
+ for (let fieldName of fieldNames) {
+ if (FXA_PWDMGR_MEMORY_FIELDS.has(fieldName)) {
+ if (this.cachedMemory[fieldName] !== undefined) {
+ result[fieldName] = this.cachedMemory[fieldName];
+ }
+ } else if (FXA_PWDMGR_PLAINTEXT_FIELDS.has(fieldName)) {
+ if (this.cachedPlain[fieldName] !== undefined) {
+ result[fieldName] = this.cachedPlain[fieldName];
+ }
+ } else if (FXA_PWDMGR_SECURE_FIELDS.has(fieldName)) {
+ // We may not have read secure storage yet.
+ if (!checkedSecure) {
+ yield this._maybeReadAndUpdateSecure();
+ checkedSecure = true;
+ }
+ if (this.cachedSecure[fieldName] !== undefined) {
+ result[fieldName] = this.cachedSecure[fieldName];
+ }
+ } else {
+ throw new Error("unexpected field '" + name + "'");
+ }
+ }
+ return result;
+ }),
+
+ // Update just the specified fields. This DOES NOT allow you to change to
+ // a different user, nor to set the user as signed-out.
+ updateAccountData: Task.async(function* (newFields) {
+ yield this._promiseInitialized;
+ if (!('uid' in this.cachedPlain)) {
+ // If this storage instance shows no logged in user, then you can't
+ // update fields.
+ throw new Error("No user is logged in");
+ }
+ if (!newFields || 'uid' in newFields || 'email' in newFields) {
+ // Once we support
+ // user changing email address this may need to change, but it's not
+ // clear how we would be told of such a change anyway...
+ throw new Error("Can't change uid or email address");
+ }
+ log.debug("_updateAccountData with items", Object.keys(newFields));
+ // work out what bucket.
+ for (let [name, value] of Object.entries(newFields)) {
+ if (FXA_PWDMGR_MEMORY_FIELDS.has(name)) {
+ if (value == null) {
+ delete this.cachedMemory[name];
+ } else {
+ this.cachedMemory[name] = value;
+ }
+ } else if (FXA_PWDMGR_PLAINTEXT_FIELDS.has(name)) {
+ if (value == null) {
+ delete this.cachedPlain[name];
+ } else {
+ this.cachedPlain[name] = value;
+ }
+ } else if (FXA_PWDMGR_SECURE_FIELDS.has(name)) {
+ // don't do the "delete on null" thing here - we need to keep it until
+ // we have managed to read so we can nuke it on write.
+ this.cachedSecure[name] = value;
+ } else {
+ // Throwing seems reasonable here as some client code has explicitly
+ // specified the field name, so it's either confused or needs to update
+ // how this field is to be treated.
+ throw new Error("unexpected field '" + name + "'");
+ }
+ }
+ // If we haven't yet read the secure data, do so now, else we may write
+ // out partial data.
+ yield this._maybeReadAndUpdateSecure();
+ // Now save it - but don't wait on the _write promise - it's queued up as
+ // a storage operation, so .finalize() will wait for completion, but no need
+ // for us to.
+ this._write();
+ }),
+
+ _clearCachedData() {
+ this.cachedMemory = {};
+ this.cachedPlain = {};
+ // If we don't have secure storage available we have cachedPlain and
+ // cachedSecure be the same object.
+ this.cachedSecure = this.secureStorage == null ? this.cachedPlain : {};
+ },
+
+ /* Reads the plain storage and caches the read values in this.cachedPlain.
+ Only ever called once and unlike the "secure" storage, is expected to never
+ fail (ie, plain storage is considered always available, whereas secure
+ storage may be unavailable if it is locked).
+
+ Returns a promise that resolves with true if valid account data was found,
+ false otherwise.
+
+ Note: _readPlainStorage is only called during initialize, so isn't
+ protected via _queueStorageOperation() nor _promiseInitialized.
+ */
+ _readPlainStorage: Task.async(function* () {
+ let got;
+ try {
+ got = yield this.plainStorage.get();
+ } catch(err) {
+ // File hasn't been created yet. That will be done
+ // when write is called.
+ if (!(err instanceof OS.File.Error) || !err.becauseNoSuchFile) {
+ log.error("Failed to read plain storage", err);
+ }
+ // either way, we return null.
+ got = null;
+ }
+ if (!got || !got.accountData || !got.accountData.uid ||
+ got.version != DATA_FORMAT_VERSION) {
+ return false;
+ }
+ // We need to update our .cachedPlain, but can't just assign to it as
+ // it may need to be the exact same object as .cachedSecure
+ // As a sanity check, .cachedPlain must be empty (as we are called by init)
+ // XXX - this would be a good use-case for a RuntimeAssert or similar, as
+ // being added in bug 1080457.
+ if (Object.keys(this.cachedPlain).length != 0) {
+ throw new Error("should be impossible to have cached data already.")
+ }
+ for (let [name, value] of Object.entries(got.accountData)) {
+ this.cachedPlain[name] = value;
+ }
+ return true;
+ }),
+
+ /* If we haven't managed to read the secure storage, try now, so
+ we can merge our cached data with the data that's already been set.
+ */
+ _maybeReadAndUpdateSecure: Task.async(function* () {
+ if (this.secureStorage == null || !this._needToReadSecure) {
+ return;
+ }
+ return this._queueStorageOperation(() => {
+ if (this._needToReadSecure) { // we might have read it by now!
+ return this._doReadAndUpdateSecure();
+ }
+ });
+ }),
+
+ /* Unconditionally read the secure storage and merge our cached data (ie, data
+ which has already been set while the secure storage was locked) with
+ the read data
+ */
+ _doReadAndUpdateSecure: Task.async(function* () {
+ let { uid, email } = this.cachedPlain;
+ try {
+ log.debug("reading secure storage with existing", Object.keys(this.cachedSecure));
+ // If we already have anything in .cachedSecure it means something has
+ // updated cachedSecure before we've read it. That means that after we do
+ // manage to read we must write back the merged data.
+ let needWrite = Object.keys(this.cachedSecure).length != 0;
+ let readSecure = yield this.secureStorage.get(uid, email);
+ // and update our cached data with it - anything already in .cachedSecure
+ // wins (including the fact it may be null or undefined, the latter
+ // which means it will be removed from storage.
+ if (readSecure && readSecure.version != DATA_FORMAT_VERSION) {
+ log.warn("got secure data but the data format version doesn't match");
+ readSecure = null;
+ }
+ if (readSecure && readSecure.accountData) {
+ log.debug("secure read fetched items", Object.keys(readSecure.accountData));
+ for (let [name, value] of Object.entries(readSecure.accountData)) {
+ if (!(name in this.cachedSecure)) {
+ this.cachedSecure[name] = value;
+ }
+ }
+ if (needWrite) {
+ log.debug("successfully read secure data; writing updated data back")
+ yield this._doWriteSecure();
+ }
+ }
+ this._needToReadSecure = false;
+ } catch (ex) {
+ if (ex instanceof this.secureStorage.STORAGE_LOCKED) {
+ log.debug("setAccountData: secure storage is locked trying to read");
+ } else {
+ log.error("failed to read secure storage", ex);
+ throw ex;
+ }
+ }
+ }),
+
+ _write() {
+ // We don't want multiple writes happening concurrently, and we also need to
+ // know when an "old" storage manager is done (this.finalize() waits for this)
+ return this._queueStorageOperation(() => this.__write());
+ },
+
+ __write: Task.async(function* () {
+ // Write everything back - later we could track what's actually dirty,
+ // but for now we write it all.
+ log.debug("writing plain storage", Object.keys(this.cachedPlain));
+ let toWritePlain = {
+ version: DATA_FORMAT_VERSION,
+ accountData: this.cachedPlain,
+ }
+ yield this.plainStorage.set(toWritePlain);
+
+ // If we have no secure storage manager we are done.
+ if (this.secureStorage == null) {
+ return;
+ }
+ // and only attempt to write to secure storage if we've managed to read it,
+ // otherwise we might clobber data that's already there.
+ if (!this._needToReadSecure) {
+ yield this._doWriteSecure();
+ }
+ }),
+
+ /* Do the actual write of secure data. Caller is expected to check if we actually
+ need to write and to ensure we are in a queued storage operation.
+ */
+ _doWriteSecure: Task.async(function* () {
+ // We need to remove null items here.
+ for (let [name, value] of Object.entries(this.cachedSecure)) {
+ if (value == null) {
+ delete this.cachedSecure[name];
+ }
+ }
+ log.debug("writing secure storage", Object.keys(this.cachedSecure));
+ let toWriteSecure = {
+ version: DATA_FORMAT_VERSION,
+ accountData: this.cachedSecure,
+ }
+ try {
+ yield this.secureStorage.set(this.cachedPlain.uid, toWriteSecure);
+ } catch (ex) {
+ if (!ex instanceof this.secureStorage.STORAGE_LOCKED) {
+ throw ex;
+ }
+ // This shouldn't be possible as once it is unlocked it can't be
+ // re-locked, and we can only be here if we've previously managed to
+ // read.
+ log.error("setAccountData: secure storage is locked trying to write");
+ }
+ }),
+
+ // Delete the data for an account - ie, called on "sign out".
+ deleteAccountData() {
+ return this._queueStorageOperation(() => this._deleteAccountData());
+ },
+
+ _deleteAccountData: Task.async(function* () {
+ log.debug("removing account data");
+ yield this._promiseInitialized;
+ yield this.plainStorage.set(null);
+ if (this.secureStorage) {
+ yield this.secureStorage.set(null);
+ }
+ this._clearCachedData();
+ log.debug("account data reset");
+ }),
+}
+
+/**
+ * JSONStorage constructor that creates instances that may set/get
+ * to a specified file, in a directory that will be created if it
+ * doesn't exist.
+ *
+ * @param options {
+ * filename: of the file to write to
+ * baseDir: directory where the file resides
+ * }
+ * @return instance
+ */
+function JSONStorage(options) {
+ this.baseDir = options.baseDir;
+ this.path = OS.Path.join(options.baseDir, options.filename);
+};
+
+JSONStorage.prototype = {
+ set: function(contents) {
+ log.trace("starting write of json user data", contents ? Object.keys(contents.accountData) : "null");
+ let start = Date.now();
+ return OS.File.makeDir(this.baseDir, {ignoreExisting: true})
+ .then(CommonUtils.writeJSON.bind(null, contents, this.path))
+ .then(result => {
+ log.trace("finished write of json user data - took", Date.now()-start);
+ return result;
+ });
+ },
+
+ get: function() {
+ log.trace("starting fetch of json user data");
+ let start = Date.now();
+ return CommonUtils.readJSON(this.path).then(result => {
+ log.trace("finished fetch of json user data - took", Date.now()-start);
+ return result;
+ });
+ },
+};
+
+function StorageLockedError() {
+}
+/**
+ * LoginManagerStorage constructor that creates instances that set/get
+ * data stored securely in the nsILoginManager.
+ *
+ * @return instance
+ */
+
+function LoginManagerStorage() {
+}
+
+LoginManagerStorage.prototype = {
+ STORAGE_LOCKED: StorageLockedError,
+ // The fields in the credentials JSON object that are stored in plain-text
+ // in the profile directory. All other fields are stored in the login manager,
+ // and thus are only available when the master-password is unlocked.
+
+ // a hook point for testing.
+ get _isLoggedIn() {
+ return Services.logins.isLoggedIn;
+ },
+
+ // Clear any data from the login manager. Returns true if the login manager
+ // was unlocked (even if no existing logins existed) or false if it was
+ // locked (meaning we don't even know if it existed or not.)
+ _clearLoginMgrData: Task.async(function* () {
+ try { // Services.logins might be third-party and broken...
+ yield Services.logins.initializationPromise;
+ if (!this._isLoggedIn) {
+ return false;
+ }
+ let logins = Services.logins.findLogins({}, FXA_PWDMGR_HOST, null, FXA_PWDMGR_REALM);
+ for (let login of logins) {
+ Services.logins.removeLogin(login);
+ }
+ return true;
+ } catch (ex) {
+ log.error("Failed to clear login data: ${}", ex);
+ return false;
+ }
+ }),
+
+ set: Task.async(function* (uid, contents) {
+ if (!contents) {
+ // Nuke it from the login manager.
+ let cleared = yield this._clearLoginMgrData();
+ if (!cleared) {
+ // just log a message - we verify that the uid matches when
+ // we reload it, so having a stale entry doesn't really hurt.
+ log.info("not removing credentials from login manager - not logged in");
+ }
+ log.trace("storage set finished clearing account data");
+ return;
+ }
+
+ // We are saving actual data.
+ log.trace("starting write of user data to the login manager");
+ try { // Services.logins might be third-party and broken...
+ // and the stuff into the login manager.
+ yield Services.logins.initializationPromise;
+ // If MP is locked we silently fail - the user may need to re-auth
+ // next startup.
+ if (!this._isLoggedIn) {
+ log.info("not saving credentials to login manager - not logged in");
+ throw new this.STORAGE_LOCKED();
+ }
+ // write the data to the login manager.
+ let loginInfo = new Components.Constructor(
+ "@mozilla.org/login-manager/loginInfo;1", Ci.nsILoginInfo, "init");
+ let login = new loginInfo(FXA_PWDMGR_HOST,
+ null, // aFormSubmitURL,
+ FXA_PWDMGR_REALM, // aHttpRealm,
+ uid, // aUsername
+ JSON.stringify(contents), // aPassword
+ "", // aUsernameField
+ "");// aPasswordField
+
+ let existingLogins = Services.logins.findLogins({}, FXA_PWDMGR_HOST, null,
+ FXA_PWDMGR_REALM);
+ if (existingLogins.length) {
+ Services.logins.modifyLogin(existingLogins[0], login);
+ } else {
+ Services.logins.addLogin(login);
+ }
+ log.trace("finished write of user data to the login manager");
+ } catch (ex) {
+ if (ex instanceof this.STORAGE_LOCKED) {
+ throw ex;
+ }
+ // just log and consume the error here - it may be a 3rd party login
+ // manager replacement that's simply broken.
+ log.error("Failed to save data to the login manager", ex);
+ }
+ }),
+
+ get: Task.async(function* (uid, email) {
+ log.trace("starting fetch of user data from the login manager");
+
+ try { // Services.logins might be third-party and broken...
+ // read the data from the login manager and merge it for return.
+ yield Services.logins.initializationPromise;
+
+ if (!this._isLoggedIn) {
+ log.info("returning partial account data as the login manager is locked.");
+ throw new this.STORAGE_LOCKED();
+ }
+
+ let logins = Services.logins.findLogins({}, FXA_PWDMGR_HOST, null, FXA_PWDMGR_REALM);
+ if (logins.length == 0) {
+ // This could happen if the MP was locked when we wrote the data.
+ log.info("Can't find any credentials in the login manager");
+ return null;
+ }
+ let login = logins[0];
+ // Support either the uid or the email as the username - as of bug 1183951
+ // we store the uid, but we support having either for b/w compat.
+ if (login.username == uid || login.username == email) {
+ return JSON.parse(login.password);
+ }
+ log.info("username in the login manager doesn't match - ignoring it");
+ yield this._clearLoginMgrData();
+ } catch (ex) {
+ if (ex instanceof this.STORAGE_LOCKED) {
+ throw ex;
+ }
+ // just log and consume the error here - it may be a 3rd party login
+ // manager replacement that's simply broken.
+ log.error("Failed to get data from the login manager", ex);
+ }
+ return null;
+ }),
+}
+
+// A global variable to indicate if the login manager is available - it doesn't
+// exist on b2g. Defined here as the use of preprocessor directives skews line
+// numbers in the runtime, meaning stack-traces etc end up off by a few lines.
+// Doing it at the end of the file makes that less of a pita.
+var haveLoginManager = !AppConstants.MOZ_B2G;
diff --git a/services/fxaccounts/FxAccountsWebChannel.jsm b/services/fxaccounts/FxAccountsWebChannel.jsm
new file mode 100644
index 000000000..810d93c65
--- /dev/null
+++ b/services/fxaccounts/FxAccountsWebChannel.jsm
@@ -0,0 +1,474 @@
+/* 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/. */
+
+/**
+ * Firefox Accounts Web Channel.
+ *
+ * Uses the WebChannel component to receive messages
+ * about account state changes.
+ */
+
+this.EXPORTED_SYMBOLS = ["EnsureFxAccountsWebChannel"];
+
+const {classes: Cc, interfaces: Ci, utils: Cu, results: Cr} = Components;
+
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+Cu.import("resource://gre/modules/FxAccountsCommon.js");
+
+XPCOMUtils.defineLazyModuleGetter(this, "Services",
+ "resource://gre/modules/Services.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "WebChannel",
+ "resource://gre/modules/WebChannel.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "fxAccounts",
+ "resource://gre/modules/FxAccounts.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "FxAccountsStorageManagerCanStoreField",
+ "resource://gre/modules/FxAccountsStorage.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "Weave",
+ "resource://services-sync/main.js");
+
+const COMMAND_PROFILE_CHANGE = "profile:change";
+const COMMAND_CAN_LINK_ACCOUNT = "fxaccounts:can_link_account";
+const COMMAND_LOGIN = "fxaccounts:login";
+const COMMAND_LOGOUT = "fxaccounts:logout";
+const COMMAND_DELETE = "fxaccounts:delete";
+const COMMAND_SYNC_PREFERENCES = "fxaccounts:sync_preferences";
+const COMMAND_CHANGE_PASSWORD = "fxaccounts:change_password";
+
+const PREF_LAST_FXA_USER = "identity.fxaccounts.lastSignedInUserHash";
+const PREF_SYNC_SHOW_CUSTOMIZATION = "services.sync-setup.ui.showCustomizationDialog";
+
+/**
+ * A helper function that extracts the message and stack from an error object.
+ * Returns a `{ message, stack }` tuple. `stack` will be null if the error
+ * doesn't have a stack trace.
+ */
+function getErrorDetails(error) {
+ let details = { message: String(error), stack: null };
+
+ // Adapted from Console.jsm.
+ if (error.stack) {
+ let frames = [];
+ for (let frame = error.stack; frame; frame = frame.caller) {
+ frames.push(String(frame).padStart(4));
+ }
+ details.stack = frames.join("\n");
+ }
+
+ return details;
+}
+
+/**
+ * Create a new FxAccountsWebChannel to listen for account updates
+ *
+ * @param {Object} options Options
+ * @param {Object} options
+ * @param {String} options.content_uri
+ * The FxA Content server uri
+ * @param {String} options.channel_id
+ * The ID of the WebChannel
+ * @param {String} options.helpers
+ * Helpers functions. Should only be passed in for testing.
+ * @constructor
+ */
+this.FxAccountsWebChannel = function(options) {
+ if (!options) {
+ throw new Error("Missing configuration options");
+ }
+ if (!options["content_uri"]) {
+ throw new Error("Missing 'content_uri' option");
+ }
+ this._contentUri = options.content_uri;
+
+ if (!options["channel_id"]) {
+ throw new Error("Missing 'channel_id' option");
+ }
+ this._webChannelId = options.channel_id;
+
+ // options.helpers is only specified by tests.
+ this._helpers = options.helpers || new FxAccountsWebChannelHelpers(options);
+
+ this._setupChannel();
+};
+
+this.FxAccountsWebChannel.prototype = {
+ /**
+ * WebChannel that is used to communicate with content page
+ */
+ _channel: null,
+
+ /**
+ * Helpers interface that does the heavy lifting.
+ */
+ _helpers: null,
+
+ /**
+ * WebChannel ID.
+ */
+ _webChannelId: null,
+ /**
+ * WebChannel origin, used to validate origin of messages
+ */
+ _webChannelOrigin: null,
+
+ /**
+ * Release all resources that are in use.
+ */
+ tearDown() {
+ this._channel.stopListening();
+ this._channel = null;
+ this._channelCallback = null;
+ },
+
+ /**
+ * Configures and registers a new WebChannel
+ *
+ * @private
+ */
+ _setupChannel() {
+ // if this.contentUri is present but not a valid URI, then this will throw an error.
+ try {
+ this._webChannelOrigin = Services.io.newURI(this._contentUri, null, null);
+ this._registerChannel();
+ } catch (e) {
+ log.error(e);
+ throw e;
+ }
+ },
+
+ _receiveMessage(message, sendingContext) {
+ let command = message.command;
+ let data = message.data;
+
+ switch (command) {
+ case COMMAND_PROFILE_CHANGE:
+ Services.obs.notifyObservers(null, ON_PROFILE_CHANGE_NOTIFICATION, data.uid);
+ break;
+ case COMMAND_LOGIN:
+ this._helpers.login(data).catch(error =>
+ this._sendError(error, message, sendingContext));
+ break;
+ case COMMAND_LOGOUT:
+ case COMMAND_DELETE:
+ this._helpers.logout(data.uid).catch(error =>
+ this._sendError(error, message, sendingContext));
+ break;
+ case COMMAND_CAN_LINK_ACCOUNT:
+ let canLinkAccount = this._helpers.shouldAllowRelink(data.email);
+
+ let response = {
+ command: command,
+ messageId: message.messageId,
+ data: { ok: canLinkAccount }
+ };
+
+ log.debug("FxAccountsWebChannel response", response);
+ this._channel.send(response, sendingContext);
+ break;
+ case COMMAND_SYNC_PREFERENCES:
+ this._helpers.openSyncPreferences(sendingContext.browser, data.entryPoint);
+ break;
+ case COMMAND_CHANGE_PASSWORD:
+ this._helpers.changePassword(data).catch(error =>
+ this._sendError(error, message, sendingContext));
+ break;
+ default:
+ log.warn("Unrecognized FxAccountsWebChannel command", command);
+ break;
+ }
+ },
+
+ _sendError(error, incomingMessage, sendingContext) {
+ log.error("Failed to handle FxAccountsWebChannel message", error);
+ this._channel.send({
+ command: incomingMessage.command,
+ messageId: incomingMessage.messageId,
+ data: {
+ error: getErrorDetails(error),
+ },
+ }, sendingContext);
+ },
+
+ /**
+ * Create a new channel with the WebChannelBroker, setup a callback listener
+ * @private
+ */
+ _registerChannel() {
+ /**
+ * Processes messages that are called back from the FxAccountsChannel
+ *
+ * @param webChannelId {String}
+ * Command webChannelId
+ * @param message {Object}
+ * Command message
+ * @param sendingContext {Object}
+ * Message sending context.
+ * @param sendingContext.browser {browser}
+ * The <browser> object that captured the
+ * WebChannelMessageToChrome.
+ * @param sendingContext.eventTarget {EventTarget}
+ * The <EventTarget> where the message was sent.
+ * @param sendingContext.principal {Principal}
+ * The <Principal> of the EventTarget where the message was sent.
+ * @private
+ *
+ */
+ let listener = (webChannelId, message, sendingContext) => {
+ if (message) {
+ log.debug("FxAccountsWebChannel message received", message.command);
+ if (logPII) {
+ log.debug("FxAccountsWebChannel message details", message);
+ }
+ try {
+ this._receiveMessage(message, sendingContext);
+ } catch (error) {
+ this._sendError(error, message, sendingContext);
+ }
+ }
+ };
+
+ this._channelCallback = listener;
+ this._channel = new WebChannel(this._webChannelId, this._webChannelOrigin);
+ this._channel.listen(listener);
+ log.debug("FxAccountsWebChannel registered: " + this._webChannelId + " with origin " + this._webChannelOrigin.prePath);
+ }
+};
+
+this.FxAccountsWebChannelHelpers = function(options) {
+ options = options || {};
+
+ this._fxAccounts = options.fxAccounts || fxAccounts;
+};
+
+this.FxAccountsWebChannelHelpers.prototype = {
+ // If the last fxa account used for sync isn't this account, we display
+ // a modal dialog checking they really really want to do this...
+ // (This is sync-specific, so ideally would be in sync's identity module,
+ // but it's a little more seamless to do here, and sync is currently the
+ // only fxa consumer, so...
+ shouldAllowRelink(acctName) {
+ return !this._needRelinkWarning(acctName) ||
+ this._promptForRelink(acctName);
+ },
+
+ /**
+ * New users are asked in the content server whether they want to
+ * customize which data should be synced. The user is only shown
+ * the dialog listing the possible data types upon verification.
+ *
+ * Save a bit into prefs that is read on verification to see whether
+ * to show the list of data types that can be saved.
+ */
+ setShowCustomizeSyncPref(showCustomizeSyncPref) {
+ Services.prefs.setBoolPref(PREF_SYNC_SHOW_CUSTOMIZATION, showCustomizeSyncPref);
+ },
+
+ getShowCustomizeSyncPref() {
+ return Services.prefs.getBoolPref(PREF_SYNC_SHOW_CUSTOMIZATION);
+ },
+
+ /**
+ * stores sync login info it in the fxaccounts service
+ *
+ * @param accountData the user's account data and credentials
+ */
+ login(accountData) {
+ if (accountData.customizeSync) {
+ this.setShowCustomizeSyncPref(true);
+ delete accountData.customizeSync;
+ }
+
+ if (accountData.declinedSyncEngines) {
+ let declinedSyncEngines = accountData.declinedSyncEngines;
+ log.debug("Received declined engines", declinedSyncEngines);
+ Weave.Service.engineManager.setDeclined(declinedSyncEngines);
+ declinedSyncEngines.forEach(engine => {
+ Services.prefs.setBoolPref("services.sync.engine." + engine, false);
+ });
+
+ // if we got declinedSyncEngines that means we do not need to show the customize screen.
+ this.setShowCustomizeSyncPref(false);
+ delete accountData.declinedSyncEngines;
+ }
+
+ // the user has already been shown the "can link account"
+ // screen. No need to keep this data around.
+ delete accountData.verifiedCanLinkAccount;
+
+ // Remember who it was so we can log out next time.
+ this.setPreviousAccountNameHashPref(accountData.email);
+
+ // A sync-specific hack - we want to ensure sync has been initialized
+ // before we set the signed-in user.
+ let xps = Cc["@mozilla.org/weave/service;1"]
+ .getService(Ci.nsISupports)
+ .wrappedJSObject;
+ return xps.whenLoaded().then(() => {
+ return this._fxAccounts.setSignedInUser(accountData);
+ });
+ },
+
+ /**
+ * logout the fxaccounts service
+ *
+ * @param the uid of the account which have been logged out
+ */
+ logout(uid) {
+ return fxAccounts.getSignedInUser().then(userData => {
+ if (userData.uid === uid) {
+ // true argument is `localOnly`, because server-side stuff
+ // has already been taken care of by the content server
+ return fxAccounts.signOut(true);
+ }
+ });
+ },
+
+ changePassword(credentials) {
+ // If |credentials| has fields that aren't handled by accounts storage,
+ // updateUserAccountData will throw - mainly to prevent errors in code
+ // that hard-codes field names.
+ // However, in this case the field names aren't really in our control.
+ // We *could* still insist the server know what fields names are valid,
+ // but that makes life difficult for the server when Firefox adds new
+ // features (ie, new fields) - forcing the server to track a map of
+ // versions to supported field names doesn't buy us much.
+ // So we just remove field names we know aren't handled.
+ let newCredentials = {
+ deviceId: null
+ };
+ for (let name of Object.keys(credentials)) {
+ if (name == "email" || name == "uid" || FxAccountsStorageManagerCanStoreField(name)) {
+ newCredentials[name] = credentials[name];
+ } else {
+ log.info("changePassword ignoring unsupported field", name);
+ }
+ }
+ return this._fxAccounts.updateUserAccountData(newCredentials)
+ .then(() => this._fxAccounts.updateDeviceRegistration());
+ },
+
+ /**
+ * Get the hash of account name of the previously signed in account
+ */
+ getPreviousAccountNameHashPref() {
+ try {
+ return Services.prefs.getComplexValue(PREF_LAST_FXA_USER, Ci.nsISupportsString).data;
+ } catch (_) {
+ return "";
+ }
+ },
+
+ /**
+ * Given an account name, set the hash of the previously signed in account
+ *
+ * @param acctName the account name of the user's account.
+ */
+ setPreviousAccountNameHashPref(acctName) {
+ let string = Cc["@mozilla.org/supports-string;1"]
+ .createInstance(Ci.nsISupportsString);
+ string.data = this.sha256(acctName);
+ Services.prefs.setComplexValue(PREF_LAST_FXA_USER, Ci.nsISupportsString, string);
+ },
+
+ /**
+ * Given a string, returns the SHA265 hash in base64
+ */
+ sha256(str) {
+ let converter = Cc["@mozilla.org/intl/scriptableunicodeconverter"]
+ .createInstance(Ci.nsIScriptableUnicodeConverter);
+ converter.charset = "UTF-8";
+ // Data is an array of bytes.
+ let data = converter.convertToByteArray(str, {});
+ let hasher = Cc["@mozilla.org/security/hash;1"]
+ .createInstance(Ci.nsICryptoHash);
+ hasher.init(hasher.SHA256);
+ hasher.update(data, data.length);
+
+ return hasher.finish(true);
+ },
+
+ /**
+ * Open Sync Preferences in the current tab of the browser
+ *
+ * @param {Object} browser the browser in which to open preferences
+ * @param {String} [entryPoint] entryPoint to use for logging
+ */
+ openSyncPreferences(browser, entryPoint) {
+ let uri = "about:preferences";
+ if (entryPoint) {
+ uri += "?entrypoint=" + encodeURIComponent(entryPoint);
+ }
+ uri += "#sync";
+
+ browser.loadURI(uri);
+ },
+
+ /**
+ * If a user signs in using a different account, the data from the
+ * previous account and the new account will be merged. Ask the user
+ * if they want to continue.
+ *
+ * @private
+ */
+ _needRelinkWarning(acctName) {
+ let prevAcctHash = this.getPreviousAccountNameHashPref();
+ return prevAcctHash && prevAcctHash != this.sha256(acctName);
+ },
+
+ /**
+ * Show the user a warning dialog that the data from the previous account
+ * and the new account will be merged.
+ *
+ * @private
+ */
+ _promptForRelink(acctName) {
+ let sb = Services.strings.createBundle("chrome://browser/locale/syncSetup.properties");
+ let continueLabel = sb.GetStringFromName("continue.label");
+ let title = sb.GetStringFromName("relinkVerify.title");
+ let description = sb.formatStringFromName("relinkVerify.description",
+ [acctName], 1);
+ let body = sb.GetStringFromName("relinkVerify.heading") +
+ "\n\n" + description;
+ let ps = Services.prompt;
+ let buttonFlags = (ps.BUTTON_POS_0 * ps.BUTTON_TITLE_IS_STRING) +
+ (ps.BUTTON_POS_1 * ps.BUTTON_TITLE_CANCEL) +
+ ps.BUTTON_POS_1_DEFAULT;
+
+ // If running in context of the browser chrome, window does not exist.
+ var targetWindow = typeof window === 'undefined' ? null : window;
+ let pressed = Services.prompt.confirmEx(targetWindow, title, body, buttonFlags,
+ continueLabel, null, null, null,
+ {});
+ return pressed === 0; // 0 is the "continue" button
+ }
+};
+
+var singleton;
+// The entry-point for this module, which ensures only one of our channels is
+// ever created - we require this because the WebChannel is global in scope
+// (eg, it uses the observer service to tell interested parties of interesting
+// things) and allowing multiple channels would cause such notifications to be
+// sent multiple times.
+this.EnsureFxAccountsWebChannel = function() {
+ let contentUri = Services.urlFormatter.formatURLPref("identity.fxaccounts.remote.webchannel.uri");
+ if (singleton && singleton._contentUri !== contentUri) {
+ singleton.tearDown();
+ singleton = null;
+ }
+ if (!singleton) {
+ try {
+ if (contentUri) {
+ // The FxAccountsWebChannel listens for events and updates
+ // the state machine accordingly.
+ singleton = new this.FxAccountsWebChannel({
+ content_uri: contentUri,
+ channel_id: WEBCHANNEL_ID,
+ });
+ } else {
+ log.warn("FxA WebChannel functionaly is disabled due to no URI pref.");
+ }
+ } catch (ex) {
+ log.error("Failed to create FxA WebChannel", ex);
+ }
+ }
+}
diff --git a/services/fxaccounts/interfaces/moz.build b/services/fxaccounts/interfaces/moz.build
new file mode 100644
index 000000000..ac80b3e93
--- /dev/null
+++ b/services/fxaccounts/interfaces/moz.build
@@ -0,0 +1,11 @@
+# -*- 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/.
+
+XPIDL_SOURCES += [
+ 'nsIFxAccountsUIGlue.idl'
+]
+
+XPIDL_MODULE = 'services_fxaccounts'
diff --git a/services/fxaccounts/interfaces/nsIFxAccountsUIGlue.idl b/services/fxaccounts/interfaces/nsIFxAccountsUIGlue.idl
new file mode 100644
index 000000000..950fdbc25
--- /dev/null
+++ b/services/fxaccounts/interfaces/nsIFxAccountsUIGlue.idl
@@ -0,0 +1,15 @@
+/* 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"
+
+[scriptable, uuid(ab8d0700-9577-11e3-a5e2-0800200c9a66)]
+interface nsIFxAccountsUIGlue : nsISupports
+{
+ // Returns a Promise.
+ jsval signInFlow();
+
+ // Returns a Promise.
+ jsval refreshAuthentication(in DOMString email);
+};
diff --git a/services/fxaccounts/moz.build b/services/fxaccounts/moz.build
new file mode 100644
index 000000000..30c8944c2
--- /dev/null
+++ b/services/fxaccounts/moz.build
@@ -0,0 +1,35 @@
+# -*- 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/.
+
+DIRS += ['interfaces']
+
+MOCHITEST_CHROME_MANIFESTS += ['tests/mochitest/chrome.ini']
+
+XPCSHELL_TESTS_MANIFESTS += ['tests/xpcshell/xpcshell.ini']
+
+EXTRA_COMPONENTS += [
+ 'FxAccountsComponents.manifest',
+ 'FxAccountsPush.js',
+]
+
+EXTRA_JS_MODULES += [
+ 'Credentials.jsm',
+ 'FxAccounts.jsm',
+ 'FxAccountsClient.jsm',
+ 'FxAccountsCommon.js',
+ 'FxAccountsConfig.jsm',
+ 'FxAccountsOAuthClient.jsm',
+ 'FxAccountsOAuthGrantClient.jsm',
+ 'FxAccountsProfile.jsm',
+ 'FxAccountsProfileClient.jsm',
+ 'FxAccountsPush.js',
+ 'FxAccountsStorage.jsm',
+ 'FxAccountsWebChannel.jsm',
+]
+
+# For now, we will only be using the FxA manager in B2G.
+if CONFIG['MOZ_B2G']:
+ EXTRA_JS_MODULES += ['FxAccountsManager.jsm']
diff --git a/services/fxaccounts/tests/mochitest/chrome.ini b/services/fxaccounts/tests/mochitest/chrome.ini
new file mode 100644
index 000000000..ab2e77053
--- /dev/null
+++ b/services/fxaccounts/tests/mochitest/chrome.ini
@@ -0,0 +1,7 @@
+[DEFAULT]
+skip-if = os == 'android'
+support-files=
+ file_invalidEmailCase.sjs
+
+[test_invalidEmailCase.html]
+
diff --git a/services/fxaccounts/tests/mochitest/file_invalidEmailCase.sjs b/services/fxaccounts/tests/mochitest/file_invalidEmailCase.sjs
new file mode 100644
index 000000000..9d97ac70c
--- /dev/null
+++ b/services/fxaccounts/tests/mochitest/file_invalidEmailCase.sjs
@@ -0,0 +1,80 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/**
+ * This server simulates the behavior of /account/login on the Firefox Accounts
+ * auth server in the case where the user is trying to sign in with an email
+ * with the wrong capitalization.
+ *
+ * https://github.com/mozilla/fxa-auth-server/blob/master/docs/api.md#post-v1accountlogin
+ *
+ * The expected behavior is that on the first attempt, with the wrong email,
+ * the server will respond with a 400 and the canonical email capitalization
+ * that the client should use. The client then has one chance to sign in with
+ * this different capitalization.
+ *
+ * In this test, the user with the account id "Greta.Garbo@gmail.COM" initially
+ * tries to sign in as "greta.garbo@gmail.com".
+ *
+ * On success, the client is responsible for updating its sign-in user state
+ * and recording the proper email capitalization.
+ */
+
+const CC = Components.Constructor;
+const BinaryInputStream = CC("@mozilla.org/binaryinputstream;1",
+ "nsIBinaryInputStream",
+ "setInputStream");
+
+const goodEmail = "Greta.Garbo@gmail.COM";
+const badEmail = "greta.garbo@gmail.com";
+
+function handleRequest(request, response) {
+ let body = new BinaryInputStream(request.bodyInputStream);
+ let bytes = [];
+ let available;
+ while ((available = body.available()) > 0) {
+ Array.prototype.push.apply(bytes, body.readByteArray(available));
+ }
+
+ let data = JSON.parse(String.fromCharCode.apply(null, bytes));
+ let message;
+
+ switch (data.email) {
+ case badEmail:
+ // Almost - try again with fixed email case
+ message = {
+ code: 400,
+ errno: 120,
+ error: "Incorrect email case",
+ email: goodEmail,
+ };
+ response.setStatusLine(request.httpVersion, 400, "Almost");
+ break;
+
+ case goodEmail:
+ // Successful login.
+ message = {
+ uid: "your-uid",
+ sessionToken: "your-sessionToken",
+ keyFetchToken: "your-keyFetchToken",
+ verified: true,
+ authAt: 1392144866,
+ };
+ response.setStatusLine(request.httpVersion, 200, "Yay");
+ break;
+
+ default:
+ // Anything else happening in this test is a failure.
+ message = {
+ code: 400,
+ errno: 999,
+ error: "What happened!?",
+ };
+ response.setStatusLine(request.httpVersion, 400, "Ouch");
+ break;
+ }
+
+ messageStr = JSON.stringify(message);
+ response.bodyOutputStream.write(messageStr, messageStr.length);
+}
+
diff --git a/services/fxaccounts/tests/mochitest/test_invalidEmailCase.html b/services/fxaccounts/tests/mochitest/test_invalidEmailCase.html
new file mode 100644
index 000000000..52866cc4b
--- /dev/null
+++ b/services/fxaccounts/tests/mochitest/test_invalidEmailCase.html
@@ -0,0 +1,131 @@
+<!--
+ Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/
+-->
+<!DOCTYPE HTML>
+<html>
+<!--
+Tests for Firefox Accounts signin with invalid email case
+https://bugzilla.mozilla.org/show_bug.cgi?id=963835
+-->
+<head>
+ <title>Test for Firefox Accounts (Bug 963835)</title>
+ <script type="application/javascript" src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script>
+ <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css" />
+</head>
+<body>
+
+<a target="_blank" href="https://bugzilla.mozilla.org/show_bug.cgi?id=963835">Mozilla Bug 963835</a>
+<p id="display"></p>
+<div id="content" style="display: none">
+ Test for correction of invalid email case in Fx Accounts signIn
+</div>
+<pre id="test">
+<script class="testbody" type="text/javascript;version=1.8">
+
+SimpleTest.waitForExplicitFinish();
+
+Components.utils.import("resource://gre/modules/Promise.jsm");
+Components.utils.import("resource://gre/modules/Services.jsm");
+Components.utils.import("resource://gre/modules/FxAccounts.jsm");
+Components.utils.import("resource://gre/modules/FxAccountsClient.jsm");
+Components.utils.import("resource://services-common/hawkclient.js");
+
+const TEST_SERVER =
+ "http://mochi.test:8888/chrome/services/fxaccounts/tests/mochitest/file_invalidEmailCase.sjs?path=";
+
+let MockStorage = function() {
+ this.data = null;
+};
+MockStorage.prototype = Object.freeze({
+ set: function (contents) {
+ this.data = contents;
+ return Promise.resolve(null);
+ },
+ get: function () {
+ return Promise.resolve(this.data);
+ },
+ getOAuthTokens() {
+ return Promise.resolve(null);
+ },
+ setOAuthTokens(contents) {
+ return Promise.resolve();
+ },
+});
+
+function MockFxAccounts() {
+ return new FxAccounts({
+ _now_is: new Date(),
+
+ now: function() {
+ return this._now_is;
+ },
+
+ signedInUserStorage: new MockStorage(),
+
+ fxAccountsClient: new FxAccountsClient(TEST_SERVER),
+ });
+}
+
+let wrongEmail = "greta.garbo@gmail.com";
+let rightEmail = "Greta.Garbo@gmail.COM";
+let password = "123456";
+
+function runTest() {
+ is(Services.prefs.getCharPref("identity.fxaccounts.auth.uri"), TEST_SERVER,
+ "Pref for auth.uri should be set to test server");
+
+ let fxa = new MockFxAccounts();
+ let client = fxa.internal.fxAccountsClient;
+
+ ok(true, !!fxa, "Couldn't mock fxa");
+ ok(true, !!client, "Couldn't mock fxa client");
+ is(client.host, TEST_SERVER, "Should be using the test auth server uri");
+
+ // First try to sign in using the email with the wrong capitalization. The
+ // FxAccountsClient will receive a 400 from the server with the corrected email.
+ // It will automatically try to sign in again. We expect this to succeed.
+ client.signIn(wrongEmail, password).then(
+ user => {
+
+ // Now store the signed-in user state. This will include the correct
+ // email capitalization.
+ fxa.setSignedInUser(user).then(
+ () => {
+
+ // Confirm that the correct email got stored.
+ fxa.getSignedInUser().then(
+ data => {
+ is(data.email, rightEmail);
+ SimpleTest.finish();
+ },
+ getUserError => {
+ ok(false, JSON.stringify(getUserError));
+ }
+ );
+ },
+ setSignedInUserError => {
+ ok(false, JSON.stringify(setSignedInUserError));
+ }
+ );
+ },
+ signInError => {
+ ok(false, JSON.stringify(signInError));
+ }
+ );
+};
+
+SpecialPowers.pushPrefEnv({"set": [
+ ["identity.fxaccounts.enabled", true], // fx accounts
+ ["identity.fxaccounts.auth.uri", TEST_SERVER], // our sjs server
+ ["toolkit.identity.debug", true], // verbose identity logging
+ ["browser.dom.window.dump.enabled", true],
+ ]},
+ function () { runTest(); }
+);
+
+</script>
+</pre>
+</body>
+</html>
+
diff --git a/services/fxaccounts/tests/xpcshell/head.js b/services/fxaccounts/tests/xpcshell/head.js
new file mode 100644
index 000000000..ed70fdac5
--- /dev/null
+++ b/services/fxaccounts/tests/xpcshell/head.js
@@ -0,0 +1,18 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+var {classes: Cc, interfaces: Ci, results: Cr, utils: Cu} = Components;
+
+"use strict";
+
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+
+(function initFxAccountsTestingInfrastructure() {
+ do_get_profile();
+
+ let ns = {};
+ Cu.import("resource://testing-common/services/common/logging.js", ns);
+
+ ns.initTestLogging("Trace");
+}).call(this);
+
diff --git a/services/fxaccounts/tests/xpcshell/test_accounts.js b/services/fxaccounts/tests/xpcshell/test_accounts.js
new file mode 100644
index 000000000..d6139a076
--- /dev/null
+++ b/services/fxaccounts/tests/xpcshell/test_accounts.js
@@ -0,0 +1,1531 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+Cu.import("resource://services-common/utils.js");
+Cu.import("resource://gre/modules/Services.jsm");
+Cu.import("resource://gre/modules/FxAccounts.jsm");
+Cu.import("resource://gre/modules/FxAccountsClient.jsm");
+Cu.import("resource://gre/modules/FxAccountsCommon.js");
+Cu.import("resource://gre/modules/FxAccountsOAuthGrantClient.jsm");
+Cu.import("resource://gre/modules/Promise.jsm");
+Cu.import("resource://gre/modules/Log.jsm");
+
+// We grab some additional stuff via backstage passes.
+var {AccountState} = Cu.import("resource://gre/modules/FxAccounts.jsm", {});
+
+const ONE_HOUR_MS = 1000 * 60 * 60;
+const ONE_DAY_MS = ONE_HOUR_MS * 24;
+const TWO_MINUTES_MS = 1000 * 60 * 2;
+
+initTestLogging("Trace");
+
+// XXX until bug 937114 is fixed
+Cu.importGlobalProperties(['atob']);
+
+var log = Log.repository.getLogger("Services.FxAccounts.test");
+log.level = Log.Level.Debug;
+
+// See verbose logging from FxAccounts.jsm
+Services.prefs.setCharPref("identity.fxaccounts.loglevel", "Trace");
+Log.repository.getLogger("FirefoxAccounts").level = Log.Level.Trace;
+
+// The oauth server is mocked, but set these prefs to pass param checks
+Services.prefs.setCharPref("identity.fxaccounts.remote.oauth.uri", "https://example.com/v1");
+Services.prefs.setCharPref("identity.fxaccounts.oauth.client_id", "abc123");
+
+
+const PROFILE_SERVER_URL = "http://example.com/v1";
+const CONTENT_URL = "http://accounts.example.com/";
+
+Services.prefs.setCharPref("identity.fxaccounts.remote.profile.uri", PROFILE_SERVER_URL);
+Services.prefs.setCharPref("identity.fxaccounts.settings.uri", CONTENT_URL);
+
+/*
+ * The FxAccountsClient communicates with the remote Firefox
+ * Accounts auth server. Mock the server calls, with a little
+ * lag time to simulate some latency.
+ *
+ * We add the _verified attribute to mock the change in verification
+ * state on the FXA server.
+ */
+
+function MockStorageManager() {
+}
+
+MockStorageManager.prototype = {
+ promiseInitialized: Promise.resolve(),
+
+ initialize(accountData) {
+ this.accountData = accountData;
+ },
+
+ finalize() {
+ return Promise.resolve();
+ },
+
+ getAccountData() {
+ return Promise.resolve(this.accountData);
+ },
+
+ updateAccountData(updatedFields) {
+ for (let [name, value] of Object.entries(updatedFields)) {
+ if (value == null) {
+ delete this.accountData[name];
+ } else {
+ this.accountData[name] = value;
+ }
+ }
+ return Promise.resolve();
+ },
+
+ deleteAccountData() {
+ this.accountData = null;
+ return Promise.resolve();
+ }
+}
+
+function MockFxAccountsClient() {
+ this._email = "nobody@example.com";
+ this._verified = false;
+ this._deletedOnServer = false; // for testing accountStatus
+
+ // mock calls up to the auth server to determine whether the
+ // user account has been verified
+ this.recoveryEmailStatus = function (sessionToken) {
+ // simulate a call to /recovery_email/status
+ return Promise.resolve({
+ email: this._email,
+ verified: this._verified
+ });
+ };
+
+ this.accountStatus = function(uid) {
+ let deferred = Promise.defer();
+ deferred.resolve(!!uid && (!this._deletedOnServer));
+ return deferred.promise;
+ };
+
+ this.accountKeys = function (keyFetchToken) {
+ let deferred = Promise.defer();
+
+ do_timeout(50, () => {
+ let response = {
+ kA: expandBytes("11"),
+ wrapKB: expandBytes("22")
+ };
+ deferred.resolve(response);
+ });
+ return deferred.promise;
+ };
+
+ this.resendVerificationEmail = function(sessionToken) {
+ // Return the session token to show that we received it in the first place
+ return Promise.resolve(sessionToken);
+ };
+
+ this.signCertificate = function() { throw "no" };
+
+ this.signOut = () => Promise.resolve();
+ this.signOutAndDestroyDevice = () => Promise.resolve({});
+
+ FxAccountsClient.apply(this);
+}
+MockFxAccountsClient.prototype = {
+ __proto__: FxAccountsClient.prototype
+}
+
+/*
+ * We need to mock the FxAccounts module's interfaces to external
+ * services, such as storage and the FxAccounts client. We also
+ * mock the now() method, so that we can simulate the passing of
+ * time and verify that signatures expire correctly.
+ */
+function MockFxAccounts() {
+ return new FxAccounts({
+ VERIFICATION_POLL_TIMEOUT_INITIAL: 100, // 100ms
+
+ _getCertificateSigned_calls: [],
+ _d_signCertificate: Promise.defer(),
+ _now_is: new Date(),
+ now: function () {
+ return this._now_is;
+ },
+ newAccountState(credentials) {
+ // we use a real accountState but mocked storage.
+ let storage = new MockStorageManager();
+ storage.initialize(credentials);
+ return new AccountState(storage);
+ },
+ getCertificateSigned: function (sessionToken, serializedPublicKey) {
+ _("mock getCertificateSigned\n");
+ this._getCertificateSigned_calls.push([sessionToken, serializedPublicKey]);
+ return this._d_signCertificate.promise;
+ },
+ _registerOrUpdateDevice() {
+ return Promise.resolve();
+ },
+ fxAccountsClient: new MockFxAccountsClient()
+ });
+}
+
+/*
+ * Some tests want a "real" fxa instance - however, we still mock the storage
+ * to keep the tests fast on b2g.
+ */
+function MakeFxAccounts(internal = {}) {
+ if (!internal.newAccountState) {
+ // we use a real accountState but mocked storage.
+ internal.newAccountState = function(credentials) {
+ let storage = new MockStorageManager();
+ storage.initialize(credentials);
+ return new AccountState(storage);
+ };
+ }
+ if (!internal._signOutServer) {
+ internal._signOutServer = () => Promise.resolve();
+ }
+ if (!internal._registerOrUpdateDevice) {
+ internal._registerOrUpdateDevice = () => Promise.resolve();
+ }
+ return new FxAccounts(internal);
+}
+
+add_task(function* test_non_https_remote_server_uri_with_requireHttps_false() {
+ Services.prefs.setBoolPref(
+ "identity.fxaccounts.allowHttp",
+ true);
+ Services.prefs.setCharPref(
+ "identity.fxaccounts.remote.signup.uri",
+ "http://example.com/browser/browser/base/content/test/general/accounts_testRemoteCommands.html");
+ do_check_eq(yield fxAccounts.promiseAccountsSignUpURI(),
+ "http://example.com/browser/browser/base/content/test/general/accounts_testRemoteCommands.html");
+
+ Services.prefs.clearUserPref("identity.fxaccounts.remote.signup.uri");
+ Services.prefs.clearUserPref("identity.fxaccounts.allowHttp");
+});
+
+add_task(function* test_non_https_remote_server_uri() {
+ Services.prefs.setCharPref(
+ "identity.fxaccounts.remote.signup.uri",
+ "http://example.com/browser/browser/base/content/test/general/accounts_testRemoteCommands.html");
+ rejects(fxAccounts.promiseAccountsSignUpURI(), null, "Firefox Accounts server must use HTTPS");
+ Services.prefs.clearUserPref("identity.fxaccounts.remote.signup.uri");
+});
+
+add_task(function* test_get_signed_in_user_initially_unset() {
+ _("Check getSignedInUser initially and after signout reports no user");
+ let account = MakeFxAccounts();
+ let credentials = {
+ email: "foo@example.com",
+ uid: "1234@lcip.org",
+ assertion: "foobar",
+ sessionToken: "dead",
+ kA: "beef",
+ kB: "cafe",
+ verified: true
+ };
+ let result = yield account.getSignedInUser();
+ do_check_eq(result, null);
+
+ yield account.setSignedInUser(credentials);
+ let histogram = Services.telemetry.getHistogramById("FXA_CONFIGURED");
+ do_check_eq(histogram.snapshot().sum, 1);
+ histogram.clear();
+
+ result = yield account.getSignedInUser();
+ do_check_eq(result.email, credentials.email);
+ do_check_eq(result.assertion, credentials.assertion);
+ do_check_eq(result.kB, credentials.kB);
+
+ // Delete the memory cache and force the user
+ // to be read and parsed from storage (e.g. disk via JSONStorage).
+ delete account.internal.signedInUser;
+ result = yield account.getSignedInUser();
+ do_check_eq(result.email, credentials.email);
+ do_check_eq(result.assertion, credentials.assertion);
+ do_check_eq(result.kB, credentials.kB);
+
+ // sign out
+ let localOnly = true;
+ yield account.signOut(localOnly);
+
+ // user should be undefined after sign out
+ result = yield account.getSignedInUser();
+ do_check_eq(result, null);
+});
+
+add_task(function* test_update_account_data() {
+ _("Check updateUserAccountData does the right thing.");
+ let account = MakeFxAccounts();
+ let credentials = {
+ email: "foo@example.com",
+ uid: "1234@lcip.org",
+ assertion: "foobar",
+ sessionToken: "dead",
+ kA: "beef",
+ kB: "cafe",
+ verified: true
+ };
+ yield account.setSignedInUser(credentials);
+
+ let newCreds = {
+ email: credentials.email,
+ uid: credentials.uid,
+ assertion: "new_assertion",
+ }
+ yield account.updateUserAccountData(newCreds);
+ do_check_eq((yield account.getSignedInUser()).assertion, "new_assertion",
+ "new field value was saved");
+
+ // but we should fail attempting to change email or uid.
+ newCreds = {
+ email: "someoneelse@example.com",
+ uid: credentials.uid,
+ assertion: "new_assertion",
+ }
+ yield Assert.rejects(account.updateUserAccountData(newCreds));
+ newCreds = {
+ email: credentials.email,
+ uid: "another_uid",
+ assertion: "new_assertion",
+ }
+ yield Assert.rejects(account.updateUserAccountData(newCreds));
+
+ // should fail without email or uid.
+ newCreds = {
+ assertion: "new_assertion",
+ }
+ yield Assert.rejects(account.updateUserAccountData(newCreds));
+
+ // and should fail with a field name that's not known by storage.
+ newCreds = {
+ email: credentials.email,
+ uid: "another_uid",
+ foo: "bar",
+ }
+ yield Assert.rejects(account.updateUserAccountData(newCreds));
+});
+
+add_task(function* test_getCertificateOffline() {
+ _("getCertificateOffline()");
+ let fxa = MakeFxAccounts();
+ let credentials = {
+ email: "foo@example.com",
+ uid: "1234@lcip.org",
+ sessionToken: "dead",
+ verified: true,
+ };
+
+ yield fxa.setSignedInUser(credentials);
+
+ // Test that an expired cert throws if we're offline.
+ let offline = Services.io.offline;
+ Services.io.offline = true;
+ yield fxa.internal.getKeypairAndCertificate(fxa.internal.currentAccountState).then(
+ result => {
+ Services.io.offline = offline;
+ do_throw("Unexpected success");
+ },
+ err => {
+ Services.io.offline = offline;
+ // ... so we have to check the error string.
+ do_check_eq(err, "Error: OFFLINE");
+ }
+ );
+ yield fxa.signOut(/*localOnly = */true);
+});
+
+add_task(function* test_getCertificateCached() {
+ _("getCertificateCached()");
+ let fxa = MakeFxAccounts();
+ let credentials = {
+ email: "foo@example.com",
+ uid: "1234@lcip.org",
+ sessionToken: "dead",
+ verified: true,
+ // A cached keypair and cert that remain valid.
+ keyPair: {
+ validUntil: Date.now() + KEY_LIFETIME + 10000,
+ rawKeyPair: "good-keypair",
+ },
+ cert: {
+ validUntil: Date.now() + CERT_LIFETIME + 10000,
+ rawCert: "good-cert",
+ },
+ };
+
+ yield fxa.setSignedInUser(credentials);
+ let {keyPair, certificate} = yield fxa.internal.getKeypairAndCertificate(fxa.internal.currentAccountState);
+ // should have the same keypair and cert.
+ do_check_eq(keyPair, credentials.keyPair.rawKeyPair);
+ do_check_eq(certificate, credentials.cert.rawCert);
+ yield fxa.signOut(/*localOnly = */true);
+});
+
+add_task(function* test_getCertificateExpiredCert() {
+ _("getCertificateExpiredCert()");
+ let fxa = MakeFxAccounts({
+ getCertificateSigned() {
+ return "new cert";
+ }
+ });
+ let credentials = {
+ email: "foo@example.com",
+ uid: "1234@lcip.org",
+ sessionToken: "dead",
+ verified: true,
+ // A cached keypair that remains valid.
+ keyPair: {
+ validUntil: Date.now() + KEY_LIFETIME + 10000,
+ rawKeyPair: "good-keypair",
+ },
+ // A cached certificate which has expired.
+ cert: {
+ validUntil: Date.parse("Mon, 13 Jan 2000 21:45:06 GMT"),
+ rawCert: "expired-cert",
+ },
+ };
+ yield fxa.setSignedInUser(credentials);
+ let {keyPair, certificate} = yield fxa.internal.getKeypairAndCertificate(fxa.internal.currentAccountState);
+ // should have the same keypair but a new cert.
+ do_check_eq(keyPair, credentials.keyPair.rawKeyPair);
+ do_check_neq(certificate, credentials.cert.rawCert);
+ yield fxa.signOut(/*localOnly = */true);
+});
+
+add_task(function* test_getCertificateExpiredKeypair() {
+ _("getCertificateExpiredKeypair()");
+ let fxa = MakeFxAccounts({
+ getCertificateSigned() {
+ return "new cert";
+ },
+ });
+ let credentials = {
+ email: "foo@example.com",
+ uid: "1234@lcip.org",
+ sessionToken: "dead",
+ verified: true,
+ // A cached keypair that has expired.
+ keyPair: {
+ validUntil: Date.now() - 1000,
+ rawKeyPair: "expired-keypair",
+ },
+ // A cached certificate which remains valid.
+ cert: {
+ validUntil: Date.now() + CERT_LIFETIME + 10000,
+ rawCert: "expired-cert",
+ },
+ };
+
+ yield fxa.setSignedInUser(credentials);
+ let {keyPair, certificate} = yield fxa.internal.getKeypairAndCertificate(fxa.internal.currentAccountState);
+ // even though the cert was valid, the fact the keypair was not means we
+ // should have fetched both.
+ do_check_neq(keyPair, credentials.keyPair.rawKeyPair);
+ do_check_neq(certificate, credentials.cert.rawCert);
+ yield fxa.signOut(/*localOnly = */true);
+});
+
+// Sanity-check that our mocked client is working correctly
+add_test(function test_client_mock() {
+ let fxa = new MockFxAccounts();
+ let client = fxa.internal.fxAccountsClient;
+ do_check_eq(client._verified, false);
+ do_check_eq(typeof client.signIn, "function");
+
+ // The recoveryEmailStatus function eventually fulfills its promise
+ client.recoveryEmailStatus()
+ .then(response => {
+ do_check_eq(response.verified, false);
+ run_next_test();
+ });
+});
+
+// Sign in a user, and after a little while, verify the user's email.
+// Right after signing in the user, we should get the 'onlogin' notification.
+// Polling should detect that the email is verified, and eventually
+// 'onverified' should be observed
+add_test(function test_verification_poll() {
+ let fxa = new MockFxAccounts();
+ let test_user = getTestUser("francine");
+ let login_notification_received = false;
+
+ makeObserver(ONVERIFIED_NOTIFICATION, function() {
+ log.debug("test_verification_poll observed onverified");
+ // Once email verification is complete, we will observe onverified
+ fxa.internal.getUserAccountData().then(user => {
+ // And confirm that the user's state has changed
+ do_check_eq(user.verified, true);
+ do_check_eq(user.email, test_user.email);
+ do_check_true(login_notification_received);
+ run_next_test();
+ });
+ });
+
+ makeObserver(ONLOGIN_NOTIFICATION, function() {
+ log.debug("test_verification_poll observer onlogin");
+ login_notification_received = true;
+ });
+
+ fxa.setSignedInUser(test_user).then(() => {
+ fxa.internal.getUserAccountData().then(user => {
+ // The user is signing in, but email has not been verified yet
+ do_check_eq(user.verified, false);
+ do_timeout(200, function() {
+ log.debug("Mocking verification of francine's email");
+ fxa.internal.fxAccountsClient._email = test_user.email;
+ fxa.internal.fxAccountsClient._verified = true;
+ });
+ });
+ });
+});
+
+// Sign in the user, but never verify the email. The check-email
+// poll should time out. No verifiedlogin event should be observed, and the
+// internal whenVerified promise should be rejected
+add_test(function test_polling_timeout() {
+ // This test could be better - the onverified observer might fire on
+ // somebody else's stack, and we're not making sure that we're not receiving
+ // such a message. In other words, this tests either failure, or success, but
+ // not both.
+
+ let fxa = new MockFxAccounts();
+ let test_user = getTestUser("carol");
+
+ let removeObserver = makeObserver(ONVERIFIED_NOTIFICATION, function() {
+ do_throw("We should not be getting a login event!");
+ });
+
+ fxa.internal.POLL_SESSION = 1;
+
+ let p = fxa.internal.whenVerified({});
+
+ fxa.setSignedInUser(test_user).then(() => {
+ p.then(
+ (success) => {
+ do_throw("this should not succeed");
+ },
+ (fail) => {
+ removeObserver();
+ fxa.signOut().then(run_next_test);
+ }
+ );
+ });
+});
+
+add_test(function test_getKeys() {
+ let fxa = new MockFxAccounts();
+ let user = getTestUser("eusebius");
+
+ // Once email has been verified, we will be able to get keys
+ user.verified = true;
+
+ fxa.setSignedInUser(user).then(() => {
+ fxa.getSignedInUser().then((user) => {
+ // Before getKeys, we have no keys
+ do_check_eq(!!user.kA, false);
+ do_check_eq(!!user.kB, false);
+ // And we still have a key-fetch token and unwrapBKey to use
+ do_check_eq(!!user.keyFetchToken, true);
+ do_check_eq(!!user.unwrapBKey, true);
+
+ fxa.internal.getKeys().then(() => {
+ fxa.getSignedInUser().then((user) => {
+ // Now we should have keys
+ do_check_eq(fxa.internal.isUserEmailVerified(user), true);
+ do_check_eq(!!user.verified, true);
+ do_check_eq(user.kA, expandHex("11"));
+ do_check_eq(user.kB, expandHex("66"));
+ do_check_eq(user.keyFetchToken, undefined);
+ do_check_eq(user.unwrapBKey, undefined);
+ run_next_test();
+ });
+ });
+ });
+ });
+});
+
+add_task(function* test_getKeys_nonexistent_account() {
+ let fxa = new MockFxAccounts();
+ let bismarck = getTestUser("bismarck");
+
+ let client = fxa.internal.fxAccountsClient;
+ client.accountStatus = () => Promise.resolve(false);
+ client.accountKeys = () => {
+ return Promise.reject({
+ code: 401,
+ errno: ERRNO_INVALID_AUTH_TOKEN,
+ });
+ };
+
+ yield fxa.setSignedInUser(bismarck);
+
+ let promiseLogout = new Promise(resolve => {
+ makeObserver(ONLOGOUT_NOTIFICATION, function() {
+ log.debug("test_getKeys_nonexistent_account observed logout");
+ resolve();
+ });
+ });
+
+ try {
+ yield fxa.internal.getKeys();
+ do_check_true(false);
+ } catch (err) {
+ do_check_eq(err.code, 401);
+ do_check_eq(err.errno, ERRNO_INVALID_AUTH_TOKEN);
+ }
+
+ yield promiseLogout;
+
+ let user = yield fxa.internal.getUserAccountData();
+ do_check_eq(user, null);
+});
+
+// getKeys with invalid keyFetchToken should delete keyFetchToken from storage
+add_task(function* test_getKeys_invalid_token() {
+ let fxa = new MockFxAccounts();
+ let yusuf = getTestUser("yusuf");
+
+ let client = fxa.internal.fxAccountsClient;
+ client.accountStatus = () => Promise.resolve(true);
+ client.accountKeys = () => {
+ return Promise.reject({
+ code: 401,
+ errno: ERRNO_INVALID_AUTH_TOKEN,
+ });
+ };
+
+ yield fxa.setSignedInUser(yusuf);
+
+ try {
+ yield fxa.internal.getKeys();
+ do_check_true(false);
+ } catch (err) {
+ do_check_eq(err.code, 401);
+ do_check_eq(err.errno, ERRNO_INVALID_AUTH_TOKEN);
+ }
+
+ let user = yield fxa.internal.getUserAccountData();
+ do_check_eq(user.email, yusuf.email);
+ do_check_eq(user.keyFetchToken, null);
+});
+
+// fetchAndUnwrapKeys with no keyFetchToken should trigger signOut
+add_test(function test_fetchAndUnwrapKeys_no_token() {
+ let fxa = new MockFxAccounts();
+ let user = getTestUser("lettuce.protheroe");
+ delete user.keyFetchToken
+
+ makeObserver(ONLOGOUT_NOTIFICATION, function() {
+ log.debug("test_fetchAndUnwrapKeys_no_token observed logout");
+ fxa.internal.getUserAccountData().then(user => {
+ run_next_test();
+ });
+ });
+
+ fxa.setSignedInUser(user).then(
+ user => {
+ return fxa.internal.fetchAndUnwrapKeys();
+ }
+ ).then(
+ null,
+ error => {
+ log.info("setSignedInUser correctly rejected");
+ }
+ )
+});
+
+// Alice (User A) signs up but never verifies her email. Then Bob (User B)
+// signs in with a verified email. Ensure that no sign-in events are triggered
+// on Alice's behalf. In the end, Bob should be the signed-in user.
+add_test(function test_overlapping_signins() {
+ let fxa = new MockFxAccounts();
+ let alice = getTestUser("alice");
+ let bob = getTestUser("bob");
+
+ makeObserver(ONVERIFIED_NOTIFICATION, function() {
+ log.debug("test_overlapping_signins observed onverified");
+ // Once email verification is complete, we will observe onverified
+ fxa.internal.getUserAccountData().then(user => {
+ do_check_eq(user.email, bob.email);
+ do_check_eq(user.verified, true);
+ run_next_test();
+ });
+ });
+
+ // Alice is the user signing in; her email is unverified.
+ fxa.setSignedInUser(alice).then(() => {
+ log.debug("Alice signing in ...");
+ fxa.internal.getUserAccountData().then(user => {
+ do_check_eq(user.email, alice.email);
+ do_check_eq(user.verified, false);
+ log.debug("Alice has not verified her email ...");
+
+ // Now Bob signs in instead and actually verifies his email
+ log.debug("Bob signing in ...");
+ fxa.setSignedInUser(bob).then(() => {
+ do_timeout(200, function() {
+ // Mock email verification ...
+ log.debug("Bob verifying his email ...");
+ fxa.internal.fxAccountsClient._verified = true;
+ });
+ });
+ });
+ });
+});
+
+add_task(function* test_getAssertion_invalid_token() {
+ let fxa = new MockFxAccounts();
+
+ let client = fxa.internal.fxAccountsClient;
+ client.accountStatus = () => Promise.resolve(true);
+
+ let creds = {
+ sessionToken: "sessionToken",
+ kA: expandHex("11"),
+ kB: expandHex("66"),
+ verified: true,
+ email: "sonia@example.com",
+ };
+ yield fxa.setSignedInUser(creds);
+
+ try {
+ let promiseAssertion = fxa.getAssertion("audience.example.com");
+ fxa.internal._d_signCertificate.reject({
+ code: 401,
+ errno: ERRNO_INVALID_AUTH_TOKEN,
+ });
+ yield promiseAssertion;
+ do_check_true(false, "getAssertion should reject invalid session token");
+ } catch (err) {
+ do_check_eq(err.code, 401);
+ do_check_eq(err.errno, ERRNO_INVALID_AUTH_TOKEN);
+ }
+
+ let user = yield fxa.internal.getUserAccountData();
+ do_check_eq(user.email, creds.email);
+ do_check_eq(user.sessionToken, null);
+});
+
+add_task(function* test_getAssertion() {
+ let fxa = new MockFxAccounts();
+
+ do_check_throws(function* () {
+ yield fxa.getAssertion("nonaudience");
+ });
+
+ let creds = {
+ sessionToken: "sessionToken",
+ kA: expandHex("11"),
+ kB: expandHex("66"),
+ verified: true
+ };
+ // By putting kA/kB/verified in "creds", we skip ahead
+ // to the "we're ready" stage.
+ yield fxa.setSignedInUser(creds);
+
+ _("== ready to go\n");
+ // Start with a nice arbitrary but realistic date. Here we use a nice RFC
+ // 1123 date string like we would get from an HTTP header. Over the course of
+ // the test, we will update 'now', but leave 'start' where it is.
+ let now = Date.parse("Mon, 13 Jan 2014 21:45:06 GMT");
+ let start = now;
+ fxa.internal._now_is = now;
+
+ let d = fxa.getAssertion("audience.example.com");
+ // At this point, a thread has been spawned to generate the keys.
+ _("-- back from fxa.getAssertion\n");
+ fxa.internal._d_signCertificate.resolve("cert1");
+ let assertion = yield d;
+ do_check_eq(fxa.internal._getCertificateSigned_calls.length, 1);
+ do_check_eq(fxa.internal._getCertificateSigned_calls[0][0], "sessionToken");
+ do_check_neq(assertion, null);
+ _("ASSERTION: " + assertion + "\n");
+ let pieces = assertion.split("~");
+ do_check_eq(pieces[0], "cert1");
+ let userData = yield fxa.getSignedInUser();
+ let keyPair = userData.keyPair;
+ let cert = userData.cert;
+ do_check_neq(keyPair, undefined);
+ _(keyPair.validUntil + "\n");
+ let p2 = pieces[1].split(".");
+ let header = JSON.parse(atob(p2[0]));
+ _("HEADER: " + JSON.stringify(header) + "\n");
+ do_check_eq(header.alg, "DS128");
+ let payload = JSON.parse(atob(p2[1]));
+ _("PAYLOAD: " + JSON.stringify(payload) + "\n");
+ do_check_eq(payload.aud, "audience.example.com");
+ do_check_eq(keyPair.validUntil, start + KEY_LIFETIME);
+ do_check_eq(cert.validUntil, start + CERT_LIFETIME);
+ _("delta: " + Date.parse(payload.exp - start) + "\n");
+ let exp = Number(payload.exp);
+
+ do_check_eq(exp, now + ASSERTION_LIFETIME);
+
+ // Reset for next call.
+ fxa.internal._d_signCertificate = Promise.defer();
+
+ // Getting a new assertion "soon" (i.e., w/o incrementing "now"), even for
+ // a new audience, should not provoke key generation or a signing request.
+ assertion = yield fxa.getAssertion("other.example.com");
+
+ // There were no additional calls - same number of getcert calls as before
+ do_check_eq(fxa.internal._getCertificateSigned_calls.length, 1);
+
+ // Wait an hour; assertion use period expires, but not the certificate
+ now += ONE_HOUR_MS;
+ fxa.internal._now_is = now;
+
+ // This won't block on anything - will make an assertion, but not get a
+ // new certificate.
+ assertion = yield fxa.getAssertion("third.example.com");
+
+ // Test will time out if that failed (i.e., if that had to go get a new cert)
+ pieces = assertion.split("~");
+ do_check_eq(pieces[0], "cert1");
+ p2 = pieces[1].split(".");
+ header = JSON.parse(atob(p2[0]));
+ payload = JSON.parse(atob(p2[1]));
+ do_check_eq(payload.aud, "third.example.com");
+
+ // The keypair and cert should have the same validity as before, but the
+ // expiration time of the assertion should be different. We compare this to
+ // the initial start time, to which they are relative, not the current value
+ // of "now".
+ userData = yield fxa.getSignedInUser();
+
+ keyPair = userData.keyPair;
+ cert = userData.cert;
+ do_check_eq(keyPair.validUntil, start + KEY_LIFETIME);
+ do_check_eq(cert.validUntil, start + CERT_LIFETIME);
+ exp = Number(payload.exp);
+ do_check_eq(exp, now + ASSERTION_LIFETIME);
+
+ // Now we wait even longer, and expect both assertion and cert to expire. So
+ // we will have to get a new keypair and cert.
+ now += ONE_DAY_MS;
+ fxa.internal._now_is = now;
+ d = fxa.getAssertion("fourth.example.com");
+ fxa.internal._d_signCertificate.resolve("cert2");
+ assertion = yield d;
+ do_check_eq(fxa.internal._getCertificateSigned_calls.length, 2);
+ do_check_eq(fxa.internal._getCertificateSigned_calls[1][0], "sessionToken");
+ pieces = assertion.split("~");
+ do_check_eq(pieces[0], "cert2");
+ p2 = pieces[1].split(".");
+ header = JSON.parse(atob(p2[0]));
+ payload = JSON.parse(atob(p2[1]));
+ do_check_eq(payload.aud, "fourth.example.com");
+ userData = yield fxa.getSignedInUser();
+ keyPair = userData.keyPair;
+ cert = userData.cert;
+ do_check_eq(keyPair.validUntil, now + KEY_LIFETIME);
+ do_check_eq(cert.validUntil, now + CERT_LIFETIME);
+ exp = Number(payload.exp);
+
+ do_check_eq(exp, now + ASSERTION_LIFETIME);
+ _("----- DONE ----\n");
+});
+
+add_task(function* test_resend_email_not_signed_in() {
+ let fxa = new MockFxAccounts();
+
+ try {
+ yield fxa.resendVerificationEmail();
+ } catch(err) {
+ do_check_eq(err.message,
+ "Cannot resend verification email; no signed-in user");
+ return;
+ }
+ do_throw("Should not be able to resend email when nobody is signed in");
+});
+
+add_test(function test_accountStatus() {
+ let fxa = new MockFxAccounts();
+ let alice = getTestUser("alice");
+
+ // If we have no user, we have no account server-side
+ fxa.accountStatus().then(
+ (result) => {
+ do_check_false(result);
+ }
+ ).then(
+ () => {
+ fxa.setSignedInUser(alice).then(
+ () => {
+ fxa.accountStatus().then(
+ (result) => {
+ // FxAccounts.accountStatus() should match Client.accountStatus()
+ do_check_true(result);
+ fxa.internal.fxAccountsClient._deletedOnServer = true;
+ fxa.accountStatus().then(
+ (result) => {
+ do_check_false(result);
+ fxa.internal.fxAccountsClient._deletedOnServer = false;
+ fxa.signOut().then(run_next_test);
+ }
+ );
+ }
+ )
+ }
+ );
+ }
+ );
+});
+
+add_task(function* test_resend_email_invalid_token() {
+ let fxa = new MockFxAccounts();
+ let sophia = getTestUser("sophia");
+ do_check_neq(sophia.sessionToken, null);
+
+ let client = fxa.internal.fxAccountsClient;
+ client.resendVerificationEmail = () => {
+ return Promise.reject({
+ code: 401,
+ errno: ERRNO_INVALID_AUTH_TOKEN,
+ });
+ };
+ client.accountStatus = () => Promise.resolve(true);
+
+ yield fxa.setSignedInUser(sophia);
+ let user = yield fxa.internal.getUserAccountData();
+ do_check_eq(user.email, sophia.email);
+ do_check_eq(user.verified, false);
+ log.debug("Sophia wants verification email resent");
+
+ try {
+ yield fxa.resendVerificationEmail();
+ do_check_true(false, "resendVerificationEmail should reject invalid session token");
+ } catch (err) {
+ do_check_eq(err.code, 401);
+ do_check_eq(err.errno, ERRNO_INVALID_AUTH_TOKEN);
+ }
+
+ user = yield fxa.internal.getUserAccountData();
+ do_check_eq(user.email, sophia.email);
+ do_check_eq(user.sessionToken, null);
+});
+
+add_test(function test_resend_email() {
+ let fxa = new MockFxAccounts();
+ let alice = getTestUser("alice");
+
+ let initialState = fxa.internal.currentAccountState;
+
+ // Alice is the user signing in; her email is unverified.
+ fxa.setSignedInUser(alice).then(() => {
+ log.debug("Alice signing in");
+
+ // We're polling for the first email
+ do_check_true(fxa.internal.currentAccountState !== initialState);
+ let aliceState = fxa.internal.currentAccountState;
+
+ // The polling timer is ticking
+ do_check_true(fxa.internal.currentTimer > 0);
+
+ fxa.internal.getUserAccountData().then(user => {
+ do_check_eq(user.email, alice.email);
+ do_check_eq(user.verified, false);
+ log.debug("Alice wants verification email resent");
+
+ fxa.resendVerificationEmail().then((result) => {
+ // Mock server response; ensures that the session token actually was
+ // passed to the client to make the hawk call
+ do_check_eq(result, "alice's session token");
+
+ // Timer was not restarted
+ do_check_true(fxa.internal.currentAccountState === aliceState);
+
+ // Timer is still ticking
+ do_check_true(fxa.internal.currentTimer > 0);
+
+ // Ok abort polling before we go on to the next test
+ fxa.internal.abortExistingFlow();
+ run_next_test();
+ });
+ });
+ });
+});
+
+add_task(function* test_sign_out_with_device() {
+ const fxa = new MockFxAccounts();
+
+ const credentials = getTestUser("alice");
+ yield fxa.internal.setSignedInUser(credentials);
+
+ const user = yield fxa.internal.getUserAccountData();
+ do_check_true(user);
+ Object.keys(credentials).forEach(key => do_check_eq(credentials[key], user[key]));
+
+ const spy = {
+ signOut: { count: 0 },
+ signOutAndDeviceDestroy: { count: 0, args: [] }
+ };
+ const client = fxa.internal.fxAccountsClient;
+ client.signOut = function () {
+ spy.signOut.count += 1;
+ return Promise.resolve();
+ };
+ client.signOutAndDestroyDevice = function () {
+ spy.signOutAndDeviceDestroy.count += 1;
+ spy.signOutAndDeviceDestroy.args.push(arguments);
+ return Promise.resolve();
+ };
+
+ const promise = new Promise(resolve => {
+ makeObserver(ONLOGOUT_NOTIFICATION, () => {
+ log.debug("test_sign_out_with_device observed onlogout");
+ // user should be undefined after sign out
+ fxa.internal.getUserAccountData().then(user2 => {
+ do_check_eq(user2, null);
+ do_check_eq(spy.signOut.count, 0);
+ do_check_eq(spy.signOutAndDeviceDestroy.count, 1);
+ do_check_eq(spy.signOutAndDeviceDestroy.args[0].length, 3);
+ do_check_eq(spy.signOutAndDeviceDestroy.args[0][0], credentials.sessionToken);
+ do_check_eq(spy.signOutAndDeviceDestroy.args[0][1], credentials.deviceId);
+ do_check_true(spy.signOutAndDeviceDestroy.args[0][2]);
+ do_check_eq(spy.signOutAndDeviceDestroy.args[0][2].service, "sync");
+ resolve();
+ });
+ });
+ });
+
+ yield fxa.signOut();
+
+ yield promise;
+});
+
+add_task(function* test_sign_out_without_device() {
+ const fxa = new MockFxAccounts();
+
+ const credentials = getTestUser("alice");
+ delete credentials.deviceId;
+ yield fxa.internal.setSignedInUser(credentials);
+
+ const user = yield fxa.internal.getUserAccountData();
+
+ const spy = {
+ signOut: { count: 0, args: [] },
+ signOutAndDeviceDestroy: { count: 0 }
+ };
+ const client = fxa.internal.fxAccountsClient;
+ client.signOut = function () {
+ spy.signOut.count += 1;
+ spy.signOut.args.push(arguments);
+ return Promise.resolve();
+ };
+ client.signOutAndDestroyDevice = function () {
+ spy.signOutAndDeviceDestroy.count += 1;
+ return Promise.resolve();
+ };
+
+ const promise = new Promise(resolve => {
+ makeObserver(ONLOGOUT_NOTIFICATION, () => {
+ log.debug("test_sign_out_without_device observed onlogout");
+ // user should be undefined after sign out
+ fxa.internal.getUserAccountData().then(user2 => {
+ do_check_eq(user2, null);
+ do_check_eq(spy.signOut.count, 1);
+ do_check_eq(spy.signOut.args[0].length, 2);
+ do_check_eq(spy.signOut.args[0][0], credentials.sessionToken);
+ do_check_true(spy.signOut.args[0][1]);
+ do_check_eq(spy.signOut.args[0][1].service, "sync");
+ do_check_eq(spy.signOutAndDeviceDestroy.count, 0);
+ resolve();
+ });
+ });
+ });
+
+ yield fxa.signOut();
+
+ yield promise;
+});
+
+add_task(function* test_sign_out_with_remote_error() {
+ let fxa = new MockFxAccounts();
+ let client = fxa.internal.fxAccountsClient;
+ let remoteSignOutCalled = false;
+ // Force remote sign out to trigger an error
+ client.signOutAndDestroyDevice = function() { remoteSignOutCalled = true; throw "Remote sign out error"; };
+ let promiseLogout = new Promise(resolve => {
+ makeObserver(ONLOGOUT_NOTIFICATION, function() {
+ log.debug("test_sign_out_with_remote_error observed onlogout");
+ resolve();
+ });
+ });
+
+ let jane = getTestUser("jane");
+ yield fxa.setSignedInUser(jane);
+ yield fxa.signOut();
+ yield promiseLogout;
+
+ let user = yield fxa.internal.getUserAccountData();
+ do_check_eq(user, null);
+ do_check_true(remoteSignOutCalled);
+});
+
+add_test(function test_getOAuthToken() {
+ let fxa = new MockFxAccounts();
+ let alice = getTestUser("alice");
+ alice.verified = true;
+ let getTokenFromAssertionCalled = false;
+
+ fxa.internal._d_signCertificate.resolve("cert1");
+
+ // create a mock oauth client
+ let client = new FxAccountsOAuthGrantClient({
+ serverURL: "http://example.com/v1",
+ client_id: "abc123"
+ });
+ client.getTokenFromAssertion = function () {
+ getTokenFromAssertionCalled = true;
+ return Promise.resolve({ access_token: "token" });
+ };
+
+ fxa.setSignedInUser(alice).then(
+ () => {
+ fxa.getOAuthToken({ scope: "profile", client: client }).then(
+ (result) => {
+ do_check_true(getTokenFromAssertionCalled);
+ do_check_eq(result, "token");
+ run_next_test();
+ }
+ )
+ }
+ );
+
+});
+
+add_test(function test_getOAuthTokenScoped() {
+ let fxa = new MockFxAccounts();
+ let alice = getTestUser("alice");
+ alice.verified = true;
+ let getTokenFromAssertionCalled = false;
+
+ fxa.internal._d_signCertificate.resolve("cert1");
+
+ // create a mock oauth client
+ let client = new FxAccountsOAuthGrantClient({
+ serverURL: "http://example.com/v1",
+ client_id: "abc123"
+ });
+ client.getTokenFromAssertion = function (assertion, scopeString) {
+ equal(scopeString, "foo bar");
+ getTokenFromAssertionCalled = true;
+ return Promise.resolve({ access_token: "token" });
+ };
+
+ fxa.setSignedInUser(alice).then(
+ () => {
+ fxa.getOAuthToken({ scope: ["foo", "bar"], client: client }).then(
+ (result) => {
+ do_check_true(getTokenFromAssertionCalled);
+ do_check_eq(result, "token");
+ run_next_test();
+ }
+ )
+ }
+ );
+
+});
+
+add_task(function* test_getOAuthTokenCached() {
+ let fxa = new MockFxAccounts();
+ let alice = getTestUser("alice");
+ alice.verified = true;
+ let numTokenFromAssertionCalls = 0;
+
+ fxa.internal._d_signCertificate.resolve("cert1");
+
+ // create a mock oauth client
+ let client = new FxAccountsOAuthGrantClient({
+ serverURL: "http://example.com/v1",
+ client_id: "abc123"
+ });
+ client.getTokenFromAssertion = function () {
+ numTokenFromAssertionCalls += 1;
+ return Promise.resolve({ access_token: "token" });
+ };
+
+ yield fxa.setSignedInUser(alice);
+ let result = yield fxa.getOAuthToken({ scope: "profile", client: client, service: "test-service" });
+ do_check_eq(numTokenFromAssertionCalls, 1);
+ do_check_eq(result, "token");
+
+ // requesting it again should not re-fetch the token.
+ result = yield fxa.getOAuthToken({ scope: "profile", client: client, service: "test-service" });
+ do_check_eq(numTokenFromAssertionCalls, 1);
+ do_check_eq(result, "token");
+ // But requesting the same service and a different scope *will* get a new one.
+ result = yield fxa.getOAuthToken({ scope: "something-else", client: client, service: "test-service" });
+ do_check_eq(numTokenFromAssertionCalls, 2);
+ do_check_eq(result, "token");
+});
+
+add_task(function* test_getOAuthTokenCachedScopeNormalization() {
+ let fxa = new MockFxAccounts();
+ let alice = getTestUser("alice");
+ alice.verified = true;
+ let numTokenFromAssertionCalls = 0;
+
+ fxa.internal._d_signCertificate.resolve("cert1");
+
+ // create a mock oauth client
+ let client = new FxAccountsOAuthGrantClient({
+ serverURL: "http://example.com/v1",
+ client_id: "abc123"
+ });
+ client.getTokenFromAssertion = function () {
+ numTokenFromAssertionCalls += 1;
+ return Promise.resolve({ access_token: "token" });
+ };
+
+ yield fxa.setSignedInUser(alice);
+ let result = yield fxa.getOAuthToken({ scope: ["foo", "bar"], client: client, service: "test-service" });
+ do_check_eq(numTokenFromAssertionCalls, 1);
+ do_check_eq(result, "token");
+
+ // requesting it again with the scope array in a different order not re-fetch the token.
+ result = yield fxa.getOAuthToken({ scope: ["bar", "foo"], client: client, service: "test-service" });
+ do_check_eq(numTokenFromAssertionCalls, 1);
+ do_check_eq(result, "token");
+ // requesting it again with the scope array in different case not re-fetch the token.
+ result = yield fxa.getOAuthToken({ scope: ["Bar", "Foo"], client: client, service: "test-service" });
+ do_check_eq(numTokenFromAssertionCalls, 1);
+ do_check_eq(result, "token");
+ // But requesting with a new entry in the array does fetch one.
+ result = yield fxa.getOAuthToken({ scope: ["foo", "bar", "etc"], client: client, service: "test-service" });
+ do_check_eq(numTokenFromAssertionCalls, 2);
+ do_check_eq(result, "token");
+});
+
+Services.prefs.setCharPref("identity.fxaccounts.remote.oauth.uri", "https://example.com/v1");
+add_test(function test_getOAuthToken_invalid_param() {
+ let fxa = new MockFxAccounts();
+
+ fxa.getOAuthToken()
+ .then(null, err => {
+ do_check_eq(err.message, "INVALID_PARAMETER");
+ fxa.signOut().then(run_next_test);
+ });
+});
+
+add_test(function test_getOAuthToken_invalid_scope_array() {
+ let fxa = new MockFxAccounts();
+
+ fxa.getOAuthToken({scope: []})
+ .then(null, err => {
+ do_check_eq(err.message, "INVALID_PARAMETER");
+ fxa.signOut().then(run_next_test);
+ });
+});
+
+add_test(function test_getOAuthToken_misconfigure_oauth_uri() {
+ let fxa = new MockFxAccounts();
+
+ Services.prefs.deleteBranch("identity.fxaccounts.remote.oauth.uri");
+
+ fxa.getOAuthToken()
+ .then(null, err => {
+ do_check_eq(err.message, "INVALID_PARAMETER");
+ // revert the pref
+ Services.prefs.setCharPref("identity.fxaccounts.remote.oauth.uri", "https://example.com/v1");
+ fxa.signOut().then(run_next_test);
+ });
+});
+
+add_test(function test_getOAuthToken_no_account() {
+ let fxa = new MockFxAccounts();
+
+ fxa.internal.currentAccountState.getUserAccountData = function () {
+ return Promise.resolve(null);
+ };
+
+ fxa.getOAuthToken({ scope: "profile" })
+ .then(null, err => {
+ do_check_eq(err.message, "NO_ACCOUNT");
+ fxa.signOut().then(run_next_test);
+ });
+});
+
+add_test(function test_getOAuthToken_unverified() {
+ let fxa = new MockFxAccounts();
+ let alice = getTestUser("alice");
+
+ fxa.setSignedInUser(alice).then(() => {
+ fxa.getOAuthToken({ scope: "profile" })
+ .then(null, err => {
+ do_check_eq(err.message, "UNVERIFIED_ACCOUNT");
+ fxa.signOut().then(run_next_test);
+ });
+ });
+});
+
+add_test(function test_getOAuthToken_network_error() {
+ let fxa = new MockFxAccounts();
+ let alice = getTestUser("alice");
+ alice.verified = true;
+
+ fxa.internal._d_signCertificate.resolve("cert1");
+
+ // create a mock oauth client
+ let client = new FxAccountsOAuthGrantClient({
+ serverURL: "http://example.com/v1",
+ client_id: "abc123"
+ });
+ client.getTokenFromAssertion = function () {
+ return Promise.reject(new FxAccountsOAuthGrantClientError({
+ error: ERROR_NETWORK,
+ errno: ERRNO_NETWORK
+ }));
+ };
+
+ fxa.setSignedInUser(alice).then(() => {
+ fxa.getOAuthToken({ scope: "profile", client: client })
+ .then(null, err => {
+ do_check_eq(err.message, "NETWORK_ERROR");
+ do_check_eq(err.details.errno, ERRNO_NETWORK);
+ run_next_test();
+ });
+ });
+});
+
+add_test(function test_getOAuthToken_auth_error() {
+ let fxa = new MockFxAccounts();
+ let alice = getTestUser("alice");
+ alice.verified = true;
+
+ fxa.internal._d_signCertificate.resolve("cert1");
+
+ // create a mock oauth client
+ let client = new FxAccountsOAuthGrantClient({
+ serverURL: "http://example.com/v1",
+ client_id: "abc123"
+ });
+ client.getTokenFromAssertion = function () {
+ return Promise.reject(new FxAccountsOAuthGrantClientError({
+ error: ERROR_INVALID_FXA_ASSERTION,
+ errno: ERRNO_INVALID_FXA_ASSERTION
+ }));
+ };
+
+ fxa.setSignedInUser(alice).then(() => {
+ fxa.getOAuthToken({ scope: "profile", client: client })
+ .then(null, err => {
+ do_check_eq(err.message, "AUTH_ERROR");
+ do_check_eq(err.details.errno, ERRNO_INVALID_FXA_ASSERTION);
+ run_next_test();
+ });
+ });
+});
+
+add_test(function test_getOAuthToken_unknown_error() {
+ let fxa = new MockFxAccounts();
+ let alice = getTestUser("alice");
+ alice.verified = true;
+
+ fxa.internal._d_signCertificate.resolve("cert1");
+
+ // create a mock oauth client
+ let client = new FxAccountsOAuthGrantClient({
+ serverURL: "http://example.com/v1",
+ client_id: "abc123"
+ });
+ client.getTokenFromAssertion = function () {
+ return Promise.reject("boom");
+ };
+
+ fxa.setSignedInUser(alice).then(() => {
+ fxa.getOAuthToken({ scope: "profile", client: client })
+ .then(null, err => {
+ do_check_eq(err.message, "UNKNOWN_ERROR");
+ run_next_test();
+ });
+ });
+});
+
+add_test(function test_getSignedInUserProfile() {
+ let alice = getTestUser("alice");
+ alice.verified = true;
+
+ let mockProfile = {
+ getProfile: function () {
+ return Promise.resolve({ avatar: "image" });
+ },
+ tearDown: function() {},
+ };
+ let fxa = new FxAccounts({
+ _signOutServer() { return Promise.resolve(); },
+ _registerOrUpdateDevice() { return Promise.resolve(); }
+ });
+
+ fxa.setSignedInUser(alice).then(() => {
+ fxa.internal._profile = mockProfile;
+ fxa.getSignedInUserProfile()
+ .then(result => {
+ do_check_true(!!result);
+ do_check_eq(result.avatar, "image");
+ run_next_test();
+ });
+ });
+});
+
+add_test(function test_getSignedInUserProfile_error_uses_account_data() {
+ let fxa = new MockFxAccounts();
+ let alice = getTestUser("alice");
+ alice.verified = true;
+
+ fxa.internal.getSignedInUser = function () {
+ return Promise.resolve({ email: "foo@bar.com" });
+ };
+
+ let teardownCalled = false;
+ fxa.setSignedInUser(alice).then(() => {
+ fxa.internal._profile = {
+ getProfile: function () {
+ return Promise.reject("boom");
+ },
+ tearDown: function() {
+ teardownCalled = true;
+ }
+ };
+
+ fxa.getSignedInUserProfile()
+ .catch(error => {
+ do_check_eq(error.message, "UNKNOWN_ERROR");
+ fxa.signOut().then(() => {
+ do_check_true(teardownCalled);
+ run_next_test();
+ });
+ });
+ });
+});
+
+add_test(function test_getSignedInUserProfile_unverified_account() {
+ let fxa = new MockFxAccounts();
+ let alice = getTestUser("alice");
+
+ fxa.setSignedInUser(alice).then(() => {
+ fxa.getSignedInUserProfile()
+ .catch(error => {
+ do_check_eq(error.message, "UNVERIFIED_ACCOUNT");
+ fxa.signOut().then(run_next_test);
+ });
+ });
+
+});
+
+add_test(function test_getSignedInUserProfile_no_account_data() {
+ let fxa = new MockFxAccounts();
+
+ fxa.internal.getSignedInUser = function () {
+ return Promise.resolve(null);
+ };
+
+ fxa.getSignedInUserProfile()
+ .catch(error => {
+ do_check_eq(error.message, "NO_ACCOUNT");
+ fxa.signOut().then(run_next_test);
+ });
+
+});
+
+add_task(function* test_checkVerificationStatusFailed() {
+ let fxa = new MockFxAccounts();
+ let alice = getTestUser("alice");
+ alice.verified = true;
+
+ let client = fxa.internal.fxAccountsClient;
+ client.recoveryEmailStatus = () => {
+ return Promise.reject({
+ code: 401,
+ errno: ERRNO_INVALID_AUTH_TOKEN,
+ });
+ };
+ client.accountStatus = () => Promise.resolve(true);
+
+ yield fxa.setSignedInUser(alice);
+ let user = yield fxa.internal.getUserAccountData();
+ do_check_neq(alice.sessionToken, null);
+ do_check_eq(user.email, alice.email);
+ do_check_eq(user.verified, true);
+
+ yield fxa.checkVerificationStatus();
+
+ user = yield fxa.internal.getUserAccountData();
+ do_check_eq(user.email, alice.email);
+ do_check_eq(user.sessionToken, null);
+});
+
+/*
+ * End of tests.
+ * Utility functions follow.
+ */
+
+function expandHex(two_hex) {
+ // Return a 64-character hex string, encoding 32 identical bytes.
+ let eight_hex = two_hex + two_hex + two_hex + two_hex;
+ let thirtytwo_hex = eight_hex + eight_hex + eight_hex + eight_hex;
+ return thirtytwo_hex + thirtytwo_hex;
+};
+
+function expandBytes(two_hex) {
+ return CommonUtils.hexToBytes(expandHex(two_hex));
+};
+
+function getTestUser(name) {
+ return {
+ email: name + "@example.com",
+ uid: "1ad7f502-4cc7-4ec1-a209-071fd2fae348",
+ deviceId: name + "'s device id",
+ sessionToken: name + "'s session token",
+ keyFetchToken: name + "'s keyfetch token",
+ unwrapBKey: expandHex("44"),
+ verified: false
+ };
+}
+
+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) {
+ log.debug("observed " + aTopic + " " + aData);
+ if (aTopic == aObserveTopic) {
+ removeMe();
+ aObserveFunc(aSubject, aTopic, aData);
+ }
+ }
+ };
+
+ function removeMe() {
+ log.debug("removing observer for " + aObserveTopic);
+ Services.obs.removeObserver(observer, aObserveTopic);
+ }
+
+ Services.obs.addObserver(observer, aObserveTopic, false);
+ return removeMe;
+}
+
+function do_check_throws(func, result, stack)
+{
+ if (!stack)
+ stack = Components.stack.caller;
+
+ try {
+ func();
+ } catch (ex) {
+ if (ex.name == result) {
+ return;
+ }
+ do_throw("Expected result " + result + ", caught " + ex.name, stack);
+ }
+
+ if (result) {
+ do_throw("Expected result " + result + ", none thrown", stack);
+ }
+}
diff --git a/services/fxaccounts/tests/xpcshell/test_accounts_device_registration.js b/services/fxaccounts/tests/xpcshell/test_accounts_device_registration.js
new file mode 100644
index 000000000..9a2d2c127
--- /dev/null
+++ b/services/fxaccounts/tests/xpcshell/test_accounts_device_registration.js
@@ -0,0 +1,526 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+Cu.import("resource://services-common/utils.js");
+Cu.import("resource://gre/modules/Services.jsm");
+Cu.import("resource://gre/modules/FxAccounts.jsm");
+Cu.import("resource://gre/modules/FxAccountsClient.jsm");
+Cu.import("resource://gre/modules/FxAccountsCommon.js");
+Cu.import("resource://gre/modules/Promise.jsm");
+Cu.import("resource://gre/modules/Log.jsm");
+
+initTestLogging("Trace");
+
+var log = Log.repository.getLogger("Services.FxAccounts.test");
+log.level = Log.Level.Debug;
+
+const BOGUS_PUBLICKEY = "BBXOKjUb84pzws1wionFpfCBjDuCh4-s_1b52WA46K5wYL2gCWEOmFKWn_NkS5nmJwTBuO8qxxdjAIDtNeklvQc";
+const BOGUS_AUTHKEY = "GSsIiaD2Mr83iPqwFNK4rw";
+
+Services.prefs.setCharPref("identity.fxaccounts.loglevel", "Trace");
+Log.repository.getLogger("FirefoxAccounts").level = Log.Level.Trace;
+
+Services.prefs.setCharPref("identity.fxaccounts.remote.oauth.uri", "https://example.com/v1");
+Services.prefs.setCharPref("identity.fxaccounts.oauth.client_id", "abc123");
+Services.prefs.setCharPref("identity.fxaccounts.remote.profile.uri", "http://example.com/v1");
+Services.prefs.setCharPref("identity.fxaccounts.settings.uri", "http://accounts.example.com/");
+
+const DEVICE_REGISTRATION_VERSION = 42;
+
+function MockStorageManager() {
+}
+
+MockStorageManager.prototype = {
+ initialize(accountData) {
+ this.accountData = accountData;
+ },
+
+ finalize() {
+ return Promise.resolve();
+ },
+
+ getAccountData() {
+ return Promise.resolve(this.accountData);
+ },
+
+ updateAccountData(updatedFields) {
+ for (let [name, value] of Object.entries(updatedFields)) {
+ if (value == null) {
+ delete this.accountData[name];
+ } else {
+ this.accountData[name] = value;
+ }
+ }
+ return Promise.resolve();
+ },
+
+ deleteAccountData() {
+ this.accountData = null;
+ return Promise.resolve();
+ }
+}
+
+function MockFxAccountsClient(device) {
+ this._email = "nobody@example.com";
+ this._verified = false;
+ this._deletedOnServer = false; // for testing accountStatus
+
+ // mock calls up to the auth server to determine whether the
+ // user account has been verified
+ this.recoveryEmailStatus = function (sessionToken) {
+ // simulate a call to /recovery_email/status
+ return Promise.resolve({
+ email: this._email,
+ verified: this._verified
+ });
+ };
+
+ this.accountStatus = function(uid) {
+ let deferred = Promise.defer();
+ deferred.resolve(!!uid && (!this._deletedOnServer));
+ return deferred.promise;
+ };
+
+ const { id: deviceId, name: deviceName, type: deviceType, sessionToken } = device;
+
+ this.registerDevice = (st, name, type) => Promise.resolve({ id: deviceId, name });
+ this.updateDevice = (st, id, name) => Promise.resolve({ id, name });
+ this.signOutAndDestroyDevice = () => Promise.resolve({});
+ this.getDeviceList = (st) =>
+ Promise.resolve([
+ { id: deviceId, name: deviceName, type: deviceType, isCurrentDevice: st === sessionToken }
+ ]);
+
+ FxAccountsClient.apply(this);
+}
+MockFxAccountsClient.prototype = {
+ __proto__: FxAccountsClient.prototype
+}
+
+function MockFxAccounts(device = {}) {
+ return new FxAccounts({
+ _getDeviceName() {
+ return device.name || "mock device name";
+ },
+ fxAccountsClient: new MockFxAccountsClient(device),
+ fxaPushService: {
+ registerPushEndpoint() {
+ return new Promise((resolve) => {
+ resolve({
+ endpoint: "http://mochi.test:8888",
+ getKey: function(type) {
+ return ChromeUtils.base64URLDecode(
+ type === "auth" ? BOGUS_AUTHKEY : BOGUS_PUBLICKEY,
+ { padding: "ignore" });
+ }
+ });
+ });
+ },
+ },
+ DEVICE_REGISTRATION_VERSION
+ });
+}
+
+add_task(function* test_updateDeviceRegistration_with_new_device() {
+ const deviceName = "foo";
+ const deviceType = "bar";
+
+ const credentials = getTestUser("baz");
+ delete credentials.deviceId;
+ const fxa = new MockFxAccounts({ name: deviceName });
+ yield fxa.internal.setSignedInUser(credentials);
+
+ const spy = {
+ registerDevice: { count: 0, args: [] },
+ updateDevice: { count: 0, args: [] },
+ getDeviceList: { count: 0, args: [] }
+ };
+ const client = fxa.internal.fxAccountsClient;
+ client.registerDevice = function () {
+ spy.registerDevice.count += 1;
+ spy.registerDevice.args.push(arguments);
+ return Promise.resolve({
+ id: "newly-generated device id",
+ createdAt: Date.now(),
+ name: deviceName,
+ type: deviceType
+ });
+ };
+ client.updateDevice = function () {
+ spy.updateDevice.count += 1;
+ spy.updateDevice.args.push(arguments);
+ return Promise.resolve({});
+ };
+ client.getDeviceList = function () {
+ spy.getDeviceList.count += 1;
+ spy.getDeviceList.args.push(arguments);
+ return Promise.resolve([]);
+ };
+
+ const result = yield fxa.updateDeviceRegistration();
+
+ do_check_eq(result, "newly-generated device id");
+ do_check_eq(spy.updateDevice.count, 0);
+ do_check_eq(spy.getDeviceList.count, 0);
+ do_check_eq(spy.registerDevice.count, 1);
+ do_check_eq(spy.registerDevice.args[0].length, 4);
+ do_check_eq(spy.registerDevice.args[0][0], credentials.sessionToken);
+ do_check_eq(spy.registerDevice.args[0][1], deviceName);
+ do_check_eq(spy.registerDevice.args[0][2], "desktop");
+ do_check_eq(spy.registerDevice.args[0][3].pushCallback, "http://mochi.test:8888");
+ do_check_eq(spy.registerDevice.args[0][3].pushPublicKey, BOGUS_PUBLICKEY);
+ do_check_eq(spy.registerDevice.args[0][3].pushAuthKey, BOGUS_AUTHKEY);
+
+ const state = fxa.internal.currentAccountState;
+ const data = yield state.getUserAccountData();
+
+ do_check_eq(data.deviceId, "newly-generated device id");
+ do_check_eq(data.deviceRegistrationVersion, DEVICE_REGISTRATION_VERSION);
+});
+
+add_task(function* test_updateDeviceRegistration_with_existing_device() {
+ const deviceName = "phil's device";
+ const deviceType = "desktop";
+
+ const credentials = getTestUser("pb");
+ const fxa = new MockFxAccounts({ name: deviceName });
+ yield fxa.internal.setSignedInUser(credentials);
+
+ const spy = {
+ registerDevice: { count: 0, args: [] },
+ updateDevice: { count: 0, args: [] },
+ getDeviceList: { count: 0, args: [] }
+ };
+ const client = fxa.internal.fxAccountsClient;
+ client.registerDevice = function () {
+ spy.registerDevice.count += 1;
+ spy.registerDevice.args.push(arguments);
+ return Promise.resolve({});
+ };
+ client.updateDevice = function () {
+ spy.updateDevice.count += 1;
+ spy.updateDevice.args.push(arguments);
+ return Promise.resolve({
+ id: credentials.deviceId,
+ name: deviceName
+ });
+ };
+ client.getDeviceList = function () {
+ spy.getDeviceList.count += 1;
+ spy.getDeviceList.args.push(arguments);
+ return Promise.resolve([]);
+ };
+ const result = yield fxa.updateDeviceRegistration();
+
+ do_check_eq(result, credentials.deviceId);
+ do_check_eq(spy.registerDevice.count, 0);
+ do_check_eq(spy.getDeviceList.count, 0);
+ do_check_eq(spy.updateDevice.count, 1);
+ do_check_eq(spy.updateDevice.args[0].length, 4);
+ do_check_eq(spy.updateDevice.args[0][0], credentials.sessionToken);
+ do_check_eq(spy.updateDevice.args[0][1], credentials.deviceId);
+ do_check_eq(spy.updateDevice.args[0][2], deviceName);
+ do_check_eq(spy.updateDevice.args[0][3].pushCallback, "http://mochi.test:8888");
+ do_check_eq(spy.updateDevice.args[0][3].pushPublicKey, BOGUS_PUBLICKEY);
+ do_check_eq(spy.updateDevice.args[0][3].pushAuthKey, BOGUS_AUTHKEY);
+
+ const state = fxa.internal.currentAccountState;
+ const data = yield state.getUserAccountData();
+
+ do_check_eq(data.deviceId, credentials.deviceId);
+ do_check_eq(data.deviceRegistrationVersion, DEVICE_REGISTRATION_VERSION);
+});
+
+add_task(function* test_updateDeviceRegistration_with_unknown_device_error() {
+ const deviceName = "foo";
+ const deviceType = "bar";
+
+ const credentials = getTestUser("baz");
+ const fxa = new MockFxAccounts({ name: deviceName });
+ yield fxa.internal.setSignedInUser(credentials);
+
+ const spy = {
+ registerDevice: { count: 0, args: [] },
+ updateDevice: { count: 0, args: [] },
+ getDeviceList: { count: 0, args: [] }
+ };
+ const client = fxa.internal.fxAccountsClient;
+ client.registerDevice = function () {
+ spy.registerDevice.count += 1;
+ spy.registerDevice.args.push(arguments);
+ return Promise.resolve({
+ id: "a different newly-generated device id",
+ createdAt: Date.now(),
+ name: deviceName,
+ type: deviceType
+ });
+ };
+ client.updateDevice = function () {
+ spy.updateDevice.count += 1;
+ spy.updateDevice.args.push(arguments);
+ return Promise.reject({
+ code: 400,
+ errno: ERRNO_UNKNOWN_DEVICE
+ });
+ };
+ client.getDeviceList = function () {
+ spy.getDeviceList.count += 1;
+ spy.getDeviceList.args.push(arguments);
+ return Promise.resolve([]);
+ };
+
+ const result = yield fxa.updateDeviceRegistration();
+
+ do_check_null(result);
+ do_check_eq(spy.getDeviceList.count, 0);
+ do_check_eq(spy.registerDevice.count, 0);
+ do_check_eq(spy.updateDevice.count, 1);
+ do_check_eq(spy.updateDevice.args[0].length, 4);
+ do_check_eq(spy.updateDevice.args[0][0], credentials.sessionToken);
+ do_check_eq(spy.updateDevice.args[0][1], credentials.deviceId);
+ do_check_eq(spy.updateDevice.args[0][2], deviceName);
+ do_check_eq(spy.updateDevice.args[0][3].pushCallback, "http://mochi.test:8888");
+ do_check_eq(spy.updateDevice.args[0][3].pushPublicKey, BOGUS_PUBLICKEY);
+ do_check_eq(spy.updateDevice.args[0][3].pushAuthKey, BOGUS_AUTHKEY);
+
+
+ const state = fxa.internal.currentAccountState;
+ const data = yield state.getUserAccountData();
+
+ do_check_null(data.deviceId);
+ do_check_eq(data.deviceRegistrationVersion, DEVICE_REGISTRATION_VERSION);
+});
+
+add_task(function* test_updateDeviceRegistration_with_device_session_conflict_error() {
+ const deviceName = "foo";
+ const deviceType = "bar";
+
+ const credentials = getTestUser("baz");
+ const fxa = new MockFxAccounts({ name: deviceName });
+ yield fxa.internal.setSignedInUser(credentials);
+
+ const spy = {
+ registerDevice: { count: 0, args: [] },
+ updateDevice: { count: 0, args: [], times: [] },
+ getDeviceList: { count: 0, args: [] }
+ };
+ const client = fxa.internal.fxAccountsClient;
+ client.registerDevice = function () {
+ spy.registerDevice.count += 1;
+ spy.registerDevice.args.push(arguments);
+ return Promise.resolve({});
+ };
+ client.updateDevice = function () {
+ spy.updateDevice.count += 1;
+ spy.updateDevice.args.push(arguments);
+ spy.updateDevice.time = Date.now();
+ if (spy.updateDevice.count === 1) {
+ return Promise.reject({
+ code: 400,
+ errno: ERRNO_DEVICE_SESSION_CONFLICT
+ });
+ }
+ return Promise.resolve({
+ id: credentials.deviceId,
+ name: deviceName
+ });
+ };
+ client.getDeviceList = function () {
+ spy.getDeviceList.count += 1;
+ spy.getDeviceList.args.push(arguments);
+ spy.getDeviceList.time = Date.now();
+ return Promise.resolve([
+ { id: "ignore", name: "ignore", type: "ignore", isCurrentDevice: false },
+ { id: credentials.deviceId, name: deviceName, type: deviceType, isCurrentDevice: true }
+ ]);
+ };
+
+ const result = yield fxa.updateDeviceRegistration();
+
+ do_check_eq(result, credentials.deviceId);
+ do_check_eq(spy.registerDevice.count, 0);
+ do_check_eq(spy.updateDevice.count, 1);
+ do_check_eq(spy.updateDevice.args[0].length, 4);
+ do_check_eq(spy.updateDevice.args[0][0], credentials.sessionToken);
+ do_check_eq(spy.updateDevice.args[0][1], credentials.deviceId);
+ do_check_eq(spy.updateDevice.args[0][2], deviceName);
+ do_check_eq(spy.updateDevice.args[0][3].pushCallback, "http://mochi.test:8888");
+ do_check_eq(spy.updateDevice.args[0][3].pushPublicKey, BOGUS_PUBLICKEY);
+ do_check_eq(spy.updateDevice.args[0][3].pushAuthKey, BOGUS_AUTHKEY);
+ do_check_eq(spy.getDeviceList.count, 1);
+ do_check_eq(spy.getDeviceList.args[0].length, 1);
+ do_check_eq(spy.getDeviceList.args[0][0], credentials.sessionToken);
+ do_check_true(spy.getDeviceList.time >= spy.updateDevice.time);
+
+ const state = fxa.internal.currentAccountState;
+ const data = yield state.getUserAccountData();
+
+ do_check_eq(data.deviceId, credentials.deviceId);
+ do_check_eq(data.deviceRegistrationVersion, null);
+});
+
+add_task(function* test_updateDeviceRegistration_with_unrecoverable_error() {
+ const deviceName = "foo";
+ const deviceType = "bar";
+
+ const credentials = getTestUser("baz");
+ delete credentials.deviceId;
+ const fxa = new MockFxAccounts({ name: deviceName });
+ yield fxa.internal.setSignedInUser(credentials);
+
+ const spy = {
+ registerDevice: { count: 0, args: [] },
+ updateDevice: { count: 0, args: [] },
+ getDeviceList: { count: 0, args: [] }
+ };
+ const client = fxa.internal.fxAccountsClient;
+ client.registerDevice = function () {
+ spy.registerDevice.count += 1;
+ spy.registerDevice.args.push(arguments);
+ return Promise.reject({
+ code: 400,
+ errno: ERRNO_TOO_MANY_CLIENT_REQUESTS
+ });
+ };
+ client.updateDevice = function () {
+ spy.updateDevice.count += 1;
+ spy.updateDevice.args.push(arguments);
+ return Promise.resolve({});
+ };
+ client.getDeviceList = function () {
+ spy.getDeviceList.count += 1;
+ spy.getDeviceList.args.push(arguments);
+ return Promise.resolve([]);
+ };
+
+ const result = yield fxa.updateDeviceRegistration();
+
+ do_check_null(result);
+ do_check_eq(spy.getDeviceList.count, 0);
+ do_check_eq(spy.updateDevice.count, 0);
+ do_check_eq(spy.registerDevice.count, 1);
+ do_check_eq(spy.registerDevice.args[0].length, 4);
+
+ const state = fxa.internal.currentAccountState;
+ const data = yield state.getUserAccountData();
+
+ do_check_null(data.deviceId);
+});
+
+add_task(function* test_getDeviceId_with_no_device_id_invokes_device_registration() {
+ const credentials = getTestUser("foo");
+ credentials.verified = true;
+ delete credentials.deviceId;
+ const fxa = new MockFxAccounts();
+ yield fxa.internal.setSignedInUser(credentials);
+
+ const spy = { count: 0, args: [] };
+ fxa.internal.currentAccountState.getUserAccountData =
+ () => Promise.resolve({ email: credentials.email,
+ deviceRegistrationVersion: DEVICE_REGISTRATION_VERSION });
+ fxa.internal._registerOrUpdateDevice = function () {
+ spy.count += 1;
+ spy.args.push(arguments);
+ return Promise.resolve("bar");
+ };
+
+ const result = yield fxa.internal.getDeviceId();
+
+ do_check_eq(spy.count, 1);
+ do_check_eq(spy.args[0].length, 1);
+ do_check_eq(spy.args[0][0].email, credentials.email);
+ do_check_null(spy.args[0][0].deviceId);
+ do_check_eq(result, "bar");
+});
+
+add_task(function* test_getDeviceId_with_registration_version_outdated_invokes_device_registration() {
+ const credentials = getTestUser("foo");
+ credentials.verified = true;
+ const fxa = new MockFxAccounts();
+ yield fxa.internal.setSignedInUser(credentials);
+
+ const spy = { count: 0, args: [] };
+ fxa.internal.currentAccountState.getUserAccountData =
+ () => Promise.resolve({ deviceId: credentials.deviceId, deviceRegistrationVersion: 0 });
+ fxa.internal._registerOrUpdateDevice = function () {
+ spy.count += 1;
+ spy.args.push(arguments);
+ return Promise.resolve("wibble");
+ };
+
+ const result = yield fxa.internal.getDeviceId();
+
+ do_check_eq(spy.count, 1);
+ do_check_eq(spy.args[0].length, 1);
+ do_check_eq(spy.args[0][0].deviceId, credentials.deviceId);
+ do_check_eq(result, "wibble");
+});
+
+add_task(function* test_getDeviceId_with_device_id_and_uptodate_registration_version_doesnt_invoke_device_registration() {
+ const credentials = getTestUser("foo");
+ credentials.verified = true;
+ const fxa = new MockFxAccounts();
+ yield fxa.internal.setSignedInUser(credentials);
+
+ const spy = { count: 0 };
+ fxa.internal.currentAccountState.getUserAccountData =
+ () => Promise.resolve({ deviceId: credentials.deviceId, deviceRegistrationVersion: DEVICE_REGISTRATION_VERSION });
+ fxa.internal._registerOrUpdateDevice = function () {
+ spy.count += 1;
+ return Promise.resolve("bar");
+ };
+
+ const result = yield fxa.internal.getDeviceId();
+
+ do_check_eq(spy.count, 0);
+ do_check_eq(result, "foo's device id");
+});
+
+add_task(function* test_getDeviceId_with_device_id_and_with_no_registration_version_invokes_device_registration() {
+ const credentials = getTestUser("foo");
+ credentials.verified = true;
+ const fxa = new MockFxAccounts();
+ yield fxa.internal.setSignedInUser(credentials);
+
+ const spy = { count: 0, args: [] };
+ fxa.internal.currentAccountState.getUserAccountData =
+ () => Promise.resolve({ deviceId: credentials.deviceId });
+ fxa.internal._registerOrUpdateDevice = function () {
+ spy.count += 1;
+ spy.args.push(arguments);
+ return Promise.resolve("wibble");
+ };
+
+ const result = yield fxa.internal.getDeviceId();
+
+ do_check_eq(spy.count, 1);
+ do_check_eq(spy.args[0].length, 1);
+ do_check_eq(spy.args[0][0].deviceId, credentials.deviceId);
+ do_check_eq(result, "wibble");
+});
+
+function expandHex(two_hex) {
+ // Return a 64-character hex string, encoding 32 identical bytes.
+ let eight_hex = two_hex + two_hex + two_hex + two_hex;
+ let thirtytwo_hex = eight_hex + eight_hex + eight_hex + eight_hex;
+ return thirtytwo_hex + thirtytwo_hex;
+};
+
+function expandBytes(two_hex) {
+ return CommonUtils.hexToBytes(expandHex(two_hex));
+};
+
+function getTestUser(name) {
+ return {
+ email: name + "@example.com",
+ uid: "1ad7f502-4cc7-4ec1-a209-071fd2fae348",
+ deviceId: name + "'s device id",
+ sessionToken: name + "'s session token",
+ keyFetchToken: name + "'s keyfetch token",
+ unwrapBKey: expandHex("44"),
+ verified: false
+ };
+}
+
diff --git a/services/fxaccounts/tests/xpcshell/test_client.js b/services/fxaccounts/tests/xpcshell/test_client.js
new file mode 100644
index 000000000..83f42bdf5
--- /dev/null
+++ b/services/fxaccounts/tests/xpcshell/test_client.js
@@ -0,0 +1,917 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+Cu.import("resource://gre/modules/FxAccountsClient.jsm");
+Cu.import("resource://gre/modules/Promise.jsm");
+Cu.import("resource://services-common/utils.js");
+Cu.import("resource://services-common/hawkrequest.js");
+Cu.import("resource://services-crypto/utils.js");
+
+const FAKE_SESSION_TOKEN = "a0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebf";
+
+function run_test() {
+ run_next_test();
+}
+
+// https://wiki.mozilla.org/Identity/AttachedServices/KeyServerProtocol#.2Faccount.2Fkeys
+var ACCOUNT_KEYS = {
+ keyFetch: h("8081828384858687 88898a8b8c8d8e8f"+
+ "9091929394959697 98999a9b9c9d9e9f"),
+
+ response: h("ee5c58845c7c9412 b11bbd20920c2fdd"+
+ "d83c33c9cd2c2de2 d66b222613364636"+
+ "c2c0f8cfbb7c6304 72c0bd88451342c6"+
+ "c05b14ce342c5ad4 6ad89e84464c993c"+
+ "3927d30230157d08 17a077eef4b20d97"+
+ "6f7a97363faf3f06 4c003ada7d01aa70"),
+
+ kA: h("2021222324252627 28292a2b2c2d2e2f"+
+ "3031323334353637 38393a3b3c3d3e3f"),
+
+ wrapKB: h("4041424344454647 48494a4b4c4d4e4f"+
+ "5051525354555657 58595a5b5c5d5e5f"),
+};
+
+function deferredStop(server) {
+ let deferred = Promise.defer();
+ server.stop(deferred.resolve);
+ return deferred.promise;
+}
+
+add_task(function* test_authenticated_get_request() {
+ let message = "{\"msg\": \"Great Success!\"}";
+ let credentials = {
+ id: "eyJleHBpcmVzIjogMTM2NTAxMDg5OC4x",
+ key: "qTZf4ZFpAMpMoeSsX3zVRjiqmNs=",
+ algorithm: "sha256"
+ };
+ let method = "GET";
+
+ let server = httpd_setup({"/foo": function(request, response) {
+ do_check_true(request.hasHeader("Authorization"));
+
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.bodyOutputStream.write(message, message.length);
+ }
+ });
+
+ let client = new FxAccountsClient(server.baseURI);
+
+ let result = yield client._request("/foo", method, credentials);
+ do_check_eq("Great Success!", result.msg);
+
+ yield deferredStop(server);
+});
+
+add_task(function* test_authenticated_post_request() {
+ let credentials = {
+ id: "eyJleHBpcmVzIjogMTM2NTAxMDg5OC4x",
+ key: "qTZf4ZFpAMpMoeSsX3zVRjiqmNs=",
+ algorithm: "sha256"
+ };
+ let method = "POST";
+
+ let server = httpd_setup({"/foo": function(request, response) {
+ do_check_true(request.hasHeader("Authorization"));
+
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.setHeader("Content-Type", "application/json");
+ response.bodyOutputStream.writeFrom(request.bodyInputStream, request.bodyInputStream.available());
+ }
+ });
+
+ let client = new FxAccountsClient(server.baseURI);
+
+ let result = yield client._request("/foo", method, credentials, {foo: "bar"});
+ do_check_eq("bar", result.foo);
+
+ yield deferredStop(server);
+});
+
+add_task(function* test_500_error() {
+ let message = "<h1>Ooops!</h1>";
+ let method = "GET";
+
+ let server = httpd_setup({"/foo": function(request, response) {
+ response.setStatusLine(request.httpVersion, 500, "Internal Server Error");
+ response.bodyOutputStream.write(message, message.length);
+ }
+ });
+
+ let client = new FxAccountsClient(server.baseURI);
+
+ try {
+ yield client._request("/foo", method);
+ do_throw("Expected to catch an exception");
+ } catch (e) {
+ do_check_eq(500, e.code);
+ do_check_eq("Internal Server Error", e.message);
+ }
+
+ yield deferredStop(server);
+});
+
+add_task(function* test_backoffError() {
+ let method = "GET";
+ let server = httpd_setup({
+ "/retryDelay": function(request, response) {
+ response.setHeader("Retry-After", "30");
+ response.setStatusLine(request.httpVersion, 429, "Client has sent too many requests");
+ let message = "<h1>Ooops!</h1>";
+ response.bodyOutputStream.write(message, message.length);
+ },
+ "/duringDelayIShouldNotBeCalled": function(request, response) {
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ let jsonMessage = "{\"working\": \"yes\"}";
+ response.bodyOutputStream.write(jsonMessage, jsonMessage.length);
+ },
+ });
+
+ let client = new FxAccountsClient(server.baseURI);
+
+ // Retry-After header sets client.backoffError
+ do_check_eq(client.backoffError, null);
+ try {
+ yield client._request("/retryDelay", method);
+ } catch (e) {
+ do_check_eq(429, e.code);
+ do_check_eq(30, e.retryAfter);
+ do_check_neq(typeof(client.fxaBackoffTimer), "undefined");
+ do_check_neq(client.backoffError, null);
+ }
+ // While delay is in effect, client short-circuits any requests
+ // and re-rejects with previous error.
+ try {
+ yield client._request("/duringDelayIShouldNotBeCalled", method);
+ throw new Error("I should not be reached");
+ } catch (e) {
+ do_check_eq(e.retryAfter, 30);
+ do_check_eq(e.message, "Client has sent too many requests");
+ do_check_neq(client.backoffError, null);
+ }
+ // Once timer fires, client nulls error out and HTTP calls work again.
+ client._clearBackoff();
+ let result = yield client._request("/duringDelayIShouldNotBeCalled", method);
+ do_check_eq(client.backoffError, null);
+ do_check_eq(result.working, "yes");
+
+ yield deferredStop(server);
+});
+
+add_task(function* test_signUp() {
+ let creationMessage_noKey = JSON.stringify({
+ uid: "uid",
+ sessionToken: "sessionToken"
+ });
+ let creationMessage_withKey = JSON.stringify({
+ uid: "uid",
+ sessionToken: "sessionToken",
+ keyFetchToken: "keyFetchToken"
+ });
+ let errorMessage = JSON.stringify({code: 400, errno: 101, error: "account exists"});
+ let created = false;
+
+ // Note these strings must be unicode and not already utf-8 encoded.
+ let unicodeUsername = "andr\xe9@example.org"; // 'andré@example.org'
+ let unicodePassword = "p\xe4ssw\xf6rd"; // 'pässwörd'
+ let server = httpd_setup({
+ "/account/create": function(request, response) {
+ let body = CommonUtils.readBytesFromInputStream(request.bodyInputStream);
+ body = CommonUtils.decodeUTF8(body);
+ let jsonBody = JSON.parse(body);
+
+ // https://github.com/mozilla/fxa-auth-server/wiki/onepw-protocol#wiki-test-vectors
+
+ if (created) {
+ // Error trying to create same account a second time
+ response.setStatusLine(request.httpVersion, 400, "Bad request");
+ response.bodyOutputStream.write(errorMessage, errorMessage.length);
+ return;
+ }
+
+ if (jsonBody.email == unicodeUsername) {
+ do_check_eq("", request._queryString);
+ do_check_eq(jsonBody.authPW, "247b675ffb4c46310bc87e26d712153abe5e1c90ef00a4784594f97ef54f2375");
+
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.bodyOutputStream.write(creationMessage_noKey,
+ creationMessage_noKey.length);
+ return;
+ }
+
+ if (jsonBody.email == "you@example.org") {
+ do_check_eq("keys=true", request._queryString);
+ do_check_eq(jsonBody.authPW, "e5c1cdfdaa5fcee06142db865b212cc8ba8abee2a27d639d42c139f006cdb930");
+ created = true;
+
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.bodyOutputStream.write(creationMessage_withKey,
+ creationMessage_withKey.length);
+ return;
+ }
+ // just throwing here doesn't make any log noise, so have an assertion
+ // fail instead.
+ do_check_true(false, "unexpected email: " + jsonBody.email);
+ },
+ });
+
+ // Try to create an account without retrieving optional keys.
+ let client = new FxAccountsClient(server.baseURI);
+ let result = yield client.signUp(unicodeUsername, unicodePassword);
+ do_check_eq("uid", result.uid);
+ do_check_eq("sessionToken", result.sessionToken);
+ do_check_eq(undefined, result.keyFetchToken);
+ do_check_eq(result.unwrapBKey,
+ "de6a2648b78284fcb9ffa81ba95803309cfba7af583c01a8a1a63e567234dd28");
+
+ // Try to create an account retrieving optional keys.
+ result = yield client.signUp('you@example.org', 'pässwörd', true);
+ do_check_eq("uid", result.uid);
+ do_check_eq("sessionToken", result.sessionToken);
+ do_check_eq("keyFetchToken", result.keyFetchToken);
+ do_check_eq(result.unwrapBKey,
+ "f589225b609e56075d76eb74f771ff9ab18a4dc0e901e131ba8f984c7fb0ca8c");
+
+ // Try to create an existing account. Triggers error path.
+ try {
+ result = yield client.signUp(unicodeUsername, unicodePassword);
+ do_throw("Expected to catch an exception");
+ } catch(expectedError) {
+ do_check_eq(101, expectedError.errno);
+ }
+
+ yield deferredStop(server);
+});
+
+add_task(function* test_signIn() {
+ let sessionMessage_noKey = JSON.stringify({
+ sessionToken: FAKE_SESSION_TOKEN
+ });
+ let sessionMessage_withKey = JSON.stringify({
+ sessionToken: FAKE_SESSION_TOKEN,
+ keyFetchToken: "keyFetchToken"
+ });
+ let errorMessage_notExistent = JSON.stringify({
+ code: 400,
+ errno: 102,
+ error: "doesn't exist"
+ });
+ let errorMessage_wrongCap = JSON.stringify({
+ code: 400,
+ errno: 120,
+ error: "Incorrect email case",
+ email: "you@example.com"
+ });
+
+ // Note this strings must be unicode and not already utf-8 encoded.
+ let unicodeUsername = "m\xe9@example.com" // 'mé@example.com'
+ let server = httpd_setup({
+ "/account/login": function(request, response) {
+ let body = CommonUtils.readBytesFromInputStream(request.bodyInputStream);
+ body = CommonUtils.decodeUTF8(body);
+ let jsonBody = JSON.parse(body);
+
+ if (jsonBody.email == unicodeUsername) {
+ do_check_eq("", request._queryString);
+ do_check_eq(jsonBody.authPW, "08b9d111196b8408e8ed92439da49206c8ecfbf343df0ae1ecefcd1e0174a8b6");
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.bodyOutputStream.write(sessionMessage_noKey,
+ sessionMessage_noKey.length);
+ return;
+ }
+ else if (jsonBody.email == "you@example.com") {
+ do_check_eq("keys=true", request._queryString);
+ do_check_eq(jsonBody.authPW, "93d20ec50304d496d0707ec20d7e8c89459b6396ec5dd5b9e92809c5e42856c7");
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.bodyOutputStream.write(sessionMessage_withKey,
+ sessionMessage_withKey.length);
+ return;
+ }
+ else if (jsonBody.email == "You@example.com") {
+ // Error trying to sign in with a wrong capitalization
+ response.setStatusLine(request.httpVersion, 400, "Bad request");
+ response.bodyOutputStream.write(errorMessage_wrongCap,
+ errorMessage_wrongCap.length);
+ return;
+ }
+ else {
+ // Error trying to sign in to nonexistent account
+ response.setStatusLine(request.httpVersion, 400, "Bad request");
+ response.bodyOutputStream.write(errorMessage_notExistent,
+ errorMessage_notExistent.length);
+ return;
+ }
+ },
+ });
+
+ // Login without retrieving optional keys
+ let client = new FxAccountsClient(server.baseURI);
+ let result = yield client.signIn(unicodeUsername, 'bigsecret');
+ do_check_eq(FAKE_SESSION_TOKEN, result.sessionToken);
+ do_check_eq(result.unwrapBKey,
+ "c076ec3f4af123a615157154c6e1d0d6293e514fd7b0221e32d50517ecf002b8");
+ do_check_eq(undefined, result.keyFetchToken);
+
+ // Login with retrieving optional keys
+ result = yield client.signIn('you@example.com', 'bigsecret', true);
+ do_check_eq(FAKE_SESSION_TOKEN, result.sessionToken);
+ do_check_eq(result.unwrapBKey,
+ "65970516211062112e955d6420bebe020269d6b6a91ebd288319fc8d0cb49624");
+ do_check_eq("keyFetchToken", result.keyFetchToken);
+
+ // Retry due to wrong email capitalization
+ result = yield client.signIn('You@example.com', 'bigsecret', true);
+ do_check_eq(FAKE_SESSION_TOKEN, result.sessionToken);
+ do_check_eq(result.unwrapBKey,
+ "65970516211062112e955d6420bebe020269d6b6a91ebd288319fc8d0cb49624");
+ do_check_eq("keyFetchToken", result.keyFetchToken);
+
+ // Trigger error path
+ try {
+ result = yield client.signIn("yøü@bad.example.org", "nofear");
+ do_throw("Expected to catch an exception");
+ } catch (expectedError) {
+ do_check_eq(102, expectedError.errno);
+ }
+
+ yield deferredStop(server);
+});
+
+add_task(function* test_signOut() {
+ let signoutMessage = JSON.stringify({});
+ let errorMessage = JSON.stringify({code: 400, errno: 102, error: "doesn't exist"});
+ let signedOut = false;
+
+ let server = httpd_setup({
+ "/session/destroy": function(request, response) {
+ if (!signedOut) {
+ signedOut = true;
+ do_check_true(request.hasHeader("Authorization"));
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.bodyOutputStream.write(signoutMessage, signoutMessage.length);
+ return;
+ }
+
+ // Error trying to sign out of nonexistent account
+ response.setStatusLine(request.httpVersion, 400, "Bad request");
+ response.bodyOutputStream.write(errorMessage, errorMessage.length);
+ return;
+ },
+ });
+
+ let client = new FxAccountsClient(server.baseURI);
+ let result = yield client.signOut("FakeSession");
+ do_check_eq(typeof result, "object");
+
+ // Trigger error path
+ try {
+ result = yield client.signOut("FakeSession");
+ do_throw("Expected to catch an exception");
+ } catch(expectedError) {
+ do_check_eq(102, expectedError.errno);
+ }
+
+ yield deferredStop(server);
+});
+
+add_task(function* test_recoveryEmailStatus() {
+ let emailStatus = JSON.stringify({verified: true});
+ let errorMessage = JSON.stringify({code: 400, errno: 102, error: "doesn't exist"});
+ let tries = 0;
+
+ let server = httpd_setup({
+ "/recovery_email/status": function(request, response) {
+ do_check_true(request.hasHeader("Authorization"));
+ do_check_eq("", request._queryString);
+
+ if (tries === 0) {
+ tries += 1;
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.bodyOutputStream.write(emailStatus, emailStatus.length);
+ return;
+ }
+
+ // Second call gets an error trying to query a nonexistent account
+ response.setStatusLine(request.httpVersion, 400, "Bad request");
+ response.bodyOutputStream.write(errorMessage, errorMessage.length);
+ return;
+ },
+ });
+
+ let client = new FxAccountsClient(server.baseURI);
+ let result = yield client.recoveryEmailStatus(FAKE_SESSION_TOKEN);
+ do_check_eq(result.verified, true);
+
+ // Trigger error path
+ try {
+ result = yield client.recoveryEmailStatus("some bogus session");
+ do_throw("Expected to catch an exception");
+ } catch(expectedError) {
+ do_check_eq(102, expectedError.errno);
+ }
+
+ yield deferredStop(server);
+});
+
+add_task(function* test_recoveryEmailStatusWithReason() {
+ let emailStatus = JSON.stringify({verified: true});
+ let server = httpd_setup({
+ "/recovery_email/status": function(request, response) {
+ do_check_true(request.hasHeader("Authorization"));
+ // if there is a query string then it will have a reason
+ do_check_eq("reason=push", request._queryString);
+
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.bodyOutputStream.write(emailStatus, emailStatus.length);
+ return;
+ },
+ });
+
+ let client = new FxAccountsClient(server.baseURI);
+ let result = yield client.recoveryEmailStatus(FAKE_SESSION_TOKEN, {
+ reason: "push",
+ });
+ do_check_eq(result.verified, true);
+ yield deferredStop(server);
+});
+
+add_task(function* test_resendVerificationEmail() {
+ let emptyMessage = "{}";
+ let errorMessage = JSON.stringify({code: 400, errno: 102, error: "doesn't exist"});
+ let tries = 0;
+
+ let server = httpd_setup({
+ "/recovery_email/resend_code": function(request, response) {
+ do_check_true(request.hasHeader("Authorization"));
+ if (tries === 0) {
+ tries += 1;
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.bodyOutputStream.write(emptyMessage, emptyMessage.length);
+ return;
+ }
+
+ // Second call gets an error trying to query a nonexistent account
+ response.setStatusLine(request.httpVersion, 400, "Bad request");
+ response.bodyOutputStream.write(errorMessage, errorMessage.length);
+ return;
+ },
+ });
+
+ let client = new FxAccountsClient(server.baseURI);
+ let result = yield client.resendVerificationEmail(FAKE_SESSION_TOKEN);
+ do_check_eq(JSON.stringify(result), emptyMessage);
+
+ // Trigger error path
+ try {
+ result = yield client.resendVerificationEmail("some bogus session");
+ do_throw("Expected to catch an exception");
+ } catch(expectedError) {
+ do_check_eq(102, expectedError.errno);
+ }
+
+ yield deferredStop(server);
+});
+
+add_task(function* test_accountKeys() {
+ // Four calls to accountKeys(). The first one should work correctly, and we
+ // should get a valid bundle back, in exchange for our keyFetch token, from
+ // which we correctly derive kA and wrapKB. The subsequent three calls
+ // should all trigger separate error paths.
+ let responseMessage = JSON.stringify({bundle: ACCOUNT_KEYS.response});
+ let errorMessage = JSON.stringify({code: 400, errno: 102, error: "doesn't exist"});
+ let emptyMessage = "{}";
+ let attempt = 0;
+
+ let server = httpd_setup({
+ "/account/keys": function(request, response) {
+ do_check_true(request.hasHeader("Authorization"));
+ attempt += 1;
+
+ switch(attempt) {
+ case 1:
+ // First time succeeds
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.bodyOutputStream.write(responseMessage, responseMessage.length);
+ break;
+
+ case 2:
+ // Second time, return no bundle to trigger client error
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.bodyOutputStream.write(emptyMessage, emptyMessage.length);
+ break;
+
+ case 3:
+ // Return gibberish to trigger client MAC error
+ // Tweak a byte
+ let garbageResponse = JSON.stringify({
+ bundle: ACCOUNT_KEYS.response.slice(0, -1) + "1"
+ });
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.bodyOutputStream.write(garbageResponse, garbageResponse.length);
+ break;
+
+ case 4:
+ // Trigger error for nonexistent account
+ response.setStatusLine(request.httpVersion, 400, "Bad request");
+ response.bodyOutputStream.write(errorMessage, errorMessage.length);
+ break;
+ }
+ },
+ });
+
+ let client = new FxAccountsClient(server.baseURI);
+
+ // First try, all should be good
+ let result = yield client.accountKeys(ACCOUNT_KEYS.keyFetch);
+ do_check_eq(CommonUtils.hexToBytes(ACCOUNT_KEYS.kA), result.kA);
+ do_check_eq(CommonUtils.hexToBytes(ACCOUNT_KEYS.wrapKB), result.wrapKB);
+
+ // Second try, empty bundle should trigger error
+ try {
+ result = yield client.accountKeys(ACCOUNT_KEYS.keyFetch);
+ do_throw("Expected to catch an exception");
+ } catch(expectedError) {
+ do_check_eq(expectedError.message, "failed to retrieve keys");
+ }
+
+ // Third try, bad bundle results in MAC error
+ try {
+ result = yield client.accountKeys(ACCOUNT_KEYS.keyFetch);
+ do_throw("Expected to catch an exception");
+ } catch(expectedError) {
+ do_check_eq(expectedError.message, "error unbundling encryption keys");
+ }
+
+ // Fourth try, pretend account doesn't exist
+ try {
+ result = yield client.accountKeys(ACCOUNT_KEYS.keyFetch);
+ do_throw("Expected to catch an exception");
+ } catch(expectedError) {
+ do_check_eq(102, expectedError.errno);
+ }
+
+ yield deferredStop(server);
+});
+
+add_task(function* test_signCertificate() {
+ let certSignMessage = JSON.stringify({cert: {bar: "baz"}});
+ let errorMessage = JSON.stringify({code: 400, errno: 102, error: "doesn't exist"});
+ let tries = 0;
+
+ let server = httpd_setup({
+ "/certificate/sign": function(request, response) {
+ do_check_true(request.hasHeader("Authorization"));
+
+ if (tries === 0) {
+ tries += 1;
+ let body = CommonUtils.readBytesFromInputStream(request.bodyInputStream);
+ let jsonBody = JSON.parse(body);
+ do_check_eq(JSON.parse(jsonBody.publicKey).foo, "bar");
+ do_check_eq(jsonBody.duration, 600);
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.bodyOutputStream.write(certSignMessage, certSignMessage.length);
+ return;
+ }
+
+ // Second attempt, trigger error
+ response.setStatusLine(request.httpVersion, 400, "Bad request");
+ response.bodyOutputStream.write(errorMessage, errorMessage.length);
+ return;
+ },
+ });
+
+ let client = new FxAccountsClient(server.baseURI);
+ let result = yield client.signCertificate(FAKE_SESSION_TOKEN, JSON.stringify({foo: "bar"}), 600);
+ do_check_eq("baz", result.bar);
+
+ // Account doesn't exist
+ try {
+ result = yield client.signCertificate("bogus", JSON.stringify({foo: "bar"}), 600);
+ do_throw("Expected to catch an exception");
+ } catch(expectedError) {
+ do_check_eq(102, expectedError.errno);
+ }
+
+ yield deferredStop(server);
+});
+
+add_task(function* test_accountExists() {
+ let sessionMessage = JSON.stringify({sessionToken: FAKE_SESSION_TOKEN});
+ let existsMessage = JSON.stringify({error: "wrong password", code: 400, errno: 103});
+ let doesntExistMessage = JSON.stringify({error: "no such account", code: 400, errno: 102});
+ let emptyMessage = "{}";
+
+ let server = httpd_setup({
+ "/account/login": function(request, response) {
+ let body = CommonUtils.readBytesFromInputStream(request.bodyInputStream);
+ let jsonBody = JSON.parse(body);
+
+ switch (jsonBody.email) {
+ // We'll test that these users' accounts exist
+ case "i.exist@example.com":
+ case "i.also.exist@example.com":
+ response.setStatusLine(request.httpVersion, 400, "Bad request");
+ response.bodyOutputStream.write(existsMessage, existsMessage.length);
+ break;
+
+ // This user's account doesn't exist
+ case "i.dont.exist@example.com":
+ response.setStatusLine(request.httpVersion, 400, "Bad request");
+ response.bodyOutputStream.write(doesntExistMessage, doesntExistMessage.length);
+ break;
+
+ // This user throws an unexpected response
+ // This will reject the client signIn promise
+ case "i.break.things@example.com":
+ response.setStatusLine(request.httpVersion, 500, "Alas");
+ response.bodyOutputStream.write(emptyMessage, emptyMessage.length);
+ break;
+
+ default:
+ throw new Error("Unexpected login from " + jsonBody.email);
+ break;
+ }
+ },
+ });
+
+ let client = new FxAccountsClient(server.baseURI);
+ let result;
+
+ result = yield client.accountExists("i.exist@example.com");
+ do_check_true(result);
+
+ result = yield client.accountExists("i.also.exist@example.com");
+ do_check_true(result);
+
+ result = yield client.accountExists("i.dont.exist@example.com");
+ do_check_false(result);
+
+ try {
+ result = yield client.accountExists("i.break.things@example.com");
+ do_throw("Expected to catch an exception");
+ } catch(unexpectedError) {
+ do_check_eq(unexpectedError.code, 500);
+ }
+
+ yield deferredStop(server);
+});
+
+add_task(function* test_registerDevice() {
+ const DEVICE_ID = "device id";
+ const DEVICE_NAME = "device name";
+ const DEVICE_TYPE = "device type";
+ const ERROR_NAME = "test that the client promise rejects";
+
+ const server = httpd_setup({
+ "/account/device": function(request, response) {
+ const body = JSON.parse(CommonUtils.readBytesFromInputStream(request.bodyInputStream));
+
+ if (body.id || !body.name || !body.type || Object.keys(body).length !== 2) {
+ response.setStatusLine(request.httpVersion, 400, "Invalid request");
+ return response.bodyOutputStream.write("{}", 2);
+ }
+
+ if (body.name === ERROR_NAME) {
+ response.setStatusLine(request.httpVersion, 500, "Alas");
+ return response.bodyOutputStream.write("{}", 2);
+ }
+
+ body.id = DEVICE_ID;
+ body.createdAt = Date.now();
+
+ const responseMessage = JSON.stringify(body);
+
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.bodyOutputStream.write(responseMessage, responseMessage.length);
+ },
+ });
+
+ const client = new FxAccountsClient(server.baseURI);
+ const result = yield client.registerDevice(FAKE_SESSION_TOKEN, DEVICE_NAME, DEVICE_TYPE);
+
+ do_check_true(result);
+ do_check_eq(Object.keys(result).length, 4);
+ do_check_eq(result.id, DEVICE_ID);
+ do_check_eq(typeof result.createdAt, 'number');
+ do_check_true(result.createdAt > 0);
+ do_check_eq(result.name, DEVICE_NAME);
+ do_check_eq(result.type, DEVICE_TYPE);
+
+ try {
+ yield client.registerDevice(FAKE_SESSION_TOKEN, ERROR_NAME, DEVICE_TYPE);
+ do_throw("Expected to catch an exception");
+ } catch(unexpectedError) {
+ do_check_eq(unexpectedError.code, 500);
+ }
+
+ yield deferredStop(server);
+});
+
+add_task(function* test_updateDevice() {
+ const DEVICE_ID = "some other id";
+ const DEVICE_NAME = "some other name";
+ const ERROR_ID = "test that the client promise rejects";
+
+ const server = httpd_setup({
+ "/account/device": function(request, response) {
+ const body = JSON.parse(CommonUtils.readBytesFromInputStream(request.bodyInputStream));
+
+ if (!body.id || !body.name || body.type || Object.keys(body).length !== 2) {
+ response.setStatusLine(request.httpVersion, 400, "Invalid request");
+ return response.bodyOutputStream.write("{}", 2);
+ }
+
+ if (body.id === ERROR_ID) {
+ response.setStatusLine(request.httpVersion, 500, "Alas");
+ return response.bodyOutputStream.write("{}", 2);
+ }
+
+ const responseMessage = JSON.stringify(body);
+
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.bodyOutputStream.write(responseMessage, responseMessage.length);
+ },
+ });
+
+ const client = new FxAccountsClient(server.baseURI);
+ const result = yield client.updateDevice(FAKE_SESSION_TOKEN, DEVICE_ID, DEVICE_NAME);
+
+ do_check_true(result);
+ do_check_eq(Object.keys(result).length, 2);
+ do_check_eq(result.id, DEVICE_ID);
+ do_check_eq(result.name, DEVICE_NAME);
+
+ try {
+ yield client.updateDevice(FAKE_SESSION_TOKEN, ERROR_ID, DEVICE_NAME);
+ do_throw("Expected to catch an exception");
+ } catch(unexpectedError) {
+ do_check_eq(unexpectedError.code, 500);
+ }
+
+ yield deferredStop(server);
+});
+
+add_task(function* test_signOutAndDestroyDevice() {
+ const DEVICE_ID = "device id";
+ const ERROR_ID = "test that the client promise rejects";
+
+ const server = httpd_setup({
+ "/account/device/destroy": function(request, response) {
+ const body = JSON.parse(CommonUtils.readBytesFromInputStream(request.bodyInputStream));
+
+ if (!body.id) {
+ response.setStatusLine(request.httpVersion, 400, "Invalid request");
+ return response.bodyOutputStream.write(emptyMessage, emptyMessage.length);
+ }
+
+ if (body.id === ERROR_ID) {
+ response.setStatusLine(request.httpVersion, 500, "Alas");
+ return response.bodyOutputStream.write("{}", 2);
+ }
+
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.bodyOutputStream.write("{}", 2);
+ },
+ });
+
+ const client = new FxAccountsClient(server.baseURI);
+ const result = yield client.signOutAndDestroyDevice(FAKE_SESSION_TOKEN, DEVICE_ID);
+
+ do_check_true(result);
+ do_check_eq(Object.keys(result).length, 0);
+
+ try {
+ yield client.signOutAndDestroyDevice(FAKE_SESSION_TOKEN, ERROR_ID);
+ do_throw("Expected to catch an exception");
+ } catch(unexpectedError) {
+ do_check_eq(unexpectedError.code, 500);
+ }
+
+ yield deferredStop(server);
+});
+
+add_task(function* test_getDeviceList() {
+ let canReturnDevices;
+
+ const server = httpd_setup({
+ "/account/devices": function(request, response) {
+ if (canReturnDevices) {
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.bodyOutputStream.write("[]", 2);
+ } else {
+ response.setStatusLine(request.httpVersion, 500, "Alas");
+ response.bodyOutputStream.write("{}", 2);
+ }
+ },
+ });
+
+ const client = new FxAccountsClient(server.baseURI);
+
+ canReturnDevices = true;
+ const result = yield client.getDeviceList(FAKE_SESSION_TOKEN);
+ do_check_true(Array.isArray(result));
+ do_check_eq(result.length, 0);
+
+ try {
+ canReturnDevices = false;
+ yield client.getDeviceList(FAKE_SESSION_TOKEN);
+ do_throw("Expected to catch an exception");
+ } catch(unexpectedError) {
+ do_check_eq(unexpectedError.code, 500);
+ }
+
+ yield deferredStop(server);
+});
+
+add_task(function* test_client_metrics() {
+ function writeResp(response, msg) {
+ if (typeof msg === "object") {
+ msg = JSON.stringify(msg);
+ }
+ response.bodyOutputStream.write(msg, msg.length);
+ }
+
+ let server = httpd_setup(
+ {
+ "/session/destroy": function(request, response) {
+ response.setHeader("Content-Type", "application/json; charset=utf-8");
+ response.setStatusLine(request.httpVersion, 401, "Unauthorized");
+ writeResp(response, {
+ error: "invalid authentication timestamp",
+ code: 401,
+ errno: 111,
+ });
+ },
+ }
+ );
+
+ let client = new FxAccountsClient(server.baseURI);
+
+ yield rejects(client.signOut(FAKE_SESSION_TOKEN, {
+ service: "sync",
+ }), function(err) {
+ return err.errno == 111;
+ });
+
+ yield deferredStop(server);
+});
+
+add_task(function* test_email_case() {
+ let canonicalEmail = "greta.garbo@gmail.com";
+ let clientEmail = "Greta.Garbo@gmail.COM";
+ let attempts = 0;
+
+ function writeResp(response, msg) {
+ if (typeof msg === "object") {
+ msg = JSON.stringify(msg);
+ }
+ response.bodyOutputStream.write(msg, msg.length);
+ }
+
+ let server = httpd_setup(
+ {
+ "/account/login": function(request, response) {
+ response.setHeader("Content-Type", "application/json; charset=utf-8");
+ attempts += 1;
+ if (attempts > 2) {
+ response.setStatusLine(request.httpVersion, 429, "Sorry, you had your chance");
+ return writeResp(response, "");
+ }
+
+ let body = CommonUtils.readBytesFromInputStream(request.bodyInputStream);
+ let jsonBody = JSON.parse(body);
+ let email = jsonBody.email;
+
+ // If the client has the wrong case on the email, we return a 400, with
+ // the capitalization of the email as saved in the accounts database.
+ if (email == canonicalEmail) {
+ response.setStatusLine(request.httpVersion, 200, "Yay");
+ return writeResp(response, {areWeHappy: "yes"});
+ }
+
+ response.setStatusLine(request.httpVersion, 400, "Incorrect email case");
+ return writeResp(response, {
+ code: 400,
+ errno: 120,
+ error: "Incorrect email case",
+ email: canonicalEmail
+ });
+ },
+ }
+ );
+
+ let client = new FxAccountsClient(server.baseURI);
+
+ let result = yield client.signIn(clientEmail, "123456");
+ do_check_eq(result.areWeHappy, "yes");
+ do_check_eq(attempts, 2);
+
+ yield deferredStop(server);
+});
+
+// turn formatted test vectors into normal hex strings
+function h(hexStr) {
+ return hexStr.replace(/\s+/g, "");
+}
diff --git a/services/fxaccounts/tests/xpcshell/test_credentials.js b/services/fxaccounts/tests/xpcshell/test_credentials.js
new file mode 100644
index 000000000..cbd9e4c7a
--- /dev/null
+++ b/services/fxaccounts/tests/xpcshell/test_credentials.js
@@ -0,0 +1,110 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+Cu.import("resource://gre/modules/Credentials.jsm");
+Cu.import("resource://gre/modules/Promise.jsm");
+Cu.import("resource://services-common/utils.js");
+Cu.import("resource://services-crypto/utils.js");
+
+var {hexToBytes: h2b,
+ hexAsString: h2s,
+ stringAsHex: s2h,
+ bytesAsHex: b2h} = CommonUtils;
+
+// Test vectors for the "onepw" protocol:
+// https://github.com/mozilla/fxa-auth-server/wiki/onepw-protocol#wiki-test-vectors
+var vectors = {
+ "client stretch-KDF": {
+ email:
+ h("616e6472c3a94065 78616d706c652e6f 7267"),
+ password:
+ h("70c3a4737377c3b6 7264"),
+ quickStretchedPW:
+ h("e4e8889bd8bd61ad 6de6b95c059d56e7 b50dacdaf62bd846 44af7e2add84345d"),
+ authPW:
+ h("247b675ffb4c4631 0bc87e26d712153a be5e1c90ef00a478 4594f97ef54f2375"),
+ authSalt:
+ h("00f0000000000000 0000000000000000 0000000000000000 0000000000000000"),
+ },
+};
+
+// A simple test suite with no utf8 encoding madness.
+add_task(function* test_onepw_setup_credentials() {
+ let email = "francine@example.org";
+ let password = CommonUtils.encodeUTF8("i like pie");
+
+ let pbkdf2 = CryptoUtils.pbkdf2Generate;
+ let hkdf = CryptoUtils.hkdf;
+
+ // quickStretch the email
+ let saltyEmail = Credentials.keyWordExtended("quickStretch", email);
+
+ do_check_eq(b2h(saltyEmail), "6964656e746974792e6d6f7a696c6c612e636f6d2f7069636c2f76312f717569636b537472657463683a6672616e63696e65406578616d706c652e6f7267");
+
+ let pbkdf2Rounds = 1000;
+ let pbkdf2Len = 32;
+
+ let quickStretchedPW = pbkdf2(password, saltyEmail, pbkdf2Rounds, pbkdf2Len, Ci.nsICryptoHMAC.SHA256, 32);
+ let quickStretchedActual = "6b88094c1c73bbf133223f300d101ed70837af48d9d2c1b6e7d38804b20cdde4";
+ do_check_eq(b2h(quickStretchedPW), quickStretchedActual);
+
+ // obtain hkdf info
+ let authKeyInfo = Credentials.keyWord('authPW');
+ do_check_eq(b2h(authKeyInfo), "6964656e746974792e6d6f7a696c6c612e636f6d2f7069636c2f76312f617574685057");
+
+ // derive auth password
+ let hkdfSalt = h2b("00");
+ let hkdfLen = 32;
+ let authPW = hkdf(quickStretchedPW, hkdfSalt, authKeyInfo, hkdfLen);
+
+ do_check_eq(b2h(authPW), "4b8dec7f48e7852658163601ff766124c312f9392af6c3d4e1a247eb439be342");
+
+ // derive unwrap key
+ let unwrapKeyInfo = Credentials.keyWord('unwrapBkey');
+ let unwrapKey = hkdf(quickStretchedPW, hkdfSalt, unwrapKeyInfo, hkdfLen);
+
+ do_check_eq(b2h(unwrapKey), "8ff58975be391338e4ec5d7138b5ed7b65c7d1bfd1f3a4f93e05aa47d5b72be9");
+});
+
+add_task(function* test_client_stretch_kdf() {
+ let pbkdf2 = CryptoUtils.pbkdf2Generate;
+ let hkdf = CryptoUtils.hkdf;
+ let expected = vectors["client stretch-KDF"];
+
+ let email = h2s(expected.email);
+ let password = h2s(expected.password);
+
+ // Intermediate value from sjcl implementation in fxa-js-client
+ // The key thing is the c3a9 sequence in "andré"
+ let salt = Credentials.keyWordExtended("quickStretch", email);
+ do_check_eq(b2h(salt), "6964656e746974792e6d6f7a696c6c612e636f6d2f7069636c2f76312f717569636b537472657463683a616e6472c3a9406578616d706c652e6f7267");
+
+ let options = {
+ stretchedPassLength: 32,
+ pbkdf2Rounds: 1000,
+ hmacAlgorithm: Ci.nsICryptoHMAC.SHA256,
+ hmacLength: 32,
+ hkdfSalt: h2b("00"),
+ hkdfLength: 32,
+ };
+
+ let results = yield Credentials.setup(email, password, options);
+
+ do_check_eq(expected.quickStretchedPW, b2h(results.quickStretchedPW),
+ "quickStretchedPW is wrong");
+
+ do_check_eq(expected.authPW, b2h(results.authPW),
+ "authPW is wrong");
+});
+
+// End of tests
+// Utility functions follow
+
+function run_test() {
+ run_next_test();
+}
+
+// turn formatted test vectors into normal hex strings
+function h(hexStr) {
+ return hexStr.replace(/\s+/g, "");
+}
diff --git a/services/fxaccounts/tests/xpcshell/test_loginmgr_storage.js b/services/fxaccounts/tests/xpcshell/test_loginmgr_storage.js
new file mode 100644
index 000000000..64ddb1fd1
--- /dev/null
+++ b/services/fxaccounts/tests/xpcshell/test_loginmgr_storage.js
@@ -0,0 +1,214 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Tests for FxAccounts, storage and the master password.
+
+// Stop us hitting the real auth server.
+Services.prefs.setCharPref("identity.fxaccounts.auth.uri", "http://localhost");
+// See verbose logging from FxAccounts.jsm
+Services.prefs.setCharPref("identity.fxaccounts.loglevel", "Trace");
+
+Cu.import("resource://gre/modules/Services.jsm");
+Cu.import("resource://gre/modules/FxAccounts.jsm");
+Cu.import("resource://gre/modules/FxAccountsClient.jsm");
+Cu.import("resource://gre/modules/FxAccountsCommon.js");
+Cu.import("resource://gre/modules/osfile.jsm");
+Cu.import("resource://services-common/utils.js");
+Cu.import("resource://gre/modules/FxAccountsCommon.js");
+
+// Use a backstage pass to get at our LoginManagerStorage object, so we can
+// mock the prototype.
+var {LoginManagerStorage} = Cu.import("resource://gre/modules/FxAccountsStorage.jsm", {});
+var isLoggedIn = true;
+LoginManagerStorage.prototype.__defineGetter__("_isLoggedIn", () => isLoggedIn);
+
+function setLoginMgrLoggedInState(loggedIn) {
+ isLoggedIn = loggedIn;
+}
+
+
+initTestLogging("Trace");
+
+function run_test() {
+ run_next_test();
+}
+
+function getLoginMgrData() {
+ let logins = Services.logins.findLogins({}, FXA_PWDMGR_HOST, null, FXA_PWDMGR_REALM);
+ if (logins.length == 0) {
+ return null;
+ }
+ Assert.equal(logins.length, 1, "only 1 login available");
+ return logins[0];
+}
+
+function createFxAccounts() {
+ return new FxAccounts({
+ _getDeviceName() {
+ return "mock device name";
+ },
+ fxaPushService: {
+ registerPushEndpoint() {
+ return new Promise((resolve) => {
+ resolve({
+ endpoint: "http://mochi.test:8888"
+ });
+ });
+ },
+ }
+ });
+}
+
+add_task(function* test_simple() {
+ let fxa = createFxAccounts();
+
+ let creds = {
+ uid: "abcd",
+ email: "test@example.com",
+ sessionToken: "sessionToken",
+ kA: "the kA value",
+ kB: "the kB value",
+ verified: true
+ };
+ yield fxa.setSignedInUser(creds);
+
+ // This should have stored stuff in both the .json file in the profile
+ // dir, and the login dir.
+ let path = OS.Path.join(OS.Constants.Path.profileDir, "signedInUser.json");
+ let data = yield CommonUtils.readJSON(path);
+
+ Assert.strictEqual(data.accountData.email, creds.email, "correct email in the clear text");
+ Assert.strictEqual(data.accountData.sessionToken, creds.sessionToken, "correct sessionToken in the clear text");
+ Assert.strictEqual(data.accountData.verified, creds.verified, "correct verified flag");
+
+ Assert.ok(!("kA" in data.accountData), "kA not stored in clear text");
+ Assert.ok(!("kB" in data.accountData), "kB not stored in clear text");
+
+ let login = getLoginMgrData();
+ Assert.strictEqual(login.username, creds.uid, "uid used for username");
+ let loginData = JSON.parse(login.password);
+ Assert.strictEqual(loginData.version, data.version, "same version flag in both places");
+ Assert.strictEqual(loginData.accountData.kA, creds.kA, "correct kA in the login mgr");
+ Assert.strictEqual(loginData.accountData.kB, creds.kB, "correct kB in the login mgr");
+
+ Assert.ok(!("email" in loginData), "email not stored in the login mgr json");
+ Assert.ok(!("sessionToken" in loginData), "sessionToken not stored in the login mgr json");
+ Assert.ok(!("verified" in loginData), "verified not stored in the login mgr json");
+
+ yield fxa.signOut(/* localOnly = */ true);
+ Assert.strictEqual(getLoginMgrData(), null, "login mgr data deleted on logout");
+});
+
+add_task(function* test_MPLocked() {
+ let fxa = createFxAccounts();
+
+ let creds = {
+ uid: "abcd",
+ email: "test@example.com",
+ sessionToken: "sessionToken",
+ kA: "the kA value",
+ kB: "the kB value",
+ verified: true
+ };
+
+ Assert.strictEqual(getLoginMgrData(), null, "no login mgr at the start");
+ // tell the storage that the MP is locked.
+ setLoginMgrLoggedInState(false);
+ yield fxa.setSignedInUser(creds);
+
+ // This should have stored stuff in the .json, and the login manager stuff
+ // will not exist.
+ let path = OS.Path.join(OS.Constants.Path.profileDir, "signedInUser.json");
+ let data = yield CommonUtils.readJSON(path);
+
+ Assert.strictEqual(data.accountData.email, creds.email, "correct email in the clear text");
+ Assert.strictEqual(data.accountData.sessionToken, creds.sessionToken, "correct sessionToken in the clear text");
+ Assert.strictEqual(data.accountData.verified, creds.verified, "correct verified flag");
+
+ Assert.ok(!("kA" in data.accountData), "kA not stored in clear text");
+ Assert.ok(!("kB" in data.accountData), "kB not stored in clear text");
+
+ Assert.strictEqual(getLoginMgrData(), null, "login mgr data doesn't exist");
+ yield fxa.signOut(/* localOnly = */ true)
+});
+
+
+add_task(function* test_consistentWithMPEdgeCases() {
+ setLoginMgrLoggedInState(true);
+
+ let fxa = createFxAccounts();
+
+ let creds1 = {
+ uid: "uid1",
+ email: "test@example.com",
+ sessionToken: "sessionToken",
+ kA: "the kA value",
+ kB: "the kB value",
+ verified: true
+ };
+
+ let creds2 = {
+ uid: "uid2",
+ email: "test2@example.com",
+ sessionToken: "sessionToken2",
+ kA: "the kA value2",
+ kB: "the kB value2",
+ verified: false,
+ };
+
+ // Log a user in while MP is unlocked.
+ yield fxa.setSignedInUser(creds1);
+
+ // tell the storage that the MP is locked - this will prevent logout from
+ // being able to clear the data.
+ setLoginMgrLoggedInState(false);
+
+ // now set the second credentials.
+ yield fxa.setSignedInUser(creds2);
+
+ // We should still have creds1 data in the login manager.
+ let login = getLoginMgrData();
+ Assert.strictEqual(login.username, creds1.uid);
+ // and that we do have the first kA in the login manager.
+ Assert.strictEqual(JSON.parse(login.password).accountData.kA, creds1.kA,
+ "stale data still in login mgr");
+
+ // Make a new FxA instance (otherwise the values in memory will be used)
+ // and we want the login manager to be unlocked.
+ setLoginMgrLoggedInState(true);
+ fxa = createFxAccounts();
+
+ let accountData = yield fxa.getSignedInUser();
+ Assert.strictEqual(accountData.email, creds2.email);
+ // we should have no kA at all.
+ Assert.strictEqual(accountData.kA, undefined, "stale kA wasn't used");
+ yield fxa.signOut(/* localOnly = */ true)
+});
+
+// A test for the fact we will accept either a UID or email when looking in
+// the login manager.
+add_task(function* test_uidMigration() {
+ setLoginMgrLoggedInState(true);
+ Assert.strictEqual(getLoginMgrData(), null, "expect no logins at the start");
+
+ // create the login entry using email as a key.
+ let contents = {kA: "kA"};
+
+ let loginInfo = new Components.Constructor(
+ "@mozilla.org/login-manager/loginInfo;1", Ci.nsILoginInfo, "init");
+ let login = new loginInfo(FXA_PWDMGR_HOST,
+ null, // aFormSubmitURL,
+ FXA_PWDMGR_REALM, // aHttpRealm,
+ "foo@bar.com", // aUsername
+ JSON.stringify(contents), // aPassword
+ "", // aUsernameField
+ "");// aPasswordField
+ Services.logins.addLogin(login);
+
+ // ensure we read it.
+ let storage = new LoginManagerStorage();
+ let got = yield storage.get("uid", "foo@bar.com");
+ Assert.deepEqual(got, contents);
+});
diff --git a/services/fxaccounts/tests/xpcshell/test_oauth_client.js b/services/fxaccounts/tests/xpcshell/test_oauth_client.js
new file mode 100644
index 000000000..9bcb1b1ab
--- /dev/null
+++ b/services/fxaccounts/tests/xpcshell/test_oauth_client.js
@@ -0,0 +1,55 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+Cu.import("resource://gre/modules/FxAccountsOAuthClient.jsm");
+
+function run_test() {
+ validationHelper(undefined,
+ "Error: Missing 'parameters' configuration option");
+
+ validationHelper({},
+ "Error: Missing 'parameters' configuration option");
+
+ validationHelper({ parameters: {} },
+ "Error: Missing 'parameters.oauth_uri' parameter");
+
+ validationHelper({ parameters: {
+ oauth_uri: "http://oauth.test/v1"
+ }},
+ "Error: Missing 'parameters.client_id' parameter");
+
+ validationHelper({ parameters: {
+ oauth_uri: "http://oauth.test/v1",
+ client_id: "client_id"
+ }},
+ "Error: Missing 'parameters.content_uri' parameter");
+
+ validationHelper({ parameters: {
+ oauth_uri: "http://oauth.test/v1",
+ client_id: "client_id",
+ content_uri: "http://content.test"
+ }},
+ "Error: Missing 'parameters.state' parameter");
+
+ validationHelper({ parameters: {
+ oauth_uri: "http://oauth.test/v1",
+ client_id: "client_id",
+ content_uri: "http://content.test",
+ state: "complete",
+ action: "force_auth"
+ }},
+ "Error: parameters.email is required for action 'force_auth'");
+
+ run_next_test();
+}
+
+function validationHelper(params, expected) {
+ try {
+ new FxAccountsOAuthClient(params);
+ } catch (e) {
+ return do_check_eq(e.toString(), expected);
+ }
+ throw new Error("Validation helper error");
+}
diff --git a/services/fxaccounts/tests/xpcshell/test_oauth_grant_client.js b/services/fxaccounts/tests/xpcshell/test_oauth_grant_client.js
new file mode 100644
index 000000000..244b79a5e
--- /dev/null
+++ b/services/fxaccounts/tests/xpcshell/test_oauth_grant_client.js
@@ -0,0 +1,292 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+Cu.import("resource://gre/modules/FxAccountsCommon.js");
+Cu.import("resource://gre/modules/FxAccountsOAuthGrantClient.jsm");
+Cu.import("resource://gre/modules/Services.jsm");
+
+const CLIENT_OPTIONS = {
+ serverURL: "http://127.0.0.1:9010/v1",
+ client_id: 'abc123'
+};
+
+const STATUS_SUCCESS = 200;
+
+/**
+ * Mock request responder
+ * @param {String} response
+ * Mocked raw response from the server
+ * @returns {Function}
+ */
+var mockResponse = function (response) {
+ return function () {
+ return {
+ setHeader: function () {},
+ post: function () {
+ this.response = response;
+ this.onComplete();
+ }
+ };
+ };
+};
+
+/**
+ * Mock request error responder
+ * @param {Error} error
+ * Error object
+ * @returns {Function}
+ */
+var mockResponseError = function (error) {
+ return function () {
+ return {
+ setHeader: function () {},
+ post: function () {
+ this.onComplete(error);
+ }
+ };
+ };
+};
+
+add_test(function missingParams () {
+ let client = new FxAccountsOAuthGrantClient(CLIENT_OPTIONS);
+ try {
+ client.getTokenFromAssertion()
+ } catch (e) {
+ do_check_eq(e.message, "Missing 'assertion' parameter");
+ }
+
+ try {
+ client.getTokenFromAssertion("assertion")
+ } catch (e) {
+ do_check_eq(e.message, "Missing 'scope' parameter");
+ }
+
+ run_next_test();
+});
+
+add_test(function successfulResponse () {
+ let client = new FxAccountsOAuthGrantClient(CLIENT_OPTIONS);
+ let response = {
+ success: true,
+ status: STATUS_SUCCESS,
+ body: "{\"access_token\":\"http://example.com/image.jpeg\",\"id\":\"0d5c1a89b8c54580b8e3e8adadae864a\"}",
+ };
+
+ client._Request = new mockResponse(response);
+ client.getTokenFromAssertion("assertion", "scope")
+ .then(
+ function (result) {
+ do_check_eq(result.access_token, "http://example.com/image.jpeg");
+ run_next_test();
+ }
+ );
+});
+
+add_test(function successfulDestroy () {
+ let client = new FxAccountsOAuthGrantClient(CLIENT_OPTIONS);
+ let response = {
+ success: true,
+ status: STATUS_SUCCESS,
+ body: "{}",
+ };
+
+ client._Request = new mockResponse(response);
+ client.destroyToken("deadbeef").then(run_next_test);
+});
+
+add_test(function parseErrorResponse () {
+ let client = new FxAccountsOAuthGrantClient(CLIENT_OPTIONS);
+ let response = {
+ success: true,
+ status: STATUS_SUCCESS,
+ body: "unexpected",
+ };
+
+ client._Request = new mockResponse(response);
+ client.getTokenFromAssertion("assertion", "scope")
+ .then(
+ null,
+ function (e) {
+ do_check_eq(e.name, "FxAccountsOAuthGrantClientError");
+ do_check_eq(e.code, STATUS_SUCCESS);
+ do_check_eq(e.errno, ERRNO_PARSE);
+ do_check_eq(e.error, ERROR_PARSE);
+ do_check_eq(e.message, "unexpected");
+ run_next_test();
+ }
+ );
+});
+
+add_test(function serverErrorResponse () {
+ let client = new FxAccountsOAuthGrantClient(CLIENT_OPTIONS);
+ let response = {
+ status: 400,
+ body: "{ \"code\": 400, \"errno\": 104, \"error\": \"Bad Request\", \"message\": \"Unauthorized\", \"reason\": \"Invalid fxa assertion\" }",
+ };
+
+ client._Request = new mockResponse(response);
+ client.getTokenFromAssertion("blah", "scope")
+ .then(
+ null,
+ function (e) {
+ do_check_eq(e.name, "FxAccountsOAuthGrantClientError");
+ do_check_eq(e.code, 400);
+ do_check_eq(e.errno, ERRNO_INVALID_FXA_ASSERTION);
+ do_check_eq(e.error, "Bad Request");
+ do_check_eq(e.message, "Unauthorized");
+ run_next_test();
+ }
+ );
+});
+
+add_test(function networkErrorResponse () {
+ let client = new FxAccountsOAuthGrantClient({
+ serverURL: "http://",
+ client_id: "abc123"
+ });
+ Services.prefs.setBoolPref("identity.fxaccounts.skipDeviceRegistration", true);
+ client.getTokenFromAssertion("assertion", "scope")
+ .then(
+ null,
+ function (e) {
+ do_check_eq(e.name, "FxAccountsOAuthGrantClientError");
+ do_check_eq(e.code, null);
+ do_check_eq(e.errno, ERRNO_NETWORK);
+ do_check_eq(e.error, ERROR_NETWORK);
+ run_next_test();
+ }
+ ).catch(() => {}).then(() =>
+ Services.prefs.clearUserPref("identity.fxaccounts.skipDeviceRegistration"));
+});
+
+add_test(function unsupportedMethod () {
+ let client = new FxAccountsOAuthGrantClient(CLIENT_OPTIONS);
+
+ return client._createRequest("/", "PUT")
+ .then(
+ null,
+ function (e) {
+ do_check_eq(e.name, "FxAccountsOAuthGrantClientError");
+ do_check_eq(e.code, ERROR_CODE_METHOD_NOT_ALLOWED);
+ do_check_eq(e.errno, ERRNO_NETWORK);
+ do_check_eq(e.error, ERROR_NETWORK);
+ do_check_eq(e.message, ERROR_MSG_METHOD_NOT_ALLOWED);
+ run_next_test();
+ }
+ );
+});
+
+add_test(function onCompleteRequestError () {
+ let client = new FxAccountsOAuthGrantClient(CLIENT_OPTIONS);
+ client._Request = new mockResponseError(new Error("onComplete error"));
+ client.getTokenFromAssertion("assertion", "scope")
+ .then(
+ null,
+ function (e) {
+ do_check_eq(e.name, "FxAccountsOAuthGrantClientError");
+ do_check_eq(e.code, null);
+ do_check_eq(e.errno, ERRNO_NETWORK);
+ do_check_eq(e.error, ERROR_NETWORK);
+ do_check_eq(e.message, "Error: onComplete error");
+ run_next_test();
+ }
+ );
+});
+
+add_test(function incorrectErrno() {
+ let client = new FxAccountsOAuthGrantClient(CLIENT_OPTIONS);
+ let response = {
+ status: 400,
+ body: "{ \"code\": 400, \"errno\": \"bad errno\", \"error\": \"Bad Request\", \"message\": \"Unauthorized\", \"reason\": \"Invalid fxa assertion\" }",
+ };
+
+ client._Request = new mockResponse(response);
+ client.getTokenFromAssertion("blah", "scope")
+ .then(
+ null,
+ function (e) {
+ do_check_eq(e.name, "FxAccountsOAuthGrantClientError");
+ do_check_eq(e.code, 400);
+ do_check_eq(e.errno, ERRNO_UNKNOWN_ERROR);
+ do_check_eq(e.error, "Bad Request");
+ do_check_eq(e.message, "Unauthorized");
+ run_next_test();
+ }
+ );
+});
+
+add_test(function constructorTests() {
+ validationHelper(undefined,
+ "Error: Missing configuration options");
+
+ validationHelper({},
+ "Error: Missing 'serverURL' parameter");
+
+ validationHelper({ serverURL: "http://example.com" },
+ "Error: Missing 'client_id' parameter");
+
+ validationHelper({ client_id: "123ABC" },
+ "Error: Missing 'serverURL' parameter");
+
+ validationHelper({ client_id: "123ABC", serverURL: "badUrl" },
+ "Error: Invalid 'serverURL'");
+
+ run_next_test();
+});
+
+add_test(function errorTests() {
+ let error1 = new FxAccountsOAuthGrantClientError();
+ do_check_eq(error1.name, "FxAccountsOAuthGrantClientError");
+ do_check_eq(error1.code, null);
+ do_check_eq(error1.errno, ERRNO_UNKNOWN_ERROR);
+ do_check_eq(error1.error, ERROR_UNKNOWN);
+ do_check_eq(error1.message, null);
+
+ let error2 = new FxAccountsOAuthGrantClientError({
+ code: STATUS_SUCCESS,
+ errno: 1,
+ error: "Error",
+ message: "Something",
+ });
+ let fields2 = error2._toStringFields();
+ let statusCode = 1;
+
+ do_check_eq(error2.name, "FxAccountsOAuthGrantClientError");
+ do_check_eq(error2.code, STATUS_SUCCESS);
+ do_check_eq(error2.errno, statusCode);
+ do_check_eq(error2.error, "Error");
+ do_check_eq(error2.message, "Something");
+
+ do_check_eq(fields2.name, "FxAccountsOAuthGrantClientError");
+ do_check_eq(fields2.code, STATUS_SUCCESS);
+ do_check_eq(fields2.errno, statusCode);
+ do_check_eq(fields2.error, "Error");
+ do_check_eq(fields2.message, "Something");
+
+ do_check_true(error2.toString().indexOf("Something") >= 0);
+ run_next_test();
+});
+
+function run_test() {
+ run_next_test();
+}
+
+/**
+ * Quick way to test the "FxAccountsOAuthGrantClient" constructor.
+ *
+ * @param {Object} options
+ * FxAccountsOAuthGrantClient constructor options
+ * @param {String} expected
+ * Expected error message
+ * @returns {*}
+ */
+function validationHelper(options, expected) {
+ try {
+ new FxAccountsOAuthGrantClient(options);
+ } catch (e) {
+ return do_check_eq(e.toString(), expected);
+ }
+ throw new Error("Validation helper error");
+}
diff --git a/services/fxaccounts/tests/xpcshell/test_oauth_grant_client_server.js b/services/fxaccounts/tests/xpcshell/test_oauth_grant_client_server.js
new file mode 100644
index 000000000..bd446513e
--- /dev/null
+++ b/services/fxaccounts/tests/xpcshell/test_oauth_grant_client_server.js
@@ -0,0 +1,73 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// A test of FxAccountsOAuthGrantClient but using a real server it can
+// hit.
+"use strict";
+
+Cu.import("resource://gre/modules/FxAccountsCommon.js");
+Cu.import("resource://gre/modules/FxAccountsOAuthGrantClient.jsm");
+
+// handlers for our server.
+var numTokenFetches;
+var activeTokens;
+
+function authorize(request, response) {
+ response.setStatusLine("1.1", 200, "OK");
+ let token = "token" + numTokenFetches;
+ numTokenFetches += 1;
+ activeTokens.add(token);
+ response.write(JSON.stringify({access_token: token}));
+}
+
+function destroy(request, response) {
+ // Getting the body seems harder than it should be!
+ let sis = Cc["@mozilla.org/scriptableinputstream;1"]
+ .createInstance(Ci.nsIScriptableInputStream);
+ sis.init(request.bodyInputStream);
+ let body = JSON.parse(sis.read(sis.available()));
+ sis.close();
+ let token = body.token;
+ ok(activeTokens.delete(token));
+ print("after destroy have", activeTokens.size, "tokens left.")
+ response.setStatusLine("1.1", 200, "OK");
+ response.write('{}');
+}
+
+function startServer() {
+ numTokenFetches = 0;
+ activeTokens = new Set();
+ let srv = new HttpServer();
+ srv.registerPathHandler("/v1/authorization", authorize);
+ srv.registerPathHandler("/v1/destroy", destroy);
+ srv.start(-1);
+ return srv;
+}
+
+function promiseStopServer(server) {
+ return new Promise(resolve => {
+ server.stop(resolve);
+ });
+}
+
+add_task(function* getAndRevokeToken () {
+ let server = startServer();
+ let clientOptions = {
+ serverURL: "http://localhost:" + server.identity.primaryPort + "/v1",
+ client_id: 'abc123',
+ }
+
+ let client = new FxAccountsOAuthGrantClient(clientOptions);
+ let result = yield client.getTokenFromAssertion("assertion", "scope");
+ equal(result.access_token, "token0");
+ equal(numTokenFetches, 1, "we hit the server to fetch a token");
+ yield client.destroyToken("token0");
+ equal(activeTokens.size, 0, "We hit the server to revoke it");
+ yield promiseStopServer(server);
+});
+
+// XXX - TODO - we should probably add more tests for unexpected responses etc.
+
+function run_test() {
+ run_next_test();
+}
diff --git a/services/fxaccounts/tests/xpcshell/test_oauth_token_storage.js b/services/fxaccounts/tests/xpcshell/test_oauth_token_storage.js
new file mode 100644
index 000000000..08642846b
--- /dev/null
+++ b/services/fxaccounts/tests/xpcshell/test_oauth_token_storage.js
@@ -0,0 +1,165 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+Cu.import("resource://gre/modules/FxAccounts.jsm");
+Cu.import("resource://gre/modules/FxAccountsClient.jsm");
+Cu.import("resource://gre/modules/FxAccountsCommon.js");
+Cu.import("resource://gre/modules/osfile.jsm");
+
+// We grab some additional stuff via backstage passes.
+var {AccountState} = Cu.import("resource://gre/modules/FxAccounts.jsm", {});
+
+function promiseNotification(topic) {
+ return new Promise(resolve => {
+ let observe = () => {
+ Services.obs.removeObserver(observe, topic);
+ resolve();
+ }
+ Services.obs.addObserver(observe, topic, false);
+ });
+}
+
+// A storage manager that doesn't actually write anywhere.
+function MockStorageManager() {
+}
+
+MockStorageManager.prototype = {
+ promiseInitialized: Promise.resolve(),
+
+ initialize(accountData) {
+ this.accountData = accountData;
+ },
+
+ finalize() {
+ return Promise.resolve();
+ },
+
+ getAccountData() {
+ return Promise.resolve(this.accountData);
+ },
+
+ updateAccountData(updatedFields) {
+ for (let [name, value] of Object.entries(updatedFields)) {
+ if (value == null) {
+ delete this.accountData[name];
+ } else {
+ this.accountData[name] = value;
+ }
+ }
+ return Promise.resolve();
+ },
+
+ deleteAccountData() {
+ this.accountData = null;
+ return Promise.resolve();
+ }
+}
+
+
+// Just enough mocks so we can avoid hawk etc.
+function MockFxAccountsClient() {
+ this._email = "nobody@example.com";
+ this._verified = false;
+
+ this.accountStatus = function(uid) {
+ let deferred = Promise.defer();
+ deferred.resolve(!!uid && (!this._deletedOnServer));
+ return deferred.promise;
+ };
+
+ this.signOut = function() { return Promise.resolve(); };
+ this.registerDevice = function() { return Promise.resolve(); };
+ this.updateDevice = function() { return Promise.resolve(); };
+ this.signOutAndDestroyDevice = function() { return Promise.resolve(); };
+ this.getDeviceList = function() { return Promise.resolve(); };
+
+ FxAccountsClient.apply(this);
+}
+
+MockFxAccountsClient.prototype = {
+ __proto__: FxAccountsClient.prototype
+}
+
+function MockFxAccounts(device={}) {
+ return new FxAccounts({
+ fxAccountsClient: new MockFxAccountsClient(),
+ newAccountState(credentials) {
+ // we use a real accountState but mocked storage.
+ let storage = new MockStorageManager();
+ storage.initialize(credentials);
+ return new AccountState(storage);
+ },
+ _getDeviceName() {
+ return "mock device name";
+ },
+ fxaPushService: {
+ registerPushEndpoint() {
+ return new Promise((resolve) => {
+ resolve({
+ endpoint: "http://mochi.test:8888"
+ });
+ });
+ },
+ },
+ });
+}
+
+function* createMockFxA() {
+ let fxa = new MockFxAccounts();
+ let credentials = {
+ email: "foo@example.com",
+ uid: "1234@lcip.org",
+ assertion: "foobar",
+ sessionToken: "dead",
+ kA: "beef",
+ kB: "cafe",
+ verified: true
+ };
+ yield fxa.setSignedInUser(credentials);
+ return fxa;
+}
+
+// The tests.
+function run_test() {
+ run_next_test();
+}
+
+add_task(function* testCacheStorage() {
+ let fxa = yield createMockFxA();
+
+ // Hook what the impl calls to save to disk.
+ let cas = fxa.internal.currentAccountState;
+ let origPersistCached = cas._persistCachedTokens.bind(cas)
+ cas._persistCachedTokens = function() {
+ return origPersistCached().then(() => {
+ Services.obs.notifyObservers(null, "testhelper-fxa-cache-persist-done", null);
+ });
+ };
+
+ let promiseWritten = promiseNotification("testhelper-fxa-cache-persist-done");
+ let tokenData = {token: "token1", somethingelse: "something else"};
+ let scopeArray = ["foo", "bar"];
+ cas.setCachedToken(scopeArray, tokenData);
+ deepEqual(cas.getCachedToken(scopeArray), tokenData);
+
+ deepEqual(cas.oauthTokens, {"bar|foo": tokenData});
+ // wait for background write to complete.
+ yield promiseWritten;
+
+ // Check the token cache made it to our mocked storage.
+ deepEqual(cas.storageManager.accountData.oauthTokens, {"bar|foo": tokenData});
+
+ // Drop the token from the cache and ensure it is removed from the json.
+ promiseWritten = promiseNotification("testhelper-fxa-cache-persist-done");
+ yield cas.removeCachedToken("token1");
+ deepEqual(cas.oauthTokens, {});
+ yield promiseWritten;
+ deepEqual(cas.storageManager.accountData.oauthTokens, {});
+
+ // sign out and the token storage should end up with null.
+ let storageManager = cas.storageManager; // .signOut() removes the attribute.
+ yield fxa.signOut( /* localOnly = */ true);
+ deepEqual(storageManager.accountData, null);
+});
diff --git a/services/fxaccounts/tests/xpcshell/test_oauth_tokens.js b/services/fxaccounts/tests/xpcshell/test_oauth_tokens.js
new file mode 100644
index 000000000..f758bf405
--- /dev/null
+++ b/services/fxaccounts/tests/xpcshell/test_oauth_tokens.js
@@ -0,0 +1,251 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+Cu.import("resource://gre/modules/FxAccounts.jsm");
+Cu.import("resource://gre/modules/FxAccountsClient.jsm");
+Cu.import("resource://gre/modules/FxAccountsCommon.js");
+Cu.import("resource://gre/modules/FxAccountsOAuthGrantClient.jsm");
+Cu.import("resource://services-common/utils.js");
+var {AccountState} = Cu.import("resource://gre/modules/FxAccounts.jsm", {});
+
+function promiseNotification(topic) {
+ return new Promise(resolve => {
+ let observe = () => {
+ Services.obs.removeObserver(observe, topic);
+ resolve();
+ }
+ Services.obs.addObserver(observe, topic, false);
+ });
+}
+
+// Just enough mocks so we can avoid hawk and storage.
+function MockStorageManager() {
+}
+
+MockStorageManager.prototype = {
+ promiseInitialized: Promise.resolve(),
+
+ initialize(accountData) {
+ this.accountData = accountData;
+ },
+
+ finalize() {
+ return Promise.resolve();
+ },
+
+ getAccountData() {
+ return Promise.resolve(this.accountData);
+ },
+
+ updateAccountData(updatedFields) {
+ for (let [name, value] of Object.entries(updatedFields)) {
+ if (value == null) {
+ delete this.accountData[name];
+ } else {
+ this.accountData[name] = value;
+ }
+ }
+ return Promise.resolve();
+ },
+
+ deleteAccountData() {
+ this.accountData = null;
+ return Promise.resolve();
+ }
+}
+
+function MockFxAccountsClient() {
+ this._email = "nobody@example.com";
+ this._verified = false;
+
+ this.accountStatus = function(uid) {
+ let deferred = Promise.defer();
+ deferred.resolve(!!uid && (!this._deletedOnServer));
+ return deferred.promise;
+ };
+
+ this.signOut = function() { return Promise.resolve(); };
+ this.registerDevice = function() { return Promise.resolve(); };
+ this.updateDevice = function() { return Promise.resolve(); };
+ this.signOutAndDestroyDevice = function() { return Promise.resolve(); };
+ this.getDeviceList = function() { return Promise.resolve(); };
+
+ FxAccountsClient.apply(this);
+}
+
+MockFxAccountsClient.prototype = {
+ __proto__: FxAccountsClient.prototype
+}
+
+function MockFxAccounts(mockGrantClient) {
+ return new FxAccounts({
+ fxAccountsClient: new MockFxAccountsClient(),
+ getAssertion: () => Promise.resolve("assertion"),
+ newAccountState(credentials) {
+ // we use a real accountState but mocked storage.
+ let storage = new MockStorageManager();
+ storage.initialize(credentials);
+ return new AccountState(storage);
+ },
+ _destroyOAuthToken: function(tokenData) {
+ // somewhat sad duplication of _destroyOAuthToken, but hard to avoid.
+ return mockGrantClient.destroyToken(tokenData.token).then( () => {
+ Services.obs.notifyObservers(null, "testhelper-fxa-revoke-complete", null);
+ });
+ },
+ _getDeviceName() {
+ return "mock device name";
+ },
+ fxaPushService: {
+ registerPushEndpoint() {
+ return new Promise((resolve) => {
+ resolve({
+ endpoint: "http://mochi.test:8888"
+ });
+ });
+ },
+ },
+ });
+}
+
+function* createMockFxA(mockGrantClient) {
+ let fxa = new MockFxAccounts(mockGrantClient);
+ let credentials = {
+ email: "foo@example.com",
+ uid: "1234@lcip.org",
+ assertion: "foobar",
+ sessionToken: "dead",
+ kA: "beef",
+ kB: "cafe",
+ verified: true
+ };
+
+ yield fxa.setSignedInUser(credentials);
+ return fxa;
+}
+
+// The tests.
+function run_test() {
+ run_next_test();
+}
+
+function MockFxAccountsOAuthGrantClient() {
+ this.activeTokens = new Set();
+}
+
+MockFxAccountsOAuthGrantClient.prototype = {
+ serverURL: {href: "http://localhost"},
+ getTokenFromAssertion(assertion, scope) {
+ let token = "token" + this.numTokenFetches;
+ this.numTokenFetches += 1;
+ this.activeTokens.add(token);
+ print("getTokenFromAssertion returning token", token);
+ return Promise.resolve({access_token: token});
+ },
+ destroyToken(token) {
+ ok(this.activeTokens.delete(token));
+ print("after destroy have", this.activeTokens.size, "tokens left.");
+ return Promise.resolve({});
+ },
+ // and some stuff used only for tests.
+ numTokenFetches: 0,
+ activeTokens: null,
+}
+
+add_task(function* testRevoke() {
+ let client = new MockFxAccountsOAuthGrantClient();
+ let tokenOptions = { scope: "test-scope", client: client };
+ let fxa = yield createMockFxA(client);
+
+ // get our first token and check we hit the mock.
+ let token1 = yield fxa.getOAuthToken(tokenOptions);
+ equal(client.numTokenFetches, 1);
+ equal(client.activeTokens.size, 1);
+ ok(token1, "got a token");
+ equal(token1, "token0");
+
+ // drop the new token from our cache.
+ yield fxa.removeCachedOAuthToken({token: token1});
+
+ // FxA fires an observer when the "background" revoke is complete.
+ yield promiseNotification("testhelper-fxa-revoke-complete");
+ // the revoke should have been successful.
+ equal(client.activeTokens.size, 0);
+ // fetching it again hits the server.
+ let token2 = yield fxa.getOAuthToken(tokenOptions);
+ equal(client.numTokenFetches, 2);
+ equal(client.activeTokens.size, 1);
+ ok(token2, "got a token");
+ notEqual(token1, token2, "got a different token");
+});
+
+add_task(function* testSignOutDestroysTokens() {
+ let client = new MockFxAccountsOAuthGrantClient();
+ let fxa = yield createMockFxA(client);
+
+ // get our first token and check we hit the mock.
+ let token1 = yield fxa.getOAuthToken({ scope: "test-scope", client: client });
+ equal(client.numTokenFetches, 1);
+ equal(client.activeTokens.size, 1);
+ ok(token1, "got a token");
+
+ // get another
+ let token2 = yield fxa.getOAuthToken({ scope: "test-scope-2", client: client });
+ equal(client.numTokenFetches, 2);
+ equal(client.activeTokens.size, 2);
+ ok(token2, "got a token");
+ notEqual(token1, token2, "got a different token");
+
+ // now sign out - they should be removed.
+ yield fxa.signOut();
+ // FxA fires an observer when the "background" signout is complete.
+ yield promiseNotification("testhelper-fxa-signout-complete");
+ // No active tokens left.
+ equal(client.activeTokens.size, 0);
+});
+
+add_task(function* testTokenRaces() {
+ // Here we do 2 concurrent fetches each for 2 different token scopes (ie,
+ // 4 token fetches in total).
+ // This should provoke a potential race in the token fetching but we should
+ // handle and detect that leaving us with one of the fetch tokens being
+ // revoked and the same token value returned to both calls.
+ let client = new MockFxAccountsOAuthGrantClient();
+ let fxa = yield createMockFxA(client);
+
+ // We should see 2 notifications as part of this - set up the listeners
+ // now (and wait on them later)
+ let notifications = Promise.all([
+ promiseNotification("testhelper-fxa-revoke-complete"),
+ promiseNotification("testhelper-fxa-revoke-complete"),
+ ]);
+ let results = yield Promise.all([
+ fxa.getOAuthToken({scope: "test-scope", client: client}),
+ fxa.getOAuthToken({scope: "test-scope", client: client}),
+ fxa.getOAuthToken({scope: "test-scope-2", client: client}),
+ fxa.getOAuthToken({scope: "test-scope-2", client: client}),
+ ]);
+
+ equal(client.numTokenFetches, 4, "should have fetched 4 tokens.");
+ // We should see 2 of the 4 revoked due to the race.
+ yield notifications;
+
+ // Should have 2 unique tokens
+ results.sort();
+ equal(results[0], results[1]);
+ equal(results[2], results[3]);
+ // should be 2 active.
+ equal(client.activeTokens.size, 2);
+ // Which can each be revoked.
+ notifications = Promise.all([
+ promiseNotification("testhelper-fxa-revoke-complete"),
+ promiseNotification("testhelper-fxa-revoke-complete"),
+ ]);
+ yield fxa.removeCachedOAuthToken({token: results[0]});
+ equal(client.activeTokens.size, 1);
+ yield fxa.removeCachedOAuthToken({token: results[2]});
+ equal(client.activeTokens.size, 0);
+ yield notifications;
+});
diff --git a/services/fxaccounts/tests/xpcshell/test_profile.js b/services/fxaccounts/tests/xpcshell/test_profile.js
new file mode 100644
index 000000000..13adf8cbb
--- /dev/null
+++ b/services/fxaccounts/tests/xpcshell/test_profile.js
@@ -0,0 +1,409 @@
+/* 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/FxAccountsCommon.js");
+Cu.import("resource://gre/modules/FxAccountsProfileClient.jsm");
+Cu.import("resource://gre/modules/FxAccountsProfile.jsm");
+
+const URL_STRING = "https://example.com";
+Services.prefs.setCharPref("identity.fxaccounts.settings.uri", "https://example.com/settings");
+
+const STATUS_SUCCESS = 200;
+
+/**
+ * Mock request responder
+ * @param {String} response
+ * Mocked raw response from the server
+ * @returns {Function}
+ */
+var mockResponse = function (response) {
+ let Request = function (requestUri) {
+ // Store the request uri so tests can inspect it
+ Request._requestUri = requestUri;
+ return {
+ setHeader: function () {},
+ head: function () {
+ this.response = response;
+ this.onComplete();
+ }
+ };
+ };
+
+ return Request;
+};
+
+/**
+ * Mock request error responder
+ * @param {Error} error
+ * Error object
+ * @returns {Function}
+ */
+var mockResponseError = function (error) {
+ return function () {
+ return {
+ setHeader: function () {},
+ head: function () {
+ this.onComplete(error);
+ }
+ };
+ };
+};
+
+var mockClient = function (fxa) {
+ let options = {
+ serverURL: "http://127.0.0.1:1111/v1",
+ fxa: fxa,
+ }
+ return new FxAccountsProfileClient(options);
+};
+
+const ACCOUNT_DATA = {
+ uid: "abc123"
+};
+
+function FxaMock() {
+}
+FxaMock.prototype = {
+ currentAccountState: {
+ profile: null,
+ get isCurrent() {
+ return true;
+ }
+ },
+
+ getSignedInUser: function () {
+ return Promise.resolve(ACCOUNT_DATA);
+ }
+};
+
+var mockFxa = function() {
+ return new FxaMock();
+};
+
+function CreateFxAccountsProfile(fxa = null, client = null) {
+ if (!fxa) {
+ fxa = mockFxa();
+ }
+ let options = {
+ fxa: fxa,
+ profileServerUrl: "http://127.0.0.1:1111/v1"
+ }
+ if (client) {
+ options.profileClient = client;
+ }
+ return new FxAccountsProfile(options);
+}
+
+add_test(function getCachedProfile() {
+ let profile = CreateFxAccountsProfile();
+ // a little pointless until bug 1157529 is fixed...
+ profile._cachedProfile = { avatar: "myurl" };
+
+ return profile._getCachedProfile()
+ .then(function (cached) {
+ do_check_eq(cached.avatar, "myurl");
+ run_next_test();
+ });
+});
+
+add_test(function cacheProfile_change() {
+ let fxa = mockFxa();
+/* Saving profile data disabled - bug 1157529
+ let setUserAccountDataCalled = false;
+ fxa.setUserAccountData = function (data) {
+ setUserAccountDataCalled = true;
+ do_check_eq(data.profile.avatar, "myurl");
+ return Promise.resolve();
+ };
+*/
+ let profile = CreateFxAccountsProfile(fxa);
+
+ makeObserver(ON_PROFILE_CHANGE_NOTIFICATION, function (subject, topic, data) {
+ do_check_eq(data, ACCOUNT_DATA.uid);
+// do_check_true(setUserAccountDataCalled); - bug 1157529
+ run_next_test();
+ });
+
+ return profile._cacheProfile({ avatar: "myurl" });
+});
+
+add_test(function cacheProfile_no_change() {
+ let fxa = mockFxa();
+ let profile = CreateFxAccountsProfile(fxa)
+ profile._cachedProfile = { avatar: "myurl" };
+// XXX - saving is disabled (but we can leave that in for now as we are
+// just checking it is *not* called)
+ fxa.setSignedInUser = function (data) {
+ throw new Error("should not update account data");
+ };
+
+ return profile._cacheProfile({ avatar: "myurl" })
+ .then((result) => {
+ do_check_false(!!result);
+ run_next_test();
+ });
+});
+
+add_test(function fetchAndCacheProfile_ok() {
+ let client = mockClient(mockFxa());
+ client.fetchProfile = function () {
+ return Promise.resolve({ avatar: "myimg"});
+ };
+ let profile = CreateFxAccountsProfile(null, client);
+
+ profile._cacheProfile = function (toCache) {
+ do_check_eq(toCache.avatar, "myimg");
+ return Promise.resolve();
+ };
+
+ return profile._fetchAndCacheProfile()
+ .then(result => {
+ do_check_eq(result.avatar, "myimg");
+ run_next_test();
+ });
+});
+
+// Check that a second profile request when one is already in-flight reuses
+// the in-flight one.
+add_task(function* fetchAndCacheProfileOnce() {
+ // A promise that remains unresolved while we fire off 2 requests for
+ // a profile.
+ let resolveProfile;
+ let promiseProfile = new Promise(resolve => {
+ resolveProfile = resolve;
+ });
+ let numFetches = 0;
+ let client = mockClient(mockFxa());
+ client.fetchProfile = function () {
+ numFetches += 1;
+ return promiseProfile;
+ };
+ let profile = CreateFxAccountsProfile(null, client);
+
+ let request1 = profile._fetchAndCacheProfile();
+ let request2 = profile._fetchAndCacheProfile();
+
+ // should be one request made to fetch the profile (but the promise returned
+ // by it remains unresolved)
+ do_check_eq(numFetches, 1);
+
+ // resolve the promise.
+ resolveProfile({ avatar: "myimg"});
+
+ // both requests should complete with the same data.
+ let got1 = yield request1;
+ do_check_eq(got1.avatar, "myimg");
+ let got2 = yield request1;
+ do_check_eq(got2.avatar, "myimg");
+
+ // and still only 1 request was made.
+ do_check_eq(numFetches, 1);
+});
+
+// Check that sharing a single fetch promise works correctly when the promise
+// is rejected.
+add_task(function* fetchAndCacheProfileOnce() {
+ // A promise that remains unresolved while we fire off 2 requests for
+ // a profile.
+ let rejectProfile;
+ let promiseProfile = new Promise((resolve,reject) => {
+ rejectProfile = reject;
+ });
+ let numFetches = 0;
+ let client = mockClient(mockFxa());
+ client.fetchProfile = function () {
+ numFetches += 1;
+ return promiseProfile;
+ };
+ let profile = CreateFxAccountsProfile(null, client);
+
+ let request1 = profile._fetchAndCacheProfile();
+ let request2 = profile._fetchAndCacheProfile();
+
+ // should be one request made to fetch the profile (but the promise returned
+ // by it remains unresolved)
+ do_check_eq(numFetches, 1);
+
+ // reject the promise.
+ rejectProfile("oh noes");
+
+ // both requests should reject.
+ try {
+ yield request1;
+ throw new Error("should have rejected");
+ } catch (ex) {
+ if (ex != "oh noes") {
+ throw ex;
+ }
+ }
+ try {
+ yield request2;
+ throw new Error("should have rejected");
+ } catch (ex) {
+ if (ex != "oh noes") {
+ throw ex;
+ }
+ }
+
+ // but a new request should work.
+ client.fetchProfile = function () {
+ return Promise.resolve({ avatar: "myimg"});
+ };
+
+ let got = yield profile._fetchAndCacheProfile();
+ do_check_eq(got.avatar, "myimg");
+});
+
+// Check that a new profile request within PROFILE_FRESHNESS_THRESHOLD of the
+// last one doesn't kick off a new request to check the cached copy is fresh.
+add_task(function* fetchAndCacheProfileAfterThreshold() {
+ let numFetches = 0;
+ let client = mockClient(mockFxa());
+ client.fetchProfile = function () {
+ numFetches += 1;
+ return Promise.resolve({ avatar: "myimg"});
+ };
+ let profile = CreateFxAccountsProfile(null, client);
+ profile.PROFILE_FRESHNESS_THRESHOLD = 1000;
+
+ yield profile.getProfile();
+ do_check_eq(numFetches, 1);
+
+ yield profile.getProfile();
+ do_check_eq(numFetches, 1);
+
+ yield new Promise(resolve => {
+ do_timeout(1000, resolve);
+ });
+
+ yield profile.getProfile();
+ do_check_eq(numFetches, 2);
+});
+
+// Check that a new profile request within PROFILE_FRESHNESS_THRESHOLD of the
+// last one *does* kick off a new request if ON_PROFILE_CHANGE_NOTIFICATION
+// is sent.
+add_task(function* fetchAndCacheProfileBeforeThresholdOnNotification() {
+ let numFetches = 0;
+ let client = mockClient(mockFxa());
+ client.fetchProfile = function () {
+ numFetches += 1;
+ return Promise.resolve({ avatar: "myimg"});
+ };
+ let profile = CreateFxAccountsProfile(null, client);
+ profile.PROFILE_FRESHNESS_THRESHOLD = 1000;
+
+ yield profile.getProfile();
+ do_check_eq(numFetches, 1);
+
+ Services.obs.notifyObservers(null, ON_PROFILE_CHANGE_NOTIFICATION, null);
+
+ yield profile.getProfile();
+ do_check_eq(numFetches, 2);
+});
+
+add_test(function tearDown_ok() {
+ let profile = CreateFxAccountsProfile();
+
+ do_check_true(!!profile.client);
+ do_check_true(!!profile.fxa);
+
+ profile.tearDown();
+ do_check_null(profile.fxa);
+ do_check_null(profile.client);
+
+ run_next_test();
+});
+
+add_test(function getProfile_ok() {
+ let cachedUrl = "myurl";
+ let didFetch = false;
+
+ let profile = CreateFxAccountsProfile();
+ profile._getCachedProfile = function () {
+ return Promise.resolve({ avatar: cachedUrl });
+ };
+
+ profile._fetchAndCacheProfile = function () {
+ didFetch = true;
+ return Promise.resolve();
+ };
+
+ return profile.getProfile()
+ .then(result => {
+ do_check_eq(result.avatar, cachedUrl);
+ do_check_true(didFetch);
+ run_next_test();
+ });
+});
+
+add_test(function getProfile_no_cache() {
+ let fetchedUrl = "newUrl";
+ let profile = CreateFxAccountsProfile();
+ profile._getCachedProfile = function () {
+ return Promise.resolve();
+ };
+
+ profile._fetchAndCacheProfile = function () {
+ return Promise.resolve({ avatar: fetchedUrl });
+ };
+
+ return profile.getProfile()
+ .then(result => {
+ do_check_eq(result.avatar, fetchedUrl);
+ run_next_test();
+ });
+});
+
+add_test(function getProfile_has_cached_fetch_deleted() {
+ let cachedUrl = "myurl";
+
+ let fxa = mockFxa();
+ let client = mockClient(fxa);
+ client.fetchProfile = function () {
+ return Promise.resolve({ avatar: null });
+ };
+
+ let profile = CreateFxAccountsProfile(fxa, client);
+ profile._cachedProfile = { avatar: cachedUrl };
+
+// instead of checking this in a mocked "save" function, just check after the
+// observer
+ makeObserver(ON_PROFILE_CHANGE_NOTIFICATION, function (subject, topic, data) {
+ profile.getProfile()
+ .then(profileData => {
+ do_check_null(profileData.avatar);
+ run_next_test();
+ });
+ });
+
+ return profile.getProfile()
+ .then(result => {
+ do_check_eq(result.avatar, "myurl");
+ });
+});
+
+function run_test() {
+ run_next_test();
+}
+
+function makeObserver(aObserveTopic, aObserveFunc) {
+ let callback = function (aSubject, aTopic, aData) {
+ log.debug("observed " + aTopic + " " + aData);
+ if (aTopic == aObserveTopic) {
+ removeMe();
+ aObserveFunc(aSubject, aTopic, aData);
+ }
+ };
+
+ function removeMe() {
+ log.debug("removing observer for " + aObserveTopic);
+ Services.obs.removeObserver(callback, aObserveTopic);
+ }
+
+ Services.obs.addObserver(callback, aObserveTopic, false);
+ return removeMe;
+}
diff --git a/services/fxaccounts/tests/xpcshell/test_profile_client.js b/services/fxaccounts/tests/xpcshell/test_profile_client.js
new file mode 100644
index 000000000..2243da3aa
--- /dev/null
+++ b/services/fxaccounts/tests/xpcshell/test_profile_client.js
@@ -0,0 +1,411 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+Cu.import("resource://gre/modules/FxAccountsCommon.js");
+Cu.import("resource://gre/modules/FxAccountsProfileClient.jsm");
+
+const STATUS_SUCCESS = 200;
+
+/**
+ * Mock request responder
+ * @param {String} response
+ * Mocked raw response from the server
+ * @returns {Function}
+ */
+var mockResponse = function (response) {
+ let Request = function (requestUri) {
+ // Store the request uri so tests can inspect it
+ Request._requestUri = requestUri;
+ return {
+ setHeader: function () {},
+ get: function () {
+ this.response = response;
+ this.onComplete();
+ }
+ };
+ };
+
+ return Request;
+};
+
+// A simple mock FxA that hands out tokens without checking them and doesn't
+// expect tokens to be revoked. We have specific token tests further down that
+// has more checks here.
+var mockFxa = {
+ getOAuthToken(options) {
+ do_check_eq(options.scope, "profile");
+ return "token";
+ }
+}
+
+const PROFILE_OPTIONS = {
+ serverURL: "http://127.0.0.1:1111/v1",
+ fxa: mockFxa,
+};
+
+/**
+ * Mock request error responder
+ * @param {Error} error
+ * Error object
+ * @returns {Function}
+ */
+var mockResponseError = function (error) {
+ return function () {
+ return {
+ setHeader: function () {},
+ get: function () {
+ this.onComplete(error);
+ }
+ };
+ };
+};
+
+add_test(function successfulResponse () {
+ let client = new FxAccountsProfileClient(PROFILE_OPTIONS);
+ let response = {
+ success: true,
+ status: STATUS_SUCCESS,
+ body: "{\"email\":\"someone@restmail.net\",\"uid\":\"0d5c1a89b8c54580b8e3e8adadae864a\"}",
+ };
+
+ client._Request = new mockResponse(response);
+ client.fetchProfile()
+ .then(
+ function (result) {
+ do_check_eq(client._Request._requestUri, "http://127.0.0.1:1111/v1/profile");
+ do_check_eq(result.email, "someone@restmail.net");
+ do_check_eq(result.uid, "0d5c1a89b8c54580b8e3e8adadae864a");
+ run_next_test();
+ }
+ );
+});
+
+add_test(function parseErrorResponse () {
+ let client = new FxAccountsProfileClient(PROFILE_OPTIONS);
+ let response = {
+ success: true,
+ status: STATUS_SUCCESS,
+ body: "unexpected",
+ };
+
+ client._Request = new mockResponse(response);
+ client.fetchProfile()
+ .then(
+ null,
+ function (e) {
+ do_check_eq(e.name, "FxAccountsProfileClientError");
+ do_check_eq(e.code, STATUS_SUCCESS);
+ do_check_eq(e.errno, ERRNO_PARSE);
+ do_check_eq(e.error, ERROR_PARSE);
+ do_check_eq(e.message, "unexpected");
+ run_next_test();
+ }
+ );
+});
+
+add_test(function serverErrorResponse () {
+ let client = new FxAccountsProfileClient(PROFILE_OPTIONS);
+ let response = {
+ status: 500,
+ body: "{ \"code\": 500, \"errno\": 100, \"error\": \"Bad Request\", \"message\": \"Something went wrong\", \"reason\": \"Because the internet\" }",
+ };
+
+ client._Request = new mockResponse(response);
+ client.fetchProfile()
+ .then(
+ null,
+ function (e) {
+ do_check_eq(e.name, "FxAccountsProfileClientError");
+ do_check_eq(e.code, 500);
+ do_check_eq(e.errno, 100);
+ do_check_eq(e.error, "Bad Request");
+ do_check_eq(e.message, "Something went wrong");
+ run_next_test();
+ }
+ );
+});
+
+// Test that we get a token, then if we get a 401 we revoke it, get a new one
+// and retry.
+add_test(function server401ResponseThenSuccess () {
+ // The last token we handed out.
+ let lastToken = -1;
+ // The number of times our removeCachedOAuthToken function was called.
+ let numTokensRemoved = 0;
+
+ let mockFxa = {
+ getOAuthToken(options) {
+ do_check_eq(options.scope, "profile");
+ return "" + ++lastToken; // tokens are strings.
+ },
+ removeCachedOAuthToken(options) {
+ // This test never has more than 1 token alive at once, so the token
+ // being revoked must always be the last token we handed out.
+ do_check_eq(parseInt(options.token), lastToken);
+ ++numTokensRemoved;
+ }
+ }
+ let profileOptions = {
+ serverURL: "http://127.0.0.1:1111/v1",
+ fxa: mockFxa,
+ };
+ let client = new FxAccountsProfileClient(profileOptions);
+
+ // 2 responses - first one implying the token has expired, second works.
+ let responses = [
+ {
+ status: 401,
+ body: "{ \"code\": 401, \"errno\": 100, \"error\": \"Token expired\", \"message\": \"That token is too old\", \"reason\": \"Because security\" }",
+ },
+ {
+ success: true,
+ status: STATUS_SUCCESS,
+ body: "{\"avatar\":\"http://example.com/image.jpg\",\"id\":\"0d5c1a89b8c54580b8e3e8adadae864a\"}",
+ },
+ ];
+
+ let numRequests = 0;
+ let numAuthHeaders = 0;
+ // Like mockResponse but we want access to headers etc.
+ client._Request = function(requestUri) {
+ return {
+ setHeader: function (name, value) {
+ if (name == "Authorization") {
+ numAuthHeaders++;
+ do_check_eq(value, "Bearer " + lastToken);
+ }
+ },
+ get: function () {
+ this.response = responses[numRequests];
+ ++numRequests;
+ this.onComplete();
+ }
+ };
+ }
+
+ client.fetchProfile()
+ .then(result => {
+ do_check_eq(result.avatar, "http://example.com/image.jpg");
+ do_check_eq(result.id, "0d5c1a89b8c54580b8e3e8adadae864a");
+ // should have been exactly 2 requests and exactly 2 auth headers.
+ do_check_eq(numRequests, 2);
+ do_check_eq(numAuthHeaders, 2);
+ // and we should have seen one token revoked.
+ do_check_eq(numTokensRemoved, 1);
+
+ run_next_test();
+ }
+ );
+});
+
+// Test that we get a token, then if we get a 401 we revoke it, get a new one
+// and retry - but we *still* get a 401 on the retry, so the caller sees that.
+add_test(function server401ResponsePersists () {
+ // The last token we handed out.
+ let lastToken = -1;
+ // The number of times our removeCachedOAuthToken function was called.
+ let numTokensRemoved = 0;
+
+ let mockFxa = {
+ getOAuthToken(options) {
+ do_check_eq(options.scope, "profile");
+ return "" + ++lastToken; // tokens are strings.
+ },
+ removeCachedOAuthToken(options) {
+ // This test never has more than 1 token alive at once, so the token
+ // being revoked must always be the last token we handed out.
+ do_check_eq(parseInt(options.token), lastToken);
+ ++numTokensRemoved;
+ }
+ }
+ let profileOptions = {
+ serverURL: "http://127.0.0.1:1111/v1",
+ fxa: mockFxa,
+ };
+ let client = new FxAccountsProfileClient(profileOptions);
+
+ let response = {
+ status: 401,
+ body: "{ \"code\": 401, \"errno\": 100, \"error\": \"It's not your token, it's you!\", \"message\": \"I don't like you\", \"reason\": \"Because security\" }",
+ };
+
+ let numRequests = 0;
+ let numAuthHeaders = 0;
+ client._Request = function(requestUri) {
+ return {
+ setHeader: function (name, value) {
+ if (name == "Authorization") {
+ numAuthHeaders++;
+ do_check_eq(value, "Bearer " + lastToken);
+ }
+ },
+ get: function () {
+ this.response = response;
+ ++numRequests;
+ this.onComplete();
+ }
+ };
+ }
+
+ client.fetchProfile().then(
+ null,
+ function (e) {
+ do_check_eq(e.name, "FxAccountsProfileClientError");
+ do_check_eq(e.code, 401);
+ do_check_eq(e.errno, 100);
+ do_check_eq(e.error, "It's not your token, it's you!");
+ // should have been exactly 2 requests and exactly 2 auth headers.
+ do_check_eq(numRequests, 2);
+ do_check_eq(numAuthHeaders, 2);
+ // and we should have seen both tokens revoked.
+ do_check_eq(numTokensRemoved, 2);
+ run_next_test();
+ }
+ );
+});
+
+add_test(function networkErrorResponse () {
+ let client = new FxAccountsProfileClient({
+ serverURL: "http://",
+ fxa: mockFxa,
+ });
+ client.fetchProfile()
+ .then(
+ null,
+ function (e) {
+ do_check_eq(e.name, "FxAccountsProfileClientError");
+ do_check_eq(e.code, null);
+ do_check_eq(e.errno, ERRNO_NETWORK);
+ do_check_eq(e.error, ERROR_NETWORK);
+ run_next_test();
+ }
+ );
+});
+
+add_test(function unsupportedMethod () {
+ let client = new FxAccountsProfileClient(PROFILE_OPTIONS);
+
+ return client._createRequest("/profile", "PUT")
+ .then(
+ null,
+ function (e) {
+ do_check_eq(e.name, "FxAccountsProfileClientError");
+ do_check_eq(e.code, ERROR_CODE_METHOD_NOT_ALLOWED);
+ do_check_eq(e.errno, ERRNO_NETWORK);
+ do_check_eq(e.error, ERROR_NETWORK);
+ do_check_eq(e.message, ERROR_MSG_METHOD_NOT_ALLOWED);
+ run_next_test();
+ }
+ );
+});
+
+add_test(function onCompleteRequestError () {
+ let client = new FxAccountsProfileClient(PROFILE_OPTIONS);
+ client._Request = new mockResponseError(new Error("onComplete error"));
+ client.fetchProfile()
+ .then(
+ null,
+ function (e) {
+ do_check_eq(e.name, "FxAccountsProfileClientError");
+ do_check_eq(e.code, null);
+ do_check_eq(e.errno, ERRNO_NETWORK);
+ do_check_eq(e.error, ERROR_NETWORK);
+ do_check_eq(e.message, "Error: onComplete error");
+ run_next_test();
+ }
+ );
+});
+
+add_test(function fetchProfileImage_successfulResponse () {
+ let client = new FxAccountsProfileClient(PROFILE_OPTIONS);
+ let response = {
+ success: true,
+ status: STATUS_SUCCESS,
+ body: "{\"avatar\":\"http://example.com/image.jpg\",\"id\":\"0d5c1a89b8c54580b8e3e8adadae864a\"}",
+ };
+
+ client._Request = new mockResponse(response);
+ client.fetchProfileImage()
+ .then(
+ function (result) {
+ do_check_eq(client._Request._requestUri, "http://127.0.0.1:1111/v1/avatar");
+ do_check_eq(result.avatar, "http://example.com/image.jpg");
+ do_check_eq(result.id, "0d5c1a89b8c54580b8e3e8adadae864a");
+ run_next_test();
+ }
+ );
+});
+
+add_test(function constructorTests() {
+ validationHelper(undefined,
+ "Error: Missing 'serverURL' configuration option");
+
+ validationHelper({},
+ "Error: Missing 'serverURL' configuration option");
+
+ validationHelper({ serverURL: "badUrl" },
+ "Error: Invalid 'serverURL'");
+
+ run_next_test();
+});
+
+add_test(function errorTests() {
+ let error1 = new FxAccountsProfileClientError();
+ do_check_eq(error1.name, "FxAccountsProfileClientError");
+ do_check_eq(error1.code, null);
+ do_check_eq(error1.errno, ERRNO_UNKNOWN_ERROR);
+ do_check_eq(error1.error, ERROR_UNKNOWN);
+ do_check_eq(error1.message, null);
+
+ let error2 = new FxAccountsProfileClientError({
+ code: STATUS_SUCCESS,
+ errno: 1,
+ error: "Error",
+ message: "Something",
+ });
+ let fields2 = error2._toStringFields();
+ let statusCode = 1;
+
+ do_check_eq(error2.name, "FxAccountsProfileClientError");
+ do_check_eq(error2.code, STATUS_SUCCESS);
+ do_check_eq(error2.errno, statusCode);
+ do_check_eq(error2.error, "Error");
+ do_check_eq(error2.message, "Something");
+
+ do_check_eq(fields2.name, "FxAccountsProfileClientError");
+ do_check_eq(fields2.code, STATUS_SUCCESS);
+ do_check_eq(fields2.errno, statusCode);
+ do_check_eq(fields2.error, "Error");
+ do_check_eq(fields2.message, "Something");
+
+ do_check_true(error2.toString().indexOf("Something") >= 0);
+ run_next_test();
+});
+
+function run_test() {
+ run_next_test();
+}
+
+/**
+ * Quick way to test the "FxAccountsProfileClient" constructor.
+ *
+ * @param {Object} options
+ * FxAccountsProfileClient constructor options
+ * @param {String} expected
+ * Expected error message
+ * @returns {*}
+ */
+function validationHelper(options, expected) {
+ // add fxa to options - that missing isn't what we are testing here.
+ if (options) {
+ options.fxa = mockFxa;
+ }
+ try {
+ new FxAccountsProfileClient(options);
+ } catch (e) {
+ return do_check_eq(e.toString(), expected);
+ }
+ throw new Error("Validation helper error");
+}
diff --git a/services/fxaccounts/tests/xpcshell/test_push_service.js b/services/fxaccounts/tests/xpcshell/test_push_service.js
new file mode 100644
index 000000000..8d66f6fa8
--- /dev/null
+++ b/services/fxaccounts/tests/xpcshell/test_push_service.js
@@ -0,0 +1,236 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Tests for the FxA push service.
+
+Cu.import("resource://gre/modules/Task.jsm");
+Cu.import("resource://gre/modules/Services.jsm");
+Cu.import("resource://gre/modules/Promise.jsm");
+Cu.import("resource://gre/modules/FxAccountsCommon.js");
+Cu.import("resource://gre/modules/FxAccountsPush.js");
+Cu.import("resource://gre/modules/Log.jsm");
+
+XPCOMUtils.defineLazyServiceGetter(this, "pushService",
+ "@mozilla.org/push/Service;1", "nsIPushService");
+
+initTestLogging("Trace");
+log.level = Log.Level.Trace;
+
+const MOCK_ENDPOINT = "http://mochi.test:8888";
+
+// tests do not allow external connections, mock the PushService
+let mockPushService = {
+ pushTopic: this.pushService.pushTopic,
+ subscriptionChangeTopic: this.pushService.subscriptionChangeTopic,
+ subscribe(scope, principal, cb) {
+ cb(Components.results.NS_OK, {
+ endpoint: MOCK_ENDPOINT
+ });
+ },
+ unsubscribe(scope, principal, cb) {
+ cb(Components.results.NS_OK, true);
+ }
+};
+
+let mockFxAccounts = {
+ checkVerificationStatus() {},
+ updateDeviceRegistration() {}
+};
+
+let mockLog = {
+ trace() {},
+ debug() {},
+ warn() {},
+ error() {}
+};
+
+
+add_task(function* initialize() {
+ let pushService = new FxAccountsPushService();
+ equal(pushService.initialize(), false);
+});
+
+add_task(function* registerPushEndpointSuccess() {
+ let pushService = new FxAccountsPushService({
+ pushService: mockPushService,
+ fxAccounts: mockFxAccounts,
+ });
+
+ let subscription = yield pushService.registerPushEndpoint();
+ equal(subscription.endpoint, MOCK_ENDPOINT);
+});
+
+add_task(function* registerPushEndpointFailure() {
+ let failPushService = Object.assign(mockPushService, {
+ subscribe(scope, principal, cb) {
+ cb(Components.results.NS_ERROR_ABORT);
+ }
+ });
+
+ let pushService = new FxAccountsPushService({
+ pushService: failPushService,
+ fxAccounts: mockFxAccounts,
+ });
+
+ let subscription = yield pushService.registerPushEndpoint();
+ equal(subscription, null);
+});
+
+add_task(function* unsubscribeSuccess() {
+ let pushService = new FxAccountsPushService({
+ pushService: mockPushService,
+ fxAccounts: mockFxAccounts,
+ });
+
+ let result = yield pushService.unsubscribe();
+ equal(result, true);
+});
+
+add_task(function* unsubscribeFailure() {
+ let failPushService = Object.assign(mockPushService, {
+ unsubscribe(scope, principal, cb) {
+ cb(Components.results.NS_ERROR_ABORT);
+ }
+ });
+
+ let pushService = new FxAccountsPushService({
+ pushService: failPushService,
+ fxAccounts: mockFxAccounts,
+ });
+
+ let result = yield pushService.unsubscribe();
+ equal(result, null);
+});
+
+add_test(function observeLogout() {
+ let customLog = Object.assign(mockLog, {
+ trace: function (msg) {
+ if (msg === "FxAccountsPushService unsubscribe") {
+ // logout means we unsubscribe
+ run_next_test();
+ }
+ }
+ });
+
+ let pushService = new FxAccountsPushService({
+ pushService: mockPushService,
+ log: customLog
+ });
+
+ pushService.observe(null, ONLOGOUT_NOTIFICATION);
+});
+
+add_test(function observePushTopicVerify() {
+ let emptyMsg = {
+ QueryInterface: function() {
+ return this;
+ }
+ };
+ let customAccounts = Object.assign(mockFxAccounts, {
+ checkVerificationStatus: function () {
+ // checking verification status on push messages without data
+ run_next_test();
+ }
+ });
+
+ let pushService = new FxAccountsPushService({
+ pushService: mockPushService,
+ fxAccounts: customAccounts,
+ });
+
+ pushService.observe(emptyMsg, mockPushService.pushTopic, FXA_PUSH_SCOPE_ACCOUNT_UPDATE);
+});
+
+add_test(function observePushTopicDeviceDisconnected() {
+ const deviceId = "bogusid";
+ let msg = {
+ data: {
+ json: () => ({
+ command: ON_DEVICE_DISCONNECTED_NOTIFICATION,
+ data: {
+ id: deviceId
+ }
+ })
+ },
+ QueryInterface: function() {
+ return this;
+ }
+ };
+ let customAccounts = Object.assign(mockFxAccounts, {
+ handleDeviceDisconnection: function () {
+ // checking verification status on push messages without data
+ run_next_test();
+ }
+ });
+
+ let pushService = new FxAccountsPushService({
+ pushService: mockPushService,
+ fxAccounts: customAccounts,
+ });
+
+ pushService.observe(msg, mockPushService.pushTopic, FXA_PUSH_SCOPE_ACCOUNT_UPDATE);
+});
+
+add_test(function observePushTopicPasswordChanged() {
+ let msg = {
+ data: {
+ json: () => ({
+ command: ON_PASSWORD_CHANGED_NOTIFICATION
+ })
+ },
+ QueryInterface: function() {
+ return this;
+ }
+ };
+
+ let pushService = new FxAccountsPushService({
+ pushService: mockPushService,
+ });
+
+ pushService._onPasswordChanged = function () {
+ run_next_test();
+ }
+
+ pushService.observe(msg, mockPushService.pushTopic, FXA_PUSH_SCOPE_ACCOUNT_UPDATE);
+});
+
+add_test(function observePushTopicPasswordReset() {
+ let msg = {
+ data: {
+ json: () => ({
+ command: ON_PASSWORD_RESET_NOTIFICATION
+ })
+ },
+ QueryInterface: function() {
+ return this;
+ }
+ };
+
+ let pushService = new FxAccountsPushService({
+ pushService: mockPushService
+ });
+
+ pushService._onPasswordChanged = function () {
+ run_next_test();
+ }
+
+ pushService.observe(msg, mockPushService.pushTopic, FXA_PUSH_SCOPE_ACCOUNT_UPDATE);
+});
+
+add_test(function observeSubscriptionChangeTopic() {
+ let customAccounts = Object.assign(mockFxAccounts, {
+ updateDeviceRegistration: function () {
+ // subscription change means updating the device registration
+ run_next_test();
+ }
+ });
+
+ let pushService = new FxAccountsPushService({
+ pushService: mockPushService,
+ fxAccounts: customAccounts,
+ });
+
+ pushService.observe(null, mockPushService.subscriptionChangeTopic, FXA_PUSH_SCOPE_ACCOUNT_UPDATE);
+});
diff --git a/services/fxaccounts/tests/xpcshell/test_storage_manager.js b/services/fxaccounts/tests/xpcshell/test_storage_manager.js
new file mode 100644
index 000000000..6a293a0ff
--- /dev/null
+++ b/services/fxaccounts/tests/xpcshell/test_storage_manager.js
@@ -0,0 +1,477 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Tests for the FxA storage manager.
+
+Cu.import("resource://gre/modules/Task.jsm");
+Cu.import("resource://gre/modules/Services.jsm");
+Cu.import("resource://gre/modules/FxAccountsStorage.jsm");
+Cu.import("resource://gre/modules/FxAccountsCommon.js");
+Cu.import("resource://gre/modules/Log.jsm");
+
+initTestLogging("Trace");
+log.level = Log.Level.Trace;
+
+const DEVICE_REGISTRATION_VERSION = 42;
+
+// A couple of mocks we can use.
+function MockedPlainStorage(accountData) {
+ let data = null;
+ if (accountData) {
+ data = {
+ version: DATA_FORMAT_VERSION,
+ accountData: accountData,
+ }
+ }
+ this.data = data;
+ this.numReads = 0;
+}
+MockedPlainStorage.prototype = {
+ get: Task.async(function* () {
+ this.numReads++;
+ Assert.equal(this.numReads, 1, "should only ever be 1 read of acct data");
+ return this.data;
+ }),
+
+ set: Task.async(function* (data) {
+ this.data = data;
+ }),
+};
+
+function MockedSecureStorage(accountData) {
+ let data = null;
+ if (accountData) {
+ data = {
+ version: DATA_FORMAT_VERSION,
+ accountData: accountData,
+ }
+ }
+ this.data = data;
+ this.numReads = 0;
+}
+
+MockedSecureStorage.prototype = {
+ fetchCount: 0,
+ locked: false,
+ STORAGE_LOCKED: function() {},
+ get: Task.async(function* (uid, email) {
+ this.fetchCount++;
+ if (this.locked) {
+ throw new this.STORAGE_LOCKED();
+ }
+ this.numReads++;
+ Assert.equal(this.numReads, 1, "should only ever be 1 read of unlocked data");
+ return this.data;
+ }),
+
+ set: Task.async(function* (uid, contents) {
+ this.data = contents;
+ }),
+}
+
+function add_storage_task(testFunction) {
+ add_task(function* () {
+ print("Starting test with secure storage manager");
+ yield testFunction(new FxAccountsStorageManager());
+ });
+ add_task(function* () {
+ print("Starting test with simple storage manager");
+ yield testFunction(new FxAccountsStorageManager({useSecure: false}));
+ });
+}
+
+// initialized without account data and there's nothing to read. Not logged in.
+add_storage_task(function* checkInitializedEmpty(sm) {
+ if (sm.secureStorage) {
+ sm.secureStorage = new MockedSecureStorage(null);
+ }
+ yield sm.initialize();
+ Assert.strictEqual((yield sm.getAccountData()), null);
+ Assert.rejects(sm.updateAccountData({kA: "kA"}), "No user is logged in")
+});
+
+// Initialized with account data (ie, simulating a new user being logged in).
+// Should reflect the initial data and be written to storage.
+add_storage_task(function* checkNewUser(sm) {
+ let initialAccountData = {
+ uid: "uid",
+ email: "someone@somewhere.com",
+ kA: "kA",
+ deviceId: "device id"
+ };
+ sm.plainStorage = new MockedPlainStorage()
+ if (sm.secureStorage) {
+ sm.secureStorage = new MockedSecureStorage(null);
+ }
+ yield sm.initialize(initialAccountData);
+ let accountData = yield sm.getAccountData();
+ Assert.equal(accountData.uid, initialAccountData.uid);
+ Assert.equal(accountData.email, initialAccountData.email);
+ Assert.equal(accountData.kA, initialAccountData.kA);
+ Assert.equal(accountData.deviceId, initialAccountData.deviceId);
+
+ // and it should have been written to storage.
+ Assert.equal(sm.plainStorage.data.accountData.uid, initialAccountData.uid);
+ Assert.equal(sm.plainStorage.data.accountData.email, initialAccountData.email);
+ Assert.equal(sm.plainStorage.data.accountData.deviceId, initialAccountData.deviceId);
+ // check secure
+ if (sm.secureStorage) {
+ Assert.equal(sm.secureStorage.data.accountData.kA, initialAccountData.kA);
+ } else {
+ Assert.equal(sm.plainStorage.data.accountData.kA, initialAccountData.kA);
+ }
+});
+
+// Initialized without account data but storage has it available.
+add_storage_task(function* checkEverythingRead(sm) {
+ sm.plainStorage = new MockedPlainStorage({
+ uid: "uid",
+ email: "someone@somewhere.com",
+ deviceId: "wibble",
+ deviceRegistrationVersion: null
+ });
+ if (sm.secureStorage) {
+ sm.secureStorage = new MockedSecureStorage(null);
+ }
+ yield sm.initialize();
+ let accountData = yield sm.getAccountData();
+ Assert.ok(accountData, "read account data");
+ Assert.equal(accountData.uid, "uid");
+ Assert.equal(accountData.email, "someone@somewhere.com");
+ Assert.equal(accountData.deviceId, "wibble");
+ Assert.equal(accountData.deviceRegistrationVersion, null);
+ // Update the data - we should be able to fetch it back and it should appear
+ // in our storage.
+ yield sm.updateAccountData({
+ verified: true,
+ kA: "kA",
+ kB: "kB",
+ deviceRegistrationVersion: DEVICE_REGISTRATION_VERSION
+ });
+ accountData = yield sm.getAccountData();
+ Assert.equal(accountData.kB, "kB");
+ Assert.equal(accountData.kA, "kA");
+ Assert.equal(accountData.deviceId, "wibble");
+ Assert.equal(accountData.deviceRegistrationVersion, DEVICE_REGISTRATION_VERSION);
+ // Check the new value was written to storage.
+ yield sm._promiseStorageComplete; // storage is written in the background.
+ // "verified", "deviceId" and "deviceRegistrationVersion" are plain-text fields.
+ Assert.equal(sm.plainStorage.data.accountData.verified, true);
+ Assert.equal(sm.plainStorage.data.accountData.deviceId, "wibble");
+ Assert.equal(sm.plainStorage.data.accountData.deviceRegistrationVersion, DEVICE_REGISTRATION_VERSION);
+ // "kA" and "foo" are secure
+ if (sm.secureStorage) {
+ Assert.equal(sm.secureStorage.data.accountData.kA, "kA");
+ Assert.equal(sm.secureStorage.data.accountData.kB, "kB");
+ } else {
+ Assert.equal(sm.plainStorage.data.accountData.kA, "kA");
+ Assert.equal(sm.plainStorage.data.accountData.kB, "kB");
+ }
+});
+
+add_storage_task(function* checkInvalidUpdates(sm) {
+ sm.plainStorage = new MockedPlainStorage({uid: "uid", email: "someone@somewhere.com"})
+ if (sm.secureStorage) {
+ sm.secureStorage = new MockedSecureStorage(null);
+ }
+ Assert.rejects(sm.updateAccountData({uid: "another"}), "Can't change");
+ Assert.rejects(sm.updateAccountData({email: "someoneelse"}), "Can't change");
+});
+
+add_storage_task(function* checkNullUpdatesRemovedUnlocked(sm) {
+ if (sm.secureStorage) {
+ sm.plainStorage = new MockedPlainStorage({uid: "uid", email: "someone@somewhere.com"})
+ sm.secureStorage = new MockedSecureStorage({kA: "kA", kB: "kB"});
+ } else {
+ sm.plainStorage = new MockedPlainStorage({uid: "uid", email: "someone@somewhere.com",
+ kA: "kA", kB: "kB"});
+ }
+ yield sm.initialize();
+
+ yield sm.updateAccountData({kA: null});
+ let accountData = yield sm.getAccountData();
+ Assert.ok(!accountData.kA);
+ Assert.equal(accountData.kB, "kB");
+});
+
+add_storage_task(function* checkDelete(sm) {
+ if (sm.secureStorage) {
+ sm.plainStorage = new MockedPlainStorage({uid: "uid", email: "someone@somewhere.com"})
+ sm.secureStorage = new MockedSecureStorage({kA: "kA", kB: "kB"});
+ } else {
+ sm.plainStorage = new MockedPlainStorage({uid: "uid", email: "someone@somewhere.com",
+ kA: "kA", kB: "kB"});
+ }
+ yield sm.initialize();
+
+ yield sm.deleteAccountData();
+ // Storage should have been reset to null.
+ Assert.equal(sm.plainStorage.data, null);
+ if (sm.secureStorage) {
+ Assert.equal(sm.secureStorage.data, null);
+ }
+ // And everything should reflect no user.
+ Assert.equal((yield sm.getAccountData()), null);
+});
+
+// Some tests only for the secure storage manager.
+add_task(function* checkNullUpdatesRemovedLocked() {
+ let sm = new FxAccountsStorageManager();
+ sm.plainStorage = new MockedPlainStorage({uid: "uid", email: "someone@somewhere.com"})
+ sm.secureStorage = new MockedSecureStorage({kA: "kA", kB: "kB"});
+ sm.secureStorage.locked = true;
+ yield sm.initialize();
+
+ yield sm.updateAccountData({kA: null});
+ let accountData = yield sm.getAccountData();
+ Assert.ok(!accountData.kA);
+ // still no kB as we are locked.
+ Assert.ok(!accountData.kB);
+
+ // now unlock - should still be no kA but kB should appear.
+ sm.secureStorage.locked = false;
+ accountData = yield sm.getAccountData();
+ Assert.ok(!accountData.kA);
+ Assert.equal(accountData.kB, "kB");
+ // And secure storage should have been written with our previously-cached
+ // data.
+ Assert.strictEqual(sm.secureStorage.data.accountData.kA, undefined);
+ Assert.strictEqual(sm.secureStorage.data.accountData.kB, "kB");
+});
+
+add_task(function* checkEverythingReadSecure() {
+ let sm = new FxAccountsStorageManager();
+ sm.plainStorage = new MockedPlainStorage({uid: "uid", email: "someone@somewhere.com"})
+ sm.secureStorage = new MockedSecureStorage({kA: "kA"});
+ yield sm.initialize();
+
+ let accountData = yield sm.getAccountData();
+ Assert.ok(accountData, "read account data");
+ Assert.equal(accountData.uid, "uid");
+ Assert.equal(accountData.email, "someone@somewhere.com");
+ Assert.equal(accountData.kA, "kA");
+});
+
+add_task(function* checkMemoryFieldsNotReturnedByDefault() {
+ let sm = new FxAccountsStorageManager();
+ sm.plainStorage = new MockedPlainStorage({uid: "uid", email: "someone@somewhere.com"})
+ sm.secureStorage = new MockedSecureStorage({kA: "kA"});
+ yield sm.initialize();
+
+ // keyPair is a memory field.
+ yield sm.updateAccountData({keyPair: "the keypair value"});
+ let accountData = yield sm.getAccountData();
+
+ // Requesting everything should *not* return in memory fields.
+ Assert.strictEqual(accountData.keyPair, undefined);
+ // But requesting them specifically does get them.
+ accountData = yield sm.getAccountData("keyPair");
+ Assert.strictEqual(accountData.keyPair, "the keypair value");
+});
+
+add_task(function* checkExplicitGet() {
+ let sm = new FxAccountsStorageManager();
+ sm.plainStorage = new MockedPlainStorage({uid: "uid", email: "someone@somewhere.com"})
+ sm.secureStorage = new MockedSecureStorage({kA: "kA"});
+ yield sm.initialize();
+
+ let accountData = yield sm.getAccountData(["uid", "kA"]);
+ Assert.ok(accountData, "read account data");
+ Assert.equal(accountData.uid, "uid");
+ Assert.equal(accountData.kA, "kA");
+ // We didn't ask for email so shouldn't have got it.
+ Assert.strictEqual(accountData.email, undefined);
+});
+
+add_task(function* checkExplicitGetNoSecureRead() {
+ let sm = new FxAccountsStorageManager();
+ sm.plainStorage = new MockedPlainStorage({uid: "uid", email: "someone@somewhere.com"})
+ sm.secureStorage = new MockedSecureStorage({kA: "kA"});
+ yield sm.initialize();
+
+ Assert.equal(sm.secureStorage.fetchCount, 0);
+ // request 2 fields in secure storage - it should have caused a single fetch.
+ let accountData = yield sm.getAccountData(["email", "uid"]);
+ Assert.ok(accountData, "read account data");
+ Assert.equal(accountData.uid, "uid");
+ Assert.equal(accountData.email, "someone@somewhere.com");
+ Assert.strictEqual(accountData.kA, undefined);
+ Assert.equal(sm.secureStorage.fetchCount, 1);
+});
+
+add_task(function* checkLockedUpdates() {
+ let sm = new FxAccountsStorageManager();
+ sm.plainStorage = new MockedPlainStorage({uid: "uid", email: "someone@somewhere.com"})
+ sm.secureStorage = new MockedSecureStorage({kA: "old-kA", kB: "kB"});
+ sm.secureStorage.locked = true;
+ yield sm.initialize();
+
+ let accountData = yield sm.getAccountData();
+ // requesting kA and kB will fail as storage is locked.
+ Assert.ok(!accountData.kA);
+ Assert.ok(!accountData.kB);
+ // While locked we can still update it and see the updated value.
+ sm.updateAccountData({kA: "new-kA"});
+ accountData = yield sm.getAccountData();
+ Assert.equal(accountData.kA, "new-kA");
+ // unlock.
+ sm.secureStorage.locked = false;
+ accountData = yield sm.getAccountData();
+ // should reflect the value we updated and the one we didn't.
+ Assert.equal(accountData.kA, "new-kA");
+ Assert.equal(accountData.kB, "kB");
+ // And storage should also reflect it.
+ Assert.strictEqual(sm.secureStorage.data.accountData.kA, "new-kA");
+ Assert.strictEqual(sm.secureStorage.data.accountData.kB, "kB");
+});
+
+// Some tests for the "storage queue" functionality.
+
+// A helper for our queued tests. It creates a StorageManager and then queues
+// an unresolved promise. The tests then do additional setup and checks, then
+// resolves or rejects the blocked promise.
+var setupStorageManagerForQueueTest = Task.async(function* () {
+ let sm = new FxAccountsStorageManager();
+ sm.plainStorage = new MockedPlainStorage({uid: "uid", email: "someone@somewhere.com"})
+ sm.secureStorage = new MockedSecureStorage({kA: "kA"});
+ sm.secureStorage.locked = true;
+ yield sm.initialize();
+
+ let resolveBlocked, rejectBlocked;
+ let blockedPromise = new Promise((resolve, reject) => {
+ resolveBlocked = resolve;
+ rejectBlocked = reject;
+ });
+
+ sm._queueStorageOperation(() => blockedPromise);
+ return {sm, blockedPromise, resolveBlocked, rejectBlocked}
+});
+
+// First the general functionality.
+add_task(function* checkQueueSemantics() {
+ let { sm, resolveBlocked } = yield setupStorageManagerForQueueTest();
+
+ // We've one unresolved promise in the queue - add another promise.
+ let resolveSubsequent;
+ let subsequentPromise = new Promise(resolve => {
+ resolveSubsequent = resolve;
+ });
+ let subsequentCalled = false;
+
+ sm._queueStorageOperation(() => {
+ subsequentCalled = true;
+ resolveSubsequent();
+ return subsequentPromise;
+ });
+
+ // Our "subsequent" function should not have been called yet.
+ Assert.ok(!subsequentCalled);
+
+ // Release our blocked promise.
+ resolveBlocked();
+
+ // Our subsequent promise should end up resolved.
+ yield subsequentPromise;
+ Assert.ok(subsequentCalled);
+ yield sm.finalize();
+});
+
+// Check that a queued promise being rejected works correctly.
+add_task(function* checkQueueSemanticsOnError() {
+ let { sm, blockedPromise, rejectBlocked } = yield setupStorageManagerForQueueTest();
+
+ let resolveSubsequent;
+ let subsequentPromise = new Promise(resolve => {
+ resolveSubsequent = resolve;
+ });
+ let subsequentCalled = false;
+
+ sm._queueStorageOperation(() => {
+ subsequentCalled = true;
+ resolveSubsequent();
+ return subsequentPromise;
+ });
+
+ // Our "subsequent" function should not have been called yet.
+ Assert.ok(!subsequentCalled);
+
+ // Reject our blocked promise - the subsequent operations should still work
+ // correctly.
+ rejectBlocked("oh no");
+
+ // Our subsequent promise should end up resolved.
+ yield subsequentPromise;
+ Assert.ok(subsequentCalled);
+
+ // But the first promise should reflect the rejection.
+ try {
+ yield blockedPromise;
+ Assert.ok(false, "expected this promise to reject");
+ } catch (ex) {
+ Assert.equal(ex, "oh no");
+ }
+ yield sm.finalize();
+});
+
+
+// And some tests for the specific operations that are queued.
+add_task(function* checkQueuedReadAndUpdate() {
+ let { sm, resolveBlocked } = yield setupStorageManagerForQueueTest();
+ // Mock the underlying operations
+ // _doReadAndUpdateSecure is queued by _maybeReadAndUpdateSecure
+ let _doReadCalled = false;
+ sm._doReadAndUpdateSecure = () => {
+ _doReadCalled = true;
+ return Promise.resolve();
+ }
+
+ let resultPromise = sm._maybeReadAndUpdateSecure();
+ Assert.ok(!_doReadCalled);
+
+ resolveBlocked();
+ yield resultPromise;
+ Assert.ok(_doReadCalled);
+ yield sm.finalize();
+});
+
+add_task(function* checkQueuedWrite() {
+ let { sm, resolveBlocked } = yield setupStorageManagerForQueueTest();
+ // Mock the underlying operations
+ let __writeCalled = false;
+ sm.__write = () => {
+ __writeCalled = true;
+ return Promise.resolve();
+ }
+
+ let writePromise = sm._write();
+ Assert.ok(!__writeCalled);
+
+ resolveBlocked();
+ yield writePromise;
+ Assert.ok(__writeCalled);
+ yield sm.finalize();
+});
+
+add_task(function* checkQueuedDelete() {
+ let { sm, resolveBlocked } = yield setupStorageManagerForQueueTest();
+ // Mock the underlying operations
+ let _deleteCalled = false;
+ sm._deleteAccountData = () => {
+ _deleteCalled = true;
+ return Promise.resolve();
+ }
+
+ let resultPromise = sm.deleteAccountData();
+ Assert.ok(!_deleteCalled);
+
+ resolveBlocked();
+ yield resultPromise;
+ Assert.ok(_deleteCalled);
+ yield sm.finalize();
+});
+
+function run_test() {
+ run_next_test();
+}
diff --git a/services/fxaccounts/tests/xpcshell/test_web_channel.js b/services/fxaccounts/tests/xpcshell/test_web_channel.js
new file mode 100644
index 000000000..3cf566278
--- /dev/null
+++ b/services/fxaccounts/tests/xpcshell/test_web_channel.js
@@ -0,0 +1,499 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+Cu.import("resource://gre/modules/FxAccountsCommon.js");
+const { FxAccountsWebChannel, FxAccountsWebChannelHelpers } =
+ Cu.import("resource://gre/modules/FxAccountsWebChannel.jsm");
+
+const URL_STRING = "https://example.com";
+
+const mockSendingContext = {
+ browser: {},
+ principal: {},
+ eventTarget: {}
+};
+
+add_test(function () {
+ validationHelper(undefined,
+ "Error: Missing configuration options");
+
+ validationHelper({
+ channel_id: WEBCHANNEL_ID
+ },
+ "Error: Missing 'content_uri' option");
+
+ validationHelper({
+ content_uri: 'bad uri',
+ channel_id: WEBCHANNEL_ID
+ },
+ /NS_ERROR_MALFORMED_URI/);
+
+ validationHelper({
+ content_uri: URL_STRING
+ },
+ 'Error: Missing \'channel_id\' option');
+
+ run_next_test();
+});
+
+add_task(function* test_rejection_reporting() {
+ let mockMessage = {
+ command: 'fxaccounts:login',
+ messageId: '1234',
+ data: { email: 'testuser@testuser.com' },
+ };
+
+ let channel = new FxAccountsWebChannel({
+ channel_id: WEBCHANNEL_ID,
+ content_uri: URL_STRING,
+ helpers: {
+ login(accountData) {
+ equal(accountData.email, 'testuser@testuser.com',
+ 'Should forward incoming message data to the helper');
+ return Promise.reject(new Error('oops'));
+ },
+ },
+ });
+
+ let promiseSend = new Promise(resolve => {
+ channel._channel.send = (message, context) => {
+ resolve({ message, context });
+ };
+ });
+
+ channel._channelCallback(WEBCHANNEL_ID, mockMessage, mockSendingContext);
+
+ let { message, context } = yield promiseSend;
+
+ equal(context, mockSendingContext, 'Should forward the original context');
+ equal(message.command, 'fxaccounts:login',
+ 'Should include the incoming command');
+ equal(message.messageId, '1234', 'Should include the message ID');
+ equal(message.data.error.message, 'Error: oops',
+ 'Should convert the error message to a string');
+ notStrictEqual(message.data.error.stack, null,
+ 'Should include the stack for JS error rejections');
+});
+
+add_test(function test_exception_reporting() {
+ let mockMessage = {
+ command: 'fxaccounts:sync_preferences',
+ messageId: '5678',
+ data: { entryPoint: 'fxa:verification_complete' }
+ };
+
+ let channel = new FxAccountsWebChannel({
+ channel_id: WEBCHANNEL_ID,
+ content_uri: URL_STRING,
+ helpers: {
+ openSyncPreferences(browser, entryPoint) {
+ equal(entryPoint, 'fxa:verification_complete',
+ 'Should forward incoming message data to the helper');
+ throw new TypeError('splines not reticulated');
+ },
+ },
+ });
+
+ channel._channel.send = (message, context) => {
+ equal(context, mockSendingContext, 'Should forward the original context');
+ equal(message.command, 'fxaccounts:sync_preferences',
+ 'Should include the incoming command');
+ equal(message.messageId, '5678', 'Should include the message ID');
+ equal(message.data.error.message, 'TypeError: splines not reticulated',
+ 'Should convert the exception to a string');
+ notStrictEqual(message.data.error.stack, null,
+ 'Should include the stack for JS exceptions');
+
+ run_next_test();
+ };
+
+ channel._channelCallback(WEBCHANNEL_ID, mockMessage, mockSendingContext);
+});
+
+add_test(function test_profile_image_change_message() {
+ var mockMessage = {
+ command: "profile:change",
+ data: { uid: "foo" }
+ };
+
+ makeObserver(ON_PROFILE_CHANGE_NOTIFICATION, function (subject, topic, data) {
+ do_check_eq(data, "foo");
+ run_next_test();
+ });
+
+ var channel = new FxAccountsWebChannel({
+ channel_id: WEBCHANNEL_ID,
+ content_uri: URL_STRING
+ });
+
+ channel._channelCallback(WEBCHANNEL_ID, mockMessage, mockSendingContext);
+});
+
+add_test(function test_login_message() {
+ let mockMessage = {
+ command: 'fxaccounts:login',
+ data: { email: 'testuser@testuser.com' }
+ };
+
+ let channel = new FxAccountsWebChannel({
+ channel_id: WEBCHANNEL_ID,
+ content_uri: URL_STRING,
+ helpers: {
+ login: function (accountData) {
+ do_check_eq(accountData.email, 'testuser@testuser.com');
+ run_next_test();
+ return Promise.resolve();
+ }
+ }
+ });
+
+ channel._channelCallback(WEBCHANNEL_ID, mockMessage, mockSendingContext);
+});
+
+add_test(function test_logout_message() {
+ let mockMessage = {
+ command: 'fxaccounts:logout',
+ data: { uid: "foo" }
+ };
+
+ let channel = new FxAccountsWebChannel({
+ channel_id: WEBCHANNEL_ID,
+ content_uri: URL_STRING,
+ helpers: {
+ logout: function (uid) {
+ do_check_eq(uid, 'foo');
+ run_next_test();
+ return Promise.resolve();
+ }
+ }
+ });
+
+ channel._channelCallback(WEBCHANNEL_ID, mockMessage, mockSendingContext);
+});
+
+add_test(function test_delete_message() {
+ let mockMessage = {
+ command: 'fxaccounts:delete',
+ data: { uid: "foo" }
+ };
+
+ let channel = new FxAccountsWebChannel({
+ channel_id: WEBCHANNEL_ID,
+ content_uri: URL_STRING,
+ helpers: {
+ logout: function (uid) {
+ do_check_eq(uid, 'foo');
+ run_next_test();
+ return Promise.resolve();
+ }
+ }
+ });
+
+ channel._channelCallback(WEBCHANNEL_ID, mockMessage, mockSendingContext);
+});
+
+add_test(function test_can_link_account_message() {
+ let mockMessage = {
+ command: 'fxaccounts:can_link_account',
+ data: { email: 'testuser@testuser.com' }
+ };
+
+ let channel = new FxAccountsWebChannel({
+ channel_id: WEBCHANNEL_ID,
+ content_uri: URL_STRING,
+ helpers: {
+ shouldAllowRelink: function (email) {
+ do_check_eq(email, 'testuser@testuser.com');
+ run_next_test();
+ }
+ }
+ });
+
+ channel._channelCallback(WEBCHANNEL_ID, mockMessage, mockSendingContext);
+});
+
+add_test(function test_sync_preferences_message() {
+ let mockMessage = {
+ command: 'fxaccounts:sync_preferences',
+ data: { entryPoint: 'fxa:verification_complete' }
+ };
+
+ let channel = new FxAccountsWebChannel({
+ channel_id: WEBCHANNEL_ID,
+ content_uri: URL_STRING,
+ helpers: {
+ openSyncPreferences: function (browser, entryPoint) {
+ do_check_eq(entryPoint, 'fxa:verification_complete');
+ do_check_eq(browser, mockSendingContext.browser);
+ run_next_test();
+ }
+ }
+ });
+
+ channel._channelCallback(WEBCHANNEL_ID, mockMessage, mockSendingContext);
+});
+
+add_test(function test_unrecognized_message() {
+ let mockMessage = {
+ command: 'fxaccounts:unrecognized',
+ data: {}
+ };
+
+ let channel = new FxAccountsWebChannel({
+ channel_id: WEBCHANNEL_ID,
+ content_uri: URL_STRING
+ });
+
+ // no error is expected.
+ channel._channelCallback(WEBCHANNEL_ID, mockMessage, mockSendingContext);
+ run_next_test();
+});
+
+
+add_test(function test_helpers_should_allow_relink_same_email() {
+ let helpers = new FxAccountsWebChannelHelpers();
+
+ helpers.setPreviousAccountNameHashPref('testuser@testuser.com');
+ do_check_true(helpers.shouldAllowRelink('testuser@testuser.com'));
+
+ run_next_test();
+});
+
+add_test(function test_helpers_should_allow_relink_different_email() {
+ let helpers = new FxAccountsWebChannelHelpers();
+
+ helpers.setPreviousAccountNameHashPref('testuser@testuser.com');
+
+ helpers._promptForRelink = (acctName) => {
+ return acctName === 'allowed_to_relink@testuser.com';
+ };
+
+ do_check_true(helpers.shouldAllowRelink('allowed_to_relink@testuser.com'));
+ do_check_false(helpers.shouldAllowRelink('not_allowed_to_relink@testuser.com'));
+
+ run_next_test();
+});
+
+add_task(function* test_helpers_login_without_customize_sync() {
+ let helpers = new FxAccountsWebChannelHelpers({
+ fxAccounts: {
+ setSignedInUser: function(accountData) {
+ return new Promise(resolve => {
+ // ensure fxAccounts is informed of the new user being signed in.
+ do_check_eq(accountData.email, 'testuser@testuser.com');
+
+ // verifiedCanLinkAccount should be stripped in the data.
+ do_check_false('verifiedCanLinkAccount' in accountData);
+
+ // the customizeSync pref should not update
+ do_check_false(helpers.getShowCustomizeSyncPref());
+
+ // previously signed in user preference is updated.
+ do_check_eq(helpers.getPreviousAccountNameHashPref(), helpers.sha256('testuser@testuser.com'));
+
+ resolve();
+ });
+ }
+ }
+ });
+
+ // the show customize sync pref should stay the same
+ helpers.setShowCustomizeSyncPref(false);
+
+ // ensure the previous account pref is overwritten.
+ helpers.setPreviousAccountNameHashPref('lastuser@testuser.com');
+
+ yield helpers.login({
+ email: 'testuser@testuser.com',
+ verifiedCanLinkAccount: true,
+ customizeSync: false
+ });
+});
+
+add_task(function* test_helpers_login_with_customize_sync() {
+ let helpers = new FxAccountsWebChannelHelpers({
+ fxAccounts: {
+ setSignedInUser: function(accountData) {
+ return new Promise(resolve => {
+ // ensure fxAccounts is informed of the new user being signed in.
+ do_check_eq(accountData.email, 'testuser@testuser.com');
+
+ // customizeSync should be stripped in the data.
+ do_check_false('customizeSync' in accountData);
+
+ // the customizeSync pref should not update
+ do_check_true(helpers.getShowCustomizeSyncPref());
+
+ resolve();
+ });
+ }
+ }
+ });
+
+ // the customize sync pref should be overwritten
+ helpers.setShowCustomizeSyncPref(false);
+
+ yield helpers.login({
+ email: 'testuser@testuser.com',
+ verifiedCanLinkAccount: true,
+ customizeSync: true
+ });
+});
+
+add_task(function* test_helpers_login_with_customize_sync_and_declined_engines() {
+ let helpers = new FxAccountsWebChannelHelpers({
+ fxAccounts: {
+ setSignedInUser: function(accountData) {
+ return new Promise(resolve => {
+ // ensure fxAccounts is informed of the new user being signed in.
+ do_check_eq(accountData.email, 'testuser@testuser.com');
+
+ // customizeSync should be stripped in the data.
+ do_check_false('customizeSync' in accountData);
+ do_check_false('declinedSyncEngines' in accountData);
+ do_check_eq(Services.prefs.getBoolPref("services.sync.engine.addons"), false);
+ do_check_eq(Services.prefs.getBoolPref("services.sync.engine.bookmarks"), true);
+ do_check_eq(Services.prefs.getBoolPref("services.sync.engine.history"), true);
+ do_check_eq(Services.prefs.getBoolPref("services.sync.engine.passwords"), true);
+ do_check_eq(Services.prefs.getBoolPref("services.sync.engine.prefs"), false);
+ do_check_eq(Services.prefs.getBoolPref("services.sync.engine.tabs"), true);
+
+ // the customizeSync pref should be disabled
+ do_check_false(helpers.getShowCustomizeSyncPref());
+
+ resolve();
+ });
+ }
+ }
+ });
+
+ // the customize sync pref should be overwritten
+ helpers.setShowCustomizeSyncPref(true);
+
+ do_check_eq(Services.prefs.getBoolPref("services.sync.engine.addons"), true);
+ do_check_eq(Services.prefs.getBoolPref("services.sync.engine.bookmarks"), true);
+ do_check_eq(Services.prefs.getBoolPref("services.sync.engine.history"), true);
+ do_check_eq(Services.prefs.getBoolPref("services.sync.engine.passwords"), true);
+ do_check_eq(Services.prefs.getBoolPref("services.sync.engine.prefs"), true);
+ do_check_eq(Services.prefs.getBoolPref("services.sync.engine.tabs"), true);
+ yield helpers.login({
+ email: 'testuser@testuser.com',
+ verifiedCanLinkAccount: true,
+ customizeSync: true,
+ declinedSyncEngines: ['addons', 'prefs']
+ });
+});
+
+add_test(function test_helpers_open_sync_preferences() {
+ let helpers = new FxAccountsWebChannelHelpers({
+ fxAccounts: {
+ }
+ });
+
+ let mockBrowser = {
+ loadURI(uri) {
+ do_check_eq(uri, "about:preferences?entrypoint=fxa%3Averification_complete#sync");
+ run_next_test();
+ }
+ };
+
+ helpers.openSyncPreferences(mockBrowser, "fxa:verification_complete");
+});
+
+add_task(function* test_helpers_change_password() {
+ let wasCalled = {
+ updateUserAccountData: false,
+ updateDeviceRegistration: false
+ };
+ let helpers = new FxAccountsWebChannelHelpers({
+ fxAccounts: {
+ updateUserAccountData(credentials) {
+ return new Promise(resolve => {
+ do_check_true(credentials.hasOwnProperty("email"));
+ do_check_true(credentials.hasOwnProperty("uid"));
+ do_check_true(credentials.hasOwnProperty("kA"));
+ do_check_true(credentials.hasOwnProperty("deviceId"));
+ do_check_null(credentials.deviceId);
+ // "foo" isn't a field known by storage, so should be dropped.
+ do_check_false(credentials.hasOwnProperty("foo"));
+ wasCalled.updateUserAccountData = true;
+
+ resolve();
+ });
+ },
+
+ updateDeviceRegistration() {
+ do_check_eq(arguments.length, 0);
+ wasCalled.updateDeviceRegistration = true;
+ return Promise.resolve()
+ }
+ }
+ });
+ yield helpers.changePassword({ email: "email", uid: "uid", kA: "kA", foo: "foo" });
+ do_check_true(wasCalled.updateUserAccountData);
+ do_check_true(wasCalled.updateDeviceRegistration);
+});
+
+add_task(function* test_helpers_change_password_with_error() {
+ let wasCalled = {
+ updateUserAccountData: false,
+ updateDeviceRegistration: false
+ };
+ let helpers = new FxAccountsWebChannelHelpers({
+ fxAccounts: {
+ updateUserAccountData() {
+ wasCalled.updateUserAccountData = true;
+ return Promise.reject();
+ },
+
+ updateDeviceRegistration() {
+ wasCalled.updateDeviceRegistration = true;
+ return Promise.resolve()
+ }
+ }
+ });
+ try {
+ yield helpers.changePassword({});
+ do_check_false('changePassword should have rejected');
+ } catch (_) {
+ do_check_true(wasCalled.updateUserAccountData);
+ do_check_false(wasCalled.updateDeviceRegistration);
+ }
+});
+
+function run_test() {
+ run_next_test();
+}
+
+function makeObserver(aObserveTopic, aObserveFunc) {
+ let callback = function (aSubject, aTopic, aData) {
+ log.debug("observed " + aTopic + " " + aData);
+ if (aTopic == aObserveTopic) {
+ removeMe();
+ aObserveFunc(aSubject, aTopic, aData);
+ }
+ };
+
+ function removeMe() {
+ log.debug("removing observer for " + aObserveTopic);
+ Services.obs.removeObserver(callback, aObserveTopic);
+ }
+
+ Services.obs.addObserver(callback, aObserveTopic, false);
+ return removeMe;
+}
+
+function validationHelper(params, expected) {
+ try {
+ new FxAccountsWebChannel(params);
+ } catch (e) {
+ if (typeof expected === 'string') {
+ return do_check_eq(e.toString(), expected);
+ } else {
+ return do_check_true(e.toString().match(expected));
+ }
+ }
+ throw new Error("Validation helper error");
+}
diff --git a/services/fxaccounts/tests/xpcshell/xpcshell.ini b/services/fxaccounts/tests/xpcshell/xpcshell.ini
new file mode 100644
index 000000000..56a3d2947
--- /dev/null
+++ b/services/fxaccounts/tests/xpcshell/xpcshell.ini
@@ -0,0 +1,23 @@
+[DEFAULT]
+head = head.js ../../../common/tests/unit/head_helpers.js ../../../common/tests/unit/head_http.js
+tail =
+skip-if = (toolkit == 'android' || appname == 'thunderbird')
+support-files =
+ !/services/common/tests/unit/head_helpers.js
+ !/services/common/tests/unit/head_http.js
+
+[test_accounts.js]
+[test_accounts_device_registration.js]
+[test_client.js]
+[test_credentials.js]
+[test_loginmgr_storage.js]
+[test_oauth_client.js]
+[test_oauth_grant_client.js]
+[test_oauth_grant_client_server.js]
+[test_oauth_tokens.js]
+[test_oauth_token_storage.js]
+[test_profile_client.js]
+[test_push_service.js]
+[test_web_channel.js]
+[test_profile.js]
+[test_storage_manager.js]