Bug 1227527 - Implement basic FxA device registration. r=markh
☠☠ backed out by 0c12d4229be0 ☠ ☠
authorPhil Booth <pmbooth@gmail.com>
Mon, 21 Dec 2015 12:29:00 +0100
changeset 312893 4228fd8ef72fd99cafe4826473593b4ffb81b273
parent 312892 83cb1c2445fa081a67d379d975557afd56bf4979
child 312894 caa21d8e9e04b25a39df17139abf85366fd1cdf5
push id5703
push userraliiev@mozilla.com
push dateMon, 07 Mar 2016 14:18:41 +0000
treeherdermozilla-beta@31e373ad5b5f [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersmarkh
bugs1227527
milestone46.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 1227527 - Implement basic FxA device registration. r=markh
b2g/components/test/unit/test_fxaccounts.js
services/fxaccounts/FxAccounts.jsm
services/fxaccounts/FxAccountsClient.jsm
services/fxaccounts/FxAccountsCommon.js
services/fxaccounts/FxAccountsWebChannel.jsm
services/fxaccounts/tests/xpcshell/test_accounts.js
services/fxaccounts/tests/xpcshell/test_accounts_device_registration.js
services/fxaccounts/tests/xpcshell/test_client.js
services/fxaccounts/tests/xpcshell/test_credentials.js
services/fxaccounts/tests/xpcshell/test_loginmgr_storage.js
services/fxaccounts/tests/xpcshell/test_oauth_grant_client.js
services/fxaccounts/tests/xpcshell/test_oauth_grant_client_server.js
services/fxaccounts/tests/xpcshell/test_oauth_token_storage.js
services/fxaccounts/tests/xpcshell/test_oauth_tokens.js
services/fxaccounts/tests/xpcshell/test_profile.js
services/fxaccounts/tests/xpcshell/test_storage_manager.js
services/fxaccounts/tests/xpcshell/xpcshell.ini
services/sync/modules/constants.js
services/sync/modules/engines/clients.js
services/sync/modules/util.js
testing/profiles/prefs_b2g_unittest.js
testing/profiles/prefs_general.js
--- a/b2g/components/test/unit/test_fxaccounts.js
+++ b/b2g/components/test/unit/test_fxaccounts.js
@@ -19,31 +19,35 @@ XPCOMUtils.defineLazyModuleGetter(this, 
 
 // At end of test, restore original state
 const ORIGINAL_AUTH_URI = Services.prefs.getCharPref("identity.fxaccounts.auth.uri");
 var { SystemAppProxy } = Cu.import("resource://gre/modules/FxAccountsMgmtService.jsm");
 const ORIGINAL_SENDCUSTOM = SystemAppProxy._sendCustomEvent;
 do_register_cleanup(function() {
   Services.prefs.setCharPref("identity.fxaccounts.auth.uri", ORIGINAL_AUTH_URI);
   SystemAppProxy._sendCustomEvent = ORIGINAL_SENDCUSTOM;
+  Services.prefs.clearUserPref("identity.fxaccounts.skipDeviceRegistration");
 });
 
 // Make profile available so that fxaccounts can store user data
 do_get_profile();
 
 // Mock the system app proxy; make message passing possible
 var mockSendCustomEvent = function(aEventName, aMsg) {
   Services.obs.notifyObservers({wrappedJSObject: aMsg}, aEventName, null);
 };
 
 function run_test() {
   run_next_test();
 }
 
 add_task(function test_overall() {
+  // FxA device registration throws from this context
+  Services.prefs.setBoolPref("identity.fxaccounts.skipDeviceRegistration", true);
+
   do_check_neq(FxAccountsMgmtService, null);
 });
 
 // Check that invalid email capitalization is corrected on signIn.
 // https://github.com/mozilla/fxa-auth-server/blob/master/docs/api.md#post-v1accountlogin
 add_test(function test_invalidEmailCase_signIn() {
   do_test_pending();
   let clientEmail = "greta.garbo@gmail.com";
@@ -100,16 +104,19 @@ add_test(function test_invalidEmailCase_
       });
       return;
     },
   });
 
   // Point the FxAccountsClient's hawk rest request client to the mock server
   Services.prefs.setCharPref("identity.fxaccounts.auth.uri", server.baseURI);
 
+  // FxA device registration throws from this context
+  Services.prefs.setBoolPref("identity.fxaccounts.skipDeviceRegistration", true);
+
   // Receive a mozFxAccountsChromeEvent message
   function onMessage(subject, topic, data) {
     let message = subject.wrappedJSObject;
 
     switch (message.id) {
       // When we signed in as "Greta.Garbo", the server should have told us
       // that the proper capitalization is really "greta.garbo".  Call
       // getAccounts to get the signed-in user and ensure that the
@@ -159,16 +166,19 @@ add_test(function test_invalidEmailCase_
       },
     },
   });
 });
 
 add_test(function testHandleGetAssertionError_defaultCase() {
   do_test_pending();
 
+  // FxA device registration throws from this context
+  Services.prefs.setBoolPref("identity.fxaccounts.skipDeviceRegistration", true);
+
   FxAccountsManager.getAssertion(null).then(
     success => {
       // getAssertion should throw with invalid audience
       ok(false);
     },
     reason => {
       equal("INVALID_AUDIENCE", reason.error);
       do_test_finished();
--- a/services/fxaccounts/FxAccounts.jsm
+++ b/services/fxaccounts/FxAccounts.jsm
@@ -25,37 +25,42 @@ XPCOMUtils.defineLazyModuleGetter(this, 
   "resource://gre/modules/identity/jwcrypto.jsm");
 
 XPCOMUtils.defineLazyModuleGetter(this, "FxAccountsOAuthGrantClient",
   "resource://gre/modules/FxAccountsOAuthGrantClient.jsm");
 
 XPCOMUtils.defineLazyModuleGetter(this, "FxAccountsProfile",
   "resource://gre/modules/FxAccountsProfile.jsm");
 
+XPCOMUtils.defineLazyModuleGetter(this, "Utils",
+  "resource://services-sync/util.js");
+
 // All properties exposed by the public FxAccounts API.
 var publicProperties = [
   "accountStatus",
   "getAccountsClient",
   "getAccountsSignInURI",
   "getAccountsSignUpURI",
   "getAssertion",
+  "getDeviceId",
   "getKeys",
   "getSignedInUser",
   "getOAuthToken",
   "getSignedInUserProfile",
   "loadAndPoll",
   "localtimeOffsetMsec",
   "now",
   "promiseAccountsForceSigninURI",
   "promiseAccountsChangeProfileURI",
   "promiseAccountsManageURI",
   "removeCachedOAuthToken",
   "resendVerificationEmail",
   "setSignedInUser",
   "signOut",
+  "updateDeviceRegistration",
   "whenVerified"
 ];
 
 // An AccountState object holds all state related to one specific account.
 // Only one AccountState is ever "current" in the FxAccountsInternal object -
 // whenever a user logs out or logs in, the current AccountState is discarded,
 // making it impossible for the wrong state or state data to be accidentally
 // used.
@@ -485,17 +490,19 @@ FxAccountsInternal.prototype = {
     return this.abortExistingFlow().then(() => {
       let currentAccountState = this.currentAccountState = this.newAccountState(
         Cu.cloneInto(credentials, {}) // Pass a clone of the credentials object.
       );
       // This promise waits for storage, but not for verification.
       // We're telling the caller that this is durable now (although is that
       // really something we should commit to? Why not let the write happen in
       // the background? Already does for updateAccountData ;)
-      return currentAccountState.promiseInitialized.then(() => {
+      return currentAccountState.promiseInitialized.then(() =>
+        this.updateDeviceRegistration()
+      ).then(() => {
         Services.telemetry.getHistogramById("FXA_CONFIGURED").add(1);
         this.notifyObservers(ONLOGIN_NOTIFICATION);
         if (!this.isUserEmailVerified(credentials)) {
           this.startVerifiedCheck(credentials);
         }
       }).then(() => {
         return currentAccountState.resolve();
       });
@@ -534,16 +541,36 @@ FxAccountsInternal.prototype = {
       return this.getKeypairAndCertificate(currentState).then(
         ({keyPair, certificate}) => {
           return this.getAssertionFromCert(data, keyPair, certificate, audience);
         }
       );
     }).then(result => currentState.resolve(result));
   },
 
+  getDeviceId() {
+    return this.currentAccountState.getUserAccountData()
+      .then(data => {
+        if (data) {
+          if (data.isDeviceStale || !data.deviceId) {
+            // A previous device registration attempt failed or there is no
+            // device id. Either way, we should register the device with FxA
+            // before returning the id to the caller.
+            return this._registerOrUpdateDevice(data);
+          }
+
+          // Return the device id that we already registered with the server.
+          return data.deviceId;
+        }
+
+        // Without a signed-in user, there can be no device id.
+        return null;
+      });
+  },
+
   /**
    * Resend the verification email fot the currently signed-in user.
    *
    */
   resendVerificationEmail: function resendVerificationEmail() {
     let currentState = this.currentAccountState;
     return this.getSignedInUser().then(data => {
       // If the caller is asking for verification to be re-sent, and there is
@@ -600,32 +627,37 @@ FxAccountsInternal.prototype = {
     }
     return Promise.all(promises);
   },
 
   signOut: function signOut(localOnly) {
     let currentState = this.currentAccountState;
     let sessionToken;
     let tokensToRevoke;
+    let deviceId;
     return currentState.getUserAccountData().then(data => {
-      // Save the session token for use in the call to signOut below.
-      sessionToken = data && data.sessionToken;
-      tokensToRevoke = data && data.oauthTokens;
+      // Save the session token, tokens to revoke and the
+      // device id for use in the call to signOut below.
+      if (data) {
+        sessionToken = data.sessionToken;
+        tokensToRevoke = data.oauthTokens;
+        deviceId = data.deviceId;
+      }
       return this._signOutLocal();
     }).then(() => {
       // FxAccountsManager calls here, then does its own call
       // to FxAccountsClient.signOut().
       if (!localOnly) {
         // Wrap this in a promise so *any* errors in signOut won't
         // block the local sign out. This is *not* returned.
         Promise.resolve().then(() => {
           // This can happen in the background and shouldn't block
           // the user from signing out. The server must tolerate
           // clients just disappearing, so this call should be best effort.
-          return this._signOutServer(sessionToken);
+          return this._signOutServer(sessionToken, deviceId);
         }).catch(err => {
           log.error("Error during remote sign out of Firefox Accounts", err);
         }).then(() => {
           return this._destroyAllOAuthTokens(tokensToRevoke);
         }).catch(err => {
           log.error("Error during destruction of oauth tokens during signout", err);
         }).then(() => {
           // just for testing - notifications are cheap when no observers.
@@ -647,21 +679,32 @@ FxAccountsInternal.prototype = {
       // this "aborts" this.currentAccountState but doesn't make a new one.
       return this.abortExistingFlow();
     }).then(() => {
       this.currentAccountState = this.newAccountState();
       return this.currentAccountState.promiseInitialized;
     });
   },
 
-  _signOutServer: function signOutServer(sessionToken) {
-    // For now we assume the service being logged out from is Sync - we might
-    // need to revisit this when this FxA code is used in a context that
-    // isn't Sync.
-    return this.fxAccountsClient.signOut(sessionToken, {service: "sync"});
+  _signOutServer(sessionToken, deviceId) {
+    // For now we assume the service being logged out from is Sync, so
+    // we must tell the server to either destroy the device or sign out
+    // (if no device exists). We might need to revisit this when this
+    // FxA code is used in a context that isn't Sync.
+
+    const options = { service: "sync" };
+
+    if (deviceId) {
+      log.debug("destroying device and session");
+      return this.fxAccountsClient.signOutAndDestroyDevice(sessionToken, deviceId, options)
+        .then(() => this.currentAccountState.updateUserAccountData({ deviceId: null }));
+    }
+
+    log.debug("destroying session");
+    return this.fxAccountsClient.signOut(sessionToken, options);
   },
 
   /**
    * Fetch encryption keys for the signed-in-user from the FxA API server.
    *
    * Not for user consumption.  Exists to cause the keys to be fetch.
    *
    * Returns user data so that it can be chained with other methods.
@@ -1329,16 +1372,137 @@ FxAccountsInternal.prototype = {
         return currentState.resolve(profile);
       },
       error => {
         log.error("Could not retrieve profile data", error);
         return currentState.reject(error);
       }
     ).catch(err => Promise.reject(this._errorToErrorClass(err)));
   },
+
+  // 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.
+  updateDeviceRegistration() {
+    return this.getSignedInUser().then(signedInUser => {
+      if (signedInUser) {
+        return this._registerOrUpdateDevice(signedInUser);
+      }
+    }).catch(error => this._logErrorAndSetStaleDeviceFlag(error));
+  },
+
+  _registerOrUpdateDevice(signedInUser) {
+    try {
+      // Allow tests to skip device registration because:
+      //   1. It makes remote requests to the auth server.
+      //   2. _getDeviceName does not work from xpcshell.
+      //   3. The B2G tests fail when attempting to import services-sync/util.js.
+      if (Services.prefs.getBoolPref("identity.fxaccounts.skipDeviceRegistration")) {
+        return Promise.resolve();
+      }
+    } catch(ignore) {}
+
+    return Promise.resolve().then(() => {
+      const deviceName = this._getDeviceName();
+
+      if (signedInUser.deviceId) {
+        log.debug("updating existing device details");
+        return this.fxAccountsClient.updateDevice(
+          signedInUser.sessionToken, signedInUser.deviceId, deviceName);
+      }
+
+      log.debug("registering new device details");
+      return this.fxAccountsClient.registerDevice(
+        signedInUser.sessionToken, deviceName, this._getDeviceType());
+    }).then(device =>
+      this.currentAccountState.updateUserAccountData({
+        deviceId: device.id,
+        isDeviceStale: null
+      }).then(() => device.id)
+    ).catch(error => this._handleDeviceError(error, signedInUser.sessionToken));
+  },
+
+  _getDeviceName() {
+    return Utils.getDeviceName();
+  },
+
+  _getDeviceType() {
+    return Utils.getDeviceType();
+  },
+
+  _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);
+        }
+      }
+
+      return this._logErrorAndSetStaleDeviceFlag(error);
+    }).catch(() => {});
+  },
+
+  _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");
+    return this.currentAccountState.updateUserAccountData({ deviceId: null })
+      .catch(error => this._logErrorAndSetStaleDeviceFlag(error));
+  },
+
+  _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 the stale device
+    //      flag 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");
+    return this.fxAccountsClient.getDeviceList(sessionToken)
+      .then(devices => {
+        const matchingDevices = devices.filter(device => device.isCurrentDevice);
+        const length = matchingDevices.length;
+        if (length === 1) {
+          const deviceId = matchingDevices[0].id
+          return this.currentAccountState.updateUserAccountData({
+            deviceId,
+            isDeviceStale: true
+          }).then(() => deviceId);
+        }
+        if (length > 1) {
+          log.error("insane server state, " + length + " devices for this session");
+        }
+        return this._logErrorAndSetStaleDeviceFlag(error);
+      }).catch(secondError => {
+        log.error("failed to recover from device-session conflict", secondError);
+        this._logErrorAndSetStaleDeviceFlag(error)
+      });
+  },
+
+  _logErrorAndSetStaleDeviceFlag(error) {
+    // Device registration should never cause other operations to fail.
+    // If we've reached this point, just log the error and set the stale
+    // device flag on the account data. At next sync or next sign-in,
+    // registration will be retried.
+    log.error("device registration failed", error);
+    return this.currentAccountState.updateUserAccountData({
+      isDeviceStale: true
+    }).catch(secondError => {
+      log.error(
+        "failed to set stale device flag, device registration won't be retried",
+        secondError);
+    }).then(() => {});
+  }
 };
 
 
 // A getter for the instance to export
 XPCOMUtils.defineLazyGetter(this, "fxAccounts", function() {
   let a = new FxAccounts();
 
   // XXX Bug 947061 - We need a strategy for resuming email verification after
--- a/services/fxaccounts/FxAccountsClient.jsm
+++ b/services/fxaccounts/FxAccountsClient.jsm
@@ -199,17 +199,17 @@ this.FxAccountsClient.prototype = {
    *
    * @param sessionTokenHex
    *        The session token encoded in hex
    * @return Promise
    */
   signOut: function (sessionTokenHex, options = {}) {
     let path = "/session/destroy";
     if (options.service) {
-      path += "?service=" + options.service;
+      path += "?service=" + encodeURIComponent(options.service);
     }
     return this._request(path, "POST",
       deriveHawkCredentials(sessionTokenHex, "sessionToken"));
   },
 
   /**
    * Check the verification status of the user's FxA email address
    *
@@ -354,16 +354,126 @@ this.FxAccountsClient.prototype = {
       },
       (error) => {
         log.error("accountStatus failed with: " + error);
         return Promise.reject(error);
       }
     );
   },
 
+  /**
+   * Register a new device
+   *
+   * @method registerDevice
+   * @param  sessionTokenHex
+   *         Session token obtained from signIn
+   * @param  name
+   *         Device name
+   * @param  type
+   *         Device type (mobile|desktop)
+   * @return Promise
+   *         Resolves to an object:
+   *         {
+   *           id: Device identifier
+   *           createdAt: Creation time (milliseconds since epoch)
+   *           name: Name of device
+   *           type: Type of device (mobile|desktop)
+   *         }
+   */
+  registerDevice(sessionTokenHex, name, type) {
+    let path = "/account/device";
+
+    let creds = deriveHawkCredentials(sessionTokenHex, "sessionToken");
+    let body = { name, type };
+
+    return this._request(path, "POST", creds, body);
+  },
+
+  /**
+   * Update the session or name for an existing device
+   *
+   * @method updateDevice
+   * @param  sessionTokenHex
+   *         Session token obtained from signIn
+   * @param  id
+   *         Device identifier
+   * @param  name
+   *         Device name
+   * @return Promise
+   *         Resolves to an object:
+   *         {
+   *           id: Device identifier
+   *           name: Device name
+   *         }
+   */
+  updateDevice(sessionTokenHex, id, name) {
+    let path = "/account/device";
+
+    let creds = deriveHawkCredentials(sessionTokenHex, "sessionToken");
+    let body = { id, name };
+
+    return this._request(path, "POST", creds, body);
+  },
+
+  /**
+   * Delete a device and its associated session token, signing the user
+   * out of the server.
+   *
+   * @method signOutAndDestroyDevice
+   * @param  sessionTokenHex
+   *         Session token obtained from signIn
+   * @param  id
+   *         Device identifier
+   * @param  [options]
+   *         Options object
+   * @param  [options.service]
+   *         `service` query parameter
+   * @return Promise
+   *         Resolves to an empty object:
+   *         {}
+   */
+  signOutAndDestroyDevice(sessionTokenHex, id, options={}) {
+    let path = "/account/device/destroy";
+
+    if (options.service) {
+      path += "?service=" + encodeURIComponent(options.service);
+    }
+
+    let creds = deriveHawkCredentials(sessionTokenHex, "sessionToken");
+    let body = { id };
+
+    return this._request(path, "POST", creds, body);
+  },
+
+  /**
+   * Get a list of currently registered devices
+   *
+   * @method getDeviceList
+   * @param  sessionTokenHex
+   *         Session token obtained from signIn
+   * @return Promise
+   *         Resolves to an array of objects:
+   *         [
+   *           {
+   *             id: Device id
+   *             isCurrentDevice: Boolean indicating whether the item
+   *                              represents the current device
+   *             name: Device name
+   *             type: Device type (mobile|desktop)
+   *           },
+   *           ...
+   *         ]
+   */
+  getDeviceList(sessionTokenHex) {
+    let path = "/account/devices";
+    let creds = deriveHawkCredentials(sessionTokenHex, "sessionToken");
+
+    return this._request(path, "GET", creds, {});
+  },
+
   _clearBackoff: function() {
       this.backoffError = null;
   },
 
   /**
    * A general method for sending raw API calls to the FxA auth server.
    * All request bodies and responses are JSON.
    *
--- a/services/fxaccounts/FxAccountsCommon.js
+++ b/services/fxaccounts/FxAccountsCommon.js
@@ -119,16 +119,20 @@ exports.ERRNO_MISSING_CONTENT_LENGTH    
 exports.ERRNO_REQUEST_BODY_TOO_LARGE         = 113;
 exports.ERRNO_TOO_MANY_CLIENT_REQUESTS       = 114;
 exports.ERRNO_INVALID_AUTH_NONCE             = 115;
 exports.ERRNO_ENDPOINT_NO_LONGER_SUPPORTED   = 116;
 exports.ERRNO_INCORRECT_LOGIN_METHOD         = 117;
 exports.ERRNO_INCORRECT_KEY_RETRIEVAL_METHOD = 118;
 exports.ERRNO_INCORRECT_API_VERSION          = 119;
 exports.ERRNO_INCORRECT_EMAIL_CASE           = 120;
+exports.ERRNO_ACCOUNT_LOCKED                 = 121;
+exports.ERRNO_ACCOUNT_UNLOCKED               = 122;
+exports.ERRNO_UNKNOWN_DEVICE                 = 123;
+exports.ERRNO_DEVICE_SESSION_CONFLICT        = 124;
 exports.ERRNO_SERVICE_TEMP_UNAVAILABLE       = 201;
 exports.ERRNO_PARSE                          = 997;
 exports.ERRNO_NETWORK                        = 998;
 exports.ERRNO_UNKNOWN_ERROR                  = 999;
 
 // Offset oauth server errnos so they don't conflict with auth server errnos
 exports.OAUTH_SERVER_ERRNO_OFFSET = 1000;
 
@@ -145,17 +149,20 @@ exports.ERRNO_INVALID_REQUEST_PARAM     
 exports.ERRNO_INVALID_RESPONSE_TYPE          = 110 + exports.OAUTH_SERVER_ERRNO_OFFSET;
 exports.ERRNO_UNAUTHORIZED                   = 111 + exports.OAUTH_SERVER_ERRNO_OFFSET;
 exports.ERRNO_FORBIDDEN                      = 112 + exports.OAUTH_SERVER_ERRNO_OFFSET;
 exports.ERRNO_INVALID_CONTENT_TYPE           = 113 + exports.OAUTH_SERVER_ERRNO_OFFSET;
 
 // Errors.
 exports.ERROR_ACCOUNT_ALREADY_EXISTS         = "ACCOUNT_ALREADY_EXISTS";
 exports.ERROR_ACCOUNT_DOES_NOT_EXIST         = "ACCOUNT_DOES_NOT_EXIST ";
+exports.ERROR_ACCOUNT_LOCKED                 = "ACCOUNT_LOCKED";
+exports.ERROR_ACCOUNT_UNLOCKED               = "ACCOUNT_UNLOCKED";
 exports.ERROR_ALREADY_SIGNED_IN_USER         = "ALREADY_SIGNED_IN_USER";
+exports.ERROR_DEVICE_SESSION_CONFLICT        = "DEVICE_SESSION_CONFLICT";
 exports.ERROR_ENDPOINT_NO_LONGER_SUPPORTED   = "ENDPOINT_NO_LONGER_SUPPORTED";
 exports.ERROR_INCORRECT_API_VERSION          = "INCORRECT_API_VERSION";
 exports.ERROR_INCORRECT_EMAIL_CASE           = "INCORRECT_EMAIL_CASE";
 exports.ERROR_INCORRECT_KEY_RETRIEVAL_METHOD = "INCORRECT_KEY_RETRIEVAL_METHOD";
 exports.ERROR_INCORRECT_LOGIN_METHOD         = "INCORRECT_LOGIN_METHOD";
 exports.ERROR_INVALID_EMAIL                  = "INVALID_EMAIL";
 exports.ERROR_INVALID_AUDIENCE               = "INVALID_AUDIENCE";
 exports.ERROR_INVALID_AUTH_TOKEN             = "INVALID_AUTH_TOKEN";
@@ -179,16 +186,17 @@ exports.ERROR_SERVER_ERROR              
 exports.ERROR_SYNC_DISABLED                  = "SYNC_DISABLED";
 exports.ERROR_TOO_MANY_CLIENT_REQUESTS       = "TOO_MANY_CLIENT_REQUESTS";
 exports.ERROR_SERVICE_TEMP_UNAVAILABLE       = "SERVICE_TEMPORARY_UNAVAILABLE";
 exports.ERROR_UI_ERROR                       = "UI_ERROR";
 exports.ERROR_UI_REQUEST                     = "UI_REQUEST";
 exports.ERROR_PARSE                          = "PARSE_ERROR";
 exports.ERROR_NETWORK                        = "NETWORK_ERROR";
 exports.ERROR_UNKNOWN                        = "UNKNOWN_ERROR";
+exports.ERROR_UNKNOWN_DEVICE                 = "UNKNOWN_DEVICE";
 exports.ERROR_UNVERIFIED_ACCOUNT             = "UNVERIFIED_ACCOUNT";
 
 // OAuth errors.
 exports.ERROR_UNKNOWN_CLIENT_ID              = "UNKNOWN_CLIENT_ID";
 exports.ERROR_INCORRECT_CLIENT_SECRET        = "INCORRECT_CLIENT_SECRET";
 exports.ERROR_INCORRECT_REDIRECT_URI         = "INCORRECT_REDIRECT_URI";
 exports.ERROR_INVALID_FXA_ASSERTION          = "INVALID_FXA_ASSERTION";
 exports.ERROR_UNKNOWN_CODE                   = "UNKNOWN_CODE";
@@ -213,17 +221,18 @@ exports.ERROR_MSG_METHOD_NOT_ALLOWED    
 // FxAccounts has the ability to "split" the credentials between a plain-text
 // JSON file in the profile dir and in the login manager.
 // In order to prevent new fields accidentally ending up in the "wrong" place,
 // all fields stored are listed here.
 
 // The fields we save in the plaintext JSON.
 // See bug 1013064 comments 23-25 for why the sessionToken is "safe"
 exports.FXA_PWDMGR_PLAINTEXT_FIELDS = new Set(
-  ["email", "verified", "authAt", "sessionToken", "uid", "oauthTokens", "profile"]);
+  ["email", "verified", "authAt", "sessionToken", "uid", "oauthTokens", "profile",
+  "deviceId", "isDeviceStale"]);
 
 // Fields we store in secure storage if it exists.
 exports.FXA_PWDMGR_SECURE_FIELDS = new Set(
   ["kA", "kB", "keyFetchToken", "unwrapBKey", "assertion"]);
 
 // Fields we keep in memory and don't persist anywhere.
 exports.FXA_PWDMGR_MEMORY_FIELDS = new Set(
   ["cert", "keyPair"]);
@@ -262,16 +271,20 @@ SERVER_ERRNO_TO_ERROR[ERRNO_MISSING_CONT
 SERVER_ERRNO_TO_ERROR[ERRNO_REQUEST_BODY_TOO_LARGE]         = ERROR_REQUEST_BODY_TOO_LARGE;
 SERVER_ERRNO_TO_ERROR[ERRNO_TOO_MANY_CLIENT_REQUESTS]       = ERROR_TOO_MANY_CLIENT_REQUESTS;
 SERVER_ERRNO_TO_ERROR[ERRNO_INVALID_AUTH_NONCE]             = ERROR_INVALID_AUTH_NONCE;
 SERVER_ERRNO_TO_ERROR[ERRNO_ENDPOINT_NO_LONGER_SUPPORTED]   = ERROR_ENDPOINT_NO_LONGER_SUPPORTED;
 SERVER_ERRNO_TO_ERROR[ERRNO_INCORRECT_LOGIN_METHOD]         = ERROR_INCORRECT_LOGIN_METHOD;
 SERVER_ERRNO_TO_ERROR[ERRNO_INCORRECT_KEY_RETRIEVAL_METHOD] = ERROR_INCORRECT_KEY_RETRIEVAL_METHOD;
 SERVER_ERRNO_TO_ERROR[ERRNO_INCORRECT_API_VERSION]          = ERROR_INCORRECT_API_VERSION;
 SERVER_ERRNO_TO_ERROR[ERRNO_INCORRECT_EMAIL_CASE]           = ERROR_INCORRECT_EMAIL_CASE;
+SERVER_ERRNO_TO_ERROR[ERRNO_ACCOUNT_LOCKED]                 = ERROR_ACCOUNT_LOCKED;
+SERVER_ERRNO_TO_ERROR[ERRNO_ACCOUNT_UNLOCKED]               = ERROR_ACCOUNT_UNLOCKED;
+SERVER_ERRNO_TO_ERROR[ERRNO_UNKNOWN_DEVICE]                 = ERROR_UNKNOWN_DEVICE;
+SERVER_ERRNO_TO_ERROR[ERRNO_DEVICE_SESSION_CONFLICT]        = ERROR_DEVICE_SESSION_CONFLICT;
 SERVER_ERRNO_TO_ERROR[ERRNO_SERVICE_TEMP_UNAVAILABLE]       = ERROR_SERVICE_TEMP_UNAVAILABLE;
 SERVER_ERRNO_TO_ERROR[ERRNO_UNKNOWN_ERROR]                  = ERROR_UNKNOWN;
 SERVER_ERRNO_TO_ERROR[ERRNO_NETWORK]                        = ERROR_NETWORK;
 
 // oauth
 SERVER_ERRNO_TO_ERROR[ERRNO_UNKNOWN_CLIENT_ID]              = ERROR_UNKNOWN_CLIENT_ID;
 SERVER_ERRNO_TO_ERROR[ERRNO_INCORRECT_CLIENT_SECRET]        = ERROR_INCORRECT_CLIENT_SECRET;
 SERVER_ERRNO_TO_ERROR[ERRNO_INCORRECT_REDIRECT_URI]         = ERROR_INCORRECT_REDIRECT_URI;
@@ -285,17 +298,20 @@ SERVER_ERRNO_TO_ERROR[ERRNO_INVALID_RESP
 SERVER_ERRNO_TO_ERROR[ERRNO_UNAUTHORIZED]                   = ERROR_UNAUTHORIZED;
 SERVER_ERRNO_TO_ERROR[ERRNO_FORBIDDEN]                      = ERROR_FORBIDDEN;
 SERVER_ERRNO_TO_ERROR[ERRNO_INVALID_CONTENT_TYPE]           = ERROR_INVALID_CONTENT_TYPE;
 
 
 // Map internal errors to more generic error classes for consumers
 ERROR_TO_GENERAL_ERROR_CLASS[ERROR_ACCOUNT_ALREADY_EXISTS]         = ERROR_AUTH_ERROR;
 ERROR_TO_GENERAL_ERROR_CLASS[ERROR_ACCOUNT_DOES_NOT_EXIST]         = ERROR_AUTH_ERROR;
+ERROR_TO_GENERAL_ERROR_CLASS[ERROR_ACCOUNT_LOCKED]                 = ERROR_AUTH_ERROR;
+ERROR_TO_GENERAL_ERROR_CLASS[ERROR_ACCOUNT_UNLOCKED]               = ERROR_AUTH_ERROR;
 ERROR_TO_GENERAL_ERROR_CLASS[ERROR_ALREADY_SIGNED_IN_USER]         = ERROR_AUTH_ERROR;
+ERROR_TO_GENERAL_ERROR_CLASS[ERROR_DEVICE_SESSION_CONFLICT]        = ERROR_AUTH_ERROR;
 ERROR_TO_GENERAL_ERROR_CLASS[ERROR_ENDPOINT_NO_LONGER_SUPPORTED]   = ERROR_AUTH_ERROR;
 ERROR_TO_GENERAL_ERROR_CLASS[ERROR_INCORRECT_API_VERSION]          = ERROR_AUTH_ERROR;
 ERROR_TO_GENERAL_ERROR_CLASS[ERROR_INCORRECT_EMAIL_CASE]           = ERROR_AUTH_ERROR;
 ERROR_TO_GENERAL_ERROR_CLASS[ERROR_INCORRECT_KEY_RETRIEVAL_METHOD] = ERROR_AUTH_ERROR;
 ERROR_TO_GENERAL_ERROR_CLASS[ERROR_INCORRECT_LOGIN_METHOD]         = ERROR_AUTH_ERROR;
 ERROR_TO_GENERAL_ERROR_CLASS[ERROR_INVALID_EMAIL]                  = ERROR_AUTH_ERROR;
 ERROR_TO_GENERAL_ERROR_CLASS[ERROR_INVALID_AUDIENCE]               = ERROR_AUTH_ERROR;
 ERROR_TO_GENERAL_ERROR_CLASS[ERROR_INVALID_AUTH_TOKEN]             = ERROR_AUTH_ERROR;
@@ -309,16 +325,17 @@ ERROR_TO_GENERAL_ERROR_CLASS[ERROR_INVAL
 ERROR_TO_GENERAL_ERROR_CLASS[ERROR_INTERNAL_INVALID_USER]          = ERROR_AUTH_ERROR;
 ERROR_TO_GENERAL_ERROR_CLASS[ERROR_MISSING_BODY_PARAMETERS]        = ERROR_AUTH_ERROR;
 ERROR_TO_GENERAL_ERROR_CLASS[ERROR_MISSING_CONTENT_LENGTH]         = ERROR_AUTH_ERROR;
 ERROR_TO_GENERAL_ERROR_CLASS[ERROR_NO_TOKEN_SESSION]               = ERROR_AUTH_ERROR;
 ERROR_TO_GENERAL_ERROR_CLASS[ERROR_NO_SILENT_REFRESH_AUTH]         = ERROR_AUTH_ERROR;
 ERROR_TO_GENERAL_ERROR_CLASS[ERROR_NOT_VALID_JSON_BODY]            = ERROR_AUTH_ERROR;
 ERROR_TO_GENERAL_ERROR_CLASS[ERROR_PERMISSION_DENIED]              = ERROR_AUTH_ERROR;
 ERROR_TO_GENERAL_ERROR_CLASS[ERROR_REQUEST_BODY_TOO_LARGE]         = ERROR_AUTH_ERROR;
+ERROR_TO_GENERAL_ERROR_CLASS[ERROR_UNKNOWN_DEVICE]                 = ERROR_AUTH_ERROR;
 ERROR_TO_GENERAL_ERROR_CLASS[ERROR_UNVERIFIED_ACCOUNT]             = ERROR_AUTH_ERROR;
 ERROR_TO_GENERAL_ERROR_CLASS[ERROR_UI_ERROR]                       = ERROR_AUTH_ERROR;
 ERROR_TO_GENERAL_ERROR_CLASS[ERROR_UI_REQUEST]                     = ERROR_AUTH_ERROR;
 ERROR_TO_GENERAL_ERROR_CLASS[ERROR_OFFLINE]                        = ERROR_NETWORK;
 ERROR_TO_GENERAL_ERROR_CLASS[ERROR_SERVER_ERROR]                   = ERROR_NETWORK;
 ERROR_TO_GENERAL_ERROR_CLASS[ERROR_TOO_MANY_CLIENT_REQUESTS]       = ERROR_NETWORK;
 ERROR_TO_GENERAL_ERROR_CLASS[ERROR_SERVICE_TEMP_UNAVAILABLE]       = ERROR_NETWORK;
 ERROR_TO_GENERAL_ERROR_CLASS[ERROR_PARSE]                          = ERROR_NETWORK;
--- a/services/fxaccounts/FxAccountsWebChannel.jsm
+++ b/services/fxaccounts/FxAccountsWebChannel.jsm
@@ -256,17 +256,19 @@ this.FxAccountsWebChannelHelpers.prototy
   /**
    * logout the fxaccounts service
    *
    * @param the uid of the account which have been logged out
    */
   logout(uid) {
     return fxAccounts.getSignedInUser().then(userData => {
       if (userData.uid === uid) {
-        return fxAccounts.signOut();
+        // true argument is `localOnly`, because server-side stuff
+        // has already been taken care of by the content server
+        return fxAccounts.signOut(true);
       }
     });
   },
 
   /**
    * Get the hash of account name of the previously signed in account
    */
   getPreviousAccountNameHashPref() {
--- a/services/fxaccounts/tests/xpcshell/test_accounts.js
+++ b/services/fxaccounts/tests/xpcshell/test_accounts.js
@@ -122,17 +122,18 @@ function MockFxAccountsClient() {
 
   this.resendVerificationEmail = function(sessionToken) {
     // Return the session token to show that we received it in the first place
     return Promise.resolve(sessionToken);
   };
 
   this.signCertificate = function() { throw "no" };
 
-  this.signOut = function() { return Promise.resolve(); };
+  this.signOut = () => Promise.resolve();
+  this.signOutAndDestroyDevice = () => Promise.resolve({});
 
   FxAccountsClient.apply(this);
 }
 MockFxAccountsClient.prototype = {
   __proto__: FxAccountsClient.prototype
 }
 
 /*
@@ -157,16 +158,19 @@ function MockFxAccounts() {
       storage.initialize(credentials);
       return new AccountState(storage);
     },
     getCertificateSigned: function (sessionToken, serializedPublicKey) {
       _("mock getCertificateSigned\n");
       this._getCertificateSigned_calls.push([sessionToken, serializedPublicKey]);
       return this._d_signCertificate.promise;
     },
+    _registerOrUpdateDevice() {
+      return Promise.resolve();
+    },
     fxAccountsClient: new MockFxAccountsClient()
   });
 }
 
 /*
  * Some tests want a "real" fxa instance - however, we still mock the storage
  * to keep the tests fast on b2g.
  */
@@ -174,16 +178,22 @@ function MakeFxAccounts(internal = {}) {
   if (!internal.newAccountState) {
     // we use a real accountState but mocked storage.
     internal.newAccountState = function(credentials) {
       let storage = new MockStorageManager();
       storage.initialize(credentials);
       return new AccountState(storage);
     };
   }
+  if (!internal._signOutServer) {
+    internal._signOutServer = () => Promise.resolve();
+  }
+  if (!internal._registerOrUpdateDevice) {
+    internal._registerOrUpdateDevice = () => Promise.resolve();
+  }
   return new FxAccounts(internal);
 }
 
 add_test(function test_non_https_remote_server_uri_with_requireHttps_false() {
   Services.prefs.setBoolPref(
     "identity.fxaccounts.allowHttp",
     true);
   Services.prefs.setCharPref(
@@ -205,17 +215,17 @@ add_test(function test_non_https_remote_
     fxAccounts.getAccountsSignUpURI();
   }, "Firefox Accounts server must use HTTPS");
 
   Services.prefs.clearUserPref("identity.fxaccounts.remote.signup.uri");
 
   run_next_test();
 });
 
-add_task(function test_get_signed_in_user_initially_unset() {
+add_task(function* test_get_signed_in_user_initially_unset() {
   _("Check getSignedInUser initially and after signout reports no user");
   let account = MakeFxAccounts();
   let credentials = {
     email: "foo@example.com",
     uid: "1234@lcip.org",
     assertion: "foobar",
     sessionToken: "dead",
     kA: "beef",
@@ -550,17 +560,17 @@ add_test(function test_overlapping_signi
           log.debug("Bob verifying his email ...");
           fxa.internal.fxAccountsClient._verified = true;
         });
       });
     });
   });
 });
 
-add_task(function test_getAssertion() {
+add_task(function* test_getAssertion() {
   let fxa = new MockFxAccounts();
 
   do_check_throws(function() {
     yield fxa.getAssertion("nonaudience");
   });
 
   let creds = {
     sessionToken: "sessionToken",
@@ -670,17 +680,17 @@ add_task(function test_getAssertion() {
   do_check_eq(keyPair.validUntil, now + KEY_LIFETIME);
   do_check_eq(cert.validUntil, now + CERT_LIFETIME);
   exp = Number(payload.exp);
 
   do_check_eq(exp, now + ASSERTION_LIFETIME);
   _("----- DONE ----\n");
 });
 
-add_task(function test_resend_email_not_signed_in() {
+add_task(function* test_resend_email_not_signed_in() {
   let fxa = new MockFxAccounts();
 
   try {
     yield fxa.resendVerificationEmail();
   } catch(err) {
     do_check_eq(err.message,
       "Cannot resend verification email; no signed-in user");
     return;
@@ -757,31 +767,108 @@ add_test(function test_resend_email() {
         // Ok abort polling before we go on to the next test
         fxa.internal.abortExistingFlow();
         run_next_test();
       });
     });
   });
 });
 
-add_test(function test_sign_out() {
-  let fxa = new MockFxAccounts();
-  let remoteSignOutCalled = false;
-  let client = fxa.internal.fxAccountsClient;
-  client.signOut = function() { remoteSignOutCalled = true; return Promise.resolve(); };
-  makeObserver(ONLOGOUT_NOTIFICATION, function() {
-    log.debug("test_sign_out_with_remote_error observed onlogout");
-    // user should be undefined after sign out
-    fxa.internal.getUserAccountData().then(user => {
-      do_check_eq(user, null);
-      do_check_true(remoteSignOutCalled);
-      run_next_test();
+add_task(function* test_sign_out_with_device() {
+  const fxa = new MockFxAccounts();
+
+  const credentials = getTestUser("alice");
+  yield fxa.internal.setSignedInUser(credentials);
+
+  const user = yield fxa.internal.getUserAccountData();
+  do_check_true(user);
+  Object.keys(credentials).forEach(key => do_check_eq(credentials[key], user[key]));
+
+  const spy = {
+    signOut: { count: 0 },
+    signOutAndDeviceDestroy: { count: 0, args: [] }
+  };
+  const client = fxa.internal.fxAccountsClient;
+  client.signOut = function () {
+    spy.signOut.count += 1;
+    return Promise.resolve();
+  };
+  client.signOutAndDestroyDevice = function () {
+    spy.signOutAndDeviceDestroy.count += 1;
+    spy.signOutAndDeviceDestroy.args.push(arguments);
+    return Promise.resolve();
+  };
+
+  const promise = new Promise(resolve => {
+    makeObserver(ONLOGOUT_NOTIFICATION, () => {
+      log.debug("test_sign_out_with_device observed onlogout");
+      // user should be undefined after sign out
+      fxa.internal.getUserAccountData().then(user2 => {
+        do_check_eq(user2, null);
+        do_check_eq(spy.signOut.count, 0);
+        do_check_eq(spy.signOutAndDeviceDestroy.count, 1);
+        do_check_eq(spy.signOutAndDeviceDestroy.args[0].length, 3);
+        do_check_eq(spy.signOutAndDeviceDestroy.args[0][0], credentials.sessionToken);
+        do_check_eq(spy.signOutAndDeviceDestroy.args[0][1], credentials.deviceId);
+        do_check_true(spy.signOutAndDeviceDestroy.args[0][2]);
+        do_check_eq(spy.signOutAndDeviceDestroy.args[0][2].service, "sync");
+        resolve();
+      });
     });
   });
-  fxa.signOut();
+
+  yield fxa.signOut();
+
+  yield promise;
+});
+
+add_task(function* test_sign_out_without_device() {
+  const fxa = new MockFxAccounts();
+
+  const credentials = getTestUser("alice");
+  delete credentials.deviceId;
+  yield fxa.internal.setSignedInUser(credentials);
+
+  const user = yield fxa.internal.getUserAccountData();
+
+  const spy = {
+    signOut: { count: 0, args: [] },
+    signOutAndDeviceDestroy: { count: 0 }
+  };
+  const client = fxa.internal.fxAccountsClient;
+  client.signOut = function () {
+    spy.signOut.count += 1;
+    spy.signOut.args.push(arguments);
+    return Promise.resolve();
+  };
+  client.signOutAndDestroyDevice = function () {
+    spy.signOutAndDeviceDestroy.count += 1;
+    return Promise.resolve();
+  };
+
+  const promise = new Promise(resolve => {
+    makeObserver(ONLOGOUT_NOTIFICATION, () => {
+      log.debug("test_sign_out_without_device observed onlogout");
+      // user should be undefined after sign out
+      fxa.internal.getUserAccountData().then(user2 => {
+        do_check_eq(user2, null);
+        do_check_eq(spy.signOut.count, 1);
+        do_check_eq(spy.signOut.args[0].length, 2);
+        do_check_eq(spy.signOut.args[0][0], credentials.sessionToken);
+        do_check_true(spy.signOut.args[0][1]);
+        do_check_eq(spy.signOut.args[0][1].service, "sync");
+        do_check_eq(spy.signOutAndDeviceDestroy.count, 0);
+        resolve();
+      });
+    });
+  });
+
+  yield fxa.signOut();
+
+  yield promise;
 });
 
 add_test(function test_sign_out_with_remote_error() {
   let fxa = new MockFxAccounts();
   let client = fxa.internal.fxAccountsClient;
   let remoteSignOutCalled = false;
   // Force remote sign out to trigger an error
   client.signOut = function() { remoteSignOutCalled = true; throw "Remote sign out error"; };
@@ -1082,17 +1169,20 @@ add_test(function test_getSignedInUserPr
   alice.verified = true;
 
   let mockProfile = {
     getProfile: function () {
       return Promise.resolve({ avatar: "image" });
     },
     tearDown: function() {},
   };
-  let fxa = new FxAccounts({});
+  let fxa = new FxAccounts({
+    _signOutServer() { return Promise.resolve(); },
+    _registerOrUpdateDevice() { return Promise.resolve(); }
+  });
 
   fxa.setSignedInUser(alice).then(() => {
     fxa.internal._profile = mockProfile;
     fxa.getSignedInUserProfile()
       .then(result => {
          do_check_true(!!result);
          do_check_eq(result.avatar, "image");
          run_next_test();
@@ -1175,16 +1265,17 @@ function expandHex(two_hex) {
 function expandBytes(two_hex) {
   return CommonUtils.hexToBytes(expandHex(two_hex));
 };
 
 function getTestUser(name) {
   return {
     email: name + "@example.com",
     uid: "1ad7f502-4cc7-4ec1-a209-071fd2fae348",
+    deviceId: name + "'s device id",
     sessionToken: name + "'s session token",
     keyFetchToken: name + "'s keyfetch token",
     unwrapBKey: expandHex("44"),
     verified: false
   };
 }
 
 function makeObserver(aObserveTopic, aObserveFunc) {
new file mode 100644
--- /dev/null
+++ b/services/fxaccounts/tests/xpcshell/test_accounts_device_registration.js
@@ -0,0 +1,465 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+Cu.import("resource://services-common/utils.js");
+Cu.import("resource://gre/modules/Services.jsm");
+Cu.import("resource://gre/modules/FxAccounts.jsm");
+Cu.import("resource://gre/modules/FxAccountsClient.jsm");
+Cu.import("resource://gre/modules/FxAccountsCommon.js");
+Cu.import("resource://gre/modules/Promise.jsm");
+Cu.import("resource://gre/modules/Log.jsm");
+
+initTestLogging("Trace");
+
+var log = Log.repository.getLogger("Services.FxAccounts.test");
+log.level = Log.Level.Debug;
+
+Services.prefs.setCharPref("identity.fxaccounts.loglevel", "Trace");
+Log.repository.getLogger("FirefoxAccounts").level = Log.Level.Trace;
+
+Services.prefs.setCharPref("identity.fxaccounts.remote.oauth.uri", "https://example.com/v1");
+Services.prefs.setCharPref("identity.fxaccounts.oauth.client_id", "abc123");
+Services.prefs.setCharPref("identity.fxaccounts.remote.profile.uri", "http://example.com/v1");
+Services.prefs.setCharPref("identity.fxaccounts.settings.uri", "http://accounts.example.com/");
+
+function MockStorageManager() {
+}
+
+MockStorageManager.prototype = {
+  initialize(accountData) {
+    this.accountData = accountData;
+  },
+
+  finalize() {
+    return Promise.resolve();
+  },
+
+  getAccountData() {
+    return Promise.resolve(this.accountData);
+  },
+
+  updateAccountData(updatedFields) {
+    for (let [name, value] of Iterator(updatedFields)) {
+      if (value == null) {
+        delete this.accountData[name];
+      } else {
+        this.accountData[name] = value;
+      }
+    }
+    return Promise.resolve();
+  },
+
+  deleteAccountData() {
+    this.accountData = null;
+    return Promise.resolve();
+  }
+}
+
+function MockFxAccountsClient(device) {
+  this._email = "nobody@example.com";
+  this._verified = false;
+  this._deletedOnServer = false; // for testing accountStatus
+
+  // mock calls up to the auth server to determine whether the
+  // user account has been verified
+  this.recoveryEmailStatus = function (sessionToken) {
+    // simulate a call to /recovery_email/status
+    return Promise.resolve({
+      email: this._email,
+      verified: this._verified
+    });
+  };
+
+  this.accountStatus = function(uid) {
+    let deferred = Promise.defer();
+    deferred.resolve(!!uid && (!this._deletedOnServer));
+    return deferred.promise;
+  };
+
+  const { id: deviceId, name: deviceName, type: deviceType, sessionToken } = device;
+
+  this.registerDevice = (st, name, type) => Promise.resolve({ id: deviceId, name });
+  this.updateDevice = (st, id, name) => Promise.resolve({ id, name });
+  this.signOutAndDestroyDevice = () => Promise.resolve({});
+  this.getDeviceList = (st) =>
+    Promise.resolve([
+      { id: deviceId, name: deviceName, type: deviceType, isCurrentDevice: st === sessionToken }
+    ]);
+
+  FxAccountsClient.apply(this);
+}
+MockFxAccountsClient.prototype = {
+  __proto__: FxAccountsClient.prototype
+}
+
+function MockFxAccounts(device = {}) {
+  return new FxAccounts({
+    _getDeviceName() {
+      return device.name || "mock device name";
+    },
+    fxAccountsClient: new MockFxAccountsClient(device)
+  });
+}
+
+add_task(function* test_updateDeviceRegistration_with_new_device() {
+  const deviceName = "foo";
+  const deviceType = "bar";
+
+  const credentials = getTestUser("baz");
+  delete credentials.deviceId;
+  const fxa = new MockFxAccounts({ name: deviceName });
+  yield fxa.internal.setSignedInUser(credentials);
+
+  const spy = {
+    registerDevice: { count: 0, args: [] },
+    updateDevice: { count: 0, args: [] },
+    getDeviceList: { count: 0, args: [] }
+  };
+  const client = fxa.internal.fxAccountsClient;
+  client.registerDevice = function () {
+    spy.registerDevice.count += 1;
+    spy.registerDevice.args.push(arguments);
+    return Promise.resolve({
+      id: "newly-generated device id",
+      createdAt: Date.now(),
+      name: deviceName,
+      type: deviceType
+    });
+  };
+  client.updateDevice = function () {
+    spy.updateDevice.count += 1;
+    spy.updateDevice.args.push(arguments);
+    return Promise.resolve({});
+  };
+  client.getDeviceList = function () {
+    spy.getDeviceList.count += 1;
+    spy.getDeviceList.args.push(arguments);
+    return Promise.resolve([]);
+  };
+
+  const result = yield fxa.updateDeviceRegistration();
+
+  do_check_eq(result, "newly-generated device id");
+  do_check_eq(spy.updateDevice.count, 0);
+  do_check_eq(spy.getDeviceList.count, 0);
+  do_check_eq(spy.registerDevice.count, 1);
+  do_check_eq(spy.registerDevice.args[0].length, 3);
+  do_check_eq(spy.registerDevice.args[0][0], credentials.sessionToken);
+  do_check_eq(spy.registerDevice.args[0][1], deviceName);
+  do_check_eq(spy.registerDevice.args[0][2], "desktop");
+
+  const state = fxa.internal.currentAccountState;
+  const data = yield state.getUserAccountData();
+
+  do_check_eq(data.deviceId, "newly-generated device id");
+  do_check_false(data.isDeviceStale);
+});
+
+add_task(function* test_updateDeviceRegistration_with_existing_device() {
+  const deviceName = "phil's device";
+  const deviceType = "desktop";
+
+  const credentials = getTestUser("pb");
+  const fxa = new MockFxAccounts({ name: deviceName });
+  yield fxa.internal.setSignedInUser(credentials);
+
+  const spy = {
+    registerDevice: { count: 0, args: [] },
+    updateDevice: { count: 0, args: [] },
+    getDeviceList: { count: 0, args: [] }
+  };
+  const client = fxa.internal.fxAccountsClient;
+  client.registerDevice = function () {
+    spy.registerDevice.count += 1;
+    spy.registerDevice.args.push(arguments);
+    return Promise.resolve({});
+  };
+  client.updateDevice = function () {
+    spy.updateDevice.count += 1;
+    spy.updateDevice.args.push(arguments);
+    return Promise.resolve({
+      id: credentials.deviceId,
+      name: deviceName
+    });
+  };
+  client.getDeviceList = function () {
+    spy.getDeviceList.count += 1;
+    spy.getDeviceList.args.push(arguments);
+    return Promise.resolve([]);
+  };
+  const result = yield fxa.updateDeviceRegistration();
+
+  do_check_eq(result, credentials.deviceId);
+  do_check_eq(spy.registerDevice.count, 0);
+  do_check_eq(spy.getDeviceList.count, 0);
+  do_check_eq(spy.updateDevice.count, 1);
+  do_check_eq(spy.updateDevice.args[0].length, 3);
+  do_check_eq(spy.updateDevice.args[0][0], credentials.sessionToken);
+  do_check_eq(spy.updateDevice.args[0][1], credentials.deviceId);
+  do_check_eq(spy.updateDevice.args[0][2], deviceName);
+
+  const state = fxa.internal.currentAccountState;
+  const data = yield state.getUserAccountData();
+
+  do_check_eq(data.deviceId, credentials.deviceId);
+  do_check_false(data.isDeviceStale);
+});
+
+add_task(function* test_updateDeviceRegistration_with_unknown_device_error() {
+  const deviceName = "foo";
+  const deviceType = "bar";
+
+  const credentials = getTestUser("baz");
+  const fxa = new MockFxAccounts({ name: deviceName });
+  yield fxa.internal.setSignedInUser(credentials);
+
+  const spy = {
+    registerDevice: { count: 0, args: [] },
+    updateDevice: { count: 0, args: [] },
+    getDeviceList: { count: 0, args: [] }
+  };
+  const client = fxa.internal.fxAccountsClient;
+  client.registerDevice = function () {
+    spy.registerDevice.count += 1;
+    spy.registerDevice.args.push(arguments);
+    return Promise.resolve({
+      id: "a different newly-generated device id",
+      createdAt: Date.now(),
+      name: deviceName,
+      type: deviceType
+    });
+  };
+  client.updateDevice = function () {
+    spy.updateDevice.count += 1;
+    spy.updateDevice.args.push(arguments);
+    return Promise.reject({
+      code: 400,
+      errno: ERRNO_UNKNOWN_DEVICE
+    });
+  };
+  client.getDeviceList = function () {
+    spy.getDeviceList.count += 1;
+    spy.getDeviceList.args.push(arguments);
+    return Promise.resolve([]);
+  };
+
+  const result = yield fxa.updateDeviceRegistration();
+
+  do_check_null(result);
+  do_check_eq(spy.getDeviceList.count, 0);
+  do_check_eq(spy.registerDevice.count, 0);
+  do_check_eq(spy.updateDevice.count, 1);
+  do_check_eq(spy.updateDevice.args[0].length, 3);
+  do_check_eq(spy.updateDevice.args[0][0], credentials.sessionToken);
+  do_check_eq(spy.updateDevice.args[0][1], credentials.deviceId);
+  do_check_eq(spy.updateDevice.args[0][2], deviceName);
+
+  const state = fxa.internal.currentAccountState;
+  const data = yield state.getUserAccountData();
+
+  do_check_null(data.deviceId);
+  do_check_false(data.isDeviceStale);
+});
+
+add_task(function* test_updateDeviceRegistration_with_device_session_conflict_error() {
+  const deviceName = "foo";
+  const deviceType = "bar";
+
+  const credentials = getTestUser("baz");
+  const fxa = new MockFxAccounts({ name: deviceName });
+  yield fxa.internal.setSignedInUser(credentials);
+
+  const spy = {
+    registerDevice: { count: 0, args: [] },
+    updateDevice: { count: 0, args: [], times: [] },
+    getDeviceList: { count: 0, args: [] }
+  };
+  const client = fxa.internal.fxAccountsClient;
+  client.registerDevice = function () {
+    spy.registerDevice.count += 1;
+    spy.registerDevice.args.push(arguments);
+    return Promise.resolve({});
+  };
+  client.updateDevice = function () {
+    spy.updateDevice.count += 1;
+    spy.updateDevice.args.push(arguments);
+    spy.updateDevice.time = Date.now();
+    if (spy.updateDevice.count === 1) {
+      return Promise.reject({
+        code: 400,
+        errno: ERRNO_DEVICE_SESSION_CONFLICT
+      });
+    }
+    return Promise.resolve({
+      id: credentials.deviceId,
+      name: deviceName
+    });
+  };
+  client.getDeviceList = function () {
+    spy.getDeviceList.count += 1;
+    spy.getDeviceList.args.push(arguments);
+    spy.getDeviceList.time = Date.now();
+    return Promise.resolve([
+      { id: "ignore", name: "ignore", type: "ignore", isCurrentDevice: false },
+      { id: credentials.deviceId, name: deviceName, type: deviceType, isCurrentDevice: true }
+    ]);
+  };
+
+  const result = yield fxa.updateDeviceRegistration();
+
+  do_check_eq(result, credentials.deviceId);
+  do_check_eq(spy.registerDevice.count, 0);
+  do_check_eq(spy.updateDevice.count, 1);
+  do_check_eq(spy.updateDevice.args[0].length, 3);
+  do_check_eq(spy.updateDevice.args[0][0], credentials.sessionToken);
+  do_check_eq(spy.updateDevice.args[0][1], credentials.deviceId);
+  do_check_eq(spy.updateDevice.args[0][2], deviceName);
+  do_check_eq(spy.getDeviceList.count, 1);
+  do_check_eq(spy.getDeviceList.args[0].length, 1);
+  do_check_eq(spy.getDeviceList.args[0][0], credentials.sessionToken);
+  do_check_true(spy.getDeviceList.time >= spy.updateDevice.time);
+
+  const state = fxa.internal.currentAccountState;
+  const data = yield state.getUserAccountData();
+
+  do_check_eq(data.deviceId, credentials.deviceId);
+  do_check_true(data.isDeviceStale);
+});
+
+add_task(function* test_updateDeviceRegistration_with_unrecoverable_error() {
+  const deviceName = "foo";
+  const deviceType = "bar";
+
+  const credentials = getTestUser("baz");
+  delete credentials.deviceId;
+  const fxa = new MockFxAccounts({ name: deviceName });
+  yield fxa.internal.setSignedInUser(credentials);
+
+  const spy = {
+    registerDevice: { count: 0, args: [] },
+    updateDevice: { count: 0, args: [] },
+    getDeviceList: { count: 0, args: [] }
+  };
+  const client = fxa.internal.fxAccountsClient;
+  client.registerDevice = function () {
+    spy.registerDevice.count += 1;
+    spy.registerDevice.args.push(arguments);
+    return Promise.reject({
+      code: 400,
+      errno: ERRNO_TOO_MANY_CLIENT_REQUESTS
+    });
+  };
+  client.updateDevice = function () {
+    spy.updateDevice.count += 1;
+    spy.updateDevice.args.push(arguments);
+    return Promise.resolve({});
+  };
+  client.getDeviceList = function () {
+    spy.getDeviceList.count += 1;
+    spy.getDeviceList.args.push(arguments);
+    return Promise.resolve([]);
+  };
+
+  const result = yield fxa.updateDeviceRegistration();
+
+  do_check_null(result);
+  do_check_eq(spy.getDeviceList.count, 0);
+  do_check_eq(spy.updateDevice.count, 0);
+  do_check_eq(spy.registerDevice.count, 1);
+  do_check_eq(spy.registerDevice.args[0].length, 3);
+
+  const state = fxa.internal.currentAccountState;
+  const data = yield state.getUserAccountData();
+
+  do_check_null(data.deviceId);
+});
+
+add_task(function* test_getDeviceId_with_no_device_id_invokes_device_registration() {
+  const credentials = getTestUser("foo");
+  credentials.verified = true;
+  delete credentials.deviceId;
+  const fxa = new MockFxAccounts();
+  yield fxa.internal.setSignedInUser(credentials);
+
+  const spy = { count: 0, args: [] };
+  fxa.internal._registerOrUpdateDevice = function () {
+    spy.count += 1;
+    spy.args.push(arguments);
+    return Promise.resolve("bar");
+  };
+
+  const result = yield fxa.internal.getDeviceId();
+
+  do_check_eq(spy.count, 1);
+  do_check_eq(spy.args[0].length, 1);
+  do_check_eq(spy.args[0][0].email, credentials.email);
+  do_check_null(spy.args[0][0].deviceId);
+  do_check_eq(result, "bar");
+});
+
+add_task(function* test_getDeviceId_with_device_id_and_stale_flag_invokes_device_registration() {
+  const credentials = getTestUser("foo");
+  credentials.verified = true;
+  const fxa = new MockFxAccounts();
+  yield fxa.internal.setSignedInUser(credentials);
+
+  const spy = { count: 0, args: [] };
+  fxa.internal.currentAccountState.getUserAccountData =
+    () => Promise.resolve({ deviceId: credentials.deviceId, isDeviceStale: true });
+  fxa.internal._registerOrUpdateDevice = function () {
+    spy.count += 1;
+    spy.args.push(arguments);
+    return Promise.resolve("wibble");
+  };
+
+  const result = yield fxa.internal.getDeviceId();
+
+  do_check_eq(spy.count, 1);
+  do_check_eq(spy.args[0].length, 1);
+  do_check_eq(spy.args[0][0].deviceId, credentials.deviceId);
+  do_check_eq(result, "wibble");
+});
+
+add_task(function* test_getDeviceId_with_device_id_and_no_stale_flag_doesnt_invoke_device_registration() {
+  const credentials = getTestUser("foo");
+  credentials.verified = true;
+  const fxa = new MockFxAccounts();
+  yield fxa.internal.setSignedInUser(credentials);
+
+  const spy = { count: 0 };
+  fxa.internal._registerOrUpdateDevice = function () {
+    spy.count += 1;
+    return Promise.resolve("bar");
+  };
+
+  const result = yield fxa.internal.getDeviceId();
+
+  do_check_eq(spy.count, 0);
+  do_check_eq(result, "foo's device id");
+});
+
+function expandHex(two_hex) {
+  // Return a 64-character hex string, encoding 32 identical bytes.
+  let eight_hex = two_hex + two_hex + two_hex + two_hex;
+  let thirtytwo_hex = eight_hex + eight_hex + eight_hex + eight_hex;
+  return thirtytwo_hex + thirtytwo_hex;
+};
+
+function expandBytes(two_hex) {
+  return CommonUtils.hexToBytes(expandHex(two_hex));
+};
+
+function getTestUser(name) {
+  return {
+    email: name + "@example.com",
+    uid: "1ad7f502-4cc7-4ec1-a209-071fd2fae348",
+    deviceId: name + "'s device id",
+    sessionToken: name + "'s session token",
+    keyFetchToken: name + "'s keyfetch token",
+    unwrapBKey: expandHex("44"),
+    verified: false
+  };
+}
+
--- a/services/fxaccounts/tests/xpcshell/test_client.js
+++ b/services/fxaccounts/tests/xpcshell/test_client.js
@@ -35,17 +35,17 @@ var ACCOUNT_KEYS = {
 };
 
 function deferredStop(server) {
   let deferred = Promise.defer();
   server.stop(deferred.resolve);
   return deferred.promise;
 }
 
-add_task(function test_authenticated_get_request() {
+add_task(function* test_authenticated_get_request() {
   let message = "{\"msg\": \"Great Success!\"}";
   let credentials = {
     id: "eyJleHBpcmVzIjogMTM2NTAxMDg5OC4x",
     key: "qTZf4ZFpAMpMoeSsX3zVRjiqmNs=",
     algorithm: "sha256"
   };
   let method = "GET";
 
@@ -60,17 +60,17 @@ add_task(function test_authenticated_get
   let client = new FxAccountsClient(server.baseURI);
 
   let result = yield client._request("/foo", method, credentials);
   do_check_eq("Great Success!", result.msg);
 
   yield deferredStop(server);
 });
 
-add_task(function test_authenticated_post_request() {
+add_task(function* test_authenticated_post_request() {
   let credentials = {
     id: "eyJleHBpcmVzIjogMTM2NTAxMDg5OC4x",
     key: "qTZf4ZFpAMpMoeSsX3zVRjiqmNs=",
     algorithm: "sha256"
   };
   let method = "POST";
 
   let server = httpd_setup({"/foo": function(request, response) {
@@ -85,17 +85,17 @@ add_task(function test_authenticated_pos
   let client = new FxAccountsClient(server.baseURI);
 
   let result = yield client._request("/foo", method, credentials, {foo: "bar"});
   do_check_eq("bar", result.foo);
 
   yield deferredStop(server);
 });
 
-add_task(function test_500_error() {
+add_task(function* test_500_error() {
   let message = "<h1>Ooops!</h1>";
   let method = "GET";
 
   let server = httpd_setup({"/foo": function(request, response) {
       response.setStatusLine(request.httpVersion, 500, "Internal Server Error");
       response.bodyOutputStream.write(message, message.length);
     }
   });
@@ -108,17 +108,17 @@ add_task(function test_500_error() {
   } catch (e) {
     do_check_eq(500, e.code);
     do_check_eq("Internal Server Error", e.message);
   }
 
   yield deferredStop(server);
 });
 
-add_task(function test_backoffError() {
+add_task(function* test_backoffError() {
   let method = "GET";
   let server = httpd_setup({
     "/retryDelay": function(request, response) {
       response.setHeader("Retry-After", "30");
       response.setStatusLine(request.httpVersion, 429, "Client has sent too many requests");
       let message = "<h1>Ooops!</h1>";
       response.bodyOutputStream.write(message, message.length);
     },
@@ -155,17 +155,17 @@ add_task(function test_backoffError() {
   client._clearBackoff();
   let result = yield client._request("/duringDelayIShouldNotBeCalled", method);
   do_check_eq(client.backoffError, null);
   do_check_eq(result.working, "yes");
 
   yield deferredStop(server);
 });
 
-add_task(function test_signUp() {
+add_task(function* test_signUp() {
   let creationMessage_noKey = JSON.stringify({
     uid: "uid",
     sessionToken: "sessionToken"
   });
   let creationMessage_withKey = JSON.stringify({
     uid: "uid",
     sessionToken: "sessionToken",
     keyFetchToken: "keyFetchToken"
@@ -233,17 +233,17 @@ add_task(function test_signUp() {
     do_throw("Expected to catch an exception");
   } catch(expectedError) {
     do_check_eq(101, expectedError.errno);
   }
 
   yield deferredStop(server);
 });
 
-add_task(function test_signIn() {
+add_task(function* test_signIn() {
   let sessionMessage_noKey = JSON.stringify({
     sessionToken: FAKE_SESSION_TOKEN
   });
   let sessionMessage_withKey = JSON.stringify({
     sessionToken: FAKE_SESSION_TOKEN,
     keyFetchToken: "keyFetchToken"
   });
   let errorMessage_notExistent = JSON.stringify({
@@ -324,17 +324,17 @@ add_task(function test_signIn() {
     do_throw("Expected to catch an exception");
   } catch (expectedError) {
     do_check_eq(102, expectedError.errno);
   }
 
   yield deferredStop(server);
 });
 
-add_task(function test_signOut() {
+add_task(function* test_signOut() {
   let signoutMessage = JSON.stringify({});
   let errorMessage = JSON.stringify({code: 400, errno: 102, error: "doesn't exist"});
   let signedOut = false;
 
   let server = httpd_setup({
     "/session/destroy": function(request, response) {
       if (!signedOut) {
         signedOut = true;
@@ -361,17 +361,17 @@ add_task(function test_signOut() {
     do_throw("Expected to catch an exception");
   } catch(expectedError) {
     do_check_eq(102, expectedError.errno);
   }
 
   yield deferredStop(server);
 });
 
-add_task(function test_recoveryEmailStatus() {
+add_task(function* test_recoveryEmailStatus() {
   let emailStatus = JSON.stringify({verified: true});
   let errorMessage = JSON.stringify({code: 400, errno: 102, error: "doesn't exist"});
   let tries = 0;
 
   let server = httpd_setup({
     "/recovery_email/status": function(request, response) {
       do_check_true(request.hasHeader("Authorization"));
 
@@ -399,17 +399,17 @@ add_task(function test_recoveryEmailStat
     do_throw("Expected to catch an exception");
   } catch(expectedError) {
     do_check_eq(102, expectedError.errno);
   }
 
   yield deferredStop(server);
 });
 
-add_task(function test_resendVerificationEmail() {
+add_task(function* test_resendVerificationEmail() {
   let emptyMessage = "{}";
   let errorMessage = JSON.stringify({code: 400, errno: 102, error: "doesn't exist"});
   let tries = 0;
 
   let server = httpd_setup({
     "/recovery_email/resend_code": function(request, response) {
       do_check_true(request.hasHeader("Authorization"));
       if (tries === 0) {
@@ -436,17 +436,17 @@ add_task(function test_resendVerificatio
     do_throw("Expected to catch an exception");
   } catch(expectedError) {
     do_check_eq(102, expectedError.errno);
   }
 
   yield deferredStop(server);
 });
 
-add_task(function test_accountKeys() {
+add_task(function* test_accountKeys() {
   // Four calls to accountKeys().  The first one should work correctly, and we
   // should get a valid bundle back, in exchange for our keyFetch token, from
   // which we correctly derive kA and wrapKB.  The subsequent three calls
   // should all trigger separate error paths.
   let responseMessage = JSON.stringify({bundle: ACCOUNT_KEYS.response});
   let errorMessage = JSON.stringify({code: 400, errno: 102, error: "doesn't exist"});
   let emptyMessage = "{}";
   let attempt = 0;
@@ -517,17 +517,17 @@ add_task(function test_accountKeys() {
     do_throw("Expected to catch an exception");
   } catch(expectedError) {
     do_check_eq(102, expectedError.errno);
   }
 
   yield deferredStop(server);
 });
 
-add_task(function test_signCertificate() {
+add_task(function* test_signCertificate() {
   let certSignMessage = JSON.stringify({cert: {bar: "baz"}});
   let errorMessage = JSON.stringify({code: 400, errno: 102, error: "doesn't exist"});
   let tries = 0;
 
   let server = httpd_setup({
     "/certificate/sign": function(request, response) {
       do_check_true(request.hasHeader("Authorization"));
 
@@ -559,17 +559,17 @@ add_task(function test_signCertificate()
     do_throw("Expected to catch an exception");
   } catch(expectedError) {
     do_check_eq(102, expectedError.errno);
   }
 
   yield deferredStop(server);
 });
 
-add_task(function test_accountExists() {
+add_task(function* test_accountExists() {
   let sessionMessage = JSON.stringify({sessionToken: FAKE_SESSION_TOKEN});
   let existsMessage = JSON.stringify({error: "wrong password", code: 400, errno: 103});
   let doesntExistMessage = JSON.stringify({error: "no such account", code: 400, errno: 102});
   let emptyMessage = "{}";
 
   let server = httpd_setup({
     "/account/login": function(request, response) {
       let body = CommonUtils.readBytesFromInputStream(request.bodyInputStream);
@@ -620,16 +620,183 @@ add_task(function test_accountExists() {
     do_throw("Expected to catch an exception");
   } catch(unexpectedError) {
     do_check_eq(unexpectedError.code, 500);
   }
 
   yield deferredStop(server);
 });
 
+add_task(function* test_registerDevice() {
+  const DEVICE_ID = "device id";
+  const DEVICE_NAME = "device name";
+  const DEVICE_TYPE = "device type";
+  const ERROR_NAME = "test that the client promise rejects";
+
+  const server = httpd_setup({
+    "/account/device": function(request, response) {
+      const body = JSON.parse(CommonUtils.readBytesFromInputStream(request.bodyInputStream));
+
+      if (body.id || !body.name || !body.type || Object.keys(body).length !== 2) {
+        response.setStatusLine(request.httpVersion, 400, "Invalid request");
+        return response.bodyOutputStream.write("{}", 2);
+      }
+
+      if (body.name === ERROR_NAME) {
+        response.setStatusLine(request.httpVersion, 500, "Alas");
+        return response.bodyOutputStream.write("{}", 2);
+      }
+
+      body.id = DEVICE_ID;
+      body.createdAt = Date.now();
+
+      const responseMessage = JSON.stringify(body);
+
+      response.setStatusLine(request.httpVersion, 200, "OK");
+      response.bodyOutputStream.write(responseMessage, responseMessage.length);
+    },
+  });
+
+  const client = new FxAccountsClient(server.baseURI);
+  const result = yield client.registerDevice(FAKE_SESSION_TOKEN, DEVICE_NAME, DEVICE_TYPE);
+
+  do_check_true(result);
+  do_check_eq(Object.keys(result).length, 4);
+  do_check_eq(result.id, DEVICE_ID);
+  do_check_eq(typeof result.createdAt, 'number');
+  do_check_true(result.createdAt > 0);
+  do_check_eq(result.name, DEVICE_NAME);
+  do_check_eq(result.type, DEVICE_TYPE);
+
+  try {
+    yield client.registerDevice(FAKE_SESSION_TOKEN, ERROR_NAME, DEVICE_TYPE);
+    do_throw("Expected to catch an exception");
+  } catch(unexpectedError) {
+    do_check_eq(unexpectedError.code, 500);
+  }
+
+  yield deferredStop(server);
+});
+
+add_task(function* test_updateDevice() {
+  const DEVICE_ID = "some other id";
+  const DEVICE_NAME = "some other name";
+  const ERROR_ID = "test that the client promise rejects";
+
+  const server = httpd_setup({
+    "/account/device": function(request, response) {
+      const body = JSON.parse(CommonUtils.readBytesFromInputStream(request.bodyInputStream));
+
+      if (!body.id || !body.name || body.type || Object.keys(body).length !== 2) {
+        response.setStatusLine(request.httpVersion, 400, "Invalid request");
+        return response.bodyOutputStream.write("{}", 2);
+      }
+
+      if (body.id === ERROR_ID) {
+        response.setStatusLine(request.httpVersion, 500, "Alas");
+        return response.bodyOutputStream.write("{}", 2);
+      }
+
+      const responseMessage = JSON.stringify(body);
+
+      response.setStatusLine(request.httpVersion, 200, "OK");
+      response.bodyOutputStream.write(responseMessage, responseMessage.length);
+    },
+  });
+
+  const client = new FxAccountsClient(server.baseURI);
+  const result = yield client.updateDevice(FAKE_SESSION_TOKEN, DEVICE_ID, DEVICE_NAME);
+
+  do_check_true(result);
+  do_check_eq(Object.keys(result).length, 2);
+  do_check_eq(result.id, DEVICE_ID);
+  do_check_eq(result.name, DEVICE_NAME);
+
+  try {
+    yield client.updateDevice(FAKE_SESSION_TOKEN, ERROR_ID, DEVICE_NAME);
+    do_throw("Expected to catch an exception");
+  } catch(unexpectedError) {
+    do_check_eq(unexpectedError.code, 500);
+  }
+
+  yield deferredStop(server);
+});
+
+add_task(function* test_signOutAndDestroyDevice() {
+  const DEVICE_ID = "device id";
+  const ERROR_ID = "test that the client promise rejects";
+
+  const server = httpd_setup({
+    "/account/device/destroy": function(request, response) {
+      const body = JSON.parse(CommonUtils.readBytesFromInputStream(request.bodyInputStream));
+
+      if (!body.id) {
+        response.setStatusLine(request.httpVersion, 400, "Invalid request");
+        return response.bodyOutputStream.write(emptyMessage, emptyMessage.length);
+      }
+
+      if (body.id === ERROR_ID) {
+        response.setStatusLine(request.httpVersion, 500, "Alas");
+        return response.bodyOutputStream.write("{}", 2);
+      }
+
+      response.setStatusLine(request.httpVersion, 200, "OK");
+      response.bodyOutputStream.write("{}", 2);
+    },
+  });
+
+  const client = new FxAccountsClient(server.baseURI);
+  const result = yield client.signOutAndDestroyDevice(FAKE_SESSION_TOKEN, DEVICE_ID);
+
+  do_check_true(result);
+  do_check_eq(Object.keys(result).length, 0);
+
+  try {
+    yield client.signOutAndDestroyDevice(FAKE_SESSION_TOKEN, ERROR_ID);
+    do_throw("Expected to catch an exception");
+  } catch(unexpectedError) {
+    do_check_eq(unexpectedError.code, 500);
+  }
+
+  yield deferredStop(server);
+});
+
+add_task(function* test_getDeviceList() {
+  let canReturnDevices;
+
+  const server = httpd_setup({
+    "/account/devices": function(request, response) {
+      if (canReturnDevices) {
+        response.setStatusLine(request.httpVersion, 200, "OK");
+        response.bodyOutputStream.write("[]", 2);
+      } else {
+        response.setStatusLine(request.httpVersion, 500, "Alas");
+        response.bodyOutputStream.write("{}", 2);
+      }
+    },
+  });
+
+  const client = new FxAccountsClient(server.baseURI);
+
+  canReturnDevices = true;
+  const result = yield client.getDeviceList(FAKE_SESSION_TOKEN);
+  do_check_true(Array.isArray(result));
+  do_check_eq(result.length, 0);
+
+  try {
+    canReturnDevices = false;
+    yield client.getDeviceList(FAKE_SESSION_TOKEN);
+    do_throw("Expected to catch an exception");
+  } catch(unexpectedError) {
+    do_check_eq(unexpectedError.code, 500);
+  }
+
+  yield deferredStop(server);
+});
+
 add_task(function* test_client_metrics() {
   ["FXA_UNVERIFIED_ACCOUNT_ERRORS", "FXA_HAWK_ERRORS", "FXA_SERVER_ERRORS"].forEach(name => {
     let histogram = Services.telemetry.getKeyedHistogramById(name);
     histogram.clear();
   });
 
   function writeResp(response, msg) {
     if (typeof msg === "object") {
@@ -705,17 +872,17 @@ add_task(function* test_client_metrics()
     return err.code == 504;
   });
   assertSnapshot("FXA_SERVER_ERRORS", "/recovery_email/resend_code",
     "Should report 500-class errors");
 
   yield deferredStop(server);
 });
 
-add_task(function test_email_case() {
+add_task(function* test_email_case() {
   let canonicalEmail = "greta.garbo@gmail.com";
   let clientEmail = "Greta.Garbo@gmail.COM";
   let attempts = 0;
 
   function writeResp(response, msg) {
     if (typeof msg === "object") {
       msg = JSON.stringify(msg);
     }
--- a/services/fxaccounts/tests/xpcshell/test_credentials.js
+++ b/services/fxaccounts/tests/xpcshell/test_credentials.js
@@ -24,17 +24,17 @@ var vectors = {
     authPW:
       h("247b675ffb4c4631 0bc87e26d712153a be5e1c90ef00a478 4594f97ef54f2375"),
     authSalt:
       h("00f0000000000000 0000000000000000 0000000000000000 0000000000000000"),
   },
 };
 
 // A simple test suite with no utf8 encoding madness.
-add_task(function test_onepw_setup_credentials() {
+add_task(function* test_onepw_setup_credentials() {
   let email = "francine@example.org";
   let password = CommonUtils.encodeUTF8("i like pie");
 
   let pbkdf2 = CryptoUtils.pbkdf2Generate;
   let hkdf = CryptoUtils.hkdf;
 
   // quickStretch the email
   let saltyEmail = Credentials.keyWordExtended("quickStretch", email);
@@ -61,17 +61,17 @@ add_task(function test_onepw_setup_crede
 
   // derive unwrap key
   let unwrapKeyInfo = Credentials.keyWord('unwrapBkey');
   let unwrapKey = hkdf(quickStretchedPW, hkdfSalt, unwrapKeyInfo, hkdfLen);
 
   do_check_eq(b2h(unwrapKey), "8ff58975be391338e4ec5d7138b5ed7b65c7d1bfd1f3a4f93e05aa47d5b72be9");
 });
 
-add_task(function test_client_stretch_kdf() {
+add_task(function* test_client_stretch_kdf() {
   let pbkdf2 = CryptoUtils.pbkdf2Generate;
   let hkdf = CryptoUtils.hkdf;
   let expected = vectors["client stretch-KDF"];
 
   let emailUTF8 = h2s(expected.email);
   let passwordUTF8 = h2s(expected.password);
 
   // Intermediate value from sjcl implementation in fxa-js-client
--- a/services/fxaccounts/tests/xpcshell/test_loginmgr_storage.js
+++ b/services/fxaccounts/tests/xpcshell/test_loginmgr_storage.js
@@ -39,18 +39,26 @@ function getLoginMgrData() {
   let logins = Services.logins.findLogins({}, FXA_PWDMGR_HOST, null, FXA_PWDMGR_REALM);
   if (logins.length == 0) {
     return null;
   }
   Assert.equal(logins.length, 1, "only 1 login available");
   return logins[0];
 }
 
-add_task(function test_simple() {
-  let fxa = new FxAccounts({});
+function createFxAccounts() {
+  return new FxAccounts({
+    _getDeviceName() {
+      return "mock device name";
+    }
+  });
+}
+
+add_task(function* test_simple() {
+  let fxa = createFxAccounts();
 
   let creds = {
     uid: "abcd",
     email: "test@example.com",
     sessionToken: "sessionToken",
     kA: "the kA value",
     kB: "the kB value",
     verified: true
@@ -79,18 +87,18 @@ add_task(function test_simple() {
   Assert.ok(!("email" in loginData), "email not stored in the login mgr json");
   Assert.ok(!("sessionToken" in loginData), "sessionToken not stored in the login mgr json");
   Assert.ok(!("verified" in loginData), "verified not stored in the login mgr json");
 
   yield fxa.signOut(/* localOnly = */ true);
   Assert.strictEqual(getLoginMgrData(), null, "login mgr data deleted on logout");
 });
 
-add_task(function test_MPLocked() {
-  let fxa = new FxAccounts({});
+add_task(function* test_MPLocked() {
+  let fxa = createFxAccounts();
 
   let creds = {
     uid: "abcd",
     email: "test@example.com",
     sessionToken: "sessionToken",
     kA: "the kA value",
     kB: "the kB value",
     verified: true
@@ -113,20 +121,20 @@ add_task(function test_MPLocked() {
   Assert.ok(!("kA" in data.accountData), "kA not stored in clear text");
   Assert.ok(!("kB" in data.accountData), "kB not stored in clear text");
 
   Assert.strictEqual(getLoginMgrData(), null, "login mgr data doesn't exist");
   yield fxa.signOut(/* localOnly = */ true)
 });
 
 
-add_task(function test_consistentWithMPEdgeCases() {
+add_task(function* test_consistentWithMPEdgeCases() {
   setLoginMgrLoggedInState(true);
 
-  let fxa = new FxAccounts({});
+  let fxa = createFxAccounts();
 
   let creds1 = {
     uid: "uid1",
     email: "test@example.com",
     sessionToken: "sessionToken",
     kA: "the kA value",
     kB: "the kB value",
     verified: true
@@ -156,28 +164,28 @@ add_task(function test_consistentWithMPE
   Assert.strictEqual(login.username, creds1.email);
   // and that we do have the first kA in the login manager.
   Assert.strictEqual(JSON.parse(login.password).accountData.kA, creds1.kA,
                      "stale data still in login mgr");
 
   // Make a new FxA instance (otherwise the values in memory will be used)
   // and we want the login manager to be unlocked.
   setLoginMgrLoggedInState(true);
-  fxa = new FxAccounts({});
+  fxa = createFxAccounts();
 
   let accountData = yield fxa.getSignedInUser();
   Assert.strictEqual(accountData.email, creds2.email);
   // we should have no kA at all.
   Assert.strictEqual(accountData.kA, undefined, "stale kA wasn't used");
   yield fxa.signOut(/* localOnly = */ true)
 });
 
 // A test for the fact we will accept either a UID or email when looking in
 // the login manager.
-add_task(function test_uidMigration() {
+add_task(function* test_uidMigration() {
   setLoginMgrLoggedInState(true);
   Assert.strictEqual(getLoginMgrData(), null, "expect no logins at the start");
 
   // create the login entry using uid as a key.
   let contents = {kA: "kA"};
 
   let loginInfo = new Components.Constructor(
    "@mozilla.org/login-manager/loginInfo;1", Ci.nsILoginInfo, "init");
--- a/services/fxaccounts/tests/xpcshell/test_oauth_grant_client.js
+++ b/services/fxaccounts/tests/xpcshell/test_oauth_grant_client.js
@@ -1,15 +1,16 @@
 /* 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/FxAccountsOAuthGrantClient.jsm");
+Cu.import("resource://gre/modules/Services.jsm");
 
 const CLIENT_OPTIONS = {
   serverURL: "http://127.0.0.1:9010/v1",
   client_id: 'abc123'
 };
 
 const STATUS_SUCCESS = 200;
 
@@ -140,27 +141,29 @@ add_test(function serverErrorResponse ()
   );
 });
 
 add_test(function networkErrorResponse () {
   let client = new FxAccountsOAuthGrantClient({
     serverURL: "http://",
     client_id: "abc123"
   });
+  Services.prefs.setBoolPref("identity.fxaccounts.skipDeviceRegistration", true);
   client.getTokenFromAssertion("assertion", "scope")
     .then(
       null,
       function (e) {
         do_check_eq(e.name, "FxAccountsOAuthGrantClientError");
         do_check_eq(e.code, null);
         do_check_eq(e.errno, ERRNO_NETWORK);
         do_check_eq(e.error, ERROR_NETWORK);
         run_next_test();
       }
-    );
+    ).catch(() => {}).then(() =>
+      Services.prefs.clearUserPref("identity.fxaccounts.skipDeviceRegistration"));
 });
 
 add_test(function unsupportedMethod () {
   let client = new FxAccountsOAuthGrantClient(CLIENT_OPTIONS);
 
   return client._createRequest("/", "PUT")
     .then(
       null,
--- a/services/fxaccounts/tests/xpcshell/test_oauth_grant_client_server.js
+++ b/services/fxaccounts/tests/xpcshell/test_oauth_grant_client_server.js
@@ -45,17 +45,17 @@ function startServer() {
 }
 
 function promiseStopServer(server) {
   return new Promise(resolve => {
     server.stop(resolve);
   });
 }
 
-add_task(function getAndRevokeToken () {
+add_task(function* getAndRevokeToken () {
   let server = startServer();
   let clientOptions = {
     serverURL: "http://localhost:" + server.identity.primaryPort + "/v1",
     client_id: 'abc123',
   }
 
   let client = new FxAccountsOAuthGrantClient(clientOptions);
   let result = yield client.getTokenFromAssertion("assertion", "scope");
--- a/services/fxaccounts/tests/xpcshell/test_oauth_token_storage.js
+++ b/services/fxaccounts/tests/xpcshell/test_oauth_token_storage.js
@@ -65,33 +65,40 @@ function MockFxAccountsClient() {
 
   this.accountStatus = function(uid) {
     let deferred = Promise.defer();
     deferred.resolve(!!uid && (!this._deletedOnServer));
     return deferred.promise;
   };
 
   this.signOut = function() { return Promise.resolve(); };
+  this.registerDevice = function() { return Promise.resolve(); };
+  this.updateDevice = function() { return Promise.resolve(); };
+  this.signOutAndDestroyDevice = function() { return Promise.resolve(); };
+  this.getDeviceList = function() { return Promise.resolve(); };
 
   FxAccountsClient.apply(this);
 }
 
 MockFxAccountsClient.prototype = {
   __proto__: FxAccountsClient.prototype
 }
 
-function MockFxAccounts() {
+function MockFxAccounts(device={}) {
   return new FxAccounts({
     fxAccountsClient: new MockFxAccountsClient(),
     newAccountState(credentials) {
       // we use a real accountState but mocked storage.
       let storage = new MockStorageManager();
       storage.initialize(credentials);
       return new AccountState(storage);
     },
+    _getDeviceName() {
+      return "mock device name";
+    }
   });
 }
 
 function* createMockFxA() {
   let fxa = new MockFxAccounts();
   let credentials = {
     email: "foo@example.com",
     uid: "1234@lcip.org",
@@ -105,17 +112,17 @@ function* createMockFxA() {
   return fxa;
 }
 
 // The tests.
 function run_test() {
   run_next_test();
 }
 
-add_task(function testCacheStorage() {
+add_task(function* testCacheStorage() {
   let fxa = yield createMockFxA();
 
   // Hook what the impl calls to save to disk.
   let cas = fxa.internal.currentAccountState;
   let origPersistCached = cas._persistCachedTokens.bind(cas)
   cas._persistCachedTokens = function() {
     return origPersistCached().then(() => {
       Services.obs.notifyObservers(null, "testhelper-fxa-cache-persist-done", null);
--- a/services/fxaccounts/tests/xpcshell/test_oauth_tokens.js
+++ b/services/fxaccounts/tests/xpcshell/test_oauth_tokens.js
@@ -62,16 +62,20 @@ function MockFxAccountsClient() {
 
   this.accountStatus = function(uid) {
     let deferred = Promise.defer();
     deferred.resolve(!!uid && (!this._deletedOnServer));
     return deferred.promise;
   };
 
   this.signOut = function() { return Promise.resolve(); };
+  this.registerDevice = function() { return Promise.resolve(); };
+  this.updateDevice = function() { return Promise.resolve(); };
+  this.signOutAndDestroyDevice = function() { return Promise.resolve(); };
+  this.getDeviceList = function() { return Promise.resolve(); };
 
   FxAccountsClient.apply(this);
 }
 
 MockFxAccountsClient.prototype = {
   __proto__: FxAccountsClient.prototype
 }
 
@@ -86,16 +90,19 @@ function MockFxAccounts(mockGrantClient)
       return new AccountState(storage);
     },
     _destroyOAuthToken: function(tokenData) {
       // somewhat sad duplication of _destroyOAuthToken, but hard to avoid.
       return mockGrantClient.destroyToken(tokenData.token).then( () => {
         Services.obs.notifyObservers(null, "testhelper-fxa-revoke-complete", null);
       });
     },
+    _getDeviceName() {
+      return "mock device name";
+    }
   });
 }
 
 function* createMockFxA(mockGrantClient) {
   let fxa = new MockFxAccounts(mockGrantClient);
   let credentials = {
     email: "foo@example.com",
     uid: "1234@lcip.org",
@@ -133,17 +140,17 @@ MockFxAccountsOAuthGrantClient.prototype
     print("after destroy have", this.activeTokens.size, "tokens left.");
     return Promise.resolve({});
   },
   // and some stuff used only for tests.
   numTokenFetches: 0,
   activeTokens: null,
 }
 
-add_task(function testRevoke() {
+add_task(function* testRevoke() {
   let client = new MockFxAccountsOAuthGrantClient();
   let tokenOptions = { scope: "test-scope", client: client };
   let fxa = yield createMockFxA(client);
 
   // get our first token and check we hit the mock.
   let token1 = yield fxa.getOAuthToken(tokenOptions);
   equal(client.numTokenFetches, 1);
   equal(client.activeTokens.size, 1);
@@ -160,17 +167,17 @@ add_task(function testRevoke() {
   // fetching it again hits the server.
   let token2 = yield fxa.getOAuthToken(tokenOptions);
   equal(client.numTokenFetches, 2);
   equal(client.activeTokens.size, 1);
   ok(token2, "got a token");
   notEqual(token1, token2, "got a different token");
 });
 
-add_task(function testSignOutDestroysTokens() {
+add_task(function* testSignOutDestroysTokens() {
   let client = new MockFxAccountsOAuthGrantClient();
   let fxa = yield createMockFxA(client);
 
   // get our first token and check we hit the mock.
   let token1 = yield fxa.getOAuthToken({ scope: "test-scope", client: client });
   equal(client.numTokenFetches, 1);
   equal(client.activeTokens.size, 1);
   ok(token1, "got a token");
@@ -185,17 +192,17 @@ add_task(function testSignOutDestroysTok
   // now sign out - they should be removed.
   yield fxa.signOut();
   // FxA fires an observer when the "background" signout is complete.
   yield promiseNotification("testhelper-fxa-signout-complete");
   // No active tokens left.
   equal(client.activeTokens.size, 0);
 });
 
-add_task(function testTokenRaces() {
+add_task(function* testTokenRaces() {
   // Here we do 2 concurrent fetches each for 2 different token scopes (ie,
   // 4 token fetches in total).
   // This should provoke a potential race in the token fetching but we should
   // handle and detect that leaving us with one of the fetch tokens being
   // revoked and the same token value returned to both calls.
   let client = new MockFxAccountsOAuthGrantClient();
   let fxa = yield createMockFxA(client);
 
--- a/services/fxaccounts/tests/xpcshell/test_profile.js
+++ b/services/fxaccounts/tests/xpcshell/test_profile.js
@@ -163,17 +163,17 @@ add_test(function fetchAndCacheProfile_o
     .then(result => {
       do_check_eq(result.avatar, "myimg");
       run_next_test();
     });
 });
 
 // Check that a second profile request when one is already in-flight reuses
 // the in-flight one.
-add_task(function fetchAndCacheProfileOnce() {
+add_task(function* fetchAndCacheProfileOnce() {
   // A promise that remains unresolved while we fire off 2 requests for
   // a profile.
   let resolveProfile;
   let promiseProfile = new Promise(resolve => {
     resolveProfile = resolve;
   });
   let numFetches = 0;
   let client = mockClient(mockFxa());
@@ -200,17 +200,17 @@ add_task(function fetchAndCacheProfileOn
   do_check_eq(got2.avatar, "myimg");
 
   // and still only 1 request was made.
   do_check_eq(numFetches, 1);
 });
 
 // Check that sharing a single fetch promise works correctly when the promise
 // is rejected.
-add_task(function fetchAndCacheProfileOnce() {
+add_task(function* fetchAndCacheProfileOnce() {
   // A promise that remains unresolved while we fire off 2 requests for
   // a profile.
   let rejectProfile;
   let promiseProfile = new Promise((resolve,reject) => {
     rejectProfile = reject;
   });
   let numFetches = 0;
   let client = mockClient(mockFxa());
@@ -246,17 +246,17 @@ add_task(function fetchAndCacheProfileOn
   };
 
   let got = yield profile._fetchAndCacheProfile();
   do_check_eq(got.avatar, "myimg");
 });
 
 // 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(function fetchAndCacheProfileAfterThreshold() {
+add_task(function* fetchAndCacheProfileAfterThreshold() {
   let numFetches = 0;
   let client = mockClient(mockFxa());
   client.fetchProfile = function () {
     numFetches += 1;
     return Promise.resolve({ avatar: "myimg"});
   };
   let profile = CreateFxAccountsProfile(null, client);
   profile.PROFILE_FRESHNESS_THRESHOLD = 1000;
@@ -273,17 +273,17 @@ add_task(function fetchAndCacheProfileAf
 
   yield profile.getProfile();
   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(function fetchAndCacheProfileBeforeThresholdOnNotification() {
+add_task(function* fetchAndCacheProfileBeforeThresholdOnNotification() {
   let numFetches = 0;
   let client = mockClient(mockFxa());
   client.fetchProfile = function () {
     numFetches += 1;
     return Promise.resolve({ avatar: "myimg"});
   };
   let profile = CreateFxAccountsProfile(null, client);
   profile.PROFILE_FRESHNESS_THRESHOLD = 1000;
--- a/services/fxaccounts/tests/xpcshell/test_storage_manager.js
+++ b/services/fxaccounts/tests/xpcshell/test_storage_manager.js
@@ -92,59 +92,78 @@ add_storage_task(function* checkInitiali
 
 // Initialized with account data (ie, simulating a new user being logged in).
 // Should reflect the initial data and be written to storage.
 add_storage_task(function* checkNewUser(sm) {
   let initialAccountData = {
     uid: "uid",
     email: "someone@somewhere.com",
     kA: "kA",
+    deviceId: "device id"
   };
   sm.plainStorage = new MockedPlainStorage()
   if (sm.secureStorage) {
     sm.secureStorage = new MockedSecureStorage(null);
   }
   yield sm.initialize(initialAccountData);
   let accountData = yield sm.getAccountData();
   Assert.equal(accountData.uid, initialAccountData.uid);
   Assert.equal(accountData.email, initialAccountData.email);
   Assert.equal(accountData.kA, initialAccountData.kA);
+  Assert.equal(accountData.deviceId, initialAccountData.deviceId);
 
   // and it should have been written to storage.
   Assert.equal(sm.plainStorage.data.accountData.uid, initialAccountData.uid);
   Assert.equal(sm.plainStorage.data.accountData.email, initialAccountData.email);
+  Assert.equal(sm.plainStorage.data.accountData.deviceId, initialAccountData.deviceId);
   // check secure
   if (sm.secureStorage) {
     Assert.equal(sm.secureStorage.data.accountData.kA, initialAccountData.kA);
   } else {
     Assert.equal(sm.plainStorage.data.accountData.kA, initialAccountData.kA);
   }
 });
 
 // Initialized without account data but storage has it available.
 add_storage_task(function* checkEverythingRead(sm) {
-  sm.plainStorage = new MockedPlainStorage({uid: "uid", email: "someone@somewhere.com"})
+  sm.plainStorage = new MockedPlainStorage({
+    uid: "uid",
+    email: "someone@somewhere.com",
+    deviceId: "wibble",
+    isDeviceStale: true
+  });
   if (sm.secureStorage) {
     sm.secureStorage = new MockedSecureStorage(null);
   }
   yield sm.initialize();
   let accountData = yield sm.getAccountData();
   Assert.ok(accountData, "read account data");
   Assert.equal(accountData.uid, "uid");
   Assert.equal(accountData.email, "someone@somewhere.com");
+  Assert.equal(accountData.deviceId, "wibble");
+  Assert.equal(accountData.isDeviceStale, true);
   // Update the data - we should be able to fetch it back and it should appear
   // in our storage.
-  yield sm.updateAccountData({verified: true, kA: "kA", kB: "kB"});
+  yield sm.updateAccountData({
+    verified: true,
+    kA: "kA",
+    kB: "kB",
+    isDeviceStale: false
+  });
   accountData = yield sm.getAccountData();
   Assert.equal(accountData.kB, "kB");
   Assert.equal(accountData.kA, "kA");
+  Assert.equal(accountData.deviceId, "wibble");
+  Assert.equal(accountData.isDeviceStale, false);
   // Check the new value was written to storage.
   yield sm._promiseStorageComplete; // storage is written in the background.
-  // "verified" is a plain-text field.
+  // "verified", "deviceId" and "isDeviceStale" are plain-text fields.
   Assert.equal(sm.plainStorage.data.accountData.verified, true);
+  Assert.equal(sm.plainStorage.data.accountData.deviceId, "wibble");
+  Assert.equal(sm.plainStorage.data.accountData.isDeviceStale, false);
   // "kA" and "foo" are secure
   if (sm.secureStorage) {
     Assert.equal(sm.secureStorage.data.accountData.kA, "kA");
     Assert.equal(sm.secureStorage.data.accountData.kB, "kB");
   } else {
     Assert.equal(sm.plainStorage.data.accountData.kA, "kA");
     Assert.equal(sm.plainStorage.data.accountData.kB, "kB");
   }
--- a/services/fxaccounts/tests/xpcshell/xpcshell.ini
+++ b/services/fxaccounts/tests/xpcshell/xpcshell.ini
@@ -1,14 +1,16 @@
 [DEFAULT]
 head = head.js ../../../common/tests/unit/head_helpers.js ../../../common/tests/unit/head_http.js
 tail =
 skip-if = toolkit == 'android'
 
 [test_accounts.js]
+[test_accounts_device_registration.js]
+skip-if = appname == 'b2g'
 [test_client.js]
 skip-if = toolkit == 'gonk' # times out, bug 1073639
 [test_credentials.js]
 [test_loginmgr_storage.js]
 skip-if = appname == 'b2g' # login manager storage only used on desktop.
 [test_manager.js]
 skip-if = appname != 'b2g'
 reason = FxAccountsManager is only available for B2G for now
--- a/services/sync/modules/constants.js
+++ b/services/sync/modules/constants.js
@@ -176,9 +176,12 @@ FENNEC_ID:                             "
 SEAMONKEY_ID:                          "{92650c4d-4b8e-4d2a-b7eb-24ecf4f6b63a}",
 TEST_HARNESS_ID:                       "xuth@mozilla.org",
 
 MIN_PP_LENGTH:                         12,
 MIN_PASS_LENGTH:                       8,
 
 LOG_DATE_FORMAT:                       "%Y-%m-%d %H:%M:%S",
 
+DEVICE_TYPE_DESKTOP:                   "desktop",
+DEVICE_TYPE_MOBILE:                    "mobile",
+
 }))];
--- a/services/sync/modules/engines/clients.js
+++ b/services/sync/modules/engines/clients.js
@@ -9,16 +9,20 @@ this.EXPORTED_SYMBOLS = [
 
 var {classes: Cc, interfaces: Ci, utils: Cu} = Components;
 
 Cu.import("resource://services-common/stringbundle.js");
 Cu.import("resource://services-sync/constants.js");
 Cu.import("resource://services-sync/engines.js");
 Cu.import("resource://services-sync/record.js");
 Cu.import("resource://services-sync/util.js");
+Cu.import("resource://services-common/async.js");
+
+XPCOMUtils.defineLazyModuleGetter(this, "fxAccounts",
+  "resource://gre/modules/FxAccounts.jsm");
 
 const CLIENTS_TTL = 1814400; // 21 days
 const CLIENTS_TTL_REFRESH = 604800; // 7 days
 
 const SUPPORTED_PROTOCOL_VERSIONS = ["1.1", "1.5"];
 
 this.ClientsRec = function ClientsRec(collection, id) {
   CryptoWrapper.call(this, collection, id);
@@ -58,24 +62,24 @@ ClientEngine.prototype = {
   },
   set lastRecordUpload(value) {
     Svc.Prefs.set(this.name + ".lastRecordUpload", Math.floor(value));
   },
 
   // Aggregate some stats on the composition of clients on this account
   get stats() {
     let stats = {
-      hasMobile: this.localType == "mobile",
+      hasMobile: this.localType == DEVICE_TYPE_MOBILE,
       names: [this.localName],
       numClients: 1,
     };
 
     for (let id in this._store._remoteClients) {
       let {name, type} = this._store._remoteClients[id];
-      stats.hasMobile = stats.hasMobile || type == "mobile";
+      stats.hasMobile = stats.hasMobile || type == DEVICE_TYPE_MOBILE;
       stats.names.push(name);
       stats.numClients++;
     }
 
     return stats;
   },
 
   /**
@@ -111,36 +115,33 @@ ClientEngine.prototype = {
   },
 
   get brandName() {
     let brand = new StringBundle("chrome://branding/locale/brand.properties");
     return brand.get("brandShortName");
   },
 
   get localName() {
-    let localName = Svc.Prefs.get("client.name", "");
-    if (localName != "")
-      return localName;
-
-    return this.localName = Utils.getDefaultDeviceName();
+    return this.localName = Utils.getDeviceName();
   },
   set localName(value) {
     Svc.Prefs.set("client.name", value);
+    fxAccounts.updateDeviceRegistration();
   },
 
   get localType() {
-    return Svc.Prefs.get("client.type", "desktop");
+    return Utils.getDeviceType();
   },
   set localType(value) {
     Svc.Prefs.set("client.type", value);
   },
 
   isMobile: function isMobile(id) {
     if (this._store._remoteClients[id])
-      return this._store._remoteClients[id].type == "mobile";
+      return this._store._remoteClients[id].type == DEVICE_TYPE_MOBILE;
     return false;
   },
 
   _syncStartup: function _syncStartup() {
     // Reupload new client record periodically.
     if (Date.now() / 1000 - this.lastRecordUpload > CLIENTS_TTL_REFRESH) {
       this._tracker.addChangedID(this.localID);
       this.lastRecordUpload = Date.now() / 1000;
@@ -408,16 +409,23 @@ ClientStore.prototype = {
       this._remoteClients[record.id] = record.cleartext;
   },
 
   createRecord: function createRecord(id, collection) {
     let record = new ClientsRec(collection, id);
 
     // Package the individual components into a record for the local client
     if (id == this.engine.localID) {
+      let cb = Async.makeSpinningCallback();
+      fxAccounts.getDeviceId().then(id => cb(null, id), cb);
+      try {
+        record.fxaDeviceId = cb.wait();
+      } catch(error) {
+        this._log.warn("failed to get fxa device id", error);
+      }
       record.name = this.engine.localName;
       record.type = this.engine.localType;
       record.commands = this.engine.localCommands;
       record.version = Services.appinfo.version;
       record.protocols = SUPPORTED_PROTOCOL_VERSIONS;
 
       // Optional fields.
       record.os = Services.appinfo.OS;             // "Darwin"
--- a/services/sync/modules/util.js
+++ b/services/sync/modules/util.js
@@ -667,16 +667,30 @@ this.Utils = {
       // 'device' is defined on unix systems
       Cc["@mozilla.org/system-info;1"].getService(Ci.nsIPropertyBag2).get("device") ||
       // hostname of the system, usually assigned by the user or admin
       Cc["@mozilla.org/system-info;1"].getService(Ci.nsIPropertyBag2).get("host") ||
       // fall back on ua info string
       Cc["@mozilla.org/network/protocol;1?name=http"].getService(Ci.nsIHttpProtocolHandler).oscpu;
 
     return Str.sync.get("client.name2", [user, appName, system]);
+  },
+
+  getDeviceName() {
+    const deviceName = Svc.Prefs.get("client.name", "");
+
+    if (deviceName === "") {
+      return this.getDefaultDeviceName();
+    }
+
+    return deviceName;
+  },
+
+  getDeviceType() {
+    return Svc.Prefs.get("client.type", DEVICE_TYPE_DESKTOP);
   }
 };
 
 XPCOMUtils.defineLazyGetter(Utils, "_utf8Converter", function() {
   let converter = Cc["@mozilla.org/intl/scriptableunicodeconverter"]
                     .createInstance(Ci.nsIScriptableUnicodeConverter);
   converter.charset = "UTF-8";
   return converter;
--- a/testing/profiles/prefs_b2g_unittest.js
+++ b/testing/profiles/prefs_b2g_unittest.js
@@ -3,9 +3,10 @@
 user_pref("b2g.system_manifest_url","app://test-container.gaiamobile.org/manifest.webapp");
 user_pref("b2g.system_startup_url","app://test-container.gaiamobile.org/index.html");
 user_pref("dom.ipc.browser_frames.oop_by_default", false);
 user_pref("dom.ipc.tabs.disabled", false);
 user_pref("dom.ipc.tabs.shutdownTimeoutSecs", 0);
 user_pref("dom.mozBrowserFramesEnabled", "%(OOP)s");
 user_pref("dom.mozBrowserFramesWhitelist","app://test-container.gaiamobile.org,http://mochi.test:8888");
 user_pref("dom.testing.datastore_enabled_for_hosted_apps", true);
+user_pref('identity.fxaccounts.skipDeviceRegistration', true);
 user_pref("marionette.force-local", true);
--- a/testing/profiles/prefs_general.js
+++ b/testing/profiles/prefs_general.js
@@ -243,16 +243,19 @@ user_pref('identity.fxaccounts.auth.uri'
 
 // Ditto for all the other Firefox accounts URIs used for about:accounts et al.:
 user_pref("identity.fxaccounts.remote.signup.uri", "https://%(server)s/fxa-signup");
 user_pref("identity.fxaccounts.remote.force_auth.uri", "https://%(server)s/fxa-force-auth");
 user_pref("identity.fxaccounts.remote.signin.uri", "https://%(server)s/fxa-signin");
 user_pref("identity.fxaccounts.settings.uri", "https://%(server)s/fxa-settings");
 user_pref('identity.fxaccounts.remote.webchannel.uri', 'https://%(server)s/');
 
+// We don't want browser tests to perform FxA device registration.
+user_pref('identity.fxaccounts.skipDeviceRegistration', true);
+
 // Increase the APZ content response timeout in tests to 1 minute.
 // This is to accommodate the fact that test environments tends to be slower
 // than production environments (with the b2g emulator being the slowest of them
 // all), resulting in the production timeout value sometimes being exceeded
 // and causing false-positive test failures. See bug 1176798, bug 1177018,
 // bug 1210465.
 user_pref("apz.content_response_timeout", 60000);