diff --git a/b2g/app/b2g.js b/b2g/app/b2g.js index 8addabb8364..5604c87d1ee 100644 --- a/b2g/app/b2g.js +++ b/b2g/app/b2g.js @@ -1064,6 +1064,9 @@ pref("services.mobileid.server.uri", "https://msisdn.services.mozilla.com"); pref("identity.fxaccounts.remote.oauth.uri", "https://oauth.accounts.firefox.com/v1"); pref("identity.fxaccounts.remote.profile.uri", "https://profile.accounts.firefox.com/v1"); +// Disable Firefox Accounts device registration until bug 1238895 is fixed. +pref("identity.fxaccounts.skipDeviceRegistration", true); + // Enable mapped array buffer. #ifndef XP_WIN pref("dom.mapped_arraybuffer.enabled", true); diff --git a/b2g/components/test/unit/test_fxaccounts.js b/b2g/components/test/unit/test_fxaccounts.js index 1cc8a1c0a8c..5de0d656537 100644 --- a/b2g/components/test/unit/test_fxaccounts.js +++ b/b2g/components/test/unit/test_fxaccounts.js @@ -24,6 +24,7 @@ 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 @@ -39,6 +40,9 @@ function run_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); }); @@ -105,6 +109,9 @@ add_test(function test_invalidEmailCase_signIn() { // 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; @@ -164,6 +171,9 @@ add_test(function test_invalidEmailCase_signIn() { 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 diff --git a/services/fxaccounts/FxAccounts.jsm b/services/fxaccounts/FxAccounts.jsm index b3495706666..afe62ab74a9 100644 --- a/services/fxaccounts/FxAccounts.jsm +++ b/services/fxaccounts/FxAccounts.jsm @@ -30,6 +30,9 @@ XPCOMUtils.defineLazyModuleGetter(this, "FxAccountsOAuthGrantClient", 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", @@ -37,6 +40,7 @@ var publicProperties = [ "getAccountsSignInURI", "getAccountsSignUpURI", "getAssertion", + "getDeviceId", "getKeys", "getSignedInUser", "getOAuthToken", @@ -51,6 +55,7 @@ var publicProperties = [ "resendVerificationEmail", "setSignedInUser", "signOut", + "updateDeviceRegistration", "whenVerified" ]; @@ -490,7 +495,9 @@ FxAccountsInternal.prototype = { // 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)) { @@ -539,6 +546,26 @@ FxAccountsInternal.prototype = { }).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. * @@ -605,10 +632,15 @@ FxAccountsInternal.prototype = { 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 @@ -620,7 +652,7 @@ FxAccountsInternal.prototype = { // 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(() => { @@ -652,11 +684,22 @@ FxAccountsInternal.prototype = { }); }, - _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); }, /** @@ -1334,6 +1377,127 @@ FxAccountsInternal.prototype = { } ).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(() => {}); + } }; diff --git a/services/fxaccounts/FxAccountsClient.jsm b/services/fxaccounts/FxAccountsClient.jsm index 0ea8cafa57a..5de9f940584 100644 --- a/services/fxaccounts/FxAccountsClient.jsm +++ b/services/fxaccounts/FxAccountsClient.jsm @@ -193,7 +193,7 @@ this.FxAccountsClient.prototype = { 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")); @@ -348,6 +348,116 @@ this.FxAccountsClient.prototype = { ); }, + /** + * 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; }, diff --git a/services/fxaccounts/FxAccountsCommon.js b/services/fxaccounts/FxAccountsCommon.js index 37bcb25a5a5..78d370411c5 100644 --- a/services/fxaccounts/FxAccountsCommon.js +++ b/services/fxaccounts/FxAccountsCommon.js @@ -124,6 +124,10 @@ 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; @@ -150,7 +154,10 @@ exports.ERRNO_INVALID_CONTENT_TYPE = 113 + exports.OAUTH_SERVER_ERRNO_ // 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"; @@ -184,6 +191,7 @@ 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. @@ -218,7 +226,8 @@ exports.ERROR_MSG_METHOD_NOT_ALLOWED = "METHOD_NOT_ALLOWED"; // 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( @@ -267,6 +276,10 @@ SERVER_ERRNO_TO_ERROR[ERRNO_INCORRECT_LOGIN_METHOD] = ERROR_INCORRECT_LO 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; @@ -290,7 +303,10 @@ SERVER_ERRNO_TO_ERROR[ERRNO_INVALID_CONTENT_TYPE] = ERROR_INVALID_CONT // 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; @@ -314,6 +330,7 @@ ERROR_TO_GENERAL_ERROR_CLASS[ERROR_NO_SILENT_REFRESH_AUTH] = ERROR_AUTH_ 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; diff --git a/services/fxaccounts/FxAccountsWebChannel.jsm b/services/fxaccounts/FxAccountsWebChannel.jsm index 0985498defb..b2668ec21d7 100644 --- a/services/fxaccounts/FxAccountsWebChannel.jsm +++ b/services/fxaccounts/FxAccountsWebChannel.jsm @@ -264,7 +264,9 @@ this.FxAccountsWebChannelHelpers.prototype = { 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); } }); }, diff --git a/services/fxaccounts/tests/xpcshell/test_accounts.js b/services/fxaccounts/tests/xpcshell/test_accounts.js index 475f0d46e60..2b32d7bdc4c 100644 --- a/services/fxaccounts/tests/xpcshell/test_accounts.js +++ b/services/fxaccounts/tests/xpcshell/test_accounts.js @@ -127,7 +127,8 @@ function MockFxAccountsClient() { this.signCertificate = function() { throw "no" }; - this.signOut = function() { return Promise.resolve(); }; + this.signOut = () => Promise.resolve(); + this.signOutAndDestroyDevice = () => Promise.resolve({}); FxAccountsClient.apply(this); } @@ -162,6 +163,9 @@ function MockFxAccounts() { this._getCertificateSigned_calls.push([sessionToken, serializedPublicKey]); return this._d_signCertificate.promise; }, + _registerOrUpdateDevice() { + return Promise.resolve(); + }, fxAccountsClient: new MockFxAccountsClient() }); } @@ -179,6 +183,12 @@ function MakeFxAccounts(internal = {}) { return new AccountState(storage); }; } + if (!internal._signOutServer) { + internal._signOutServer = () => Promise.resolve(); + } + if (!internal._registerOrUpdateDevice) { + internal._registerOrUpdateDevice = () => Promise.resolve(); + } return new FxAccounts(internal); } @@ -210,7 +220,7 @@ add_test(function test_non_https_remote_server_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 = { @@ -555,7 +565,7 @@ add_test(function test_overlapping_signins() { }); }); -add_task(function test_getAssertion() { +add_task(function* test_getAssertion() { let fxa = new MockFxAccounts(); do_check_throws(function() { @@ -675,7 +685,7 @@ add_task(function test_getAssertion() { _("----- 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 { @@ -762,21 +772,98 @@ add_test(function test_resend_email() { }); }); -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() { @@ -1087,7 +1174,10 @@ add_test(function test_getSignedInUserProfile() { }, 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; @@ -1180,6 +1270,7 @@ 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"), diff --git a/services/fxaccounts/tests/xpcshell/test_accounts_device_registration.js b/services/fxaccounts/tests/xpcshell/test_accounts_device_registration.js new file mode 100644 index 00000000000..5412620234d --- /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 + }; +} + diff --git a/services/fxaccounts/tests/xpcshell/test_client.js b/services/fxaccounts/tests/xpcshell/test_client.js index b1c9bff2f4b..53610e15bde 100644 --- a/services/fxaccounts/tests/xpcshell/test_client.js +++ b/services/fxaccounts/tests/xpcshell/test_client.js @@ -40,7 +40,7 @@ function deferredStop(server) { 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", @@ -65,7 +65,7 @@ add_task(function test_authenticated_get_request() { yield deferredStop(server); }); -add_task(function test_authenticated_post_request() { +add_task(function* test_authenticated_post_request() { let credentials = { id: "eyJleHBpcmVzIjogMTM2NTAxMDg5OC4x", key: "qTZf4ZFpAMpMoeSsX3zVRjiqmNs=", @@ -90,7 +90,7 @@ add_task(function test_authenticated_post_request() { yield deferredStop(server); }); -add_task(function test_500_error() { +add_task(function* test_500_error() { let message = "