Bug 1383663 part 3 - Update FxA local state on profile email change. r=markh
☠☠ backed out by 753df1b7c5ed ☠ ☠
authorEdouard Oger <eoger@fastmail.com>
Mon, 21 Aug 2017 17:01:57 -0400
changeset 430840 f384a524cac6e769af1498d1b67ec33834a551d5
parent 430839 9d26a627e2f825eee2276d7644ea3c1455b628b4
child 430841 753df1b7c5edb84daf585e0fb434f67755c60be2
push id7771
push userryanvm@gmail.com
push dateSun, 17 Sep 2017 03:17:38 +0000
treeherdermozilla-beta@3d2edf73fb90 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersmarkh
bugs1383663
milestone57.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 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
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
    */
--- 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/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"],