Bug 972070 - improve FxAccounts.jsm state management (aurora version, touches australis test). r=jedp, a=sylvestre
authorMark Hammond <mhammond@skippinet.com.au>
Fri, 07 Mar 2014 14:42:40 +1100
changeset 183200 12450a1263bd9b4b52a361aeb7698acecd962bbe
parent 183199 b91a791d42b8ea033555dead080e110ec1662795
child 183201 bcda1e3e802b66560d482544ebbb3a5f0822920e
push id3343
push userffxbld
push dateMon, 17 Mar 2014 21:55:32 +0000
treeherdermozilla-beta@2f7d3415f79f [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersjedp, sylvestre
bugs972070
milestone29.0a2
Bug 972070 - improve FxAccounts.jsm state management (aurora version, touches australis test). r=jedp, a=sylvestre
browser/components/customizableui/test/browser_946320_tabs_from_other_computers.js
services/fxaccounts/FxAccounts.jsm
services/fxaccounts/tests/xpcshell/test_accounts.js
services/sync/modules-testing/utils.js
services/sync/tests/unit/test_browserid_identity.js
--- a/browser/components/customizableui/test/browser_946320_tabs_from_other_computers.js
+++ b/browser/components/customizableui/test/browser_946320_tabs_from_other_computers.js
@@ -89,36 +89,36 @@ function configureFxAccountIdentity() {
   let token = {
     endpoint: Weave.Svc.Prefs.get("tokenServerURI"),
     duration: 300,
     id: "id",
     key: "key",
     // uid will be set to the username.
   };
 
-  let MockInternal = {
-    signedInUser: {
-      version: DATA_FORMAT_VERSION,
-      accountData: user
-    },
-    getCertificate: function(data, keyPair, mustBeValidUntil) {
-      this.cert = {
-        validUntil: Date.now() + CERT_LIFETIME,
-        cert: "certificate",
-      };
-      return Promise.resolve(this.cert.cert);
-    },
-  };
-
+  let MockInternal = {};
   let mockTSC = { // TokenServerClient
     getTokenFromBrowserIDAssertion: function(uri, assertion, cb) {
       token.uid = "username";
       cb(null, token);
     },
   };
 
   let authService = Weave.Service.identity;
   authService._fxaService = new FxAccounts(MockInternal);
+
+  authService._fxaService.internal.currentAccountState.signedInUser = {
+    version: DATA_FORMAT_VERSION,
+    accountData: user
+  }
+  authService._fxaService.internal.currentAccountState.getCertificate = function(data, keyPair, mustBeValidUntil) {
+    this.cert = {
+      validUntil: authService._fxaService.internal.now() + CERT_LIFETIME,
+      cert: "certificate",
+    };
+    return Promise.resolve(this.cert.cert);
+  };
+
   authService._tokenServerClient = mockTSC;
   // Set the "account" of the browserId manager to be the "email" of the
   // logged in user of the mockFXA service.
   authService._account = user.email;
 }
--- a/services/fxaccounts/FxAccounts.jsm
+++ b/services/fxaccounts/FxAccounts.jsm
@@ -35,16 +35,174 @@ let publicProperties = [
   "promiseAccountsForceSigninURI",
   "resendVerificationEmail",
   "setSignedInUser",
   "signOut",
   "version",
   "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.
+AccountState = function(fxaInternal) {
+  this.fxaInternal = fxaInternal;
+};
+
+AccountState.prototype = {
+  cert: null,
+  keyPair: null,
+  signedInUser: null,
+  whenVerifiedPromise: null,
+  whenKeysReadyPromise: null,
+
+  get isCurrent() this.fxaInternal && this.fxaInternal.currentAccountState === this,
+
+  abort: function() {
+    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("Verification aborted; Another user signing in"));
+      this.whenKeysReadyPromise = null;
+    }
+    this.cert = null;
+    this.keyPair = null;
+    this.signedInUser = null;
+    this.fxaInternal = null;
+  },
+
+  getUserAccountData: function() {
+    // Skip disk if user is cached.
+    if (this.signedInUser) {
+      return this.resolve(this.signedInUser.accountData);
+    }
+
+    return this.fxaInternal.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;
+        }
+        return this.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
+          return this.resolve(null);
+        }
+        return this.reject(err);
+      }
+    );
+  },
+
+  setUserAccountData: function(accountData) {
+    return this.fxaInternal.signedInUserStorage.get().then(record => {
+      if (!this.isCurrent) {
+        return this.reject(new Error("Another user has signed in"));
+      }
+      record.accountData = accountData;
+      this.signedInUser = record;
+      return this.fxaInternal.signedInUserStorage.set(record)
+        .then(() => this.resolve(accountData));
+    });
+  },
+
+
+  getCertificate: function(data, keyPair, mustBeValidUntil) {
+    log.debug("getCertificate" + JSON.stringify(this.signedInUser));
+    // TODO: get the lifetime from the cert's .exp field
+    if (this.cert && this.cert.validUntil > mustBeValidUntil) {
+      log.debug(" getCertificate already had one");
+      return this.resolve(this.cert.cert);
+    }
+    // else get our cert signed
+    let willBeValidUntil = this.fxaInternal.now() + CERT_LIFETIME;
+    return this.fxaInternal.getCertificateSigned(data.sessionToken,
+                                                 keyPair.serializedPublicKey,
+                                                 CERT_LIFETIME).then(
+      cert => {
+        this.cert = {
+          cert: cert,
+          validUntil: willBeValidUntil
+        };
+        return cert;
+      }
+    ).then(result => this.resolve(result));
+  },
+
+  getKeyPair: function(mustBeValidUntil) {
+    if (this.keyPair && (this.keyPair.validUntil > mustBeValidUntil)) {
+      log.debug("getKeyPair: already have a keyPair");
+      return this.resolve(this.keyPair.keyPair);
+    }
+    // Otherwse, create a keypair and set validity limit.
+    let willBeValidUntil = this.fxaInternal.now() + KEY_LIFETIME;
+    let d = Promise.defer();
+    jwcrypto.generateKeyPair("DS160", (err, kp) => {
+      if (err) {
+        return this.reject(err);
+      }
+      this.keyPair = {
+        keyPair: kp,
+        validUntil: willBeValidUntil
+      };
+      log.debug("got keyPair");
+      delete this.cert;
+      d.resolve(this.keyPair.keyPair);
+    });
+    return d.promise.then(result => this.resolve(result));
+  },
+
+  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: " + reason);
+      return Promise.reject(new Error("A different user signed in"));
+    }
+    return Promise.reject(error);
+  },
+
+}
 /**
  * The public API's constructor.
  */
 this.FxAccounts = function (mockInternal) {
   let internal = new FxAccountsInternal();
   let external = {};
 
   // Copy all public properties to the 'external' object.
@@ -64,40 +222,36 @@ this.FxAccounts = function (mockInternal
 
   return Object.freeze(external);
 }
 
 /**
  * The internal API's constructor.
  */
 function FxAccountsInternal() {
-  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;
+  // currentAccountState are used for this purpose.
+  // (XXX - should the timer be directly on the currentAccountState?)
   this.currentTimer = null;
-  this.generationCount = 0;
+  this.currentAccountState = new AccountState(this);
 
   this.fxAccountsClient = new FxAccountsClient();
 
   // 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,
@@ -172,28 +326,29 @@ FxAccountsInternal.prototype = {
    *          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.
    */
   getSignedInUser: function getSignedInUser() {
-    return this.getUserAccountData().then(data => {
+    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:
@@ -208,99 +363,90 @@ FxAccountsInternal.prototype = {
    *         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");
     this.abortExistingFlow();
 
     let record = {version: this.version, accountData: credentials};
+    let currentState = this.currentAccountState;
     // Cache a clone of the credentials object.
-    this.signedInUser = JSON.parse(JSON.stringify(record));
+    currentState.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 this.signedInUserStorage.set(record).then(() => {
       this.notifyObservers(ONLOGIN_NOTIFICATION);
       if (!this.isUserEmailVerified(credentials)) {
         this.startVerifiedCheck(credentials);
       }
-    });
+    }).then(result => currentState.resolve(result));
   },
 
   /**
    * 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 currentState = this.currentAccountState;
     let mustBeValidUntil = this.now() + ASSERTION_LIFETIME;
-    return this.getUserAccountData().then(data => {
+    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;
       }
-      return this.getKeyPair(mustBeValidUntil).then(keyPair => {
-        return this.getCertificate(data, keyPair, mustBeValidUntil)
+      return currentState.getKeyPair(mustBeValidUntil).then(keyPair => {
+        return currentState.getCertificate(data, keyPair, mustBeValidUntil)
           .then(cert => {
             return this.getAssertionFromCert(data, keyPair, cert, audience);
           });
       });
-    });
+    }).then(result => currentState.resolve(result));
   },
 
   /**
    * 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) {
-        this.pollEmailStatus(data.sessionToken, "start");
+        this.pollEmailStatus(currentState, data.sessionToken, "start");
         return this.fxAccountsClient.resendVerificationEmail(data.sessionToken);
       }
       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;
     }
-    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;
-    }
+    this.currentAccountState.abort();
+    this.currentAccountState = new AccountState(this);
   },
 
   signOut: function signOut() {
     this.abortExistingFlow();
-    this.signedInUser = null; // clear in-memory cache
+    this.currentAccountState.signedInUser = null; // clear in-memory cache
     return this.signedInUserStorage.set(null).then(() => {
       this.notifyObservers(ONLOGOUT_NOTIFICATION);
     });
   },
 
   /**
    * Fetch encryption keys for the signed-in-user from the FxA API server.
    *
@@ -316,48 +462,47 @@ FxAccountsInternal.prototype = {
    *          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() {
-    return this.getUserAccountData().then((data) => {
+    let currentState = this.currentAccountState;
+    return currentState.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();
+      if (!currentState.whenKeysReadyPromise) {
+        currentState.whenKeysReadyPromise = Promise.defer();
         this.fetchAndUnwrapKeys(data.keyFetchToken).then(data => {
-          if (this.whenKeysReadyPromise) {
-            this.whenKeysReadyPromise.resolve(data);
-          }
+          currentState.whenKeysReadyPromise.resolve(data);
         });
       }
-      return this.whenKeysReadyPromise.promise;
-    });
+      return currentState.whenKeysReadyPromise.promise;
+    }).then(result => currentState.resolve(result));
    },
 
   fetchAndUnwrapKeys: function(keyFetchToken) {
     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) {
         yield this.signOut();
         return null;
       }
-      let myGenerationCount = this.generationCount;
 
       let {kA, wrapKB} = yield this.fetchKeys(keyFetchToken);
 
-      let data = yield this.getUserAccountData();
+      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.
@@ -367,251 +512,162 @@ FxAccountsInternal.prototype = {
       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 (this.generationCount !== myGenerationCount) {
-        return null;
-      }
-
-      yield this.setUserAccountData(data);
-
+      yield currentState.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.
       this.notifyObservers(ONVERIFIED_NOTIFICATION);
       return data;
-    }.bind(this));
+    }.bind(this)).then(result => currentState.resolve(result));
   },
 
   getAssertionFromCert: function(data, keyPair, cert, audience) {
     log.debug("getAssertionFromCert");
     let payload = {};
     let d = Promise.defer();
     let options = {
       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);
         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;
-      }
-    );
+    return d.promise.then(result => currentState.resolve(result));
   },
 
   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;
+    return this.fxAccountsClient.signCertificate(
+      sessionToken,
+      JSON.parse(serializedPublicKey),
+      lifetime
+    );
   },
 
   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;
+    return this.currentAccountState.getUserAccountData();
   },
 
   isUserEmailVerified: function isUserEmailVerified(data) {
     return !!(data && data.verified);
   },
 
   /**
    * Setup for and if necessary do email verification polling.
    */
   loadAndPoll: function() {
-    return this.getUserAccountData()
+    let currentState = this.currentAccountState;
+    return currentState.getUserAccountData()
       .then(data => {
         if (data && !this.isUserEmailVerified(data)) {
-          this.pollEmailStatus(data.sessionToken, "start");
+          this.pollEmailStatus(currentState, 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 onverified observer
     // notification.
     return this.whenVerified(data)
-      .then((data) => this.getKeys(data));
+      .then(() => this.getKeys());
   },
 
   whenVerified: function(data) {
+    let currentState = this.currentAccountState;
     if (data.verified) {
       log.debug("already verified");
-      return Promise.resolve(data);
+      return currentState.resolve(data);
     }
-    if (!this.whenVerifiedPromise) {
+    if (!currentState.whenVerifiedPromise) {
       log.debug("whenVerified promise starts polling for verified email");
-      this.pollEmailStatus(data.sessionToken, "start");
+      this.pollEmailStatus(currentState, data.sessionToken, "start");
     }
-    return this.whenVerifiedPromise.promise;
+    return currentState.whenVerifiedPromise.promise.then(
+      result => currentState.resolve(result)
+    );
   },
 
   notifyObservers: function(topic) {
     log.debug("Notifying observers of " + topic);
     Services.obs.notifyObservers(null, topic, null);
   },
 
-  pollEmailStatus: function pollEmailStatus(sessionToken, why) {
-    let myGenerationCount = this.generationCount;
-    log.debug("entering pollEmailStatus: " + why + " " + myGenerationCount);
+  // XXX - pollEmailStatus should maybe be on the AccountState object?
+  pollEmailStatus: function pollEmailStatus(currentState, sessionToken, why) {
+    log.debug("entering pollEmailStatus: " + why);
     if (why == "start") {
       // 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.pollTimeRemaining = this.POLL_SESSION;
-      if (!this.whenVerifiedPromise) {
-        this.whenVerifiedPromise = Promise.defer();
+      if (!currentState.whenVerifiedPromise) {
+        currentState.whenVerifiedPromise = Promise.defer();
       }
     }
 
     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()
+          currentState.getUserAccountData()
             .then((data) => {
               data.verified = true;
-              return this.setUserAccountData(data);
+              return currentState.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;
+              if (currentState.whenVerifiedPromise) {
+                currentState.whenVerifiedPromise.resolve(data);
+                delete currentState.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);
+              this.pollEmailStatus(currentState, sessionToken, "timer")}, this.POLL_STEP);
             log.debug("started timer " + this.currentTimer);
           } else {
-            if (this.whenVerifiedPromise) {
-              this.whenVerifiedPromise.reject(
+            if (currentState.whenVerifiedPromise) {
+              currentState.whenVerifiedPromise.reject(
                 new Error("User email verification timed out.")
               );
-              delete this.whenVerifiedPromise;
+              delete currentState.whenVerifiedPromise;
             }
           }
         }
       });
     },
 
-  setUserAccountData: function(accountData) {
-    return this.signedInUserStorage.get().then(record => {
-      record.accountData = accountData;
-      this.signedInUser = record;
-      return this.signedInUserStorage.set(record)
-        .then(() => accountData);
-    });
-  },
-
   // Return the URI of the remote UI flows.
   getAccountsURI: function() {
     let url = Services.urlFormatter.formatURLPref("identity.fxaccounts.remote.uri");
     if (!/^https:/.test(url)) { // Comment to un-break emacs js-mode highlighting
       throw new Error("Firefox Accounts server must use HTTPS");
     }
     return url;
   },
@@ -627,25 +683,26 @@ FxAccountsInternal.prototype = {
 
   // Returns a promise that resolves with the URL to use to force a re-signin
   // of the current account.
   promiseAccountsForceSigninURI: function() {
     let url = Services.urlFormatter.formatURLPref("identity.fxaccounts.remote.force_auth.uri");
     if (!/^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));
   }
 };
 
 /**
  * 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.
  *
--- a/services/fxaccounts/tests/xpcshell/test_accounts.js
+++ b/services/fxaccounts/tests/xpcshell/test_accounts.js
@@ -397,27 +397,29 @@ add_task(function test_getAssertion() {
   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");
-  do_check_neq(fxa.internal.keyPair, undefined);
-  _(fxa.internal.keyPair.validUntil + "\n");
+  let keyPair = fxa.internal.currentAccountState.keyPair;
+  let cert = fxa.internal.currentAccountState.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(fxa.internal.keyPair.validUntil, start + KEY_LIFETIME);
-  do_check_eq(fxa.internal.cert.validUntil, start + CERT_LIFETIME);
+  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 + TWO_MINUTES_MS);
 
   // Reset for next call.
   fxa.internal._d_signCertificate = Promise.defer();
 
@@ -444,18 +446,20 @@ add_task(function test_getAssertion() {
   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".
 
-  do_check_eq(fxa.internal.keyPair.validUntil, start + KEY_LIFETIME);
-  do_check_eq(fxa.internal.cert.validUntil, start + CERT_LIFETIME);
+  keyPair = fxa.internal.currentAccountState.keyPair;
+  cert = fxa.internal.currentAccountState.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 + TWO_MINUTES_MS);
 
   // 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");
@@ -464,18 +468,20 @@ add_task(function test_getAssertion() {
   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");
-  do_check_eq(fxa.internal.keyPair.validUntil, now + KEY_LIFETIME);
-  do_check_eq(fxa.internal.cert.validUntil, now + CERT_LIFETIME);
+  keyPair = fxa.internal.currentAccountState.keyPair;
+  cert = fxa.internal.currentAccountState.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 + TWO_MINUTES_MS);
   _("----- DONE ----\n");
 });
 
 add_task(function test_resend_email_not_signed_in() {
   let fxa = new MockFxAccounts();
@@ -493,40 +499,41 @@ add_task(function test_resend_email_not_
 });
 
 add_task(function test_resend_email() {
   do_test_pending();
 
   let fxa = new MockFxAccounts();
   let alice = getTestUser("alice");
 
-  do_check_eq(fxa.internal.generationCount, 0);
+  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_eq(fxa.internal.generationCount, 1);
+    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_eq(fxa.internal.generationCount, 1);
+        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();
         do_test_finished();
         run_next_test();
--- a/services/sync/modules-testing/utils.js
+++ b/services/sync/modules-testing/utils.js
@@ -109,30 +109,30 @@ this.makeIdentityConfig = function(overr
   }
   return result;
 }
 
 // Configure an instance of an FxAccount identity provider with the specified
 // config (or the default config if not specified).
 this.configureFxAccountIdentity = function(authService,
                                            config = makeIdentityConfig()) {
-  let MockInternal = {
-    signedInUser: {
-      version: DATA_FORMAT_VERSION,
-      accountData: config.fxaccount.user
-    },
-    getCertificate: function(data, keyPair, mustBeValidUntil) {
-      this.cert = {
-        validUntil: Date.now() + CERT_LIFETIME,
-        cert: "certificate",
-      };
-      return Promise.resolve(this.cert.cert);
-    },
+  let MockInternal = {};
+  let fxa = new FxAccounts(MockInternal);
+
+  fxa.internal.currentAccountState.signedInUser = {
+    version: DATA_FORMAT_VERSION,
+    accountData: config.fxaccount.user
   };
-  let fxa = new FxAccounts(MockInternal);
+  fxa.internal.currentAccountState.getCertificate = function(data, keyPair, mustBeValidUntil) {
+    this.cert = {
+      validUntil: fxa.internal.now() + CERT_LIFETIME,
+      cert: "certificate",
+    };
+    return Promise.resolve(this.cert.cert);
+  };
 
   let mockTSC = { // TokenServerClient
     getTokenFromBrowserIDAssertion: function(uri, assertion, cb) {
       config.fxaccount.token.uid = config.username;
       cb(null, config.fxaccount.token);
     },
   };
   authService._fxaService = fxa;
--- a/services/sync/tests/unit/test_browserid_identity.js
+++ b/services/sync/tests/unit/test_browserid_identity.js
@@ -30,33 +30,33 @@ configureFxAccountIdentity(browseridMana
 let MockFxAccountsClient = function() {
   FxAccountsClient.apply(this);
 };
 MockFxAccountsClient.prototype = {
   __proto__: FxAccountsClient.prototype
 };
 
 function MockFxAccounts() {
-  return new FxAccounts({
+  let fxa = new FxAccounts({
     _now_is: Date.now(),
 
     now: function () {
       return this._now_is;
     },
 
-    getCertificate: function(data, keyPair, mustBeValidUntil) {
-      this.cert = {
-        validUntil: Date.now() + CERT_LIFETIME,
-        cert: "certificate",
-      };
-      return Promise.resolve(this.cert.cert);
-    },
-
     fxAccountsClient: new MockFxAccountsClient()
   });
+  fxa.internal.currentAccountState.getCertificate = function(data, keyPair, mustBeValidUntil) {
+    this.cert = {
+      validUntil: fxa.internal.now() + CERT_LIFETIME,
+      cert: "certificate",
+    };
+    return Promise.resolve(this.cert.cert);
+  };
+  return fxa;
 }
 
 function run_test() {
   initTestLogging("Trace");
   Log.repository.getLogger("Sync.Identity").level = Log.Level.Trace;
   run_next_test();
 };
 
@@ -142,17 +142,17 @@ add_test(function test_resourceAuthentic
 
   do_check_eq(fxa.now(), now);
   do_check_eq(fxa.localtimeOffsetMsec, localtimeOffsetMsec);
 
   // Mocks within mocks...
   configureFxAccountIdentity(browseridManager, identityConfig);
 
   // Ensure the new FxAccounts mock has a signed-in user.
-  fxa.internal.signedInUser = browseridManager._fxaService.internal.signedInUser;
+  fxa.internal.currentAccountState.signedInUser = browseridManager._fxaService.internal.currentAccountState.signedInUser;
 
   browseridManager._fxaService = fxa;
 
   do_check_eq(browseridManager._fxaService.internal.now(), now);
   do_check_eq(browseridManager._fxaService.internal.localtimeOffsetMsec,
       localtimeOffsetMsec);
 
   do_check_eq(browseridManager._fxaService.now(), now);
@@ -195,17 +195,17 @@ add_test(function test_RESTResourceAuthe
   fxaClient.hawk = hawkClient;
   let fxa = new MockFxAccounts();
   fxa.internal._now_is = now;
   fxa.internal.fxAccountsClient = fxaClient;
 
   configureFxAccountIdentity(browseridManager, identityConfig);
 
   // Ensure the new FxAccounts mock has a signed-in user.
-  fxa.internal.signedInUser = browseridManager._fxaService.internal.signedInUser;
+  fxa.internal.currentAccountState.signedInUser = browseridManager._fxaService.internal.currentAccountState.signedInUser;
 
   browseridManager._fxaService = fxa;
 
   do_check_eq(browseridManager._fxaService.internal.now(), now);
 
   let request = new SyncStorageRequest("https://example.net/i/like/pie/");
   let authenticator = browseridManager.getResourceAuthenticator();
   let output = authenticator(request, 'GET');