Bug 1583413 - Move device-related methods into `FxAccountsDevice.jsm`. r=eoger
authorLina Cambridge <lina@yakshaving.ninja>
Thu, 03 Oct 2019 22:40:41 +0000
changeset 496253 8d090eb60c78fd6c28d7f5d8832c8bb498aafbd4
parent 496252 1c00cccfb23fea4779d399fe8f7e6350fa2cd6a5
child 496254 0e871ed50b6c30999fba87362058c60112fc8195
push id36647
push usernerli@mozilla.com
push dateFri, 04 Oct 2019 04:09:18 +0000
treeherdermozilla-central@678d4d2c3c4d [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewerseoger
bugs1583413
milestone71.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 1583413 - Move device-related methods into `FxAccountsDevice.jsm`. r=eoger This commit contains no functional changes, just moving code around to make the next patch in this series easier to write. Differential Revision: https://phabricator.services.mozilla.com/D47520
services/fxaccounts/FxAccounts.jsm
services/fxaccounts/FxAccountsCommands.js
services/fxaccounts/FxAccountsDevice.jsm
services/fxaccounts/tests/xpcshell/test_accounts.js
services/fxaccounts/tests/xpcshell/test_accounts_device_registration.js
--- a/services/fxaccounts/FxAccounts.jsm
+++ b/services/fxaccounts/FxAccounts.jsm
@@ -1,19 +1,16 @@
 /* This Source Code Form is subject to the terms of the Mozilla Public
  * License, v. 2.0. If a copy of the MPL was not distributed with this
  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
 "use strict";
 
 const { PromiseUtils } = ChromeUtils.import(
   "resource://gre/modules/PromiseUtils.jsm"
 );
-const { CommonUtils } = ChromeUtils.import(
-  "resource://services-common/utils.js"
-);
 const { CryptoUtils } = ChromeUtils.import(
   "resource://services-crypto/utils.js"
 );
 const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
 const { XPCOMUtils } = ChromeUtils.import(
   "resource://gre/modules/XPCOMUtils.jsm"
 );
 const { clearTimeout, setTimeout } = ChromeUtils.import(
@@ -21,20 +18,17 @@ const { clearTimeout, setTimeout } = Chr
 );
 const { FxAccountsStorageManager } = ChromeUtils.import(
   "resource://gre/modules/FxAccountsStorage.jsm"
 );
 const {
   ASSERTION_LIFETIME,
   ASSERTION_USE_PERIOD,
   CERT_LIFETIME,
-  COMMAND_SENDTAB,
-  ERRNO_DEVICE_SESSION_CONFLICT,
   ERRNO_INVALID_AUTH_TOKEN,
-  ERRNO_UNKNOWN_DEVICE,
   ERROR_AUTH_ERROR,
   ERROR_INVALID_PARAMETER,
   ERROR_NO_ACCOUNT,
   ERROR_OFFLINE,
   ERROR_TO_GENERAL_ERROR_CLASS,
   ERROR_UNKNOWN,
   ERROR_UNVERIFIED_ACCOUNT,
   FXA_PWDMGR_MEMORY_FIELDS,
@@ -42,17 +36,16 @@ const {
   FXA_PWDMGR_REAUTH_WHITELIST,
   FXA_PWDMGR_SECURE_FIELDS,
   FX_OAUTH_CLIENT_ID,
   KEY_LIFETIME,
   ONLOGIN_NOTIFICATION,
   ONLOGOUT_NOTIFICATION,
   ONVERIFIED_NOTIFICATION,
   ON_DEVICE_DISCONNECTED_NOTIFICATION,
-  ON_NEW_DEVICE_ID,
   POLL_SESSION,
   PREF_ACCOUNT_ROOT,
   PREF_LAST_FXA_USER,
   SERVER_ERRNO_TO_ERROR,
   log,
   logPII,
 } = ChromeUtils.import("resource://gre/modules/FxAccountsCommon.js");
 
@@ -355,20 +348,16 @@ function copyObjectProperties(from, to, 
     if (desc.set) {
       desc.set = desc.set.bind(thisObj);
     }
 
     Object.defineProperty(to, prop, desc);
   }
 }
 
-function urlsafeBase64Encode(key) {
-  return ChromeUtils.base64URLEncode(new Uint8Array(key), { pad: false });
-}
-
 /**
  * The public API.
  *
  * TODO - *all* non-underscore stuff here should have sphinx docstrings so
  * that docs magically appear on https://firefox-source-docs.mozilla.org/
  * (although |./mach doc| is broken on windows (bug 1232403) and on Linux for
  * markh (some obscure npm issue he gave up on) - so later...)
  */
@@ -379,23 +368,29 @@ class FxAccounts {
       // it's slightly unfortunate that we need to mock the main "internal" object
       // before calling initialize, primarily so a mock `newAccountState` is in
       // place before initialize calls it, but we need to initialize the
       // "sub-object" mocks after. This can probably be fixed, but whatever...
       copyObjectProperties(
         mocks,
         this._internal,
         this._internal,
-        Object.keys(mocks)
+        Object.keys(mocks).filter(key => !["device", "commands"].includes(key))
       );
     }
     this._internal.initialize();
     // allow mocking our "sub-objects" too.
     if (mocks) {
-      for (let subobject of ["currentAccountState", "keys", "fxaPushService"]) {
+      for (let subobject of [
+        "currentAccountState",
+        "keys",
+        "fxaPushService",
+        "device",
+        "commands",
+      ]) {
         if (typeof mocks[subobject] == "object") {
           copyObjectProperties(
             mocks[subobject],
             this._internal[subobject],
             this._internal[subobject],
             Object.keys(mocks[subobject])
           );
         }
@@ -765,19 +760,16 @@ FxAccountsInternal.prototype = {
   // VERIFICATION_POLL_START_SLOWDOWN_THRESHOLD minutes if the user has
   // logged-in in this session.
   VERIFICATION_POLL_TIMEOUT_INITIAL: 60000, // 1 minute.
   // All the other cases (> 5 min, on restart etc).
   VERIFICATION_POLL_TIMEOUT_SUBSEQUENT: 5 * 60000, // 5 minutes.
   // After X minutes, the polling will slow down to _SUBSEQUENT if we have
   // logged-in in this session.
   VERIFICATION_POLL_START_SLOWDOWN_THRESHOLD: 5,
-  // The current version of the device registration, we use this to re-register
-  // devices after we update what we send on device registration.
-  DEVICE_REGISTRATION_VERSION: 2,
 
   _fxAccountsClient: null,
 
   // All significant initialization should be done in this initialize() method
   // to help with our mocking story.
   initialize() {
     XPCOMUtils.defineLazyGetter(this, "fxaPushService", function() {
       return Cc["@mozilla.org/fxaccounts/push;1"].getService(
@@ -1104,51 +1096,18 @@ FxAccountsInternal.prototype = {
             );
           }
         );
       })
       .catch(err => this._handleTokenError(err))
       .then(result => currentState.resolve(result));
   },
 
-  async checkDeviceUpdateNeeded(device) {
-    // There is no device registered or the device registration is outdated.
-    // Either way, we should register the device with FxA
-    // before returning the id to the caller.
-    const availableCommandsKeys = Object.keys(
-      await this.availableCommands()
-    ).sort();
-    return (
-      !device ||
-      !device.registrationVersion ||
-      device.registrationVersion < this.DEVICE_REGISTRATION_VERSION ||
-      !device.registeredCommandsKeys ||
-      !CommonUtils.arrayEqual(
-        device.registeredCommandsKeys,
-        availableCommandsKeys
-      )
-    );
-  },
-
   getDeviceList() {
-    return this.withVerifiedAccountState(async state => {
-      let accountData = await state.getUserAccountData();
-
-      const devices = await this.fxAccountsClient.getDeviceList(
-        accountData.sessionToken
-      );
-
-      // Check if our push registration is still good.
-      const ourDevice = devices.find(device => device.isCurrentDevice);
-      if (ourDevice.pushEndpointExpired) {
-        await this.fxaPushService.unsubscribe();
-        await this._registerOrUpdateDevice(accountData);
-      }
-      return devices;
-    });
+    return this.device.getDeviceList();
   },
 
   /*
    * Reset state such that any previous flow is canceled.
    */
   abortExistingFlow() {
     if (this.currentTimer) {
       log.debug("Polling aborted; Another user signing in");
@@ -1823,25 +1782,18 @@ FxAccountsInternal.prototype = {
       reason.details = aDetails;
     }
     return reason;
   },
 
   // Attempt to update the auth server with whatever device details are stored
   // in the account data. Returns a promise that always resolves, never rejects.
   // If the promise resolves to a value, that value is the device id.
-  async updateDeviceRegistration() {
-    try {
-      const signedInUser = await this.currentAccountState.getUserAccountData();
-      if (signedInUser) {
-        await this._registerOrUpdateDevice(signedInUser);
-      }
-    } catch (error) {
-      await this._logErrorAndResetDeviceRegistrationVersion(error);
-    }
+  updateDeviceRegistration() {
+    return this.device.updateDeviceRegistration();
   },
 
   /**
    * Delete all the persisted credentials we store for FxA. After calling
    * this, the user will be forced to re-authenticate to continue.
    *
    * @return Promise resolves when the user data has been persisted
    */
@@ -1856,188 +1808,16 @@ FxAccountsInternal.prototype = {
     };
     FXA_PWDMGR_PLAINTEXT_FIELDS.forEach(clearField);
     FXA_PWDMGR_SECURE_FIELDS.forEach(clearField);
     FXA_PWDMGR_MEMORY_FIELDS.forEach(clearField);
 
     return state.updateUserAccountData(updateData);
   },
 
-  async availableCommands() {
-    if (
-      !Services.prefs.getBoolPref("identity.fxaccounts.commands.enabled", true)
-    ) {
-      return {};
-    }
-    const sendTabKey = await this.commands.sendTab.getEncryptedKey();
-    if (!sendTabKey) {
-      // This will happen if the account is not verified yet.
-      return {};
-    }
-    return {
-      [COMMAND_SENDTAB]: sendTabKey,
-    };
-  },
-
-  // If you change what we send to the FxA servers during device registration,
-  // you'll have to bump the DEVICE_REGISTRATION_VERSION number to force older
-  // devices to re-register when Firefox updates
-  async _registerOrUpdateDevice(signedInUser) {
-    const { sessionToken, device: currentDevice } = signedInUser;
-    if (!sessionToken) {
-      throw new Error("_registerOrUpdateDevice called without a session token");
-    }
-
-    try {
-      const subscription = await this.fxaPushService.registerPushEndpoint();
-      const deviceName = this.device.getLocalName();
-      let deviceOptions = {};
-
-      // if we were able to obtain a subscription
-      if (subscription && subscription.endpoint) {
-        deviceOptions.pushCallback = subscription.endpoint;
-        let publicKey = subscription.getKey("p256dh");
-        let authKey = subscription.getKey("auth");
-        if (publicKey && authKey) {
-          deviceOptions.pushPublicKey = urlsafeBase64Encode(publicKey);
-          deviceOptions.pushAuthKey = urlsafeBase64Encode(authKey);
-        }
-      }
-      deviceOptions.availableCommands = await this.availableCommands();
-      const availableCommandsKeys = Object.keys(
-        deviceOptions.availableCommands
-      ).sort();
-
-      let device;
-      if (currentDevice && currentDevice.id) {
-        log.debug("updating existing device details");
-        device = await this.fxAccountsClient.updateDevice(
-          sessionToken,
-          currentDevice.id,
-          deviceName,
-          deviceOptions
-        );
-      } else {
-        log.debug("registering new device details");
-        device = await this.fxAccountsClient.registerDevice(
-          sessionToken,
-          deviceName,
-          this.device.getLocalType(),
-          deviceOptions
-        );
-        Services.obs.notifyObservers(null, ON_NEW_DEVICE_ID);
-      }
-
-      // Get the freshest device props before updating them.
-      let {
-        device: deviceProps,
-      } = await this.currentAccountState.getUserAccountData();
-      await this.currentAccountState.updateUserAccountData({
-        device: {
-          ...deviceProps, // Copy the other properties (e.g. handledCommands).
-          id: device.id,
-          registrationVersion: this.DEVICE_REGISTRATION_VERSION,
-          registeredCommandsKeys: availableCommandsKeys,
-        },
-      });
-      return device.id;
-    } catch (error) {
-      return this._handleDeviceError(error, sessionToken);
-    }
-  },
-
-  _handleDeviceError(error, sessionToken) {
-    return Promise.resolve()
-      .then(() => {
-        if (error.code === 400) {
-          if (error.errno === ERRNO_UNKNOWN_DEVICE) {
-            return this._recoverFromUnknownDevice();
-          }
-
-          if (error.errno === ERRNO_DEVICE_SESSION_CONFLICT) {
-            return this._recoverFromDeviceSessionConflict(error, sessionToken);
-          }
-        }
-
-        // `_handleTokenError` re-throws the error.
-        return this._handleTokenError(error);
-      })
-      .catch(error => this._logErrorAndResetDeviceRegistrationVersion(error))
-      .catch(() => {});
-  },
-
-  async _recoverFromUnknownDevice() {
-    // FxA did not recognise the device id. Handle it by clearing the device
-    // id on the account data. At next sync or next sign-in, registration is
-    // retried and should succeed.
-    log.warn("unknown device id, clearing the local device data");
-    try {
-      await this.currentAccountState.updateUserAccountData({ device: null });
-    } catch (error) {
-      await this._logErrorAndResetDeviceRegistrationVersion(error);
-    }
-  },
-
-  async _recoverFromDeviceSessionConflict(error, sessionToken) {
-    // FxA has already associated this session with a different device id.
-    // Perhaps we were beaten in a race to register. Handle the conflict:
-    //   1. Fetch the list of devices for the current user from FxA.
-    //   2. Look for ourselves in the list.
-    //   3. If we find a match, set the correct device id and device registration
-    //      version on the account data and return the correct device id. At next
-    //      sync or next sign-in, registration is retried and should succeed.
-    //   4. If we don't find a match, log the original error.
-    log.warn(
-      "device session conflict, attempting to ascertain the correct device id"
-    );
-    try {
-      const devices = await this.fxAccountsClient.getDeviceList(sessionToken);
-      const matchingDevices = devices.filter(device => device.isCurrentDevice);
-      const length = matchingDevices.length;
-      if (length === 1) {
-        const deviceId = matchingDevices[0].id;
-        await this.currentAccountState.updateUserAccountData({
-          device: {
-            id: deviceId,
-            registrationVersion: null,
-          },
-        });
-        return deviceId;
-      }
-      if (length > 1) {
-        log.error(
-          "insane server state, " + length + " devices for this session"
-        );
-      }
-      await this._logErrorAndResetDeviceRegistrationVersion(error);
-    } catch (secondError) {
-      log.error("failed to recover from device-session conflict", secondError);
-      await this._logErrorAndResetDeviceRegistrationVersion(error);
-    }
-    return null;
-  },
-
-  async _logErrorAndResetDeviceRegistrationVersion(error) {
-    // Device registration should never cause other operations to fail.
-    // If we've reached this point, just log the error and reset the device
-    // on the account data. At next sync or next sign-in,
-    // registration will be retried.
-    log.error("device registration failed", error);
-    try {
-      this.currentAccountState.updateUserAccountData({
-        device: null,
-      });
-    } catch (secondError) {
-      log.error(
-        "failed to reset the device registration version, device registration won't be retried",
-        secondError
-      );
-    }
-  },
-
   _handleTokenError(err) {
     if (!err || err.code != 401 || err.errno != ERRNO_INVALID_AUTH_TOKEN) {
       throw err;
     }
     log.warn("recovering from invalid token error", err);
     return this.accountStatus()
       .then(exists => {
         if (!exists) {
--- a/services/fxaccounts/FxAccountsCommands.js
+++ b/services/fxaccounts/FxAccountsCommands.js
@@ -28,16 +28,32 @@ XPCOMUtils.defineLazyModuleGetters(this,
 });
 
 class FxAccountsCommands {
   constructor(fxAccountsInternal) {
     this._fxai = fxAccountsInternal;
     this.sendTab = new SendTab(this, fxAccountsInternal);
   }
 
+  async availableCommands() {
+    if (
+      !Services.prefs.getBoolPref("identity.fxaccounts.commands.enabled", true)
+    ) {
+      return {};
+    }
+    const sendTabKey = await this.sendTab.getEncryptedKey();
+    if (!sendTabKey) {
+      // This will happen if the account is not verified yet.
+      return {};
+    }
+    return {
+      [COMMAND_SENDTAB]: sendTabKey,
+    };
+  }
+
   async invoke(command, device, payload) {
     const { sessionToken } = await this._fxai.getUserAccountData([
       "sessionToken",
     ]);
     const client = this._fxai.fxAccountsClient;
     await client.invokeCommand(sessionToken, command, device.id, payload);
     log.info(`Payload sent to device ${device.id}.`);
   }
--- a/services/fxaccounts/FxAccountsDevice.jsm
+++ b/services/fxaccounts/FxAccountsDevice.jsm
@@ -4,55 +4,65 @@
 "use strict";
 
 const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
 
 const { XPCOMUtils } = ChromeUtils.import(
   "resource://gre/modules/XPCOMUtils.jsm"
 );
 
-const { log } = ChromeUtils.import(
-  "resource://gre/modules/FxAccountsCommon.js"
-);
+const {
+  log,
+  ERRNO_DEVICE_SESSION_CONFLICT,
+  ERRNO_UNKNOWN_DEVICE,
+  ON_NEW_DEVICE_ID,
+} = ChromeUtils.import("resource://gre/modules/FxAccountsCommon.js");
 
 const { DEVICE_TYPE_DESKTOP } = ChromeUtils.import(
   "resource://services-sync/constants.js"
 );
 
 const { PREF_ACCOUNT_ROOT } = ChromeUtils.import(
   "resource://gre/modules/FxAccountsCommon.js"
 );
 
+ChromeUtils.defineModuleGetter(
+  this,
+  "CommonUtils",
+  "resource://services-common/utils.js"
+);
+
 const PREF_LOCAL_DEVICE_NAME = PREF_ACCOUNT_ROOT + "device.name";
 XPCOMUtils.defineLazyPreferenceGetter(
   this,
   "pref_localDeviceName",
   PREF_LOCAL_DEVICE_NAME,
   ""
 );
 
 const PREF_DEPRECATED_DEVICE_NAME = "services.sync.client.name";
 
 // Everything to do with FxA devices.
-// TODO: Move more device stuff from FxAccounts.jsm into here - eg, device
-// registration, device lists, etc.
 class FxAccountsDevice {
   constructor(fxai) {
     this._fxai = fxai;
+    // The current version of the device registration, we use this to re-register
+    // devices after we update what we send on device registration.
+    this.DEVICE_REGISTRATION_VERSION = 2;
   }
 
   async getLocalId() {
     let data = await this._fxai.currentAccountState.getUserAccountData();
     if (!data) {
       // Without a signed-in user, there can be no device id.
       return null;
     }
     const { device } = data;
-    if (await this._fxai.checkDeviceUpdateNeeded(device)) {
-      return this._fxai._registerOrUpdateDevice(data);
+    if (await this.checkDeviceUpdateNeeded(device)) {
+      return this._registerOrUpdateDevice(data);
     }
     // Return the device id that we already registered with the server.
     return device.id;
   }
 
   // Generate a client name if we don't have a useful one yet
   getDefaultLocalName() {
     let env = Cc["@mozilla.org/process/environment;1"].getService(
@@ -132,19 +142,231 @@ class FxAccountsDevice {
     }
     return name;
   }
 
   setLocalName(newName) {
     Services.prefs.clearUserPref(PREF_DEPRECATED_DEVICE_NAME);
     Services.prefs.setStringPref(PREF_LOCAL_DEVICE_NAME, newName);
     // Update the registration in the background.
-    this._fxai.updateDeviceRegistration().catch(error => {
+    this.updateDeviceRegistration().catch(error => {
       log.warn("failed to update fxa device registration", error);
     });
   }
 
   getLocalType() {
     return DEVICE_TYPE_DESKTOP;
   }
+
+  async checkDeviceUpdateNeeded(device) {
+    // There is no device registered or the device registration is outdated.
+    // Either way, we should register the device with FxA
+    // before returning the id to the caller.
+    const availableCommandsKeys = Object.keys(
+      await this._fxai.commands.availableCommands()
+    ).sort();
+    return (
+      !device ||
+      !device.registrationVersion ||
+      device.registrationVersion < this.DEVICE_REGISTRATION_VERSION ||
+      !device.registeredCommandsKeys ||
+      !CommonUtils.arrayEqual(
+        device.registeredCommandsKeys,
+        availableCommandsKeys
+      )
+    );
+  }
+
+  getDeviceList() {
+    return this._fxai.withVerifiedAccountState(async state => {
+      let accountData = await state.getUserAccountData();
+
+      const devices = await this._fxai.fxAccountsClient.getDeviceList(
+        accountData.sessionToken
+      );
+
+      // Check if our push registration is still good.
+      const ourDevice = devices.find(device => device.isCurrentDevice);
+      if (ourDevice.pushEndpointExpired) {
+        await this._fxai.fxaPushService.unsubscribe();
+        await this._registerOrUpdateDevice(accountData);
+      }
+      return devices;
+    });
+  }
+
+  async updateDeviceRegistration() {
+    try {
+      const signedInUser = await this._fxai.currentAccountState.getUserAccountData();
+      if (signedInUser) {
+        await this._registerOrUpdateDevice(signedInUser);
+      }
+    } catch (error) {
+      await this._logErrorAndResetDeviceRegistrationVersion(error);
+    }
+  }
+
+  // If you change what we send to the FxA servers during device registration,
+  // you'll have to bump the DEVICE_REGISTRATION_VERSION number to force older
+  // devices to re-register when Firefox updates
+  async _registerOrUpdateDevice(signedInUser) {
+    const { sessionToken, device: currentDevice } = signedInUser;
+    if (!sessionToken) {
+      throw new Error("_registerOrUpdateDevice called without a session token");
+    }
+
+    try {
+      const subscription = await this._fxai.fxaPushService.registerPushEndpoint();
+      const deviceName = this.getLocalName();
+      let deviceOptions = {};
+
+      // if we were able to obtain a subscription
+      if (subscription && subscription.endpoint) {
+        deviceOptions.pushCallback = subscription.endpoint;
+        let publicKey = subscription.getKey("p256dh");
+        let authKey = subscription.getKey("auth");
+        if (publicKey && authKey) {
+          deviceOptions.pushPublicKey = urlsafeBase64Encode(publicKey);
+          deviceOptions.pushAuthKey = urlsafeBase64Encode(authKey);
+        }
+      }
+      deviceOptions.availableCommands = await this._fxai.commands.availableCommands();
+      const availableCommandsKeys = Object.keys(
+        deviceOptions.availableCommands
+      ).sort();
+
+      let device;
+      if (currentDevice && currentDevice.id) {
+        log.debug("updating existing device details");
+        device = await this._fxai.fxAccountsClient.updateDevice(
+          sessionToken,
+          currentDevice.id,
+          deviceName,
+          deviceOptions
+        );
+      } else {
+        log.debug("registering new device details");
+        device = await this._fxai.fxAccountsClient.registerDevice(
+          sessionToken,
+          deviceName,
+          this.getLocalType(),
+          deviceOptions
+        );
+        Services.obs.notifyObservers(null, ON_NEW_DEVICE_ID);
+      }
+
+      // Get the freshest device props before updating them.
+      let {
+        device: deviceProps,
+      } = await this._fxai.currentAccountState.getUserAccountData();
+      await this._fxai.currentAccountState.updateUserAccountData({
+        device: {
+          ...deviceProps, // Copy the other properties (e.g. handledCommands).
+          id: device.id,
+          registrationVersion: this.DEVICE_REGISTRATION_VERSION,
+          registeredCommandsKeys: availableCommandsKeys,
+        },
+      });
+      return device.id;
+    } catch (error) {
+      return this._handleDeviceError(error, sessionToken);
+    }
+  }
+
+  _handleDeviceError(error, sessionToken) {
+    return Promise.resolve()
+      .then(() => {
+        if (error.code === 400) {
+          if (error.errno === ERRNO_UNKNOWN_DEVICE) {
+            return this._recoverFromUnknownDevice();
+          }
+
+          if (error.errno === ERRNO_DEVICE_SESSION_CONFLICT) {
+            return this._recoverFromDeviceSessionConflict(error, sessionToken);
+          }
+        }
+
+        // `_handleTokenError` re-throws the error.
+        return this._fxai._handleTokenError(error);
+      })
+      .catch(error => this._logErrorAndResetDeviceRegistrationVersion(error))
+      .catch(() => {});
+  }
+
+  async _recoverFromUnknownDevice() {
+    // FxA did not recognise the device id. Handle it by clearing the device
+    // id on the account data. At next sync or next sign-in, registration is
+    // retried and should succeed.
+    log.warn("unknown device id, clearing the local device data");
+    try {
+      await this._fxai.currentAccountState.updateUserAccountData({
+        device: null,
+      });
+    } catch (error) {
+      await this._logErrorAndResetDeviceRegistrationVersion(error);
+    }
+  }
+
+  async _recoverFromDeviceSessionConflict(error, sessionToken) {
+    // FxA has already associated this session with a different device id.
+    // Perhaps we were beaten in a race to register. Handle the conflict:
+    //   1. Fetch the list of devices for the current user from FxA.
+    //   2. Look for ourselves in the list.
+    //   3. If we find a match, set the correct device id and device registration
+    //      version on the account data and return the correct device id. At next
+    //      sync or next sign-in, registration is retried and should succeed.
+    //   4. If we don't find a match, log the original error.
+    log.warn(
+      "device session conflict, attempting to ascertain the correct device id"
+    );
+    try {
+      const devices = await this._fxai.fxAccountsClient.getDeviceList(
+        sessionToken
+      );
+      const matchingDevices = devices.filter(device => device.isCurrentDevice);
+      const length = matchingDevices.length;
+      if (length === 1) {
+        const deviceId = matchingDevices[0].id;
+        await this._fxai.currentAccountState.updateUserAccountData({
+          device: {
+            id: deviceId,
+            registrationVersion: null,
+          },
+        });
+        return deviceId;
+      }
+      if (length > 1) {
+        log.error(
+          "insane server state, " + length + " devices for this session"
+        );
+      }
+      await this._logErrorAndResetDeviceRegistrationVersion(error);
+    } catch (secondError) {
+      log.error("failed to recover from device-session conflict", secondError);
+      await this._logErrorAndResetDeviceRegistrationVersion(error);
+    }
+    return null;
+  }
+
+  async _logErrorAndResetDeviceRegistrationVersion(error) {
+    // Device registration should never cause other operations to fail.
+    // If we've reached this point, just log the error and reset the device
+    // on the account data. At next sync or next sign-in,
+    // registration will be retried.
+    log.error("device registration failed", error);
+    try {
+      this._fxai.currentAccountState.updateUserAccountData({
+        device: null,
+      });
+    } catch (secondError) {
+      log.error(
+        "failed to reset the device registration version, device registration won't be retried",
+        secondError
+      );
+    }
+  }
+}
+
+function urlsafeBase64Encode(buffer) {
+  return ChromeUtils.base64URLEncode(new Uint8Array(buffer), { pad: false });
 }
 
 var EXPORTED_SYMBOLS = ["FxAccountsDevice"];
--- a/services/fxaccounts/tests/xpcshell/test_accounts.js
+++ b/services/fxaccounts/tests/xpcshell/test_accounts.js
@@ -169,21 +169,21 @@ function MockFxAccounts(credentials = nu
     getCertificateSigned(sessionToken, serializedPublicKey) {
       _("mock getCertificateSigned\n");
       this._getCertificateSigned_calls.push([
         sessionToken,
         serializedPublicKey,
       ]);
       return this._d_signCertificate.promise;
     },
-    _registerOrUpdateDevice() {
-      return Promise.resolve();
-    },
     fxAccountsClient: new MockFxAccountsClient(),
     observerPreloads: [],
+    device: {
+      _registerOrUpdateDevice() {},
+    },
   });
   // and for convenience so we don't have to touch as many lines in this test
   // when we refactored FxAccounts.jsm :)
   result.setSignedInUser = function(creds) {
     return result._internal.setSignedInUser(creds);
   };
   return result;
 }
@@ -199,18 +199,24 @@ async function MakeFxAccounts({ internal
       let storage = new MockStorageManager();
       storage.initialize(newCredentials);
       return new AccountState(storage);
     };
   }
   if (!internal._signOutServer) {
     internal._signOutServer = () => Promise.resolve();
   }
-  if (!internal._registerOrUpdateDevice) {
-    internal._registerOrUpdateDevice = () => Promise.resolve();
+  if (internal.device) {
+    if (!internal.device._registerOrUpdateDevice) {
+      internal.device._registerOrUpdateDevice = () => Promise.resolve();
+    }
+  } else {
+    internal.device = {
+      _registerOrUpdateDevice() {},
+    };
   }
   if (!internal.observerPreloads) {
     internal.observerPreloads = [];
   }
   let result = new FxAccounts(internal);
 
   if (credentials) {
     await result._internal.setSignedInUser(credentials);
@@ -1688,18 +1694,20 @@ add_test(function test_getSignedInUserPr
       return Promise.resolve({ avatar: "image" });
     },
     tearDown() {},
   };
   let fxa = new FxAccounts({
     _signOutServer() {
       return Promise.resolve();
     },
-    _registerOrUpdateDevice() {
-      return Promise.resolve();
+    device: {
+      _registerOrUpdateDevice() {
+        return Promise.resolve();
+      },
     },
   });
 
   fxa._internal.setSignedInUser(alice).then(() => {
     fxa._internal._profile = mockProfile;
     fxa.getSignedInUserProfile().then(result => {
       Assert.ok(!!result);
       Assert.equal(result.avatar, "image");
--- a/services/fxaccounts/tests/xpcshell/test_accounts_device_registration.js
+++ b/services/fxaccounts/tests/xpcshell/test_accounts_device_registration.js
@@ -114,19 +114,16 @@ MockFxAccountsClient.prototype = {
 async function MockFxAccounts(credentials, device = {}) {
   let fxa = new FxAccounts({
     newAccountState(creds) {
       // we use a real accountState but mocked storage.
       let storage = new MockStorageManager();
       storage.initialize(creds);
       return new AccountState(storage);
     },
-    async availableCommands() {
-      return {};
-    },
     fxAccountsClient: new MockFxAccountsClient(device),
     fxaPushService: {
       registerPushEndpoint() {
         return new Promise(resolve => {
           resolve({
             endpoint: "http://mochi.test:8888",
             getKey(type) {
               return ChromeUtils.base64URLDecode(
@@ -136,17 +133,24 @@ async function MockFxAccounts(credential
             },
           });
         });
       },
       unsubscribe() {
         return Promise.resolve();
       },
     },
-    DEVICE_REGISTRATION_VERSION,
+    commands: {
+      async availableCommands() {
+        return {};
+      },
+    },
+    device: {
+      DEVICE_REGISTRATION_VERSION,
+    },
   });
   await fxa._internal.setSignedInUser(credentials);
   Services.prefs.setStringPref(
     "identity.fxaccounts.account.device.name",
     device.name || "mock device name"
   );
   return fxa;
 }
@@ -492,17 +496,17 @@ add_task(
     await updateUserAccountData(fxa, { uid: credentials.uid, device: null });
 
     const spy = { count: 0, args: [] };
     fxa._internal.currentAccountState.getUserAccountData = () =>
       Promise.resolve({
         email: credentials.email,
         registrationVersion: DEVICE_REGISTRATION_VERSION,
       });
-    fxa._internal._registerOrUpdateDevice = function() {
+    fxa._internal.device._registerOrUpdateDevice = function() {
       spy.count += 1;
       spy.args.push(arguments);
       return Promise.resolve("bar");
     };
 
     const result = await fxa.device.getLocalId();
 
     Assert.equal(spy.count, 1);
@@ -523,17 +527,17 @@ add_task(
     fxa._internal.currentAccountState.getUserAccountData = () =>
       Promise.resolve({
         device: {
           id: "my id",
           registrationVersion: 0,
           registeredCommandsKeys: [],
         },
       });
-    fxa._internal._registerOrUpdateDevice = function() {
+    fxa._internal.device._registerOrUpdateDevice = function() {
       spy.count += 1;
       spy.args.push(arguments);
       return Promise.resolve("wibble");
     };
 
     const result = await fxa.device.getLocalId();
 
     Assert.equal(spy.count, 1);
@@ -552,17 +556,17 @@ add_task(
     const spy = { count: 0 };
     fxa._internal.currentAccountState.getUserAccountData = async () => ({
       device: {
         id: "foo's device id",
         registrationVersion: DEVICE_REGISTRATION_VERSION,
         registeredCommandsKeys: [],
       },
     });
-    fxa._internal._registerOrUpdateDevice = function() {
+    fxa._internal.device._registerOrUpdateDevice = function() {
       spy.count += 1;
       return Promise.resolve("bar");
     };
 
     const result = await fxa.device.getLocalId();
 
     Assert.equal(spy.count, 0);
     Assert.equal(result, "foo's device id");
@@ -573,17 +577,17 @@ add_task(
   async function test_getDeviceId_with_device_id_and_with_no_registration_version_invokes_device_registration() {
     const credentials = getTestUser("foo");
     credentials.verified = true;
     const fxa = await MockFxAccounts(credentials);
 
     const spy = { count: 0, args: [] };
     fxa._internal.currentAccountState.getUserAccountData = () =>
       Promise.resolve({ device: { id: "wibble" } });
-    fxa._internal._registerOrUpdateDevice = function() {
+    fxa._internal.device._registerOrUpdateDevice = function() {
       spy.count += 1;
       spy.args.push(arguments);
       return Promise.resolve("wibble");
     };
 
     const result = await fxa.device.getLocalId();
 
     Assert.equal(spy.count, 1);