Bug 1004319 - Handle server-side account changes in Gecko. r=jedp
authorSam Penrose <spenrose@mozilla.com>
Fri, 06 Jun 2014 08:54:27 -0700
changeset 206484 4720480c606b81fdfeafd27d371e8e469753481f
parent 206392 cb0c1f1666f7798be572a9836755162486a4a421
child 206485 cea5bf661931f3abd532bc1df0d5796463a8ab1f
push id3741
push userasasaki@mozilla.com
push dateMon, 21 Jul 2014 20:25:18 +0000
treeherdermozilla-beta@4d6f46f5af68 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersjedp
bugs1004319
milestone32.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 1004319 - Handle server-side account changes in Gecko. r=jedp
services/fxaccounts/FxAccountsClient.jsm
services/fxaccounts/FxAccountsManager.jsm
services/fxaccounts/tests/xpcshell/test_manager.js
--- a/services/fxaccounts/FxAccountsClient.jsm
+++ b/services/fxaccounts/FxAccountsClient.jsm
@@ -235,18 +235,21 @@ this.FxAccountsClient.prototype = {
    *
    * @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.
+   *        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 = this._deriveHawkCredentials(sessionTokenHex, "sessionToken");
 
     let body = { publicKey: serializedPublicKey,
                  duration: lifetime };
     return Promise.resolve()
       .then(_ => this._request("/certificate/sign", "POST", creds, body))
--- a/services/fxaccounts/FxAccountsManager.jsm
+++ b/services/fxaccounts/FxAccountsManager.jsm
@@ -147,32 +147,106 @@ this.FxAccountsManager = {
             });
           }
         );
       },
       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) {
+    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);
+              }
+            );
+          // ... otherwise, the account was deleted, so ask for Sign In/Up
+          } else {
+            return this._localSignOut().then(
+              () => {
+                return this._uiRequest(UI_REQUEST_SIGN_IN_FLOW, aAudience);
+              },
+              (reason) => { // reject primary problem, not signout failure
+                log.error("Signing out in response to server error threw: " + reason);
+                return this._error(reason);
+              }
+            );
+          }
+        }
+      );
+    }
+    return rejection;
+  },
+
   _getAssertion: function(aAudience) {
-    return this._fxAccounts.getAssertion(aAudience);
+    return this._fxAccounts.getAssertion(aAudience).then(
+      (result) => {
+        return result;
+      },
+      (reason) => {
+        return this._handleGetAssertionError(reason, aAudience);
+      }
+    );
+  },
+
+  _refreshAuthentication: function(aAudience, aEmail) {
+    this._refreshing = true;
+    return this._uiRequest(UI_REQUEST_REFRESH_AUTH,
+                           aAudience, aEmail).then(
+      (assertion) => {
+        this._refreshing = false;
+        return assertion;
+      },
+      (reason) => {
+        this._refreshing = false;
+        return this._signOut().then(
+          () => {
+            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._fxAccounts.signOut(true).then(
+    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();
         }
@@ -357,91 +431,76 @@ this.FxAccountsManager = {
         }
         log.debug(JSON.stringify(this._user));
       },
       reason => { this._serverError(reason); }
     );
   },
 
   /*
-   * Try to get an assertion for the given audience.
+   * 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 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, aOptions) {
     if (!aAudience) {
       return this._error(ERROR_INVALID_AUDIENCE);
     }
-
     if (Services.io.offline) {
       return this._error(ERROR_OFFLINE);
     }
-
     return this.getAccount().then(
       user => {
         if (user) {
-          // We cannot get assertions for unverified accounts.
+          // Three have-user cases to consider. First: are we unverified?
           if (!user.verified) {
             return this._error(ERROR_UNVERIFIED_ACCOUNT, {
               user: user
             });
           }
-
-          // RPs might require an authentication refresh.
+          // 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 will sometimes succeed silently.
+            // though it might succeed silently if we didn't reject here.
             if (aOptions.silent) {
               return this._error(ERROR_NO_SILENT_REFRESH_AUTH);
             }
-            if ((Date.now() / 1000) - this._activeSession.authAt > gracePeriod) {
-              // Grace period expired, so we sign out and request the user to
-              // authenticate herself again. If the authentication succeeds, we
-              // will return the assertion. Otherwise, we will return an error.
-              this._refreshing = true;
-              return this._uiRequest(UI_REQUEST_REFRESH_AUTH,
-                                     aAudience, user.email).then(
-                (assertion) => {
-                  this._refreshing = false;
-                  return assertion;
-                },
-                (reason) => {
-                  this._refreshing = false;
-                  return this._signOut().then(
-                    () => {
-                      return this._error(reason);
-                    }
-                  );
-                }
-              );
+            let secondsSinceAuth = (Date.now() / 1000) - this._activeSession.authAt;
+            if (secondsSinceAuth > gracePeriod) {
+              return this._refreshAuthentication(aAudience, user.email);
             }
           }
-
+          // 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.
           return this._getAssertion(aAudience);
         }
-
         log.debug("No signed in user");
-
         if (aOptions && aOptions.silent) {
           return Promise.resolve(null);
         }
-
-        // If there is no currently signed in user, we trigger the signIn UI
-        // flow.
         return this._uiRequest(UI_REQUEST_SIGN_IN_FLOW, aAudience);
       }
     );
   }
 
 };
 
 FxAccountsManager.init();
--- a/services/fxaccounts/tests/xpcshell/test_manager.js
+++ b/services/fxaccounts/tests/xpcshell/test_manager.js
@@ -7,16 +7,20 @@ const Cm = Components.manager;
 
 Cu.import("resource://gre/modules/FxAccounts.jsm");
 Cu.import("resource://gre/modules/FxAccountsCommon.js");
 Cu.import("resource://gre/modules/FxAccountsManager.jsm");
 Cu.import("resource://gre/modules/Promise.jsm");
 
 // === Mocks ===
 
+// Globals representing server state
+let passwordResetOnServer = false;
+let deletedOnServer = false;
+
 // Override FxAccountsUIGlue.
 const kFxAccountsUIGlueUUID = "{8f6d5d87-41ed-4bb5-aa28-625de57564c5}";
 const kFxAccountsUIGlueContractID =
   "@mozilla.org/fxaccounts/fxaccounts-ui-glue;1";
 
 // Save original FxAccountsUIGlue factory.
 const kFxAccountsUIGlueFactory =
   Cm.getClassObject(Cc[kFxAccountsUIGlueContractID], Ci.nsIFactory);
@@ -49,30 +53,32 @@ let FxAccountsUIGlue = {
   QueryInterface: XPCOMUtils.generateQI([Ci.nsIFxAccountsUIGlue]),
 
   _promise: function() {
     let deferred = Promise.defer();
 
     if (this._reject) {
       deferred.reject(this._error);
     } else {
+      passwordResetOnServer = false;
       FxAccountsManager._activeSession = this._activeSession || {
         email: "user@domain.org",
         verified: false,
         sessionToken: "1234"
       };
       FxAccountsManager._fxAccounts
                        .setSignedInUser(FxAccountsManager._activeSession);
       deferred.resolve(FxAccountsManager._activeSession);
     }
 
     return deferred.promise;
   },
 
   signInFlow: function() {
+    deletedOnServer = false;
     this._signInFlowCalled = true;
     return this._promise();
   },
 
   refreshAuthentication: function() {
     this._refreshAuthCalled = true;
     return this._promise();
   }
@@ -99,23 +105,33 @@ FxAccountsManager._fxAccounts = {
   _signedInUser: null,
 
   _reset: function() {
     this._getSignedInUserCalled = false;
     this._setSignedInUserCalled = false;
     this._reject = false;
   },
 
+  accountStatus: function() {
+    let deferred = Promise.defer();
+    deferred.resolve(!deletedOnServer);
+    return deferred.promise;
+  },
+
   getAssertion: function() {
     if (!this._signedInUser) {
       return null;
     }
 
     let deferred = Promise.defer();
-    deferred.resolve(this._assertion);
+    if (passwordResetOnServer || deletedOnServer) {
+      deferred.reject({errno: ERRNO_INVALID_AUTH_TOKEN});
+    } else {
+      deferred.resolve(this._assertion);
+    }
     return deferred.promise;
   },
 
   getSignedInUser: function() {
     this._getSignedInUserCalled = true;
     let deferred = Promise.defer();
     this._reject ? deferred.reject(this._error)
                  : deferred.resolve(this._signedInUser);
@@ -371,16 +387,48 @@ add_test(function(test_getAssertion_refr
       run_next_test();
     },
     error => {
       do_throw("Unexpected error: " + error);
     }
   );
 });
 
+add_test(function(test_getAssertion_server_state_change) {
+  FxAccountsManager._fxAccounts._signedInUser.verified = true;
+  FxAccountsManager._activeSession.verified = true;
+  passwordResetOnServer = true;
+  FxAccountsManager.getAssertion("audience").then(
+    (result) => {
+      // For password reset, the UIGlue mock simulates sucessful
+      // refreshAuth which supplies new password, not signin/signup.
+      do_check_true(FxAccountsUIGlue._refreshAuthCalled);
+      do_check_false(FxAccountsUIGlue._signInFlowCalled)
+      do_check_eq(result, "assertion");
+      FxAccountsUIGlue._refreshAuthCalled = false;
+    }
+  ).then(
+    () => {
+      deletedOnServer = true;
+      FxAccountsManager.getAssertion("audience").then(
+        (result) => {
+          // For account deletion, the UIGlue's signin/signup is called.
+          do_check_true(FxAccountsUIGlue._signInFlowCalled)
+          do_check_false(FxAccountsUIGlue._refreshAuthCalled);
+          do_check_eq(result, "assertion");
+          deletedOnServer = false;
+          passwordResetOnServer = false;
+          FxAccountsUIGlue._reset()
+          run_next_test();
+        }
+      );
+    }
+  );
+});
+
 add_test(function(test_getAssertion_refreshAuth_NaN) {
   do_print("= getAssertion refreshAuth NaN=");
   let gracePeriod = "NaN";
   FxAccountsManager.getAssertion("audience", {
     "refreshAuthentication": gracePeriod
   }).then(
     result => {
       do_throw("Unexpected success");