Bug 1383663 part 3 - Update FxA local state on profile email change. r?markh draft
authorEdouard Oger <eoger@fastmail.com>
Mon, 21 Aug 2017 17:01:57 -0400
changeset 665957 c0fc5f79f32cdd855c9c5f5739f75e5662c1ffc8
parent 665956 2c3efe762d7a46dae42dd0aef4613bb16054dfc6
child 731940 671c8ab3afc1317c63b2e54f6bf6b04137f1c1d5
push id80234
push userbmo:eoger@fastmail.com
push dateSat, 16 Sep 2017 19:17:57 +0000
reviewersmarkh
bugs1383663
milestone57.0a1
Bug 1383663 part 3 - Update FxA local state on profile email change. r?markh MozReview-Commit-ID: 5epKjoT4TF3
services/crypto/modules/utils.js
services/fxaccounts/FxAccounts.jsm
services/fxaccounts/FxAccountsCommon.js
services/fxaccounts/FxAccountsProfile.jsm
services/fxaccounts/FxAccountsStorage.jsm
services/fxaccounts/FxAccountsWebChannel.jsm
services/fxaccounts/tests/xpcshell/test_accounts.js
services/fxaccounts/tests/xpcshell/test_profile.js
services/fxaccounts/tests/xpcshell/test_web_channel.js
tools/lint/eslint/modules.json
--- a/services/crypto/modules/utils.js
+++ b/services/crypto/modules/utils.js
@@ -108,16 +108,25 @@ this.CryptoUtils = {
 
   sha256(message) {
     let hasher = Cc["@mozilla.org/security/hash;1"]
                  .createInstance(Ci.nsICryptoHash);
     hasher.init(hasher.SHA256);
     return CommonUtils.bytesAsHex(CryptoUtils.digestUTF8(message, hasher));
   },
 
+  sha256Base64(message) {
+    let data = this._utf8Converter.convertToByteArray(message, {});
+    let hasher = Cc["@mozilla.org/security/hash;1"]
+                 .createInstance(Ci.nsICryptoHash);
+    hasher.init(hasher.SHA256);
+    hasher.update(data, data.length);
+    return hasher.finish(true);
+  },
+
   /**
    * Produce an HMAC key object from a key string.
    */
   makeHMACKey: function makeHMACKey(str) {
     return Svc.KeyFactory.keyFromString(Ci.nsIKeyObject.HMAC, str);
   },
 
   /**
--- a/services/fxaccounts/FxAccounts.jsm
+++ b/services/fxaccounts/FxAccounts.jsm
@@ -46,18 +46,19 @@ var publicProperties = [
   "getAssertion",
   "getDeviceId",
   "getDeviceList",
   "getKeys",
   "getOAuthToken",
   "getProfileCache",
   "getSignedInUser",
   "getSignedInUserProfile",
+  "handleAccountDestroyed",
   "handleDeviceDisconnection",
-  "handleAccountDestroyed",
+  "handleEmailUpdated",
   "hasLocalSession",
   "invalidateCertificate",
   "loadAndPoll",
   "localtimeOffsetMsec",
   "notifyDevices",
   "now",
   "promiseAccountsChangeProfileURI",
   "promiseAccountsForceSigninURI",
@@ -588,35 +589,34 @@ FxAccountsInternal.prototype = {
     });
   },
 
   /**
    * Update account data for the currently signed in user.
    *
    * @param credentials
    *        The credentials object containing the fields to be updated.
-   *        This object must contain |email| and |uid| fields and they must
+   *        This object must contain the |uid| field and it must
    *        match the currently signed in user.
    */
   updateUserAccountData(credentials) {
     log.debug("updateUserAccountData called with fields", Object.keys(credentials));
     if (logPII) {
       log.debug("updateUserAccountData called with data", credentials);
     }
     let currentAccountState = this.currentAccountState;
     return currentAccountState.promiseInitialized.then(() => {
-      return currentAccountState.getUserAccountData(["email", "uid"]);
+      return currentAccountState.getUserAccountData(["uid"]);
     }).then(existing => {
-      if (existing.email != credentials.email || existing.uid != credentials.uid) {
+      if (existing.uid != credentials.uid) {
         throw new Error("The specified credentials aren't for the current user");
       }
-      // We need to nuke email and uid as storage will complain if we try and
-      // update them (even when the value is the same)
+      // We need to nuke uid as storage will complain if we try and
+      // update it (even when the value is the same)
       credentials = Cu.cloneInto(credentials, {}); // clone it first
-      delete credentials.email;
       delete credentials.uid;
       return currentAccountState.updateUserAccountData(credentials);
     });
   },
 
   /**
    * returns a promise that fires with the assertion.  If there is no verified
    * signed-in user, fires with null.
@@ -1602,16 +1602,21 @@ FxAccountsInternal.prototype = {
     if (isLocalDevice) {
       this.signOut(true);
     }
     const data = JSON.stringify({ isLocalDevice });
     Services.obs.notifyObservers(null, ON_DEVICE_DISCONNECTED_NOTIFICATION, data);
     return null;
   },
 
+  handleEmailUpdated(newEmail) {
+    Services.prefs.setStringPref(PREF_LAST_FXA_USER, CryptoUtils.sha256Base64(newEmail));
+    return this.currentAccountState.updateUserAccountData({ email: newEmail });
+  },
+
   async handleAccountDestroyed(uid) {
     const accountData = await this.currentAccountState.getUserAccountData();
     const localUid = accountData ? accountData.uid : null;
     if (!localUid) {
       log.info(`Account destroyed push notification received, but we're already logged-out`);
       return null;
     }
     if (uid == localUid) {
--- a/services/fxaccounts/FxAccountsCommon.js
+++ b/services/fxaccounts/FxAccountsCommon.js
@@ -108,16 +108,18 @@ exports.UI_REQUEST_SIGN_IN_FLOW = "signI
 exports.UI_REQUEST_REFRESH_AUTH = "refreshAuthentication";
 
 // The OAuth client ID for Firefox Desktop
 exports.FX_OAUTH_CLIENT_ID = "5882386c6d801776";
 
 // Firefox Accounts WebChannel ID
 exports.WEBCHANNEL_ID = "account_updates";
 
+exports.PREF_LAST_FXA_USER = "identity.fxaccounts.lastSignedInUserHash";
+
 // Server errno.
 // From https://github.com/mozilla/fxa-auth-server/blob/master/docs/api.md#response-format
 exports.ERRNO_ACCOUNT_ALREADY_EXISTS         = 101;
 exports.ERRNO_ACCOUNT_DOES_NOT_EXIST         = 102;
 exports.ERRNO_INCORRECT_PASSWORD             = 103;
 exports.ERRNO_UNVERIFIED_ACCOUNT             = 104;
 exports.ERRNO_INVALID_VERIFICATION_CODE      = 105;
 exports.ERRNO_NOT_VALID_JSON_BODY            = 106;
--- a/services/fxaccounts/FxAccountsProfile.jsm
+++ b/services/fxaccounts/FxAccountsProfile.jsm
@@ -66,83 +66,76 @@ this.FxAccountsProfile.prototype = {
 
   _notifyProfileChange(uid) {
     this._isNotifying = true;
     Services.obs.notifyObservers(null, ON_PROFILE_CHANGE_NOTIFICATION, uid);
     this._isNotifying = false;
   },
 
   // Cache fetched data and send out a notification so that UI can update.
-  _cacheProfile(response) {
+  async _cacheProfile(response) {
+    const profile = response.body;
+    const userData = await this.fxa.getSignedInUser();
+    if (profile.uid != userData.uid) {
+      throw new Error("The fetched profile does not correspond with the current account.")
+    }
     let profileCache = {
-      profile: response.body,
+      profile,
       etag: response.etag
     };
-
-    return this.fxa.setProfileCache(profileCache)
-      .then(() => {
-        return this.fxa.getSignedInUser();
-      })
-      .then(userData => {
-        log.debug("notifying profile changed for user ${uid}", userData);
-        this._notifyProfileChange(userData.uid);
-        return response.body;
-      });
+    await this.fxa.setProfileCache(profileCache);
+    if (profile.email != userData.email) {
+      await this.fxa.handleEmailUpdated(profile.email);
+    }
+    log.debug("notifying profile changed for user ${uid}", userData);
+    this._notifyProfileChange(userData.uid);
+    return profile;
   },
 
-  _fetchAndCacheProfileInternal() {
-    let onFinally = () => {
+  async _fetchAndCacheProfileInternal() {
+    try {
+      const profileCache = await this.fxa.getProfileCache();
+      const etag = profileCache ? profileCache.etag : null;
+      const response = await this.client.fetchProfile(etag);
+
+      // response may be null if the profile was not modified (same ETag).
+      if (!response) {
+        return null;
+      }
+      return await this._cacheProfile(response);
+    } finally {
       this._cachedAt = Date.now();
       this._currentFetchPromise = null;
     }
-    return this.fxa.getProfileCache()
-      .then(profileCache => {
-        const etag = profileCache ? profileCache.etag : null;
-        return this.client.fetchProfile(etag);
-      })
-      .then(response => {
-        // response may be null if the profile was not modified (same ETag).
-        return response ? this._cacheProfile(response) : null;
-      })
-      .then(body => { // finally block
-        onFinally();
-        // body may be null if the profile was not modified
-        return body;
-      }, err => {
-        onFinally();
-        throw err;
-      });
   },
 
   _fetchAndCacheProfile() {
     if (!this._currentFetchPromise) {
       this._currentFetchPromise = this._fetchAndCacheProfileInternal();
     }
     return this._currentFetchPromise;
   },
 
   // Returns cached data right away if available, then fetches the latest profile
   // data in the background. After data is fetched a notification will be sent
   // out if the profile has changed.
-  getProfile() {
-    return this.fxa.getProfileCache()
-      .then(profileCache => {
-        if (profileCache) {
-          if (Date.now() > this._cachedAt + this.PROFILE_FRESHNESS_THRESHOLD) {
-            // Note that _fetchAndCacheProfile isn't returned, so continues
-            // in the background.
-            this._fetchAndCacheProfile().catch(err => {
-              log.error("Background refresh of profile failed", err);
-            });
-          } else {
-            log.trace("not checking freshness of profile as it remains recent");
-          }
-          return profileCache.profile;
-        }
-        return this._fetchAndCacheProfile();
+  async getProfile() {
+    const profileCache = await this.fxa.getProfileCache();
+    if (!profileCache) {
+      return this._fetchAndCacheProfile();
+    }
+    if (Date.now() > this._cachedAt + this.PROFILE_FRESHNESS_THRESHOLD) {
+      // Note that _fetchAndCacheProfile isn't returned, so continues
+      // in the background.
+      this._fetchAndCacheProfile().catch(err => {
+        log.error("Background refresh of profile failed", err);
       });
+    } else {
+      log.trace("not checking freshness of profile as it remains recent");
+    }
+    return profileCache.profile;
   },
 
   QueryInterface: XPCOMUtils.generateQI([
       Ci.nsIObserver,
       Ci.nsISupportsWeakReference,
   ]),
 };
--- a/services/fxaccounts/FxAccountsStorage.jsm
+++ b/services/fxaccounts/FxAccountsStorage.jsm
@@ -203,21 +203,18 @@ this.FxAccountsStorageManager.prototype 
   // a different user, nor to set the user as signed-out.
   async updateAccountData(newFields) {
     await this._promiseInitialized;
     if (!("uid" in this.cachedPlain)) {
       // If this storage instance shows no logged in user, then you can't
       // update fields.
       throw new Error("No user is logged in");
     }
-    if (!newFields || "uid" in newFields || "email" in newFields) {
-      // Once we support
-      // user changing email address this may need to change, but it's not
-      // clear how we would be told of such a change anyway...
-      throw new Error("Can't change uid or email address");
+    if (!newFields || "uid" in newFields) {
+      throw new Error("Can't change uid");
     }
     log.debug("_updateAccountData with items", Object.keys(newFields));
     // work out what bucket.
     for (let [name, value] of Object.entries(newFields)) {
       if (FXA_PWDMGR_MEMORY_FIELDS.has(name)) {
         if (value == null) {
           delete this.cachedMemory[name];
         } else {
--- a/services/fxaccounts/FxAccountsWebChannel.jsm
+++ b/services/fxaccounts/FxAccountsWebChannel.jsm
@@ -24,28 +24,28 @@ XPCOMUtils.defineLazyModuleGetter(this, 
 XPCOMUtils.defineLazyModuleGetter(this, "fxAccounts",
                                   "resource://gre/modules/FxAccounts.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "FxAccountsStorageManagerCanStoreField",
                                   "resource://gre/modules/FxAccountsStorage.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "PrivateBrowsingUtils",
                                   "resource://gre/modules/PrivateBrowsingUtils.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "Weave",
                                   "resource://services-sync/main.js");
+XPCOMUtils.defineLazyModuleGetter(this, "CryptoUtils",
+                                  "resource://services-crypto/utils.js");
 
 const COMMAND_PROFILE_CHANGE       = "profile:change";
 const COMMAND_CAN_LINK_ACCOUNT     = "fxaccounts:can_link_account";
 const COMMAND_LOGIN                = "fxaccounts:login";
 const COMMAND_LOGOUT               = "fxaccounts:logout";
 const COMMAND_DELETE               = "fxaccounts:delete";
 const COMMAND_SYNC_PREFERENCES     = "fxaccounts:sync_preferences";
 const COMMAND_CHANGE_PASSWORD      = "fxaccounts:change_password";
 const COMMAND_FXA_STATUS           = "fxaccounts:fxa_status";
 
-const PREF_LAST_FXA_USER           = "identity.fxaccounts.lastSignedInUserHash";
-
 // These engines were added years after Sync had been introduced, they need
 // special handling since they are system add-ons and are un-available on
 // older versions of Firefox.
 const EXTRA_ENGINES = ["addresses", "creditcards"];
 
 /**
  * A helper function that extracts the message and stack from an error object.
  * Returns a `{ message, stack }` tuple. `stack` will be null if the error
@@ -454,34 +454,17 @@ this.FxAccountsWebChannelHelpers.prototy
   },
 
   /**
    * Given an account name, set the hash of the previously signed in account
    *
    * @param acctName the account name of the user's account.
    */
   setPreviousAccountNameHashPref(acctName) {
-    Services.prefs.setStringPref(PREF_LAST_FXA_USER, this.sha256(acctName));
-  },
-
-  /**
-   * Given a string, returns the SHA265 hash in base64
-   */
-  sha256(str) {
-    let converter = Cc["@mozilla.org/intl/scriptableunicodeconverter"]
-                      .createInstance(Ci.nsIScriptableUnicodeConverter);
-    converter.charset = "UTF-8";
-    // Data is an array of bytes.
-    let data = converter.convertToByteArray(str, {});
-    let hasher = Cc["@mozilla.org/security/hash;1"]
-                   .createInstance(Ci.nsICryptoHash);
-    hasher.init(hasher.SHA256);
-    hasher.update(data, data.length);
-
-    return hasher.finish(true);
+    Services.prefs.setStringPref(PREF_LAST_FXA_USER, CryptoUtils.sha256Base64(acctName));
   },
 
   /**
    * Open Sync Preferences in the current tab of the browser
    *
    * @param {Object} browser the browser in which to open preferences
    * @param {String} [entryPoint] entryPoint to use for logging
    */
@@ -499,17 +482,17 @@ this.FxAccountsWebChannelHelpers.prototy
    * If a user signs in using a different account, the data from the
    * previous account and the new account will be merged. Ask the user
    * if they want to continue.
    *
    * @private
    */
   _needRelinkWarning(acctName) {
     let prevAcctHash = this.getPreviousAccountNameHashPref();
-    return prevAcctHash && prevAcctHash != this.sha256(acctName);
+    return prevAcctHash && prevAcctHash != CryptoUtils.sha256Base64(acctName);
   },
 
   /**
    * Show the user a warning dialog that the data from the previous account
    * and the new account will be merged.
    *
    * @private
    */
--- a/services/fxaccounts/tests/xpcshell/test_accounts.js
+++ b/services/fxaccounts/tests/xpcshell/test_accounts.js
@@ -294,31 +294,25 @@ add_task(async function test_update_acco
     email: credentials.email,
     uid: credentials.uid,
     assertion: "new_assertion",
   }
   await account.updateUserAccountData(newCreds);
   do_check_eq((await account.getSignedInUser()).assertion, "new_assertion",
               "new field value was saved");
 
-  // but we should fail attempting to change email or uid.
-  newCreds = {
-    email: "someoneelse@example.com",
-    uid: credentials.uid,
-    assertion: "new_assertion",
-  }
-  await Assert.rejects(account.updateUserAccountData(newCreds));
+  // but we should fail attempting to change the uid.
   newCreds = {
     email: credentials.email,
     uid: "another_uid",
     assertion: "new_assertion",
   }
   await Assert.rejects(account.updateUserAccountData(newCreds));
 
-  // should fail without email or uid.
+  // should fail without the uid.
   newCreds = {
     assertion: "new_assertion",
   }
   await Assert.rejects(account.updateUserAccountData(newCreds));
 
   // and should fail with a field name that's not known by storage.
   newCreds = {
     email: credentials.email,
--- a/services/fxaccounts/tests/xpcshell/test_profile.js
+++ b/services/fxaccounts/tests/xpcshell/test_profile.js
@@ -1,16 +1,17 @@
 /* Any copyright is dedicated to the Public Domain.
  * http://creativecommons.org/publicdomain/zero/1.0/ */
 
 "use strict";
 
 Cu.import("resource://gre/modules/FxAccountsCommon.js");
 Cu.import("resource://gre/modules/FxAccountsProfileClient.jsm");
 Cu.import("resource://gre/modules/FxAccountsProfile.jsm");
+Cu.import("resource://gre/modules/PromiseUtils.jsm");
 
 const URL_STRING = "https://example.com";
 Services.prefs.setCharPref("identity.fxaccounts.settings.uri", "https://example.com/settings");
 
 const STATUS_SUCCESS = 200;
 
 /**
  * Mock request responder
@@ -54,18 +55,21 @@ let mockResponseError = function(error) 
 let mockClient = function(fxa) {
   let options = {
     serverURL: "http://127.0.0.1:1111/v1",
     fxa,
   }
   return new FxAccountsProfileClient(options);
 };
 
+const ACCOUNT_UID = "abc123";
+const ACCOUNT_EMAIL = "foo@bar.com";
 const ACCOUNT_DATA = {
-  uid: "abc123"
+  uid: ACCOUNT_UID,
+  email: ACCOUNT_EMAIL
 };
 
 function FxaMock() {
 }
 FxaMock.prototype = {
   currentAccountState: {
     profile: null,
     get isCurrent() {
@@ -117,23 +121,23 @@ add_test(function cacheProfile_change() 
   let profile = CreateFxAccountsProfile(fxa);
 
   makeObserver(ON_PROFILE_CHANGE_NOTIFICATION, function(subject, topic, data) {
     do_check_eq(data, ACCOUNT_DATA.uid);
     do_check_true(setProfileCacheCalled);
     run_next_test();
   });
 
-  return profile._cacheProfile({ body: { avatar: "myurl" }, etag: "bogusetag" });
+  return profile._cacheProfile({ body: { uid: ACCOUNT_UID, email: ACCOUNT_EMAIL, avatar: "myurl" }, etag: "bogusetag" });
 });
 
 add_test(function fetchAndCacheProfile_ok() {
   let client = mockClient(mockFxa());
   client.fetchProfile = function() {
-    return Promise.resolve({ body: { avatar: "myimg"} });
+    return Promise.resolve({ body: { uid: ACCOUNT_UID, avatar: "myimg"} });
   };
   let profile = CreateFxAccountsProfile(null, client);
   profile._cachedAt = 12345;
 
   profile._cacheProfile = function(toCache) {
     do_check_eq(toCache.body.avatar, "myimg");
     return Promise.resolve(toCache.body);
   };
@@ -164,17 +168,17 @@ add_test(function fetchAndCacheProfile_a
 });
 
 add_test(function fetchAndCacheProfile_sendsETag() {
   let fxa = mockFxa();
   fxa.profileCache = { profile: {}, etag: "bogusETag" };
   let client = mockClient(fxa);
   client.fetchProfile = function(etag) {
     do_check_eq(etag, "bogusETag");
-    return Promise.resolve({ body: { avatar: "myimg"} });
+    return Promise.resolve({ body: { uid: ACCOUNT_UID, email: ACCOUNT_EMAIL, avatar: "myimg"} });
   };
   let profile = CreateFxAccountsProfile(fxa, client);
 
   return profile._fetchAndCacheProfile()
     .then(result => {
       run_next_test();
     });
 });
@@ -190,36 +194,28 @@ add_task(async function fetchAndCachePro
   });
   let numFetches = 0;
   let client = mockClient(mockFxa());
   client.fetchProfile = function() {
     numFetches += 1;
     return promiseProfile;
   };
   let fxa = mockFxa();
-  fxa.getProfileCache = () => {
-    // We do this because we are gonna have a race condition and fetchProfile will
-    // not be called before we check numFetches.
-    return {
-      then(thenFunc) {
-        return thenFunc();
-      }
-    }
-  };
   let profile = CreateFxAccountsProfile(fxa, client);
 
   let request1 = profile._fetchAndCacheProfile();
   profile._fetchAndCacheProfile();
+  await new Promise(res => setTimeout(res, 0)); // Yield so fetchProfile() is called (promise)
 
   // should be one request made to fetch the profile (but the promise returned
   // by it remains unresolved)
   do_check_eq(numFetches, 1);
 
   // resolve the promise.
-  resolveProfile({ body: { avatar: "myimg"} });
+  resolveProfile({ body: { uid: ACCOUNT_UID, email: ACCOUNT_EMAIL, avatar: "myimg"} });
 
   // both requests should complete with the same data.
   let got1 = await request1;
   do_check_eq(got1.avatar, "myimg");
   let got2 = await request1;
   do_check_eq(got2.avatar, "myimg");
 
   // and still only 1 request was made.
@@ -237,29 +233,21 @@ add_task(async function fetchAndCachePro
   });
   let numFetches = 0;
   let client = mockClient(mockFxa());
   client.fetchProfile = function() {
     numFetches += 1;
     return promiseProfile;
   };
   let fxa = mockFxa();
-  fxa.getProfileCache = () => {
-    // We do this because we are gonna have a race condition and fetchProfile will
-    // not be called before we check numFetches.
-    return {
-      then(thenFunc) {
-        return thenFunc();
-      }
-    }
-  };
   let profile = CreateFxAccountsProfile(fxa, client);
 
   let request1 = profile._fetchAndCacheProfile();
   let request2 = profile._fetchAndCacheProfile();
+  await new Promise(res => setTimeout(res, 0)); // Yield so fetchProfile() is called (promise)
 
   // should be one request made to fetch the profile (but the promise returned
   // by it remains unresolved)
   do_check_eq(numFetches, 1);
 
   // reject the promise.
   rejectProfile("oh noes");
 
@@ -278,27 +266,27 @@ add_task(async function fetchAndCachePro
   } catch (ex) {
     if (ex != "oh noes") {
       throw ex;
     }
   }
 
   // but a new request should works.
   client.fetchProfile = function() {
-    return Promise.resolve({body: { avatar: "myimg"}});
+    return Promise.resolve({body: { uid: ACCOUNT_UID, email: ACCOUNT_EMAIL, avatar: "myimg"}});
   };
 
   let got = await profile._fetchAndCacheProfile();
   do_check_eq(got.avatar, "myimg");
 });
 
 add_test(function fetchAndCacheProfile_alreadyCached() {
   let cachedUrl = "cachedurl";
   let fxa = mockFxa();
-  fxa.profileCache = { profile: { avatar: cachedUrl }, etag: "bogusETag" };
+  fxa.profileCache = { profile: { uid: ACCOUNT_UID, avatar: cachedUrl }, etag: "bogusETag" };
   let client = mockClient(fxa);
   client.fetchProfile = function(etag) {
     do_check_eq(etag, "bogusETag");
     return Promise.resolve(null);
   };
 
   let profile = CreateFxAccountsProfile(fxa, client);
   profile._cacheProfile = function(toCache) {
@@ -313,56 +301,70 @@ add_test(function fetchAndCacheProfile_a
     });
 });
 
 // Check that a new profile request within PROFILE_FRESHNESS_THRESHOLD of the
 // last one doesn't kick off a new request to check the cached copy is fresh.
 add_task(async function fetchAndCacheProfileAfterThreshold() {
   let numFetches = 0;
   let client = mockClient(mockFxa());
-  client.fetchProfile = function() {
+  client.fetchProfile = async function() {
     numFetches += 1;
-    return Promise.resolve({ avatar: "myimg"});
+    return {body: {uid: ACCOUNT_UID, email: ACCOUNT_EMAIL, avatar: "myimg"}};
   };
   let profile = CreateFxAccountsProfile(null, client);
   profile.PROFILE_FRESHNESS_THRESHOLD = 1000;
 
   await profile.getProfile();
   do_check_eq(numFetches, 1);
 
   await profile.getProfile();
   do_check_eq(numFetches, 1);
 
   await new Promise(resolve => {
     do_timeout(1000, resolve);
   });
 
+  let origFetchAndCatch = profile._fetchAndCacheProfile;
+  let backgroundFetchDone = PromiseUtils.defer();
+  profile._fetchAndCacheProfile = async () => {
+    await origFetchAndCatch.call(profile);
+    backgroundFetchDone.resolve();
+  }
   await profile.getProfile();
+  await backgroundFetchDone.promise;
   do_check_eq(numFetches, 2);
 });
 
 // Check that a new profile request within PROFILE_FRESHNESS_THRESHOLD of the
 // last one *does* kick off a new request if ON_PROFILE_CHANGE_NOTIFICATION
 // is sent.
 add_task(async function fetchAndCacheProfileBeforeThresholdOnNotification() {
   let numFetches = 0;
   let client = mockClient(mockFxa());
-  client.fetchProfile = function() {
+  client.fetchProfile = async function() {
     numFetches += 1;
-    return Promise.resolve({ avatar: "myimg"});
+    return {body: {uid: ACCOUNT_UID, email: ACCOUNT_EMAIL, avatar: "myimg"}};
   };
   let profile = CreateFxAccountsProfile(null, client);
   profile.PROFILE_FRESHNESS_THRESHOLD = 1000;
 
   await profile.getProfile();
   do_check_eq(numFetches, 1);
 
   Services.obs.notifyObservers(null, ON_PROFILE_CHANGE_NOTIFICATION);
 
+  let origFetchAndCatch = profile._fetchAndCacheProfile;
+  let backgroundFetchDone = PromiseUtils.defer();
+  profile._fetchAndCacheProfile = async () => {
+    await origFetchAndCatch.call(profile);
+    backgroundFetchDone.resolve();
+  }
   await profile.getProfile();
+  await backgroundFetchDone.promise;
   do_check_eq(numFetches, 2);
 });
 
 add_test(function tearDown_ok() {
   let profile = CreateFxAccountsProfile();
 
   do_check_true(!!profile.client);
   do_check_true(!!profile.fxa);
@@ -374,17 +376,17 @@ add_test(function tearDown_ok() {
   run_next_test();
 });
 
 add_test(function getProfile_ok() {
   let cachedUrl = "myurl";
   let didFetch = false;
 
   let fxa = mockFxa();
-  fxa.profileCache = { profile: { avatar: cachedUrl } };
+  fxa.profileCache = { profile: { uid: ACCOUNT_UID, avatar: cachedUrl } };
   let profile = CreateFxAccountsProfile(fxa);
 
   profile._fetchAndCacheProfile = function() {
     didFetch = true;
     return Promise.resolve();
   };
 
   return profile.getProfile()
@@ -397,37 +399,37 @@ add_test(function getProfile_ok() {
 
 add_test(function getProfile_no_cache() {
   let fetchedUrl = "newUrl";
   let fxa = mockFxa();
   fxa.profileCache = null;
   let profile = CreateFxAccountsProfile(fxa);
 
   profile._fetchAndCacheProfile = function() {
-    return Promise.resolve({ avatar: fetchedUrl });
+    return Promise.resolve({ uid: ACCOUNT_UID, avatar: fetchedUrl });
   };
 
   return profile.getProfile()
     .then(result => {
       do_check_eq(result.avatar, fetchedUrl);
       run_next_test();
     });
 });
 
 add_test(function getProfile_has_cached_fetch_deleted() {
   let cachedUrl = "myurl";
 
   let fxa = mockFxa();
   let client = mockClient(fxa);
   client.fetchProfile = function() {
-    return Promise.resolve({ body: { avatar: null } });
+    return Promise.resolve({ body: { uid: ACCOUNT_UID, email: ACCOUNT_EMAIL, avatar: null } });
   };
 
   let profile = CreateFxAccountsProfile(fxa, client);
-  fxa.profileCache = { profile: { avatar: cachedUrl } };
+  fxa.profileCache = { profile: { uid: ACCOUNT_UID, email: ACCOUNT_EMAIL, avatar: cachedUrl } };
 
 // instead of checking this in a mocked "save" function, just check after the
 // observer
   makeObserver(ON_PROFILE_CHANGE_NOTIFICATION, function(subject, topic, data) {
     profile.getProfile()
       .then(profileData => {
         do_check_null(profileData.avatar);
         run_next_test();
@@ -437,28 +439,43 @@ add_test(function getProfile_has_cached_
   return profile.getProfile()
     .then(result => {
       do_check_eq(result.avatar, "myurl");
     });
 });
 
 add_test(function getProfile_fetchAndCacheProfile_throws() {
   let fxa = mockFxa();
-  fxa.profileCache = { profile: { avatar: "myimg" } };
+  fxa.profileCache = { profile: { uid: ACCOUNT_UID, email: ACCOUNT_EMAIL, avatar: "myimg" } };
   let profile = CreateFxAccountsProfile(fxa);
 
   profile._fetchAndCacheProfile = () => Promise.reject(new Error());
 
   return profile.getProfile()
     .then(result => {
       do_check_eq(result.avatar, "myimg");
       run_next_test();
     });
 });
 
+add_test(function getProfile_email_changed() {
+  let fxa = mockFxa();
+  let client = mockClient(fxa);
+  client.fetchProfile = function() {
+    return Promise.resolve({ body: { uid: ACCOUNT_UID, email: "newemail@bar.com" } });
+  };
+  fxa.handleEmailUpdated = email => {
+    do_check_eq(email, "newemail@bar.com");
+    run_next_test();
+  };
+
+  let profile = CreateFxAccountsProfile(fxa, client);
+  return profile._fetchAndCacheProfile();
+});
+
 function makeObserver(aObserveTopic, aObserveFunc) {
   let callback = function(aSubject, aTopic, aData) {
     log.debug("observed " + aTopic + " " + aData);
     if (aTopic == aObserveTopic) {
       removeMe();
       aObserveFunc(aSubject, aTopic, aData);
     }
   };
--- a/services/fxaccounts/tests/xpcshell/test_web_channel.js
+++ b/services/fxaccounts/tests/xpcshell/test_web_channel.js
@@ -1,14 +1,15 @@
 /* Any copyright is dedicated to the Public Domain.
  * http://creativecommons.org/publicdomain/zero/1.0/ */
 
 "use strict";
 
 Cu.import("resource://gre/modules/FxAccountsCommon.js");
+Cu.import("resource://services-crypto/utils.js");
 const { FxAccountsWebChannel, FxAccountsWebChannelHelpers } =
     Cu.import("resource://gre/modules/FxAccountsWebChannel.jsm", {});
 
 const URL_STRING = "https://example.com";
 
 const mockSendingContext = {
   browser: {},
   principal: {},
@@ -333,17 +334,18 @@ add_task(async function test_helpers_log
         return new Promise(resolve => {
           // ensure fxAccounts is informed of the new user being signed in.
           do_check_eq(accountData.email, "testuser@testuser.com");
 
           // verifiedCanLinkAccount should be stripped in the data.
           do_check_false("verifiedCanLinkAccount" in accountData);
 
           // previously signed in user preference is updated.
-          do_check_eq(helpers.getPreviousAccountNameHashPref(), helpers.sha256("testuser@testuser.com"));
+          do_check_eq(helpers.getPreviousAccountNameHashPref(),
+                      CryptoUtils.sha256Base64("testuser@testuser.com"));
 
           resolve();
         });
       }
     }
   });
 
   // ensure the previous account pref is overwritten.
--- a/tools/lint/eslint/modules.json
+++ b/tools/lint/eslint/modules.json
@@ -79,17 +79,17 @@
   "forms.jsm": ["FormData"],
   "FormAutofillHeuristics.jsm": ["FormAutofillHeuristics", "LabelUtils"],
   "FormAutofillSync.jsm": ["AddressesEngine", "CreditCardsEngine"],
   "frame.js": ["Collector", "Runner", "events", "runTestFile", "log", "timers", "persisted", "shutdownApplication"],
   "FrameScriptManager.jsm": ["getNewLoaderID"],
   "fxa_utils.js": ["initializeIdentityWithTokenServerResponse"],
   "fxaccounts.jsm": ["Authentication"],
   "FxAccounts.jsm": ["fxAccounts", "FxAccounts"],
-  "FxAccountsCommon.js": ["log", "logPII", "FXACCOUNTS_PERMISSION", "DATA_FORMAT_VERSION", "DEFAULT_STORAGE_FILENAME", "ASSERTION_LIFETIME", "ASSERTION_USE_PERIOD", "CERT_LIFETIME", "KEY_LIFETIME", "POLL_SESSION", "ONLOGIN_NOTIFICATION", "ONVERIFIED_NOTIFICATION", "ONLOGOUT_NOTIFICATION", "ON_FXA_UPDATE_NOTIFICATION", "ON_DEVICE_CONNECTED_NOTIFICATION", "ON_DEVICE_DISCONNECTED_NOTIFICATION", "ON_PROFILE_UPDATED_NOTIFICATION", "ON_PASSWORD_CHANGED_NOTIFICATION", "ON_PASSWORD_RESET_NOTIFICATION", "ON_VERIFY_LOGIN_NOTIFICATION", "ON_ACCOUNT_DESTROYED_NOTIFICATION", "ON_COLLECTION_CHANGED_NOTIFICATION", "FXA_PUSH_SCOPE_ACCOUNT_UPDATE", "ON_PROFILE_CHANGE_NOTIFICATION", "ON_ACCOUNT_STATE_CHANGE_NOTIFICATION", "UI_REQUEST_SIGN_IN_FLOW", "UI_REQUEST_REFRESH_AUTH", "FX_OAUTH_CLIENT_ID", "WEBCHANNEL_ID", "ERRNO_ACCOUNT_ALREADY_EXISTS", "ERRNO_ACCOUNT_DOES_NOT_EXIST", "ERRNO_INCORRECT_PASSWORD", "ERRNO_UNVERIFIED_ACCOUNT", "ERRNO_INVALID_VERIFICATION_CODE", "ERRNO_NOT_VALID_JSON_BODY", "ERRNO_INVALID_BODY_PARAMETERS", "ERRNO_MISSING_BODY_PARAMETERS", "ERRNO_INVALID_REQUEST_SIGNATURE", "ERRNO_INVALID_AUTH_TOKEN", "ERRNO_INVALID_AUTH_TIMESTAMP", "ERRNO_MISSING_CONTENT_LENGTH", "ERRNO_REQUEST_BODY_TOO_LARGE", "ERRNO_TOO_MANY_CLIENT_REQUESTS", "ERRNO_INVALID_AUTH_NONCE", "ERRNO_ENDPOINT_NO_LONGER_SUPPORTED", "ERRNO_INCORRECT_LOGIN_METHOD", "ERRNO_INCORRECT_KEY_RETRIEVAL_METHOD", "ERRNO_INCORRECT_API_VERSION", "ERRNO_INCORRECT_EMAIL_CASE", "ERRNO_ACCOUNT_LOCKED", "ERRNO_ACCOUNT_UNLOCKED", "ERRNO_UNKNOWN_DEVICE", "ERRNO_DEVICE_SESSION_CONFLICT", "ERRNO_SERVICE_TEMP_UNAVAILABLE", "ERRNO_PARSE", "ERRNO_NETWORK", "ERRNO_UNKNOWN_ERROR", "OAUTH_SERVER_ERRNO_OFFSET", "ERRNO_UNKNOWN_CLIENT_ID", "ERRNO_INCORRECT_CLIENT_SECRET", "ERRNO_INCORRECT_REDIRECT_URI", "ERRNO_INVALID_FXA_ASSERTION", "ERRNO_UNKNOWN_CODE", "ERRNO_INCORRECT_CODE", "ERRNO_EXPIRED_CODE", "ERRNO_OAUTH_INVALID_TOKEN", "ERRNO_INVALID_REQUEST_PARAM", "ERRNO_INVALID_RESPONSE_TYPE", "ERRNO_UNAUTHORIZED", "ERRNO_FORBIDDEN", "ERRNO_INVALID_CONTENT_TYPE", "ERROR_ACCOUNT_ALREADY_EXISTS", "ERROR_ACCOUNT_DOES_NOT_EXIST", "ERROR_ACCOUNT_LOCKED", "ERROR_ACCOUNT_UNLOCKED", "ERROR_ALREADY_SIGNED_IN_USER", "ERROR_DEVICE_SESSION_CONFLICT", "ERROR_ENDPOINT_NO_LONGER_SUPPORTED", "ERROR_INCORRECT_API_VERSION", "ERROR_INCORRECT_EMAIL_CASE", "ERROR_INCORRECT_KEY_RETRIEVAL_METHOD", "ERROR_INCORRECT_LOGIN_METHOD", "ERROR_INVALID_EMAIL", "ERROR_INVALID_AUDIENCE", "ERROR_INVALID_AUTH_TOKEN", "ERROR_INVALID_AUTH_TIMESTAMP", "ERROR_INVALID_AUTH_NONCE", "ERROR_INVALID_BODY_PARAMETERS", "ERROR_INVALID_PASSWORD", "ERROR_INVALID_VERIFICATION_CODE", "ERROR_INVALID_REFRESH_AUTH_VALUE", "ERROR_INVALID_REQUEST_SIGNATURE", "ERROR_INTERNAL_INVALID_USER", "ERROR_MISSING_BODY_PARAMETERS", "ERROR_MISSING_CONTENT_LENGTH", "ERROR_NO_TOKEN_SESSION", "ERROR_NO_SILENT_REFRESH_AUTH", "ERROR_NOT_VALID_JSON_BODY", "ERROR_OFFLINE", "ERROR_PERMISSION_DENIED", "ERROR_REQUEST_BODY_TOO_LARGE", "ERROR_SERVER_ERROR", "ERROR_SYNC_DISABLED", "ERROR_TOO_MANY_CLIENT_REQUESTS", "ERROR_SERVICE_TEMP_UNAVAILABLE", "ERROR_UI_ERROR", "ERROR_UI_REQUEST", "ERROR_PARSE", "ERROR_NETWORK", "ERROR_UNKNOWN", "ERROR_UNKNOWN_DEVICE", "ERROR_UNVERIFIED_ACCOUNT", "ERROR_UNKNOWN_CLIENT_ID", "ERROR_INCORRECT_CLIENT_SECRET", "ERROR_INCORRECT_REDIRECT_URI", "ERROR_INVALID_FXA_ASSERTION", "ERROR_UNKNOWN_CODE", "ERROR_INCORRECT_CODE", "ERROR_EXPIRED_CODE", "ERROR_OAUTH_INVALID_TOKEN", "ERROR_INVALID_REQUEST_PARAM", "ERROR_INVALID_RESPONSE_TYPE", "ERROR_UNAUTHORIZED", "ERROR_FORBIDDEN", "ERROR_INVALID_CONTENT_TYPE", "ERROR_NO_ACCOUNT", "ERROR_AUTH_ERROR", "ERROR_INVALID_PARAMETER", "ERROR_CODE_METHOD_NOT_ALLOWED", "ERROR_MSG_METHOD_NOT_ALLOWED", "FXA_PWDMGR_PLAINTEXT_FIELDS", "FXA_PWDMGR_SECURE_FIELDS", "FXA_PWDMGR_MEMORY_FIELDS", "FXA_PWDMGR_REAUTH_WHITELIST", "FXA_PWDMGR_HOST", "FXA_PWDMGR_REALM", "SERVER_ERRNO_TO_ERROR", "ERROR_TO_GENERAL_ERROR_CLASS"],
+  "FxAccountsCommon.js": ["log", "logPII", "FXACCOUNTS_PERMISSION", "DATA_FORMAT_VERSION", "DEFAULT_STORAGE_FILENAME", "ASSERTION_LIFETIME", "ASSERTION_USE_PERIOD", "CERT_LIFETIME", "KEY_LIFETIME", "POLL_SESSION", "ONLOGIN_NOTIFICATION", "ONVERIFIED_NOTIFICATION", "ONLOGOUT_NOTIFICATION", "ON_FXA_UPDATE_NOTIFICATION", "ON_DEVICE_CONNECTED_NOTIFICATION", "ON_DEVICE_DISCONNECTED_NOTIFICATION", "ON_PROFILE_UPDATED_NOTIFICATION", "ON_PASSWORD_CHANGED_NOTIFICATION", "ON_PASSWORD_RESET_NOTIFICATION", "ON_VERIFY_LOGIN_NOTIFICATION", "ON_ACCOUNT_DESTROYED_NOTIFICATION", "ON_COLLECTION_CHANGED_NOTIFICATION", "FXA_PUSH_SCOPE_ACCOUNT_UPDATE", "ON_PROFILE_CHANGE_NOTIFICATION", "ON_ACCOUNT_STATE_CHANGE_NOTIFICATION", "UI_REQUEST_SIGN_IN_FLOW", "UI_REQUEST_REFRESH_AUTH", "FX_OAUTH_CLIENT_ID", "WEBCHANNEL_ID", "PREF_LAST_FXA_USER", "ERRNO_ACCOUNT_ALREADY_EXISTS", "ERRNO_ACCOUNT_DOES_NOT_EXIST", "ERRNO_INCORRECT_PASSWORD", "ERRNO_UNVERIFIED_ACCOUNT", "ERRNO_INVALID_VERIFICATION_CODE", "ERRNO_NOT_VALID_JSON_BODY", "ERRNO_INVALID_BODY_PARAMETERS", "ERRNO_MISSING_BODY_PARAMETERS", "ERRNO_INVALID_REQUEST_SIGNATURE", "ERRNO_INVALID_AUTH_TOKEN", "ERRNO_INVALID_AUTH_TIMESTAMP", "ERRNO_MISSING_CONTENT_LENGTH", "ERRNO_REQUEST_BODY_TOO_LARGE", "ERRNO_TOO_MANY_CLIENT_REQUESTS", "ERRNO_INVALID_AUTH_NONCE", "ERRNO_ENDPOINT_NO_LONGER_SUPPORTED", "ERRNO_INCORRECT_LOGIN_METHOD", "ERRNO_INCORRECT_KEY_RETRIEVAL_METHOD", "ERRNO_INCORRECT_API_VERSION", "ERRNO_INCORRECT_EMAIL_CASE", "ERRNO_ACCOUNT_LOCKED", "ERRNO_ACCOUNT_UNLOCKED", "ERRNO_UNKNOWN_DEVICE", "ERRNO_DEVICE_SESSION_CONFLICT", "ERRNO_SERVICE_TEMP_UNAVAILABLE", "ERRNO_PARSE", "ERRNO_NETWORK", "ERRNO_UNKNOWN_ERROR", "OAUTH_SERVER_ERRNO_OFFSET", "ERRNO_UNKNOWN_CLIENT_ID", "ERRNO_INCORRECT_CLIENT_SECRET", "ERRNO_INCORRECT_REDIRECT_URI", "ERRNO_INVALID_FXA_ASSERTION", "ERRNO_UNKNOWN_CODE", "ERRNO_INCORRECT_CODE", "ERRNO_EXPIRED_CODE", "ERRNO_OAUTH_INVALID_TOKEN", "ERRNO_INVALID_REQUEST_PARAM", "ERRNO_INVALID_RESPONSE_TYPE", "ERRNO_UNAUTHORIZED", "ERRNO_FORBIDDEN", "ERRNO_INVALID_CONTENT_TYPE", "ERROR_ACCOUNT_ALREADY_EXISTS", "ERROR_ACCOUNT_DOES_NOT_EXIST", "ERROR_ACCOUNT_LOCKED", "ERROR_ACCOUNT_UNLOCKED", "ERROR_ALREADY_SIGNED_IN_USER", "ERROR_DEVICE_SESSION_CONFLICT", "ERROR_ENDPOINT_NO_LONGER_SUPPORTED", "ERROR_INCORRECT_API_VERSION", "ERROR_INCORRECT_EMAIL_CASE", "ERROR_INCORRECT_KEY_RETRIEVAL_METHOD", "ERROR_INCORRECT_LOGIN_METHOD", "ERROR_INVALID_EMAIL", "ERROR_INVALID_AUDIENCE", "ERROR_INVALID_AUTH_TOKEN", "ERROR_INVALID_AUTH_TIMESTAMP", "ERROR_INVALID_AUTH_NONCE", "ERROR_INVALID_BODY_PARAMETERS", "ERROR_INVALID_PASSWORD", "ERROR_INVALID_VERIFICATION_CODE", "ERROR_INVALID_REFRESH_AUTH_VALUE", "ERROR_INVALID_REQUEST_SIGNATURE", "ERROR_INTERNAL_INVALID_USER", "ERROR_MISSING_BODY_PARAMETERS", "ERROR_MISSING_CONTENT_LENGTH", "ERROR_NO_TOKEN_SESSION", "ERROR_NO_SILENT_REFRESH_AUTH", "ERROR_NOT_VALID_JSON_BODY", "ERROR_OFFLINE", "ERROR_PERMISSION_DENIED", "ERROR_REQUEST_BODY_TOO_LARGE", "ERROR_SERVER_ERROR", "ERROR_SYNC_DISABLED", "ERROR_TOO_MANY_CLIENT_REQUESTS", "ERROR_SERVICE_TEMP_UNAVAILABLE", "ERROR_UI_ERROR", "ERROR_UI_REQUEST", "ERROR_PARSE", "ERROR_NETWORK", "ERROR_UNKNOWN", "ERROR_UNKNOWN_DEVICE", "ERROR_UNVERIFIED_ACCOUNT", "ERROR_UNKNOWN_CLIENT_ID", "ERROR_INCORRECT_CLIENT_SECRET", "ERROR_INCORRECT_REDIRECT_URI", "ERROR_INVALID_FXA_ASSERTION", "ERROR_UNKNOWN_CODE", "ERROR_INCORRECT_CODE", "ERROR_EXPIRED_CODE", "ERROR_OAUTH_INVALID_TOKEN", "ERROR_INVALID_REQUEST_PARAM", "ERROR_INVALID_RESPONSE_TYPE", "ERROR_UNAUTHORIZED", "ERROR_FORBIDDEN", "ERROR_INVALID_CONTENT_TYPE", "ERROR_NO_ACCOUNT", "ERROR_AUTH_ERROR", "ERROR_INVALID_PARAMETER", "ERROR_CODE_METHOD_NOT_ALLOWED", "ERROR_MSG_METHOD_NOT_ALLOWED", "FXA_PWDMGR_PLAINTEXT_FIELDS", "FXA_PWDMGR_SECURE_FIELDS", "FXA_PWDMGR_MEMORY_FIELDS", "FXA_PWDMGR_REAUTH_WHITELIST", "FXA_PWDMGR_HOST", "FXA_PWDMGR_REALM", "SERVER_ERRNO_TO_ERROR", "ERROR_TO_GENERAL_ERROR_CLASS"],
   "FxAccountsOAuthGrantClient.jsm": ["FxAccountsOAuthGrantClient", "FxAccountsOAuthGrantClientError"],
   "FxAccountsProfileClient.jsm": ["FxAccountsProfileClient", "FxAccountsProfileClientError"],
   "FxAccountsPush.js": ["FxAccountsPushService"],
   "FxAccountsStorage.jsm": ["FxAccountsStorageManagerCanStoreField", "FxAccountsStorageManager"],
   "FxAccountsWebChannel.jsm": ["EnsureFxAccountsWebChannel"],
   "gDevTools.jsm": ["gDevTools", "gDevToolsBrowser"],
   "gDevTools.jsm": ["gDevTools", "gDevToolsBrowser"],
   "Geometry.jsm": ["Point", "Rect"],