☠☠ backed out by de25ce87442b ☠ ☠ | |
author | Mark Hammond <mhammond@skippinet.com.au> |
Wed, 15 Jul 2015 12:16:21 +1000 | |
changeset 284654 | be39e54d4dffffd790fba2836637a6054b50e10c |
parent 284653 | d2daa6751884a18868897335f0a3ab563a090212 |
child 284655 | 656a56a747f94e3fde3320342d0d756bec743349 |
push id | 5067 |
push user | raliiev@mozilla.com |
push date | Mon, 21 Sep 2015 14:04:52 +0000 |
treeherder | mozilla-beta@14221ffe5b2f [default view] [failures only] |
perfherder | [talos] [build metrics] [platform microbench] (compared to previous push) |
reviewers | ckarlof |
bugs | 1157529 |
milestone | 42.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/browser/components/customizableui/test/browser_946320_tabs_from_other_computers.js +++ b/browser/components/customizableui/test/browser_946320_tabs_from_other_computers.js @@ -1,22 +1,17 @@ /* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ "use strict"; let Preferences = Cu.import("resource://gre/modules/Preferences.jsm", {}).Preferences; -let tmp = {}; -Cu.import("resource://gre/modules/FxAccounts.jsm", tmp); -Cu.import("resource://gre/modules/FxAccountsCommon.js", tmp); -Cu.import("resource://services-sync/browserid_identity.js", tmp); -let {FxAccounts, BrowserIDManager, DATA_FORMAT_VERSION, CERT_LIFETIME} = tmp; -let fxaSyncIsEnabled = Weave.Service.identity instanceof BrowserIDManager; +const {FxAccounts, AccountState} = Cu.import("resource://gre/modules/FxAccounts.jsm", {}); add_task(function() { yield PanelUI.show({type: "command"}); let historyButton = document.getElementById("history-panelmenu"); let historySubview = document.getElementById("PanelUI-history"); let subviewShownPromise = subviewShown(historySubview); historyButton.click(); @@ -42,45 +37,64 @@ add_task(function() { let syncPrefBranch = new Preferences("services.sync."); syncPrefBranch.resetBranch(""); Services.logins.removeAllLogins(); hiddenPanelPromise = promisePanelHidden(window); PanelUI.toggle({type: "command"}); yield hiddenPanelPromise; - if (fxaSyncIsEnabled) { - yield fxAccounts.signOut(); - } + yield fxAccounts.signOut(/*localOnly = */true); }); function configureIdentity() { - // do the FxAccounts thang... - configureFxAccountIdentity(); + // do the FxAccounts thang and wait for Sync to initialize the identity. + return configureFxAccountIdentity().then(() => { + Weave.Service.identity.whenReadyToAuthenticate.promise + }); +} - if (fxaSyncIsEnabled) { - return Weave.Service.identity.initializeWithCurrentIdentity().then(() => { - // need to wait until this identity manager is readyToAuthenticate. - return Weave.Service.identity.whenReadyToAuthenticate.promise; - }); +// Configure an instance of an FxAccount identity provider. +function configureFxAccountIdentity() { + // A mock "storage manager" for FxAccounts that doesn't actually write anywhere. + function MockFxaStorageManager() { } - Weave.Service.createAccount("john@doe.com", "mysecretpw", - "challenge", "response"); - Weave.Service.identity.account = "john@doe.com"; - Weave.Service.identity.basicPassword = "mysecretpw"; - Weave.Service.identity.syncKey = Weave.Utils.generatePassphrase(); - Weave.Svc.Prefs.set("firstSync", "newAccount"); - Weave.Service.persistLogin(); - return Promise.resolve(); -} + MockFxaStorageManager.prototype = { + promiseInitialized: Promise.resolve(), + + initialize(accountData) { + this.accountData = accountData; + }, + + finalize() { + return Promise.resolve(); + }, + + getAccountData() { + return Promise.resolve(this.accountData); + }, -// Configure an instance of an FxAccount identity provider with the specified -// config (or the default config if not specified). -function configureFxAccountIdentity() { + 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(); + } + } + let user = { assertion: "assertion", email: "email", kA: "kA", kB: "kB", sessionToken: "sessionToken", uid: "user_uid", verified: true, @@ -89,36 +103,37 @@ function configureFxAccountIdentity() { let token = { endpoint: Weave.Svc.Prefs.get("tokenServerURI"), duration: 300, id: "id", key: "key", // uid will be set to the username. }; - let MockInternal = {}; + let MockInternal = { + newAccountState(credentials) { + let storageManager = new MockFxaStorageManager(); + storageManager.initialize(credentials); + return new AccountState(this, storageManager); + }, + getCertificate(data, keyPair, mustBeValidUntil) { + this.cert = { + validUntil: this.now() + 10000, + cert: "certificate", + }; + return Promise.resolve(this.cert.cert); + }, + }; let mockTSC = { // TokenServerClient getTokenFromBrowserIDAssertion: function(uri, assertion, cb) { token.uid = "username"; cb(null, token); }, }; - let authService = Weave.Service.identity; - authService._fxaService = new FxAccounts(MockInternal); - - authService._fxaService.internal.currentAccountState.signedInUser = { - version: DATA_FORMAT_VERSION, - accountData: user - } - authService._fxaService.internal.currentAccountState.getCertificate = function(data, keyPair, mustBeValidUntil) { - this.cert = { - validUntil: authService._fxaService.internal.now() + CERT_LIFETIME, - cert: "certificate", - }; - return Promise.resolve(this.cert.cert); - }; - - authService._tokenServerClient = mockTSC; + let fxa = new FxAccounts(MockInternal); + Weave.Service.identity._fxaService = fxa; + Weave.Service.identity._tokenServerClient = mockTSC; // Set the "account" of the browserId manager to be the "email" of the // logged in user of the mockFXA service. - authService._account = user.email; + Weave.Service.identity._account = user.email; + return fxa.setSignedInUser(user); }
--- a/services/fxaccounts/FxAccounts.jsm +++ b/services/fxaccounts/FxAccounts.jsm @@ -1,25 +1,26 @@ /* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +"use strict"; this.EXPORTED_SYMBOLS = ["fxAccounts", "FxAccounts"]; const {classes: Cc, interfaces: Ci, utils: Cu} = Components; Cu.import("resource://gre/modules/Log.jsm"); Cu.import("resource://gre/modules/Promise.jsm"); -Cu.import("resource://gre/modules/osfile.jsm"); Cu.import("resource://services-common/utils.js"); Cu.import("resource://services-crypto/utils.js"); Cu.import("resource://gre/modules/Services.jsm"); Cu.import("resource://gre/modules/XPCOMUtils.jsm"); Cu.import("resource://gre/modules/Timer.jsm"); Cu.import("resource://gre/modules/Task.jsm"); +Cu.import("resource://gre/modules/FxAccountsStorage.jsm"); Cu.import("resource://gre/modules/FxAccountsCommon.js"); XPCOMUtils.defineLazyModuleGetter(this, "FxAccountsClient", "resource://gre/modules/FxAccountsClient.jsm"); XPCOMUtils.defineLazyModuleGetter(this, "jwcrypto", "resource://gre/modules/identity/jwcrypto.jsm"); @@ -45,17 +46,16 @@ let publicProperties = [ "now", "promiseAccountsForceSigninURI", "promiseAccountsChangeProfileURI", "promiseAccountsManageURI", "removeCachedOAuthToken", "resendVerificationEmail", "setSignedInUser", "signOut", - "version", "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. @@ -67,173 +67,92 @@ let publicProperties = [ // somePromiseBasedFunction: function() { // let currentState = this.currentAccountState; // return someOtherPromiseFunction().then( // data => currentState.resolve(data) // ); // } // If the state has changed between the function being called and the promise // being resolved, the .resolve() call will actually be rejected. -let AccountState = function(fxaInternal, signedInUserStorage, accountData = null) { +let AccountState = function(fxaInternal, storageManager) { this.fxaInternal = fxaInternal; - this.signedInUserStorage = signedInUserStorage; - this.signedInUser = accountData ? {version: DATA_FORMAT_VERSION, accountData} : null; - this.uid = accountData ? accountData.uid : null; - this.oauthTokens = {}; + this.storageManager = storageManager; + this.promiseInitialized = this.storageManager.getAccountData().then(data => { + this.oauthTokens = data && data.oauthTokens ? data.oauthTokens : {}; + }).catch(err => { + log.error("Failed to initialize the storage manager", err); + // Things are going to fall apart, but not much we can do about it here. + }); }; AccountState.prototype = { cert: null, keyPair: null, - signedInUser: null, oauthTokens: null, whenVerifiedDeferred: null, whenKeysReadyDeferred: null, - profile: null, - promiseInitialAccountData: null, - uid: null, get isCurrent() this.fxaInternal && this.fxaInternal.currentAccountState === this, - abort: function() { + abort() { if (this.whenVerifiedDeferred) { this.whenVerifiedDeferred.reject( new Error("Verification aborted; Another user signing in")); this.whenVerifiedDeferred = null; } if (this.whenKeysReadyDeferred) { this.whenKeysReadyDeferred.reject( new Error("Verification aborted; Another user signing in")); this.whenKeysReadyDeferred = null; } this.cert = null; this.keyPair = null; - this.signedInUser = null; - this.uid = null; + this.oauthTokens = null; this.fxaInternal = null; + // Avoid finalizing the storageManager multiple times (ie, .signOut() + // followed by .abort()) + if (!this.storageManager) { + return Promise.resolve(); + } + let storageManager = this.storageManager; + this.storageManager = null; + return storageManager.finalize(); }, // Clobber all cached data and write that empty data to storage. signOut() { this.cert = null; this.keyPair = null; - this.signedInUser = null; - this.oauthTokens = {}; - this.uid = null; - return this.persistUserData(); + this.oauthTokens = null; + let storageManager = this.storageManager; + this.storageManager = null; + return storageManager.deleteAccountData().then(() => { + return storageManager.finalize(); + }); }, getUserAccountData() { if (!this.isCurrent) { - return this.reject(new Error("Another user has signed in")); - } - if (this.promiseInitialAccountData) { - // We are still reading the data for the first and only time. - return this.promiseInitialAccountData; - } - // We've previously read it successfully (and possibly updated it since) - if (this.signedInUser) { - return this.resolve(this.signedInUser.accountData); + return Promise.reject(new Error("Another user has signed in")); } - - // We fetch the signedInUser data first, then fetch the token store and - // ensure the uid in the tokens matches our user. - let accountData = null; - let oauthTokens = {}; - return this.promiseInitialAccountData = this.signedInUserStorage.get() - .then(user => { - if (logPII) { - log.debug("getUserAccountData", user); - } - // In an ideal world we could cache the data in this.signedInUser, but - // if we do, the interaction with the login manager breaks when the - // password is locked as this read may only have obtained partial data. - // Therefore every read *must* really read incase the login manager is - // now unlocked. We could fix this with a refactor... - accountData = user ? user.accountData : null; - }, err => { - // Error reading signed in user account data. - this.promiseInitialAccountData = null; - if (err instanceof OS.File.Error && err.becauseNoSuchFile) { - // File hasn't been created yet. That will be done - // on the first call to setSignedInUser - return; - } - // something else went wrong - report the error but continue without - // user data. - log.error("Failed to read signed in user data", err); - }).then(() => { - if (!accountData) { - return null; - } - return this.signedInUserStorage.getOAuthTokens(); - }).then(tokenData => { - if (tokenData && tokenData.tokens && - tokenData.version == DATA_FORMAT_VERSION && - tokenData.uid == accountData.uid ) { - oauthTokens = tokenData.tokens; - } - }, err => { - // Error reading the OAuth tokens file. - if (err instanceof OS.File.Error && err.becauseNoSuchFile) { - // File hasn't been created yet, but will be when tokens are saved. - return; - } - log.error("Failed to read oauth tokens", err) - }).then(() => { - // We are done - clear our promise and save the data if we are still - // current. - this.promiseInitialAccountData = null; - if (this.isCurrent) { - // As above, we can not cache the data to this.signedInUser as we - // may only have partial data due to a locked MP, so the next - // request must re-read incase it is now unlocked. - // But we do save the tokens and the uid - this.oauthTokens = oauthTokens; - this.uid = accountData ? accountData.uid : null; - } - return accountData; - }); - // phew! + return this.storageManager.getAccountData().then(result => { + return this.resolve(result); + }); }, - // XXX - this should really be called "updateCurrentUserData" or similar as - // it is only ever used to add new fields to the *current* user, not to - // set a new user as current. - setUserAccountData: function(accountData) { + updateUserAccountData(updatedFields) { if (!this.isCurrent) { - return this.reject(new Error("Another user has signed in")); - } - if (this.promiseInitialAccountData) { - throw new Error("Can't set account data before it's been read."); + return Promise.reject(new Error("Another user has signed in")); } - if (!accountData) { - // see above - this should really be called "updateCurrentUserData" or similar. - throw new Error("Attempt to use setUserAccountData with null user data."); - } - if (accountData.uid != this.uid) { - // see above - this should really be called "updateCurrentUserData" or similar. - throw new Error("Attempt to use setUserAccountData with a different user."); - } - // Set our signedInUser before we start the write, so any updates to the - // data while the write completes are still captured. - this.signedInUser = {version: DATA_FORMAT_VERSION, accountData: accountData}; - return this.signedInUserStorage.set(this.signedInUser) - .then(() => this.resolve(accountData)); + return this.storageManager.updateAccountData(updatedFields); }, - getCertificate: function(data, keyPair, mustBeValidUntil) { - if (logPII) { - // don't stringify unless it will be written. We should replace this - // check with param substitutions added in bug 966674 - log.debug("getCertificate" + JSON.stringify(this.signedInUser)); - } // TODO: get the lifetime from the cert's .exp field if (this.cert && this.cert.validUntil > mustBeValidUntil) { log.debug(" getCertificate already had one"); return this.resolve(this.cert.cert); } if (Services.io.offline) { return this.reject(new Error(ERROR_OFFLINE)); @@ -287,38 +206,42 @@ AccountState.prototype = { }); return d.promise.then(result => this.resolve(result)); }, resolve: function(result) { if (!this.isCurrent) { log.info("An accountState promise was resolved, but was actually rejected" + " due to a different user being signed in. Originally resolved" + - " with: " + result); + " with", result); return Promise.reject(new Error("A different user signed in")); } return Promise.resolve(result); }, reject: function(error) { // It could be argued that we should just let it reject with the original // error - but this runs the risk of the error being (eg) a 401, which // might cause the consumer to attempt some remediation and cause other // problems. if (!this.isCurrent) { log.info("An accountState promise was rejected, but we are ignoring that" + "reason and rejecting it due to a different user being signed in." + - "Originally rejected with: " + error); + "Originally rejected with", error); return Promise.reject(new Error("A different user signed in")); } return Promise.reject(error); }, // Abstractions for storage of cached tokens - these are all sync, and don't - // handle revocation etc - it's just storage. + // handle revocation etc - it's just storage (and the storage itself is async, + // but we don't return the storage promises, so it *looks* sync) + // These functions are sync simply so we can handle "token races" - when there + // are multiple in-flight requests for the same scope, we can detect this + // and revoke the redundant token. // A preamble for the cache helpers... _cachePreamble() { if (!this.isCurrent) { throw new Error("Another user has signed in"); } }, @@ -335,35 +258,26 @@ AccountState.prototype = { // And a background save... this._persistCachedTokens(); }, // Return data for a cached token or null (or throws on bad state etc) getCachedToken(scopeArray) { this._cachePreamble(); let key = getScopeKey(scopeArray); - if (this.oauthTokens[key]) { + let result = this.oauthTokens[key]; + if (result) { // later we might want to check an expiry date - but we currently // have no such concept, so just return it. log.trace("getCachedToken returning cached token"); - return this.oauthTokens[key]; + return result; } return null; }, - // Get an array of tokenData for all cached tokens. - getAllCachedTokens() { - this._cachePreamble(); - let result = []; - for (let [key, tokenValue] in Iterator(this.oauthTokens)) { - result.push(tokenValue); - } - return result; - }, - // Remove a cached token from the cache. Does *not* revoke it from anywhere. // Returns the entire token entry if found, null otherwise. removeCachedToken(token) { this._cachePreamble(); let data = this.oauthTokens; for (let [key, tokenValue] in Iterator(data)) { if (tokenValue.token == token) { delete data[key]; @@ -375,40 +289,18 @@ AccountState.prototype = { return null; }, // A hook-point for tests. Returns a promise that's ignored in most cases // (notable exceptions are tests and when we explicitly are saving the entire // set of user data.) _persistCachedTokens() { this._cachePreamble(); - let record; - if (this.uid) { - record = { - version: DATA_FORMAT_VERSION, - uid: this.uid, - tokens: this.oauthTokens, - }; - } else { - record = null; - } - return this.signedInUserStorage.setOAuthTokens(record).catch( - err => { - log.error("Failed to save account data for token cache", err); - } - ); - }, - - persistUserData() { - return this._persistCachedTokens().catch(err => { - log.error("Failed to persist cached tokens", err); - }).then(() => { - return this.signedInUserStorage.set(this.signedInUser); - }).catch(err => { - log.error("Failed to persist account data", err); + return this.updateUserAccountData({ oauthTokens: this.oauthTokens }).catch(err => { + log.error("Failed to update cached tokens", err); }); }, } /* Given an array of scopes, make a string key by normalizing. */ function getScopeKey(scopeArray) { let normalizedScopes = scopeArray.map(item => item.toLowerCase()); return normalizedScopes.sort().join("|"); @@ -467,90 +359,55 @@ this.FxAccounts = function (mockInternal copyObjectProperties(prototype, external, options); // Copy all of the mock's properties to the internal object. if (mockInternal && !mockInternal.onlySetInternal) { copyObjectProperties(mockInternal, internal); } if (mockInternal) { - // A little work-around to ensure the initial currentAccountState has - // the same mock storage the test passed in. - if (mockInternal.signedInUserStorage) { - internal.currentAccountState.signedInUserStorage = mockInternal.signedInUserStorage; - } // Exposes the internal object for testing only. external.internal = internal; } + // wait until after the mocks are setup before initializing. + internal.initialize(); + return Object.freeze(external); } /** * The internal API's constructor. */ function FxAccountsInternal() { - this.version = DATA_FORMAT_VERSION; - // Make a local copy of this constant so we can mock it in testing this.POLL_SESSION = POLL_SESSION; - // The one and only "storage" object. While this is created here, the - // FxAccountsInternal object does *not* use it directly, but instead passes - // it to AccountState objects which has sole responsibility for storage. - // Ideally we would create it in the AccountState objects, but that makes - // testing hard as AccountState objects are regularly created and thrown - // away. Doing it this way means tests can mock/replace this storage object - // and have it used by all AccountState objects, even those created before - // and after the mock has been setup. - - // We only want the fancy LoginManagerStorage on desktop. -#if defined(MOZ_B2G) - this.signedInUserStorage = new JSONStorage({ -#else - this.signedInUserStorage = new LoginManagerStorage({ -#endif - // We don't reference |profileDir| in the top-level module scope - // as we may be imported before we know where it is. - filename: DEFAULT_STORAGE_FILENAME, - oauthTokensFilename: DEFAULT_OAUTH_TOKENS_FILENAME, - baseDir: OS.Constants.Path.profileDir, - }); - - // We interact with the Firefox Accounts auth server in order to confirm that - // a user's email has been verified and also to fetch the user's keys from - // the server. We manage these processes in possibly long-lived promises - // that are internal to this object (never exposed to callers). Because - // Firefox Accounts allows for only one logged-in user, and because it's - // conceivable that while we are waiting to verify one identity, a caller - // could start verification on a second, different identity, we need to be - // able to abort all work on the first sign-in process. The currentTimer and - // currentAccountState are used for this purpose. - // (XXX - should the timer be directly on the currentAccountState?) - this.currentTimer = null; - this.currentAccountState = new AccountState(this, this.signedInUserStorage); + // All significant initialization should be done in the initialize() method + // below as it helps with testing. } /** * The internal API's prototype. */ FxAccountsInternal.prototype = { - - /** - * The current data format's version number. - */ - version: DATA_FORMAT_VERSION, - // The timeout (in ms) we use to poll for a verified mail for the first 2 mins. VERIFICATION_POLL_TIMEOUT_INITIAL: 5000, // 5 seconds // And how often we poll after the first 2 mins. VERIFICATION_POLL_TIMEOUT_SUBSEQUENT: 15000, // 15 seconds. _fxAccountsClient: null, + // All significant initialization should be done in this initialize() method, + // as it's called after this object has been mocked for tests. + initialize() { + this.currentTimer = null; + this.currentAccountState = this.newAccountState(); + }, + get fxAccountsClient() { if (!this._fxAccountsClient) { this._fxAccountsClient = new FxAccountsClient(); } return this._fxAccountsClient; }, // The profile object used to fetch the actual user profile. @@ -561,16 +418,23 @@ FxAccountsInternal.prototype = { this._profile = new FxAccountsProfile({ fxa: this, profileServerUrl: profileServerUrl, }); } return this._profile; }, + // A hook-point for tests who may want a mocked AccountState or mocked storage. + newAccountState(credentials) { + let storage = new FxAccountsStorageManager(); + storage.initialize(credentials); + return new AccountState(this, storage); + }, + /** * Return the current time in milliseconds as an integer. Allows tests to * manipulate the date to simulate certificate expiration. */ now: function() { return this.fxAccountsClient.now(); }, @@ -671,34 +535,33 @@ FxAccountsInternal.prototype = { * verified: true/false * } * @return Promise * The promise resolves to null when the data is saved * successfully and is rejected on error. */ setSignedInUser: function setSignedInUser(credentials) { log.debug("setSignedInUser - aborting any existing flows"); - this.abortExistingFlow(); - - let currentAccountState = this.currentAccountState = new AccountState( - this, - this.signedInUserStorage, - JSON.parse(JSON.stringify(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. - return currentAccountState.persistUserData().then(() => { - this.notifyObservers(ONLOGIN_NOTIFICATION); - if (!this.isUserEmailVerified(credentials)) { - this.startVerifiedCheck(credentials); - } - }).then(() => { - return currentAccountState.resolve(); - }); + 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(() => { + this.notifyObservers(ONLOGIN_NOTIFICATION); + if (!this.isUserEmailVerified(credentials)) { + this.startVerifiedCheck(credentials); + } + }).then(() => { + return currentAccountState.resolve(); + }); + }) }, /** * returns a promise that fires with the assertion. If there is no verified * signed-in user, fires with null. */ getAssertion: function getAssertion(audience) { log.debug("enter getAssertion()"); @@ -744,18 +607,23 @@ FxAccountsInternal.prototype = { * Reset state such that any previous flow is canceled. */ abortExistingFlow: function abortExistingFlow() { if (this.currentTimer) { log.debug("Polling aborted; Another user signing in"); clearTimeout(this.currentTimer); this.currentTimer = 0; } - this.currentAccountState.abort(); - this.currentAccountState = new AccountState(this, this.signedInUserStorage); + if (this._profile) { + this._profile.tearDown(); + this._profile = null; + } + // We "abort" the accountState and assume our caller is about to throw it + // away and replace it with a new one. + return this.currentAccountState.abort(); }, accountStatus: function accountStatus() { return this.currentAccountState.getUserAccountData().then(data => { if (!data) { return false; } return this.fxAccountsClient.accountStatus(data.uid); @@ -768,30 +636,30 @@ FxAccountsInternal.prototype = { client_id: FX_OAUTH_CLIENT_ID }); return client.destroyToken(tokenData.token) }, _destroyAllOAuthTokens: function(tokenInfos) { // let's just destroy them all in parallel... let promises = []; - for (let tokenInfo of tokenInfos) { + for (let [key, tokenInfo] in Iterator(tokenInfos || {})) { promises.push(this._destroyOAuthToken(tokenInfo)); } return Promise.all(promises); }, signOut: function signOut(localOnly) { let currentState = this.currentAccountState; let sessionToken; let tokensToRevoke; return currentState.getUserAccountData().then(data => { // Save the session token for use in the call to signOut below. sessionToken = data && data.sessionToken; - tokensToRevoke = currentState.getAllCachedTokens(); + tokensToRevoke = data && data.oauthTokens; 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(() => { @@ -816,22 +684,22 @@ FxAccountsInternal.prototype = { }, /** * This function should be called in conjunction with a server-side * signOut via FxAccountsClient. */ _signOutLocal: function signOutLocal() { let currentAccountState = this.currentAccountState; - if (this._profile) { - this._profile.tearDown(); - this._profile = null; - } return currentAccountState.signOut().then(() => { - this.abortExistingFlow(); // this resets this.currentAccountState. + // 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) { return this.fxAccountsClient.signOut(sessionToken); }, /** @@ -912,33 +780,34 @@ FxAccountsInternal.prototype = { // Next statements must be synchronous until we setUserAccountData // so that we don't risk getting into a weird state. let kB_hex = CryptoUtils.xor(CommonUtils.hexToBytes(data.unwrapBKey), wrapKB); if (logPII) { log.debug("kB_hex: " + kB_hex); } - data.kA = CommonUtils.bytesAsHex(kA); - data.kB = CommonUtils.bytesAsHex(kB_hex); - - delete data.keyFetchToken; - delete data.unwrapBKey; - - log.debug("Keys Obtained: kA=" + !!data.kA + ", kB=" + !!data.kB); - if (logPII) { - log.debug("Keys Obtained: kA=" + data.kA + ", kB=" + data.kB); + let updateData = { + kA: CommonUtils.bytesAsHex(kA), + kB: CommonUtils.bytesAsHex(kB_hex), + keyFetchToken: null, // null values cause the item to be removed. + unwrapBKey: null, } - yield currentState.setUserAccountData(data); + log.debug("Keys Obtained: kA=" + !!updateData.kA + ", kB=" + !!updateData.kB); + if (logPII) { + log.debug("Keys Obtained: kA=" + updateData.kA + ", kB=" + updateData.kB); + } + + yield currentState.updateUserAccountData(updateData); // We are now ready for business. This should only be invoked once // per setSignedInUser(), regardless of whether we've rebooted since // setSignedInUser() was called. this.notifyObservers(ONVERIFIED_NOTIFICATION); - return data; + return currentState.getUserAccountData(); }.bind(this)).then(result => currentState.resolve(result)); }, getAssertionFromCert: function(data, keyPair, cert, audience) { log.debug("getAssertionFromCert"); let payload = {}; let d = Promise.defer(); let options = { @@ -1065,22 +934,21 @@ FxAccountsInternal.prototype = { }); } } this.checkEmailStatus(sessionToken) .then((response) => { log.debug("checkEmailStatus -> " + JSON.stringify(response)); if (response && response.verified) { - currentState.getUserAccountData() - .then((data) => { - data.verified = true; - return currentState.setUserAccountData(data); + currentState.updateUserAccountData({ verified: true }) + .then(() => { + return currentState.getUserAccountData(); }) - .then((data) => { + .then(data => { // Now that the user is verified, we can proceed to fetch keys if (currentState.whenVerifiedDeferred) { currentState.whenVerifiedDeferred.resolve(data); delete currentState.whenVerifiedDeferred; } // Tell FxAccountsManager to clear its cache this.notifyObservers(ON_FXA_UPDATE_NOTIFICATION, ONVERIFIED_NOTIFICATION); }); @@ -1404,262 +1272,27 @@ FxAccountsInternal.prototype = { * NETWORK_ERROR * AUTH_ERROR * UNKNOWN_ERROR */ getSignedInUserProfile: function () { let currentState = this.currentAccountState; return this.profile.getProfile().then( profileData => { - let profile = JSON.parse(JSON.stringify(profileData)); + let profile = Cu.cloneInto(profileData, {}); return currentState.resolve(profile); }, error => { log.error("Could not retrieve profile data", error); return currentState.reject(error); } ).catch(err => Promise.reject(this._errorToErrorClass(err))); }, }; -/** - * JSONStorage constructor that creates instances that may set/get - * to a specified file, in a directory that will be created if it - * doesn't exist. - * - * @param options { - * filename: of the file to write to - * baseDir: directory where the file resides - * } - * @return instance - */ -function JSONStorage(options) { - this.baseDir = options.baseDir; - this.path = OS.Path.join(options.baseDir, options.filename); - this.oauthTokensPath = OS.Path.join(options.baseDir, options.oauthTokensFilename); -}; - -JSONStorage.prototype = { - set: function(contents) { - return OS.File.makeDir(this.baseDir, {ignoreExisting: true}) - .then(CommonUtils.writeJSON.bind(null, contents, this.path)); - }, - - get: function() { - return CommonUtils.readJSON(this.path); - }, - - setOAuthTokens: function(contents) { - return OS.File.makeDir(this.baseDir, {ignoreExisting: true}) - .then(CommonUtils.writeJSON.bind(null, contents, this.oauthTokensPath)); - }, - - getOAuthTokens: function(contents) { - return CommonUtils.readJSON(this.oauthTokensPath); - }, - -}; - -/** - * LoginManagerStorage constructor that creates instances that may set/get - * from a combination of a clear-text JSON file and stored securely in - * the nsILoginManager. - * - * @param options { - * filename: of the plain-text file to write to - * baseDir: directory where the file resides - * } - * @return instance - */ - -function LoginManagerStorage(options) { - // we reuse the JSONStorage for writing the plain-text stuff. - this.jsonStorage = new JSONStorage(options); -} - -LoginManagerStorage.prototype = { - // The fields in the credentials JSON object that are stored in plain-text - // in the profile directory. All other fields are stored in the login manager, - // and thus are only available when the master-password is unlocked. - - // a hook point for testing. - get _isLoggedIn() { - return Services.logins.isLoggedIn; - }, - - // Clear any data from the login manager. Returns true if the login manager - // was unlocked (even if no existing logins existed) or false if it was - // locked (meaning we don't even know if it existed or not.) - _clearLoginMgrData: Task.async(function* () { - try { // Services.logins might be third-party and broken... - yield Services.logins.initializationPromise; - if (!this._isLoggedIn) { - return false; - } - let logins = Services.logins.findLogins({}, FXA_PWDMGR_HOST, null, FXA_PWDMGR_REALM); - for (let login of logins) { - Services.logins.removeLogin(login); - } - return true; - } catch (ex) { - log.error("Failed to clear login data: ${}", ex); - return false; - } - }), - - set: Task.async(function* (contents) { - if (!contents) { - // User is signing out - write the null to the json file. - yield this.jsonStorage.set(contents); - - // And nuke it from the login manager. - let cleared = yield this._clearLoginMgrData(); - if (!cleared) { - // just log a message - we verify that the email address matches when - // we reload it, so having a stale entry doesn't really hurt. - log.info("not removing credentials from login manager - not logged in"); - } - return; - } - - // We are saving actual data. - // Split the data into 2 chunks - one to go to the plain-text, and the - // other to write to the login manager. - let toWriteJSON = {version: contents.version}; - let accountDataJSON = toWriteJSON.accountData = {}; - let toWriteLoginMgr = {version: contents.version}; - let accountDataLoginMgr = toWriteLoginMgr.accountData = {}; - for (let [name, value] of Iterator(contents.accountData)) { - if (FXA_PWDMGR_PLAINTEXT_FIELDS.indexOf(name) >= 0) { - accountDataJSON[name] = value; - } else { - accountDataLoginMgr[name] = value; - } - } - yield this.jsonStorage.set(toWriteJSON); - - try { // Services.logins might be third-party and broken... - // and the stuff into the login manager. - yield Services.logins.initializationPromise; - // If MP is locked we silently fail - the user may need to re-auth - // next startup. - if (!this._isLoggedIn) { - log.info("not saving credentials to login manager - not logged in"); - return; - } - // write the rest of the data to the login manager. - let loginInfo = new Components.Constructor( - "@mozilla.org/login-manager/loginInfo;1", Ci.nsILoginInfo, "init"); - let login = new loginInfo(FXA_PWDMGR_HOST, - null, // aFormSubmitURL, - FXA_PWDMGR_REALM, // aHttpRealm, - contents.accountData.email, // aUsername - JSON.stringify(toWriteLoginMgr), // aPassword - "", // aUsernameField - "");// aPasswordField - - let existingLogins = Services.logins.findLogins({}, FXA_PWDMGR_HOST, null, - FXA_PWDMGR_REALM); - if (existingLogins.length) { - Services.logins.modifyLogin(existingLogins[0], login); - } else { - Services.logins.addLogin(login); - } - } catch (ex) { - log.error("Failed to save data to the login manager: ${}", ex); - } - }), - - get: Task.async(function* () { - // we need to suck some data from the .json file in the profile dir and - // some other from the login manager. - let data = yield this.jsonStorage.get(); - if (!data) { - // no user logged in, nuke the storage data incase we couldn't remove - // it previously and then we are done. - yield this._clearLoginMgrData(); - return null; - } - - // if we have encryption keys it must have been saved before we - // used the login manager, so re-save it. - if (data.accountData.kA || data.accountData.kB || data.keyFetchToken) { - // We need to migrate, but the MP might be locked (eg, on the first run - // with this enabled, we will get here very soon after startup, so will - // certainly be locked.) This means we can't actually store the data in - // the login manager (and thus might lose it if we migrated now) - // So if the MP is locked, we *don't* migrate, but still just return - // the subset of data we now store in the JSON. - // This will cause sync to notice the lack of keys, force an unlock then - // re-fetch the account data to see if the keys are there. At *that* - // point we will end up back here, but because the MP is now unlocked - // we can actually perform the migration. - if (!this._isLoggedIn) { - // return the "safe" subset but leave the storage alone. - log.info("account data needs migration to the login manager but the MP is locked."); - let result = { - version: data.version, - accountData: {}, - }; - for (let fieldName of FXA_PWDMGR_PLAINTEXT_FIELDS) { - result.accountData[fieldName] = data.accountData[fieldName]; - } - return result; - } - // actually migrate - just calling .set() will split everything up. - log.info("account data is being migrated to the login manager."); - yield this.set(data); - } - - try { // Services.logins might be third-party and broken... - // read the data from the login manager and merge it for return. - yield Services.logins.initializationPromise; - - if (!this._isLoggedIn) { - log.info("returning partial account data as the login manager is locked."); - return data; - } - - let logins = Services.logins.findLogins({}, FXA_PWDMGR_HOST, null, FXA_PWDMGR_REALM); - if (logins.length == 0) { - // This could happen if the MP was locked when we wrote the data. - log.info("Can't find the rest of the credentials in the login manager"); - return data; - } - let login = logins[0]; - if (login.username == data.accountData.email) { - let lmData = JSON.parse(login.password); - if (lmData.version == data.version) { - // Merge the login manager data - copyObjectProperties(lmData.accountData, data.accountData); - } else { - log.info("version field in the login manager doesn't match - ignoring it"); - yield this._clearLoginMgrData(); - } - } else { - log.info("username in the login manager doesn't match - ignoring it"); - yield this._clearLoginMgrData(); - } - } catch (ex) { - log.error("Failed to get data from the login manager: ${}", ex); - } - return data; - }), - - // OAuth tokens are always written to disk, so delegate to our JSON storage. - // (Bug 1013064 comments 23-25 explain why we save the sessionToken into the - // plain JSON file, and the same logic applies for oauthTokens being in JSON) - getOAuthTokens() { - return this.jsonStorage.getOAuthTokens(); - }, - - setOAuthTokens(contents) { - return this.jsonStorage.setOAuthTokens(contents); - }, -} // 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 // browser restart a.loadAndPoll();
--- a/services/fxaccounts/FxAccountsCommon.js +++ b/services/fxaccounts/FxAccountsCommon.js @@ -61,17 +61,16 @@ XPCOMUtils.defineLazyGetter(exports, 'lo return false; } }); exports.FXACCOUNTS_PERMISSION = "firefox-accounts"; exports.DATA_FORMAT_VERSION = 1; exports.DEFAULT_STORAGE_FILENAME = "signedInUser.json"; -exports.DEFAULT_OAUTH_TOKENS_FILENAME = "signedInUserOAuthTokens.json"; // Token life times. // Having this parameter be short has limited security value and can cause // spurious authentication values if the client's clock is skewed and // we fail to adjust. See Bug 983256. exports.ASSERTION_LIFETIME = 1000 * 3600 * 24 * 365 * 25; // 25 years // This is a time period we want to guarantee that the assertion will be // valid after we generate it (e.g., the signed cert won't expire in this @@ -212,17 +211,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. // These constants relate to that. // 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 = ["email", "verified", "authAt", - "sessionToken", "uid"]; + "sessionToken", "uid", "oauthTokens", + "profile"]; // The pseudo-host we use in the login manager exports.FXA_PWDMGR_HOST = "chrome://FirefoxAccounts"; // The realm we use in the login manager. exports.FXA_PWDMGR_REALM = "Firefox Accounts credentials"; // Error matching. exports.SERVER_ERRNO_TO_ERROR = {};
new file mode 100644 --- /dev/null +++ b/services/fxaccounts/FxAccountsStorage.jsm @@ -0,0 +1,540 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +"use strict"; + +this.EXPORTED_SYMBOLS = [ + "FxAccountsStorageManager", +]; + +const {classes: Cc, interfaces: Ci, utils: Cu} = Components; + +Cu.import("resource://gre/modules/Services.jsm"); +Cu.import("resource://gre/modules/Task.jsm"); +Cu.import("resource://gre/modules/FxAccountsCommon.js"); +Cu.import("resource://gre/modules/osfile.jsm"); +Cu.import("resource://services-common/utils.js"); + +function FxAccountsStorageManager(options = {}) { + this.options = { + filename: options.filename || DEFAULT_STORAGE_FILENAME, + baseDir: options.baseDir || OS.Constants.Path.profileDir, + } + this.plainStorage = new JSONStorage(this.options); + // On b2g we have no loginManager for secure storage, and tests may want + // to pretend secure storage isn't available. + let useSecure = 'useSecure' in options ? options.useSecure : haveLoginManager; + if (useSecure) { + this.secureStorage = new LoginManagerStorage(); + } else { + this.secureStorage = null; + } + this._clearCachedData(); + // See .initialize() below - this protects against it not being called. + this._promiseInitialized = Promise.reject("initialize not called"); + // A promise to avoid storage races - see _queueStorageOperation + this._promiseStorageComplete = Promise.resolve(); +} + +FxAccountsStorageManager.prototype = { + _initialized: false, + _needToReadSecure: true, + + // An initialization routine that *looks* synchronous to the callers, but + // is actually async as everything else waits for it to complete. + initialize(accountData) { + if (this._initialized) { + throw new Error("already initialized"); + } + this._initialized = true; + // If we just throw away our pre-rejected promise it is reported as an + // unhandled exception when it is GCd - so add an empty .catch handler here + // to prevent this. + this._promiseInitialized.catch(() => {}); + this._promiseInitialized = this._initialize(accountData); + }, + + _initialize: Task.async(function* (accountData) { + log.trace("initializing new storage manager"); + try { + if (accountData) { + // If accountData is passed we don't need to read any storage. + this._needToReadSecure = false; + // split it into the 2 parts, write it and we are done. + for (let [name, val] of Iterator(accountData)) { + if (FXA_PWDMGR_PLAINTEXT_FIELDS.indexOf(name) >= 0) { + this.cachedPlain[name] = val; + } else { + this.cachedSecure[name] = val; + } + } + // write it out and we are done. + yield this._write(); + return; + } + // So we were initialized without account data - that means we need to + // read the state from storage. We try and read plain storage first and + // only attempt to read secure storage if the plain storage had a user. + this._needToReadSecure = yield this._readPlainStorage(); + if (this._needToReadSecure && this.secureStorage) { + yield this._doReadAndUpdateSecure(); + } + } finally { + log.trace("initializing of new storage manager done"); + } + }), + + finalize() { + // We can't throw this instance away while it is still writing or we may + // end up racing with the newly created one. + log.trace("StorageManager finalizing"); + return this._promiseInitialized.then(() => { + return this._promiseStorageComplete; + }).then(() => { + this._promiseStorageComplete = null; + this._promiseInitialized = null; + this._clearCachedData(); + log.trace("StorageManager finalized"); + }) + }, + + // We want to make sure we don't end up doing multiple storage requests + // concurrently - which has a small window for reads if the master-password + // is locked at initialization time and becomes unlocked later, and always + // has an opportunity for updates. + // We also want to make sure we finished writing when finalizing, so we + // can't accidentally end up with the previous user's write finishing after + // a signOut attempts to clear it. + // So all such operations "queue" themselves via this. + _queueStorageOperation(func) { + // |result| is the promise we return - it has no .catch handler, so callers + // of the storage operation still see failure as a normal rejection. + let result = this._promiseStorageComplete.then(func); + // But the promise we assign to _promiseStorageComplete *does* have a catch + // handler so that rejections in one storage operation does not prevent + // future operations from starting (ie, _promiseStorageComplete must never + // be in a rejected state) + this._promiseStorageComplete = result.catch(err => { + log.error("${func} failed: ${err}", {func, err}); + }); + return result; + }, + + // Get the account data by combining the plain and secure storage. + getAccountData: Task.async(function* () { + yield this._promiseInitialized; + // We know we are initialized - this means our .cachedPlain is accurate + // and doesn't need to be read (it was read if necessary by initialize). + // So if there's no uid, there's no user signed in. + if (!('uid' in this.cachedPlain)) { + return null; + } + let result = {}; + for (let [name, value] of Iterator(this.cachedPlain)) { + result[name] = value; + } + // But the secure data may not have been read, so try that now. + yield this._maybeReadAndUpdateSecure(); + // .cachedSecure now has as much as it possibly can (which is possibly + // nothing if (a) secure storage remains locked and (b) we've never updated + // a field to be stored in secure storage.) + for (let [name, value] of Iterator(this.cachedSecure)) { + result[name] = value; + } + return result; + }), + + + // Update just the specified fields. This DOES NOT allow you to change to + // a different user, nor to set the user as signed-out. + updateAccountData: Task.async(function* (newFields) { + yield this._promiseInitialized; + if (!('uid' in this.cachedPlain)) { + // If this storage instance shows no logged in user, then you can't + // update fields. + throw new Error("No user is logged in"); + } + if (!newFields || 'uid' in newFields || 'email' in newFields) { + // Once we support + // user changing email address this may need to change, but it's not + // clear how we would be told of such a change anyway... + throw new Error("Can't change uid or email address"); + } + log.debug("_updateAccountData with items", Object.keys(newFields)); + // work out what bucket. + for (let [name, value] of Iterator(newFields)) { + if (FXA_PWDMGR_PLAINTEXT_FIELDS.indexOf(name) >= 0) { + if (value == null) { + delete this.cachedPlain[name]; + } else { + this.cachedPlain[name] = value; + } + } else { + // don't do the "delete on null" thing here - we need to keep it until + // we have managed to read so we can nuke it on write. + this.cachedSecure[name] = value; + } + } + // If we haven't yet read the secure data, do so now, else we may write + // out partial data. + yield this._maybeReadAndUpdateSecure(); + // Now save it - but don't wait on the _write promise - it's queued up as + // a storage operation, so .finalize() will wait for completion, but no need + // for us to. + this._write(); + }), + + _clearCachedData() { + this.cachedPlain = {}; + // If we don't have secure storage available we have cachedPlain and + // cachedSecure be the same object. + this.cachedSecure = this.secureStorage == null ? this.cachedPlain : {}; + }, + + /* Reads the plain storage and caches the read values in this.cachedPlain. + Only ever called once and unlike the "secure" storage, is expected to never + fail (ie, plain storage is considered always available, whereas secure + storage may be unavailable if it is locked). + + Returns a promise that resolves with true if valid account data was found, + false otherwise. + + Note: _readPlainStorage is only called during initialize, so isn't + protected via _queueStorageOperation() nor _promiseInitialized. + */ + _readPlainStorage: Task.async(function* () { + let got; + try { + got = yield this.plainStorage.get(); + } catch(err) { + // File hasn't been created yet. That will be done + // when write is called. + if (!(err instanceof OS.File.Error) || !err.becauseNoSuchFile) { + log.error("Failed to read plain storage", err); + } + // either way, we return null. + got = null; + } + if (!got || !got.accountData || !got.accountData.uid || + got.version != DATA_FORMAT_VERSION) { + return false; + } + // We need to update our .cachedPlain, but can't just assign to it as + // it may need to be the exact same object as .cachedSecure + // As a sanity check, .cachedPlain must be empty (as we are called by init) + // XXX - this would be a good use-case for a RuntimeAssert or similar, as + // being added in bug 1080457. + if (Object.keys(this.cachedPlain).length != 0) { + throw new Error("should be impossible to have cached data already.") + } + for (let [name, value] of Iterator(got.accountData)) { + this.cachedPlain[name] = value; + } + return true; + }), + + /* If we haven't managed to read the secure storage, try now, so + we can merge our cached data with the data that's already been set. + */ + _maybeReadAndUpdateSecure: Task.async(function* () { + if (this.secureStorage == null || !this._needToReadSecure) { + return; + } + return this._queueStorageOperation(() => { + if (this._needToReadSecure) { // we might have read it by now! + return this._doReadAndUpdateSecure(); + } + }); + }), + + /* Unconditionally read the secure storage and merge our cached data (ie, data + which has already been set while the secure storage was locked) with + the read data + */ + _doReadAndUpdateSecure: Task.async(function* () { + let { uid, email } = this.cachedPlain; + try { + log.debug("reading secure storage with existing", Object.keys(this.cachedSecure)); + // If we already have anything in .cachedSecure it means something has + // updated cachedSecure before we've read it. That means that after we do + // manage to read we must write back the merged data. + let needWrite = Object.keys(this.cachedSecure).length != 0; + let readSecure = yield this.secureStorage.get(uid, email); + // and update our cached data with it - anything already in .cachedSecure + // wins (including the fact it may be null or undefined, the latter + // which means it will be removed from storage. + if (readSecure && readSecure.version != DATA_FORMAT_VERSION) { + log.warn("got secure data but the data format version doesn't match"); + readSecure = null; + } + if (readSecure && readSecure.accountData) { + log.debug("secure read fetched items", Object.keys(readSecure.accountData)); + for (let [name, value] of Iterator(readSecure.accountData)) { + if (!(name in this.cachedSecure)) { + this.cachedSecure[name] = value; + } + } + if (needWrite) { + log.debug("successfully read secure data; writing updated data back") + yield this._doWriteSecure(); + } + } + this._needToReadSecure = false; + } catch (ex if ex instanceof this.secureStorage.STORAGE_LOCKED) { + log.debug("setAccountData: secure storage is locked trying to read"); + } catch (ex) { + log.error("failed to read secure storage", ex); + throw ex; + } + }), + + _write() { + // We don't want multiple writes happening concurrently, and we also need to + // know when an "old" storage manager is done (this.finalize() waits for this) + return this._queueStorageOperation(() => this.__write()); + }, + + __write: Task.async(function* () { + // Write everything back - later we could track what's actually dirty, + // but for now we write it all. + log.debug("writing plain storage", Object.keys(this.cachedPlain)); + let toWritePlain = { + version: DATA_FORMAT_VERSION, + accountData: this.cachedPlain, + } + yield this.plainStorage.set(toWritePlain); + + // If we have no secure storage manager we are done. + if (this.secureStorage == null) { + return; + } + // and only attempt to write to secure storage if we've managed to read it, + // otherwise we might clobber data that's already there. + if (!this._needToReadSecure) { + yield this._doWriteSecure(); + } + }), + + /* Do the actual write of secure data. Caller is expected to check if we actually + need to write and to ensure we are in a queued storage operation. + */ + _doWriteSecure: Task.async(function* () { + // We need to remove null items here. + for (let [name, value] of Iterator(this.cachedSecure)) { + if (value == null) { + delete this.cachedSecure[name]; + } + } + log.debug("writing secure storage", Object.keys(this.cachedSecure)); + let toWriteSecure = { + version: DATA_FORMAT_VERSION, + accountData: this.cachedSecure, + } + try { + yield this.secureStorage.set(this.cachedPlain.email, toWriteSecure); + } catch (ex if ex instanceof this.secureStorage.STORAGE_LOCKED) { + // This shouldn't be possible as once it is unlocked it can't be + // re-locked, and we can only be here if we've previously managed to + // read. + log.error("setAccountData: secure storage is locked trying to write"); + } + }), + + // Delete the data for an account - ie, called on "sign out". + deleteAccountData() { + return this._queueStorageOperation(() => this._deleteAccountData()); + }, + + _deleteAccountData: Task.async(function() { + log.debug("removing account data"); + yield this._promiseInitialized; + yield this.plainStorage.set(null); + if (this.secureStorage) { + yield this.secureStorage.set(null); + } + this._clearCachedData(); + log.debug("account data reset"); + }), +} + +/** + * JSONStorage constructor that creates instances that may set/get + * to a specified file, in a directory that will be created if it + * doesn't exist. + * + * @param options { + * filename: of the file to write to + * baseDir: directory where the file resides + * } + * @return instance + */ +function JSONStorage(options) { + this.baseDir = options.baseDir; + this.path = OS.Path.join(options.baseDir, options.filename); +}; + +JSONStorage.prototype = { + set: function(contents) { + log.trace("starting write of json user data", contents ? Object.keys(contents.accountData) : "null"); + let start = Date.now(); + return OS.File.makeDir(this.baseDir, {ignoreExisting: true}) + .then(CommonUtils.writeJSON.bind(null, contents, this.path)) + .then(result => { + log.trace("finished write of json user data - took", Date.now()-start); + return result; + }); + }, + + get: function() { + log.trace("starting fetch of json user data"); + let start = Date.now(); + return CommonUtils.readJSON(this.path).then(result => { + log.trace("finished fetch of json user data - took", Date.now()-start); + return result; + }); + }, +}; + +function StorageLockedError() { +} +/** + * LoginManagerStorage constructor that creates instances that set/get + * data stored securely in the nsILoginManager. + * + * @return instance + */ + +function LoginManagerStorage() { +} + +LoginManagerStorage.prototype = { + STORAGE_LOCKED: StorageLockedError, + // The fields in the credentials JSON object that are stored in plain-text + // in the profile directory. All other fields are stored in the login manager, + // and thus are only available when the master-password is unlocked. + + // a hook point for testing. + get _isLoggedIn() { + return Services.logins.isLoggedIn; + }, + + // Clear any data from the login manager. Returns true if the login manager + // was unlocked (even if no existing logins existed) or false if it was + // locked (meaning we don't even know if it existed or not.) + _clearLoginMgrData: Task.async(function* () { + try { // Services.logins might be third-party and broken... + yield Services.logins.initializationPromise; + if (!this._isLoggedIn) { + return false; + } + let logins = Services.logins.findLogins({}, FXA_PWDMGR_HOST, null, FXA_PWDMGR_REALM); + for (let login of logins) { + Services.logins.removeLogin(login); + } + return true; + } catch (ex) { + log.error("Failed to clear login data: ${}", ex); + return false; + } + }), + + set: Task.async(function* (email, contents) { + if (!contents) { + // Nuke it from the login manager. + let cleared = yield this._clearLoginMgrData(); + if (!cleared) { + // just log a message - we verify that the uid matches when + // we reload it, so having a stale entry doesn't really hurt. + log.info("not removing credentials from login manager - not logged in"); + } + log.trace("storage set finished clearing account data"); + return; + } + + // We are saving actual data. + log.trace("starting write of user data to the login manager"); + try { // Services.logins might be third-party and broken... + // and the stuff into the login manager. + yield Services.logins.initializationPromise; + // If MP is locked we silently fail - the user may need to re-auth + // next startup. + if (!this._isLoggedIn) { + log.info("not saving credentials to login manager - not logged in"); + throw new this.STORAGE_LOCKED(); + } + // write the data to the login manager. + let loginInfo = new Components.Constructor( + "@mozilla.org/login-manager/loginInfo;1", Ci.nsILoginInfo, "init"); + let login = new loginInfo(FXA_PWDMGR_HOST, + null, // aFormSubmitURL, + FXA_PWDMGR_REALM, // aHttpRealm, + email, // aUsername + JSON.stringify(contents), // aPassword + "", // aUsernameField + "");// aPasswordField + + let existingLogins = Services.logins.findLogins({}, FXA_PWDMGR_HOST, null, + FXA_PWDMGR_REALM); + if (existingLogins.length) { + Services.logins.modifyLogin(existingLogins[0], login); + } else { + Services.logins.addLogin(login); + } + log.trace("finished write of user data to the login manager"); + } catch (ex if ex instanceof this.STORAGE_LOCKED) { + throw ex; + } catch (ex) { + // just log and consume the error here - it may be a 3rd party login + // manager replacement that's simply broken. + log.error("Failed to save data to the login manager", ex); + } + }), + + get: Task.async(function* (uid, email) { + log.trace("starting fetch of user data from the login manager"); + + try { // Services.logins might be third-party and broken... + // read the data from the login manager and merge it for return. + yield Services.logins.initializationPromise; + + if (!this._isLoggedIn) { + log.info("returning partial account data as the login manager is locked."); + throw new this.STORAGE_LOCKED(); + } + + let logins = Services.logins.findLogins({}, FXA_PWDMGR_HOST, null, FXA_PWDMGR_REALM); + if (logins.length == 0) { + // This could happen if the MP was locked when we wrote the data. + log.info("Can't find any credentials in the login manager"); + return null; + } + let login = logins[0]; + // Support either the uid or the email as the username - we plan to move + // to storing the uid once Fx41 hits the release channel as the code below + // that handles either first landed in 41. Bug 1183951 is to store the uid. + if (login.username == uid || login.username == email) { + return JSON.parse(login.password); + } + log.info("username in the login manager doesn't match - ignoring it"); + yield this._clearLoginMgrData(); + } catch (ex if ex instanceof this.STORAGE_LOCKED) { + throw ex; + } catch (ex) { + // just log and consume the error here - it may be a 3rd party login + // manager replacement that's simply broken. + log.error("Failed to get data from the login manager", ex); + } + return null; + }), +} + +// A global variable to indicate if the login manager is available - it doesn't +// exist on b2g. Defined here as the use of preprocessor directives skews line +// numbers in the runtime, meaning stack-traces etc end up off by a few lines. +// Doing it at the end of the file makes that less of a pita. +let haveLoginManager = +#if defined(MOZ_B2G) + false; +#else + true; +#endif
--- a/services/fxaccounts/moz.build +++ b/services/fxaccounts/moz.build @@ -7,24 +7,25 @@ DIRS += ['interfaces'] MOCHITEST_CHROME_MANIFESTS += ['tests/mochitest/chrome.ini'] XPCSHELL_TESTS_MANIFESTS += ['tests/xpcshell/xpcshell.ini'] EXTRA_JS_MODULES += [ 'Credentials.jsm', + 'FxAccounts.jsm', 'FxAccountsClient.jsm', 'FxAccountsCommon.js', 'FxAccountsOAuthClient.jsm', 'FxAccountsOAuthGrantClient.jsm', 'FxAccountsProfile.jsm', 'FxAccountsProfileClient.jsm', 'FxAccountsWebChannel.jsm', ] EXTRA_PP_JS_MODULES += [ - 'FxAccounts.jsm', + 'FxAccountsStorage.jsm', ] # For now, we will only be using the FxA manager in B2G. if CONFIG['MOZ_B2G']: EXTRA_JS_MODULES += ['FxAccountsManager.jsm']
--- a/services/fxaccounts/tests/xpcshell/test_accounts.js +++ b/services/fxaccounts/tests/xpcshell/test_accounts.js @@ -7,16 +7,19 @@ Cu.import("resource://services-common/ut 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/FxAccountsOAuthGrantClient.jsm"); Cu.import("resource://gre/modules/Promise.jsm"); Cu.import("resource://gre/modules/Log.jsm"); +// We grab some additional stuff via backstage passes. +let {AccountState} = Cu.import("resource://gre/modules/FxAccounts.jsm", {}); + const ONE_HOUR_MS = 1000 * 60 * 60; const ONE_DAY_MS = ONE_HOUR_MS * 24; const TWO_MINUTES_MS = 1000 * 60 * 2; initTestLogging("Trace"); // XXX until bug 937114 is fixed Cu.importGlobalProperties(['atob']); @@ -42,16 +45,52 @@ Services.prefs.setCharPref("identity.fxa /* * The FxAccountsClient communicates with the remote Firefox * Accounts auth server. Mock the server calls, with a little * lag time to simulate some latency. * * We add the _verified attribute to mock the change in verification * state on the FXA server. */ + +function MockStorageManager() { +} + +MockStorageManager.prototype = { + promiseInitialized: Promise.resolve(), + + 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() { 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) { @@ -91,52 +130,38 @@ function MockFxAccountsClient() { this.signOut = function() { return Promise.resolve(); }; FxAccountsClient.apply(this); } MockFxAccountsClient.prototype = { __proto__: FxAccountsClient.prototype } -let MockStorage = function() { - this.data = null; -}; -MockStorage.prototype = Object.freeze({ - set: function (contents) { - this.data = contents; - return Promise.resolve(null); - }, - get: function () { - return Promise.resolve(this.data); - }, - getOAuthTokens() { - return Promise.resolve(null); - }, - setOAuthTokens(contents) { - return Promise.resolve(); - }, -}); - /* * We need to mock the FxAccounts module's interfaces to external * services, such as storage and the FxAccounts client. We also * mock the now() method, so that we can simulate the passing of * time and verify that signatures expire correctly. */ function MockFxAccounts() { return new FxAccounts({ VERIFICATION_POLL_TIMEOUT_INITIAL: 100, // 100ms _getCertificateSigned_calls: [], _d_signCertificate: Promise.defer(), _now_is: new Date(), - signedInUserStorage: new MockStorage(), now: function () { return this._now_is; }, + newAccountState(credentials) { + // we use a real accountState but mocked storage. + let storage = new MockStorageManager(); + storage.initialize(credentials); + return new AccountState(this, storage); + }, getCertificateSigned: function (sessionToken, serializedPublicKey) { _("mock getCertificateSigned\n"); this._getCertificateSigned_calls.push([sessionToken, serializedPublicKey]); return this._d_signCertificate.promise; }, fxAccountsClient: new MockFxAccountsClient() }); } @@ -167,32 +192,33 @@ add_test(function test_non_https_remote_ Services.prefs.clearUserPref("identity.fxaccounts.remote.signup.uri"); run_next_test(); }); add_task(function test_get_signed_in_user_initially_unset() { // This test, unlike many of the the rest, uses a (largely) un-mocked // FxAccounts instance. - // We do mock the storage to keep the test fast on b2g. let account = new FxAccounts({ - signedInUserStorage: new MockStorage(), + newAccountState(credentials) { + // we use a real accountState but mocked storage. + let storage = new MockStorageManager(); + storage.initialize(credentials); + return new AccountState(this, storage); + }, }); let credentials = { email: "foo@example.com", uid: "1234@lcip.org", assertion: "foobar", sessionToken: "dead", kA: "beef", kB: "cafe", verified: true }; - // and a sad hack to ensure the mocked storage is used for the initial reads. - account.internal.currentAccountState.signedInUserStorage = account.internal.signedInUserStorage; - let result = yield account.getSignedInUser(); do_check_eq(result, null); yield account.setSignedInUser(credentials); result = yield account.getSignedInUser(); do_check_eq(result.email, credentials.email); do_check_eq(result.assertion, credentials.assertion); @@ -216,29 +242,32 @@ add_task(function test_get_signed_in_use }); add_task(function* test_getCertificate() { _("getCertificate()"); // This test, unlike many of the the rest, uses a (largely) un-mocked // FxAccounts instance. // We do mock the storage to keep the test fast on b2g. let fxa = new FxAccounts({ - signedInUserStorage: new MockStorage(), + newAccountState(credentials) { + // we use a real accountState but mocked storage. + let storage = new MockStorageManager(); + storage.initialize(credentials); + return new AccountState(this, storage); + }, }); let credentials = { email: "foo@example.com", uid: "1234@lcip.org", assertion: "foobar", sessionToken: "dead", kA: "beef", kB: "cafe", verified: true }; - // and a sad hack to ensure the mocked storage is used for the initial reads. - fxa.internal.currentAccountState.signedInUserStorage = fxa.internal.signedInUserStorage; yield fxa.setSignedInUser(credentials); // Test that an expired cert throws if we're offline. fxa.internal.currentAccountState.cert = { validUntil: Date.parse("Mon, 13 Jan 2000 21:45:06 GMT") }; let offline = Services.io.offline; Services.io.offline = true; @@ -809,17 +838,16 @@ add_task(function* test_getOAuthTokenCac do_check_eq(numTokenFromAssertionCalls, 1); do_check_eq(result, "token"); // But requesting with a new entry in the array does fetch one. result = yield fxa.getOAuthToken({ scope: ["foo", "bar", "etc"], client: client, service: "test-service" }); do_check_eq(numTokenFromAssertionCalls, 2); do_check_eq(result, "token"); }); - Services.prefs.setCharPref("identity.fxaccounts.remote.oauth.uri", "https://example.com/v1"); add_test(function test_getOAuthToken_invalid_param() { let fxa = new MockFxAccounts(); fxa.getOAuthToken() .then(null, err => { do_check_eq(err.message, "INVALID_PARAMETER"); fxa.signOut().then(run_next_test); @@ -962,23 +990,23 @@ add_test(function test_getOAuthToken_unk add_test(function test_getSignedInUserProfile() { let alice = getTestUser("alice"); alice.verified = true; let mockProfile = { getProfile: function () { return Promise.resolve({ avatar: "image" }); - } + }, + tearDown: function() {}, }; - let fxa = new FxAccounts({ - _profile: mockProfile, - }); + let fxa = new FxAccounts({}); 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(); }); }); });
--- a/services/fxaccounts/tests/xpcshell/test_loginmgr_storage.js +++ b/services/fxaccounts/tests/xpcshell/test_loginmgr_storage.js @@ -2,28 +2,39 @@ * http://creativecommons.org/publicdomain/zero/1.0/ */ "use strict"; // Tests for FxAccounts, storage and the master password. // Stop us hitting the real auth server. Services.prefs.setCharPref("identity.fxaccounts.auth.uri", "http://localhost"); +// See verbose logging from FxAccounts.jsm +Services.prefs.setCharPref("identity.fxaccounts.loglevel", "Trace"); 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/osfile.jsm"); Cu.import("resource://services-common/utils.js"); Cu.import("resource://gre/modules/FxAccountsCommon.js"); +// Use a backstage pass to get at our LoginManagerStorage object, so we can +// mock the prototype. +let {LoginManagerStorage} = Cu.import("resource://gre/modules/FxAccountsStorage.jsm", {}); +let isLoggedIn = true; +LoginManagerStorage.prototype.__defineGetter__("_isLoggedIn", () => isLoggedIn); + +function setLoginMgrLoggedInState(loggedIn) { + isLoggedIn = loggedIn; +} + + initTestLogging("Trace"); -// See verbose logging from FxAccounts.jsm -Services.prefs.setCharPref("identity.fxaccounts.loglevel", "DEBUG"); function run_test() { run_next_test(); } function getLoginMgrData() { let logins = Services.logins.findLogins({}, FXA_PWDMGR_HOST, null, FXA_PWDMGR_REALM); if (logins.length == 0) { @@ -32,16 +43,17 @@ function getLoginMgrData() { Assert.equal(logins.length, 1, "only 1 login available"); return logins[0]; } add_task(function test_simple() { let fxa = new FxAccounts({}); let creds = { + uid: "abcd", email: "test@example.com", sessionToken: "sessionToken", kA: "the kA value", kB: "the kB value", verified: true }; yield fxa.setSignedInUser(creds); @@ -53,17 +65,17 @@ add_task(function test_simple() { Assert.strictEqual(data.accountData.email, creds.email, "correct email in the clear text"); Assert.strictEqual(data.accountData.sessionToken, creds.sessionToken, "correct sessionToken in the clear text"); Assert.strictEqual(data.accountData.verified, creds.verified, "correct verified flag"); Assert.ok(!("kA" in data.accountData), "kA not stored in clear text"); Assert.ok(!("kB" in data.accountData), "kB not stored in clear text"); let login = getLoginMgrData(); - Assert.strictEqual(login.username, creds.email, "email matches"); + Assert.strictEqual(login.username, creds.email, "email used for username"); let loginData = JSON.parse(login.password); Assert.strictEqual(loginData.version, data.version, "same version flag in both places"); Assert.strictEqual(loginData.accountData.kA, creds.kA, "correct kA in the login mgr"); Assert.strictEqual(loginData.accountData.kB, creds.kB, "correct kB in the login mgr"); 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"); @@ -71,25 +83,27 @@ add_task(function test_simple() { yield fxa.signOut(/* localOnly = */ true); Assert.strictEqual(getLoginMgrData(), null, "login mgr data deleted on logout"); }); add_task(function test_MPLocked() { let fxa = new FxAccounts({}); let creds = { + uid: "abcd", email: "test@example.com", sessionToken: "sessionToken", kA: "the kA value", kB: "the kB value", verified: true }; + Assert.strictEqual(getLoginMgrData(), null, "no login mgr at the start"); // tell the storage that the MP is locked. - fxa.internal.signedInUserStorage.__defineGetter__("_isLoggedIn", () => false); + setLoginMgrLoggedInState(false); yield fxa.setSignedInUser(creds); // This should have stored stuff in the .json, and the login manager stuff // will not exist. let path = OS.Path.join(OS.Constants.Path.profileDir, "signedInUser.json"); let data = yield CommonUtils.readJSON(path); Assert.strictEqual(data.accountData.email, creds.email, "correct email in the clear text"); @@ -98,212 +112,86 @@ 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_migrationMPUnlocked() { - // first manually save a signedInUser.json to simulate a first-run with - // pre-migrated data. - let fxa = new FxAccounts({}); - - let creds = { - email: "test@example.com", - sessionToken: "sessionToken", - kA: "the kA value", - kB: "the kB value", - verified: true - }; - let toWrite = { - version: fxa.version, - accountData: creds, - } - - let path = OS.Path.join(OS.Constants.Path.profileDir, "signedInUser.json"); - yield CommonUtils.writeJSON(toWrite, path); - - // now load it - it should migrate. - let data = yield fxa.getSignedInUser(); - Assert.deepEqual(data, creds, "we got all the data back"); - - // and verify it was actually migrated - re-read signedInUser back. - data = yield CommonUtils.readJSON(path); - - Assert.strictEqual(data.accountData.email, creds.email, "correct email in the clear text"); - Assert.strictEqual(data.accountData.sessionToken, creds.sessionToken, "correct sessionToken in the clear text"); - Assert.strictEqual(data.accountData.verified, creds.verified, "correct verified flag"); - - Assert.ok(!("kA" in data.accountData), "kA not stored in clear text"); - Assert.ok(!("kB" in data.accountData), "kB not stored in clear text"); - - let login = getLoginMgrData(); - Assert.strictEqual(login.username, creds.email, "email matches"); - let loginData = JSON.parse(login.password); - Assert.strictEqual(loginData.version, data.version, "same version flag in both places"); - Assert.strictEqual(loginData.accountData.kA, creds.kA, "correct kA in the login mgr"); - Assert.strictEqual(loginData.accountData.kB, creds.kB, "correct kB in the login mgr"); - - 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_migrationMPLocked() { - // first manually save a signedInUser.json to simulate a first-run with - // pre-migrated data. - let fxa = new FxAccounts({}); - - let creds = { - email: "test@example.com", - sessionToken: "sessionToken", - kA: "the kA value", - kB: "the kB value", - verified: true - }; - let toWrite = { - version: fxa.version, - accountData: creds, - } - - let path = OS.Path.join(OS.Constants.Path.profileDir, "signedInUser.json"); - yield CommonUtils.writeJSON(toWrite, path); - - // pretend the MP is locked. - fxa.internal.signedInUserStorage.__defineGetter__("_isLoggedIn", () => false); - - // now load it - it should *not* migrate, but should only give the JSON-safe - // data back. - let data = yield fxa.getSignedInUser(); - Assert.ok(!data.kA); - Assert.ok(!data.kB); - - // and verify the data on disk wan't migrated. - data = yield CommonUtils.readJSON(path); - Assert.deepEqual(data, toWrite); - - // Now "unlock" and re-ask for the signedInUser - it should migrate. - fxa.internal.signedInUserStorage.__defineGetter__("_isLoggedIn", () => true); - data = yield fxa.getSignedInUser(); - // this time we should have got all the data, not just the JSON-safe fields. - Assert.strictEqual(data.kA, creds.kA); - Assert.strictEqual(data.kB, creds.kB); - - // And verify the data in the JSON was migrated - data = yield CommonUtils.readJSON(path); - Assert.strictEqual(data.accountData.email, creds.email, "correct email in the clear text"); - Assert.strictEqual(data.accountData.sessionToken, creds.sessionToken, "correct sessionToken in the clear text"); - Assert.strictEqual(data.accountData.verified, creds.verified, "correct verified flag"); - - Assert.ok(!("kA" in data.accountData), "kA not stored in clear text"); - Assert.ok(!("kB" in data.accountData), "kB not stored in clear text"); - - let login = getLoginMgrData(); - Assert.strictEqual(login.username, creds.email, "email matches"); - let loginData = JSON.parse(login.password); - Assert.strictEqual(loginData.version, data.version, "same version flag in both places"); - Assert.strictEqual(loginData.accountData.kA, creds.kA, "correct kA in the login mgr"); - Assert.strictEqual(loginData.accountData.kB, creds.kB, "correct kB in the login mgr"); - - 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_consistentWithMPEdgeCases() { + setLoginMgrLoggedInState(true); + let fxa = new FxAccounts({}); let creds1 = { + uid: "uid1", email: "test@example.com", sessionToken: "sessionToken", kA: "the kA value", kB: "the kB value", verified: true }; let creds2 = { + uid: "uid2", email: "test2@example.com", sessionToken: "sessionToken2", kA: "the kA value2", kB: "the kB value2", verified: false, }; // Log a user in while MP is unlocked. yield fxa.setSignedInUser(creds1); // tell the storage that the MP is locked - this will prevent logout from // being able to clear the data. - fxa.internal.signedInUserStorage.__defineGetter__("_isLoggedIn", () => false); + setLoginMgrLoggedInState(false); // now set the second credentials. yield fxa.setSignedInUser(creds2); // We should still have creds1 data in the login manager. let login = getLoginMgrData(); 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.) - // Because we haven't overridden _isLoggedIn for this new instance it will - // treat the MP as unlocked. + // 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({}); 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) }); -add_task(function test_migration() { - // manually write out the full creds data to the JSON - this will look like - // old data that needs migration. - let creds = { - email: "test@example.com", - sessionToken: "sessionToken", - kA: "the kA value", - kB: "the kB value", - verified: true - }; - let toWrite = { - version: 1, - accountData: creds, - }; +// A test for the fact we will accept either a UID or email when looking in +// the login manager. +add_task(function test_uidMigration() { + setLoginMgrLoggedInState(true); + Assert.strictEqual(getLoginMgrData(), null, "expect no logins at the start"); - let path = OS.Path.join(OS.Constants.Path.profileDir, "signedInUser.json"); - let data = yield CommonUtils.writeJSON(toWrite, path); - - // Create an FxA object - and tell it to load the data. - let fxa = new FxAccounts({}); - data = yield fxa.getSignedInUser(); + // create the login entry using uid as a key. + let contents = {kA: "kA"}; - Assert.deepEqual(data, creds, "we should have everything available"); - - // now sniff the data on disk - it should have been magically migrated. - data = yield CommonUtils.readJSON(path); - - Assert.strictEqual(data.accountData.email, creds.email, "correct email in the clear text"); - Assert.strictEqual(data.accountData.sessionToken, creds.sessionToken, "correct sessionToken in the clear text"); - Assert.strictEqual(data.accountData.verified, creds.verified, "correct verified flag"); + let loginInfo = new Components.Constructor( + "@mozilla.org/login-manager/loginInfo;1", Ci.nsILoginInfo, "init"); + let login = new loginInfo(FXA_PWDMGR_HOST, + null, // aFormSubmitURL, + FXA_PWDMGR_REALM, // aHttpRealm, + "uid", // aUsername + JSON.stringify(contents), // aPassword + "", // aUsernameField + "");// aPasswordField + Services.logins.addLogin(login); - Assert.ok(!("kA" in data.accountData), "kA not stored in clear text"); - Assert.ok(!("kB" in data.accountData), "kB not stored in clear text"); - - // and it should magically be in the login manager. - let login = getLoginMgrData(); - Assert.strictEqual(login.username, creds.email); - // and that we do have the first kA in the login manager. - Assert.strictEqual(JSON.parse(login.password).accountData.kA, creds.kA, - "kA was migrated"); - - yield fxa.signOut(/* localOnly = */ true) + // ensure we read it. + let storage = new LoginManagerStorage(); + let got = yield storage.get("uid", "foo@bar.com"); + Assert.deepEqual(got, contents); });
--- a/services/fxaccounts/tests/xpcshell/test_oauth_token_storage.js +++ b/services/fxaccounts/tests/xpcshell/test_oauth_token_storage.js @@ -3,26 +3,66 @@ "use strict"; 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/osfile.jsm"); +// We grab some additional stuff via backstage passes. +let {AccountState} = Cu.import("resource://gre/modules/FxAccounts.jsm", {}); + function promiseNotification(topic) { return new Promise(resolve => { let observe = () => { Services.obs.removeObserver(observe, topic); resolve(); } Services.obs.addObserver(observe, topic, false); }); } +// A storage manager that doesn't actually write anywhere. +function MockStorageManager() { +} + +MockStorageManager.prototype = { + promiseInitialized: Promise.resolve(), + + 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(); + } +} + + // Just enough mocks so we can avoid hawk etc. function MockFxAccountsClient() { this._email = "nobody@example.com"; this._verified = false; this.accountStatus = function(uid) { let deferred = Promise.defer(); deferred.resolve(!!uid && (!this._deletedOnServer)); @@ -36,16 +76,22 @@ function MockFxAccountsClient() { MockFxAccountsClient.prototype = { __proto__: FxAccountsClient.prototype } function MockFxAccounts() { 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(this, storage); + }, }); } function* createMockFxA() { let fxa = new MockFxAccounts(); let credentials = { email: "foo@example.com", uid: "1234@lcip.org", @@ -77,137 +123,27 @@ add_task(function testCacheStorage() { }; let promiseWritten = promiseNotification("testhelper-fxa-cache-persist-done"); let tokenData = {token: "token1", somethingelse: "something else"}; let scopeArray = ["foo", "bar"]; cas.setCachedToken(scopeArray, tokenData); deepEqual(cas.getCachedToken(scopeArray), tokenData); - deepEqual(cas.getAllCachedTokens(), [tokenData]); + deepEqual(cas.oauthTokens, {"bar|foo": tokenData}); // wait for background write to complete. yield promiseWritten; - // Check the token cache was written to signedInUserOAuthTokens.json. - let path = OS.Path.join(OS.Constants.Path.profileDir, DEFAULT_OAUTH_TOKENS_FILENAME); - let data = yield CommonUtils.readJSON(path); - ok(data.tokens, "the data is in the json"); - equal(data.uid, "1234@lcip.org", "The user's uid is in the json"); - - // Check it's all in the json. - let expectedKey = "bar|foo"; - let entry = data.tokens[expectedKey]; - ok(entry, "our key is in the json"); - deepEqual(entry, tokenData, "correct token is in the json"); + // Check the token cache made it to our mocked storage. + deepEqual(cas.storageManager.accountData.oauthTokens, {"bar|foo": tokenData}); // Drop the token from the cache and ensure it is removed from the json. promiseWritten = promiseNotification("testhelper-fxa-cache-persist-done"); yield cas.removeCachedToken("token1"); - deepEqual(cas.getAllCachedTokens(), []); + deepEqual(cas.oauthTokens, {}); yield promiseWritten; - data = yield CommonUtils.readJSON(path); - ok(!data.tokens[expectedKey], "our key was removed from the json"); + deepEqual(cas.storageManager.accountData.oauthTokens, {}); // sign out and the token storage should end up with null. + let storageManager = cas.storageManager; // .signOut() removes the attribute. yield fxa.signOut( /* localOnly = */ true); - data = yield CommonUtils.readJSON(path); - ok(data === null, "data wiped on signout"); -}); - -// Test that the tokens are available after a full read of credentials from disk. -add_task(function testCacheAfterRead() { - 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); - }); - }; - - let promiseWritten = promiseNotification("testhelper-fxa-cache-persist-done"); - let tokenData = {token: "token1", somethingelse: "something else"}; - let scopeArray = ["foo", "bar"]; - cas.setCachedToken(scopeArray, tokenData); - yield promiseWritten; - - // trick things so the data is re-read from disk. - cas.signedInUser = null; - cas.oauthTokens = null; - yield cas.getUserAccountData(); - ok(cas.oauthTokens, "token data was re-read"); - deepEqual(cas.getCachedToken(scopeArray), tokenData); + deepEqual(storageManager.accountData, null); }); - -// Test that the tokens are saved after we read user credentials from disk. -add_task(function testCacheAfterRead() { - let fxa = yield createMockFxA(); - // Hook what the impl calls to save to disk. - let cas = fxa.internal.currentAccountState; - let origPersistCached = cas._persistCachedTokens.bind(cas) - - // trick things so that FxAccounts is in the mode where we're reading data - // from disk each time getSignedInUser() is called (ie, where .signedInUser - // remains null) - cas.signedInUser = null; - cas.oauthTokens = null; - - yield cas.getUserAccountData(); - - // hook our "persist" function. - cas._persistCachedTokens = function() { - return origPersistCached().then(() => { - Services.obs.notifyObservers(null, "testhelper-fxa-cache-persist-done", null); - }); - }; - let promiseWritten = promiseNotification("testhelper-fxa-cache-persist-done"); - - // save a new token - it should be persisted. - let tokenData = {token: "token1", somethingelse: "something else"}; - let scopeArray = ["foo", "bar"]; - cas.setCachedToken(scopeArray, tokenData); - - yield promiseWritten; - - // re-read the tokens directly from the storage to ensure they were persisted. - let got = yield cas.signedInUserStorage.getOAuthTokens(); - ok(got, "got persisted data"); - ok(got.tokens, "have tokens"); - // this is internal knowledge of how scopes get turned into "keys", but that's OK - ok(got.tokens["bar|foo"], "have our scope"); - equal(got.tokens["bar|foo"].token, "token1", "have our token"); -}); - -// Test that the tokens are ignored when the token storage has an incorrect uid. -add_task(function testCacheAfterReadBadUID() { - 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); - }); - }; - - let promiseWritten = promiseNotification("testhelper-fxa-cache-persist-done"); - let tokenData = {token: "token1", somethingelse: "something else"}; - let scopeArray = ["foo", "bar"]; - cas.setCachedToken(scopeArray, tokenData); - yield promiseWritten; - - // trick things so the data is re-read from disk. - cas.signedInUser = null; - cas.oauthTokens = null; - - // re-write the tokens data with an invalid UID. - let path = OS.Path.join(OS.Constants.Path.profileDir, DEFAULT_OAUTH_TOKENS_FILENAME); - let data = yield CommonUtils.readJSON(path); - ok(data.tokens, "the data is in the json"); - equal(data.uid, "1234@lcip.org", "The user's uid is in the json"); - data.uid = "someone_else"; - yield CommonUtils.writeJSON(data, path); - - yield cas.getUserAccountData(); - deepEqual(cas.oauthTokens, {}, "token data ignored due to bad uid"); - equal(null, cas.getCachedToken(scopeArray), "no token available"); -});
new file mode 100644 --- /dev/null +++ b/services/fxaccounts/tests/xpcshell/test_storage_manager.js @@ -0,0 +1,407 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Tests for the FxA storage manager. + +Cu.import("resource://gre/modules/Task.jsm"); +Cu.import("resource://gre/modules/Services.jsm"); +Cu.import("resource://gre/modules/FxAccountsStorage.jsm"); +Cu.import("resource://gre/modules/FxAccountsCommon.js"); +Cu.import("resource://gre/modules/Log.jsm"); + +initTestLogging("Trace"); +log.level = Log.Level.Trace; + +// A couple of mocks we can use. +function MockedPlainStorage(accountData) { + let data = null; + if (accountData) { + data = { + version: DATA_FORMAT_VERSION, + accountData: accountData, + } + } + this.data = data; + this.numReads = 0; +} +MockedPlainStorage.prototype = { + get: Task.async(function* () { + this.numReads++; + Assert.equal(this.numReads, 1, "should only ever be 1 read of acct data"); + return this.data; + }), + + set: Task.async(function* (data) { + this.data = data; + }), +}; + +function MockedSecureStorage(accountData) { + let data = null; + if (accountData) { + data = { + version: DATA_FORMAT_VERSION, + accountData: accountData, + } + } + this.data = data; + this.numReads = 0; +} + +MockedSecureStorage.prototype = { + locked: false, + STORAGE_LOCKED: function() {}, + get: Task.async(function* (uid, email) { + if (this.locked) { + throw new this.STORAGE_LOCKED(); + } + this.numReads++; + Assert.equal(this.numReads, 1, "should only ever be 1 read of unlocked data"); + return this.data; + }), + + set: Task.async(function* (uid, contents) { + this.data = contents; + }), +} + +function add_storage_task(testFunction) { + add_task(function* () { + print("Starting test with secure storage manager"); + yield testFunction(new FxAccountsStorageManager()); + }); + add_task(function* () { + print("Starting test with simple storage manager"); + yield testFunction(new FxAccountsStorageManager({useSecure: false})); + }); +} + +// initialized without account data and there's nothing to read. Not logged in. +add_storage_task(function* checkInitializedEmpty(sm) { + if (sm.secureStorage) { + sm.secureStorage = new MockedSecureStorage(null); + } + yield sm.initialize(); + Assert.strictEqual((yield sm.getAccountData()), null); + Assert.rejects(sm.updateAccountData({foo: "bar"}), "No user is logged in") +}); + +// 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", + }; + 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); + + // 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); + // 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"}) + 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"); + // Update the data - we should be able to fetch it back and it should appear + // in our storage. + yield sm.updateAccountData({verified: true, foo: "bar", kA: "kA"}); + accountData = yield sm.getAccountData(); + Assert.equal(accountData.foo, "bar"); + Assert.equal(accountData.kA, "kA"); + // Check the new value was written to storage. + yield sm._promiseStorageComplete; // storage is written in the background. + // "verified" is a plain-text field. + Assert.equal(sm.plainStorage.data.accountData.verified, true); + // "kA" and "foo" are secure + if (sm.secureStorage) { + Assert.equal(sm.secureStorage.data.accountData.kA, "kA"); + Assert.equal(sm.secureStorage.data.accountData.foo, "bar"); + } else { + Assert.equal(sm.plainStorage.data.accountData.kA, "kA"); + Assert.equal(sm.plainStorage.data.accountData.foo, "bar"); + } +}); + +add_storage_task(function* checkInvalidUpdates(sm) { + sm.plainStorage = new MockedPlainStorage({uid: "uid", email: "someone@somewhere.com"}) + if (sm.secureStorage) { + sm.secureStorage = new MockedSecureStorage(null); + } + Assert.rejects(sm.updateAccountData({uid: "another"}), "Can't change"); + Assert.rejects(sm.updateAccountData({email: "someoneelse"}), "Can't change"); +}); + +add_storage_task(function* checkNullUpdatesRemovedUnlocked(sm) { + if (sm.secureStorage) { + sm.plainStorage = new MockedPlainStorage({uid: "uid", email: "someone@somewhere.com"}) + sm.secureStorage = new MockedSecureStorage({kA: "kA", kB: "kB"}); + } else { + sm.plainStorage = new MockedPlainStorage({uid: "uid", email: "someone@somewhere.com", + kA: "kA", kB: "kB"}); + } + yield sm.initialize(); + + yield sm.updateAccountData({kA: null}); + let accountData = yield sm.getAccountData(); + Assert.ok(!accountData.kA); + Assert.equal(accountData.kB, "kB"); +}); + +add_storage_task(function* checkDelete(sm) { + if (sm.secureStorage) { + sm.plainStorage = new MockedPlainStorage({uid: "uid", email: "someone@somewhere.com"}) + sm.secureStorage = new MockedSecureStorage({kA: "kA", kB: "kB"}); + } else { + sm.plainStorage = new MockedPlainStorage({uid: "uid", email: "someone@somewhere.com", + kA: "kA", kB: "kB"}); + } + yield sm.initialize(); + + yield sm.deleteAccountData(); + // Storage should have been reset to null. + Assert.equal(sm.plainStorage.data, null); + if (sm.secureStorage) { + Assert.equal(sm.secureStorage.data, null); + } + // And everything should reflect no user. + Assert.equal((yield sm.getAccountData()), null); +}); + +// Some tests only for the secure storage manager. +add_task(function* checkNullUpdatesRemovedLocked() { + let sm = new FxAccountsStorageManager(); + sm.plainStorage = new MockedPlainStorage({uid: "uid", email: "someone@somewhere.com"}) + sm.secureStorage = new MockedSecureStorage({kA: "kA", kB: "kB"}); + sm.secureStorage.locked = true; + yield sm.initialize(); + + yield sm.updateAccountData({kA: null}); + let accountData = yield sm.getAccountData(); + Assert.ok(!accountData.kA); + // still no kB as we are locked. + Assert.ok(!accountData.kB); + + // now unlock - should still be no kA but kB should appear. + sm.secureStorage.locked = false; + accountData = yield sm.getAccountData(); + Assert.ok(!accountData.kA); + Assert.equal(accountData.kB, "kB"); + // And secure storage should have been written with our previously-cached + // data. + Assert.strictEqual(sm.secureStorage.data.accountData.kA, undefined); + Assert.strictEqual(sm.secureStorage.data.accountData.kB, "kB"); +}); + +add_task(function* checkEverythingReadSecure() { + let sm = new FxAccountsStorageManager(); + sm.plainStorage = new MockedPlainStorage({uid: "uid", email: "someone@somewhere.com"}) + sm.secureStorage = new MockedSecureStorage({kA: "kA"}); + 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.kA, "kA"); +}); + +add_task(function* checkLockedUpdates() { + let sm = new FxAccountsStorageManager(); + sm.plainStorage = new MockedPlainStorage({uid: "uid", email: "someone@somewhere.com"}) + sm.secureStorage = new MockedSecureStorage({kA: "old-kA", kB: "kB"}); + sm.secureStorage.locked = true; + yield sm.initialize(); + + let accountData = yield sm.getAccountData(); + // requesting kA and kB will fail as storage is locked. + Assert.ok(!accountData.kA); + Assert.ok(!accountData.kB); + // While locked we can still update it and see the updated value. + sm.updateAccountData({kA: "new-kA"}); + accountData = yield sm.getAccountData(); + Assert.equal(accountData.kA, "new-kA"); + // unlock. + sm.secureStorage.locked = false; + accountData = yield sm.getAccountData(); + // should reflect the value we updated and the one we didn't. + Assert.equal(accountData.kA, "new-kA"); + Assert.equal(accountData.kB, "kB"); + // And storage should also reflect it. + Assert.strictEqual(sm.secureStorage.data.accountData.kA, "new-kA"); + Assert.strictEqual(sm.secureStorage.data.accountData.kB, "kB"); +}); + +// Some tests for the "storage queue" functionality. + +// A helper for our queued tests. It creates a StorageManager and then queues +// an unresolved promise. The tests then do additional setup and checks, then +// resolves or rejects the blocked promise. +let setupStorageManagerForQueueTest = Task.async(function* () { + let sm = new FxAccountsStorageManager(); + sm.plainStorage = new MockedPlainStorage({uid: "uid", email: "someone@somewhere.com"}) + sm.secureStorage = new MockedSecureStorage({kA: "kA"}); + sm.secureStorage.locked = true; + yield sm.initialize(); + + let resolveBlocked, rejectBlocked; + let blockedPromise = new Promise((resolve, reject) => { + resolveBlocked = resolve; + rejectBlocked = reject; + }); + + sm._queueStorageOperation(() => blockedPromise); + return {sm, blockedPromise, resolveBlocked, rejectBlocked} +}); + +// First the general functionality. +add_task(function* checkQueueSemantics() { + let { sm, resolveBlocked } = yield setupStorageManagerForQueueTest(); + + // We've one unresolved promise in the queue - add another promise. + let resolveSubsequent; + let subsequentPromise = new Promise(resolve => { + resolveSubsequent = resolve; + }); + let subsequentCalled = false; + + sm._queueStorageOperation(() => { + subsequentCalled = true; + resolveSubsequent(); + return subsequentPromise; + }); + + // Our "subsequent" function should not have been called yet. + Assert.ok(!subsequentCalled); + + // Release our blocked promise. + resolveBlocked(); + + // Our subsequent promise should end up resolved. + yield subsequentPromise; + Assert.ok(subsequentCalled); + yield sm.finalize(); +}); + +// Check that a queued promise being rejected works correctly. +add_task(function* checkQueueSemanticsOnError() { + let { sm, blockedPromise, rejectBlocked } = yield setupStorageManagerForQueueTest(); + + let resolveSubsequent; + let subsequentPromise = new Promise(resolve => { + resolveSubsequent = resolve; + }); + let subsequentCalled = false; + + sm._queueStorageOperation(() => { + subsequentCalled = true; + resolveSubsequent(); + return subsequentPromise; + }); + + // Our "subsequent" function should not have been called yet. + Assert.ok(!subsequentCalled); + + // Reject our blocked promise - the subsequent operations should still work + // correctly. + rejectBlocked("oh no"); + + // Our subsequent promise should end up resolved. + yield subsequentPromise; + Assert.ok(subsequentCalled); + + // But the first promise should reflect the rejection. + try { + yield blockedPromise; + Assert.ok(false, "expected this promise to reject"); + } catch (ex) { + Assert.equal(ex, "oh no"); + } + yield sm.finalize(); +}); + + +// And some tests for the specific operations that are queued. +add_task(function* checkQueuedReadAndUpdate() { + let { sm, resolveBlocked } = yield setupStorageManagerForQueueTest(); + // Mock the underlying operations + // _doReadAndUpdateSecure is queued by _maybeReadAndUpdateSecure + let _doReadCalled = false; + sm._doReadAndUpdateSecure = () => { + _doReadCalled = true; + return Promise.resolve(); + } + + let resultPromise = sm._maybeReadAndUpdateSecure(); + Assert.ok(!_doReadCalled); + + resolveBlocked(); + yield resultPromise; + Assert.ok(_doReadCalled); + yield sm.finalize(); +}); + +add_task(function* checkQueuedWrite() { + let { sm, resolveBlocked } = yield setupStorageManagerForQueueTest(); + // Mock the underlying operations + let __writeCalled = false; + sm.__write = () => { + __writeCalled = true; + return Promise.resolve(); + } + + let writePromise = sm._write(); + Assert.ok(!__writeCalled); + + resolveBlocked(); + yield writePromise; + Assert.ok(__writeCalled); + yield sm.finalize(); +}); + +add_task(function* checkQueuedDelete() { + let { sm, resolveBlocked } = yield setupStorageManagerForQueueTest(); + // Mock the underlying operations + let _deleteCalled = false; + sm._deleteAccountData = () => { + _deleteCalled = true; + return Promise.resolve(); + } + + let resultPromise = sm.deleteAccountData(); + Assert.ok(!_deleteCalled); + + resolveBlocked(); + yield resultPromise; + Assert.ok(_deleteCalled); + yield sm.finalize(); +}); + +function run_test() { + run_next_test(); +}
--- a/services/fxaccounts/tests/xpcshell/xpcshell.ini +++ b/services/fxaccounts/tests/xpcshell/xpcshell.ini @@ -16,8 +16,9 @@ reason = FxAccountsManager is only avail [test_oauth_grant_client.js] [test_oauth_grant_client_server.js] [test_oauth_tokens.js] [test_oauth_token_storage.js] [test_profile_client.js] [test_web_channel.js] skip-if = (appname == 'b2g' || appname == 'thunderbird') # fxa web channels only used on desktop [test_profile.js] +[test_storage_manager.js]
--- a/services/sync/modules-testing/utils.js +++ b/services/sync/modules-testing/utils.js @@ -11,32 +11,73 @@ this.EXPORTED_SYMBOLS = [ "setBasicCredentials", "makeIdentityConfig", "configureFxAccountIdentity", "configureIdentity", "SyncTestingInfrastructure", "waitForZeroTimer", "Promise", // from a module import "add_identity_test", + "MockFxaStorageManager", + "AccountState", // from a module import ]; const {utils: Cu} = Components; Cu.import("resource://services-sync/status.js"); Cu.import("resource://services-sync/identity.js"); Cu.import("resource://services-common/utils.js"); Cu.import("resource://services-crypto/utils.js"); Cu.import("resource://services-sync/util.js"); Cu.import("resource://services-sync/browserid_identity.js"); Cu.import("resource://testing-common/services/common/logging.js"); Cu.import("resource://testing-common/services/sync/fakeservices.js"); Cu.import("resource://gre/modules/FxAccounts.jsm"); Cu.import("resource://gre/modules/FxAccountsCommon.js"); Cu.import("resource://gre/modules/Promise.jsm"); +// and grab non-exported stuff via a backstage pass. +const {AccountState} = Cu.import("resource://gre/modules/FxAccounts.jsm", {}); + +// A mock "storage manager" for FxAccounts that doesn't actually write anywhere. +function MockFxaStorageManager() { +} + +MockFxaStorageManager.prototype = { + promiseInitialized: Promise.resolve(), + + 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(); + } +} + /** * First wait >100ms (nsITimers can take up to that much time to fire, so * we can account for the timer in delayedAutoconnect) and then two event * loop ticks (to account for the Utils.nextTick() in autoConnect). */ this.waitForZeroTimer = function waitForZeroTimer(callback) { let ticks = 2; function wait() { @@ -121,45 +162,55 @@ this.makeIdentityConfig = function(overr } return result; } // Configure an instance of an FxAccount identity provider with the specified // config (or the default config if not specified). this.configureFxAccountIdentity = function(authService, config = makeIdentityConfig()) { - let MockInternal = {}; - let fxa = new FxAccounts(MockInternal); - // until we get better test infrastructure for bid_identity, we set the // signedin user's "email" to the username, simply as many tests rely on this. config.fxaccount.user.email = config.username; - fxa.internal.currentAccountState.signedInUser = { - version: DATA_FORMAT_VERSION, - accountData: config.fxaccount.user + + let fxa; + let MockInternal = { + newAccountState(credentials) { + // We only expect this to be called with null indicating the (mock) + // storage should be read. + if (credentials) { + throw new Error("Not expecting to have credentials passed"); + } + let storageManager = new MockFxaStorageManager(); + storageManager.initialize(config.fxaccount.user); + let accountState = new AccountState(this, storageManager); + // mock getCertificate + accountState.getCertificate = function(data, keyPair, mustBeValidUntil) { + accountState.cert = { + validUntil: fxa.internal.now() + CERT_LIFETIME, + cert: "certificate", + }; + return Promise.resolve(this.cert.cert); + } + return accountState; + } }; - fxa.internal.currentAccountState.getCertificate = function(data, keyPair, mustBeValidUntil) { - this.cert = { - validUntil: fxa.internal.now() + CERT_LIFETIME, - cert: "certificate", - }; - return Promise.resolve(this.cert.cert); - }; + fxa = new FxAccounts(MockInternal); let mockTSC = { // TokenServerClient getTokenFromBrowserIDAssertion: function(uri, assertion, cb) { config.fxaccount.token.uid = config.username; cb(null, config.fxaccount.token); }, }; authService._fxaService = fxa; authService._tokenServerClient = mockTSC; // Set the "account" of the browserId manager to be the "email" of the // logged in user of the mockFXA service. - authService._signedInUser = fxa.internal.currentAccountState.signedInUser.accountData; + authService._signedInUser = config.fxaccount.user; authService._account = config.fxaccount.user.email; } this.configureIdentity = function(identityOverrides) { let config = makeIdentityConfig(identityOverrides); let ns = {}; Cu.import("resource://services-sync/service.js", ns);
--- a/services/sync/tests/unit/test_browserid_identity.js +++ b/services/sync/tests/unit/test_browserid_identity.js @@ -259,26 +259,27 @@ add_task(function test_ensureLoggedIn() Assert.equal(Status.login, LOGIN_SUCCEEDED, "original initialize worked"); yield browseridManager.ensureLoggedIn(); Assert.equal(Status.login, LOGIN_SUCCEEDED, "original ensureLoggedIn worked"); Assert.ok(browseridManager._shouldHaveSyncKeyBundle, "_shouldHaveSyncKeyBundle should always be true after ensureLogin completes."); // arrange for no logged in user. let fxa = browseridManager._fxaService - let signedInUser = fxa.internal.currentAccountState.signedInUser; - fxa.internal.currentAccountState.signedInUser = null; + let signedInUser = fxa.internal.currentAccountState.storageManager.accountData; + fxa.internal.currentAccountState.storageManager.accountData = null; browseridManager.initializeWithCurrentIdentity(); Assert.ok(!browseridManager._shouldHaveSyncKeyBundle, "_shouldHaveSyncKeyBundle should be false so we know we are testing what we think we are."); Status.login = LOGIN_FAILED_NO_USERNAME; yield Assert.rejects(browseridManager.ensureLoggedIn(), "expecting rejection due to no user"); Assert.ok(browseridManager._shouldHaveSyncKeyBundle, "_shouldHaveSyncKeyBundle should always be true after ensureLogin completes."); - fxa.internal.currentAccountState.signedInUser = signedInUser; + // Restore the logged in user to what it was. + fxa.internal.currentAccountState.storageManager.accountData = signedInUser; Status.login = LOGIN_FAILED_LOGIN_REJECTED; yield Assert.rejects(browseridManager.ensureLoggedIn(), "LOGIN_FAILED_LOGIN_REJECTED should have caused immediate rejection"); Assert.equal(Status.login, LOGIN_FAILED_LOGIN_REJECTED, "status should remain LOGIN_FAILED_LOGIN_REJECTED"); Status.login = LOGIN_FAILED_NETWORK_ERROR; yield browseridManager.ensureLoggedIn(); Assert.equal(Status.login, LOGIN_SUCCEEDED, "final ensureLoggedIn worked"); @@ -580,31 +581,38 @@ add_task(function test_getKeysMissing() configureFxAccountIdentity(browseridManager, identityConfig); // Mock a fxAccounts object that returns no keys let fxa = new FxAccounts({ fetchAndUnwrapKeys: function () { return Promise.resolve({}); }, - fxAccountsClient: new MockFxAccountsClient() + fxAccountsClient: new MockFxAccountsClient(), + newAccountState(credentials) { + // We only expect this to be called with null indicating the (mock) + // storage should be read. + if (credentials) { + throw new Error("Not expecting to have credentials passed"); + } + let storageManager = new MockFxaStorageManager(); + storageManager.initialize(identityConfig.fxaccount.user); + return new AccountState(this, storageManager); + }, }); // Add a mock to the currentAccountState object. fxa.internal.currentAccountState.getCertificate = function(data, keyPair, mustBeValidUntil) { this.cert = { validUntil: fxa.internal.now() + CERT_LIFETIME, cert: "certificate", }; return Promise.resolve(this.cert.cert); }; - // Ensure the new FxAccounts mock has a signed-in user. - fxa.internal.currentAccountState.signedInUser = browseridManager._fxaService.internal.currentAccountState.signedInUser; - browseridManager._fxaService = fxa; yield browseridManager.initializeWithCurrentIdentity(); let ex; try { yield browseridManager.whenReadyToAuthenticate.promise; } catch (e) { @@ -653,21 +661,28 @@ function* initializeIdentityWithHAWKResp // Arrange for the same observerPrefix as FxAccountsClient uses MockedHawkClient.prototype.observerPrefix = "FxA:hawk"; // tie it all together - configureFxAccountIdentity isn't useful here :( let fxaClient = new MockFxAccountsClient(); fxaClient.hawk = new MockedHawkClient(); let internal = { fxAccountsClient: fxaClient, + newAccountState(credentials) { + // We only expect this to be called with null indicating the (mock) + // storage should be read. + if (credentials) { + throw new Error("Not expecting to have credentials passed"); + } + let storageManager = new MockFxaStorageManager(); + storageManager.initialize(config.fxaccount.user); + return new AccountState(this, storageManager); + }, } let fxa = new FxAccounts(internal); - fxa.internal.currentAccountState.signedInUser = { - accountData: config.fxaccount.user, - }; browseridManager._fxaService = fxa; browseridManager._signedInUser = null; yield browseridManager.initializeWithCurrentIdentity(); yield Assert.rejects(browseridManager.whenReadyToAuthenticate.promise, "expecting rejection due to hawk error"); }