☠☠ backed out by 0c12d4229be0 ☠ ☠ | |
author | Phil Booth <pmbooth@gmail.com> |
Mon, 21 Dec 2015 12:29:00 +0100 | |
changeset 277861 | 4228fd8ef72fd99cafe4826473593b4ffb81b273 |
parent 277860 | 83cb1c2445fa081a67d379d975557afd56bf4979 |
child 277862 | caa21d8e9e04b25a39df17139abf85366fd1cdf5 |
push id | 69628 |
push user | cbook@mozilla.com |
push date | Wed, 30 Dec 2015 11:16:09 +0000 |
treeherder | mozilla-inbound@b493cf33851f [default view] [failures only] |
perfherder | [talos] [build metrics] [platform microbench] (compared to previous push) |
reviewers | markh |
bugs | 1227527 |
milestone | 46.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
|
--- 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);