Bug 909967 - Firefox Account Signed-in User module. p=warner,spenrose,zcarter,jedp, r=markh
authorJed Parsons <jparsons@mozilla.com>
Thu, 05 Dec 2013 22:46:12 -0800
changeset 174904 f57ff3c8f1e96ab346c086be263a30f4a428b665
parent 174903 e7a6c931bcf5b05d86574f48b181c39639de414a
child 174905 d7afefc2546aa7ed12a1b25eca462284425f4999
push id445
push userffxbld
push dateMon, 10 Mar 2014 22:05:19 +0000
treeherdermozilla-release@dc38b741b04e [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersmarkh
bugs909967
milestone28.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Bug 909967 - Firefox Account Signed-in User module. p=warner,spenrose,zcarter,jedp, r=markh
services/fxaccounts/FxAccounts.jsm
services/fxaccounts/moz.build
services/fxaccounts/tests/xpcshell/test_accounts.js
services/fxaccounts/tests/xpcshell/xpcshell.ini
new file mode 100644
--- /dev/null
+++ b/services/fxaccounts/FxAccounts.jsm
@@ -0,0 +1,639 @@
+/* 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 = ["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://gre/modules/osfile.jsm");
+Cu.import("resource://services-common/utils.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/FxAccountsClient.jsm");
+
+XPCOMUtils.defineLazyModuleGetter(this, "jwcrypto",
+                                  "resource://gre/modules/identity/jwcrypto.jsm");
+
+const DATA_FORMAT_VERSION = 1;
+const DEFAULT_STORAGE_FILENAME = "signedInUser.json";
+const ASSERTION_LIFETIME = 1000 * 60 * 5; // 5 minutes
+const KEY_LIFETIME = 1000 * 3600 * 12;    // 12 hours
+const CERT_LIFETIME = 1000 * 3600 * 6;    // 6 hours
+const POLL_SESSION = 1000 * 60 * 5;       // 5 minutes
+const POLL_STEP = 1000 * 3;               // 3 seconds
+
+// 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;
+}
+
+let log = Log.repository.getLogger("Services.FxAccounts");
+log.level = LOG_LEVEL;
+log.addAppender(new Log.ConsoleAppender(new Log.BasicFormatter()));
+
+InternalMethods = function(mock) {
+  this.cert = null;
+  this.keyPair = null;
+  this.signedInUser = null;
+  this.version = DATA_FORMAT_VERSION;
+
+  // Make a local copy of these constants so we can mock it in testing
+  this.POLL_STEP = POLL_STEP;
+  this.POLL_SESSION = POLL_SESSION;
+  // We will create this.pollTimeRemaining below; it will initially be
+  // set to the value of POLL_SESSION.
+
+  // We interact with the Firefox Accounts auth server in order to confirm that
+  // a user's email has been verified and also to fetch the user's keys from
+  // the server.  We manage these processes in possibly long-lived promises
+  // that are internal to this object (never exposed to callers).  Because
+  // Firefox Accounts allows for only one logged-in user, and because it's
+  // conceivable that while we are waiting to verify one identity, a caller
+  // could start verification on a second, different identity, we need to be
+  // able to abort all work on the first sign-in process.  The currentTimer and
+  // generationCount are used for this purpose.
+  this.whenVerifiedPromise = null;
+  this.whenKeysReadyPromise = null;
+  this.currentTimer = null;
+  this.generationCount = 0;
+
+  this.fxAccountsClient = new FxAccountsClient();
+
+  if (mock) { // Testing.
+    Object.keys(mock).forEach((prop) => {
+      log.debug("InternalMethods: mocking: " + prop);
+      this[prop] = mock[prop];
+    });
+  }
+  if (!this.signedInUserStorage) {
+    // Normal (i.e., non-testing) initialization.
+    // We don't reference |profileDir| in the top-level module scope
+    // as we may be imported before we know where it is.
+    this.signedInUserStorage = new JSONStorage({
+      filename: DEFAULT_STORAGE_FILENAME,
+      baseDir: OS.Constants.Path.profileDir,
+    });
+  }
+}
+InternalMethods.prototype = {
+
+  /**
+   * Ask the server whether the user's email has been verified
+   */
+  checkEmailStatus: function checkEmailStatus(sessionToken) {
+    return this.fxAccountsClient.recoveryEmailStatus(sessionToken);
+  },
+
+  /**
+   * Once the user's email is verified, we can request the keys
+   */
+  fetchKeys: function fetchKeys(keyFetchToken) {
+    log.debug("fetchKeys: " + keyFetchToken);
+    return this.fxAccountsClient.accountKeys(keyFetchToken);
+  },
+
+  /*
+   * 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;
+    }
+    this.generationCount++;
+    log.debug("generationCount: " + this.generationCount);
+
+    if (this.whenVerifiedPromise) {
+      this.whenVerifiedPromise.reject(
+        new Error("Verification aborted; Another user signing in"));
+      this.whenVerifiedPromise = null;
+    }
+
+    if (this.whenKeysReadyPromise) {
+      this.whenKeysReadyPromise.reject(
+        new Error("KeyFetch aborted; Another user signing in"));
+      this.whenKeysReadyPromise = null;
+    }
+  },
+
+  /**
+   * 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
+   *          isVerified: email verification status
+   *        }
+   *        or null if no user is signed in
+   */
+  getKeys: function() {
+    return this.getUserAccountData().then((data) => {
+      if (!data) {
+        throw new Error("Can't get keys; User is not signed in");
+      }
+      if (data.kA && data.kB) {
+        return data;
+      }
+      if (!this.whenKeysReadyPromise) {
+        this.whenKeysReadyPromise = Promise.defer();
+        this.fetchAndUnwrapKeys(data.keyFetchToken)
+          .then((data) => {
+            if (this.whenKeysReadyPromise) {
+              this.whenKeysReadyPromise.resolve(data);
+            }
+          });
+      }
+      return this.whenKeysReadyPromise.promise;
+      });
+   },
+
+  fetchAndUnwrapKeys: function(keyFetchToken) {
+    log.debug("fetchAndUnwrapKeys: token: " + keyFetchToken);
+    return Task.spawn(function* task() {
+      // Sign out if we don't have a key fetch token.
+      if (!keyFetchToken) {
+        yield internal.signOut();
+        return null;
+      }
+      let myGenerationCount = internal.generationCount;
+
+      let {kA, wrapKB} = yield internal.fetchKeys(keyFetchToken);
+
+      let data = yield internal.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);
+
+      log.debug("kB_hex: " + kB_hex);
+      data.kA = CommonUtils.bytesAsHex(kA);
+      data.kB = CommonUtils.bytesAsHex(kB_hex);
+
+      delete data.keyFetchToken;
+
+      log.debug("Keys Obtained: kA=" + data.kA + ", kB=" + data.kB);
+
+      // Before writing any data, ensure that a new flow hasn't been
+      // started behind our backs.
+      if (internal.generationCount !== myGenerationCount) {
+        return null;
+      }
+
+      yield internal.setUserAccountData(data);
+
+      // We are now ready for business. This should only be invoked once
+      // per setSignedInUser(), regardless of whether we've rebooted since
+      // setSignedInUser() was called.
+      internal.notifyObservers("fxaccounts:onlogin");
+      return data;
+    }.bind(this));
+  },
+
+  getAssertionFromCert: function(data, keyPair, cert, audience) {
+    log.debug("getAssertionFromCert");
+    let payload = {};
+    let d = Promise.defer();
+    // "audience" should look like "http://123done.org".
+    // The generated assertion will expire in two minutes.
+    jwcrypto.generateAssertion(cert, keyPair, audience, function(err, signed) {
+      if (err) {
+        log.error("getAssertionFromCert: " + err);
+        d.reject(err);
+      } else {
+        log.debug("getAssertionFromCert returning signed: " + signed);
+        d.resolve(signed);
+      }
+    });
+    return d.promise;
+  },
+
+  getCertificate: function(data, keyPair, mustBeValidUntil) {
+    log.debug("getCertificate" + JSON.stringify(this.signedInUserStorage));
+    // TODO: get the lifetime from the cert's .exp field
+    if (this.cert && this.cert.validUntil > mustBeValidUntil) {
+      log.debug(" getCertificate already had one");
+      return Promise.resolve(this.cert.cert);
+    }
+    // else get our cert signed
+    let willBeValidUntil = this.now() + CERT_LIFETIME;
+    return this.getCertificateSigned(data.sessionToken,
+                                     keyPair.serializedPublicKey,
+                                     CERT_LIFETIME)
+      .then((cert) => {
+        this.cert = {
+          cert: cert,
+          validUntil: willBeValidUntil
+        };
+        return cert;
+      }
+    );
+  },
+
+  getCertificateSigned: function(sessionToken, serializedPublicKey, lifetime) {
+    log.debug("getCertificateSigned: " + sessionToken + " " + serializedPublicKey);
+    return this.fxAccountsClient.signCertificate(sessionToken,
+                                                 JSON.parse(serializedPublicKey),
+                                                 lifetime);
+  },
+
+  getKeyPair: function(mustBeValidUntil) {
+    if (this.keyPair && (this.keyPair.validUntil > mustBeValidUntil)) {
+      log.debug("getKeyPair: already have a keyPair");
+      return Promise.resolve(this.keyPair.keyPair);
+    }
+    // Otherwse, create a keypair and set validity limit.
+    let willBeValidUntil = this.now() + KEY_LIFETIME;
+    let d = Promise.defer();
+    jwcrypto.generateKeyPair("DS160", (err, kp) => {
+      if (err) {
+        d.reject(err);
+      } else {
+        this.keyPair = {
+          keyPair: kp,
+          validUntil: willBeValidUntil
+        };
+        log.debug("got keyPair");
+        delete this.cert;
+        d.resolve(this.keyPair.keyPair);
+      }
+    });
+    return d.promise;
+  },
+
+  getUserAccountData: function() {
+    // Skip disk if user is cached.
+    if (this.signedInUser) {
+      return Promise.resolve(this.signedInUser.accountData);
+    }
+
+    let deferred = Promise.defer();
+    this.signedInUserStorage.get()
+      .then((user) => {
+        log.debug("getUserAccountData -> " + JSON.stringify(user));
+        if (user && user.version == this.version) {
+          log.debug("setting signed in user");
+          this.signedInUser = user;
+        }
+        deferred.resolve(user ? user.accountData : null);
+      },
+      (err) => {
+        if (err instanceof OS.File.Error && err.becauseNoSuchFile) {
+          // File hasn't been created yet.  That will be done
+          // on the first call to getSignedInUser
+          deferred.resolve(null);
+        } else {
+          deferred.reject(err);
+        }
+      }
+    );
+
+    return deferred.promise;
+  },
+
+  isUserEmailVerified: function isUserEmailVerified(data) {
+    return !!(data && data.isVerified);
+  },
+
+  /**
+   * Setup for and if necessary do email verification polling.
+   */
+  loadAndPoll: function() {
+    return this.getUserAccountData()
+      .then(data => {
+        if (data && !this.isUserEmailVerified(data)) {
+          this.pollEmailStatus(data.sessionToken, "start");
+        }
+        return data;
+      });
+  },
+
+  startVerifiedCheck: function(data) {
+    log.debug("startVerifiedCheck " + JSON.stringify(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 onlogin observer
+    // notification.
+    return this.whenVerified(data)
+      .then((data) => this.getKeys(data));
+  },
+
+  whenVerified: function(data) {
+    if (data.isVerified) {
+      log.debug("already verified");
+      return Promise.resolve(data);
+    }
+    if (!this.whenVerifiedPromise) {
+      this.whenVerifiedPromise = Promise.defer();
+      log.debug("whenVerified promise starts polling for verified email");
+      this.pollEmailStatus(data.sessionToken, "start");
+    }
+    return this.whenVerifiedPromise.promise;
+  },
+
+  notifyObservers: function(topic) {
+    log.debug("Notifying observers of user login");
+    Services.obs.notifyObservers(null, topic, null);
+  },
+
+  /**
+   * Give xpcshell tests an override point for duration testing. This is
+   * necessary because the tests need to manipulate the date in order to
+   * simulate certificate expiration.
+   */
+  now: function() {
+    return Date.now();
+  },
+
+  pollEmailStatus: function pollEmailStatus(sessionToken, why) {
+    let myGenerationCount = this.generationCount;
+    log.debug("entering pollEmailStatus: " + why + " " + myGenerationCount);
+    if (why == "start") {
+      if (this.currentTimer) {
+        // safety check - this case should have been caught on
+        // entry with setSignedInUser
+        throw new Error("Already polling for email status");
+      }
+      this.pollTimeRemaining = this.POLL_SESSION;
+    }
+
+    this.checkEmailStatus(sessionToken)
+      .then((response) => {
+        log.debug("checkEmailStatus -> " + JSON.stringify(response));
+        // Check to see if we're still current.
+        // If for some ghastly reason we are not, stop processing.
+        if (this.generationCount !== myGenerationCount) {
+          log.debug("generation count differs from " + this.generationCount + " - aborting");
+          log.debug("sessionToken on abort is " + sessionToken);
+          return;
+        }
+
+        if (response && response.verified) {
+          // Bug 947056 - Server should be able to tell FxAccounts.jsm to back
+          // off or stop polling altogether
+          this.getUserAccountData()
+            .then((data) => {
+              data.isVerified = true;
+              return this.setUserAccountData(data);
+            })
+            .then((data) => {
+              // Now that the user is verified, we can proceed to fetch keys
+              if (this.whenVerifiedPromise) {
+                this.whenVerifiedPromise.resolve(data);
+                delete this.whenVerifiedPromise;
+              }
+            });
+        } else {
+          log.debug("polling with step = " + this.POLL_STEP);
+          this.pollTimeRemaining -= this.POLL_STEP;
+          log.debug("time remaining: " + this.pollTimeRemaining);
+          if (this.pollTimeRemaining > 0) {
+            this.currentTimer = setTimeout(() => {
+              this.pollEmailStatus(sessionToken, "timer")}, this.POLL_STEP);
+            log.debug("started timer " + this.currentTimer);
+          } else {
+            if (this.whenVerifiedPromise) {
+              this.whenVerifiedPromise.reject(
+                new Error("User email verification timed out.")
+              );
+              delete this.whenVerifiedPromise;
+            }
+          }
+        }
+      });
+    },
+
+  setUserAccountData: function(accountData) {
+    return this.signedInUserStorage.get().then((record) => {
+      record.accountData = accountData;
+      this.signedInUser = record;
+      return this.signedInUserStorage.set(record)
+        .then(() => accountData);
+    });
+  }
+};
+
+let internal = null;
+
+/**
+ * FxAccounts delegates private methods to an instance of InternalMethods,
+ * which is not exported. The xpcshell tests need two overrides:
+ *  1) Access to the real internal.signedInUserStorage.
+ *  2) The ability to mock InternalMethods.
+ * If mockInternal is undefined, we are live.
+ * If mockInternal.onlySetInternal is present, we are executing the first
+ * case by binding internal to the FxAccounts instance.
+ * Otherwise if we have a mock instance, we are executing the second case.
+ */
+this.FxAccounts = function(mockInternal) {
+  let mocks = mockInternal;
+  if (mocks && mocks.onlySetInternal) {
+    mocks = null;
+  }
+  internal = new InternalMethods(mocks);
+  if (mockInternal) {
+    // Exposes the internal object for testing only.
+    this.internal = internal;
+  }
+}
+this.FxAccounts.prototype = Object.freeze({
+  version: DATA_FORMAT_VERSION,
+
+  // 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 .isVerified .
+  // The "fxaccounts:onlogin" 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.isVerified()) {go()}
+
+  /**
+   * 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:
+   *        {
+   *          email: The users email address
+   *          uid: The user's unique id
+   *          sessionToken: Session for the FxA server
+   *          keyFetchToken: an unused keyFetchToken
+   *          isVerified: 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");
+    internal.abortExistingFlow();
+
+    let record = {version: this.version, accountData: credentials };
+    // Cache a clone of the credentials object.
+    internal.signedInUser = JSON.parse(JSON.stringify(record));
+
+    // This promise waits for storage, but not for verification.
+    // We're telling the caller that this is durable now.
+    return internal.signedInUserStorage.set(record)
+      .then(() => {
+        if (!internal.isUserEmailVerified(credentials)) {
+          internal.startVerifiedCheck(credentials);
+        }
+      });
+  },
+
+  /**
+   * 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
+   *          isVerified: email verification status
+   *        }
+   *        or null if no user is signed in.
+   */
+  getSignedInUser: function getSignedInUser() {
+    return internal.getUserAccountData()
+      .then((data) => {
+        if (!data) {
+          return null;
+        }
+        if (!internal.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.
+          internal.startVerifiedCheck(credentials);
+        }
+        return data;
+      });
+  },
+
+  /**
+   * returns a promise that fires with the assertion.  If there is no verified
+   * signed-in user, fires with null.
+   */
+  getAssertion: function getAssertion(audience) {
+    log.debug("enter getAssertion()");
+    let mustBeValidUntil = internal.now() + ASSERTION_LIFETIME;
+    return internal.getUserAccountData()
+      .then((data) => {
+        if (!data) {
+          // No signed-in user
+          return null;
+        }
+        if (!internal.isUserEmailVerified(data)) {
+          // Signed-in user has not verified email
+          return null;
+        }
+        return internal.getKeyPair(mustBeValidUntil)
+          .then((keyPair) => {
+            return internal.getCertificate(data, keyPair, mustBeValidUntil)
+              .then((cert) => {
+                return internal.getAssertionFromCert(data, keyPair,
+                                                     cert, audience)
+              });
+          });
+      });
+  },
+
+  /**
+   * Sign the current user out.
+   *
+   * @return Promise
+   *         The promise is rejected if a storage error occurs.
+   */
+  signOut: function signOut() {
+    internal.abortExistingFlow();
+    internal.signedInUser = null; // clear in-memory cache
+    return internal.signedInUserStorage.set(null).then(() => {
+      internal.notifyObservers("fxaccounts:onlogout");
+    });
+  },
+
+  // Return the URI of the remote UI flows.
+  getAccountsURI: function() {
+    let url = Services.urlFormatter.formatURLPref("firefox.accounts.remoteUrl");
+    if (!/^https:/.test(url)) { // Comment to un-break emacs js-mode highlighting
+      throw new Error("Firefox Accounts server must use HTTPS");
+    }
+    return url;
+  }
+});
+
+/**
+ * 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) {
+    return OS.File.makeDir(this.baseDir, {ignoreExisting: true})
+      .then(CommonUtils.writeJSON.bind(null, contents, this.path));
+  },
+
+  get: function() {
+    return CommonUtils.readJSON(this.path);
+  }
+};
+
+// 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
+  internal.loadAndPoll();
+
+  return a;
+});
+
--- a/services/fxaccounts/moz.build
+++ b/services/fxaccounts/moz.build
@@ -1,8 +1,11 @@
 # -*- Mode: python; c-basic-offset: 4; 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/.
 
 TEST_DIRS += ['tests']
-EXTRA_JS_MODULES += ['FxAccountsClient.jsm']
+EXTRA_JS_MODULES += [
+  'FxAccounts.jsm',
+  'FxAccountsClient.jsm'
+]
new file mode 100644
--- /dev/null
+++ b/services/fxaccounts/tests/xpcshell/test_accounts.js
@@ -0,0 +1,493 @@
+/* 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/Promise.jsm");
+Cu.import("resource://gre/modules/Log.jsm");
+
+// XXX until bug 937114 is fixed
+Cu.importGlobalProperties(['atob']);
+
+let log = Log.repository.getLogger("Services.FxAccounts.test");
+log.level = Log.Level.Debug;
+
+// See verbose logging from FxAccounts.jsm
+Services.prefs.setCharPref("identity.fxaccounts.loglevel", "DEBUG");
+
+function run_test() {
+  run_next_test();
+}
+
+/*
+ * 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 MockFxAccountsClient() {
+  this._email = "nobody@example.com";
+  this._verified = false;
+
+  // 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
+    let deferred = Promise.defer();
+
+    let response = {
+      email: this._email,
+      verified: this._verified
+    };
+    deferred.resolve(response);
+
+    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.signCertificate = function() { throw "no" };
+
+  FxAccountsClient.apply(this);
+}
+MockFxAccountsClient.prototype = {
+  __proto__: FxAccountsClient.prototype
+}
+
+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);
+  },
+});
+
+/*
+ * 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.
+ */
+let MockFxAccounts = function() {
+  this._getCertificateSigned_calls = [];
+  this._d_signCertificate = Promise.defer();
+  this._now_is = new Date();
+
+  let mockInternal = {
+    signedInUserStorage: new MockStorage(),
+    now: () => {
+      return this._now_is;
+    },
+    getCertificateSigned: (sessionToken, serializedPublicKey) => {
+      _("mock getCerificateSigned\n");
+      this._getCertificateSigned_calls.push([sessionToken, serializedPublicKey]);
+      return this._d_signCertificate.promise;
+    },
+    fxAccountsClient: new MockFxAccountsClient()
+  };
+  FxAccounts.apply(this, [mockInternal]);
+};
+MockFxAccounts.prototype = {
+  __proto__: FxAccounts.prototype,
+};
+
+add_test(function test_non_https_remote_server_uri() {
+  Services.prefs.setCharPref(
+    "firefox.accounts.remoteUrl",
+    "http://example.com/browser/browser/base/content/test/general/accounts_testRemoteCommands.html");
+  do_check_throws_message(function () {
+    fxAccounts.getAccountsURI();
+  }, "Firefox Accounts server must use HTTPS");
+
+  Services.prefs.clearUserPref("firefox.accounts.remoteUrl");
+
+  run_next_test();
+});
+
+add_task(function test_get_signed_in_user_initially_unset() {
+  // This test, unlike the rest, uses an un-mocked FxAccounts instance.
+  // However, we still need to pass an object to the constructor to
+  // force it to expose "internal", so we can test the disk storage.
+  let account = new FxAccounts({onlySetInternal: true})
+  let credentials = {
+    email: "foo@example.com",
+    uid: "1234@lcip.org",
+    assertion: "foobar",
+    sessionToken: "dead",
+    kA: "beef",
+    kB: "cafe",
+    isVerified: true
+  };
+
+  let result = yield account.getSignedInUser();
+  do_check_eq(result, null);
+
+  yield account.setSignedInUser(credentials);
+
+  let 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;
+  let 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
+  yield account.signOut();
+
+  // user should be undefined after sign out
+  let result = yield account.getSignedInUser();
+  do_check_eq(result, null);
+});
+
+/*
+ * Sanity-check that our mocked client is working correctly
+ */
+add_test(function test_client_mock() {
+  do_test_pending();
+
+  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);
+      do_test_finished();
+      run_next_test();
+    });
+});
+
+/*
+ * Sign in a user, and after a little while, verify the user's email.
+ * Polling should detect that the email is verified, and eventually
+ * 'onlogin' should be observed
+ */
+add_test(function test_verification_poll() {
+  do_test_pending();
+
+  let fxa = new MockFxAccounts();
+  let test_user = getTestUser("francine");
+
+  makeObserver("fxaccounts:onlogin", function() {
+    log.debug("test_verification_poll observed onlogin");
+    // Once email verification is complete, we will observe onlogin
+    fxa.internal.getUserAccountData().then(user => {
+      // And confirm that the user's state has changed
+      do_check_eq(user.isVerified, true);
+      do_check_eq(user.email, test_user.email);
+      do_test_finished();
+      run_next_test();
+    });
+  });
+
+  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.isVerified, false);
+      do_timeout(200, function() {
+        // Mock email verification ...
+        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 login event should be observed, and the
+ * internal whenVerified promise should be rejected
+ */
+add_test(function test_polling_timeout() {
+  do_test_pending();
+
+  // This test could be better - the onlogin 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("fxaccounts:onlogin", function() {
+    do_throw("We should not be getting a login event!");
+  });
+
+  fxa.internal.POLL_SESSION = 1;
+  fxa.internal.POLL_STEP = 2;
+
+  let p = fxa.internal.whenVerified({});
+
+  fxa.setSignedInUser(test_user).then(() => {
+    p.then(
+      (success) => {
+        do_throw("this should not succeed");
+      },
+      (fail) => {
+        removeObserver();
+        do_test_finished();
+        run_next_test();
+      }
+    );
+  });
+});
+
+add_task(function test_getKeys() {
+  let fxa = new MockFxAccounts();
+  let user = getTestUser("eusebius");
+
+  // Once email has been verified, we will be able to get keys
+  user.isVerified = true;
+
+  fxa.setSignedInUser(user).then(() => {
+    fxa.getSignedInUser().then((user) => {
+      // Before getKeys, we have no keys
+      do_check_eq(!!data.kA, false);
+      do_check_eq(!!data.kB, false);
+      // And we still have a key-fetch token to use
+      do_check_eq(!!data.keyFetchToken, true);
+
+      fxa.internal.getKeys().then(() => {
+        fxa.getSignedInUser().then((user) => {
+          // Now we should have keys
+          do_check_eq(fxa.internal.isUserEmailVerified(data), true);
+          do_check_eq(!!data.isVerified, true);
+          do_check_eq(data.kA, expandHex("11"));
+          do_check_eq(data.kB, expandHex("66"));
+          do_check_eq(data.keyFetchToken, undefined);
+        });
+      });
+    });
+  });
+});
+
+/*
+ * 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() {
+  do_test_pending();
+
+  let fxa = new MockFxAccounts();
+  let alice = getTestUser("alice");
+  let bob = getTestUser("bob");
+
+  makeObserver("fxaccounts:onlogin", function() {
+    log.debug("test_overlapping_signins observed onlogin");
+    // Once email verification is complete, we will observe onlogin
+    fxa.internal.getUserAccountData().then(user => {
+      do_check_eq(user.email, bob.email);
+      do_check_eq(user.isVerified, true);
+      do_test_finished();
+      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.isVerified, 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() {
+  let fxa = new MockFxAccounts();
+
+  do_check_throws(function() {
+    yield fxa.getAssertion("nonaudience");
+  });
+
+  let creds = {
+    sessionToken: "sessionToken",
+    kA: expandHex("11"),
+    kB: expandHex("66"),
+    isVerified: true
+  };
+  // By putting kA/kB/isVerified in "creds", we skip ahead
+  // to the "we're ready" stage.
+  yield fxa.setSignedInUser(creds);
+
+  _("== ready to go\n");
+  let now = 138000000*1000;
+  let start = Date.now();
+  fxa._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._d_signCertificate.resolve("cert1");
+  let assertion = yield d;
+  let finish = Date.now();
+  do_check_eq(fxa._getCertificateSigned_calls.length, 1);
+  do_check_eq(fxa._getCertificateSigned_calls[0][0], "sessionToken");
+  do_check_neq(assertion, null);
+  _("ASSERTION: "+assertion+"\n");
+  let pieces = assertion.split("~");
+  do_check_eq(pieces[0], "cert1");
+  do_check_neq(fxa.internal.keyPair, undefined);
+  _(fxa.internal.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");
+  // FxAccounts KEY_LIFETIME
+  do_check_eq(fxa.internal.keyPair.validUntil, now + (12*3600*1000));
+  // FxAccounts CERT_LIFETIME
+  do_check_eq(fxa.internal.cert.validUntil, now + (6*3600*1000));
+  _("delta: "+(new Date(payload.exp) - now)+"\n");
+  let exp = Number(payload.exp);
+  // jwcrypto.jsm uses an unmocked Date.now()+2min to decide on the
+  // expiration time, so we test that it's inside a specific timebox
+  do_check_true(start + 2*60*1000 <= exp);
+  do_check_true(exp <= finish + 2*60*1000);
+
+  // Reset for next call.
+  fxa._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");
+  do_check_eq(fxa._getCertificateSigned_calls.length, 1);
+
+  // But "waiting" (i.e. incrementing "now") will need a new key+signature.
+  fxa._now_is = now + 24*3600*1000;
+  start = Date.now();
+  d = fxa.getAssertion("third.example.com");
+  fxa._d_signCertificate.resolve("cert2");
+  assertion = yield d;
+  finish = Date.now();
+  do_check_eq(fxa._getCertificateSigned_calls.length, 2);
+  do_check_eq(fxa._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, "third.example.com");
+  // 12*3600*1000 === FxAccounts KEY_LIFETIME
+  do_check_eq(fxa.internal.keyPair.validUntil, now + 24*3600*1000 + (12*3600*1000));
+  // 6*3600*1000 === FxAccounts CERT_LIFETIME
+  do_check_eq(fxa.internal.cert.validUntil, now + 24*3600*1000 + (6*3600*1000));
+  exp = Number(payload.exp);
+  do_check_true(start + 2*60*1000 <= exp);
+  do_check_true(exp <= finish + 2*60*1000);
+
+  _("----- DONE ----\n");
+});
+
+/*
+ * 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",
+    sessionToken: name + "'s session token",
+    keyFetchToken: name + "'s keyfetch token",
+    unwrapBKey: expandHex("44"),
+    isVerified: 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, stack);
+  }
+
+  if (result) {
+    do_throw("Expected result " + result + ", none thrown", stack);
+  }
+}
--- a/services/fxaccounts/tests/xpcshell/xpcshell.ini
+++ b/services/fxaccounts/tests/xpcshell/xpcshell.ini
@@ -1,6 +1,7 @@
 [DEFAULT]
 head = head.js ../../../common/tests/unit/head_helpers.js ../../../common/tests/unit/head_http.js
 tail =
 
+[test_accounts.js]
 [test_client.js]