diff options
Diffstat (limited to 'services/fxaccounts')
37 files changed, 12469 insertions, 0 deletions
diff --git a/services/fxaccounts/Credentials.jsm b/services/fxaccounts/Credentials.jsm new file mode 100644 index 000000000..56e8b3db7 --- /dev/null +++ b/services/fxaccounts/Credentials.jsm @@ -0,0 +1,136 @@ +/* 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/. */ + +/** + * This module implements client-side key stretching for use in Firefox + * Accounts account creation and login. + * + * See https://github.com/mozilla/fxa-auth-server/wiki/onepw-protocol + */ + +"use strict"; + +this.EXPORTED_SYMBOLS = ["Credentials"]; + +const {utils: Cu, interfaces: Ci} = Components; + +Cu.import("resource://gre/modules/Log.jsm"); +Cu.import("resource://gre/modules/Services.jsm"); +Cu.import("resource://gre/modules/Promise.jsm"); +Cu.import("resource://services-crypto/utils.js"); +Cu.import("resource://services-common/utils.js"); + +const PROTOCOL_VERSION = "identity.mozilla.com/picl/v1/"; +const PBKDF2_ROUNDS = 1000; +const STRETCHED_PW_LENGTH_BYTES = 32; +const HKDF_SALT = CommonUtils.hexToBytes("00"); +const HKDF_LENGTH = 32; +const HMAC_ALGORITHM = Ci.nsICryptoHMAC.SHA256; +const HMAC_LENGTH = 32; + +// loglevel preference should be one of: "FATAL", "ERROR", "WARN", "INFO", +// "CONFIG", "DEBUG", "TRACE" or "ALL". We will be logging error messages by +// default. +const PREF_LOG_LEVEL = "identity.fxaccounts.loglevel"; +try { + this.LOG_LEVEL = + Services.prefs.getPrefType(PREF_LOG_LEVEL) == Ci.nsIPrefBranch.PREF_STRING + && Services.prefs.getCharPref(PREF_LOG_LEVEL); +} catch (e) { + this.LOG_LEVEL = Log.Level.Error; +} + +var log = Log.repository.getLogger("Identity.FxAccounts"); +log.level = LOG_LEVEL; +log.addAppender(new Log.ConsoleAppender(new Log.BasicFormatter())); + +this.Credentials = Object.freeze({ + /** + * Make constants accessible to tests + */ + constants: { + PROTOCOL_VERSION: PROTOCOL_VERSION, + PBKDF2_ROUNDS: PBKDF2_ROUNDS, + STRETCHED_PW_LENGTH_BYTES: STRETCHED_PW_LENGTH_BYTES, + HKDF_SALT: HKDF_SALT, + HKDF_LENGTH: HKDF_LENGTH, + HMAC_ALGORITHM: HMAC_ALGORITHM, + HMAC_LENGTH: HMAC_LENGTH, + }, + + /** + * KW function from https://github.com/mozilla/fxa-auth-server/wiki/onepw-protocol + * + * keyWord derivation for use as a salt. + * + * + * @param {String} context String for use in generating salt + * + * @return {bitArray} the salt + * + * Note that PROTOCOL_VERSION does not refer in any way to the version of the + * Firefox Accounts API. + */ + keyWord: function(context) { + return CommonUtils.stringToBytes(PROTOCOL_VERSION + context); + }, + + /** + * KWE function from https://github.com/mozilla/fxa-auth-server/wiki/onepw-protocol + * + * keyWord extended with a name and an email. + * + * @param {String} name The name of the salt + * @param {String} email The email of the user. + * + * @return {bitArray} the salt combination with the namespace + * + * Note that PROTOCOL_VERSION does not refer in any way to the version of the + * Firefox Accounts API. + */ + keyWordExtended: function(name, email) { + return CommonUtils.stringToBytes(PROTOCOL_VERSION + name + ':' + email); + }, + + setup: function(emailInput, passwordInput, options={}) { + let deferred = Promise.defer(); + log.debug("setup credentials for " + emailInput); + + let hkdfSalt = options.hkdfSalt || HKDF_SALT; + let hkdfLength = options.hkdfLength || HKDF_LENGTH; + let hmacLength = options.hmacLength || HMAC_LENGTH; + let hmacAlgorithm = options.hmacAlgorithm || HMAC_ALGORITHM; + let stretchedPWLength = options.stretchedPassLength || STRETCHED_PW_LENGTH_BYTES; + let pbkdf2Rounds = options.pbkdf2Rounds || PBKDF2_ROUNDS; + + let result = {}; + + let password = CommonUtils.encodeUTF8(passwordInput); + let salt = this.keyWordExtended("quickStretch", emailInput); + + let runnable = () => { + let start = Date.now(); + let quickStretchedPW = CryptoUtils.pbkdf2Generate( + password, salt, pbkdf2Rounds, stretchedPWLength, hmacAlgorithm, hmacLength); + + result.quickStretchedPW = quickStretchedPW; + + result.authPW = + CryptoUtils.hkdf(quickStretchedPW, hkdfSalt, this.keyWord("authPW"), hkdfLength); + + result.unwrapBKey = + CryptoUtils.hkdf(quickStretchedPW, hkdfSalt, this.keyWord("unwrapBkey"), hkdfLength); + + log.debug("Credentials set up after " + (Date.now() - start) + " ms"); + deferred.resolve(result); + } + + Services.tm.currentThread.dispatch(runnable, + Ci.nsIThread.DISPATCH_NORMAL); + log.debug("Dispatched thread for credentials setup crypto work"); + + return deferred.promise; + } +}); + diff --git a/services/fxaccounts/FxAccounts.jsm b/services/fxaccounts/FxAccounts.jsm new file mode 100644 index 000000000..5bed881ea --- /dev/null +++ b/services/fxaccounts/FxAccounts.jsm @@ -0,0 +1,1735 @@ +/* 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://services-common/utils.js"); +Cu.import("resource://services-common/rest.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, "FxAccountsConfig", + "resource://gre/modules/FxAccountsConfig.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, "jwcrypto", + "resource://gre/modules/identity/jwcrypto.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, "FxAccountsOAuthGrantClient", + "resource://gre/modules/FxAccountsOAuthGrantClient.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, "FxAccountsProfile", + "resource://gre/modules/FxAccountsProfile.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, "Utils", + "resource://services-sync/util.js"); + +// All properties exposed by the public FxAccounts API. +var publicProperties = [ + "accountStatus", + "checkVerificationStatus", + "getAccountsClient", + "getAssertion", + "getDeviceId", + "getKeys", + "getOAuthToken", + "getSignedInUser", + "getSignedInUserProfile", + "handleDeviceDisconnection", + "invalidateCertificate", + "loadAndPoll", + "localtimeOffsetMsec", + "notifyDevices", + "now", + "promiseAccountsChangeProfileURI", + "promiseAccountsForceSigninURI", + "promiseAccountsManageURI", + "promiseAccountsSignUpURI", + "promiseAccountsSignInURI", + "removeCachedOAuthToken", + "requiresHttps", + "resendVerificationEmail", + "resetCredentials", + "sessionStatus", + "setSignedInUser", + "signOut", + "updateDeviceRegistration", + "updateUserAccountData", + "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. +// In addition, it has some promise-related helpers to ensure that if an +// attempt is made to resolve a promise on a "stale" state (eg, if an +// operation starts, but a different user logs in before the operation +// completes), the promise will be rejected. +// It is intended to be used thusly: +// 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. +var AccountState = this.AccountState = function(storageManager) { + 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 = { + oauthTokens: null, + whenVerifiedDeferred: null, + whenKeysReadyDeferred: null, + + // If the storage manager has been nuked then we are no longer current. + get isCurrent() { + return this.storageManager != null; + }, + + 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.oauthTokens = 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.oauthTokens = null; + let storageManager = this.storageManager; + this.storageManager = null; + return storageManager.deleteAccountData().then(() => { + return storageManager.finalize(); + }); + }, + + // Get user account data. Optionally specify explicit field names to fetch + // (and note that if you require an in-memory field you *must* specify the + // field name(s).) + getUserAccountData(fieldNames = null) { + if (!this.isCurrent) { + return Promise.reject(new Error("Another user has signed in")); + } + return this.storageManager.getAccountData(fieldNames).then(result => { + return this.resolve(result); + }); + }, + + updateUserAccountData(updatedFields) { + if (!this.isCurrent) { + return Promise.reject(new Error("Another user has signed in")); + } + return this.storageManager.updateAccountData(updatedFields); + }, + + 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); + 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); + 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 (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"); + } + }, + + // Set a cached token. |tokenData| must have a 'token' element, but may also + // have additional fields (eg, it probably specifies the server to revoke + // from). The 'get' functions below return the entire |tokenData| value. + setCachedToken(scopeArray, tokenData) { + this._cachePreamble(); + if (!tokenData.token) { + throw new Error("No token"); + } + let key = getScopeKey(scopeArray); + this.oauthTokens[key] = tokenData; + // 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); + 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 result; + } + return null; + }, + + // 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] of Object.entries(data)) { + if (tokenValue.token == token) { + delete data[key]; + // And a background save... + this._persistCachedTokens(); + return tokenValue; + } + } + 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(); + 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("|"); +} + +/** + * Copies properties from a given object to another object. + * + * @param from (object) + * The object we read property descriptors from. + * @param to (object) + * The object that we set property descriptors on. + * @param options (object) (optional) + * {keys: [...]} + * Lets the caller pass the names of all properties they want to be + * copied. Will copy all properties of the given source object by + * default. + * {bind: object} + * Lets the caller specify the object that will be used to .bind() + * all function properties we find to. Will bind to the given target + * object by default. + */ +function copyObjectProperties(from, to, opts = {}) { + let keys = (opts && opts.keys) || Object.keys(from); + let thisArg = (opts && opts.bind) || to; + + for (let prop of keys) { + let desc = Object.getOwnPropertyDescriptor(from, prop); + + if (typeof(desc.value) == "function") { + desc.value = desc.value.bind(thisArg); + } + + if (desc.get) { + desc.get = desc.get.bind(thisArg); + } + + if (desc.set) { + desc.set = desc.set.bind(thisArg); + } + + Object.defineProperty(to, prop, desc); + } +} + +function urlsafeBase64Encode(key) { + return ChromeUtils.base64URLEncode(new Uint8Array(key), { pad: false }); +} + +/** + * The public API's constructor. + */ +this.FxAccounts = function (mockInternal) { + let internal = new FxAccountsInternal(); + let external = {}; + + // Copy all public properties to the 'external' object. + let prototype = FxAccountsInternal.prototype; + let options = {keys: publicProperties, bind: internal}; + copyObjectProperties(prototype, external, options); + + // Copy all of the mock's properties to the internal object. + if (mockInternal && !mockInternal.onlySetInternal) { + copyObjectProperties(mockInternal, internal); + } + + if (mockInternal) { + // Exposes the internal object for testing only. + external.internal = internal; + } + + if (!internal.fxaPushService) { + // internal.fxaPushService option is used in testing. + // Otherwise we load the service lazily. + XPCOMUtils.defineLazyGetter(internal, "fxaPushService", function () { + return Components.classes["@mozilla.org/fxaccounts/push;1"] + .getService(Components.interfaces.nsISupports) + .wrappedJSObject; + }); + } + + // wait until after the mocks are setup before initializing. + internal.initialize(); + + return Object.freeze(external); +} + +/** + * The internal API's constructor. + */ +function FxAccountsInternal() { + // Make a local copy of this constant so we can mock it in testing + this.POLL_SESSION = POLL_SESSION; + + // All significant initialization should be done in the initialize() method + // below as it helps with testing. +} + +/** + * The internal API's prototype. + */ +FxAccountsInternal.prototype = { + // The timeout (in ms) we use to poll for a verified mail for the first 2 mins. + VERIFICATION_POLL_TIMEOUT_INITIAL: 15000, // 15 seconds + // And how often we poll after the first 2 mins. + VERIFICATION_POLL_TIMEOUT_SUBSEQUENT: 30000, // 30 seconds. + // The current version of the device registration, we use this to re-register + // devices after we update what we send on device registration. + DEVICE_REGISTRATION_VERSION: 2, + + _fxAccountsClient: null, + + // All significant initialization should be done in this initialize() method, + // 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. + _profile: null, + get profile() { + if (!this._profile) { + let profileServerUrl = Services.urlFormatter.formatURLPref("identity.fxaccounts.remote.profile.uri"); + 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(storage); + }, + + /** + * Send a message to a set of devices in the same account + * + * @return Promise + */ + notifyDevices: function(deviceIds, payload, TTL) { + if (!Array.isArray(deviceIds)) { + deviceIds = [deviceIds]; + } + return this.currentAccountState.getUserAccountData() + .then(data => { + if (!data) { + throw this._error(ERROR_NO_ACCOUNT); + } + if (!data.sessionToken) { + throw this._error(ERROR_AUTH_ERROR, + "notifyDevices called without a session token"); + } + return this.fxAccountsClient.notifyDevices(data.sessionToken, deviceIds, + payload, TTL); + }); + }, + + /** + * 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(); + }, + + getAccountsClient: function() { + return this.fxAccountsClient; + }, + + /** + * Return clock offset in milliseconds, as reported by the fxAccountsClient. + * This can be overridden for testing. + * + * The offset is the number of milliseconds that must be added to the client + * clock to make it equal to the server clock. For example, if the client is + * five minutes ahead of the server, the localtimeOffsetMsec will be -300000. + */ + get localtimeOffsetMsec() { + return this.fxAccountsClient.localtimeOffsetMsec; + }, + + /** + * Ask the server whether the user's email has been verified + */ + checkEmailStatus: function checkEmailStatus(sessionToken, options = {}) { + if (!sessionToken) { + return Promise.reject(new Error( + "checkEmailStatus called without a session token")); + } + return this.fxAccountsClient.recoveryEmailStatus(sessionToken, + options).catch(error => this._handleTokenError(error)); + }, + + /** + * Once the user's email is verified, we can request the keys + */ + fetchKeys: function fetchKeys(keyFetchToken) { + log.debug("fetchKeys: " + !!keyFetchToken); + if (logPII) { + log.debug("fetchKeys - the token is " + keyFetchToken); + } + return this.fxAccountsClient.accountKeys(keyFetchToken); + }, + + // set() makes sure that polling is happening, if necessary. + // get() does not wait for verification, and returns an object even if + // unverified. The caller of get() must check .verified . + // The "fxaccounts:onverified" event will fire only when the verified + // state goes from false to true, so callers must register their observer + // and then call get(). In particular, it will not fire when the account + // was found to be verified in a previous boot: if our stored state says + // the account is verified, the event will never fire. So callers must do: + // register notification observer (go) + // userdata = get() + // if (userdata.verified()) {go()} + + /** + * Get the user currently signed in to Firefox Accounts. + * + * @return Promise + * The promise resolves to the credentials object of the signed-in user: + * { + * email: The user's email address + * uid: The user's unique id + * sessionToken: Session for the FxA server + * kA: An encryption key from the FxA server + * kB: An encryption key derived from the user's FxA password + * verified: email verification status + * authAt: The time (seconds since epoch) that this record was + * authenticated + * } + * or null if no user is signed in. + */ + getSignedInUser: function getSignedInUser() { + let currentState = this.currentAccountState; + return currentState.getUserAccountData().then(data => { + if (!data) { + return null; + } + if (!this.isUserEmailVerified(data)) { + // If the email is not verified, start polling for verification, + // but return null right away. We don't want to return a promise + // that might not be fulfilled for a long time. + this.startVerifiedCheck(data); + } + return data; + }).then(result => currentState.resolve(result)); + }, + + /** + * Set the current user signed in to Firefox Accounts. + * + * @param credentials + * The credentials object obtained by logging in or creating + * an account on the FxA server: + * { + * authAt: The time (seconds since epoch) that this record was + * authenticated + * email: The users email address + * keyFetchToken: a keyFetchToken which has not yet been used + * sessionToken: Session for the FxA server + * uid: The user's unique id + * unwrapBKey: used to unwrap kB, derived locally from the + * password (not revealed to the FxA server) + * 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"); + 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(() => { + // Starting point for polling if new user + if (!this.isUserEmailVerified(credentials)) { + this.startVerifiedCheck(credentials); + } + + return this.updateDeviceRegistration(); + }).then(() => { + Services.telemetry.getHistogramById("FXA_CONFIGURED").add(1); + this.notifyObservers(ONLOGIN_NOTIFICATION); + }).then(() => { + return currentAccountState.resolve(); + }); + }) + }, + + /** + * Update account data for the currently signed in user. + * + * @param credentials + * The credentials object containing the fields to be updated. + * This object must contain |email| and |uid| fields and they must + * match the currently signed in user. + */ + updateUserAccountData(credentials) { + log.debug("updateUserAccountData called with fields", Object.keys(credentials)); + if (logPII) { + log.debug("updateUserAccountData called with data", credentials); + } + let currentAccountState = this.currentAccountState; + return currentAccountState.promiseInitialized.then(() => { + return currentAccountState.getUserAccountData(["email", "uid"]); + }).then(existing => { + if (existing.email != credentials.email || existing.uid != credentials.uid) { + throw new Error("The specified credentials aren't for the current user"); + } + // We need to nuke email and uid as storage will complain if we try and + // update them (even when the value is the same) + credentials = Cu.cloneInto(credentials, {}); // clone it first + delete credentials.email; + delete credentials.uid; + return currentAccountState.updateUserAccountData(credentials); + }); + }, + + /** + * returns a promise that fires with the assertion. If there is no verified + * signed-in user, fires with null. + */ + getAssertion: function getAssertion(audience) { + return this._getAssertion(audience); + }, + + // getAssertion() is "public" so screws with our mock story. This + // implementation method *can* be (and is) mocked by tests. + _getAssertion: function _getAssertion(audience) { + log.debug("enter getAssertion()"); + let currentState = this.currentAccountState; + return currentState.getUserAccountData().then(data => { + if (!data) { + // No signed-in user + return null; + } + if (!this.isUserEmailVerified(data)) { + // Signed-in user has not verified email + return null; + } + if (!data.sessionToken) { + // can't get a signed certificate without a session token. This + // can happen if we request an assertion after clearing an invalid + // session token from storage. + throw this._error(ERROR_AUTH_ERROR, "getAssertion called without a session token"); + } + return this.getKeypairAndCertificate(currentState).then( + ({keyPair, certificate}) => { + return this.getAssertionFromCert(data, keyPair, certificate, audience); + } + ); + }).catch(err => + this._handleTokenError(err) + ).then(result => currentState.resolve(result)); + }, + + /** + * Invalidate the FxA certificate, so that it will be refreshed from the server + * the next time it is needed. + */ + invalidateCertificate() { + return this.currentAccountState.updateUserAccountData({ cert: null }); + }, + + getDeviceId() { + return this.currentAccountState.getUserAccountData() + .then(data => { + if (data) { + if (!data.deviceId || !data.deviceRegistrationVersion || + data.deviceRegistrationVersion < this.DEVICE_REGISTRATION_VERSION) { + // There is no device id or the device registration is outdated. + // Either way, we should register the device with FxA + // before returning the id to the caller. + return this._registerOrUpdateDevice(data); + } + + // Return the device id that we already registered with the server. + return data.deviceId; + } + + // Without a signed-in user, there can be no device id. + return null; + }); + }, + + /** + * Resend the verification email fot the currently signed-in user. + * + */ + resendVerificationEmail: function resendVerificationEmail() { + let currentState = this.currentAccountState; + return this.getSignedInUser().then(data => { + // If the caller is asking for verification to be re-sent, and there is + // no signed-in user to begin with, this is probably best regarded as an + // error. + if (data) { + if (!data.sessionToken) { + return Promise.reject(new Error( + "resendVerificationEmail called without a session token")); + } + this.pollEmailStatus(currentState, data.sessionToken, "start"); + return this.fxAccountsClient.resendVerificationEmail( + data.sessionToken).catch(err => this._handleTokenError(err)); + } + throw new Error("Cannot resend verification email; no signed-in user"); + }); + }, + + /* + * 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; + } + 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); + }); + }, + + checkVerificationStatus: function() { + log.trace('checkVerificationStatus'); + let currentState = this.currentAccountState; + return currentState.getUserAccountData().then(data => { + if (!data) { + log.trace("checkVerificationStatus - no user data"); + return null; + } + + // Always check the verification status, even if the local state indicates + // we're already verified. If the user changed their password, the check + // will fail, and we'll enter the reauth state. + log.trace("checkVerificationStatus - forcing verification status check"); + return this.pollEmailStatus(currentState, data.sessionToken, "push"); + }); + }, + + _destroyOAuthToken: function(tokenData) { + let client = new FxAccountsOAuthGrantClient({ + serverURL: tokenData.server, + 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 [key, tokenInfo] of Object.entries(tokenInfos || {})) { + promises.push(this._destroyOAuthToken(tokenInfo)); + } + return Promise.all(promises); + }, + + signOut: function signOut(localOnly) { + let currentState = this.currentAccountState; + let sessionToken; + let tokensToRevoke; + let deviceId; + return currentState.getUserAccountData().then(data => { + // Save the session token, tokens to revoke and the + // device id for use in the call to signOut below. + if (data) { + sessionToken = data.sessionToken; + tokensToRevoke = data.oauthTokens; + deviceId = data.deviceId; + } + return this._signOutLocal(); + }).then(() => { + // FxAccountsManager calls here, then does its own call + // to FxAccountsClient.signOut(). + if (!localOnly) { + // Wrap this in a promise so *any* errors in signOut won't + // block the local sign out. This is *not* returned. + Promise.resolve().then(() => { + // This can happen in the background and shouldn't block + // the user from signing out. The server must tolerate + // clients just disappearing, so this call should be best effort. + if (sessionToken) { + return this._signOutServer(sessionToken, deviceId); + } + log.warn("Missing session token; skipping remote sign out"); + }).catch(err => { + log.error("Error during remote sign out of Firefox Accounts", err); + }).then(() => { + return this._destroyAllOAuthTokens(tokensToRevoke); + }).catch(err => { + log.error("Error during destruction of oauth tokens during signout", err); + }).then(() => { + FxAccountsConfig.resetConfigURLs(); + // just for testing - notifications are cheap when no observers. + this.notifyObservers("testhelper-fxa-signout-complete"); + }) + } else { + // We want to do this either way -- but if we're signing out remotely we + // need to wait until we destroy the oauth tokens if we want that to succeed. + FxAccountsConfig.resetConfigURLs(); + } + }).then(() => { + this.notifyObservers(ONLOGOUT_NOTIFICATION); + }); + }, + + /** + * This function should be called in conjunction with a server-side + * signOut via FxAccountsClient. + */ + _signOutLocal: function signOutLocal() { + let currentAccountState = this.currentAccountState; + return currentAccountState.signOut().then(() => { + // this "aborts" this.currentAccountState but doesn't make a new one. + return this.abortExistingFlow(); + }).then(() => { + this.currentAccountState = this.newAccountState(); + return this.currentAccountState.promiseInitialized; + }); + }, + + _signOutServer(sessionToken, deviceId) { + // For now we assume the service being logged out from is Sync, so + // we must tell the server to either destroy the device or sign out + // (if no device exists). We might need to revisit this when this + // FxA code is used in a context that isn't Sync. + + const options = { service: "sync" }; + + if (deviceId) { + log.debug("destroying device and session"); + return this.fxAccountsClient.signOutAndDestroyDevice(sessionToken, deviceId, options); + } + + log.debug("destroying session"); + return this.fxAccountsClient.signOut(sessionToken, options); + }, + + /** + * Check the status of the current session using cached credentials. + * + * @return Promise + * Resolves with a boolean indicating if the session is still valid + */ + sessionStatus() { + return this.getSignedInUser().then(data => { + if (!data.sessionToken) { + return Promise.reject(new Error( + "sessionStatus called without a session token")); + } + return this.fxAccountsClient.sessionStatus(data.sessionToken); + }); + }, + + /** + * Fetch encryption keys for the signed-in-user from the FxA API server. + * + * Not for user consumption. Exists to cause the keys to be fetch. + * + * Returns user data so that it can be chained with other methods. + * + * @return Promise + * The promise resolves to the credentials object of the signed-in user: + * { + * email: The user's email address + * uid: The user's unique id + * sessionToken: Session for the FxA server + * kA: An encryption key from the FxA server + * kB: An encryption key derived from the user's FxA password + * verified: email verification status + * } + * or null if no user is signed in + */ + getKeys: function() { + let currentState = this.currentAccountState; + return currentState.getUserAccountData().then((userData) => { + if (!userData) { + throw new Error("Can't get keys; User is not signed in"); + } + if (userData.kA && userData.kB) { + return userData; + } + if (!currentState.whenKeysReadyDeferred) { + currentState.whenKeysReadyDeferred = Promise.defer(); + if (userData.keyFetchToken) { + this.fetchAndUnwrapKeys(userData.keyFetchToken).then( + (dataWithKeys) => { + if (!dataWithKeys.kA || !dataWithKeys.kB) { + currentState.whenKeysReadyDeferred.reject( + new Error("user data missing kA or kB") + ); + return; + } + currentState.whenKeysReadyDeferred.resolve(dataWithKeys); + }, + (err) => { + currentState.whenKeysReadyDeferred.reject(err); + } + ); + } else { + currentState.whenKeysReadyDeferred.reject('No keyFetchToken'); + } + } + return currentState.whenKeysReadyDeferred.promise; + }).catch(err => + this._handleTokenError(err) + ).then(result => currentState.resolve(result)); + }, + + fetchAndUnwrapKeys: function(keyFetchToken) { + if (logPII) { + log.debug("fetchAndUnwrapKeys: token: " + keyFetchToken); + } + let currentState = this.currentAccountState; + return Task.spawn(function* task() { + // Sign out if we don't have a key fetch token. + if (!keyFetchToken) { + log.warn("improper fetchAndUnwrapKeys() call: token missing"); + yield this.signOut(); + return null; + } + + let {kA, wrapKB} = yield this.fetchKeys(keyFetchToken); + + let data = yield currentState.getUserAccountData(); + + // Sanity check that the user hasn't changed out from under us + if (data.keyFetchToken !== keyFetchToken) { + throw new Error("Signed in user changed while fetching keys!"); + } + + // 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); + } + let updateData = { + kA: CommonUtils.bytesAsHex(kA), + kB: CommonUtils.bytesAsHex(kB_hex), + keyFetchToken: null, // null values cause the item to be removed. + unwrapBKey: null, + } + + 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 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 = { + duration: ASSERTION_LIFETIME, + localtimeOffsetMsec: this.localtimeOffsetMsec, + now: this.now() + }; + let currentState = this.currentAccountState; + // "audience" should look like "http://123done.org". + // The generated assertion will expire in two minutes. + jwcrypto.generateAssertion(cert, keyPair, audience, options, (err, signed) => { + if (err) { + log.error("getAssertionFromCert: " + err); + d.reject(err); + } else { + log.debug("getAssertionFromCert returning signed: " + !!signed); + if (logPII) { + log.debug("getAssertionFromCert returning signed: " + signed); + } + d.resolve(signed); + } + }); + return d.promise.then(result => currentState.resolve(result)); + }, + + getCertificateSigned: function(sessionToken, serializedPublicKey, lifetime) { + log.debug("getCertificateSigned: " + !!sessionToken + " " + !!serializedPublicKey); + if (logPII) { + log.debug("getCertificateSigned: " + sessionToken + " " + serializedPublicKey); + } + return this.fxAccountsClient.signCertificate( + sessionToken, + JSON.parse(serializedPublicKey), + lifetime + ); + }, + + /** + * returns a promise that fires with {keyPair, certificate}. + */ + getKeypairAndCertificate: Task.async(function* (currentState) { + // If the debugging pref to ignore cached authentication credentials is set for Sync, + // then don't use any cached key pair/certificate, i.e., generate a new + // one and get it signed. + // The purpose of this pref is to expedite any auth errors as the result of a + // expired or revoked FxA session token, e.g., from resetting or changing the FxA + // password. + let ignoreCachedAuthCredentials = false; + try { + ignoreCachedAuthCredentials = Services.prefs.getBoolPref("services.sync.debug.ignoreCachedAuthCredentials"); + } catch(e) { + // Pref doesn't exist + } + let mustBeValidUntil = this.now() + ASSERTION_USE_PERIOD; + let accountData = yield currentState.getUserAccountData(["cert", "keyPair", "sessionToken"]); + + let keyPairValid = !ignoreCachedAuthCredentials && + accountData.keyPair && + (accountData.keyPair.validUntil > mustBeValidUntil); + let certValid = !ignoreCachedAuthCredentials && + accountData.cert && + (accountData.cert.validUntil > mustBeValidUntil); + // TODO: get the lifetime from the cert's .exp field + if (keyPairValid && certValid) { + log.debug("getKeypairAndCertificate: already have keyPair and certificate"); + return { + keyPair: accountData.keyPair.rawKeyPair, + certificate: accountData.cert.rawCert + } + } + // We are definately going to generate a new cert, either because it has + // already expired, or the keyPair has - and a new keyPair means we must + // generate a new cert. + + // A keyPair has a longer lifetime than a cert, so it's possible we will + // have a valid keypair but an expired cert, which means we can skip + // keypair generation. + // Either way, the cert will require hitting the network, so bail now if + // we know that's going to fail. + if (Services.io.offline) { + throw new Error(ERROR_OFFLINE); + } + + let keyPair; + if (keyPairValid) { + keyPair = accountData.keyPair; + } else { + let keyWillBeValidUntil = this.now() + KEY_LIFETIME; + keyPair = yield new Promise((resolve, reject) => { + jwcrypto.generateKeyPair("DS160", (err, kp) => { + if (err) { + return reject(err); + } + log.debug("got keyPair"); + resolve({ + rawKeyPair: kp, + validUntil: keyWillBeValidUntil, + }); + }); + }); + } + + // and generate the cert. + let certWillBeValidUntil = this.now() + CERT_LIFETIME; + let certificate = yield this.getCertificateSigned(accountData.sessionToken, + keyPair.rawKeyPair.serializedPublicKey, + CERT_LIFETIME); + log.debug("getCertificate got a new one: " + !!certificate); + if (certificate) { + // Cache both keypair and cert. + let toUpdate = { + keyPair, + cert: { + rawCert: certificate, + validUntil: certWillBeValidUntil, + }, + }; + yield currentState.updateUserAccountData(toUpdate); + } + return { + keyPair: keyPair.rawKeyPair, + certificate: certificate, + } + }), + + getUserAccountData: function() { + return this.currentAccountState.getUserAccountData(); + }, + + isUserEmailVerified: function isUserEmailVerified(data) { + return !!(data && data.verified); + }, + + /** + * Setup for and if necessary do email verification polling. + */ + loadAndPoll: function() { + let currentState = this.currentAccountState; + return currentState.getUserAccountData() + .then(data => { + if (data) { + Services.telemetry.getHistogramById("FXA_CONFIGURED").add(1); + if (!this.isUserEmailVerified(data)) { + this.pollEmailStatus(currentState, data.sessionToken, "start"); + } + } + return data; + }); + }, + + startVerifiedCheck: function(data) { + log.debug("startVerifiedCheck", data && data.verified); + if (logPII) { + log.debug("startVerifiedCheck with user data", data); + } + + // Get us to the verified state, then get the keys. This returns a promise + // that will fire when we are completely ready. + // + // Login is truly complete once keys have been fetched, so once getKeys() + // obtains and stores kA and kB, it will fire the onverified observer + // notification. + + // The callers of startVerifiedCheck never consume a returned promise (ie, + // this is simply kicking off a background fetch) so we must add a rejection + // handler to avoid runtime warnings about the rejection not being handled. + this.whenVerified(data).then( + () => this.getKeys(), + err => log.info("startVerifiedCheck promise was rejected: " + err) + ); + }, + + whenVerified: function(data) { + let currentState = this.currentAccountState; + if (data.verified) { + log.debug("already verified"); + return currentState.resolve(data); + } + if (!currentState.whenVerifiedDeferred) { + log.debug("whenVerified promise starts polling for verified email"); + this.pollEmailStatus(currentState, data.sessionToken, "start"); + } + return currentState.whenVerifiedDeferred.promise.then( + result => currentState.resolve(result) + ); + }, + + notifyObservers: function(topic, data) { + log.debug("Notifying observers of " + topic); + Services.obs.notifyObservers(null, topic, data); + }, + + // XXX - pollEmailStatus should maybe be on the AccountState object? + pollEmailStatus: function pollEmailStatus(currentState, sessionToken, why) { + log.debug("entering pollEmailStatus: " + why); + if (why == "start" || why == "push") { + if (this.currentTimer) { + log.debug("pollEmailStatus starting while existing timer is running"); + clearTimeout(this.currentTimer); + this.currentTimer = null; + } + + // If we were already polling, stop and start again. This could happen + // if the user requested the verification email to be resent while we + // were already polling for receipt of an earlier email. + this.pollStartDate = Date.now(); + if (!currentState.whenVerifiedDeferred) { + currentState.whenVerifiedDeferred = Promise.defer(); + // This deferred might not end up with any handlers (eg, if sync + // is yet to start up.) This might cause "A promise chain failed to + // handle a rejection" messages, so add an error handler directly + // on the promise to log the error. + currentState.whenVerifiedDeferred.promise.then(null, err => { + log.info("the wait for user verification was stopped: " + err); + }); + } + } + + // We return a promise for testing only. Other callers can ignore this, + // since verification polling continues in the background. + return this.checkEmailStatus(sessionToken, { reason: why }) + .then((response) => { + log.debug("checkEmailStatus -> " + JSON.stringify(response)); + if (response && response.verified) { + currentState.updateUserAccountData({ verified: true }) + .then(() => { + return currentState.getUserAccountData(); + }) + .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); + }); + } else { + // Poll email status again after a short delay. + this.pollEmailStatusAgain(currentState, sessionToken); + } + }, error => { + let timeoutMs = undefined; + if (error && error.retryAfter) { + // If the server told us to back off, back off the requested amount. + timeoutMs = (error.retryAfter + 3) * 1000; + } + // The server will return 401 if a request parameter is erroneous or + // if the session token expired. Let's continue polling otherwise. + if (!error || !error.code || error.code != 401) { + this.pollEmailStatusAgain(currentState, sessionToken, timeoutMs); + } else { + let error = new Error("Verification status check failed"); + this._rejectWhenVerified(currentState, error); + } + }); + }, + + _rejectWhenVerified(currentState, error) { + currentState.whenVerifiedDeferred.reject(error); + delete currentState.whenVerifiedDeferred; + }, + + // Poll email status using truncated exponential back-off. + pollEmailStatusAgain: function (currentState, sessionToken, timeoutMs) { + let ageMs = Date.now() - this.pollStartDate; + if (ageMs >= this.POLL_SESSION) { + if (currentState.whenVerifiedDeferred) { + let error = new Error("User email verification timed out."); + this._rejectWhenVerified(currentState, error); + } + log.debug("polling session exceeded, giving up"); + return; + } + if (timeoutMs === undefined) { + let currentMinute = Math.ceil(ageMs / 60000); + timeoutMs = currentMinute <= 2 ? this.VERIFICATION_POLL_TIMEOUT_INITIAL + : this.VERIFICATION_POLL_TIMEOUT_SUBSEQUENT; + } + log.debug("polling with timeout = " + timeoutMs); + this.currentTimer = setTimeout(() => { + this.pollEmailStatus(currentState, sessionToken, "timer"); + }, timeoutMs); + }, + + requiresHttps: function() { + let allowHttp = false; + try { + allowHttp = Services.prefs.getBoolPref("identity.fxaccounts.allowHttp"); + } catch(e) { + // Pref doesn't exist + } + return allowHttp !== true; + }, + + promiseAccountsSignUpURI() { + return FxAccountsConfig.promiseAccountsSignUpURI(); + }, + + promiseAccountsSignInURI() { + return FxAccountsConfig.promiseAccountsSignInURI(); + }, + + // Returns a promise that resolves with the URL to use to force a re-signin + // of the current account. + promiseAccountsForceSigninURI: Task.async(function *() { + yield FxAccountsConfig.ensureConfigured(); + let url = Services.urlFormatter.formatURLPref("identity.fxaccounts.remote.force_auth.uri"); + if (this.requiresHttps() && !/^https:/.test(url)) { // Comment to un-break emacs js-mode highlighting + throw new Error("Firefox Accounts server must use HTTPS"); + } + let currentState = this.currentAccountState; + // but we need to append the email address onto a query string. + return this.getSignedInUser().then(accountData => { + if (!accountData) { + return null; + } + let newQueryPortion = url.indexOf("?") == -1 ? "?" : "&"; + newQueryPortion += "email=" + encodeURIComponent(accountData.email); + return url + newQueryPortion; + }).then(result => currentState.resolve(result)); + }), + + // Returns a promise that resolves with the URL to use to change + // the current account's profile image. + // if settingToEdit is set, the profile page should hightlight that setting + // for the user to edit. + promiseAccountsChangeProfileURI: function(entrypoint, settingToEdit = null) { + let url = Services.urlFormatter.formatURLPref("identity.fxaccounts.settings.uri"); + + if (settingToEdit) { + url += (url.indexOf("?") == -1 ? "?" : "&") + + "setting=" + encodeURIComponent(settingToEdit); + } + + if (this.requiresHttps() && !/^https:/.test(url)) { // Comment to un-break emacs js-mode highlighting + throw new Error("Firefox Accounts server must use HTTPS"); + } + let currentState = this.currentAccountState; + // but we need to append the email address onto a query string. + return this.getSignedInUser().then(accountData => { + if (!accountData) { + return null; + } + let newQueryPortion = url.indexOf("?") == -1 ? "?" : "&"; + newQueryPortion += "email=" + encodeURIComponent(accountData.email); + newQueryPortion += "&uid=" + encodeURIComponent(accountData.uid); + if (entrypoint) { + newQueryPortion += "&entrypoint=" + encodeURIComponent(entrypoint); + } + return url + newQueryPortion; + }).then(result => currentState.resolve(result)); + }, + + // Returns a promise that resolves with the URL to use to manage the current + // user's FxA acct. + promiseAccountsManageURI: function(entrypoint) { + let url = Services.urlFormatter.formatURLPref("identity.fxaccounts.settings.uri"); + if (this.requiresHttps() && !/^https:/.test(url)) { // Comment to un-break emacs js-mode highlighting + throw new Error("Firefox Accounts server must use HTTPS"); + } + let currentState = this.currentAccountState; + // but we need to append the uid and email address onto a query string + // (if the server has no matching uid it will offer to sign in with the + // email address) + return this.getSignedInUser().then(accountData => { + if (!accountData) { + return null; + } + let newQueryPortion = url.indexOf("?") == -1 ? "?" : "&"; + newQueryPortion += "uid=" + encodeURIComponent(accountData.uid) + + "&email=" + encodeURIComponent(accountData.email); + if (entrypoint) { + newQueryPortion += "&entrypoint=" + encodeURIComponent(entrypoint); + } + return url + newQueryPortion; + }).then(result => currentState.resolve(result)); + }, + + /** + * Get an OAuth token for the user + * + * @param options + * { + * scope: (string/array) the oauth scope(s) being requested. As a + * convenience, you may pass a string if only one scope is + * required, or an array of strings if multiple are needed. + * } + * + * @return Promise.<string | Error> + * The promise resolves the oauth token as a string or rejects with + * an error object ({error: ERROR, details: {}}) of the following: + * INVALID_PARAMETER + * NO_ACCOUNT + * UNVERIFIED_ACCOUNT + * NETWORK_ERROR + * AUTH_ERROR + * UNKNOWN_ERROR + */ + getOAuthToken: Task.async(function* (options = {}) { + log.debug("getOAuthToken enter"); + let scope = options.scope; + if (typeof scope === "string") { + scope = [scope]; + } + + if (!scope || !scope.length) { + throw this._error(ERROR_INVALID_PARAMETER, "Missing or invalid 'scope' option"); + } + + yield this._getVerifiedAccountOrReject(); + + // Early exit for a cached token. + let currentState = this.currentAccountState; + let cached = currentState.getCachedToken(scope); + if (cached) { + log.debug("getOAuthToken returning a cached token"); + return cached.token; + } + + // We are going to hit the server - this is the string we pass to it. + let scopeString = scope.join(" "); + let client = options.client; + + if (!client) { + try { + let defaultURL = Services.urlFormatter.formatURLPref("identity.fxaccounts.remote.oauth.uri"); + client = new FxAccountsOAuthGrantClient({ + serverURL: defaultURL, + client_id: FX_OAUTH_CLIENT_ID + }); + } catch (e) { + throw this._error(ERROR_INVALID_PARAMETER, e); + } + } + let oAuthURL = client.serverURL.href; + + try { + log.debug("getOAuthToken fetching new token from", oAuthURL); + let assertion = yield this.getAssertion(oAuthURL); + let result = yield client.getTokenFromAssertion(assertion, scopeString); + let token = result.access_token; + // If we got one, cache it. + if (token) { + let entry = {token: token, server: oAuthURL}; + // But before we do, check the cache again - if we find one now, it + // means someone else concurrently requested the same scope and beat + // us to the cache write. To be nice to the server, we revoke the one + // we just got and return the newly cached value. + let cached = currentState.getCachedToken(scope); + if (cached) { + log.debug("Detected a race for this token - revoking the new one."); + this._destroyOAuthToken(entry); + return cached.token; + } + currentState.setCachedToken(scope, entry); + } + return token; + } catch (err) { + throw this._errorToErrorClass(err); + } + }), + + /** + * Remove an OAuth token from the token cache. Callers should call this + * after they determine a token is invalid, so a new token will be fetched + * on the next call to getOAuthToken(). + * + * @param options + * { + * token: (string) A previously fetched token. + * } + * @return Promise.<undefined> This function will always resolve, even if + * an unknown token is passed. + */ + removeCachedOAuthToken: Task.async(function* (options) { + if (!options.token || typeof options.token !== "string") { + throw this._error(ERROR_INVALID_PARAMETER, "Missing or invalid 'token' option"); + } + let currentState = this.currentAccountState; + let existing = currentState.removeCachedToken(options.token); + if (existing) { + // background destroy. + this._destroyOAuthToken(existing).catch(err => { + log.warn("FxA failed to revoke a cached token", err); + }); + } + }), + + _getVerifiedAccountOrReject: Task.async(function* () { + let data = yield this.currentAccountState.getUserAccountData(); + if (!data) { + // No signed-in user + throw this._error(ERROR_NO_ACCOUNT); + } + if (!this.isUserEmailVerified(data)) { + // Signed-in user has not verified email + throw this._error(ERROR_UNVERIFIED_ACCOUNT); + } + }), + + /* + * Coerce an error into one of the general error cases: + * NETWORK_ERROR + * AUTH_ERROR + * UNKNOWN_ERROR + * + * These errors will pass through: + * INVALID_PARAMETER + * NO_ACCOUNT + * UNVERIFIED_ACCOUNT + */ + _errorToErrorClass: function (aError) { + if (aError.errno) { + let error = SERVER_ERRNO_TO_ERROR[aError.errno]; + return this._error(ERROR_TO_GENERAL_ERROR_CLASS[error] || ERROR_UNKNOWN, aError); + } else if (aError.message && + (aError.message === "INVALID_PARAMETER" || + aError.message === "NO_ACCOUNT" || + aError.message === "UNVERIFIED_ACCOUNT" || + aError.message === "AUTH_ERROR")) { + return aError; + } + return this._error(ERROR_UNKNOWN, aError); + }, + + _error: function(aError, aDetails) { + log.error("FxA rejecting with error ${aError}, details: ${aDetails}", {aError, aDetails}); + let reason = new Error(aError); + if (aDetails) { + reason.details = aDetails; + } + return reason; + }, + + /** + * Get the user's account and profile data + * + * @param options + * { + * contentUrl: (string) Used by the FxAccountsWebChannel. + * Defaults to pref identity.fxaccounts.settings.uri + * profileServerUrl: (string) Used by the FxAccountsWebChannel. + * Defaults to pref identity.fxaccounts.remote.profile.uri + * } + * + * @return Promise.<object | Error> + * The promise resolves to an accountData object with extra profile + * information such as profileImageUrl, or rejects with + * an error object ({error: ERROR, details: {}}) of the following: + * INVALID_PARAMETER + * NO_ACCOUNT + * UNVERIFIED_ACCOUNT + * NETWORK_ERROR + * AUTH_ERROR + * UNKNOWN_ERROR + */ + getSignedInUserProfile: function () { + let currentState = this.currentAccountState; + return this.profile.getProfile().then( + 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))); + }, + + // Attempt to update the auth server with whatever device details are stored + // in the account data. Returns a promise that always resolves, never rejects. + // If the promise resolves to a value, that value is the device id. + updateDeviceRegistration() { + return this.getSignedInUser().then(signedInUser => { + if (signedInUser) { + return this._registerOrUpdateDevice(signedInUser); + } + }).catch(error => this._logErrorAndResetDeviceRegistrationVersion(error)); + }, + + handleDeviceDisconnection(deviceId) { + return this.currentAccountState.getUserAccountData() + .then(data => data ? data.deviceId : null) + .then(localDeviceId => { + if (deviceId == localDeviceId) { + this.notifyObservers(ON_DEVICE_DISCONNECTED_NOTIFICATION, deviceId); + return this.signOut(true); + } + log.error( + "The device ID to disconnect doesn't match with the local device ID.\n" + + "Local: " + localDeviceId + ", ID to disconnect: " + deviceId); + }); + }, + + /** + * Delete all the cached persisted credentials we store for FxA. + * + * @return Promise resolves when the user data has been persisted + */ + resetCredentials() { + // Delete all fields except those required for the user to + // reauthenticate. + let updateData = {}; + let clearField = field => { + if (!FXA_PWDMGR_REAUTH_WHITELIST.has(field)) { + updateData[field] = null; + } + } + FXA_PWDMGR_PLAINTEXT_FIELDS.forEach(clearField); + FXA_PWDMGR_SECURE_FIELDS.forEach(clearField); + FXA_PWDMGR_MEMORY_FIELDS.forEach(clearField); + + let currentState = this.currentAccountState; + return currentState.updateUserAccountData(updateData); + }, + + // If you change what we send to the FxA servers during device registration, + // you'll have to bump the DEVICE_REGISTRATION_VERSION number to force older + // devices to re-register when Firefox updates + _registerOrUpdateDevice(signedInUser) { + try { + // Allow tests to skip device registration because: + // 1. It makes remote requests to the auth server. + // 2. _getDeviceName does not work from xpcshell. + // 3. The B2G tests fail when attempting to import services-sync/util.js. + if (Services.prefs.getBoolPref("identity.fxaccounts.skipDeviceRegistration")) { + return Promise.resolve(); + } + } catch(ignore) {} + + if (!signedInUser.sessionToken) { + return Promise.reject(new Error( + "_registerOrUpdateDevice called without a session token")); + } + + return this.fxaPushService.registerPushEndpoint().then(subscription => { + const deviceName = this._getDeviceName(); + let deviceOptions = {}; + + // if we were able to obtain a subscription + if (subscription && subscription.endpoint) { + deviceOptions.pushCallback = subscription.endpoint; + let publicKey = subscription.getKey('p256dh'); + let authKey = subscription.getKey('auth'); + if (publicKey && authKey) { + deviceOptions.pushPublicKey = urlsafeBase64Encode(publicKey); + deviceOptions.pushAuthKey = urlsafeBase64Encode(authKey); + } + } + + if (signedInUser.deviceId) { + log.debug("updating existing device details"); + return this.fxAccountsClient.updateDevice( + signedInUser.sessionToken, signedInUser.deviceId, deviceName, deviceOptions); + } + + log.debug("registering new device details"); + return this.fxAccountsClient.registerDevice( + signedInUser.sessionToken, deviceName, this._getDeviceType(), deviceOptions); + }).then(device => + this.currentAccountState.updateUserAccountData({ + deviceId: device.id, + deviceRegistrationVersion: this.DEVICE_REGISTRATION_VERSION + }).then(() => device.id) + ).catch(error => this._handleDeviceError(error, signedInUser.sessionToken)); + }, + + _getDeviceName() { + return Utils.getDeviceName(); + }, + + _getDeviceType() { + return Utils.getDeviceType(); + }, + + _handleDeviceError(error, sessionToken) { + return Promise.resolve().then(() => { + if (error.code === 400) { + if (error.errno === ERRNO_UNKNOWN_DEVICE) { + return this._recoverFromUnknownDevice(); + } + + if (error.errno === ERRNO_DEVICE_SESSION_CONFLICT) { + return this._recoverFromDeviceSessionConflict(error, sessionToken); + } + } + + // `_handleTokenError` re-throws the error. + return this._handleTokenError(error); + }).catch(error => + this._logErrorAndResetDeviceRegistrationVersion(error) + ).catch(() => {}); + }, + + _recoverFromUnknownDevice() { + // FxA did not recognise the device id. Handle it by clearing the device + // id on the account data. At next sync or next sign-in, registration is + // retried and should succeed. + log.warn("unknown device id, clearing the local device data"); + return this.currentAccountState.updateUserAccountData({ deviceId: null }) + .catch(error => this._logErrorAndResetDeviceRegistrationVersion(error)); + }, + + _recoverFromDeviceSessionConflict(error, sessionToken) { + // FxA has already associated this session with a different device id. + // Perhaps we were beaten in a race to register. Handle the conflict: + // 1. Fetch the list of devices for the current user from FxA. + // 2. Look for ourselves in the list. + // 3. If we find a match, set the correct device id and device registration + // version on the account data and return the correct device id. At next + // sync or next sign-in, registration is retried and should succeed. + // 4. If we don't find a match, log the original error. + log.warn("device session conflict, attempting to ascertain the correct device id"); + return this.fxAccountsClient.getDeviceList(sessionToken) + .then(devices => { + const matchingDevices = devices.filter(device => device.isCurrentDevice); + const length = matchingDevices.length; + if (length === 1) { + const deviceId = matchingDevices[0].id; + return this.currentAccountState.updateUserAccountData({ + deviceId, + deviceRegistrationVersion: null + }).then(() => deviceId); + } + if (length > 1) { + log.error("insane server state, " + length + " devices for this session"); + } + return this._logErrorAndResetDeviceRegistrationVersion(error); + }).catch(secondError => { + log.error("failed to recover from device-session conflict", secondError); + this._logErrorAndResetDeviceRegistrationVersion(error) + }); + }, + + _logErrorAndResetDeviceRegistrationVersion(error) { + // Device registration should never cause other operations to fail. + // If we've reached this point, just log the error and reset the device + // registration version on the account data. At next sync or next sign-in, + // registration will be retried. + log.error("device registration failed", error); + return this.currentAccountState.updateUserAccountData({ + deviceRegistrationVersion: null + }).catch(secondError => { + log.error( + "failed to reset the device registration version, device registration won't be retried", + secondError); + }).then(() => {}); + }, + + _handleTokenError(err) { + if (!err || err.code != 401 || err.errno != ERRNO_INVALID_AUTH_TOKEN) { + throw err; + } + log.warn("recovering from invalid token error", err); + return this.accountStatus().then(exists => { + if (!exists) { + // Delete all local account data. Since the account no longer + // exists, we can skip the remote calls. + log.info("token invalidated because the account no longer exists"); + return this.signOut(true); + } + log.info("clearing credentials to handle invalid token error"); + return this.resetCredentials(); + }).then(() => Promise.reject(err)); + }, +}; + + +// 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(); + + return a; +}); diff --git a/services/fxaccounts/FxAccountsClient.jsm b/services/fxaccounts/FxAccountsClient.jsm new file mode 100644 index 000000000..fbe8da2fe --- /dev/null +++ b/services/fxaccounts/FxAccountsClient.jsm @@ -0,0 +1,623 @@ +/* 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/. */ + +this.EXPORTED_SYMBOLS = ["FxAccountsClient"]; + +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/Services.jsm"); +Cu.import("resource://services-common/utils.js"); +Cu.import("resource://services-common/hawkclient.js"); +Cu.import("resource://services-common/hawkrequest.js"); +Cu.import("resource://services-crypto/utils.js"); +Cu.import("resource://gre/modules/FxAccountsCommon.js"); +Cu.import("resource://gre/modules/Credentials.jsm"); + +const HOST_PREF = "identity.fxaccounts.auth.uri"; + +const SIGNIN = "/account/login"; +const SIGNUP = "/account/create"; + +this.FxAccountsClient = function(host = Services.prefs.getCharPref(HOST_PREF)) { + this.host = host; + + // The FxA auth server expects requests to certain endpoints to be authorized + // using Hawk. + this.hawk = new HawkClient(host); + this.hawk.observerPrefix = "FxA:hawk"; + + // Manage server backoff state. C.f. + // https://github.com/mozilla/fxa-auth-server/blob/master/docs/api.md#backoff-protocol + this.backoffError = null; +}; + +this.FxAccountsClient.prototype = { + + /** + * Return client clock offset, in milliseconds, as determined by hawk client. + * Provided because callers should not have to know about hawk + * implementation. + * + * The offset is the number of milliseconds that must be added to the client + * clock to make it equal to the server clock. For example, if the client is + * five minutes ahead of the server, the localtimeOffsetMsec will be -300000. + */ + get localtimeOffsetMsec() { + return this.hawk.localtimeOffsetMsec; + }, + + /* + * Return current time in milliseconds + * + * Not used by this module, but made available to the FxAccounts.jsm + * that uses this client. + */ + now: function() { + return this.hawk.now(); + }, + + /** + * Common code from signIn and signUp. + * + * @param path + * Request URL path. Can be /account/create or /account/login + * @param email + * The email address for the account (utf8) + * @param password + * The user's password + * @param [getKeys=false] + * If set to true the keyFetchToken will be retrieved + * @param [retryOK=true] + * If capitalization of the email is wrong and retryOK is set to true, + * we will retry with the suggested capitalization from the server + * @return Promise + * Returns a promise that resolves to an object: + * { + * authAt: authentication time for the session (seconds since epoch) + * email: the primary email for this account + * keyFetchToken: a key fetch token (hex) + * sessionToken: a session token (hex) + * uid: the user's unique ID (hex) + * unwrapBKey: used to unwrap kB, derived locally from the + * password (not revealed to the FxA server) + * verified (optional): flag indicating verification status of the + * email + * } + */ + _createSession: function(path, email, password, getKeys=false, + retryOK=true) { + return Credentials.setup(email, password).then((creds) => { + let data = { + authPW: CommonUtils.bytesAsHex(creds.authPW), + email: email, + }; + let keys = getKeys ? "?keys=true" : ""; + + return this._request(path + keys, "POST", null, data).then( + // Include the canonical capitalization of the email in the response so + // the caller can set its signed-in user state accordingly. + result => { + result.email = data.email; + result.unwrapBKey = CommonUtils.bytesAsHex(creds.unwrapBKey); + + return result; + }, + error => { + log.debug("Session creation failed", error); + // If the user entered an email with different capitalization from + // what's stored in the database (e.g., Greta.Garbo@gmail.COM as + // opposed to greta.garbo@gmail.com), the server will respond with a + // errno 120 (code 400) and the expected capitalization of the email. + // We retry with this email exactly once. If successful, we use the + // server's version of the email as the signed-in-user's email. This + // is necessary because the email also serves as salt; so we must be + // in agreement with the server on capitalization. + // + // API reference: + // https://github.com/mozilla/fxa-auth-server/blob/master/docs/api.md + if (ERRNO_INCORRECT_EMAIL_CASE === error.errno && retryOK) { + if (!error.email) { + log.error("Server returned errno 120 but did not provide email"); + throw error; + } + return this._createSession(path, error.email, password, getKeys, + false); + } + throw error; + } + ); + }); + }, + + /** + * Create a new Firefox Account and authenticate + * + * @param email + * The email address for the account (utf8) + * @param password + * The user's password + * @param [getKeys=false] + * If set to true the keyFetchToken will be retrieved + * @return Promise + * Returns a promise that resolves to an object: + * { + * uid: the user's unique ID (hex) + * sessionToken: a session token (hex) + * keyFetchToken: a key fetch token (hex), + * unwrapBKey: used to unwrap kB, derived locally from the + * password (not revealed to the FxA server) + * } + */ + signUp: function(email, password, getKeys=false) { + return this._createSession(SIGNUP, email, password, getKeys, + false /* no retry */); + }, + + /** + * Authenticate and create a new session with the Firefox Account API server + * + * @param email + * The email address for the account (utf8) + * @param password + * The user's password + * @param [getKeys=false] + * If set to true the keyFetchToken will be retrieved + * @return Promise + * Returns a promise that resolves to an object: + * { + * authAt: authentication time for the session (seconds since epoch) + * email: the primary email for this account + * keyFetchToken: a key fetch token (hex) + * sessionToken: a session token (hex) + * uid: the user's unique ID (hex) + * unwrapBKey: used to unwrap kB, derived locally from the + * password (not revealed to the FxA server) + * verified: flag indicating verification status of the email + * } + */ + signIn: function signIn(email, password, getKeys=false) { + return this._createSession(SIGNIN, email, password, getKeys, + true /* retry */); + }, + + /** + * Check the status of a session given a session token + * + * @param sessionTokenHex + * The session token encoded in hex + * @return Promise + * Resolves with a boolean indicating if the session is still valid + */ + sessionStatus: function (sessionTokenHex) { + return this._request("/session/status", "GET", + deriveHawkCredentials(sessionTokenHex, "sessionToken")).then( + () => Promise.resolve(true), + error => { + if (isInvalidTokenError(error)) { + return Promise.resolve(false); + } + throw error; + } + ); + }, + + /** + * Destroy the current session with the Firefox Account API server + * + * @param sessionTokenHex + * The session token encoded in hex + * @return Promise + */ + signOut: function (sessionTokenHex, options = {}) { + let path = "/session/destroy"; + if (options.service) { + path += "?service=" + encodeURIComponent(options.service); + } + return this._request(path, "POST", + deriveHawkCredentials(sessionTokenHex, "sessionToken")); + }, + + /** + * Check the verification status of the user's FxA email address + * + * @param sessionTokenHex + * The current session token encoded in hex + * @return Promise + */ + recoveryEmailStatus: function (sessionTokenHex, options = {}) { + let path = "/recovery_email/status"; + if (options.reason) { + path += "?reason=" + encodeURIComponent(options.reason); + } + + return this._request(path, "GET", + deriveHawkCredentials(sessionTokenHex, "sessionToken")); + }, + + /** + * Resend the verification email for the user + * + * @param sessionTokenHex + * The current token encoded in hex + * @return Promise + */ + resendVerificationEmail: function(sessionTokenHex) { + return this._request("/recovery_email/resend_code", "POST", + deriveHawkCredentials(sessionTokenHex, "sessionToken")); + }, + + /** + * Retrieve encryption keys + * + * @param keyFetchTokenHex + * A one-time use key fetch token encoded in hex + * @return Promise + * Returns a promise that resolves to an object: + * { + * kA: an encryption key for recevorable data (bytes) + * wrapKB: an encryption key that requires knowledge of the + * user's password (bytes) + * } + */ + accountKeys: function (keyFetchTokenHex) { + let creds = deriveHawkCredentials(keyFetchTokenHex, "keyFetchToken"); + let keyRequestKey = creds.extra.slice(0, 32); + let morecreds = CryptoUtils.hkdf(keyRequestKey, undefined, + Credentials.keyWord("account/keys"), 3 * 32); + let respHMACKey = morecreds.slice(0, 32); + let respXORKey = morecreds.slice(32, 96); + + return this._request("/account/keys", "GET", creds).then(resp => { + if (!resp.bundle) { + throw new Error("failed to retrieve keys"); + } + + let bundle = CommonUtils.hexToBytes(resp.bundle); + let mac = bundle.slice(-32); + + let hasher = CryptoUtils.makeHMACHasher(Ci.nsICryptoHMAC.SHA256, + CryptoUtils.makeHMACKey(respHMACKey)); + + let bundleMAC = CryptoUtils.digestBytes(bundle.slice(0, -32), hasher); + if (mac !== bundleMAC) { + throw new Error("error unbundling encryption keys"); + } + + let keyAWrapB = CryptoUtils.xor(respXORKey, bundle.slice(0, 64)); + + return { + kA: keyAWrapB.slice(0, 32), + wrapKB: keyAWrapB.slice(32) + }; + }); + }, + + /** + * Sends a public key to the FxA API server and returns a signed certificate + * + * @param sessionTokenHex + * The current session token encoded in hex + * @param serializedPublicKey + * A public key (usually generated by jwcrypto) + * @param lifetime + * The lifetime of the certificate + * @return Promise + * Returns a promise that resolves to the signed certificate. + * The certificate can be used to generate a Persona assertion. + * @throws a new Error + * wrapping any of these HTTP code/errno pairs: + * https://github.com/mozilla/fxa-auth-server/blob/master/docs/api.md#response-12 + */ + signCertificate: function (sessionTokenHex, serializedPublicKey, lifetime) { + let creds = deriveHawkCredentials(sessionTokenHex, "sessionToken"); + + let body = { publicKey: serializedPublicKey, + duration: lifetime }; + return Promise.resolve() + .then(_ => this._request("/certificate/sign", "POST", creds, body)) + .then(resp => resp.cert, + err => { + log.error("HAWK.signCertificate error: " + JSON.stringify(err)); + throw err; + }); + }, + + /** + * Determine if an account exists + * + * @param email + * The email address to check + * @return Promise + * The promise resolves to true if the account exists, or false + * if it doesn't. The promise is rejected on other errors. + */ + accountExists: function (email) { + return this.signIn(email, "").then( + (cantHappen) => { + throw new Error("How did I sign in with an empty password?"); + }, + (expectedError) => { + switch (expectedError.errno) { + case ERRNO_ACCOUNT_DOES_NOT_EXIST: + return false; + break; + case ERRNO_INCORRECT_PASSWORD: + return true; + break; + default: + // not so expected, any more ... + throw expectedError; + break; + } + } + ); + }, + + /** + * Given the uid of an existing account (not an arbitrary email), ask + * the server if it still exists via /account/status. + * + * Used for differentiating between password change and account deletion. + */ + accountStatus: function(uid) { + return this._request("/account/status?uid="+uid, "GET").then( + (result) => { + return result.exists; + }, + (error) => { + log.error("accountStatus failed with: " + error); + return Promise.reject(error); + } + ); + }, + + /** + * Register a new device + * + * @method registerDevice + * @param sessionTokenHex + * Session token obtained from signIn + * @param name + * Device name + * @param type + * Device type (mobile|desktop) + * @param [options] + * Extra device options + * @param [options.pushCallback] + * `pushCallback` push endpoint callback + * @param [options.pushPublicKey] + * `pushPublicKey` push public key (URLSafe Base64 string) + * @param [options.pushAuthKey] + * `pushAuthKey` push auth secret (URLSafe Base64 string) + * @return Promise + * Resolves to an object: + * { + * id: Device identifier + * createdAt: Creation time (milliseconds since epoch) + * name: Name of device + * type: Type of device (mobile|desktop) + * } + */ + registerDevice(sessionTokenHex, name, type, options = {}) { + let path = "/account/device"; + + let creds = deriveHawkCredentials(sessionTokenHex, "sessionToken"); + let body = { name, type }; + + if (options.pushCallback) { + body.pushCallback = options.pushCallback; + } + if (options.pushPublicKey && options.pushAuthKey) { + body.pushPublicKey = options.pushPublicKey; + body.pushAuthKey = options.pushAuthKey; + } + + return this._request(path, "POST", creds, body); + }, + + /** + * Sends a message to other devices. Must conform with the push payload schema: + * https://github.com/mozilla/fxa-auth-server/blob/master/docs/pushpayloads.schema.json + * + * @method notifyDevice + * @param sessionTokenHex + * Session token obtained from signIn + * @param deviceIds + * Devices to send the message to + * @param payload + * Data to send with the message + * @return Promise + * Resolves to an empty object: + * {} + */ + notifyDevices(sessionTokenHex, deviceIds, payload, TTL = 0) { + const body = { + to: deviceIds, + payload, + TTL + }; + return this._request("/account/devices/notify", "POST", + deriveHawkCredentials(sessionTokenHex, "sessionToken"), body); + }, + + /** + * Update the session or name for an existing device + * + * @method updateDevice + * @param sessionTokenHex + * Session token obtained from signIn + * @param id + * Device identifier + * @param name + * Device name + * @param [options] + * Extra device options + * @param [options.pushCallback] + * `pushCallback` push endpoint callback + * @param [options.pushPublicKey] + * `pushPublicKey` push public key (URLSafe Base64 string) + * @param [options.pushAuthKey] + * `pushAuthKey` push auth secret (URLSafe Base64 string) + * @return Promise + * Resolves to an object: + * { + * id: Device identifier + * name: Device name + * } + */ + updateDevice(sessionTokenHex, id, name, options = {}) { + let path = "/account/device"; + + let creds = deriveHawkCredentials(sessionTokenHex, "sessionToken"); + let body = { id, name }; + if (options.pushCallback) { + body.pushCallback = options.pushCallback; + } + if (options.pushPublicKey && options.pushAuthKey) { + body.pushPublicKey = options.pushPublicKey; + body.pushAuthKey = options.pushAuthKey; + } + + return this._request(path, "POST", creds, body); + }, + + /** + * Delete a device and its associated session token, signing the user + * out of the server. + * + * @method signOutAndDestroyDevice + * @param sessionTokenHex + * Session token obtained from signIn + * @param id + * Device identifier + * @param [options] + * Options object + * @param [options.service] + * `service` query parameter + * @return Promise + * Resolves to an empty object: + * {} + */ + signOutAndDestroyDevice(sessionTokenHex, id, options = {}) { + let path = "/account/device/destroy"; + + if (options.service) { + path += "?service=" + encodeURIComponent(options.service); + } + + let creds = deriveHawkCredentials(sessionTokenHex, "sessionToken"); + let body = { id }; + + return this._request(path, "POST", creds, body); + }, + + /** + * Get a list of currently registered devices + * + * @method getDeviceList + * @param sessionTokenHex + * Session token obtained from signIn + * @return Promise + * Resolves to an array of objects: + * [ + * { + * id: Device id + * isCurrentDevice: Boolean indicating whether the item + * represents the current device + * name: Device name + * type: Device type (mobile|desktop) + * }, + * ... + * ] + */ + getDeviceList(sessionTokenHex) { + let path = "/account/devices"; + let creds = deriveHawkCredentials(sessionTokenHex, "sessionToken"); + + return this._request(path, "GET", creds, {}); + }, + + _clearBackoff: function() { + this.backoffError = null; + }, + + /** + * A general method for sending raw API calls to the FxA auth server. + * All request bodies and responses are JSON. + * + * @param path + * API endpoint path + * @param method + * The HTTP request method + * @param credentials + * Hawk credentials + * @param jsonPayload + * A JSON payload + * @return Promise + * Returns a promise that resolves to the JSON response of the API call, + * or is rejected with an error. Error responses have the following properties: + * { + * "code": 400, // matches the HTTP status code + * "errno": 107, // stable application-level error number + * "error": "Bad Request", // string description of the error type + * "message": "the value of salt is not allowed to be undefined", + * "info": "https://docs.dev.lcip.og/errors/1234" // link to more info on the error + * } + */ + _request: function hawkRequest(path, method, credentials, jsonPayload) { + let deferred = Promise.defer(); + + // We were asked to back off. + if (this.backoffError) { + log.debug("Received new request during backoff, re-rejecting."); + deferred.reject(this.backoffError); + return deferred.promise; + } + + this.hawk.request(path, method, credentials, jsonPayload).then( + (response) => { + try { + let responseObj = JSON.parse(response.body); + deferred.resolve(responseObj); + } catch (err) { + log.error("json parse error on response: " + response.body); + deferred.reject({error: err}); + } + }, + + (error) => { + log.error("error " + method + "ing " + path + ": " + JSON.stringify(error)); + if (error.retryAfter) { + log.debug("Received backoff response; caching error as flag."); + this.backoffError = error; + // Schedule clearing of cached-error-as-flag. + CommonUtils.namedTimer( + this._clearBackoff, + error.retryAfter * 1000, + this, + "fxaBackoffTimer" + ); + } + deferred.reject(error); + } + ); + + return deferred.promise; + }, +}; + +function isInvalidTokenError(error) { + if (error.code != 401) { + return false; + } + switch (error.errno) { + case ERRNO_INVALID_AUTH_TOKEN: + case ERRNO_INVALID_AUTH_TIMESTAMP: + case ERRNO_INVALID_AUTH_NONCE: + return true; + } + return false; +} diff --git a/services/fxaccounts/FxAccountsCommon.js b/services/fxaccounts/FxAccountsCommon.js new file mode 100644 index 000000000..71fe78a50 --- /dev/null +++ b/services/fxaccounts/FxAccountsCommon.js @@ -0,0 +1,368 @@ +/* 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/. */ + +var { interfaces: Ci, utils: Cu } = Components; + +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); +Cu.import("resource://gre/modules/Services.jsm"); +Cu.import("resource://gre/modules/Log.jsm"); + +// loglevel should be one of "Fatal", "Error", "Warn", "Info", "Config", +// "Debug", "Trace" or "All". If none is specified, "Debug" will be used by +// default. Note "Debug" is usually appropriate so that when this log is +// included in the Sync file logs we get verbose output. +const PREF_LOG_LEVEL = "identity.fxaccounts.loglevel"; +// The level of messages that will be dumped to the console. If not specified, +// "Error" will be used. +const PREF_LOG_LEVEL_DUMP = "identity.fxaccounts.log.appender.dump"; + +// A pref that can be set so "sensitive" information (eg, personally +// identifiable info, credentials, etc) will be logged. +const PREF_LOG_SENSITIVE_DETAILS = "identity.fxaccounts.log.sensitive"; + +var exports = Object.create(null); + +XPCOMUtils.defineLazyGetter(exports, 'log', function() { + let log = Log.repository.getLogger("FirefoxAccounts"); + // We set the log level to debug, but the default dump appender is set to + // the level reflected in the pref. Other code that consumes FxA may then + // choose to add another appender at a different level. + log.level = Log.Level.Debug; + let appender = new Log.DumpAppender(); + appender.level = Log.Level.Error; + + log.addAppender(appender); + try { + // The log itself. + let level = + Services.prefs.getPrefType(PREF_LOG_LEVEL) == Ci.nsIPrefBranch.PREF_STRING + && Services.prefs.getCharPref(PREF_LOG_LEVEL); + log.level = Log.Level[level] || Log.Level.Debug; + + // The appender. + level = + Services.prefs.getPrefType(PREF_LOG_LEVEL_DUMP) == Ci.nsIPrefBranch.PREF_STRING + && Services.prefs.getCharPref(PREF_LOG_LEVEL_DUMP); + appender.level = Log.Level[level] || Log.Level.Error; + } catch (e) { + log.error(e); + } + + return log; +}); + +// A boolean to indicate if personally identifiable information (or anything +// else sensitive, such as credentials) should be logged. +XPCOMUtils.defineLazyGetter(exports, 'logPII', function() { + try { + return Services.prefs.getBoolPref(PREF_LOG_SENSITIVE_DETAILS); + } catch (_) { + return false; + } +}); + +exports.FXACCOUNTS_PERMISSION = "firefox-accounts"; + +exports.DATA_FORMAT_VERSION = 1; +exports.DEFAULT_STORAGE_FILENAME = "signedInUser.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 +// period). +exports.ASSERTION_USE_PERIOD = 1000 * 60 * 5; // 5 minutes +exports.CERT_LIFETIME = 1000 * 3600 * 6; // 6 hours +exports.KEY_LIFETIME = 1000 * 3600 * 12; // 12 hours + +// After we start polling for account verification, we stop polling when this +// many milliseconds have elapsed. +exports.POLL_SESSION = 1000 * 60 * 20; // 20 minutes + +// Observer notifications. +exports.ONLOGIN_NOTIFICATION = "fxaccounts:onlogin"; +exports.ONVERIFIED_NOTIFICATION = "fxaccounts:onverified"; +exports.ONLOGOUT_NOTIFICATION = "fxaccounts:onlogout"; +// Internal to services/fxaccounts only +exports.ON_FXA_UPDATE_NOTIFICATION = "fxaccounts:update"; +exports.ON_DEVICE_DISCONNECTED_NOTIFICATION = "fxaccounts:device_disconnected"; +exports.ON_PASSWORD_CHANGED_NOTIFICATION = "fxaccounts:password_changed"; +exports.ON_PASSWORD_RESET_NOTIFICATION = "fxaccounts:password_reset"; +exports.ON_COLLECTION_CHANGED_NOTIFICATION = "sync:collection_changed"; + +exports.FXA_PUSH_SCOPE_ACCOUNT_UPDATE = "chrome://fxa-device-update"; + +exports.ON_PROFILE_CHANGE_NOTIFICATION = "fxaccounts:profilechange"; +exports.ON_ACCOUNT_STATE_CHANGE_NOTIFICATION = "fxaccounts:statechange"; + +// UI Requests. +exports.UI_REQUEST_SIGN_IN_FLOW = "signInFlow"; +exports.UI_REQUEST_REFRESH_AUTH = "refreshAuthentication"; + +// The OAuth client ID for Firefox Desktop +exports.FX_OAUTH_CLIENT_ID = "5882386c6d801776"; + +// Firefox Accounts WebChannel ID +exports.WEBCHANNEL_ID = "account_updates"; + +// Server errno. +// From https://github.com/mozilla/fxa-auth-server/blob/master/docs/api.md#response-format +exports.ERRNO_ACCOUNT_ALREADY_EXISTS = 101; +exports.ERRNO_ACCOUNT_DOES_NOT_EXIST = 102; +exports.ERRNO_INCORRECT_PASSWORD = 103; +exports.ERRNO_UNVERIFIED_ACCOUNT = 104; +exports.ERRNO_INVALID_VERIFICATION_CODE = 105; +exports.ERRNO_NOT_VALID_JSON_BODY = 106; +exports.ERRNO_INVALID_BODY_PARAMETERS = 107; +exports.ERRNO_MISSING_BODY_PARAMETERS = 108; +exports.ERRNO_INVALID_REQUEST_SIGNATURE = 109; +exports.ERRNO_INVALID_AUTH_TOKEN = 110; +exports.ERRNO_INVALID_AUTH_TIMESTAMP = 111; +exports.ERRNO_MISSING_CONTENT_LENGTH = 112; +exports.ERRNO_REQUEST_BODY_TOO_LARGE = 113; +exports.ERRNO_TOO_MANY_CLIENT_REQUESTS = 114; +exports.ERRNO_INVALID_AUTH_NONCE = 115; +exports.ERRNO_ENDPOINT_NO_LONGER_SUPPORTED = 116; +exports.ERRNO_INCORRECT_LOGIN_METHOD = 117; +exports.ERRNO_INCORRECT_KEY_RETRIEVAL_METHOD = 118; +exports.ERRNO_INCORRECT_API_VERSION = 119; +exports.ERRNO_INCORRECT_EMAIL_CASE = 120; +exports.ERRNO_ACCOUNT_LOCKED = 121; +exports.ERRNO_ACCOUNT_UNLOCKED = 122; +exports.ERRNO_UNKNOWN_DEVICE = 123; +exports.ERRNO_DEVICE_SESSION_CONFLICT = 124; +exports.ERRNO_SERVICE_TEMP_UNAVAILABLE = 201; +exports.ERRNO_PARSE = 997; +exports.ERRNO_NETWORK = 998; +exports.ERRNO_UNKNOWN_ERROR = 999; + +// Offset oauth server errnos so they don't conflict with auth server errnos +exports.OAUTH_SERVER_ERRNO_OFFSET = 1000; + +// OAuth Server errno. +exports.ERRNO_UNKNOWN_CLIENT_ID = 101 + exports.OAUTH_SERVER_ERRNO_OFFSET; +exports.ERRNO_INCORRECT_CLIENT_SECRET = 102 + exports.OAUTH_SERVER_ERRNO_OFFSET; +exports.ERRNO_INCORRECT_REDIRECT_URI = 103 + exports.OAUTH_SERVER_ERRNO_OFFSET; +exports.ERRNO_INVALID_FXA_ASSERTION = 104 + exports.OAUTH_SERVER_ERRNO_OFFSET; +exports.ERRNO_UNKNOWN_CODE = 105 + exports.OAUTH_SERVER_ERRNO_OFFSET; +exports.ERRNO_INCORRECT_CODE = 106 + exports.OAUTH_SERVER_ERRNO_OFFSET; +exports.ERRNO_EXPIRED_CODE = 107 + exports.OAUTH_SERVER_ERRNO_OFFSET; +exports.ERRNO_OAUTH_INVALID_TOKEN = 108 + exports.OAUTH_SERVER_ERRNO_OFFSET; +exports.ERRNO_INVALID_REQUEST_PARAM = 109 + exports.OAUTH_SERVER_ERRNO_OFFSET; +exports.ERRNO_INVALID_RESPONSE_TYPE = 110 + exports.OAUTH_SERVER_ERRNO_OFFSET; +exports.ERRNO_UNAUTHORIZED = 111 + exports.OAUTH_SERVER_ERRNO_OFFSET; +exports.ERRNO_FORBIDDEN = 112 + exports.OAUTH_SERVER_ERRNO_OFFSET; +exports.ERRNO_INVALID_CONTENT_TYPE = 113 + exports.OAUTH_SERVER_ERRNO_OFFSET; + +// Errors. +exports.ERROR_ACCOUNT_ALREADY_EXISTS = "ACCOUNT_ALREADY_EXISTS"; +exports.ERROR_ACCOUNT_DOES_NOT_EXIST = "ACCOUNT_DOES_NOT_EXIST "; +exports.ERROR_ACCOUNT_LOCKED = "ACCOUNT_LOCKED"; +exports.ERROR_ACCOUNT_UNLOCKED = "ACCOUNT_UNLOCKED"; +exports.ERROR_ALREADY_SIGNED_IN_USER = "ALREADY_SIGNED_IN_USER"; +exports.ERROR_DEVICE_SESSION_CONFLICT = "DEVICE_SESSION_CONFLICT"; +exports.ERROR_ENDPOINT_NO_LONGER_SUPPORTED = "ENDPOINT_NO_LONGER_SUPPORTED"; +exports.ERROR_INCORRECT_API_VERSION = "INCORRECT_API_VERSION"; +exports.ERROR_INCORRECT_EMAIL_CASE = "INCORRECT_EMAIL_CASE"; +exports.ERROR_INCORRECT_KEY_RETRIEVAL_METHOD = "INCORRECT_KEY_RETRIEVAL_METHOD"; +exports.ERROR_INCORRECT_LOGIN_METHOD = "INCORRECT_LOGIN_METHOD"; +exports.ERROR_INVALID_EMAIL = "INVALID_EMAIL"; +exports.ERROR_INVALID_AUDIENCE = "INVALID_AUDIENCE"; +exports.ERROR_INVALID_AUTH_TOKEN = "INVALID_AUTH_TOKEN"; +exports.ERROR_INVALID_AUTH_TIMESTAMP = "INVALID_AUTH_TIMESTAMP"; +exports.ERROR_INVALID_AUTH_NONCE = "INVALID_AUTH_NONCE"; +exports.ERROR_INVALID_BODY_PARAMETERS = "INVALID_BODY_PARAMETERS"; +exports.ERROR_INVALID_PASSWORD = "INVALID_PASSWORD"; +exports.ERROR_INVALID_VERIFICATION_CODE = "INVALID_VERIFICATION_CODE"; +exports.ERROR_INVALID_REFRESH_AUTH_VALUE = "INVALID_REFRESH_AUTH_VALUE"; +exports.ERROR_INVALID_REQUEST_SIGNATURE = "INVALID_REQUEST_SIGNATURE"; +exports.ERROR_INTERNAL_INVALID_USER = "INTERNAL_ERROR_INVALID_USER"; +exports.ERROR_MISSING_BODY_PARAMETERS = "MISSING_BODY_PARAMETERS"; +exports.ERROR_MISSING_CONTENT_LENGTH = "MISSING_CONTENT_LENGTH"; +exports.ERROR_NO_TOKEN_SESSION = "NO_TOKEN_SESSION"; +exports.ERROR_NO_SILENT_REFRESH_AUTH = "NO_SILENT_REFRESH_AUTH"; +exports.ERROR_NOT_VALID_JSON_BODY = "NOT_VALID_JSON_BODY"; +exports.ERROR_OFFLINE = "OFFLINE"; +exports.ERROR_PERMISSION_DENIED = "PERMISSION_DENIED"; +exports.ERROR_REQUEST_BODY_TOO_LARGE = "REQUEST_BODY_TOO_LARGE"; +exports.ERROR_SERVER_ERROR = "SERVER_ERROR"; +exports.ERROR_SYNC_DISABLED = "SYNC_DISABLED"; +exports.ERROR_TOO_MANY_CLIENT_REQUESTS = "TOO_MANY_CLIENT_REQUESTS"; +exports.ERROR_SERVICE_TEMP_UNAVAILABLE = "SERVICE_TEMPORARY_UNAVAILABLE"; +exports.ERROR_UI_ERROR = "UI_ERROR"; +exports.ERROR_UI_REQUEST = "UI_REQUEST"; +exports.ERROR_PARSE = "PARSE_ERROR"; +exports.ERROR_NETWORK = "NETWORK_ERROR"; +exports.ERROR_UNKNOWN = "UNKNOWN_ERROR"; +exports.ERROR_UNKNOWN_DEVICE = "UNKNOWN_DEVICE"; +exports.ERROR_UNVERIFIED_ACCOUNT = "UNVERIFIED_ACCOUNT"; + +// OAuth errors. +exports.ERROR_UNKNOWN_CLIENT_ID = "UNKNOWN_CLIENT_ID"; +exports.ERROR_INCORRECT_CLIENT_SECRET = "INCORRECT_CLIENT_SECRET"; +exports.ERROR_INCORRECT_REDIRECT_URI = "INCORRECT_REDIRECT_URI"; +exports.ERROR_INVALID_FXA_ASSERTION = "INVALID_FXA_ASSERTION"; +exports.ERROR_UNKNOWN_CODE = "UNKNOWN_CODE"; +exports.ERROR_INCORRECT_CODE = "INCORRECT_CODE"; +exports.ERROR_EXPIRED_CODE = "EXPIRED_CODE"; +exports.ERROR_OAUTH_INVALID_TOKEN = "OAUTH_INVALID_TOKEN"; +exports.ERROR_INVALID_REQUEST_PARAM = "INVALID_REQUEST_PARAM"; +exports.ERROR_INVALID_RESPONSE_TYPE = "INVALID_RESPONSE_TYPE"; +exports.ERROR_UNAUTHORIZED = "UNAUTHORIZED"; +exports.ERROR_FORBIDDEN = "FORBIDDEN"; +exports.ERROR_INVALID_CONTENT_TYPE = "INVALID_CONTENT_TYPE"; + +// Additional generic error classes for external consumers +exports.ERROR_NO_ACCOUNT = "NO_ACCOUNT"; +exports.ERROR_AUTH_ERROR = "AUTH_ERROR"; +exports.ERROR_INVALID_PARAMETER = "INVALID_PARAMETER"; + +// Status code errors +exports.ERROR_CODE_METHOD_NOT_ALLOWED = 405; +exports.ERROR_MSG_METHOD_NOT_ALLOWED = "METHOD_NOT_ALLOWED"; + +// FxAccounts has the ability to "split" the credentials between a plain-text +// JSON file in the profile dir and in the login manager. +// In order to prevent new fields accidentally ending up in the "wrong" place, +// all fields stored are listed here. + +// The fields we save in the plaintext JSON. +// See bug 1013064 comments 23-25 for why the sessionToken is "safe" +exports.FXA_PWDMGR_PLAINTEXT_FIELDS = new Set( + ["email", "verified", "authAt", "sessionToken", "uid", "oauthTokens", "profile", + "deviceId", "deviceRegistrationVersion"]); + +// Fields we store in secure storage if it exists. +exports.FXA_PWDMGR_SECURE_FIELDS = new Set( + ["kA", "kB", "keyFetchToken", "unwrapBKey", "assertion"]); + +// Fields we keep in memory and don't persist anywhere. +exports.FXA_PWDMGR_MEMORY_FIELDS = new Set( + ["cert", "keyPair"]); + +// A whitelist of fields that remain in storage when the user needs to +// reauthenticate. All other fields will be removed. +exports.FXA_PWDMGR_REAUTH_WHITELIST = new Set( + ["email", "uid", "profile", "deviceId", "deviceRegistrationVersion", "verified"]); + +// 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 = {}; + +// Error mapping +exports.ERROR_TO_GENERAL_ERROR_CLASS = {}; + +for (let id in exports) { + this[id] = exports[id]; +} + +// Allow this file to be imported via Components.utils.import(). +this.EXPORTED_SYMBOLS = Object.keys(exports); + +// Set these up now that everything has been loaded into |this|. +SERVER_ERRNO_TO_ERROR[ERRNO_ACCOUNT_ALREADY_EXISTS] = ERROR_ACCOUNT_ALREADY_EXISTS; +SERVER_ERRNO_TO_ERROR[ERRNO_ACCOUNT_DOES_NOT_EXIST] = ERROR_ACCOUNT_DOES_NOT_EXIST; +SERVER_ERRNO_TO_ERROR[ERRNO_INCORRECT_PASSWORD] = ERROR_INVALID_PASSWORD; +SERVER_ERRNO_TO_ERROR[ERRNO_UNVERIFIED_ACCOUNT] = ERROR_UNVERIFIED_ACCOUNT; +SERVER_ERRNO_TO_ERROR[ERRNO_INVALID_VERIFICATION_CODE] = ERROR_INVALID_VERIFICATION_CODE; +SERVER_ERRNO_TO_ERROR[ERRNO_NOT_VALID_JSON_BODY] = ERROR_NOT_VALID_JSON_BODY; +SERVER_ERRNO_TO_ERROR[ERRNO_INVALID_BODY_PARAMETERS] = ERROR_INVALID_BODY_PARAMETERS; +SERVER_ERRNO_TO_ERROR[ERRNO_MISSING_BODY_PARAMETERS] = ERROR_MISSING_BODY_PARAMETERS; +SERVER_ERRNO_TO_ERROR[ERRNO_INVALID_REQUEST_SIGNATURE] = ERROR_INVALID_REQUEST_SIGNATURE; +SERVER_ERRNO_TO_ERROR[ERRNO_INVALID_AUTH_TOKEN] = ERROR_INVALID_AUTH_TOKEN; +SERVER_ERRNO_TO_ERROR[ERRNO_INVALID_AUTH_TIMESTAMP] = ERROR_INVALID_AUTH_TIMESTAMP; +SERVER_ERRNO_TO_ERROR[ERRNO_MISSING_CONTENT_LENGTH] = ERROR_MISSING_CONTENT_LENGTH; +SERVER_ERRNO_TO_ERROR[ERRNO_REQUEST_BODY_TOO_LARGE] = ERROR_REQUEST_BODY_TOO_LARGE; +SERVER_ERRNO_TO_ERROR[ERRNO_TOO_MANY_CLIENT_REQUESTS] = ERROR_TOO_MANY_CLIENT_REQUESTS; +SERVER_ERRNO_TO_ERROR[ERRNO_INVALID_AUTH_NONCE] = ERROR_INVALID_AUTH_NONCE; +SERVER_ERRNO_TO_ERROR[ERRNO_ENDPOINT_NO_LONGER_SUPPORTED] = ERROR_ENDPOINT_NO_LONGER_SUPPORTED; +SERVER_ERRNO_TO_ERROR[ERRNO_INCORRECT_LOGIN_METHOD] = ERROR_INCORRECT_LOGIN_METHOD; +SERVER_ERRNO_TO_ERROR[ERRNO_INCORRECT_KEY_RETRIEVAL_METHOD] = ERROR_INCORRECT_KEY_RETRIEVAL_METHOD; +SERVER_ERRNO_TO_ERROR[ERRNO_INCORRECT_API_VERSION] = ERROR_INCORRECT_API_VERSION; +SERVER_ERRNO_TO_ERROR[ERRNO_INCORRECT_EMAIL_CASE] = ERROR_INCORRECT_EMAIL_CASE; +SERVER_ERRNO_TO_ERROR[ERRNO_ACCOUNT_LOCKED] = ERROR_ACCOUNT_LOCKED; +SERVER_ERRNO_TO_ERROR[ERRNO_ACCOUNT_UNLOCKED] = ERROR_ACCOUNT_UNLOCKED; +SERVER_ERRNO_TO_ERROR[ERRNO_UNKNOWN_DEVICE] = ERROR_UNKNOWN_DEVICE; +SERVER_ERRNO_TO_ERROR[ERRNO_DEVICE_SESSION_CONFLICT] = ERROR_DEVICE_SESSION_CONFLICT; +SERVER_ERRNO_TO_ERROR[ERRNO_SERVICE_TEMP_UNAVAILABLE] = ERROR_SERVICE_TEMP_UNAVAILABLE; +SERVER_ERRNO_TO_ERROR[ERRNO_UNKNOWN_ERROR] = ERROR_UNKNOWN; +SERVER_ERRNO_TO_ERROR[ERRNO_NETWORK] = ERROR_NETWORK; + +// oauth +SERVER_ERRNO_TO_ERROR[ERRNO_UNKNOWN_CLIENT_ID] = ERROR_UNKNOWN_CLIENT_ID; +SERVER_ERRNO_TO_ERROR[ERRNO_INCORRECT_CLIENT_SECRET] = ERROR_INCORRECT_CLIENT_SECRET; +SERVER_ERRNO_TO_ERROR[ERRNO_INCORRECT_REDIRECT_URI] = ERROR_INCORRECT_REDIRECT_URI; +SERVER_ERRNO_TO_ERROR[ERRNO_INVALID_FXA_ASSERTION] = ERROR_INVALID_FXA_ASSERTION; +SERVER_ERRNO_TO_ERROR[ERRNO_UNKNOWN_CODE] = ERROR_UNKNOWN_CODE; +SERVER_ERRNO_TO_ERROR[ERRNO_INCORRECT_CODE] = ERROR_INCORRECT_CODE; +SERVER_ERRNO_TO_ERROR[ERRNO_EXPIRED_CODE] = ERROR_EXPIRED_CODE; +SERVER_ERRNO_TO_ERROR[ERRNO_OAUTH_INVALID_TOKEN] = ERROR_OAUTH_INVALID_TOKEN; +SERVER_ERRNO_TO_ERROR[ERRNO_INVALID_REQUEST_PARAM] = ERROR_INVALID_REQUEST_PARAM; +SERVER_ERRNO_TO_ERROR[ERRNO_INVALID_RESPONSE_TYPE] = ERROR_INVALID_RESPONSE_TYPE; +SERVER_ERRNO_TO_ERROR[ERRNO_UNAUTHORIZED] = ERROR_UNAUTHORIZED; +SERVER_ERRNO_TO_ERROR[ERRNO_FORBIDDEN] = ERROR_FORBIDDEN; +SERVER_ERRNO_TO_ERROR[ERRNO_INVALID_CONTENT_TYPE] = ERROR_INVALID_CONTENT_TYPE; + + +// Map internal errors to more generic error classes for consumers +ERROR_TO_GENERAL_ERROR_CLASS[ERROR_ACCOUNT_ALREADY_EXISTS] = ERROR_AUTH_ERROR; +ERROR_TO_GENERAL_ERROR_CLASS[ERROR_ACCOUNT_DOES_NOT_EXIST] = ERROR_AUTH_ERROR; +ERROR_TO_GENERAL_ERROR_CLASS[ERROR_ACCOUNT_LOCKED] = ERROR_AUTH_ERROR; +ERROR_TO_GENERAL_ERROR_CLASS[ERROR_ACCOUNT_UNLOCKED] = ERROR_AUTH_ERROR; +ERROR_TO_GENERAL_ERROR_CLASS[ERROR_ALREADY_SIGNED_IN_USER] = ERROR_AUTH_ERROR; +ERROR_TO_GENERAL_ERROR_CLASS[ERROR_DEVICE_SESSION_CONFLICT] = ERROR_AUTH_ERROR; +ERROR_TO_GENERAL_ERROR_CLASS[ERROR_ENDPOINT_NO_LONGER_SUPPORTED] = ERROR_AUTH_ERROR; +ERROR_TO_GENERAL_ERROR_CLASS[ERROR_INCORRECT_API_VERSION] = ERROR_AUTH_ERROR; +ERROR_TO_GENERAL_ERROR_CLASS[ERROR_INCORRECT_EMAIL_CASE] = ERROR_AUTH_ERROR; +ERROR_TO_GENERAL_ERROR_CLASS[ERROR_INCORRECT_KEY_RETRIEVAL_METHOD] = ERROR_AUTH_ERROR; +ERROR_TO_GENERAL_ERROR_CLASS[ERROR_INCORRECT_LOGIN_METHOD] = ERROR_AUTH_ERROR; +ERROR_TO_GENERAL_ERROR_CLASS[ERROR_INVALID_EMAIL] = ERROR_AUTH_ERROR; +ERROR_TO_GENERAL_ERROR_CLASS[ERROR_INVALID_AUDIENCE] = ERROR_AUTH_ERROR; +ERROR_TO_GENERAL_ERROR_CLASS[ERROR_INVALID_AUTH_TOKEN] = ERROR_AUTH_ERROR; +ERROR_TO_GENERAL_ERROR_CLASS[ERROR_INVALID_AUTH_TIMESTAMP] = ERROR_AUTH_ERROR; +ERROR_TO_GENERAL_ERROR_CLASS[ERROR_INVALID_AUTH_NONCE] = ERROR_AUTH_ERROR; +ERROR_TO_GENERAL_ERROR_CLASS[ERROR_INVALID_BODY_PARAMETERS] = ERROR_AUTH_ERROR; +ERROR_TO_GENERAL_ERROR_CLASS[ERROR_INVALID_PASSWORD] = ERROR_AUTH_ERROR; +ERROR_TO_GENERAL_ERROR_CLASS[ERROR_INVALID_VERIFICATION_CODE] = ERROR_AUTH_ERROR; +ERROR_TO_GENERAL_ERROR_CLASS[ERROR_INVALID_REFRESH_AUTH_VALUE] = ERROR_AUTH_ERROR; +ERROR_TO_GENERAL_ERROR_CLASS[ERROR_INVALID_REQUEST_SIGNATURE] = ERROR_AUTH_ERROR; +ERROR_TO_GENERAL_ERROR_CLASS[ERROR_INTERNAL_INVALID_USER] = ERROR_AUTH_ERROR; +ERROR_TO_GENERAL_ERROR_CLASS[ERROR_MISSING_BODY_PARAMETERS] = ERROR_AUTH_ERROR; +ERROR_TO_GENERAL_ERROR_CLASS[ERROR_MISSING_CONTENT_LENGTH] = ERROR_AUTH_ERROR; +ERROR_TO_GENERAL_ERROR_CLASS[ERROR_NO_TOKEN_SESSION] = ERROR_AUTH_ERROR; +ERROR_TO_GENERAL_ERROR_CLASS[ERROR_NO_SILENT_REFRESH_AUTH] = ERROR_AUTH_ERROR; +ERROR_TO_GENERAL_ERROR_CLASS[ERROR_NOT_VALID_JSON_BODY] = ERROR_AUTH_ERROR; +ERROR_TO_GENERAL_ERROR_CLASS[ERROR_PERMISSION_DENIED] = ERROR_AUTH_ERROR; +ERROR_TO_GENERAL_ERROR_CLASS[ERROR_REQUEST_BODY_TOO_LARGE] = ERROR_AUTH_ERROR; +ERROR_TO_GENERAL_ERROR_CLASS[ERROR_UNKNOWN_DEVICE] = ERROR_AUTH_ERROR; +ERROR_TO_GENERAL_ERROR_CLASS[ERROR_UNVERIFIED_ACCOUNT] = ERROR_AUTH_ERROR; +ERROR_TO_GENERAL_ERROR_CLASS[ERROR_UI_ERROR] = ERROR_AUTH_ERROR; +ERROR_TO_GENERAL_ERROR_CLASS[ERROR_UI_REQUEST] = ERROR_AUTH_ERROR; +ERROR_TO_GENERAL_ERROR_CLASS[ERROR_OFFLINE] = ERROR_NETWORK; +ERROR_TO_GENERAL_ERROR_CLASS[ERROR_SERVER_ERROR] = ERROR_NETWORK; +ERROR_TO_GENERAL_ERROR_CLASS[ERROR_TOO_MANY_CLIENT_REQUESTS] = ERROR_NETWORK; +ERROR_TO_GENERAL_ERROR_CLASS[ERROR_SERVICE_TEMP_UNAVAILABLE] = ERROR_NETWORK; +ERROR_TO_GENERAL_ERROR_CLASS[ERROR_PARSE] = ERROR_NETWORK; +ERROR_TO_GENERAL_ERROR_CLASS[ERROR_NETWORK] = ERROR_NETWORK; + +// oauth +ERROR_TO_GENERAL_ERROR_CLASS[ERROR_INCORRECT_CLIENT_SECRET] = ERROR_AUTH_ERROR; +ERROR_TO_GENERAL_ERROR_CLASS[ERROR_INCORRECT_REDIRECT_URI] = ERROR_AUTH_ERROR; +ERROR_TO_GENERAL_ERROR_CLASS[ERROR_INVALID_FXA_ASSERTION] = ERROR_AUTH_ERROR; +ERROR_TO_GENERAL_ERROR_CLASS[ERROR_UNKNOWN_CODE] = ERROR_AUTH_ERROR; +ERROR_TO_GENERAL_ERROR_CLASS[ERROR_INCORRECT_CODE] = ERROR_AUTH_ERROR; +ERROR_TO_GENERAL_ERROR_CLASS[ERROR_EXPIRED_CODE] = ERROR_AUTH_ERROR; +ERROR_TO_GENERAL_ERROR_CLASS[ERROR_OAUTH_INVALID_TOKEN] = ERROR_AUTH_ERROR; +ERROR_TO_GENERAL_ERROR_CLASS[ERROR_INVALID_REQUEST_PARAM] = ERROR_AUTH_ERROR; +ERROR_TO_GENERAL_ERROR_CLASS[ERROR_INVALID_RESPONSE_TYPE] = ERROR_AUTH_ERROR; +ERROR_TO_GENERAL_ERROR_CLASS[ERROR_UNAUTHORIZED] = ERROR_AUTH_ERROR; +ERROR_TO_GENERAL_ERROR_CLASS[ERROR_FORBIDDEN] = ERROR_AUTH_ERROR; +ERROR_TO_GENERAL_ERROR_CLASS[ERROR_INVALID_CONTENT_TYPE] = ERROR_AUTH_ERROR; diff --git a/services/fxaccounts/FxAccountsComponents.manifest b/services/fxaccounts/FxAccountsComponents.manifest new file mode 100644 index 000000000..5069755bc --- /dev/null +++ b/services/fxaccounts/FxAccountsComponents.manifest @@ -0,0 +1,4 @@ +# FxAccountsPush.js +component {1b7db999-2ecd-4abf-bb95-a726896798ca} FxAccountsPush.js process=main +contract @mozilla.org/fxaccounts/push;1 {1b7db999-2ecd-4abf-bb95-a726896798ca} +category push chrome://fxa-device-update @mozilla.org/fxaccounts/push;1 diff --git a/services/fxaccounts/FxAccountsConfig.jsm b/services/fxaccounts/FxAccountsConfig.jsm new file mode 100644 index 000000000..9dcf532ab --- /dev/null +++ b/services/fxaccounts/FxAccountsConfig.jsm @@ -0,0 +1,179 @@ +/* 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 = ["FxAccountsConfig"]; + +const {classes: Cc, interfaces: Ci, utils: Cu} = Components; + +Cu.import("resource://services-common/rest.js"); +Cu.import("resource://gre/modules/FxAccountsCommon.js"); +Cu.import("resource://gre/modules/Services.jsm"); +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); +Cu.import("resource://gre/modules/Task.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, "fxAccounts", + "resource://gre/modules/FxAccounts.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, "EnsureFxAccountsWebChannel", + "resource://gre/modules/FxAccountsWebChannel.jsm"); + +const CONFIG_PREFS = [ + "identity.fxaccounts.auth.uri", + "identity.fxaccounts.remote.oauth.uri", + "identity.fxaccounts.remote.profile.uri", + "identity.sync.tokenserver.uri", + "identity.fxaccounts.remote.webchannel.uri", + "identity.fxaccounts.settings.uri", + "identity.fxaccounts.remote.signup.uri", + "identity.fxaccounts.remote.signin.uri", + "identity.fxaccounts.remote.force_auth.uri", +]; + +this.FxAccountsConfig = { + + // Returns a promise that resolves with the URI of the remote UI flows. + promiseAccountsSignUpURI: Task.async(function*() { + yield this.ensureConfigured(); + let url = Services.urlFormatter.formatURLPref("identity.fxaccounts.remote.signup.uri"); + if (fxAccounts.requiresHttps() && !/^https:/.test(url)) { // Comment to un-break emacs js-mode highlighting + throw new Error("Firefox Accounts server must use HTTPS"); + } + return url; + }), + + // Returns a promise that resolves with the URI of the remote UI flows. + promiseAccountsSignInURI: Task.async(function*() { + yield this.ensureConfigured(); + let url = Services.urlFormatter.formatURLPref("identity.fxaccounts.remote.signin.uri"); + if (fxAccounts.requiresHttps() && !/^https:/.test(url)) { // Comment to un-break emacs js-mode highlighting + throw new Error("Firefox Accounts server must use HTTPS"); + } + return url; + }), + + resetConfigURLs() { + let autoconfigURL = this.getAutoConfigURL(); + if (!autoconfigURL) { + return; + } + // They have the autoconfig uri pref set, so we clear all the prefs that we + // will have initialized, which will leave them pointing at production. + for (let pref of CONFIG_PREFS) { + Services.prefs.clearUserPref(pref); + } + // Reset the webchannel. + EnsureFxAccountsWebChannel(); + if (!Services.prefs.prefHasUserValue("webchannel.allowObject.urlWhitelist")) { + return; + } + let whitelistValue = Services.prefs.getCharPref("webchannel.allowObject.urlWhitelist"); + if (whitelistValue.startsWith(autoconfigURL + " ")) { + whitelistValue = whitelistValue.slice(autoconfigURL.length + 1); + // Check and see if the value will be the default, and just clear the pref if it would + // to avoid it showing up as changed in about:config. + let defaultWhitelist; + try { + defaultWhitelist = Services.prefs.getDefaultBranch("webchannel.allowObject.").getCharPref("urlWhitelist"); + } catch (e) { + // No default value ... + } + + if (defaultWhitelist === whitelistValue) { + Services.prefs.clearUserPref("webchannel.allowObject.urlWhitelist"); + } else { + Services.prefs.setCharPref("webchannel.allowObject.urlWhitelist", whitelistValue); + } + } + }, + + getAutoConfigURL() { + let pref; + try { + pref = Services.prefs.getCharPref("identity.fxaccounts.autoconfig.uri"); + } catch (e) { /* no pref */ } + if (!pref) { + // no pref / empty pref means we don't bother here. + return ""; + } + let rootURL = Services.urlFormatter.formatURL(pref); + if (rootURL.endsWith("/")) { + rootURL.slice(0, -1); + } + return rootURL; + }, + + ensureConfigured: Task.async(function*() { + let isSignedIn = !!(yield fxAccounts.getSignedInUser()); + if (!isSignedIn) { + yield this.fetchConfigURLs(); + } + }), + + // Read expected client configuration from the fxa auth server + // (from `identity.fxaccounts.autoconfig.uri`/.well-known/fxa-client-configuration) + // and replace all the relevant our prefs with the information found there. + // This is only done before sign-in and sign-up, and even then only if the + // `identity.fxaccounts.autoconfig.uri` preference is set. + fetchConfigURLs: Task.async(function*() { + let rootURL = this.getAutoConfigURL(); + if (!rootURL) { + return; + } + let configURL = rootURL + "/.well-known/fxa-client-configuration"; + let jsonStr = yield new Promise((resolve, reject) => { + let request = new RESTRequest(configURL); + request.setHeader("Accept", "application/json"); + request.get(error => { + if (error) { + log.error(`Failed to get configuration object from "${configURL}"`, error); + return reject(error); + } + if (!request.response.success) { + log.error(`Received HTTP response code ${request.response.status} from configuration object request`); + if (request.response && request.response.body) { + log.debug("Got error response", request.response.body); + } + return reject(request.response.status); + } + resolve(request.response.body); + }); + }); + + log.debug("Got successful configuration response", jsonStr); + try { + // Update the prefs directly specified by the config. + let config = JSON.parse(jsonStr) + let authServerBase = config.auth_server_base_url; + if (!authServerBase.endsWith("/v1")) { + authServerBase += "/v1"; + } + Services.prefs.setCharPref("identity.fxaccounts.auth.uri", authServerBase); + Services.prefs.setCharPref("identity.fxaccounts.remote.oauth.uri", config.oauth_server_base_url + "/v1"); + Services.prefs.setCharPref("identity.fxaccounts.remote.profile.uri", config.profile_server_base_url + "/v1"); + Services.prefs.setCharPref("identity.sync.tokenserver.uri", config.sync_tokenserver_base_url + "/1.0/sync/1.5"); + // Update the prefs that are based off of the autoconfig url + + let contextParam = encodeURIComponent( + Services.prefs.getCharPref("identity.fxaccounts.contextParam")); + + Services.prefs.setCharPref("identity.fxaccounts.remote.webchannel.uri", rootURL); + Services.prefs.setCharPref("identity.fxaccounts.settings.uri", rootURL + "/settings?service=sync&context=" + contextParam); + Services.prefs.setCharPref("identity.fxaccounts.remote.signup.uri", rootURL + "/signup?service=sync&context=" + contextParam); + Services.prefs.setCharPref("identity.fxaccounts.remote.signin.uri", rootURL + "/signin?service=sync&context=" + contextParam); + Services.prefs.setCharPref("identity.fxaccounts.remote.force_auth.uri", rootURL + "/force_auth?service=sync&context=" + contextParam); + + let whitelistValue = Services.prefs.getCharPref("webchannel.allowObject.urlWhitelist"); + if (!whitelistValue.includes(rootURL)) { + whitelistValue = `${rootURL} ${whitelistValue}`; + Services.prefs.setCharPref("webchannel.allowObject.urlWhitelist", whitelistValue); + } + // Ensure the webchannel is pointed at the correct uri + EnsureFxAccountsWebChannel(); + } catch (e) { + log.error("Failed to initialize configuration preferences from autoconfig object", e); + throw e; + } + }), + +}; diff --git a/services/fxaccounts/FxAccountsManager.jsm b/services/fxaccounts/FxAccountsManager.jsm new file mode 100644 index 000000000..680310ff5 --- /dev/null +++ b/services/fxaccounts/FxAccountsManager.jsm @@ -0,0 +1,654 @@ +/* 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/. */ + +/** + * Temporary abstraction layer for common Fx Accounts operations. + * For now, we will be using this module only from B2G but in the end we might + * want this to be merged with FxAccounts.jsm and let other products also use + * it. + */ + +"use strict"; + +this.EXPORTED_SYMBOLS = ["FxAccountsManager"]; + +const { classes: Cc, interfaces: Ci, utils: Cu } = Components; + +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); +Cu.import("resource://gre/modules/Services.jsm"); +Cu.import("resource://gre/modules/FxAccounts.jsm"); +Cu.import("resource://gre/modules/Promise.jsm"); +Cu.import("resource://gre/modules/FxAccountsCommon.js"); + +XPCOMUtils.defineLazyServiceGetter(this, "permissionManager", + "@mozilla.org/permissionmanager;1", + "nsIPermissionManager"); + +this.FxAccountsManager = { + + init: function() { + Services.obs.addObserver(this, ONLOGOUT_NOTIFICATION, false); + Services.obs.addObserver(this, ON_FXA_UPDATE_NOTIFICATION, false); + }, + + observe: function(aSubject, aTopic, aData) { + // Both topics indicate our cache is invalid + this._activeSession = null; + + if (aData == ONVERIFIED_NOTIFICATION) { + log.debug("FxAccountsManager: cache cleared, broadcasting: " + aData); + Services.obs.notifyObservers(null, aData, null); + } + }, + + // We don't really need to save fxAccounts instance but this way we allow + // to mock FxAccounts from tests. + _fxAccounts: fxAccounts, + + // We keep the session details here so consumers don't need to deal with + // session tokens and are only required to handle the email. + _activeSession: null, + + // Are we refreshing our authentication? If so, allow attempts to sign in + // while we are already signed in. + _refreshing: false, + + // We only expose the email and the verified status so far. + get _user() { + if (!this._activeSession || !this._activeSession.email) { + return null; + } + + return { + email: this._activeSession.email, + verified: this._activeSession.verified, + profile: this._activeSession.profile, + } + }, + + _error: function(aError, aDetails) { + log.error(aError); + let reason = { + error: aError + }; + if (aDetails) { + reason.details = aDetails; + } + return Promise.reject(reason); + }, + + _getError: function(aServerResponse) { + if (!aServerResponse || !aServerResponse.error || !aServerResponse.error.errno) { + return; + } + let error = SERVER_ERRNO_TO_ERROR[aServerResponse.error.errno]; + return error; + }, + + _serverError: function(aServerResponse) { + let error = this._getError({ error: aServerResponse }); + return this._error(error ? error : ERROR_SERVER_ERROR, aServerResponse); + }, + + // As with _fxAccounts, we don't really need this method, but this way we + // allow tests to mock FxAccountsClient. By default, we want to return the + // client used by the fxAccounts object because deep down they should have + // access to the same hawk request object which will enable them to share + // local clock skeq data. + _getFxAccountsClient: function() { + return this._fxAccounts.getAccountsClient(); + }, + + _signInSignUp: function(aMethod, aEmail, aPassword, aFetchKeys) { + if (Services.io.offline) { + return this._error(ERROR_OFFLINE); + } + + if (!aEmail) { + return this._error(ERROR_INVALID_EMAIL); + } + + if (!aPassword) { + return this._error(ERROR_INVALID_PASSWORD); + } + + // Check that there is no signed in account first. + if ((!this._refreshing) && this._activeSession) { + return this._error(ERROR_ALREADY_SIGNED_IN_USER, { + user: this._user + }); + } + + let client = this._getFxAccountsClient(); + return this._fxAccounts.getSignedInUser().then( + user => { + if ((!this._refreshing) && user) { + return this._error(ERROR_ALREADY_SIGNED_IN_USER, { + user: this._user + }); + } + return client[aMethod](aEmail, aPassword, aFetchKeys); + } + ).then( + user => { + let error = this._getError(user); + if (!user || !user.uid || !user.sessionToken || error) { + return this._error(error ? error : ERROR_INTERNAL_INVALID_USER, { + user: user + }); + } + + // If the user object includes an email field, it may differ in + // capitalization from what we sent down. This is the server's + // canonical capitalization and should be used instead. + user.email = user.email || aEmail; + + // If we're using server-side sign to refreshAuthentication + // we don't need to update local state; also because of two + // interacting glitches we need to bypass an event emission. + // See https://bugzilla.mozilla.org/show_bug.cgi?id=1031580 + if (this._refreshing) { + return Promise.resolve({user: this._user}); + } + + return this._fxAccounts.setSignedInUser(user).then( + () => { + this._activeSession = user; + log.debug("User signed in: " + JSON.stringify(this._user) + + " - Account created " + (aMethod == "signUp")); + + // There is no way to obtain the key fetch token afterwards + // without login out the user and asking her to log in again. + // Also, key fetch tokens are designed to be short-lived, so + // we need to fetch kB as soon as we have the key fetch token. + if (aFetchKeys) { + this._fxAccounts.getKeys(); + } + + return this._fxAccounts.getSignedInUserProfile().catch(error => { + // Not fetching the profile is sad but the FxA logs will already + // have noise. + return null; + }); + } + ).then(profile => { + if (profile) { + this._activeSession.profile = profile; + } + + return Promise.resolve({ + accountCreated: aMethod === "signUp", + user: this._user + }); + }); + }, + reason => { return this._serverError(reason); } + ); + }, + + /** + * Determine whether the incoming error means that the current account + * has new server-side state via deletion or password change, and if so, + * spawn the appropriate UI (sign in or refresh); otherwise re-reject. + * + * As of May 2014, the only HTTP call triggered by this._getAssertion() + * is to /certificate/sign via: + * FxAccounts.getAssertion() + * FxAccountsInternal.getCertificateSigned() + * FxAccountsClient.signCertificate() + * See the latter method for possible (error code, errno) pairs. + */ + _handleGetAssertionError: function(reason, aAudience, aPrincipal) { + log.debug("FxAccountsManager._handleGetAssertionError()"); + let errno = (reason ? reason.errno : NaN) || NaN; + // If the previously valid email/password pair is no longer valid ... + if (errno == ERRNO_INVALID_AUTH_TOKEN) { + return this._fxAccounts.accountStatus().then( + (exists) => { + // ... if the email still maps to an account, the password + // must have changed, so ask the user to enter the new one ... + if (exists) { + return this.getAccount().then( + (user) => { + return this._refreshAuthentication(aAudience, user.email, + aPrincipal, + true /* logoutOnFailure */); + } + ); + } + // ... otherwise, the account was deleted, so ask for Sign In/Up + return this._localSignOut().then( + () => { + return this._uiRequest(UI_REQUEST_SIGN_IN_FLOW, aAudience, + aPrincipal); + }, + (reason) => { + // reject primary problem, not signout failure + log.error("Signing out in response to server error threw: " + + reason); + return this._error(reason); + } + ); + } + ); + } + return Promise.reject(reason.message ? { error: reason.message } : reason); + }, + + _getAssertion: function(aAudience, aPrincipal) { + return this._fxAccounts.getAssertion(aAudience).then( + (result) => { + if (aPrincipal) { + this._addPermission(aPrincipal); + } + return result; + }, + (reason) => { + return this._handleGetAssertionError(reason, aAudience, aPrincipal); + } + ); + }, + + /** + * "Refresh authentication" means: + * Interactively demonstrate knowledge of the FxA password + * for the currently logged-in account. + * There are two very different scenarios: + * 1) The password has changed on the server. Failure should log + * the current account OUT. + * 2) The person typing can't prove knowledge of the password used + * to log in. Failure should do nothing. + */ + _refreshAuthentication: function(aAudience, aEmail, aPrincipal, + logoutOnFailure=false) { + this._refreshing = true; + return this._uiRequest(UI_REQUEST_REFRESH_AUTH, + aAudience, aPrincipal, aEmail).then( + (assertion) => { + this._refreshing = false; + return assertion; + }, + (reason) => { + this._refreshing = false; + if (logoutOnFailure) { + return this._signOut().then( + () => { + return this._error(reason); + } + ); + } + return this._error(reason); + } + ); + }, + + _localSignOut: function() { + return this._fxAccounts.signOut(true); + }, + + _signOut: function() { + if (!this._activeSession) { + return Promise.resolve(); + } + + // We clear the local session cache as soon as we get the onlogout + // notification triggered within FxAccounts.signOut, so we save the + // session token value to be able to remove the remote server session + // in case that we have network connection. + let sessionToken = this._activeSession.sessionToken; + + return this._localSignOut().then( + () => { + // At this point the local session should already be removed. + + // The client can create new sessions up to the limit (100?). + // Orphaned tokens on the server will eventually be garbage collected. + if (Services.io.offline) { + return Promise.resolve(); + } + // Otherwise, we try to remove the remote session. + let client = this._getFxAccountsClient(); + return client.signOut(sessionToken).then( + result => { + let error = this._getError(result); + if (error) { + return this._error(error, result); + } + log.debug("Signed out"); + return Promise.resolve(); + }, + reason => { + return this._serverError(reason); + } + ); + } + ); + }, + + _uiRequest: function(aRequest, aAudience, aPrincipal, aParams) { + if (Services.io.offline) { + return this._error(ERROR_OFFLINE); + } + let ui = Cc["@mozilla.org/fxaccounts/fxaccounts-ui-glue;1"] + .createInstance(Ci.nsIFxAccountsUIGlue); + if (!ui[aRequest]) { + return this._error(ERROR_UI_REQUEST); + } + + if (!aParams || !Array.isArray(aParams)) { + aParams = [aParams]; + } + + return ui[aRequest].apply(this, aParams).then( + result => { + // Even if we get a successful result from the UI, the account will + // most likely be unverified, so we cannot get an assertion. + if (result && result.verified) { + return this._getAssertion(aAudience, aPrincipal); + } + + return this._error(ERROR_UNVERIFIED_ACCOUNT, { + user: result + }); + }, + error => { + return this._error(ERROR_UI_ERROR, error); + } + ); + }, + + _addPermission: function(aPrincipal) { + // This will fail from tests cause we are running them in the child + // process until we have chrome tests in b2g. Bug 797164. + try { + permissionManager.addFromPrincipal(aPrincipal, FXACCOUNTS_PERMISSION, + Ci.nsIPermissionManager.ALLOW_ACTION); + } catch (e) { + log.warn("Could not add permission " + e); + } + }, + + // -- API -- + + signIn: function(aEmail, aPassword, aFetchKeys) { + return this._signInSignUp("signIn", aEmail, aPassword, aFetchKeys); + }, + + signUp: function(aEmail, aPassword, aFetchKeys) { + return this._signInSignUp("signUp", aEmail, aPassword, aFetchKeys); + }, + + signOut: function() { + if (!this._activeSession) { + // If there is no cached active session, we try to get it from the + // account storage. + return this.getAccount().then( + result => { + if (!result) { + return Promise.resolve(); + } + return this._signOut(); + } + ); + } + return this._signOut(); + }, + + resendVerificationEmail: function() { + return this._fxAccounts.resendVerificationEmail().then( + (result) => { + return result; + }, + (error) => { + return this._error(ERROR_SERVER_ERROR, error); + } + ); + }, + + getAccount: function() { + // We check first if we have session details cached. + if (this._activeSession) { + // If our cache says that the account is not yet verified, + // we kick off verification before returning what we have. + if (!this._activeSession.verified) { + this.verificationStatus(this._activeSession); + } + log.debug("Account " + JSON.stringify(this._user)); + return Promise.resolve(this._user); + } + + // If no cached information, we try to get it from the persistent storage. + return this._fxAccounts.getSignedInUser().then( + user => { + if (!user || !user.email) { + log.debug("No signed in account"); + return Promise.resolve(null); + } + + this._activeSession = user; + // If we get a stored information of a not yet verified account, + // we kick off verification before returning what we have. + if (!user.verified) { + this.verificationStatus(user); + // Trying to get the profile for unverified users will fail, so we + // don't even try in that case. + log.debug("Account ", this._user); + return Promise.resolve(this._user); + } + + return this._fxAccounts.getSignedInUserProfile().then(profile => { + if (profile) { + this._activeSession.profile = profile; + } + log.debug("Account ", this._user); + return Promise.resolve(this._user); + }).catch(error => { + // FxAccounts logs already inform about the error. + log.debug("Account ", this._user); + return Promise.resolve(this._user); + }); + } + ); + }, + + queryAccount: function(aEmail) { + log.debug("queryAccount " + aEmail); + if (Services.io.offline) { + return this._error(ERROR_OFFLINE); + } + + let deferred = Promise.defer(); + + if (!aEmail) { + return this._error(ERROR_INVALID_EMAIL); + } + + let client = this._getFxAccountsClient(); + return client.accountExists(aEmail).then( + result => { + log.debug("Account " + result ? "" : "does not" + " exists"); + let error = this._getError(result); + if (error) { + return this._error(error, result); + } + + return Promise.resolve({ + registered: result + }); + }, + reason => { this._serverError(reason); } + ); + }, + + verificationStatus: function() { + log.debug("verificationStatus"); + if (!this._activeSession || !this._activeSession.sessionToken) { + this._error(ERROR_NO_TOKEN_SESSION); + } + + // There is no way to unverify an already verified account, so we just + // return the account details of a verified account + if (this._activeSession.verified) { + log.debug("Account already verified"); + return; + } + + if (Services.io.offline) { + log.warn("Offline; skipping verification."); + return; + } + + let client = this._getFxAccountsClient(); + client.recoveryEmailStatus(this._activeSession.sessionToken).then( + data => { + let error = this._getError(data); + if (error) { + this._error(error, data); + } + // If the verification status has changed, update state. + if (this._activeSession.verified != data.verified) { + this._activeSession.verified = data.verified; + this._fxAccounts.setSignedInUser(this._activeSession); + this._fxAccounts.getSignedInUserProfile().then(profile => { + if (profile) { + this._activeSession.profile = profile; + } + }).catch(error => { + // FxAccounts logs already inform about the error. + }); + } + log.debug(JSON.stringify(this._user)); + }, + reason => { this._serverError(reason); } + ); + }, + + /* + * Try to get an assertion for the given audience. Here we implement + * the heart of the response to navigator.mozId.request() on device. + * (We can also be called via the IAC API, but it's request() that + * makes this method complex.) The state machine looks like this, + * ignoring simple errors: + * If no one is signed in, and we aren't suppressing the UI: + * trigger the sign in flow. + * else if we were asked to refresh and the grace period is up: + * trigger the refresh flow. + * else: + * request user permission to share an assertion if we don't have it + * already and ask the core code for an assertion, which might itself + * trigger either the sign in or refresh flows (if our account + * changed on the server). + * + * aOptions can include: + * refreshAuthentication - (bool) Force re-auth. + * silent - (bool) Prevent any UI interaction. + * I.e., try to get an automatic assertion. + */ + getAssertion: function(aAudience, aPrincipal, aOptions) { + if (!aAudience) { + return this._error(ERROR_INVALID_AUDIENCE); + } + + let principal = aPrincipal; + log.debug("FxAccountsManager.getAssertion() aPrincipal: ", + principal.origin, principal.appId, + principal.isInIsolatedMozBrowserElement); + + return this.getAccount().then( + user => { + if (user) { + // Three have-user cases to consider. First: are we unverified? + if (!user.verified) { + return this._error(ERROR_UNVERIFIED_ACCOUNT, { + user: user + }); + } + // Second case: do we need to refresh? + if (aOptions && + (typeof(aOptions.refreshAuthentication) != "undefined")) { + let gracePeriod = aOptions.refreshAuthentication; + if (typeof(gracePeriod) !== "number" || isNaN(gracePeriod)) { + return this._error(ERROR_INVALID_REFRESH_AUTH_VALUE); + } + // Forcing refreshAuth to silent is a contradiction in terms, + // though it might succeed silently if we didn't reject here. + if (aOptions.silent) { + return this._error(ERROR_NO_SILENT_REFRESH_AUTH); + } + let secondsSinceAuth = (Date.now() / 1000) - + this._activeSession.authAt; + if (secondsSinceAuth > gracePeriod) { + return this._refreshAuthentication(aAudience, user.email, + principal, + false /* logoutOnFailure */); + } + } + // Third case: we are all set *locally*. Probably we just return + // the assertion, but the attempt might lead to the server saying + // we are deleted or have a new password, which will trigger a flow. + // Also we need to check if we have permission to get the assertion, + // otherwise we need to show the forceAuth UI to let the user know + // that the RP with no fxa permissions is trying to obtain an + // assertion. Once the user authenticates herself in the forceAuth UI + // the permission will be remembered by default. + let permission = permissionManager.testPermissionFromPrincipal( + principal, + FXACCOUNTS_PERMISSION + ); + if (permission == Ci.nsIPermissionManager.PROMPT_ACTION && + !this._refreshing) { + return this._refreshAuthentication(aAudience, user.email, + principal, + false /* logoutOnFailure */); + } else if (permission == Ci.nsIPermissionManager.DENY_ACTION && + !this._refreshing) { + return this._error(ERROR_PERMISSION_DENIED); + } else if (this._refreshing) { + // If we are blocked asking for a password we should not continue + // the getAssertion process. + return Promise.resolve(null); + } + return this._getAssertion(aAudience, principal); + } + log.debug("No signed in user"); + if (aOptions && aOptions.silent) { + return Promise.resolve(null); + } + return this._uiRequest(UI_REQUEST_SIGN_IN_FLOW, aAudience, principal); + } + ); + }, + + getKeys: function() { + let syncEnabled = false; + try { + syncEnabled = Services.prefs.getBoolPref("services.sync.enabled"); + } catch(e) { + dump("Sync is disabled, so you won't get the keys. " + e + "\n"); + } + + if (!syncEnabled) { + return Promise.reject(ERROR_SYNC_DISABLED); + } + + return this.getAccount().then( + user => { + if (!user) { + log.debug("No signed in user"); + return Promise.resolve(null); + } + + if (!user.verified) { + return this._error(ERROR_UNVERIFIED_ACCOUNT, { + user: user + }); + } + + return this._fxAccounts.getKeys(); + } + ); + } +}; + +FxAccountsManager.init(); diff --git a/services/fxaccounts/FxAccountsOAuthClient.jsm b/services/fxaccounts/FxAccountsOAuthClient.jsm new file mode 100644 index 000000000..c59f1a869 --- /dev/null +++ b/services/fxaccounts/FxAccountsOAuthClient.jsm @@ -0,0 +1,269 @@ +/* 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/. */ + +/** + * Firefox Accounts OAuth browser login helper. + * Uses the WebChannel component to receive OAuth messages and complete login flows. + */ + +this.EXPORTED_SYMBOLS = ["FxAccountsOAuthClient"]; + +const {classes: Cc, interfaces: Ci, utils: Cu, results: Cr} = Components; + +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); +Cu.import("resource://gre/modules/Log.jsm"); +Cu.import("resource://gre/modules/Services.jsm"); +Cu.import("resource://gre/modules/FxAccountsCommon.js"); +XPCOMUtils.defineLazyModuleGetter(this, "WebChannel", + "resource://gre/modules/WebChannel.jsm"); +Cu.importGlobalProperties(["URL"]); + +/** + * Create a new FxAccountsOAuthClient for browser some service. + * + * @param {Object} options Options + * @param {Object} options.parameters + * Opaque alphanumeric token to be included in verification links + * @param {String} options.parameters.client_id + * OAuth id returned from client registration + * @param {String} options.parameters.state + * A value that will be returned to the client as-is upon redirection + * @param {String} options.parameters.oauth_uri + * The FxA OAuth server uri + * @param {String} options.parameters.content_uri + * The FxA Content server uri + * @param {String} [options.parameters.scope] + * Optional. A colon-separated list of scopes that the user has authorized + * @param {String} [options.parameters.action] + * Optional. If provided, should be either signup, signin or force_auth. + * @param {String} [options.parameters.email] + * Optional. Required if options.paramters.action is 'force_auth'. + * @param {Boolean} [options.parameters.keys] + * Optional. If true then relier-specific encryption keys will be + * available in the second argument to onComplete. + * @param [authorizationEndpoint] {String} + * Optional authorization endpoint for the OAuth server + * @constructor + */ +this.FxAccountsOAuthClient = function(options) { + this._validateOptions(options); + this.parameters = options.parameters; + this._configureChannel(); + + let authorizationEndpoint = options.authorizationEndpoint || "/authorization"; + + try { + this._fxaOAuthStartUrl = new URL(this.parameters.oauth_uri + authorizationEndpoint + "?"); + } catch (e) { + throw new Error("Invalid OAuth Url"); + } + + let params = this._fxaOAuthStartUrl.searchParams; + params.append("client_id", this.parameters.client_id); + params.append("state", this.parameters.state); + params.append("scope", this.parameters.scope || ""); + params.append("action", this.parameters.action || "signin"); + params.append("webChannelId", this._webChannelId); + if (this.parameters.keys) { + params.append("keys", "true"); + } + // Only append if we actually have a value. + if (this.parameters.email) { + params.append("email", this.parameters.email); + } +}; + +this.FxAccountsOAuthClient.prototype = { + /** + * Function that gets called once the OAuth flow is complete. + * The callback will receive an object with code and state properties. + * If the keys parameter was specified and true, the callback will receive + * a second argument with kAr and kBr properties. + */ + onComplete: null, + /** + * Function that gets called if there is an error during the OAuth flow, + * for example due to a state mismatch. + * The callback will receive an Error object as its argument. + */ + onError: null, + /** + * Configuration object that stores all OAuth parameters. + */ + parameters: null, + /** + * WebChannel that is used to communicate with content page. + */ + _channel: null, + /** + * Boolean to indicate if this client has completed an OAuth flow. + */ + _complete: false, + /** + * The url that opens the Firefox Accounts OAuth flow. + */ + _fxaOAuthStartUrl: null, + /** + * WebChannel id. + */ + _webChannelId: null, + /** + * WebChannel origin, used to validate origin of messages. + */ + _webChannelOrigin: null, + /** + * Opens a tab at "this._fxaOAuthStartUrl". + * Registers a WebChannel listener and sets up a callback if needed. + */ + launchWebFlow: function () { + if (!this._channelCallback) { + this._registerChannel(); + } + + if (this._complete) { + throw new Error("This client already completed the OAuth flow"); + } else { + let opener = Services.wm.getMostRecentWindow("navigator:browser").gBrowser; + opener.selectedTab = opener.addTab(this._fxaOAuthStartUrl.href); + } + }, + + /** + * Release all resources that are in use. + */ + tearDown: function() { + this.onComplete = null; + this.onError = null; + this._complete = true; + this._channel.stopListening(); + this._channel = null; + }, + + /** + * Configures WebChannel id and origin + * + * @private + */ + _configureChannel: function() { + this._webChannelId = "oauth_" + this.parameters.client_id; + + // if this.parameters.content_uri is present but not a valid URI, then this will throw an error. + try { + this._webChannelOrigin = Services.io.newURI(this.parameters.content_uri, null, null); + } catch (e) { + throw e; + } + }, + + /** + * Create a new channel with the WebChannelBroker, setup a callback listener + * @private + */ + _registerChannel: function() { + /** + * Processes messages that are called back from the FxAccountsChannel + * + * @param webChannelId {String} + * Command webChannelId + * @param message {Object} + * Command message + * @param sendingContext {Object} + * Channel message event sendingContext + * @private + */ + let listener = function (webChannelId, message, sendingContext) { + if (message) { + let command = message.command; + let data = message.data; + let target = sendingContext && sendingContext.browser; + + switch (command) { + case "oauth_complete": + // validate the returned state and call onComplete or onError + let result = null; + let err = null; + + if (this.parameters.state !== data.state) { + err = new Error("OAuth flow failed. State doesn't match"); + } else if (this.parameters.keys && !data.keys) { + err = new Error("OAuth flow failed. Keys were not returned"); + } else { + result = { + code: data.code, + state: data.state + }; + } + + // if the message asked to close the tab + if (data.closeWindow && target) { + // for e10s reasons the best way is to use the TabBrowser to close the tab. + let tabbrowser = target.getTabBrowser(); + + if (tabbrowser) { + let tab = tabbrowser.getTabForBrowser(target); + + if (tab) { + tabbrowser.removeTab(tab); + log.debug("OAuth flow closed the tab."); + } else { + log.debug("OAuth flow failed to close the tab. Tab not found in TabBrowser."); + } + } else { + log.debug("OAuth flow failed to close the tab. TabBrowser not found."); + } + } + + if (err) { + log.debug(err.message); + if (this.onError) { + this.onError(err); + } + } else { + log.debug("OAuth flow completed."); + if (this.onComplete) { + if (this.parameters.keys) { + this.onComplete(result, data.keys); + } else { + this.onComplete(result); + } + } + } + + // onComplete will be called for this client only once + // calling onComplete again will result in a failure of the OAuth flow + this.tearDown(); + break; + } + } + }; + + this._channelCallback = listener.bind(this); + this._channel = new WebChannel(this._webChannelId, this._webChannelOrigin); + this._channel.listen(this._channelCallback); + log.debug("Channel registered: " + this._webChannelId + " with origin " + this._webChannelOrigin.prePath); + }, + + /** + * Validates the required FxA OAuth parameters + * + * @param options {Object} + * OAuth client options + * @private + */ + _validateOptions: function (options) { + if (!options || !options.parameters) { + throw new Error("Missing 'parameters' configuration option"); + } + + ["oauth_uri", "client_id", "content_uri", "state"].forEach(option => { + if (!options.parameters[option]) { + throw new Error("Missing 'parameters." + option + "' parameter"); + } + }); + + if (options.parameters.action == "force_auth" && !options.parameters.email) { + throw new Error("parameters.email is required for action 'force_auth'"); + } + }, +}; diff --git a/services/fxaccounts/FxAccountsOAuthGrantClient.jsm b/services/fxaccounts/FxAccountsOAuthGrantClient.jsm new file mode 100644 index 000000000..4319a07ab --- /dev/null +++ b/services/fxaccounts/FxAccountsOAuthGrantClient.jsm @@ -0,0 +1,241 @@ +/* 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/. */ + +/** + * Firefox Accounts OAuth Grant Client allows clients to obtain + * an OAuth token from a BrowserID assertion. Only certain client + * IDs support this privilage. + */ + +this.EXPORTED_SYMBOLS = ["FxAccountsOAuthGrantClient", "FxAccountsOAuthGrantClientError"]; + +const {classes: Cc, interfaces: Ci, utils: Cu, results: Cr} = Components; + +Cu.import("resource://gre/modules/Promise.jsm"); +Cu.import("resource://gre/modules/Log.jsm"); +Cu.import("resource://gre/modules/FxAccountsCommon.js"); +Cu.import("resource://services-common/rest.js"); + +Cu.importGlobalProperties(["URL"]); + +const AUTH_ENDPOINT = "/authorization"; +const DESTROY_ENDPOINT = "/destroy"; + +/** + * Create a new FxAccountsOAuthClient for browser some service. + * + * @param {Object} options Options + * @param {Object} options.parameters + * @param {String} options.parameters.client_id + * OAuth id returned from client registration + * @param {String} options.parameters.serverURL + * The FxA OAuth server URL + * @param [authorizationEndpoint] {String} + * Optional authorization endpoint for the OAuth server + * @constructor + */ +this.FxAccountsOAuthGrantClient = function(options) { + + this._validateOptions(options); + this.parameters = options; + + try { + this.serverURL = new URL(this.parameters.serverURL); + } catch (e) { + throw new Error("Invalid 'serverURL'"); + } + + log.debug("FxAccountsOAuthGrantClient Initialized"); +}; + +this.FxAccountsOAuthGrantClient.prototype = { + + /** + * Retrieves an OAuth access token for the signed in user + * + * @param {Object} assertion BrowserID assertion + * @param {String} scope OAuth scope + * @return Promise + * Resolves: {Object} Object with access_token property + */ + getTokenFromAssertion: function (assertion, scope) { + if (!assertion) { + throw new Error("Missing 'assertion' parameter"); + } + if (!scope) { + throw new Error("Missing 'scope' parameter"); + } + let params = { + scope: scope, + client_id: this.parameters.client_id, + assertion: assertion, + response_type: "token" + }; + + return this._createRequest(AUTH_ENDPOINT, "POST", params); + }, + + /** + * Destroys a previously fetched OAuth access token. + * + * @param {String} token The previously fetched token + * @return Promise + * Resolves: {Object} with the server response, which is typically + * ignored. + */ + destroyToken: function (token) { + if (!token) { + throw new Error("Missing 'token' parameter"); + } + let params = { + token: token, + }; + + return this._createRequest(DESTROY_ENDPOINT, "POST", params); + }, + + /** + * Validates the required FxA OAuth parameters + * + * @param options {Object} + * OAuth client options + * @private + */ + _validateOptions: function (options) { + if (!options) { + throw new Error("Missing configuration options"); + } + + ["serverURL", "client_id"].forEach(option => { + if (!options[option]) { + throw new Error("Missing '" + option + "' parameter"); + } + }); + }, + + /** + * Interface for making remote requests. + */ + _Request: RESTRequest, + + /** + * Remote request helper + * + * @param {String} path + * Profile server path, i.e "/profile". + * @param {String} [method] + * Type of request, i.e "GET". + * @return Promise + * Resolves: {Object} Successful response from the Profile server. + * Rejects: {FxAccountsOAuthGrantClientError} Profile client error. + * @private + */ + _createRequest: function(path, method = "POST", params) { + return new Promise((resolve, reject) => { + let profileDataUrl = this.serverURL + path; + let request = new this._Request(profileDataUrl); + method = method.toUpperCase(); + + request.setHeader("Accept", "application/json"); + request.setHeader("Content-Type", "application/json"); + + request.onComplete = function (error) { + if (error) { + return reject(new FxAccountsOAuthGrantClientError({ + error: ERROR_NETWORK, + errno: ERRNO_NETWORK, + message: error.toString(), + })); + } + + let body = null; + try { + body = JSON.parse(request.response.body); + } catch (e) { + return reject(new FxAccountsOAuthGrantClientError({ + error: ERROR_PARSE, + errno: ERRNO_PARSE, + code: request.response.status, + message: request.response.body, + })); + } + + // "response.success" means status code is 200 + if (request.response.success) { + return resolve(body); + } + + if (typeof body.errno === 'number') { + // Offset oauth server errnos to avoid conflict with other FxA server errnos + body.errno += OAUTH_SERVER_ERRNO_OFFSET; + } else if (body.errno) { + body.errno = ERRNO_UNKNOWN_ERROR; + } + return reject(new FxAccountsOAuthGrantClientError(body)); + }; + + if (method === "POST") { + request.post(params); + } else { + // method not supported + return reject(new FxAccountsOAuthGrantClientError({ + error: ERROR_NETWORK, + errno: ERRNO_NETWORK, + code: ERROR_CODE_METHOD_NOT_ALLOWED, + message: ERROR_MSG_METHOD_NOT_ALLOWED, + })); + } + }); + }, + +}; + +/** + * Normalized profile client errors + * @param {Object} [details] + * Error details object + * @param {number} [details.code] + * Error code + * @param {number} [details.errno] + * Error number + * @param {String} [details.error] + * Error description + * @param {String|null} [details.message] + * Error message + * @constructor + */ +this.FxAccountsOAuthGrantClientError = function(details) { + details = details || {}; + + this.name = "FxAccountsOAuthGrantClientError"; + this.code = details.code || null; + this.errno = details.errno || ERRNO_UNKNOWN_ERROR; + this.error = details.error || ERROR_UNKNOWN; + this.message = details.message || null; +}; + +/** + * Returns error object properties + * + * @returns {{name: *, code: *, errno: *, error: *, message: *}} + * @private + */ +FxAccountsOAuthGrantClientError.prototype._toStringFields = function() { + return { + name: this.name, + code: this.code, + errno: this.errno, + error: this.error, + message: this.message, + }; +}; + +/** + * String representation of a oauth grant client error + * + * @returns {String} + */ +FxAccountsOAuthGrantClientError.prototype.toString = function() { + return this.name + "(" + JSON.stringify(this._toStringFields()) + ")"; +}; diff --git a/services/fxaccounts/FxAccountsProfile.jsm b/services/fxaccounts/FxAccountsProfile.jsm new file mode 100644 index 000000000..b63cd64c1 --- /dev/null +++ b/services/fxaccounts/FxAccountsProfile.jsm @@ -0,0 +1,191 @@ +/* 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"; + +/** + * Firefox Accounts Profile helper. + * + * This class abstracts interaction with the profile server for an account. + * It will handle things like fetching profile data, listening for updates to + * the user's profile in open browser tabs, and cacheing/invalidating profile data. + */ + +this.EXPORTED_SYMBOLS = ["FxAccountsProfile"]; + +const {classes: Cc, interfaces: Ci, utils: Cu, results: Cr} = Components; + +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); +Cu.import("resource://gre/modules/Services.jsm"); +Cu.import("resource://gre/modules/FxAccountsCommon.js"); +Cu.import("resource://gre/modules/FxAccounts.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, "FxAccountsProfileClient", + "resource://gre/modules/FxAccountsProfileClient.jsm"); + +// Based off of deepEqual from Assert.jsm +function deepEqual(actual, expected) { + if (actual === expected) { + return true; + } else if (typeof actual != "object" && typeof expected != "object") { + return actual == expected; + } else { + return objEquiv(actual, expected); + } +} + +function isUndefinedOrNull(value) { + return value === null || value === undefined; +} + +function objEquiv(a, b) { + if (isUndefinedOrNull(a) || isUndefinedOrNull(b)) { + return false; + } + if (a.prototype !== b.prototype) { + return false; + } + let ka, kb, key, i; + try { + ka = Object.keys(a); + kb = Object.keys(b); + } catch (e) { + return false; + } + if (ka.length != kb.length) { + return false; + } + ka.sort(); + kb.sort(); + for (i = ka.length - 1; i >= 0; i--) { + key = ka[i]; + if (!deepEqual(a[key], b[key])) { + return false; + } + } + return true; +} + +function hasChanged(oldData, newData) { + return !deepEqual(oldData, newData); +} + +this.FxAccountsProfile = function (options = {}) { + this._cachedProfile = null; + this._cachedAt = 0; // when we saved the cached version. + this._currentFetchPromise = null; + this._isNotifying = false; // are we sending a notification? + this.fxa = options.fxa || fxAccounts; + this.client = options.profileClient || new FxAccountsProfileClient({ + fxa: this.fxa, + serverURL: options.profileServerUrl, + }); + + // An observer to invalidate our _cachedAt optimization. We use a weak-ref + // just incase this.tearDown isn't called in some cases. + Services.obs.addObserver(this, ON_PROFILE_CHANGE_NOTIFICATION, true); + // for testing + if (options.channel) { + this.channel = options.channel; + } +} + +this.FxAccountsProfile.prototype = { + // If we get subsequent requests for a profile within this period, don't bother + // making another request to determine if it is fresh or not. + PROFILE_FRESHNESS_THRESHOLD: 120000, // 2 minutes + + observe(subject, topic, data) { + // If we get a profile change notification from our webchannel it means + // the user has just changed their profile via the web, so we want to + // ignore our "freshness threshold" + if (topic == ON_PROFILE_CHANGE_NOTIFICATION && !this._isNotifying) { + log.debug("FxAccountsProfile observed profile change"); + this._cachedAt = 0; + } + }, + + tearDown: function () { + this.fxa = null; + this.client = null; + this._cachedProfile = null; + Services.obs.removeObserver(this, ON_PROFILE_CHANGE_NOTIFICATION); + }, + + _getCachedProfile: function () { + // The cached profile will end up back in the generic accountData + // once bug 1157529 is fixed. + return Promise.resolve(this._cachedProfile); + }, + + _notifyProfileChange: function (uid) { + this._isNotifying = true; + Services.obs.notifyObservers(null, ON_PROFILE_CHANGE_NOTIFICATION, uid); + this._isNotifying = false; + }, + + // Cache fetched data if it is different from what's in the cache. + // Send out a notification if it has changed so that UI can update. + _cacheProfile: function (profileData) { + if (!hasChanged(this._cachedProfile, profileData)) { + log.debug("fetched profile matches cached copy"); + return Promise.resolve(null); // indicates no change (but only tests care) + } + this._cachedProfile = profileData; + this._cachedAt = Date.now(); + return this.fxa.getSignedInUser() + .then(userData => { + log.debug("notifying profile changed for user ${uid}", userData); + this._notifyProfileChange(userData.uid); + return profileData; + }); + }, + + _fetchAndCacheProfile: function () { + if (!this._currentFetchPromise) { + this._currentFetchPromise = this.client.fetchProfile().then(profile => { + return this._cacheProfile(profile).then(() => { + return profile; + }); + }).then(profile => { + this._currentFetchPromise = null; + return profile; + }, err => { + this._currentFetchPromise = null; + throw err; + }); + } + return this._currentFetchPromise + }, + + // Returns cached data right away if available, then fetches the latest profile + // data in the background. After data is fetched a notification will be sent + // out if the profile has changed. + getProfile: function () { + return this._getCachedProfile() + .then(cachedProfile => { + if (cachedProfile) { + if (Date.now() > this._cachedAt + this.PROFILE_FRESHNESS_THRESHOLD) { + // Note that _fetchAndCacheProfile isn't returned, so continues + // in the background. + this._fetchAndCacheProfile().catch(err => { + log.error("Background refresh of profile failed", err); + }); + } else { + log.trace("not checking freshness of profile as it remains recent"); + } + return cachedProfile; + } + return this._fetchAndCacheProfile(); + }) + .then(profile => { + return profile; + }); + }, + + QueryInterface: XPCOMUtils.generateQI([ + Ci.nsIObserver, + Ci.nsISupportsWeakReference, + ]), +}; diff --git a/services/fxaccounts/FxAccountsProfileClient.jsm b/services/fxaccounts/FxAccountsProfileClient.jsm new file mode 100644 index 000000000..37115a3fa --- /dev/null +++ b/services/fxaccounts/FxAccountsProfileClient.jsm @@ -0,0 +1,260 @@ +/* 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/. */ + +/** + * A client to fetch profile information for a Firefox Account. + */ + "use strict;" + +this.EXPORTED_SYMBOLS = ["FxAccountsProfileClient", "FxAccountsProfileClientError"]; + +const {classes: Cc, interfaces: Ci, utils: Cu, results: Cr} = Components; + +Cu.import("resource://gre/modules/Promise.jsm"); +Cu.import("resource://gre/modules/Log.jsm"); +Cu.import("resource://gre/modules/FxAccountsCommon.js"); +Cu.import("resource://gre/modules/FxAccounts.jsm"); +Cu.import("resource://gre/modules/Task.jsm"); +Cu.import("resource://services-common/rest.js"); + +Cu.importGlobalProperties(["URL"]); + +/** + * Create a new FxAccountsProfileClient to be able to fetch Firefox Account profile information. + * + * @param {Object} options Options + * @param {String} options.serverURL + * The URL of the profile server to query. + * Example: https://profile.accounts.firefox.com/v1 + * @param {String} options.token + * The bearer token to access the profile server + * @constructor + */ +this.FxAccountsProfileClient = function(options) { + if (!options || !options.serverURL) { + throw new Error("Missing 'serverURL' configuration option"); + } + + this.fxa = options.fxa || fxAccounts; + // This is a work-around for loop that manages its own oauth tokens. + // * If |token| is in options we use it and don't attempt any token refresh + // on 401. This is for loop. + // * If |token| doesn't exist we will fetch our own token. This is for the + // normal FxAccounts methods for obtaining the profile. + // We should nuke all |this.token| support once loop moves closer to FxAccounts. + this.token = options.token; + + try { + this.serverURL = new URL(options.serverURL); + } catch (e) { + throw new Error("Invalid 'serverURL'"); + } + this.oauthOptions = { + scope: "profile", + }; + log.debug("FxAccountsProfileClient: Initialized"); +}; + +this.FxAccountsProfileClient.prototype = { + /** + * {nsIURI} + * The server to fetch profile information from. + */ + serverURL: null, + + /** + * Interface for making remote requests. + */ + _Request: RESTRequest, + + /** + * Remote request helper which abstracts authentication away. + * + * @param {String} path + * Profile server path, i.e "/profile". + * @param {String} [method] + * Type of request, i.e "GET". + * @return Promise + * Resolves: {Object} Successful response from the Profile server. + * Rejects: {FxAccountsProfileClientError} Profile client error. + * @private + */ + _createRequest: Task.async(function* (path, method = "GET") { + let token = this.token; + if (!token) { + // tokens are cached, so getting them each request is cheap. + token = yield this.fxa.getOAuthToken(this.oauthOptions); + } + try { + return (yield this._rawRequest(path, method, token)); + } catch (ex) { + if (!ex instanceof FxAccountsProfileClientError || ex.code != 401) { + throw ex; + } + // If this object was instantiated with a token then we don't refresh it. + if (this.token) { + throw ex; + } + // it's an auth error - assume our token expired and retry. + log.info("Fetching the profile returned a 401 - revoking our token and retrying"); + yield this.fxa.removeCachedOAuthToken({token}); + token = yield this.fxa.getOAuthToken(this.oauthOptions); + // and try with the new token - if that also fails then we fail after + // revoking the token. + try { + return (yield this._rawRequest(path, method, token)); + } catch (ex) { + if (!ex instanceof FxAccountsProfileClientError || ex.code != 401) { + throw ex; + } + log.info("Retry fetching the profile still returned a 401 - revoking our token and failing"); + yield this.fxa.removeCachedOAuthToken({token}); + throw ex; + } + } + }), + + /** + * Remote "raw" request helper - doesn't handle auth errors and tokens. + * + * @param {String} path + * Profile server path, i.e "/profile". + * @param {String} method + * Type of request, i.e "GET". + * @param {String} token + * @return Promise + * Resolves: {Object} Successful response from the Profile server. + * Rejects: {FxAccountsProfileClientError} Profile client error. + * @private + */ + _rawRequest: function(path, method, token) { + return new Promise((resolve, reject) => { + let profileDataUrl = this.serverURL + path; + let request = new this._Request(profileDataUrl); + method = method.toUpperCase(); + + request.setHeader("Authorization", "Bearer " + token); + request.setHeader("Accept", "application/json"); + + request.onComplete = function (error) { + if (error) { + return reject(new FxAccountsProfileClientError({ + error: ERROR_NETWORK, + errno: ERRNO_NETWORK, + message: error.toString(), + })); + } + + let body = null; + try { + body = JSON.parse(request.response.body); + } catch (e) { + return reject(new FxAccountsProfileClientError({ + error: ERROR_PARSE, + errno: ERRNO_PARSE, + code: request.response.status, + message: request.response.body, + })); + } + + // "response.success" means status code is 200 + if (request.response.success) { + return resolve(body); + } else { + return reject(new FxAccountsProfileClientError({ + error: body.error || ERROR_UNKNOWN, + errno: body.errno || ERRNO_UNKNOWN_ERROR, + code: request.response.status, + message: body.message || body, + })); + } + }; + + if (method === "GET") { + request.get(); + } else { + // method not supported + return reject(new FxAccountsProfileClientError({ + error: ERROR_NETWORK, + errno: ERRNO_NETWORK, + code: ERROR_CODE_METHOD_NOT_ALLOWED, + message: ERROR_MSG_METHOD_NOT_ALLOWED, + })); + } + }); + }, + + /** + * Retrieve user's profile from the server + * + * @return Promise + * Resolves: {Object} Successful response from the '/profile' endpoint. + * Rejects: {FxAccountsProfileClientError} profile client error. + */ + fetchProfile: function () { + log.debug("FxAccountsProfileClient: Requested profile"); + return this._createRequest("/profile", "GET"); + }, + + /** + * Retrieve user's profile from the server + * + * @return Promise + * Resolves: {Object} Successful response from the '/avatar' endpoint. + * Rejects: {FxAccountsProfileClientError} profile client error. + */ + fetchProfileImage: function () { + log.debug("FxAccountsProfileClient: Requested avatar"); + return this._createRequest("/avatar", "GET"); + } +}; + +/** + * Normalized profile client errors + * @param {Object} [details] + * Error details object + * @param {number} [details.code] + * Error code + * @param {number} [details.errno] + * Error number + * @param {String} [details.error] + * Error description + * @param {String|null} [details.message] + * Error message + * @constructor + */ +this.FxAccountsProfileClientError = function(details) { + details = details || {}; + + this.name = "FxAccountsProfileClientError"; + this.code = details.code || null; + this.errno = details.errno || ERRNO_UNKNOWN_ERROR; + this.error = details.error || ERROR_UNKNOWN; + this.message = details.message || null; +}; + +/** + * Returns error object properties + * + * @returns {{name: *, code: *, errno: *, error: *, message: *}} + * @private + */ +FxAccountsProfileClientError.prototype._toStringFields = function() { + return { + name: this.name, + code: this.code, + errno: this.errno, + error: this.error, + message: this.message, + }; +}; + +/** + * String representation of a profile client error + * + * @returns {String} + */ +FxAccountsProfileClientError.prototype.toString = function() { + return this.name + "(" + JSON.stringify(this._toStringFields()) + ")"; +}; diff --git a/services/fxaccounts/FxAccountsPush.js b/services/fxaccounts/FxAccountsPush.js new file mode 100644 index 000000000..358be06ee --- /dev/null +++ b/services/fxaccounts/FxAccountsPush.js @@ -0,0 +1,240 @@ +/* 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/. */ + +const Cc = Components.classes; +const Ci = Components.interfaces; +const Cu = Components.utils; + +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); +Cu.import("resource://gre/modules/Services.jsm"); +Cu.import("resource://services-sync/util.js"); +Cu.import("resource://gre/modules/FxAccountsCommon.js"); +Cu.import("resource://gre/modules/Task.jsm"); + +/** + * FxAccountsPushService manages Push notifications for Firefox Accounts in the browser + * + * @param [options] + * Object, custom options that used for testing + * @constructor + */ +function FxAccountsPushService(options = {}) { + this.log = log; + + if (options.log) { + // allow custom log for testing purposes + this.log = options.log; + } + + this.log.debug("FxAccountsPush loading service"); + this.wrappedJSObject = this; + this.initialize(options); +} + +FxAccountsPushService.prototype = { + /** + * Helps only initialize observers once. + */ + _initialized: false, + /** + * Instance of the nsIPushService or a mocked object. + */ + pushService: null, + /** + * Instance of FxAccounts or a mocked object. + */ + fxAccounts: null, + /** + * Component ID of this service, helps register this component. + */ + classID: Components.ID("{1b7db999-2ecd-4abf-bb95-a726896798ca}"), + /** + * Register used interfaces in this service + */ + QueryInterface: XPCOMUtils.generateQI([Ci.nsIObserver]), + /** + * Initialize the service and register all the required observers. + * + * @param [options] + */ + initialize(options) { + if (this._initialized) { + return false; + } + + this._initialized = true; + + if (options.pushService) { + this.pushService = options.pushService; + } else { + this.pushService = Cc["@mozilla.org/push/Service;1"].getService(Ci.nsIPushService); + } + + if (options.fxAccounts) { + this.fxAccounts = options.fxAccounts; + } else { + XPCOMUtils.defineLazyModuleGetter(this, "fxAccounts", + "resource://gre/modules/FxAccounts.jsm"); + } + + // listen to new push messages, push changes and logout events + Services.obs.addObserver(this, this.pushService.pushTopic, false); + Services.obs.addObserver(this, this.pushService.subscriptionChangeTopic, false); + Services.obs.addObserver(this, ONLOGOUT_NOTIFICATION, false); + + this.log.debug("FxAccountsPush initialized"); + }, + /** + * Registers a new endpoint with the Push Server + * + * @returns {Promise} + * Promise always resolves with a subscription or a null if failed to subscribe. + */ + registerPushEndpoint() { + this.log.trace("FxAccountsPush registerPushEndpoint"); + + return new Promise((resolve) => { + this.pushService.subscribe(FXA_PUSH_SCOPE_ACCOUNT_UPDATE, + Services.scriptSecurityManager.getSystemPrincipal(), + (result, subscription) => { + if (Components.isSuccessCode(result)) { + this.log.debug("FxAccountsPush got subscription"); + resolve(subscription); + } else { + this.log.warn("FxAccountsPush failed to subscribe", result); + resolve(null); + } + }); + }); + }, + /** + * Standard observer interface to listen to push messages, changes and logout. + * + * @param subject + * @param topic + * @param data + * @returns {Promise} + */ + _observe(subject, topic, data) { + this.log.trace(`observed topic=${topic}, data=${data}, subject=${subject}`); + switch (topic) { + case this.pushService.pushTopic: + if (data === FXA_PUSH_SCOPE_ACCOUNT_UPDATE) { + let message = subject.QueryInterface(Ci.nsIPushMessage); + return this._onPushMessage(message); + } + break; + case this.pushService.subscriptionChangeTopic: + if (data === FXA_PUSH_SCOPE_ACCOUNT_UPDATE) { + return this._onPushSubscriptionChange(); + } + break; + case ONLOGOUT_NOTIFICATION: + // user signed out, we need to stop polling the Push Server + return this.unsubscribe().catch(err => { + this.log.error("Error during unsubscribe", err); + }); + break; + default: + break; + } + }, + /** + * Wrapper around _observe that catches errors + */ + observe(subject, topic, data) { + Promise.resolve() + .then(() => this._observe(subject, topic, data)) + .catch(err => this.log.error(err)); + }, + /** + * Fired when the Push server sends a notification. + * + * @private + * @returns {Promise} + */ + _onPushMessage(message) { + this.log.trace("FxAccountsPushService _onPushMessage"); + if (!message.data) { + // Use the empty signal to check the verification state of the account right away + this.log.debug("empty push message - checking account status"); + return this.fxAccounts.checkVerificationStatus(); + } + let payload = message.data.json(); + this.log.debug(`push command: ${payload.command}`); + switch (payload.command) { + case ON_DEVICE_DISCONNECTED_NOTIFICATION: + return this.fxAccounts.handleDeviceDisconnection(payload.data.id); + break; + case ON_PASSWORD_CHANGED_NOTIFICATION: + case ON_PASSWORD_RESET_NOTIFICATION: + return this._onPasswordChanged(); + break; + case ON_COLLECTION_CHANGED_NOTIFICATION: + Services.obs.notifyObservers(null, ON_COLLECTION_CHANGED_NOTIFICATION, payload.data.collections); + default: + this.log.warn("FxA Push command unrecognized: " + payload.command); + } + }, + /** + * Check the FxA session status after a password change/reset event. + * If the session is invalid, reset credentials and notify listeners of + * ON_ACCOUNT_STATE_CHANGE_NOTIFICATION that the account may have changed + * + * @returns {Promise} + * @private + */ + _onPasswordChanged: Task.async(function* () { + if (!(yield this.fxAccounts.sessionStatus())) { + yield this.fxAccounts.resetCredentials(); + Services.obs.notifyObservers(null, ON_ACCOUNT_STATE_CHANGE_NOTIFICATION, null); + } + }), + /** + * Fired when the Push server drops a subscription, or the subscription identifier changes. + * + * https://developer.mozilla.org/en-US/docs/Mozilla/Tech/XPCOM/Reference/Interface/nsIPushService#Receiving_Push_Messages + * + * @returns {Promise} + * @private + */ + _onPushSubscriptionChange() { + this.log.trace("FxAccountsPushService _onPushSubscriptionChange"); + return this.fxAccounts.updateDeviceRegistration(); + }, + /** + * Unsubscribe from the Push server + * + * Ref: https://developer.mozilla.org/en-US/docs/Mozilla/Tech/XPCOM/Reference/Interface/nsIPushService#unsubscribe() + * + * @returns {Promise} + * @private + */ + unsubscribe() { + this.log.trace("FxAccountsPushService unsubscribe"); + return new Promise((resolve) => { + this.pushService.unsubscribe(FXA_PUSH_SCOPE_ACCOUNT_UPDATE, + Services.scriptSecurityManager.getSystemPrincipal(), + (result, ok) => { + if (Components.isSuccessCode(result)) { + if (ok === true) { + this.log.debug("FxAccountsPushService unsubscribed"); + } else { + this.log.debug("FxAccountsPushService had no subscription to unsubscribe"); + } + } else { + this.log.warn("FxAccountsPushService failed to unsubscribe", result); + } + return resolve(ok); + }); + }); + }, +}; + +// Service registration below registers with FxAccountsComponents.manifest +const components = [FxAccountsPushService]; +this.NSGetFactory = XPCOMUtils.generateNSGetFactory(components); + +// The following registration below helps with testing this service. +this.EXPORTED_SYMBOLS=["FxAccountsPushService"]; diff --git a/services/fxaccounts/FxAccountsStorage.jsm b/services/fxaccounts/FxAccountsStorage.jsm new file mode 100644 index 000000000..021763b92 --- /dev/null +++ b/services/fxaccounts/FxAccountsStorage.jsm @@ -0,0 +1,609 @@ +/* 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 = [ + "FxAccountsStorageManagerCanStoreField", + "FxAccountsStorageManager", +]; + +const {classes: Cc, interfaces: Ci, utils: Cu} = Components; + +Cu.import("resource://gre/modules/AppConstants.jsm"); +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"); + +// A helper function so code can check what fields are able to be stored by +// the storage manager without having a reference to a manager instance. +function FxAccountsStorageManagerCanStoreField(fieldName) { + return FXA_PWDMGR_MEMORY_FIELDS.has(fieldName) || + FXA_PWDMGR_PLAINTEXT_FIELDS.has(fieldName) || + FXA_PWDMGR_SECURE_FIELDS.has(fieldName); +} + +// The storage manager object. +this.FxAccountsStorageManager = function(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(); +} + +this.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 Object.entries(accountData)) { + if (FXA_PWDMGR_PLAINTEXT_FIELDS.has(name)) { + this.cachedPlain[name] = val; + } else if (FXA_PWDMGR_SECURE_FIELDS.has(name)) { + this.cachedSecure[name] = val; + } else { + // Hopefully it's an "in memory" field. If it's not we log a warning + // but still treat it as such (so it will still be available in this + // session but isn't persisted anywhere.) + if (!FXA_PWDMGR_MEMORY_FIELDS.has(name)) { + log.warn("Unknown FxA field name in user data, treating as in-memory", name); + } + this.cachedMemory[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. + // If fieldNames is specified, it may be a string or an array of strings, + // and only those fields are returned. If not specified the entire account + // data is returned except for "in memory" fields. Note that not specifying + // field names will soon be deprecated/removed - we want all callers to + // specify the fields they care about. + getAccountData: Task.async(function* (fieldNames = null) { + 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 = {}; + if (fieldNames === null) { + // The "old" deprecated way of fetching a logged in user. + for (let [name, value] of Object.entries(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 Object.entries(this.cachedSecure)) { + result[name] = value; + } + // Note we don't return cachedMemory fields here - they must be explicitly + // requested. + return result; + } + // The new explicit way of getting attributes. + if (!Array.isArray(fieldNames)) { + fieldNames = [fieldNames]; + } + let checkedSecure = false; + for (let fieldName of fieldNames) { + if (FXA_PWDMGR_MEMORY_FIELDS.has(fieldName)) { + if (this.cachedMemory[fieldName] !== undefined) { + result[fieldName] = this.cachedMemory[fieldName]; + } + } else if (FXA_PWDMGR_PLAINTEXT_FIELDS.has(fieldName)) { + if (this.cachedPlain[fieldName] !== undefined) { + result[fieldName] = this.cachedPlain[fieldName]; + } + } else if (FXA_PWDMGR_SECURE_FIELDS.has(fieldName)) { + // We may not have read secure storage yet. + if (!checkedSecure) { + yield this._maybeReadAndUpdateSecure(); + checkedSecure = true; + } + if (this.cachedSecure[fieldName] !== undefined) { + result[fieldName] = this.cachedSecure[fieldName]; + } + } else { + throw new Error("unexpected field '" + name + "'"); + } + } + 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 Object.entries(newFields)) { + if (FXA_PWDMGR_MEMORY_FIELDS.has(name)) { + if (value == null) { + delete this.cachedMemory[name]; + } else { + this.cachedMemory[name] = value; + } + } else if (FXA_PWDMGR_PLAINTEXT_FIELDS.has(name)) { + if (value == null) { + delete this.cachedPlain[name]; + } else { + this.cachedPlain[name] = value; + } + } else if (FXA_PWDMGR_SECURE_FIELDS.has(name)) { + // 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; + } else { + // Throwing seems reasonable here as some client code has explicitly + // specified the field name, so it's either confused or needs to update + // how this field is to be treated. + throw new Error("unexpected field '" + name + "'"); + } + } + // 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.cachedMemory = {}; + 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 Object.entries(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 Object.entries(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"); + } else { + 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 Object.entries(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.uid, toWriteSecure); + } catch (ex) { + if (!ex instanceof this.secureStorage.STORAGE_LOCKED) { + throw ex; + } + // 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* (uid, 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, + uid, // 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; + } + // 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 - as of bug 1183951 + // we store the uid, but we support having either for b/w compat. + 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; + } + // 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. +var haveLoginManager = !AppConstants.MOZ_B2G; diff --git a/services/fxaccounts/FxAccountsWebChannel.jsm b/services/fxaccounts/FxAccountsWebChannel.jsm new file mode 100644 index 000000000..810d93c65 --- /dev/null +++ b/services/fxaccounts/FxAccountsWebChannel.jsm @@ -0,0 +1,474 @@ +/* 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/. */ + +/** + * Firefox Accounts Web Channel. + * + * Uses the WebChannel component to receive messages + * about account state changes. + */ + +this.EXPORTED_SYMBOLS = ["EnsureFxAccountsWebChannel"]; + +const {classes: Cc, interfaces: Ci, utils: Cu, results: Cr} = Components; + +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); +Cu.import("resource://gre/modules/FxAccountsCommon.js"); + +XPCOMUtils.defineLazyModuleGetter(this, "Services", + "resource://gre/modules/Services.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "WebChannel", + "resource://gre/modules/WebChannel.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "fxAccounts", + "resource://gre/modules/FxAccounts.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "FxAccountsStorageManagerCanStoreField", + "resource://gre/modules/FxAccountsStorage.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "Weave", + "resource://services-sync/main.js"); + +const COMMAND_PROFILE_CHANGE = "profile:change"; +const COMMAND_CAN_LINK_ACCOUNT = "fxaccounts:can_link_account"; +const COMMAND_LOGIN = "fxaccounts:login"; +const COMMAND_LOGOUT = "fxaccounts:logout"; +const COMMAND_DELETE = "fxaccounts:delete"; +const COMMAND_SYNC_PREFERENCES = "fxaccounts:sync_preferences"; +const COMMAND_CHANGE_PASSWORD = "fxaccounts:change_password"; + +const PREF_LAST_FXA_USER = "identity.fxaccounts.lastSignedInUserHash"; +const PREF_SYNC_SHOW_CUSTOMIZATION = "services.sync-setup.ui.showCustomizationDialog"; + +/** + * A helper function that extracts the message and stack from an error object. + * Returns a `{ message, stack }` tuple. `stack` will be null if the error + * doesn't have a stack trace. + */ +function getErrorDetails(error) { + let details = { message: String(error), stack: null }; + + // Adapted from Console.jsm. + if (error.stack) { + let frames = []; + for (let frame = error.stack; frame; frame = frame.caller) { + frames.push(String(frame).padStart(4)); + } + details.stack = frames.join("\n"); + } + + return details; +} + +/** + * Create a new FxAccountsWebChannel to listen for account updates + * + * @param {Object} options Options + * @param {Object} options + * @param {String} options.content_uri + * The FxA Content server uri + * @param {String} options.channel_id + * The ID of the WebChannel + * @param {String} options.helpers + * Helpers functions. Should only be passed in for testing. + * @constructor + */ +this.FxAccountsWebChannel = function(options) { + if (!options) { + throw new Error("Missing configuration options"); + } + if (!options["content_uri"]) { + throw new Error("Missing 'content_uri' option"); + } + this._contentUri = options.content_uri; + + if (!options["channel_id"]) { + throw new Error("Missing 'channel_id' option"); + } + this._webChannelId = options.channel_id; + + // options.helpers is only specified by tests. + this._helpers = options.helpers || new FxAccountsWebChannelHelpers(options); + + this._setupChannel(); +}; + +this.FxAccountsWebChannel.prototype = { + /** + * WebChannel that is used to communicate with content page + */ + _channel: null, + + /** + * Helpers interface that does the heavy lifting. + */ + _helpers: null, + + /** + * WebChannel ID. + */ + _webChannelId: null, + /** + * WebChannel origin, used to validate origin of messages + */ + _webChannelOrigin: null, + + /** + * Release all resources that are in use. + */ + tearDown() { + this._channel.stopListening(); + this._channel = null; + this._channelCallback = null; + }, + + /** + * Configures and registers a new WebChannel + * + * @private + */ + _setupChannel() { + // if this.contentUri is present but not a valid URI, then this will throw an error. + try { + this._webChannelOrigin = Services.io.newURI(this._contentUri, null, null); + this._registerChannel(); + } catch (e) { + log.error(e); + throw e; + } + }, + + _receiveMessage(message, sendingContext) { + let command = message.command; + let data = message.data; + + switch (command) { + case COMMAND_PROFILE_CHANGE: + Services.obs.notifyObservers(null, ON_PROFILE_CHANGE_NOTIFICATION, data.uid); + break; + case COMMAND_LOGIN: + this._helpers.login(data).catch(error => + this._sendError(error, message, sendingContext)); + break; + case COMMAND_LOGOUT: + case COMMAND_DELETE: + this._helpers.logout(data.uid).catch(error => + this._sendError(error, message, sendingContext)); + break; + case COMMAND_CAN_LINK_ACCOUNT: + let canLinkAccount = this._helpers.shouldAllowRelink(data.email); + + let response = { + command: command, + messageId: message.messageId, + data: { ok: canLinkAccount } + }; + + log.debug("FxAccountsWebChannel response", response); + this._channel.send(response, sendingContext); + break; + case COMMAND_SYNC_PREFERENCES: + this._helpers.openSyncPreferences(sendingContext.browser, data.entryPoint); + break; + case COMMAND_CHANGE_PASSWORD: + this._helpers.changePassword(data).catch(error => + this._sendError(error, message, sendingContext)); + break; + default: + log.warn("Unrecognized FxAccountsWebChannel command", command); + break; + } + }, + + _sendError(error, incomingMessage, sendingContext) { + log.error("Failed to handle FxAccountsWebChannel message", error); + this._channel.send({ + command: incomingMessage.command, + messageId: incomingMessage.messageId, + data: { + error: getErrorDetails(error), + }, + }, sendingContext); + }, + + /** + * Create a new channel with the WebChannelBroker, setup a callback listener + * @private + */ + _registerChannel() { + /** + * Processes messages that are called back from the FxAccountsChannel + * + * @param webChannelId {String} + * Command webChannelId + * @param message {Object} + * Command message + * @param sendingContext {Object} + * Message sending context. + * @param sendingContext.browser {browser} + * The <browser> object that captured the + * WebChannelMessageToChrome. + * @param sendingContext.eventTarget {EventTarget} + * The <EventTarget> where the message was sent. + * @param sendingContext.principal {Principal} + * The <Principal> of the EventTarget where the message was sent. + * @private + * + */ + let listener = (webChannelId, message, sendingContext) => { + if (message) { + log.debug("FxAccountsWebChannel message received", message.command); + if (logPII) { + log.debug("FxAccountsWebChannel message details", message); + } + try { + this._receiveMessage(message, sendingContext); + } catch (error) { + this._sendError(error, message, sendingContext); + } + } + }; + + this._channelCallback = listener; + this._channel = new WebChannel(this._webChannelId, this._webChannelOrigin); + this._channel.listen(listener); + log.debug("FxAccountsWebChannel registered: " + this._webChannelId + " with origin " + this._webChannelOrigin.prePath); + } +}; + +this.FxAccountsWebChannelHelpers = function(options) { + options = options || {}; + + this._fxAccounts = options.fxAccounts || fxAccounts; +}; + +this.FxAccountsWebChannelHelpers.prototype = { + // If the last fxa account used for sync isn't this account, we display + // a modal dialog checking they really really want to do this... + // (This is sync-specific, so ideally would be in sync's identity module, + // but it's a little more seamless to do here, and sync is currently the + // only fxa consumer, so... + shouldAllowRelink(acctName) { + return !this._needRelinkWarning(acctName) || + this._promptForRelink(acctName); + }, + + /** + * New users are asked in the content server whether they want to + * customize which data should be synced. The user is only shown + * the dialog listing the possible data types upon verification. + * + * Save a bit into prefs that is read on verification to see whether + * to show the list of data types that can be saved. + */ + setShowCustomizeSyncPref(showCustomizeSyncPref) { + Services.prefs.setBoolPref(PREF_SYNC_SHOW_CUSTOMIZATION, showCustomizeSyncPref); + }, + + getShowCustomizeSyncPref() { + return Services.prefs.getBoolPref(PREF_SYNC_SHOW_CUSTOMIZATION); + }, + + /** + * stores sync login info it in the fxaccounts service + * + * @param accountData the user's account data and credentials + */ + login(accountData) { + if (accountData.customizeSync) { + this.setShowCustomizeSyncPref(true); + delete accountData.customizeSync; + } + + if (accountData.declinedSyncEngines) { + let declinedSyncEngines = accountData.declinedSyncEngines; + log.debug("Received declined engines", declinedSyncEngines); + Weave.Service.engineManager.setDeclined(declinedSyncEngines); + declinedSyncEngines.forEach(engine => { + Services.prefs.setBoolPref("services.sync.engine." + engine, false); + }); + + // if we got declinedSyncEngines that means we do not need to show the customize screen. + this.setShowCustomizeSyncPref(false); + delete accountData.declinedSyncEngines; + } + + // the user has already been shown the "can link account" + // screen. No need to keep this data around. + delete accountData.verifiedCanLinkAccount; + + // Remember who it was so we can log out next time. + this.setPreviousAccountNameHashPref(accountData.email); + + // A sync-specific hack - we want to ensure sync has been initialized + // before we set the signed-in user. + let xps = Cc["@mozilla.org/weave/service;1"] + .getService(Ci.nsISupports) + .wrappedJSObject; + return xps.whenLoaded().then(() => { + return this._fxAccounts.setSignedInUser(accountData); + }); + }, + + /** + * logout the fxaccounts service + * + * @param the uid of the account which have been logged out + */ + logout(uid) { + return fxAccounts.getSignedInUser().then(userData => { + if (userData.uid === uid) { + // true argument is `localOnly`, because server-side stuff + // has already been taken care of by the content server + return fxAccounts.signOut(true); + } + }); + }, + + changePassword(credentials) { + // If |credentials| has fields that aren't handled by accounts storage, + // updateUserAccountData will throw - mainly to prevent errors in code + // that hard-codes field names. + // However, in this case the field names aren't really in our control. + // We *could* still insist the server know what fields names are valid, + // but that makes life difficult for the server when Firefox adds new + // features (ie, new fields) - forcing the server to track a map of + // versions to supported field names doesn't buy us much. + // So we just remove field names we know aren't handled. + let newCredentials = { + deviceId: null + }; + for (let name of Object.keys(credentials)) { + if (name == "email" || name == "uid" || FxAccountsStorageManagerCanStoreField(name)) { + newCredentials[name] = credentials[name]; + } else { + log.info("changePassword ignoring unsupported field", name); + } + } + return this._fxAccounts.updateUserAccountData(newCredentials) + .then(() => this._fxAccounts.updateDeviceRegistration()); + }, + + /** + * Get the hash of account name of the previously signed in account + */ + getPreviousAccountNameHashPref() { + try { + return Services.prefs.getComplexValue(PREF_LAST_FXA_USER, Ci.nsISupportsString).data; + } catch (_) { + return ""; + } + }, + + /** + * Given an account name, set the hash of the previously signed in account + * + * @param acctName the account name of the user's account. + */ + setPreviousAccountNameHashPref(acctName) { + let string = Cc["@mozilla.org/supports-string;1"] + .createInstance(Ci.nsISupportsString); + string.data = this.sha256(acctName); + Services.prefs.setComplexValue(PREF_LAST_FXA_USER, Ci.nsISupportsString, string); + }, + + /** + * Given a string, returns the SHA265 hash in base64 + */ + sha256(str) { + let converter = Cc["@mozilla.org/intl/scriptableunicodeconverter"] + .createInstance(Ci.nsIScriptableUnicodeConverter); + converter.charset = "UTF-8"; + // Data is an array of bytes. + let data = converter.convertToByteArray(str, {}); + let hasher = Cc["@mozilla.org/security/hash;1"] + .createInstance(Ci.nsICryptoHash); + hasher.init(hasher.SHA256); + hasher.update(data, data.length); + + return hasher.finish(true); + }, + + /** + * Open Sync Preferences in the current tab of the browser + * + * @param {Object} browser the browser in which to open preferences + * @param {String} [entryPoint] entryPoint to use for logging + */ + openSyncPreferences(browser, entryPoint) { + let uri = "about:preferences"; + if (entryPoint) { + uri += "?entrypoint=" + encodeURIComponent(entryPoint); + } + uri += "#sync"; + + browser.loadURI(uri); + }, + + /** + * If a user signs in using a different account, the data from the + * previous account and the new account will be merged. Ask the user + * if they want to continue. + * + * @private + */ + _needRelinkWarning(acctName) { + let prevAcctHash = this.getPreviousAccountNameHashPref(); + return prevAcctHash && prevAcctHash != this.sha256(acctName); + }, + + /** + * Show the user a warning dialog that the data from the previous account + * and the new account will be merged. + * + * @private + */ + _promptForRelink(acctName) { + let sb = Services.strings.createBundle("chrome://browser/locale/syncSetup.properties"); + let continueLabel = sb.GetStringFromName("continue.label"); + let title = sb.GetStringFromName("relinkVerify.title"); + let description = sb.formatStringFromName("relinkVerify.description", + [acctName], 1); + let body = sb.GetStringFromName("relinkVerify.heading") + + "\n\n" + description; + let ps = Services.prompt; + let buttonFlags = (ps.BUTTON_POS_0 * ps.BUTTON_TITLE_IS_STRING) + + (ps.BUTTON_POS_1 * ps.BUTTON_TITLE_CANCEL) + + ps.BUTTON_POS_1_DEFAULT; + + // If running in context of the browser chrome, window does not exist. + var targetWindow = typeof window === 'undefined' ? null : window; + let pressed = Services.prompt.confirmEx(targetWindow, title, body, buttonFlags, + continueLabel, null, null, null, + {}); + return pressed === 0; // 0 is the "continue" button + } +}; + +var singleton; +// The entry-point for this module, which ensures only one of our channels is +// ever created - we require this because the WebChannel is global in scope +// (eg, it uses the observer service to tell interested parties of interesting +// things) and allowing multiple channels would cause such notifications to be +// sent multiple times. +this.EnsureFxAccountsWebChannel = function() { + let contentUri = Services.urlFormatter.formatURLPref("identity.fxaccounts.remote.webchannel.uri"); + if (singleton && singleton._contentUri !== contentUri) { + singleton.tearDown(); + singleton = null; + } + if (!singleton) { + try { + if (contentUri) { + // The FxAccountsWebChannel listens for events and updates + // the state machine accordingly. + singleton = new this.FxAccountsWebChannel({ + content_uri: contentUri, + channel_id: WEBCHANNEL_ID, + }); + } else { + log.warn("FxA WebChannel functionaly is disabled due to no URI pref."); + } + } catch (ex) { + log.error("Failed to create FxA WebChannel", ex); + } + } +} diff --git a/services/fxaccounts/interfaces/moz.build b/services/fxaccounts/interfaces/moz.build new file mode 100644 index 000000000..ac80b3e93 --- /dev/null +++ b/services/fxaccounts/interfaces/moz.build @@ -0,0 +1,11 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# 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/. + +XPIDL_SOURCES += [ + 'nsIFxAccountsUIGlue.idl' +] + +XPIDL_MODULE = 'services_fxaccounts' diff --git a/services/fxaccounts/interfaces/nsIFxAccountsUIGlue.idl b/services/fxaccounts/interfaces/nsIFxAccountsUIGlue.idl new file mode 100644 index 000000000..950fdbc25 --- /dev/null +++ b/services/fxaccounts/interfaces/nsIFxAccountsUIGlue.idl @@ -0,0 +1,15 @@ +/* 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/. */ + +#include "nsISupports.idl" + +[scriptable, uuid(ab8d0700-9577-11e3-a5e2-0800200c9a66)] +interface nsIFxAccountsUIGlue : nsISupports +{ + // Returns a Promise. + jsval signInFlow(); + + // Returns a Promise. + jsval refreshAuthentication(in DOMString email); +}; diff --git a/services/fxaccounts/moz.build b/services/fxaccounts/moz.build new file mode 100644 index 000000000..30c8944c2 --- /dev/null +++ b/services/fxaccounts/moz.build @@ -0,0 +1,35 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# 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/. + +DIRS += ['interfaces'] + +MOCHITEST_CHROME_MANIFESTS += ['tests/mochitest/chrome.ini'] + +XPCSHELL_TESTS_MANIFESTS += ['tests/xpcshell/xpcshell.ini'] + +EXTRA_COMPONENTS += [ + 'FxAccountsComponents.manifest', + 'FxAccountsPush.js', +] + +EXTRA_JS_MODULES += [ + 'Credentials.jsm', + 'FxAccounts.jsm', + 'FxAccountsClient.jsm', + 'FxAccountsCommon.js', + 'FxAccountsConfig.jsm', + 'FxAccountsOAuthClient.jsm', + 'FxAccountsOAuthGrantClient.jsm', + 'FxAccountsProfile.jsm', + 'FxAccountsProfileClient.jsm', + 'FxAccountsPush.js', + 'FxAccountsStorage.jsm', + 'FxAccountsWebChannel.jsm', +] + +# For now, we will only be using the FxA manager in B2G. +if CONFIG['MOZ_B2G']: + EXTRA_JS_MODULES += ['FxAccountsManager.jsm'] diff --git a/services/fxaccounts/tests/mochitest/chrome.ini b/services/fxaccounts/tests/mochitest/chrome.ini new file mode 100644 index 000000000..ab2e77053 --- /dev/null +++ b/services/fxaccounts/tests/mochitest/chrome.ini @@ -0,0 +1,7 @@ +[DEFAULT] +skip-if = os == 'android' +support-files= + file_invalidEmailCase.sjs + +[test_invalidEmailCase.html] + diff --git a/services/fxaccounts/tests/mochitest/file_invalidEmailCase.sjs b/services/fxaccounts/tests/mochitest/file_invalidEmailCase.sjs new file mode 100644 index 000000000..9d97ac70c --- /dev/null +++ b/services/fxaccounts/tests/mochitest/file_invalidEmailCase.sjs @@ -0,0 +1,80 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * This server simulates the behavior of /account/login on the Firefox Accounts + * auth server in the case where the user is trying to sign in with an email + * with the wrong capitalization. + * + * https://github.com/mozilla/fxa-auth-server/blob/master/docs/api.md#post-v1accountlogin + * + * The expected behavior is that on the first attempt, with the wrong email, + * the server will respond with a 400 and the canonical email capitalization + * that the client should use. The client then has one chance to sign in with + * this different capitalization. + * + * In this test, the user with the account id "Greta.Garbo@gmail.COM" initially + * tries to sign in as "greta.garbo@gmail.com". + * + * On success, the client is responsible for updating its sign-in user state + * and recording the proper email capitalization. + */ + +const CC = Components.Constructor; +const BinaryInputStream = CC("@mozilla.org/binaryinputstream;1", + "nsIBinaryInputStream", + "setInputStream"); + +const goodEmail = "Greta.Garbo@gmail.COM"; +const badEmail = "greta.garbo@gmail.com"; + +function handleRequest(request, response) { + let body = new BinaryInputStream(request.bodyInputStream); + let bytes = []; + let available; + while ((available = body.available()) > 0) { + Array.prototype.push.apply(bytes, body.readByteArray(available)); + } + + let data = JSON.parse(String.fromCharCode.apply(null, bytes)); + let message; + + switch (data.email) { + case badEmail: + // Almost - try again with fixed email case + message = { + code: 400, + errno: 120, + error: "Incorrect email case", + email: goodEmail, + }; + response.setStatusLine(request.httpVersion, 400, "Almost"); + break; + + case goodEmail: + // Successful login. + message = { + uid: "your-uid", + sessionToken: "your-sessionToken", + keyFetchToken: "your-keyFetchToken", + verified: true, + authAt: 1392144866, + }; + response.setStatusLine(request.httpVersion, 200, "Yay"); + break; + + default: + // Anything else happening in this test is a failure. + message = { + code: 400, + errno: 999, + error: "What happened!?", + }; + response.setStatusLine(request.httpVersion, 400, "Ouch"); + break; + } + + messageStr = JSON.stringify(message); + response.bodyOutputStream.write(messageStr, messageStr.length); +} + diff --git a/services/fxaccounts/tests/mochitest/test_invalidEmailCase.html b/services/fxaccounts/tests/mochitest/test_invalidEmailCase.html new file mode 100644 index 000000000..52866cc4b --- /dev/null +++ b/services/fxaccounts/tests/mochitest/test_invalidEmailCase.html @@ -0,0 +1,131 @@ +<!-- + Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ +--> +<!DOCTYPE HTML> +<html> +<!-- +Tests for Firefox Accounts signin with invalid email case +https://bugzilla.mozilla.org/show_bug.cgi?id=963835 +--> +<head> + <title>Test for Firefox Accounts (Bug 963835)</title> + <script type="application/javascript" src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css" /> +</head> +<body> + +<a target="_blank" href="https://bugzilla.mozilla.org/show_bug.cgi?id=963835">Mozilla Bug 963835</a> +<p id="display"></p> +<div id="content" style="display: none"> + Test for correction of invalid email case in Fx Accounts signIn +</div> +<pre id="test"> +<script class="testbody" type="text/javascript;version=1.8"> + +SimpleTest.waitForExplicitFinish(); + +Components.utils.import("resource://gre/modules/Promise.jsm"); +Components.utils.import("resource://gre/modules/Services.jsm"); +Components.utils.import("resource://gre/modules/FxAccounts.jsm"); +Components.utils.import("resource://gre/modules/FxAccountsClient.jsm"); +Components.utils.import("resource://services-common/hawkclient.js"); + +const TEST_SERVER = + "http://mochi.test:8888/chrome/services/fxaccounts/tests/mochitest/file_invalidEmailCase.sjs?path="; + +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(); + }, +}); + +function MockFxAccounts() { + return new FxAccounts({ + _now_is: new Date(), + + now: function() { + return this._now_is; + }, + + signedInUserStorage: new MockStorage(), + + fxAccountsClient: new FxAccountsClient(TEST_SERVER), + }); +} + +let wrongEmail = "greta.garbo@gmail.com"; +let rightEmail = "Greta.Garbo@gmail.COM"; +let password = "123456"; + +function runTest() { + is(Services.prefs.getCharPref("identity.fxaccounts.auth.uri"), TEST_SERVER, + "Pref for auth.uri should be set to test server"); + + let fxa = new MockFxAccounts(); + let client = fxa.internal.fxAccountsClient; + + ok(true, !!fxa, "Couldn't mock fxa"); + ok(true, !!client, "Couldn't mock fxa client"); + is(client.host, TEST_SERVER, "Should be using the test auth server uri"); + + // First try to sign in using the email with the wrong capitalization. The + // FxAccountsClient will receive a 400 from the server with the corrected email. + // It will automatically try to sign in again. We expect this to succeed. + client.signIn(wrongEmail, password).then( + user => { + + // Now store the signed-in user state. This will include the correct + // email capitalization. + fxa.setSignedInUser(user).then( + () => { + + // Confirm that the correct email got stored. + fxa.getSignedInUser().then( + data => { + is(data.email, rightEmail); + SimpleTest.finish(); + }, + getUserError => { + ok(false, JSON.stringify(getUserError)); + } + ); + }, + setSignedInUserError => { + ok(false, JSON.stringify(setSignedInUserError)); + } + ); + }, + signInError => { + ok(false, JSON.stringify(signInError)); + } + ); +}; + +SpecialPowers.pushPrefEnv({"set": [ + ["identity.fxaccounts.enabled", true], // fx accounts + ["identity.fxaccounts.auth.uri", TEST_SERVER], // our sjs server + ["toolkit.identity.debug", true], // verbose identity logging + ["browser.dom.window.dump.enabled", true], + ]}, + function () { runTest(); } +); + +</script> +</pre> +</body> +</html> + diff --git a/services/fxaccounts/tests/xpcshell/head.js b/services/fxaccounts/tests/xpcshell/head.js new file mode 100644 index 000000000..ed70fdac5 --- /dev/null +++ b/services/fxaccounts/tests/xpcshell/head.js @@ -0,0 +1,18 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +var {classes: Cc, interfaces: Ci, results: Cr, utils: Cu} = Components; + +"use strict"; + +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); + +(function initFxAccountsTestingInfrastructure() { + do_get_profile(); + + let ns = {}; + Cu.import("resource://testing-common/services/common/logging.js", ns); + + ns.initTestLogging("Trace"); +}).call(this); + diff --git a/services/fxaccounts/tests/xpcshell/test_accounts.js b/services/fxaccounts/tests/xpcshell/test_accounts.js new file mode 100644 index 000000000..d6139a076 --- /dev/null +++ b/services/fxaccounts/tests/xpcshell/test_accounts.js @@ -0,0 +1,1531 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +Cu.import("resource://services-common/utils.js"); +Cu.import("resource://gre/modules/Services.jsm"); +Cu.import("resource://gre/modules/FxAccounts.jsm"); +Cu.import("resource://gre/modules/FxAccountsClient.jsm"); +Cu.import("resource://gre/modules/FxAccountsCommon.js"); +Cu.import("resource://gre/modules/FxAccountsOAuthGrantClient.jsm"); +Cu.import("resource://gre/modules/Promise.jsm"); +Cu.import("resource://gre/modules/Log.jsm"); + +// We grab some additional stuff via backstage passes. +var {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']); + +var log = Log.repository.getLogger("Services.FxAccounts.test"); +log.level = Log.Level.Debug; + +// See verbose logging from FxAccounts.jsm +Services.prefs.setCharPref("identity.fxaccounts.loglevel", "Trace"); +Log.repository.getLogger("FirefoxAccounts").level = Log.Level.Trace; + +// The oauth server is mocked, but set these prefs to pass param checks +Services.prefs.setCharPref("identity.fxaccounts.remote.oauth.uri", "https://example.com/v1"); +Services.prefs.setCharPref("identity.fxaccounts.oauth.client_id", "abc123"); + + +const PROFILE_SERVER_URL = "http://example.com/v1"; +const CONTENT_URL = "http://accounts.example.com/"; + +Services.prefs.setCharPref("identity.fxaccounts.remote.profile.uri", PROFILE_SERVER_URL); +Services.prefs.setCharPref("identity.fxaccounts.settings.uri", CONTENT_URL); + +/* + * 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 Object.entries(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) { + // simulate a call to /recovery_email/status + return Promise.resolve({ + email: this._email, + verified: this._verified + }); + }; + + this.accountStatus = function(uid) { + let deferred = Promise.defer(); + deferred.resolve(!!uid && (!this._deletedOnServer)); + return deferred.promise; + }; + + this.accountKeys = function (keyFetchToken) { + let deferred = Promise.defer(); + + do_timeout(50, () => { + let response = { + kA: expandBytes("11"), + wrapKB: expandBytes("22") + }; + deferred.resolve(response); + }); + return deferred.promise; + }; + + this.resendVerificationEmail = function(sessionToken) { + // Return the session token to show that we received it in the first place + return Promise.resolve(sessionToken); + }; + + this.signCertificate = function() { throw "no" }; + + this.signOut = () => Promise.resolve(); + this.signOutAndDestroyDevice = () => Promise.resolve({}); + + FxAccountsClient.apply(this); +} +MockFxAccountsClient.prototype = { + __proto__: FxAccountsClient.prototype +} + +/* + * 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(), + 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(storage); + }, + getCertificateSigned: function (sessionToken, serializedPublicKey) { + _("mock getCertificateSigned\n"); + this._getCertificateSigned_calls.push([sessionToken, serializedPublicKey]); + return this._d_signCertificate.promise; + }, + _registerOrUpdateDevice() { + return Promise.resolve(); + }, + fxAccountsClient: new MockFxAccountsClient() + }); +} + +/* + * Some tests want a "real" fxa instance - however, we still mock the storage + * to keep the tests fast on b2g. + */ +function MakeFxAccounts(internal = {}) { + if (!internal.newAccountState) { + // we use a real accountState but mocked storage. + internal.newAccountState = function(credentials) { + let storage = new MockStorageManager(); + storage.initialize(credentials); + return new AccountState(storage); + }; + } + if (!internal._signOutServer) { + internal._signOutServer = () => Promise.resolve(); + } + if (!internal._registerOrUpdateDevice) { + internal._registerOrUpdateDevice = () => Promise.resolve(); + } + return new FxAccounts(internal); +} + +add_task(function* test_non_https_remote_server_uri_with_requireHttps_false() { + Services.prefs.setBoolPref( + "identity.fxaccounts.allowHttp", + true); + Services.prefs.setCharPref( + "identity.fxaccounts.remote.signup.uri", + "http://example.com/browser/browser/base/content/test/general/accounts_testRemoteCommands.html"); + do_check_eq(yield fxAccounts.promiseAccountsSignUpURI(), + "http://example.com/browser/browser/base/content/test/general/accounts_testRemoteCommands.html"); + + Services.prefs.clearUserPref("identity.fxaccounts.remote.signup.uri"); + Services.prefs.clearUserPref("identity.fxaccounts.allowHttp"); +}); + +add_task(function* test_non_https_remote_server_uri() { + Services.prefs.setCharPref( + "identity.fxaccounts.remote.signup.uri", + "http://example.com/browser/browser/base/content/test/general/accounts_testRemoteCommands.html"); + rejects(fxAccounts.promiseAccountsSignUpURI(), null, "Firefox Accounts server must use HTTPS"); + Services.prefs.clearUserPref("identity.fxaccounts.remote.signup.uri"); +}); + +add_task(function* test_get_signed_in_user_initially_unset() { + _("Check getSignedInUser initially and after signout reports no user"); + let account = MakeFxAccounts(); + let credentials = { + email: "foo@example.com", + uid: "1234@lcip.org", + assertion: "foobar", + sessionToken: "dead", + kA: "beef", + kB: "cafe", + verified: true + }; + let result = yield account.getSignedInUser(); + do_check_eq(result, null); + + yield account.setSignedInUser(credentials); + let histogram = Services.telemetry.getHistogramById("FXA_CONFIGURED"); + do_check_eq(histogram.snapshot().sum, 1); + histogram.clear(); + + result = yield account.getSignedInUser(); + do_check_eq(result.email, credentials.email); + do_check_eq(result.assertion, credentials.assertion); + do_check_eq(result.kB, credentials.kB); + + // Delete the memory cache and force the user + // to be read and parsed from storage (e.g. disk via JSONStorage). + delete account.internal.signedInUser; + result = yield account.getSignedInUser(); + do_check_eq(result.email, credentials.email); + do_check_eq(result.assertion, credentials.assertion); + do_check_eq(result.kB, credentials.kB); + + // sign out + let localOnly = true; + yield account.signOut(localOnly); + + // user should be undefined after sign out + result = yield account.getSignedInUser(); + do_check_eq(result, null); +}); + +add_task(function* test_update_account_data() { + _("Check updateUserAccountData does the right thing."); + let account = MakeFxAccounts(); + let credentials = { + email: "foo@example.com", + uid: "1234@lcip.org", + assertion: "foobar", + sessionToken: "dead", + kA: "beef", + kB: "cafe", + verified: true + }; + yield account.setSignedInUser(credentials); + + let newCreds = { + email: credentials.email, + uid: credentials.uid, + assertion: "new_assertion", + } + yield account.updateUserAccountData(newCreds); + do_check_eq((yield account.getSignedInUser()).assertion, "new_assertion", + "new field value was saved"); + + // but we should fail attempting to change email or uid. + newCreds = { + email: "someoneelse@example.com", + uid: credentials.uid, + assertion: "new_assertion", + } + yield Assert.rejects(account.updateUserAccountData(newCreds)); + newCreds = { + email: credentials.email, + uid: "another_uid", + assertion: "new_assertion", + } + yield Assert.rejects(account.updateUserAccountData(newCreds)); + + // should fail without email or uid. + newCreds = { + assertion: "new_assertion", + } + yield Assert.rejects(account.updateUserAccountData(newCreds)); + + // and should fail with a field name that's not known by storage. + newCreds = { + email: credentials.email, + uid: "another_uid", + foo: "bar", + } + yield Assert.rejects(account.updateUserAccountData(newCreds)); +}); + +add_task(function* test_getCertificateOffline() { + _("getCertificateOffline()"); + let fxa = MakeFxAccounts(); + let credentials = { + email: "foo@example.com", + uid: "1234@lcip.org", + sessionToken: "dead", + verified: true, + }; + + yield fxa.setSignedInUser(credentials); + + // Test that an expired cert throws if we're offline. + let offline = Services.io.offline; + Services.io.offline = true; + yield fxa.internal.getKeypairAndCertificate(fxa.internal.currentAccountState).then( + result => { + Services.io.offline = offline; + do_throw("Unexpected success"); + }, + err => { + Services.io.offline = offline; + // ... so we have to check the error string. + do_check_eq(err, "Error: OFFLINE"); + } + ); + yield fxa.signOut(/*localOnly = */true); +}); + +add_task(function* test_getCertificateCached() { + _("getCertificateCached()"); + let fxa = MakeFxAccounts(); + let credentials = { + email: "foo@example.com", + uid: "1234@lcip.org", + sessionToken: "dead", + verified: true, + // A cached keypair and cert that remain valid. + keyPair: { + validUntil: Date.now() + KEY_LIFETIME + 10000, + rawKeyPair: "good-keypair", + }, + cert: { + validUntil: Date.now() + CERT_LIFETIME + 10000, + rawCert: "good-cert", + }, + }; + + yield fxa.setSignedInUser(credentials); + let {keyPair, certificate} = yield fxa.internal.getKeypairAndCertificate(fxa.internal.currentAccountState); + // should have the same keypair and cert. + do_check_eq(keyPair, credentials.keyPair.rawKeyPair); + do_check_eq(certificate, credentials.cert.rawCert); + yield fxa.signOut(/*localOnly = */true); +}); + +add_task(function* test_getCertificateExpiredCert() { + _("getCertificateExpiredCert()"); + let fxa = MakeFxAccounts({ + getCertificateSigned() { + return "new cert"; + } + }); + let credentials = { + email: "foo@example.com", + uid: "1234@lcip.org", + sessionToken: "dead", + verified: true, + // A cached keypair that remains valid. + keyPair: { + validUntil: Date.now() + KEY_LIFETIME + 10000, + rawKeyPair: "good-keypair", + }, + // A cached certificate which has expired. + cert: { + validUntil: Date.parse("Mon, 13 Jan 2000 21:45:06 GMT"), + rawCert: "expired-cert", + }, + }; + yield fxa.setSignedInUser(credentials); + let {keyPair, certificate} = yield fxa.internal.getKeypairAndCertificate(fxa.internal.currentAccountState); + // should have the same keypair but a new cert. + do_check_eq(keyPair, credentials.keyPair.rawKeyPair); + do_check_neq(certificate, credentials.cert.rawCert); + yield fxa.signOut(/*localOnly = */true); +}); + +add_task(function* test_getCertificateExpiredKeypair() { + _("getCertificateExpiredKeypair()"); + let fxa = MakeFxAccounts({ + getCertificateSigned() { + return "new cert"; + }, + }); + let credentials = { + email: "foo@example.com", + uid: "1234@lcip.org", + sessionToken: "dead", + verified: true, + // A cached keypair that has expired. + keyPair: { + validUntil: Date.now() - 1000, + rawKeyPair: "expired-keypair", + }, + // A cached certificate which remains valid. + cert: { + validUntil: Date.now() + CERT_LIFETIME + 10000, + rawCert: "expired-cert", + }, + }; + + yield fxa.setSignedInUser(credentials); + let {keyPair, certificate} = yield fxa.internal.getKeypairAndCertificate(fxa.internal.currentAccountState); + // even though the cert was valid, the fact the keypair was not means we + // should have fetched both. + do_check_neq(keyPair, credentials.keyPair.rawKeyPair); + do_check_neq(certificate, credentials.cert.rawCert); + yield fxa.signOut(/*localOnly = */true); +}); + +// Sanity-check that our mocked client is working correctly +add_test(function test_client_mock() { + let fxa = new MockFxAccounts(); + let client = fxa.internal.fxAccountsClient; + do_check_eq(client._verified, false); + do_check_eq(typeof client.signIn, "function"); + + // The recoveryEmailStatus function eventually fulfills its promise + client.recoveryEmailStatus() + .then(response => { + do_check_eq(response.verified, false); + run_next_test(); + }); +}); + +// Sign in a user, and after a little while, verify the user's email. +// Right after signing in the user, we should get the 'onlogin' notification. +// Polling should detect that the email is verified, and eventually +// 'onverified' should be observed +add_test(function test_verification_poll() { + let fxa = new MockFxAccounts(); + let test_user = getTestUser("francine"); + let login_notification_received = false; + + makeObserver(ONVERIFIED_NOTIFICATION, function() { + log.debug("test_verification_poll observed onverified"); + // Once email verification is complete, we will observe onverified + fxa.internal.getUserAccountData().then(user => { + // And confirm that the user's state has changed + do_check_eq(user.verified, true); + do_check_eq(user.email, test_user.email); + do_check_true(login_notification_received); + run_next_test(); + }); + }); + + makeObserver(ONLOGIN_NOTIFICATION, function() { + log.debug("test_verification_poll observer onlogin"); + login_notification_received = true; + }); + + fxa.setSignedInUser(test_user).then(() => { + fxa.internal.getUserAccountData().then(user => { + // The user is signing in, but email has not been verified yet + do_check_eq(user.verified, false); + do_timeout(200, function() { + log.debug("Mocking verification of francine's email"); + fxa.internal.fxAccountsClient._email = test_user.email; + fxa.internal.fxAccountsClient._verified = true; + }); + }); + }); +}); + +// Sign in the user, but never verify the email. The check-email +// poll should time out. No verifiedlogin event should be observed, and the +// internal whenVerified promise should be rejected +add_test(function test_polling_timeout() { + // This test could be better - the onverified observer might fire on + // somebody else's stack, and we're not making sure that we're not receiving + // such a message. In other words, this tests either failure, or success, but + // not both. + + let fxa = new MockFxAccounts(); + let test_user = getTestUser("carol"); + + let removeObserver = makeObserver(ONVERIFIED_NOTIFICATION, function() { + do_throw("We should not be getting a login event!"); + }); + + fxa.internal.POLL_SESSION = 1; + + let p = fxa.internal.whenVerified({}); + + fxa.setSignedInUser(test_user).then(() => { + p.then( + (success) => { + do_throw("this should not succeed"); + }, + (fail) => { + removeObserver(); + fxa.signOut().then(run_next_test); + } + ); + }); +}); + +add_test(function test_getKeys() { + let fxa = new MockFxAccounts(); + let user = getTestUser("eusebius"); + + // Once email has been verified, we will be able to get keys + user.verified = true; + + fxa.setSignedInUser(user).then(() => { + fxa.getSignedInUser().then((user) => { + // Before getKeys, we have no keys + do_check_eq(!!user.kA, false); + do_check_eq(!!user.kB, false); + // And we still have a key-fetch token and unwrapBKey to use + do_check_eq(!!user.keyFetchToken, true); + do_check_eq(!!user.unwrapBKey, true); + + fxa.internal.getKeys().then(() => { + fxa.getSignedInUser().then((user) => { + // Now we should have keys + do_check_eq(fxa.internal.isUserEmailVerified(user), true); + do_check_eq(!!user.verified, true); + do_check_eq(user.kA, expandHex("11")); + do_check_eq(user.kB, expandHex("66")); + do_check_eq(user.keyFetchToken, undefined); + do_check_eq(user.unwrapBKey, undefined); + run_next_test(); + }); + }); + }); + }); +}); + +add_task(function* test_getKeys_nonexistent_account() { + let fxa = new MockFxAccounts(); + let bismarck = getTestUser("bismarck"); + + let client = fxa.internal.fxAccountsClient; + client.accountStatus = () => Promise.resolve(false); + client.accountKeys = () => { + return Promise.reject({ + code: 401, + errno: ERRNO_INVALID_AUTH_TOKEN, + }); + }; + + yield fxa.setSignedInUser(bismarck); + + let promiseLogout = new Promise(resolve => { + makeObserver(ONLOGOUT_NOTIFICATION, function() { + log.debug("test_getKeys_nonexistent_account observed logout"); + resolve(); + }); + }); + + try { + yield fxa.internal.getKeys(); + do_check_true(false); + } catch (err) { + do_check_eq(err.code, 401); + do_check_eq(err.errno, ERRNO_INVALID_AUTH_TOKEN); + } + + yield promiseLogout; + + let user = yield fxa.internal.getUserAccountData(); + do_check_eq(user, null); +}); + +// getKeys with invalid keyFetchToken should delete keyFetchToken from storage +add_task(function* test_getKeys_invalid_token() { + let fxa = new MockFxAccounts(); + let yusuf = getTestUser("yusuf"); + + let client = fxa.internal.fxAccountsClient; + client.accountStatus = () => Promise.resolve(true); + client.accountKeys = () => { + return Promise.reject({ + code: 401, + errno: ERRNO_INVALID_AUTH_TOKEN, + }); + }; + + yield fxa.setSignedInUser(yusuf); + + try { + yield fxa.internal.getKeys(); + do_check_true(false); + } catch (err) { + do_check_eq(err.code, 401); + do_check_eq(err.errno, ERRNO_INVALID_AUTH_TOKEN); + } + + let user = yield fxa.internal.getUserAccountData(); + do_check_eq(user.email, yusuf.email); + do_check_eq(user.keyFetchToken, null); +}); + +// fetchAndUnwrapKeys with no keyFetchToken should trigger signOut +add_test(function test_fetchAndUnwrapKeys_no_token() { + let fxa = new MockFxAccounts(); + let user = getTestUser("lettuce.protheroe"); + delete user.keyFetchToken + + makeObserver(ONLOGOUT_NOTIFICATION, function() { + log.debug("test_fetchAndUnwrapKeys_no_token observed logout"); + fxa.internal.getUserAccountData().then(user => { + run_next_test(); + }); + }); + + fxa.setSignedInUser(user).then( + user => { + return fxa.internal.fetchAndUnwrapKeys(); + } + ).then( + null, + error => { + log.info("setSignedInUser correctly rejected"); + } + ) +}); + +// Alice (User A) signs up but never verifies her email. Then Bob (User B) +// signs in with a verified email. Ensure that no sign-in events are triggered +// on Alice's behalf. In the end, Bob should be the signed-in user. +add_test(function test_overlapping_signins() { + let fxa = new MockFxAccounts(); + let alice = getTestUser("alice"); + let bob = getTestUser("bob"); + + makeObserver(ONVERIFIED_NOTIFICATION, function() { + log.debug("test_overlapping_signins observed onverified"); + // Once email verification is complete, we will observe onverified + fxa.internal.getUserAccountData().then(user => { + do_check_eq(user.email, bob.email); + do_check_eq(user.verified, true); + run_next_test(); + }); + }); + + // Alice is the user signing in; her email is unverified. + fxa.setSignedInUser(alice).then(() => { + log.debug("Alice signing in ..."); + fxa.internal.getUserAccountData().then(user => { + do_check_eq(user.email, alice.email); + do_check_eq(user.verified, false); + log.debug("Alice has not verified her email ..."); + + // Now Bob signs in instead and actually verifies his email + log.debug("Bob signing in ..."); + fxa.setSignedInUser(bob).then(() => { + do_timeout(200, function() { + // Mock email verification ... + log.debug("Bob verifying his email ..."); + fxa.internal.fxAccountsClient._verified = true; + }); + }); + }); + }); +}); + +add_task(function* test_getAssertion_invalid_token() { + let fxa = new MockFxAccounts(); + + let client = fxa.internal.fxAccountsClient; + client.accountStatus = () => Promise.resolve(true); + + let creds = { + sessionToken: "sessionToken", + kA: expandHex("11"), + kB: expandHex("66"), + verified: true, + email: "sonia@example.com", + }; + yield fxa.setSignedInUser(creds); + + try { + let promiseAssertion = fxa.getAssertion("audience.example.com"); + fxa.internal._d_signCertificate.reject({ + code: 401, + errno: ERRNO_INVALID_AUTH_TOKEN, + }); + yield promiseAssertion; + do_check_true(false, "getAssertion should reject invalid session token"); + } catch (err) { + do_check_eq(err.code, 401); + do_check_eq(err.errno, ERRNO_INVALID_AUTH_TOKEN); + } + + let user = yield fxa.internal.getUserAccountData(); + do_check_eq(user.email, creds.email); + do_check_eq(user.sessionToken, null); +}); + +add_task(function* test_getAssertion() { + let fxa = new MockFxAccounts(); + + do_check_throws(function* () { + yield fxa.getAssertion("nonaudience"); + }); + + let creds = { + sessionToken: "sessionToken", + kA: expandHex("11"), + kB: expandHex("66"), + verified: true + }; + // By putting kA/kB/verified in "creds", we skip ahead + // to the "we're ready" stage. + yield fxa.setSignedInUser(creds); + + _("== ready to go\n"); + // Start with a nice arbitrary but realistic date. Here we use a nice RFC + // 1123 date string like we would get from an HTTP header. Over the course of + // the test, we will update 'now', but leave 'start' where it is. + let now = Date.parse("Mon, 13 Jan 2014 21:45:06 GMT"); + let start = now; + fxa.internal._now_is = now; + + let d = fxa.getAssertion("audience.example.com"); + // At this point, a thread has been spawned to generate the keys. + _("-- back from fxa.getAssertion\n"); + fxa.internal._d_signCertificate.resolve("cert1"); + let assertion = yield d; + do_check_eq(fxa.internal._getCertificateSigned_calls.length, 1); + do_check_eq(fxa.internal._getCertificateSigned_calls[0][0], "sessionToken"); + do_check_neq(assertion, null); + _("ASSERTION: " + assertion + "\n"); + let pieces = assertion.split("~"); + do_check_eq(pieces[0], "cert1"); + let userData = yield fxa.getSignedInUser(); + let keyPair = userData.keyPair; + let cert = userData.cert; + do_check_neq(keyPair, undefined); + _(keyPair.validUntil + "\n"); + let p2 = pieces[1].split("."); + let header = JSON.parse(atob(p2[0])); + _("HEADER: " + JSON.stringify(header) + "\n"); + do_check_eq(header.alg, "DS128"); + let payload = JSON.parse(atob(p2[1])); + _("PAYLOAD: " + JSON.stringify(payload) + "\n"); + do_check_eq(payload.aud, "audience.example.com"); + do_check_eq(keyPair.validUntil, start + KEY_LIFETIME); + do_check_eq(cert.validUntil, start + CERT_LIFETIME); + _("delta: " + Date.parse(payload.exp - start) + "\n"); + let exp = Number(payload.exp); + + do_check_eq(exp, now + ASSERTION_LIFETIME); + + // Reset for next call. + fxa.internal._d_signCertificate = Promise.defer(); + + // Getting a new assertion "soon" (i.e., w/o incrementing "now"), even for + // a new audience, should not provoke key generation or a signing request. + assertion = yield fxa.getAssertion("other.example.com"); + + // There were no additional calls - same number of getcert calls as before + do_check_eq(fxa.internal._getCertificateSigned_calls.length, 1); + + // Wait an hour; assertion use period expires, but not the certificate + now += ONE_HOUR_MS; + fxa.internal._now_is = now; + + // This won't block on anything - will make an assertion, but not get a + // new certificate. + assertion = yield fxa.getAssertion("third.example.com"); + + // Test will time out if that failed (i.e., if that had to go get a new cert) + pieces = assertion.split("~"); + do_check_eq(pieces[0], "cert1"); + p2 = pieces[1].split("."); + header = JSON.parse(atob(p2[0])); + payload = JSON.parse(atob(p2[1])); + do_check_eq(payload.aud, "third.example.com"); + + // The keypair and cert should have the same validity as before, but the + // expiration time of the assertion should be different. We compare this to + // the initial start time, to which they are relative, not the current value + // of "now". + userData = yield fxa.getSignedInUser(); + + keyPair = userData.keyPair; + cert = userData.cert; + do_check_eq(keyPair.validUntil, start + KEY_LIFETIME); + do_check_eq(cert.validUntil, start + CERT_LIFETIME); + exp = Number(payload.exp); + do_check_eq(exp, now + ASSERTION_LIFETIME); + + // Now we wait even longer, and expect both assertion and cert to expire. So + // we will have to get a new keypair and cert. + now += ONE_DAY_MS; + fxa.internal._now_is = now; + d = fxa.getAssertion("fourth.example.com"); + fxa.internal._d_signCertificate.resolve("cert2"); + assertion = yield d; + do_check_eq(fxa.internal._getCertificateSigned_calls.length, 2); + do_check_eq(fxa.internal._getCertificateSigned_calls[1][0], "sessionToken"); + pieces = assertion.split("~"); + do_check_eq(pieces[0], "cert2"); + p2 = pieces[1].split("."); + header = JSON.parse(atob(p2[0])); + payload = JSON.parse(atob(p2[1])); + do_check_eq(payload.aud, "fourth.example.com"); + userData = yield fxa.getSignedInUser(); + keyPair = userData.keyPair; + cert = userData.cert; + do_check_eq(keyPair.validUntil, now + KEY_LIFETIME); + do_check_eq(cert.validUntil, now + CERT_LIFETIME); + exp = Number(payload.exp); + + do_check_eq(exp, now + ASSERTION_LIFETIME); + _("----- DONE ----\n"); +}); + +add_task(function* test_resend_email_not_signed_in() { + let fxa = new MockFxAccounts(); + + try { + yield fxa.resendVerificationEmail(); + } catch(err) { + do_check_eq(err.message, + "Cannot resend verification email; no signed-in user"); + return; + } + do_throw("Should not be able to resend email when nobody is signed in"); +}); + +add_test(function test_accountStatus() { + let fxa = new MockFxAccounts(); + let alice = getTestUser("alice"); + + // If we have no user, we have no account server-side + fxa.accountStatus().then( + (result) => { + do_check_false(result); + } + ).then( + () => { + fxa.setSignedInUser(alice).then( + () => { + fxa.accountStatus().then( + (result) => { + // FxAccounts.accountStatus() should match Client.accountStatus() + do_check_true(result); + fxa.internal.fxAccountsClient._deletedOnServer = true; + fxa.accountStatus().then( + (result) => { + do_check_false(result); + fxa.internal.fxAccountsClient._deletedOnServer = false; + fxa.signOut().then(run_next_test); + } + ); + } + ) + } + ); + } + ); +}); + +add_task(function* test_resend_email_invalid_token() { + let fxa = new MockFxAccounts(); + let sophia = getTestUser("sophia"); + do_check_neq(sophia.sessionToken, null); + + let client = fxa.internal.fxAccountsClient; + client.resendVerificationEmail = () => { + return Promise.reject({ + code: 401, + errno: ERRNO_INVALID_AUTH_TOKEN, + }); + }; + client.accountStatus = () => Promise.resolve(true); + + yield fxa.setSignedInUser(sophia); + let user = yield fxa.internal.getUserAccountData(); + do_check_eq(user.email, sophia.email); + do_check_eq(user.verified, false); + log.debug("Sophia wants verification email resent"); + + try { + yield fxa.resendVerificationEmail(); + do_check_true(false, "resendVerificationEmail should reject invalid session token"); + } catch (err) { + do_check_eq(err.code, 401); + do_check_eq(err.errno, ERRNO_INVALID_AUTH_TOKEN); + } + + user = yield fxa.internal.getUserAccountData(); + do_check_eq(user.email, sophia.email); + do_check_eq(user.sessionToken, null); +}); + +add_test(function test_resend_email() { + let fxa = new MockFxAccounts(); + let alice = getTestUser("alice"); + + let initialState = fxa.internal.currentAccountState; + + // Alice is the user signing in; her email is unverified. + fxa.setSignedInUser(alice).then(() => { + log.debug("Alice signing in"); + + // We're polling for the first email + do_check_true(fxa.internal.currentAccountState !== initialState); + let aliceState = fxa.internal.currentAccountState; + + // The polling timer is ticking + do_check_true(fxa.internal.currentTimer > 0); + + fxa.internal.getUserAccountData().then(user => { + do_check_eq(user.email, alice.email); + do_check_eq(user.verified, false); + log.debug("Alice wants verification email resent"); + + fxa.resendVerificationEmail().then((result) => { + // Mock server response; ensures that the session token actually was + // passed to the client to make the hawk call + do_check_eq(result, "alice's session token"); + + // Timer was not restarted + do_check_true(fxa.internal.currentAccountState === aliceState); + + // Timer is still ticking + do_check_true(fxa.internal.currentTimer > 0); + + // Ok abort polling before we go on to the next test + fxa.internal.abortExistingFlow(); + run_next_test(); + }); + }); + }); +}); + +add_task(function* test_sign_out_with_device() { + const fxa = new MockFxAccounts(); + + const credentials = getTestUser("alice"); + yield fxa.internal.setSignedInUser(credentials); + + const user = yield fxa.internal.getUserAccountData(); + do_check_true(user); + Object.keys(credentials).forEach(key => do_check_eq(credentials[key], user[key])); + + const spy = { + signOut: { count: 0 }, + signOutAndDeviceDestroy: { count: 0, args: [] } + }; + const client = fxa.internal.fxAccountsClient; + client.signOut = function () { + spy.signOut.count += 1; + return Promise.resolve(); + }; + client.signOutAndDestroyDevice = function () { + spy.signOutAndDeviceDestroy.count += 1; + spy.signOutAndDeviceDestroy.args.push(arguments); + return Promise.resolve(); + }; + + const promise = new Promise(resolve => { + makeObserver(ONLOGOUT_NOTIFICATION, () => { + log.debug("test_sign_out_with_device observed onlogout"); + // user should be undefined after sign out + fxa.internal.getUserAccountData().then(user2 => { + do_check_eq(user2, null); + do_check_eq(spy.signOut.count, 0); + do_check_eq(spy.signOutAndDeviceDestroy.count, 1); + do_check_eq(spy.signOutAndDeviceDestroy.args[0].length, 3); + do_check_eq(spy.signOutAndDeviceDestroy.args[0][0], credentials.sessionToken); + do_check_eq(spy.signOutAndDeviceDestroy.args[0][1], credentials.deviceId); + do_check_true(spy.signOutAndDeviceDestroy.args[0][2]); + do_check_eq(spy.signOutAndDeviceDestroy.args[0][2].service, "sync"); + resolve(); + }); + }); + }); + + yield fxa.signOut(); + + yield promise; +}); + +add_task(function* test_sign_out_without_device() { + const fxa = new MockFxAccounts(); + + const credentials = getTestUser("alice"); + delete credentials.deviceId; + yield fxa.internal.setSignedInUser(credentials); + + const user = yield fxa.internal.getUserAccountData(); + + const spy = { + signOut: { count: 0, args: [] }, + signOutAndDeviceDestroy: { count: 0 } + }; + const client = fxa.internal.fxAccountsClient; + client.signOut = function () { + spy.signOut.count += 1; + spy.signOut.args.push(arguments); + return Promise.resolve(); + }; + client.signOutAndDestroyDevice = function () { + spy.signOutAndDeviceDestroy.count += 1; + return Promise.resolve(); + }; + + const promise = new Promise(resolve => { + makeObserver(ONLOGOUT_NOTIFICATION, () => { + log.debug("test_sign_out_without_device observed onlogout"); + // user should be undefined after sign out + fxa.internal.getUserAccountData().then(user2 => { + do_check_eq(user2, null); + do_check_eq(spy.signOut.count, 1); + do_check_eq(spy.signOut.args[0].length, 2); + do_check_eq(spy.signOut.args[0][0], credentials.sessionToken); + do_check_true(spy.signOut.args[0][1]); + do_check_eq(spy.signOut.args[0][1].service, "sync"); + do_check_eq(spy.signOutAndDeviceDestroy.count, 0); + resolve(); + }); + }); + }); + + yield fxa.signOut(); + + yield promise; +}); + +add_task(function* test_sign_out_with_remote_error() { + let fxa = new MockFxAccounts(); + let client = fxa.internal.fxAccountsClient; + let remoteSignOutCalled = false; + // Force remote sign out to trigger an error + client.signOutAndDestroyDevice = function() { remoteSignOutCalled = true; throw "Remote sign out error"; }; + let promiseLogout = new Promise(resolve => { + makeObserver(ONLOGOUT_NOTIFICATION, function() { + log.debug("test_sign_out_with_remote_error observed onlogout"); + resolve(); + }); + }); + + let jane = getTestUser("jane"); + yield fxa.setSignedInUser(jane); + yield fxa.signOut(); + yield promiseLogout; + + let user = yield fxa.internal.getUserAccountData(); + do_check_eq(user, null); + do_check_true(remoteSignOutCalled); +}); + +add_test(function test_getOAuthToken() { + let fxa = new MockFxAccounts(); + let alice = getTestUser("alice"); + alice.verified = true; + let getTokenFromAssertionCalled = false; + + fxa.internal._d_signCertificate.resolve("cert1"); + + // create a mock oauth client + let client = new FxAccountsOAuthGrantClient({ + serverURL: "http://example.com/v1", + client_id: "abc123" + }); + client.getTokenFromAssertion = function () { + getTokenFromAssertionCalled = true; + return Promise.resolve({ access_token: "token" }); + }; + + fxa.setSignedInUser(alice).then( + () => { + fxa.getOAuthToken({ scope: "profile", client: client }).then( + (result) => { + do_check_true(getTokenFromAssertionCalled); + do_check_eq(result, "token"); + run_next_test(); + } + ) + } + ); + +}); + +add_test(function test_getOAuthTokenScoped() { + let fxa = new MockFxAccounts(); + let alice = getTestUser("alice"); + alice.verified = true; + let getTokenFromAssertionCalled = false; + + fxa.internal._d_signCertificate.resolve("cert1"); + + // create a mock oauth client + let client = new FxAccountsOAuthGrantClient({ + serverURL: "http://example.com/v1", + client_id: "abc123" + }); + client.getTokenFromAssertion = function (assertion, scopeString) { + equal(scopeString, "foo bar"); + getTokenFromAssertionCalled = true; + return Promise.resolve({ access_token: "token" }); + }; + + fxa.setSignedInUser(alice).then( + () => { + fxa.getOAuthToken({ scope: ["foo", "bar"], client: client }).then( + (result) => { + do_check_true(getTokenFromAssertionCalled); + do_check_eq(result, "token"); + run_next_test(); + } + ) + } + ); + +}); + +add_task(function* test_getOAuthTokenCached() { + let fxa = new MockFxAccounts(); + let alice = getTestUser("alice"); + alice.verified = true; + let numTokenFromAssertionCalls = 0; + + fxa.internal._d_signCertificate.resolve("cert1"); + + // create a mock oauth client + let client = new FxAccountsOAuthGrantClient({ + serverURL: "http://example.com/v1", + client_id: "abc123" + }); + client.getTokenFromAssertion = function () { + numTokenFromAssertionCalls += 1; + return Promise.resolve({ access_token: "token" }); + }; + + yield fxa.setSignedInUser(alice); + let result = yield fxa.getOAuthToken({ scope: "profile", client: client, service: "test-service" }); + do_check_eq(numTokenFromAssertionCalls, 1); + do_check_eq(result, "token"); + + // requesting it again should not re-fetch the token. + result = yield fxa.getOAuthToken({ scope: "profile", client: client, service: "test-service" }); + do_check_eq(numTokenFromAssertionCalls, 1); + do_check_eq(result, "token"); + // But requesting the same service and a different scope *will* get a new one. + result = yield fxa.getOAuthToken({ scope: "something-else", client: client, service: "test-service" }); + do_check_eq(numTokenFromAssertionCalls, 2); + do_check_eq(result, "token"); +}); + +add_task(function* test_getOAuthTokenCachedScopeNormalization() { + let fxa = new MockFxAccounts(); + let alice = getTestUser("alice"); + alice.verified = true; + let numTokenFromAssertionCalls = 0; + + fxa.internal._d_signCertificate.resolve("cert1"); + + // create a mock oauth client + let client = new FxAccountsOAuthGrantClient({ + serverURL: "http://example.com/v1", + client_id: "abc123" + }); + client.getTokenFromAssertion = function () { + numTokenFromAssertionCalls += 1; + return Promise.resolve({ access_token: "token" }); + }; + + yield fxa.setSignedInUser(alice); + let result = yield fxa.getOAuthToken({ scope: ["foo", "bar"], client: client, service: "test-service" }); + do_check_eq(numTokenFromAssertionCalls, 1); + do_check_eq(result, "token"); + + // requesting it again with the scope array in a different order not re-fetch the token. + result = yield fxa.getOAuthToken({ scope: ["bar", "foo"], client: client, service: "test-service" }); + do_check_eq(numTokenFromAssertionCalls, 1); + do_check_eq(result, "token"); + // requesting it again with the scope array in different case not re-fetch the token. + result = yield fxa.getOAuthToken({ scope: ["Bar", "Foo"], client: client, service: "test-service" }); + 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); + }); +}); + +add_test(function test_getOAuthToken_invalid_scope_array() { + let fxa = new MockFxAccounts(); + + fxa.getOAuthToken({scope: []}) + .then(null, err => { + do_check_eq(err.message, "INVALID_PARAMETER"); + fxa.signOut().then(run_next_test); + }); +}); + +add_test(function test_getOAuthToken_misconfigure_oauth_uri() { + let fxa = new MockFxAccounts(); + + Services.prefs.deleteBranch("identity.fxaccounts.remote.oauth.uri"); + + fxa.getOAuthToken() + .then(null, err => { + do_check_eq(err.message, "INVALID_PARAMETER"); + // revert the pref + Services.prefs.setCharPref("identity.fxaccounts.remote.oauth.uri", "https://example.com/v1"); + fxa.signOut().then(run_next_test); + }); +}); + +add_test(function test_getOAuthToken_no_account() { + let fxa = new MockFxAccounts(); + + fxa.internal.currentAccountState.getUserAccountData = function () { + return Promise.resolve(null); + }; + + fxa.getOAuthToken({ scope: "profile" }) + .then(null, err => { + do_check_eq(err.message, "NO_ACCOUNT"); + fxa.signOut().then(run_next_test); + }); +}); + +add_test(function test_getOAuthToken_unverified() { + let fxa = new MockFxAccounts(); + let alice = getTestUser("alice"); + + fxa.setSignedInUser(alice).then(() => { + fxa.getOAuthToken({ scope: "profile" }) + .then(null, err => { + do_check_eq(err.message, "UNVERIFIED_ACCOUNT"); + fxa.signOut().then(run_next_test); + }); + }); +}); + +add_test(function test_getOAuthToken_network_error() { + let fxa = new MockFxAccounts(); + let alice = getTestUser("alice"); + alice.verified = true; + + fxa.internal._d_signCertificate.resolve("cert1"); + + // create a mock oauth client + let client = new FxAccountsOAuthGrantClient({ + serverURL: "http://example.com/v1", + client_id: "abc123" + }); + client.getTokenFromAssertion = function () { + return Promise.reject(new FxAccountsOAuthGrantClientError({ + error: ERROR_NETWORK, + errno: ERRNO_NETWORK + })); + }; + + fxa.setSignedInUser(alice).then(() => { + fxa.getOAuthToken({ scope: "profile", client: client }) + .then(null, err => { + do_check_eq(err.message, "NETWORK_ERROR"); + do_check_eq(err.details.errno, ERRNO_NETWORK); + run_next_test(); + }); + }); +}); + +add_test(function test_getOAuthToken_auth_error() { + let fxa = new MockFxAccounts(); + let alice = getTestUser("alice"); + alice.verified = true; + + fxa.internal._d_signCertificate.resolve("cert1"); + + // create a mock oauth client + let client = new FxAccountsOAuthGrantClient({ + serverURL: "http://example.com/v1", + client_id: "abc123" + }); + client.getTokenFromAssertion = function () { + return Promise.reject(new FxAccountsOAuthGrantClientError({ + error: ERROR_INVALID_FXA_ASSERTION, + errno: ERRNO_INVALID_FXA_ASSERTION + })); + }; + + fxa.setSignedInUser(alice).then(() => { + fxa.getOAuthToken({ scope: "profile", client: client }) + .then(null, err => { + do_check_eq(err.message, "AUTH_ERROR"); + do_check_eq(err.details.errno, ERRNO_INVALID_FXA_ASSERTION); + run_next_test(); + }); + }); +}); + +add_test(function test_getOAuthToken_unknown_error() { + let fxa = new MockFxAccounts(); + let alice = getTestUser("alice"); + alice.verified = true; + + fxa.internal._d_signCertificate.resolve("cert1"); + + // create a mock oauth client + let client = new FxAccountsOAuthGrantClient({ + serverURL: "http://example.com/v1", + client_id: "abc123" + }); + client.getTokenFromAssertion = function () { + return Promise.reject("boom"); + }; + + fxa.setSignedInUser(alice).then(() => { + fxa.getOAuthToken({ scope: "profile", client: client }) + .then(null, err => { + do_check_eq(err.message, "UNKNOWN_ERROR"); + run_next_test(); + }); + }); +}); + +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({ + _signOutServer() { return Promise.resolve(); }, + _registerOrUpdateDevice() { return Promise.resolve(); } + }); + + fxa.setSignedInUser(alice).then(() => { + fxa.internal._profile = mockProfile; + fxa.getSignedInUserProfile() + .then(result => { + do_check_true(!!result); + do_check_eq(result.avatar, "image"); + run_next_test(); + }); + }); +}); + +add_test(function test_getSignedInUserProfile_error_uses_account_data() { + let fxa = new MockFxAccounts(); + let alice = getTestUser("alice"); + alice.verified = true; + + fxa.internal.getSignedInUser = function () { + return Promise.resolve({ email: "foo@bar.com" }); + }; + + let teardownCalled = false; + fxa.setSignedInUser(alice).then(() => { + fxa.internal._profile = { + getProfile: function () { + return Promise.reject("boom"); + }, + tearDown: function() { + teardownCalled = true; + } + }; + + fxa.getSignedInUserProfile() + .catch(error => { + do_check_eq(error.message, "UNKNOWN_ERROR"); + fxa.signOut().then(() => { + do_check_true(teardownCalled); + run_next_test(); + }); + }); + }); +}); + +add_test(function test_getSignedInUserProfile_unverified_account() { + let fxa = new MockFxAccounts(); + let alice = getTestUser("alice"); + + fxa.setSignedInUser(alice).then(() => { + fxa.getSignedInUserProfile() + .catch(error => { + do_check_eq(error.message, "UNVERIFIED_ACCOUNT"); + fxa.signOut().then(run_next_test); + }); + }); + +}); + +add_test(function test_getSignedInUserProfile_no_account_data() { + let fxa = new MockFxAccounts(); + + fxa.internal.getSignedInUser = function () { + return Promise.resolve(null); + }; + + fxa.getSignedInUserProfile() + .catch(error => { + do_check_eq(error.message, "NO_ACCOUNT"); + fxa.signOut().then(run_next_test); + }); + +}); + +add_task(function* test_checkVerificationStatusFailed() { + let fxa = new MockFxAccounts(); + let alice = getTestUser("alice"); + alice.verified = true; + + let client = fxa.internal.fxAccountsClient; + client.recoveryEmailStatus = () => { + return Promise.reject({ + code: 401, + errno: ERRNO_INVALID_AUTH_TOKEN, + }); + }; + client.accountStatus = () => Promise.resolve(true); + + yield fxa.setSignedInUser(alice); + let user = yield fxa.internal.getUserAccountData(); + do_check_neq(alice.sessionToken, null); + do_check_eq(user.email, alice.email); + do_check_eq(user.verified, true); + + yield fxa.checkVerificationStatus(); + + user = yield fxa.internal.getUserAccountData(); + do_check_eq(user.email, alice.email); + do_check_eq(user.sessionToken, null); +}); + +/* + * End of tests. + * Utility functions follow. + */ + +function expandHex(two_hex) { + // Return a 64-character hex string, encoding 32 identical bytes. + let eight_hex = two_hex + two_hex + two_hex + two_hex; + let thirtytwo_hex = eight_hex + eight_hex + eight_hex + eight_hex; + return thirtytwo_hex + thirtytwo_hex; +}; + +function expandBytes(two_hex) { + return CommonUtils.hexToBytes(expandHex(two_hex)); +}; + +function getTestUser(name) { + return { + email: name + "@example.com", + uid: "1ad7f502-4cc7-4ec1-a209-071fd2fae348", + deviceId: name + "'s device id", + sessionToken: name + "'s session token", + keyFetchToken: name + "'s keyfetch token", + unwrapBKey: expandHex("44"), + verified: false + }; +} + +function makeObserver(aObserveTopic, aObserveFunc) { + let observer = { + // nsISupports provides type management in C++ + // nsIObserver is to be an observer + QueryInterface: XPCOMUtils.generateQI([Ci.nsISupports, Ci.nsIObserver]), + + observe: function (aSubject, aTopic, aData) { + log.debug("observed " + aTopic + " " + aData); + if (aTopic == aObserveTopic) { + removeMe(); + aObserveFunc(aSubject, aTopic, aData); + } + } + }; + + function removeMe() { + log.debug("removing observer for " + aObserveTopic); + Services.obs.removeObserver(observer, aObserveTopic); + } + + Services.obs.addObserver(observer, aObserveTopic, false); + return removeMe; +} + +function do_check_throws(func, result, stack) +{ + if (!stack) + stack = Components.stack.caller; + + try { + func(); + } catch (ex) { + if (ex.name == result) { + return; + } + do_throw("Expected result " + result + ", caught " + ex.name, stack); + } + + if (result) { + do_throw("Expected result " + result + ", none thrown", stack); + } +} diff --git a/services/fxaccounts/tests/xpcshell/test_accounts_device_registration.js b/services/fxaccounts/tests/xpcshell/test_accounts_device_registration.js new file mode 100644 index 000000000..9a2d2c127 --- /dev/null +++ b/services/fxaccounts/tests/xpcshell/test_accounts_device_registration.js @@ -0,0 +1,526 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +Cu.import("resource://services-common/utils.js"); +Cu.import("resource://gre/modules/Services.jsm"); +Cu.import("resource://gre/modules/FxAccounts.jsm"); +Cu.import("resource://gre/modules/FxAccountsClient.jsm"); +Cu.import("resource://gre/modules/FxAccountsCommon.js"); +Cu.import("resource://gre/modules/Promise.jsm"); +Cu.import("resource://gre/modules/Log.jsm"); + +initTestLogging("Trace"); + +var log = Log.repository.getLogger("Services.FxAccounts.test"); +log.level = Log.Level.Debug; + +const BOGUS_PUBLICKEY = "BBXOKjUb84pzws1wionFpfCBjDuCh4-s_1b52WA46K5wYL2gCWEOmFKWn_NkS5nmJwTBuO8qxxdjAIDtNeklvQc"; +const BOGUS_AUTHKEY = "GSsIiaD2Mr83iPqwFNK4rw"; + +Services.prefs.setCharPref("identity.fxaccounts.loglevel", "Trace"); +Log.repository.getLogger("FirefoxAccounts").level = Log.Level.Trace; + +Services.prefs.setCharPref("identity.fxaccounts.remote.oauth.uri", "https://example.com/v1"); +Services.prefs.setCharPref("identity.fxaccounts.oauth.client_id", "abc123"); +Services.prefs.setCharPref("identity.fxaccounts.remote.profile.uri", "http://example.com/v1"); +Services.prefs.setCharPref("identity.fxaccounts.settings.uri", "http://accounts.example.com/"); + +const DEVICE_REGISTRATION_VERSION = 42; + +function MockStorageManager() { +} + +MockStorageManager.prototype = { + initialize(accountData) { + this.accountData = accountData; + }, + + finalize() { + return Promise.resolve(); + }, + + getAccountData() { + return Promise.resolve(this.accountData); + }, + + updateAccountData(updatedFields) { + for (let [name, value] of Object.entries(updatedFields)) { + if (value == null) { + delete this.accountData[name]; + } else { + this.accountData[name] = value; + } + } + return Promise.resolve(); + }, + + deleteAccountData() { + this.accountData = null; + return Promise.resolve(); + } +} + +function MockFxAccountsClient(device) { + this._email = "nobody@example.com"; + this._verified = false; + this._deletedOnServer = false; // for testing accountStatus + + // mock calls up to the auth server to determine whether the + // user account has been verified + this.recoveryEmailStatus = function (sessionToken) { + // simulate a call to /recovery_email/status + return Promise.resolve({ + email: this._email, + verified: this._verified + }); + }; + + this.accountStatus = function(uid) { + let deferred = Promise.defer(); + deferred.resolve(!!uid && (!this._deletedOnServer)); + return deferred.promise; + }; + + const { id: deviceId, name: deviceName, type: deviceType, sessionToken } = device; + + this.registerDevice = (st, name, type) => Promise.resolve({ id: deviceId, name }); + this.updateDevice = (st, id, name) => Promise.resolve({ id, name }); + this.signOutAndDestroyDevice = () => Promise.resolve({}); + this.getDeviceList = (st) => + Promise.resolve([ + { id: deviceId, name: deviceName, type: deviceType, isCurrentDevice: st === sessionToken } + ]); + + FxAccountsClient.apply(this); +} +MockFxAccountsClient.prototype = { + __proto__: FxAccountsClient.prototype +} + +function MockFxAccounts(device = {}) { + return new FxAccounts({ + _getDeviceName() { + return device.name || "mock device name"; + }, + fxAccountsClient: new MockFxAccountsClient(device), + fxaPushService: { + registerPushEndpoint() { + return new Promise((resolve) => { + resolve({ + endpoint: "http://mochi.test:8888", + getKey: function(type) { + return ChromeUtils.base64URLDecode( + type === "auth" ? BOGUS_AUTHKEY : BOGUS_PUBLICKEY, + { padding: "ignore" }); + } + }); + }); + }, + }, + DEVICE_REGISTRATION_VERSION + }); +} + +add_task(function* test_updateDeviceRegistration_with_new_device() { + const deviceName = "foo"; + const deviceType = "bar"; + + const credentials = getTestUser("baz"); + delete credentials.deviceId; + const fxa = new MockFxAccounts({ name: deviceName }); + yield fxa.internal.setSignedInUser(credentials); + + const spy = { + registerDevice: { count: 0, args: [] }, + updateDevice: { count: 0, args: [] }, + getDeviceList: { count: 0, args: [] } + }; + const client = fxa.internal.fxAccountsClient; + client.registerDevice = function () { + spy.registerDevice.count += 1; + spy.registerDevice.args.push(arguments); + return Promise.resolve({ + id: "newly-generated device id", + createdAt: Date.now(), + name: deviceName, + type: deviceType + }); + }; + client.updateDevice = function () { + spy.updateDevice.count += 1; + spy.updateDevice.args.push(arguments); + return Promise.resolve({}); + }; + client.getDeviceList = function () { + spy.getDeviceList.count += 1; + spy.getDeviceList.args.push(arguments); + return Promise.resolve([]); + }; + + const result = yield fxa.updateDeviceRegistration(); + + do_check_eq(result, "newly-generated device id"); + do_check_eq(spy.updateDevice.count, 0); + do_check_eq(spy.getDeviceList.count, 0); + do_check_eq(spy.registerDevice.count, 1); + do_check_eq(spy.registerDevice.args[0].length, 4); + do_check_eq(spy.registerDevice.args[0][0], credentials.sessionToken); + do_check_eq(spy.registerDevice.args[0][1], deviceName); + do_check_eq(spy.registerDevice.args[0][2], "desktop"); + do_check_eq(spy.registerDevice.args[0][3].pushCallback, "http://mochi.test:8888"); + do_check_eq(spy.registerDevice.args[0][3].pushPublicKey, BOGUS_PUBLICKEY); + do_check_eq(spy.registerDevice.args[0][3].pushAuthKey, BOGUS_AUTHKEY); + + const state = fxa.internal.currentAccountState; + const data = yield state.getUserAccountData(); + + do_check_eq(data.deviceId, "newly-generated device id"); + do_check_eq(data.deviceRegistrationVersion, DEVICE_REGISTRATION_VERSION); +}); + +add_task(function* test_updateDeviceRegistration_with_existing_device() { + const deviceName = "phil's device"; + const deviceType = "desktop"; + + const credentials = getTestUser("pb"); + const fxa = new MockFxAccounts({ name: deviceName }); + yield fxa.internal.setSignedInUser(credentials); + + const spy = { + registerDevice: { count: 0, args: [] }, + updateDevice: { count: 0, args: [] }, + getDeviceList: { count: 0, args: [] } + }; + const client = fxa.internal.fxAccountsClient; + client.registerDevice = function () { + spy.registerDevice.count += 1; + spy.registerDevice.args.push(arguments); + return Promise.resolve({}); + }; + client.updateDevice = function () { + spy.updateDevice.count += 1; + spy.updateDevice.args.push(arguments); + return Promise.resolve({ + id: credentials.deviceId, + name: deviceName + }); + }; + client.getDeviceList = function () { + spy.getDeviceList.count += 1; + spy.getDeviceList.args.push(arguments); + return Promise.resolve([]); + }; + const result = yield fxa.updateDeviceRegistration(); + + do_check_eq(result, credentials.deviceId); + do_check_eq(spy.registerDevice.count, 0); + do_check_eq(spy.getDeviceList.count, 0); + do_check_eq(spy.updateDevice.count, 1); + do_check_eq(spy.updateDevice.args[0].length, 4); + do_check_eq(spy.updateDevice.args[0][0], credentials.sessionToken); + do_check_eq(spy.updateDevice.args[0][1], credentials.deviceId); + do_check_eq(spy.updateDevice.args[0][2], deviceName); + do_check_eq(spy.updateDevice.args[0][3].pushCallback, "http://mochi.test:8888"); + do_check_eq(spy.updateDevice.args[0][3].pushPublicKey, BOGUS_PUBLICKEY); + do_check_eq(spy.updateDevice.args[0][3].pushAuthKey, BOGUS_AUTHKEY); + + const state = fxa.internal.currentAccountState; + const data = yield state.getUserAccountData(); + + do_check_eq(data.deviceId, credentials.deviceId); + do_check_eq(data.deviceRegistrationVersion, DEVICE_REGISTRATION_VERSION); +}); + +add_task(function* test_updateDeviceRegistration_with_unknown_device_error() { + const deviceName = "foo"; + const deviceType = "bar"; + + const credentials = getTestUser("baz"); + const fxa = new MockFxAccounts({ name: deviceName }); + yield fxa.internal.setSignedInUser(credentials); + + const spy = { + registerDevice: { count: 0, args: [] }, + updateDevice: { count: 0, args: [] }, + getDeviceList: { count: 0, args: [] } + }; + const client = fxa.internal.fxAccountsClient; + client.registerDevice = function () { + spy.registerDevice.count += 1; + spy.registerDevice.args.push(arguments); + return Promise.resolve({ + id: "a different newly-generated device id", + createdAt: Date.now(), + name: deviceName, + type: deviceType + }); + }; + client.updateDevice = function () { + spy.updateDevice.count += 1; + spy.updateDevice.args.push(arguments); + return Promise.reject({ + code: 400, + errno: ERRNO_UNKNOWN_DEVICE + }); + }; + client.getDeviceList = function () { + spy.getDeviceList.count += 1; + spy.getDeviceList.args.push(arguments); + return Promise.resolve([]); + }; + + const result = yield fxa.updateDeviceRegistration(); + + do_check_null(result); + do_check_eq(spy.getDeviceList.count, 0); + do_check_eq(spy.registerDevice.count, 0); + do_check_eq(spy.updateDevice.count, 1); + do_check_eq(spy.updateDevice.args[0].length, 4); + do_check_eq(spy.updateDevice.args[0][0], credentials.sessionToken); + do_check_eq(spy.updateDevice.args[0][1], credentials.deviceId); + do_check_eq(spy.updateDevice.args[0][2], deviceName); + do_check_eq(spy.updateDevice.args[0][3].pushCallback, "http://mochi.test:8888"); + do_check_eq(spy.updateDevice.args[0][3].pushPublicKey, BOGUS_PUBLICKEY); + do_check_eq(spy.updateDevice.args[0][3].pushAuthKey, BOGUS_AUTHKEY); + + + const state = fxa.internal.currentAccountState; + const data = yield state.getUserAccountData(); + + do_check_null(data.deviceId); + do_check_eq(data.deviceRegistrationVersion, DEVICE_REGISTRATION_VERSION); +}); + +add_task(function* test_updateDeviceRegistration_with_device_session_conflict_error() { + const deviceName = "foo"; + const deviceType = "bar"; + + const credentials = getTestUser("baz"); + const fxa = new MockFxAccounts({ name: deviceName }); + yield fxa.internal.setSignedInUser(credentials); + + const spy = { + registerDevice: { count: 0, args: [] }, + updateDevice: { count: 0, args: [], times: [] }, + getDeviceList: { count: 0, args: [] } + }; + const client = fxa.internal.fxAccountsClient; + client.registerDevice = function () { + spy.registerDevice.count += 1; + spy.registerDevice.args.push(arguments); + return Promise.resolve({}); + }; + client.updateDevice = function () { + spy.updateDevice.count += 1; + spy.updateDevice.args.push(arguments); + spy.updateDevice.time = Date.now(); + if (spy.updateDevice.count === 1) { + return Promise.reject({ + code: 400, + errno: ERRNO_DEVICE_SESSION_CONFLICT + }); + } + return Promise.resolve({ + id: credentials.deviceId, + name: deviceName + }); + }; + client.getDeviceList = function () { + spy.getDeviceList.count += 1; + spy.getDeviceList.args.push(arguments); + spy.getDeviceList.time = Date.now(); + return Promise.resolve([ + { id: "ignore", name: "ignore", type: "ignore", isCurrentDevice: false }, + { id: credentials.deviceId, name: deviceName, type: deviceType, isCurrentDevice: true } + ]); + }; + + const result = yield fxa.updateDeviceRegistration(); + + do_check_eq(result, credentials.deviceId); + do_check_eq(spy.registerDevice.count, 0); + do_check_eq(spy.updateDevice.count, 1); + do_check_eq(spy.updateDevice.args[0].length, 4); + do_check_eq(spy.updateDevice.args[0][0], credentials.sessionToken); + do_check_eq(spy.updateDevice.args[0][1], credentials.deviceId); + do_check_eq(spy.updateDevice.args[0][2], deviceName); + do_check_eq(spy.updateDevice.args[0][3].pushCallback, "http://mochi.test:8888"); + do_check_eq(spy.updateDevice.args[0][3].pushPublicKey, BOGUS_PUBLICKEY); + do_check_eq(spy.updateDevice.args[0][3].pushAuthKey, BOGUS_AUTHKEY); + do_check_eq(spy.getDeviceList.count, 1); + do_check_eq(spy.getDeviceList.args[0].length, 1); + do_check_eq(spy.getDeviceList.args[0][0], credentials.sessionToken); + do_check_true(spy.getDeviceList.time >= spy.updateDevice.time); + + const state = fxa.internal.currentAccountState; + const data = yield state.getUserAccountData(); + + do_check_eq(data.deviceId, credentials.deviceId); + do_check_eq(data.deviceRegistrationVersion, null); +}); + +add_task(function* test_updateDeviceRegistration_with_unrecoverable_error() { + const deviceName = "foo"; + const deviceType = "bar"; + + const credentials = getTestUser("baz"); + delete credentials.deviceId; + const fxa = new MockFxAccounts({ name: deviceName }); + yield fxa.internal.setSignedInUser(credentials); + + const spy = { + registerDevice: { count: 0, args: [] }, + updateDevice: { count: 0, args: [] }, + getDeviceList: { count: 0, args: [] } + }; + const client = fxa.internal.fxAccountsClient; + client.registerDevice = function () { + spy.registerDevice.count += 1; + spy.registerDevice.args.push(arguments); + return Promise.reject({ + code: 400, + errno: ERRNO_TOO_MANY_CLIENT_REQUESTS + }); + }; + client.updateDevice = function () { + spy.updateDevice.count += 1; + spy.updateDevice.args.push(arguments); + return Promise.resolve({}); + }; + client.getDeviceList = function () { + spy.getDeviceList.count += 1; + spy.getDeviceList.args.push(arguments); + return Promise.resolve([]); + }; + + const result = yield fxa.updateDeviceRegistration(); + + do_check_null(result); + do_check_eq(spy.getDeviceList.count, 0); + do_check_eq(spy.updateDevice.count, 0); + do_check_eq(spy.registerDevice.count, 1); + do_check_eq(spy.registerDevice.args[0].length, 4); + + const state = fxa.internal.currentAccountState; + const data = yield state.getUserAccountData(); + + do_check_null(data.deviceId); +}); + +add_task(function* test_getDeviceId_with_no_device_id_invokes_device_registration() { + const credentials = getTestUser("foo"); + credentials.verified = true; + delete credentials.deviceId; + const fxa = new MockFxAccounts(); + yield fxa.internal.setSignedInUser(credentials); + + const spy = { count: 0, args: [] }; + fxa.internal.currentAccountState.getUserAccountData = + () => Promise.resolve({ email: credentials.email, + deviceRegistrationVersion: DEVICE_REGISTRATION_VERSION }); + fxa.internal._registerOrUpdateDevice = function () { + spy.count += 1; + spy.args.push(arguments); + return Promise.resolve("bar"); + }; + + const result = yield fxa.internal.getDeviceId(); + + do_check_eq(spy.count, 1); + do_check_eq(spy.args[0].length, 1); + do_check_eq(spy.args[0][0].email, credentials.email); + do_check_null(spy.args[0][0].deviceId); + do_check_eq(result, "bar"); +}); + +add_task(function* test_getDeviceId_with_registration_version_outdated_invokes_device_registration() { + const credentials = getTestUser("foo"); + credentials.verified = true; + const fxa = new MockFxAccounts(); + yield fxa.internal.setSignedInUser(credentials); + + const spy = { count: 0, args: [] }; + fxa.internal.currentAccountState.getUserAccountData = + () => Promise.resolve({ deviceId: credentials.deviceId, deviceRegistrationVersion: 0 }); + fxa.internal._registerOrUpdateDevice = function () { + spy.count += 1; + spy.args.push(arguments); + return Promise.resolve("wibble"); + }; + + const result = yield fxa.internal.getDeviceId(); + + do_check_eq(spy.count, 1); + do_check_eq(spy.args[0].length, 1); + do_check_eq(spy.args[0][0].deviceId, credentials.deviceId); + do_check_eq(result, "wibble"); +}); + +add_task(function* test_getDeviceId_with_device_id_and_uptodate_registration_version_doesnt_invoke_device_registration() { + const credentials = getTestUser("foo"); + credentials.verified = true; + const fxa = new MockFxAccounts(); + yield fxa.internal.setSignedInUser(credentials); + + const spy = { count: 0 }; + fxa.internal.currentAccountState.getUserAccountData = + () => Promise.resolve({ deviceId: credentials.deviceId, deviceRegistrationVersion: DEVICE_REGISTRATION_VERSION }); + fxa.internal._registerOrUpdateDevice = function () { + spy.count += 1; + return Promise.resolve("bar"); + }; + + const result = yield fxa.internal.getDeviceId(); + + do_check_eq(spy.count, 0); + do_check_eq(result, "foo's device id"); +}); + +add_task(function* test_getDeviceId_with_device_id_and_with_no_registration_version_invokes_device_registration() { + const credentials = getTestUser("foo"); + credentials.verified = true; + const fxa = new MockFxAccounts(); + yield fxa.internal.setSignedInUser(credentials); + + const spy = { count: 0, args: [] }; + fxa.internal.currentAccountState.getUserAccountData = + () => Promise.resolve({ deviceId: credentials.deviceId }); + fxa.internal._registerOrUpdateDevice = function () { + spy.count += 1; + spy.args.push(arguments); + return Promise.resolve("wibble"); + }; + + const result = yield fxa.internal.getDeviceId(); + + do_check_eq(spy.count, 1); + do_check_eq(spy.args[0].length, 1); + do_check_eq(spy.args[0][0].deviceId, credentials.deviceId); + do_check_eq(result, "wibble"); +}); + +function expandHex(two_hex) { + // Return a 64-character hex string, encoding 32 identical bytes. + let eight_hex = two_hex + two_hex + two_hex + two_hex; + let thirtytwo_hex = eight_hex + eight_hex + eight_hex + eight_hex; + return thirtytwo_hex + thirtytwo_hex; +}; + +function expandBytes(two_hex) { + return CommonUtils.hexToBytes(expandHex(two_hex)); +}; + +function getTestUser(name) { + return { + email: name + "@example.com", + uid: "1ad7f502-4cc7-4ec1-a209-071fd2fae348", + deviceId: name + "'s device id", + sessionToken: name + "'s session token", + keyFetchToken: name + "'s keyfetch token", + unwrapBKey: expandHex("44"), + verified: false + }; +} + diff --git a/services/fxaccounts/tests/xpcshell/test_client.js b/services/fxaccounts/tests/xpcshell/test_client.js new file mode 100644 index 000000000..83f42bdf5 --- /dev/null +++ b/services/fxaccounts/tests/xpcshell/test_client.js @@ -0,0 +1,917 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +Cu.import("resource://gre/modules/FxAccountsClient.jsm"); +Cu.import("resource://gre/modules/Promise.jsm"); +Cu.import("resource://services-common/utils.js"); +Cu.import("resource://services-common/hawkrequest.js"); +Cu.import("resource://services-crypto/utils.js"); + +const FAKE_SESSION_TOKEN = "a0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebf"; + +function run_test() { + run_next_test(); +} + +// https://wiki.mozilla.org/Identity/AttachedServices/KeyServerProtocol#.2Faccount.2Fkeys +var ACCOUNT_KEYS = { + keyFetch: h("8081828384858687 88898a8b8c8d8e8f"+ + "9091929394959697 98999a9b9c9d9e9f"), + + response: h("ee5c58845c7c9412 b11bbd20920c2fdd"+ + "d83c33c9cd2c2de2 d66b222613364636"+ + "c2c0f8cfbb7c6304 72c0bd88451342c6"+ + "c05b14ce342c5ad4 6ad89e84464c993c"+ + "3927d30230157d08 17a077eef4b20d97"+ + "6f7a97363faf3f06 4c003ada7d01aa70"), + + kA: h("2021222324252627 28292a2b2c2d2e2f"+ + "3031323334353637 38393a3b3c3d3e3f"), + + wrapKB: h("4041424344454647 48494a4b4c4d4e4f"+ + "5051525354555657 58595a5b5c5d5e5f"), +}; + +function deferredStop(server) { + let deferred = Promise.defer(); + server.stop(deferred.resolve); + return deferred.promise; +} + +add_task(function* test_authenticated_get_request() { + let message = "{\"msg\": \"Great Success!\"}"; + let credentials = { + id: "eyJleHBpcmVzIjogMTM2NTAxMDg5OC4x", + key: "qTZf4ZFpAMpMoeSsX3zVRjiqmNs=", + algorithm: "sha256" + }; + let method = "GET"; + + let server = httpd_setup({"/foo": function(request, response) { + do_check_true(request.hasHeader("Authorization")); + + response.setStatusLine(request.httpVersion, 200, "OK"); + response.bodyOutputStream.write(message, message.length); + } + }); + + let client = new FxAccountsClient(server.baseURI); + + let result = yield client._request("/foo", method, credentials); + do_check_eq("Great Success!", result.msg); + + yield deferredStop(server); +}); + +add_task(function* test_authenticated_post_request() { + let credentials = { + id: "eyJleHBpcmVzIjogMTM2NTAxMDg5OC4x", + key: "qTZf4ZFpAMpMoeSsX3zVRjiqmNs=", + algorithm: "sha256" + }; + let method = "POST"; + + let server = httpd_setup({"/foo": function(request, response) { + do_check_true(request.hasHeader("Authorization")); + + response.setStatusLine(request.httpVersion, 200, "OK"); + response.setHeader("Content-Type", "application/json"); + response.bodyOutputStream.writeFrom(request.bodyInputStream, request.bodyInputStream.available()); + } + }); + + let client = new FxAccountsClient(server.baseURI); + + let result = yield client._request("/foo", method, credentials, {foo: "bar"}); + do_check_eq("bar", result.foo); + + yield deferredStop(server); +}); + +add_task(function* test_500_error() { + let message = "<h1>Ooops!</h1>"; + let method = "GET"; + + let server = httpd_setup({"/foo": function(request, response) { + response.setStatusLine(request.httpVersion, 500, "Internal Server Error"); + response.bodyOutputStream.write(message, message.length); + } + }); + + let client = new FxAccountsClient(server.baseURI); + + try { + yield client._request("/foo", method); + do_throw("Expected to catch an exception"); + } catch (e) { + do_check_eq(500, e.code); + do_check_eq("Internal Server Error", e.message); + } + + yield deferredStop(server); +}); + +add_task(function* test_backoffError() { + let method = "GET"; + let server = httpd_setup({ + "/retryDelay": function(request, response) { + response.setHeader("Retry-After", "30"); + response.setStatusLine(request.httpVersion, 429, "Client has sent too many requests"); + let message = "<h1>Ooops!</h1>"; + response.bodyOutputStream.write(message, message.length); + }, + "/duringDelayIShouldNotBeCalled": function(request, response) { + response.setStatusLine(request.httpVersion, 200, "OK"); + let jsonMessage = "{\"working\": \"yes\"}"; + response.bodyOutputStream.write(jsonMessage, jsonMessage.length); + }, + }); + + let client = new FxAccountsClient(server.baseURI); + + // Retry-After header sets client.backoffError + do_check_eq(client.backoffError, null); + try { + yield client._request("/retryDelay", method); + } catch (e) { + do_check_eq(429, e.code); + do_check_eq(30, e.retryAfter); + do_check_neq(typeof(client.fxaBackoffTimer), "undefined"); + do_check_neq(client.backoffError, null); + } + // While delay is in effect, client short-circuits any requests + // and re-rejects with previous error. + try { + yield client._request("/duringDelayIShouldNotBeCalled", method); + throw new Error("I should not be reached"); + } catch (e) { + do_check_eq(e.retryAfter, 30); + do_check_eq(e.message, "Client has sent too many requests"); + do_check_neq(client.backoffError, null); + } + // Once timer fires, client nulls error out and HTTP calls work again. + client._clearBackoff(); + let result = yield client._request("/duringDelayIShouldNotBeCalled", method); + do_check_eq(client.backoffError, null); + do_check_eq(result.working, "yes"); + + yield deferredStop(server); +}); + +add_task(function* test_signUp() { + let creationMessage_noKey = JSON.stringify({ + uid: "uid", + sessionToken: "sessionToken" + }); + let creationMessage_withKey = JSON.stringify({ + uid: "uid", + sessionToken: "sessionToken", + keyFetchToken: "keyFetchToken" + }); + let errorMessage = JSON.stringify({code: 400, errno: 101, error: "account exists"}); + let created = false; + + // Note these strings must be unicode and not already utf-8 encoded. + let unicodeUsername = "andr\xe9@example.org"; // 'andré@example.org' + let unicodePassword = "p\xe4ssw\xf6rd"; // 'pässwörd' + let server = httpd_setup({ + "/account/create": function(request, response) { + let body = CommonUtils.readBytesFromInputStream(request.bodyInputStream); + body = CommonUtils.decodeUTF8(body); + let jsonBody = JSON.parse(body); + + // https://github.com/mozilla/fxa-auth-server/wiki/onepw-protocol#wiki-test-vectors + + if (created) { + // Error trying to create same account a second time + response.setStatusLine(request.httpVersion, 400, "Bad request"); + response.bodyOutputStream.write(errorMessage, errorMessage.length); + return; + } + + if (jsonBody.email == unicodeUsername) { + do_check_eq("", request._queryString); + do_check_eq(jsonBody.authPW, "247b675ffb4c46310bc87e26d712153abe5e1c90ef00a4784594f97ef54f2375"); + + response.setStatusLine(request.httpVersion, 200, "OK"); + response.bodyOutputStream.write(creationMessage_noKey, + creationMessage_noKey.length); + return; + } + + if (jsonBody.email == "you@example.org") { + do_check_eq("keys=true", request._queryString); + do_check_eq(jsonBody.authPW, "e5c1cdfdaa5fcee06142db865b212cc8ba8abee2a27d639d42c139f006cdb930"); + created = true; + + response.setStatusLine(request.httpVersion, 200, "OK"); + response.bodyOutputStream.write(creationMessage_withKey, + creationMessage_withKey.length); + return; + } + // just throwing here doesn't make any log noise, so have an assertion + // fail instead. + do_check_true(false, "unexpected email: " + jsonBody.email); + }, + }); + + // Try to create an account without retrieving optional keys. + let client = new FxAccountsClient(server.baseURI); + let result = yield client.signUp(unicodeUsername, unicodePassword); + do_check_eq("uid", result.uid); + do_check_eq("sessionToken", result.sessionToken); + do_check_eq(undefined, result.keyFetchToken); + do_check_eq(result.unwrapBKey, + "de6a2648b78284fcb9ffa81ba95803309cfba7af583c01a8a1a63e567234dd28"); + + // Try to create an account retrieving optional keys. + result = yield client.signUp('you@example.org', 'pässwörd', true); + do_check_eq("uid", result.uid); + do_check_eq("sessionToken", result.sessionToken); + do_check_eq("keyFetchToken", result.keyFetchToken); + do_check_eq(result.unwrapBKey, + "f589225b609e56075d76eb74f771ff9ab18a4dc0e901e131ba8f984c7fb0ca8c"); + + // Try to create an existing account. Triggers error path. + try { + result = yield client.signUp(unicodeUsername, unicodePassword); + do_throw("Expected to catch an exception"); + } catch(expectedError) { + do_check_eq(101, expectedError.errno); + } + + yield deferredStop(server); +}); + +add_task(function* test_signIn() { + let sessionMessage_noKey = JSON.stringify({ + sessionToken: FAKE_SESSION_TOKEN + }); + let sessionMessage_withKey = JSON.stringify({ + sessionToken: FAKE_SESSION_TOKEN, + keyFetchToken: "keyFetchToken" + }); + let errorMessage_notExistent = JSON.stringify({ + code: 400, + errno: 102, + error: "doesn't exist" + }); + let errorMessage_wrongCap = JSON.stringify({ + code: 400, + errno: 120, + error: "Incorrect email case", + email: "you@example.com" + }); + + // Note this strings must be unicode and not already utf-8 encoded. + let unicodeUsername = "m\xe9@example.com" // 'mé@example.com' + let server = httpd_setup({ + "/account/login": function(request, response) { + let body = CommonUtils.readBytesFromInputStream(request.bodyInputStream); + body = CommonUtils.decodeUTF8(body); + let jsonBody = JSON.parse(body); + + if (jsonBody.email == unicodeUsername) { + do_check_eq("", request._queryString); + do_check_eq(jsonBody.authPW, "08b9d111196b8408e8ed92439da49206c8ecfbf343df0ae1ecefcd1e0174a8b6"); + response.setStatusLine(request.httpVersion, 200, "OK"); + response.bodyOutputStream.write(sessionMessage_noKey, + sessionMessage_noKey.length); + return; + } + else if (jsonBody.email == "you@example.com") { + do_check_eq("keys=true", request._queryString); + do_check_eq(jsonBody.authPW, "93d20ec50304d496d0707ec20d7e8c89459b6396ec5dd5b9e92809c5e42856c7"); + response.setStatusLine(request.httpVersion, 200, "OK"); + response.bodyOutputStream.write(sessionMessage_withKey, + sessionMessage_withKey.length); + return; + } + else if (jsonBody.email == "You@example.com") { + // Error trying to sign in with a wrong capitalization + response.setStatusLine(request.httpVersion, 400, "Bad request"); + response.bodyOutputStream.write(errorMessage_wrongCap, + errorMessage_wrongCap.length); + return; + } + else { + // Error trying to sign in to nonexistent account + response.setStatusLine(request.httpVersion, 400, "Bad request"); + response.bodyOutputStream.write(errorMessage_notExistent, + errorMessage_notExistent.length); + return; + } + }, + }); + + // Login without retrieving optional keys + let client = new FxAccountsClient(server.baseURI); + let result = yield client.signIn(unicodeUsername, 'bigsecret'); + do_check_eq(FAKE_SESSION_TOKEN, result.sessionToken); + do_check_eq(result.unwrapBKey, + "c076ec3f4af123a615157154c6e1d0d6293e514fd7b0221e32d50517ecf002b8"); + do_check_eq(undefined, result.keyFetchToken); + + // Login with retrieving optional keys + result = yield client.signIn('you@example.com', 'bigsecret', true); + do_check_eq(FAKE_SESSION_TOKEN, result.sessionToken); + do_check_eq(result.unwrapBKey, + "65970516211062112e955d6420bebe020269d6b6a91ebd288319fc8d0cb49624"); + do_check_eq("keyFetchToken", result.keyFetchToken); + + // Retry due to wrong email capitalization + result = yield client.signIn('You@example.com', 'bigsecret', true); + do_check_eq(FAKE_SESSION_TOKEN, result.sessionToken); + do_check_eq(result.unwrapBKey, + "65970516211062112e955d6420bebe020269d6b6a91ebd288319fc8d0cb49624"); + do_check_eq("keyFetchToken", result.keyFetchToken); + + // Trigger error path + try { + result = yield client.signIn("yøü@bad.example.org", "nofear"); + do_throw("Expected to catch an exception"); + } catch (expectedError) { + do_check_eq(102, expectedError.errno); + } + + yield deferredStop(server); +}); + +add_task(function* test_signOut() { + let signoutMessage = JSON.stringify({}); + let errorMessage = JSON.stringify({code: 400, errno: 102, error: "doesn't exist"}); + let signedOut = false; + + let server = httpd_setup({ + "/session/destroy": function(request, response) { + if (!signedOut) { + signedOut = true; + do_check_true(request.hasHeader("Authorization")); + response.setStatusLine(request.httpVersion, 200, "OK"); + response.bodyOutputStream.write(signoutMessage, signoutMessage.length); + return; + } + + // Error trying to sign out of nonexistent account + response.setStatusLine(request.httpVersion, 400, "Bad request"); + response.bodyOutputStream.write(errorMessage, errorMessage.length); + return; + }, + }); + + let client = new FxAccountsClient(server.baseURI); + let result = yield client.signOut("FakeSession"); + do_check_eq(typeof result, "object"); + + // Trigger error path + try { + result = yield client.signOut("FakeSession"); + do_throw("Expected to catch an exception"); + } catch(expectedError) { + do_check_eq(102, expectedError.errno); + } + + yield deferredStop(server); +}); + +add_task(function* test_recoveryEmailStatus() { + let emailStatus = JSON.stringify({verified: true}); + let errorMessage = JSON.stringify({code: 400, errno: 102, error: "doesn't exist"}); + let tries = 0; + + let server = httpd_setup({ + "/recovery_email/status": function(request, response) { + do_check_true(request.hasHeader("Authorization")); + do_check_eq("", request._queryString); + + if (tries === 0) { + tries += 1; + response.setStatusLine(request.httpVersion, 200, "OK"); + response.bodyOutputStream.write(emailStatus, emailStatus.length); + return; + } + + // Second call gets an error trying to query a nonexistent account + response.setStatusLine(request.httpVersion, 400, "Bad request"); + response.bodyOutputStream.write(errorMessage, errorMessage.length); + return; + }, + }); + + let client = new FxAccountsClient(server.baseURI); + let result = yield client.recoveryEmailStatus(FAKE_SESSION_TOKEN); + do_check_eq(result.verified, true); + + // Trigger error path + try { + result = yield client.recoveryEmailStatus("some bogus session"); + do_throw("Expected to catch an exception"); + } catch(expectedError) { + do_check_eq(102, expectedError.errno); + } + + yield deferredStop(server); +}); + +add_task(function* test_recoveryEmailStatusWithReason() { + let emailStatus = JSON.stringify({verified: true}); + let server = httpd_setup({ + "/recovery_email/status": function(request, response) { + do_check_true(request.hasHeader("Authorization")); + // if there is a query string then it will have a reason + do_check_eq("reason=push", request._queryString); + + response.setStatusLine(request.httpVersion, 200, "OK"); + response.bodyOutputStream.write(emailStatus, emailStatus.length); + return; + }, + }); + + let client = new FxAccountsClient(server.baseURI); + let result = yield client.recoveryEmailStatus(FAKE_SESSION_TOKEN, { + reason: "push", + }); + do_check_eq(result.verified, true); + yield deferredStop(server); +}); + +add_task(function* test_resendVerificationEmail() { + let emptyMessage = "{}"; + let errorMessage = JSON.stringify({code: 400, errno: 102, error: "doesn't exist"}); + let tries = 0; + + let server = httpd_setup({ + "/recovery_email/resend_code": function(request, response) { + do_check_true(request.hasHeader("Authorization")); + if (tries === 0) { + tries += 1; + response.setStatusLine(request.httpVersion, 200, "OK"); + response.bodyOutputStream.write(emptyMessage, emptyMessage.length); + return; + } + + // Second call gets an error trying to query a nonexistent account + response.setStatusLine(request.httpVersion, 400, "Bad request"); + response.bodyOutputStream.write(errorMessage, errorMessage.length); + return; + }, + }); + + let client = new FxAccountsClient(server.baseURI); + let result = yield client.resendVerificationEmail(FAKE_SESSION_TOKEN); + do_check_eq(JSON.stringify(result), emptyMessage); + + // Trigger error path + try { + result = yield client.resendVerificationEmail("some bogus session"); + do_throw("Expected to catch an exception"); + } catch(expectedError) { + do_check_eq(102, expectedError.errno); + } + + yield deferredStop(server); +}); + +add_task(function* test_accountKeys() { + // Four calls to accountKeys(). The first one should work correctly, and we + // should get a valid bundle back, in exchange for our keyFetch token, from + // which we correctly derive kA and wrapKB. The subsequent three calls + // should all trigger separate error paths. + let responseMessage = JSON.stringify({bundle: ACCOUNT_KEYS.response}); + let errorMessage = JSON.stringify({code: 400, errno: 102, error: "doesn't exist"}); + let emptyMessage = "{}"; + let attempt = 0; + + let server = httpd_setup({ + "/account/keys": function(request, response) { + do_check_true(request.hasHeader("Authorization")); + attempt += 1; + + switch(attempt) { + case 1: + // First time succeeds + response.setStatusLine(request.httpVersion, 200, "OK"); + response.bodyOutputStream.write(responseMessage, responseMessage.length); + break; + + case 2: + // Second time, return no bundle to trigger client error + response.setStatusLine(request.httpVersion, 200, "OK"); + response.bodyOutputStream.write(emptyMessage, emptyMessage.length); + break; + + case 3: + // Return gibberish to trigger client MAC error + // Tweak a byte + let garbageResponse = JSON.stringify({ + bundle: ACCOUNT_KEYS.response.slice(0, -1) + "1" + }); + response.setStatusLine(request.httpVersion, 200, "OK"); + response.bodyOutputStream.write(garbageResponse, garbageResponse.length); + break; + + case 4: + // Trigger error for nonexistent account + response.setStatusLine(request.httpVersion, 400, "Bad request"); + response.bodyOutputStream.write(errorMessage, errorMessage.length); + break; + } + }, + }); + + let client = new FxAccountsClient(server.baseURI); + + // First try, all should be good + let result = yield client.accountKeys(ACCOUNT_KEYS.keyFetch); + do_check_eq(CommonUtils.hexToBytes(ACCOUNT_KEYS.kA), result.kA); + do_check_eq(CommonUtils.hexToBytes(ACCOUNT_KEYS.wrapKB), result.wrapKB); + + // Second try, empty bundle should trigger error + try { + result = yield client.accountKeys(ACCOUNT_KEYS.keyFetch); + do_throw("Expected to catch an exception"); + } catch(expectedError) { + do_check_eq(expectedError.message, "failed to retrieve keys"); + } + + // Third try, bad bundle results in MAC error + try { + result = yield client.accountKeys(ACCOUNT_KEYS.keyFetch); + do_throw("Expected to catch an exception"); + } catch(expectedError) { + do_check_eq(expectedError.message, "error unbundling encryption keys"); + } + + // Fourth try, pretend account doesn't exist + try { + result = yield client.accountKeys(ACCOUNT_KEYS.keyFetch); + do_throw("Expected to catch an exception"); + } catch(expectedError) { + do_check_eq(102, expectedError.errno); + } + + yield deferredStop(server); +}); + +add_task(function* test_signCertificate() { + let certSignMessage = JSON.stringify({cert: {bar: "baz"}}); + let errorMessage = JSON.stringify({code: 400, errno: 102, error: "doesn't exist"}); + let tries = 0; + + let server = httpd_setup({ + "/certificate/sign": function(request, response) { + do_check_true(request.hasHeader("Authorization")); + + if (tries === 0) { + tries += 1; + let body = CommonUtils.readBytesFromInputStream(request.bodyInputStream); + let jsonBody = JSON.parse(body); + do_check_eq(JSON.parse(jsonBody.publicKey).foo, "bar"); + do_check_eq(jsonBody.duration, 600); + response.setStatusLine(request.httpVersion, 200, "OK"); + response.bodyOutputStream.write(certSignMessage, certSignMessage.length); + return; + } + + // Second attempt, trigger error + response.setStatusLine(request.httpVersion, 400, "Bad request"); + response.bodyOutputStream.write(errorMessage, errorMessage.length); + return; + }, + }); + + let client = new FxAccountsClient(server.baseURI); + let result = yield client.signCertificate(FAKE_SESSION_TOKEN, JSON.stringify({foo: "bar"}), 600); + do_check_eq("baz", result.bar); + + // Account doesn't exist + try { + result = yield client.signCertificate("bogus", JSON.stringify({foo: "bar"}), 600); + do_throw("Expected to catch an exception"); + } catch(expectedError) { + do_check_eq(102, expectedError.errno); + } + + yield deferredStop(server); +}); + +add_task(function* test_accountExists() { + let sessionMessage = JSON.stringify({sessionToken: FAKE_SESSION_TOKEN}); + let existsMessage = JSON.stringify({error: "wrong password", code: 400, errno: 103}); + let doesntExistMessage = JSON.stringify({error: "no such account", code: 400, errno: 102}); + let emptyMessage = "{}"; + + let server = httpd_setup({ + "/account/login": function(request, response) { + let body = CommonUtils.readBytesFromInputStream(request.bodyInputStream); + let jsonBody = JSON.parse(body); + + switch (jsonBody.email) { + // We'll test that these users' accounts exist + case "i.exist@example.com": + case "i.also.exist@example.com": + response.setStatusLine(request.httpVersion, 400, "Bad request"); + response.bodyOutputStream.write(existsMessage, existsMessage.length); + break; + + // This user's account doesn't exist + case "i.dont.exist@example.com": + response.setStatusLine(request.httpVersion, 400, "Bad request"); + response.bodyOutputStream.write(doesntExistMessage, doesntExistMessage.length); + break; + + // This user throws an unexpected response + // This will reject the client signIn promise + case "i.break.things@example.com": + response.setStatusLine(request.httpVersion, 500, "Alas"); + response.bodyOutputStream.write(emptyMessage, emptyMessage.length); + break; + + default: + throw new Error("Unexpected login from " + jsonBody.email); + break; + } + }, + }); + + let client = new FxAccountsClient(server.baseURI); + let result; + + result = yield client.accountExists("i.exist@example.com"); + do_check_true(result); + + result = yield client.accountExists("i.also.exist@example.com"); + do_check_true(result); + + result = yield client.accountExists("i.dont.exist@example.com"); + do_check_false(result); + + try { + result = yield client.accountExists("i.break.things@example.com"); + do_throw("Expected to catch an exception"); + } catch(unexpectedError) { + do_check_eq(unexpectedError.code, 500); + } + + yield deferredStop(server); +}); + +add_task(function* test_registerDevice() { + const DEVICE_ID = "device id"; + const DEVICE_NAME = "device name"; + const DEVICE_TYPE = "device type"; + const ERROR_NAME = "test that the client promise rejects"; + + const server = httpd_setup({ + "/account/device": function(request, response) { + const body = JSON.parse(CommonUtils.readBytesFromInputStream(request.bodyInputStream)); + + if (body.id || !body.name || !body.type || Object.keys(body).length !== 2) { + response.setStatusLine(request.httpVersion, 400, "Invalid request"); + return response.bodyOutputStream.write("{}", 2); + } + + if (body.name === ERROR_NAME) { + response.setStatusLine(request.httpVersion, 500, "Alas"); + return response.bodyOutputStream.write("{}", 2); + } + + body.id = DEVICE_ID; + body.createdAt = Date.now(); + + const responseMessage = JSON.stringify(body); + + response.setStatusLine(request.httpVersion, 200, "OK"); + response.bodyOutputStream.write(responseMessage, responseMessage.length); + }, + }); + + const client = new FxAccountsClient(server.baseURI); + const result = yield client.registerDevice(FAKE_SESSION_TOKEN, DEVICE_NAME, DEVICE_TYPE); + + do_check_true(result); + do_check_eq(Object.keys(result).length, 4); + do_check_eq(result.id, DEVICE_ID); + do_check_eq(typeof result.createdAt, 'number'); + do_check_true(result.createdAt > 0); + do_check_eq(result.name, DEVICE_NAME); + do_check_eq(result.type, DEVICE_TYPE); + + try { + yield client.registerDevice(FAKE_SESSION_TOKEN, ERROR_NAME, DEVICE_TYPE); + do_throw("Expected to catch an exception"); + } catch(unexpectedError) { + do_check_eq(unexpectedError.code, 500); + } + + yield deferredStop(server); +}); + +add_task(function* test_updateDevice() { + const DEVICE_ID = "some other id"; + const DEVICE_NAME = "some other name"; + const ERROR_ID = "test that the client promise rejects"; + + const server = httpd_setup({ + "/account/device": function(request, response) { + const body = JSON.parse(CommonUtils.readBytesFromInputStream(request.bodyInputStream)); + + if (!body.id || !body.name || body.type || Object.keys(body).length !== 2) { + response.setStatusLine(request.httpVersion, 400, "Invalid request"); + return response.bodyOutputStream.write("{}", 2); + } + + if (body.id === ERROR_ID) { + response.setStatusLine(request.httpVersion, 500, "Alas"); + return response.bodyOutputStream.write("{}", 2); + } + + const responseMessage = JSON.stringify(body); + + response.setStatusLine(request.httpVersion, 200, "OK"); + response.bodyOutputStream.write(responseMessage, responseMessage.length); + }, + }); + + const client = new FxAccountsClient(server.baseURI); + const result = yield client.updateDevice(FAKE_SESSION_TOKEN, DEVICE_ID, DEVICE_NAME); + + do_check_true(result); + do_check_eq(Object.keys(result).length, 2); + do_check_eq(result.id, DEVICE_ID); + do_check_eq(result.name, DEVICE_NAME); + + try { + yield client.updateDevice(FAKE_SESSION_TOKEN, ERROR_ID, DEVICE_NAME); + do_throw("Expected to catch an exception"); + } catch(unexpectedError) { + do_check_eq(unexpectedError.code, 500); + } + + yield deferredStop(server); +}); + +add_task(function* test_signOutAndDestroyDevice() { + const DEVICE_ID = "device id"; + const ERROR_ID = "test that the client promise rejects"; + + const server = httpd_setup({ + "/account/device/destroy": function(request, response) { + const body = JSON.parse(CommonUtils.readBytesFromInputStream(request.bodyInputStream)); + + if (!body.id) { + response.setStatusLine(request.httpVersion, 400, "Invalid request"); + return response.bodyOutputStream.write(emptyMessage, emptyMessage.length); + } + + if (body.id === ERROR_ID) { + response.setStatusLine(request.httpVersion, 500, "Alas"); + return response.bodyOutputStream.write("{}", 2); + } + + response.setStatusLine(request.httpVersion, 200, "OK"); + response.bodyOutputStream.write("{}", 2); + }, + }); + + const client = new FxAccountsClient(server.baseURI); + const result = yield client.signOutAndDestroyDevice(FAKE_SESSION_TOKEN, DEVICE_ID); + + do_check_true(result); + do_check_eq(Object.keys(result).length, 0); + + try { + yield client.signOutAndDestroyDevice(FAKE_SESSION_TOKEN, ERROR_ID); + do_throw("Expected to catch an exception"); + } catch(unexpectedError) { + do_check_eq(unexpectedError.code, 500); + } + + yield deferredStop(server); +}); + +add_task(function* test_getDeviceList() { + let canReturnDevices; + + const server = httpd_setup({ + "/account/devices": function(request, response) { + if (canReturnDevices) { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.bodyOutputStream.write("[]", 2); + } else { + response.setStatusLine(request.httpVersion, 500, "Alas"); + response.bodyOutputStream.write("{}", 2); + } + }, + }); + + const client = new FxAccountsClient(server.baseURI); + + canReturnDevices = true; + const result = yield client.getDeviceList(FAKE_SESSION_TOKEN); + do_check_true(Array.isArray(result)); + do_check_eq(result.length, 0); + + try { + canReturnDevices = false; + yield client.getDeviceList(FAKE_SESSION_TOKEN); + do_throw("Expected to catch an exception"); + } catch(unexpectedError) { + do_check_eq(unexpectedError.code, 500); + } + + yield deferredStop(server); +}); + +add_task(function* test_client_metrics() { + function writeResp(response, msg) { + if (typeof msg === "object") { + msg = JSON.stringify(msg); + } + response.bodyOutputStream.write(msg, msg.length); + } + + let server = httpd_setup( + { + "/session/destroy": function(request, response) { + response.setHeader("Content-Type", "application/json; charset=utf-8"); + response.setStatusLine(request.httpVersion, 401, "Unauthorized"); + writeResp(response, { + error: "invalid authentication timestamp", + code: 401, + errno: 111, + }); + }, + } + ); + + let client = new FxAccountsClient(server.baseURI); + + yield rejects(client.signOut(FAKE_SESSION_TOKEN, { + service: "sync", + }), function(err) { + return err.errno == 111; + }); + + yield deferredStop(server); +}); + +add_task(function* test_email_case() { + let canonicalEmail = "greta.garbo@gmail.com"; + let clientEmail = "Greta.Garbo@gmail.COM"; + let attempts = 0; + + function writeResp(response, msg) { + if (typeof msg === "object") { + msg = JSON.stringify(msg); + } + response.bodyOutputStream.write(msg, msg.length); + } + + let server = httpd_setup( + { + "/account/login": function(request, response) { + response.setHeader("Content-Type", "application/json; charset=utf-8"); + attempts += 1; + if (attempts > 2) { + response.setStatusLine(request.httpVersion, 429, "Sorry, you had your chance"); + return writeResp(response, ""); + } + + let body = CommonUtils.readBytesFromInputStream(request.bodyInputStream); + let jsonBody = JSON.parse(body); + let email = jsonBody.email; + + // If the client has the wrong case on the email, we return a 400, with + // the capitalization of the email as saved in the accounts database. + if (email == canonicalEmail) { + response.setStatusLine(request.httpVersion, 200, "Yay"); + return writeResp(response, {areWeHappy: "yes"}); + } + + response.setStatusLine(request.httpVersion, 400, "Incorrect email case"); + return writeResp(response, { + code: 400, + errno: 120, + error: "Incorrect email case", + email: canonicalEmail + }); + }, + } + ); + + let client = new FxAccountsClient(server.baseURI); + + let result = yield client.signIn(clientEmail, "123456"); + do_check_eq(result.areWeHappy, "yes"); + do_check_eq(attempts, 2); + + yield deferredStop(server); +}); + +// turn formatted test vectors into normal hex strings +function h(hexStr) { + return hexStr.replace(/\s+/g, ""); +} diff --git a/services/fxaccounts/tests/xpcshell/test_credentials.js b/services/fxaccounts/tests/xpcshell/test_credentials.js new file mode 100644 index 000000000..cbd9e4c7a --- /dev/null +++ b/services/fxaccounts/tests/xpcshell/test_credentials.js @@ -0,0 +1,110 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +Cu.import("resource://gre/modules/Credentials.jsm"); +Cu.import("resource://gre/modules/Promise.jsm"); +Cu.import("resource://services-common/utils.js"); +Cu.import("resource://services-crypto/utils.js"); + +var {hexToBytes: h2b, + hexAsString: h2s, + stringAsHex: s2h, + bytesAsHex: b2h} = CommonUtils; + +// Test vectors for the "onepw" protocol: +// https://github.com/mozilla/fxa-auth-server/wiki/onepw-protocol#wiki-test-vectors +var vectors = { + "client stretch-KDF": { + email: + h("616e6472c3a94065 78616d706c652e6f 7267"), + password: + h("70c3a4737377c3b6 7264"), + quickStretchedPW: + h("e4e8889bd8bd61ad 6de6b95c059d56e7 b50dacdaf62bd846 44af7e2add84345d"), + authPW: + h("247b675ffb4c4631 0bc87e26d712153a be5e1c90ef00a478 4594f97ef54f2375"), + authSalt: + h("00f0000000000000 0000000000000000 0000000000000000 0000000000000000"), + }, +}; + +// A simple test suite with no utf8 encoding madness. +add_task(function* test_onepw_setup_credentials() { + let email = "francine@example.org"; + let password = CommonUtils.encodeUTF8("i like pie"); + + let pbkdf2 = CryptoUtils.pbkdf2Generate; + let hkdf = CryptoUtils.hkdf; + + // quickStretch the email + let saltyEmail = Credentials.keyWordExtended("quickStretch", email); + + do_check_eq(b2h(saltyEmail), "6964656e746974792e6d6f7a696c6c612e636f6d2f7069636c2f76312f717569636b537472657463683a6672616e63696e65406578616d706c652e6f7267"); + + let pbkdf2Rounds = 1000; + let pbkdf2Len = 32; + + let quickStretchedPW = pbkdf2(password, saltyEmail, pbkdf2Rounds, pbkdf2Len, Ci.nsICryptoHMAC.SHA256, 32); + let quickStretchedActual = "6b88094c1c73bbf133223f300d101ed70837af48d9d2c1b6e7d38804b20cdde4"; + do_check_eq(b2h(quickStretchedPW), quickStretchedActual); + + // obtain hkdf info + let authKeyInfo = Credentials.keyWord('authPW'); + do_check_eq(b2h(authKeyInfo), "6964656e746974792e6d6f7a696c6c612e636f6d2f7069636c2f76312f617574685057"); + + // derive auth password + let hkdfSalt = h2b("00"); + let hkdfLen = 32; + let authPW = hkdf(quickStretchedPW, hkdfSalt, authKeyInfo, hkdfLen); + + do_check_eq(b2h(authPW), "4b8dec7f48e7852658163601ff766124c312f9392af6c3d4e1a247eb439be342"); + + // derive unwrap key + let unwrapKeyInfo = Credentials.keyWord('unwrapBkey'); + let unwrapKey = hkdf(quickStretchedPW, hkdfSalt, unwrapKeyInfo, hkdfLen); + + do_check_eq(b2h(unwrapKey), "8ff58975be391338e4ec5d7138b5ed7b65c7d1bfd1f3a4f93e05aa47d5b72be9"); +}); + +add_task(function* test_client_stretch_kdf() { + let pbkdf2 = CryptoUtils.pbkdf2Generate; + let hkdf = CryptoUtils.hkdf; + let expected = vectors["client stretch-KDF"]; + + let email = h2s(expected.email); + let password = h2s(expected.password); + + // Intermediate value from sjcl implementation in fxa-js-client + // The key thing is the c3a9 sequence in "andré" + let salt = Credentials.keyWordExtended("quickStretch", email); + do_check_eq(b2h(salt), "6964656e746974792e6d6f7a696c6c612e636f6d2f7069636c2f76312f717569636b537472657463683a616e6472c3a9406578616d706c652e6f7267"); + + let options = { + stretchedPassLength: 32, + pbkdf2Rounds: 1000, + hmacAlgorithm: Ci.nsICryptoHMAC.SHA256, + hmacLength: 32, + hkdfSalt: h2b("00"), + hkdfLength: 32, + }; + + let results = yield Credentials.setup(email, password, options); + + do_check_eq(expected.quickStretchedPW, b2h(results.quickStretchedPW), + "quickStretchedPW is wrong"); + + do_check_eq(expected.authPW, b2h(results.authPW), + "authPW is wrong"); +}); + +// End of tests +// Utility functions follow + +function run_test() { + run_next_test(); +} + +// turn formatted test vectors into normal hex strings +function h(hexStr) { + return hexStr.replace(/\s+/g, ""); +} diff --git a/services/fxaccounts/tests/xpcshell/test_loginmgr_storage.js b/services/fxaccounts/tests/xpcshell/test_loginmgr_storage.js new file mode 100644 index 000000000..64ddb1fd1 --- /dev/null +++ b/services/fxaccounts/tests/xpcshell/test_loginmgr_storage.js @@ -0,0 +1,214 @@ +/* Any copyright is dedicated to the Public Domain. + * 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. +var {LoginManagerStorage} = Cu.import("resource://gre/modules/FxAccountsStorage.jsm", {}); +var isLoggedIn = true; +LoginManagerStorage.prototype.__defineGetter__("_isLoggedIn", () => isLoggedIn); + +function setLoginMgrLoggedInState(loggedIn) { + isLoggedIn = loggedIn; +} + + +initTestLogging("Trace"); + +function run_test() { + run_next_test(); +} + +function getLoginMgrData() { + let logins = Services.logins.findLogins({}, FXA_PWDMGR_HOST, null, FXA_PWDMGR_REALM); + if (logins.length == 0) { + return null; + } + Assert.equal(logins.length, 1, "only 1 login available"); + return logins[0]; +} + +function createFxAccounts() { + return new FxAccounts({ + _getDeviceName() { + return "mock device name"; + }, + fxaPushService: { + registerPushEndpoint() { + return new Promise((resolve) => { + resolve({ + endpoint: "http://mochi.test:8888" + }); + }); + }, + } + }); +} + +add_task(function* test_simple() { + let fxa = createFxAccounts(); + + let creds = { + uid: "abcd", + email: "test@example.com", + sessionToken: "sessionToken", + kA: "the kA value", + kB: "the kB value", + verified: true + }; + yield fxa.setSignedInUser(creds); + + // This should have stored stuff in both the .json file in the profile + // dir, and the login dir. + 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"); + 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.uid, "uid 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"); + + yield fxa.signOut(/* localOnly = */ true); + Assert.strictEqual(getLoginMgrData(), null, "login mgr data deleted on logout"); +}); + +add_task(function* test_MPLocked() { + let fxa = createFxAccounts(); + + let creds = { + uid: "abcd", + email: "test@example.com", + sessionToken: "sessionToken", + kA: "the kA value", + kB: "the kB value", + verified: true + }; + + Assert.strictEqual(getLoginMgrData(), null, "no login mgr at the start"); + // tell the storage that the MP is locked. + 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"); + 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"); + + Assert.strictEqual(getLoginMgrData(), null, "login mgr data doesn't exist"); + yield fxa.signOut(/* localOnly = */ true) +}); + + +add_task(function* test_consistentWithMPEdgeCases() { + setLoginMgrLoggedInState(true); + + let fxa = createFxAccounts(); + + 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. + 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.uid); + // and that we do have the first kA in the login manager. + Assert.strictEqual(JSON.parse(login.password).accountData.kA, creds1.kA, + "stale data still in login mgr"); + + // Make a new FxA instance (otherwise the values in memory will be used) + // and we want the login manager to be unlocked. + setLoginMgrLoggedInState(true); + fxa = createFxAccounts(); + + let accountData = yield fxa.getSignedInUser(); + Assert.strictEqual(accountData.email, creds2.email); + // we should have no kA at all. + Assert.strictEqual(accountData.kA, undefined, "stale kA wasn't used"); + yield fxa.signOut(/* localOnly = */ true) +}); + +// A test for the fact we will accept either a UID or email when looking in +// the login manager. +add_task(function* test_uidMigration() { + setLoginMgrLoggedInState(true); + Assert.strictEqual(getLoginMgrData(), null, "expect no logins at the start"); + + // create the login entry using email as a key. + let contents = {kA: "kA"}; + + 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, + "foo@bar.com", // aUsername + JSON.stringify(contents), // aPassword + "", // aUsernameField + "");// aPasswordField + Services.logins.addLogin(login); + + // ensure we read it. + let storage = new LoginManagerStorage(); + let got = yield storage.get("uid", "foo@bar.com"); + Assert.deepEqual(got, contents); +}); diff --git a/services/fxaccounts/tests/xpcshell/test_oauth_client.js b/services/fxaccounts/tests/xpcshell/test_oauth_client.js new file mode 100644 index 000000000..9bcb1b1ab --- /dev/null +++ b/services/fxaccounts/tests/xpcshell/test_oauth_client.js @@ -0,0 +1,55 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +Cu.import("resource://gre/modules/FxAccountsOAuthClient.jsm"); + +function run_test() { + validationHelper(undefined, + "Error: Missing 'parameters' configuration option"); + + validationHelper({}, + "Error: Missing 'parameters' configuration option"); + + validationHelper({ parameters: {} }, + "Error: Missing 'parameters.oauth_uri' parameter"); + + validationHelper({ parameters: { + oauth_uri: "http://oauth.test/v1" + }}, + "Error: Missing 'parameters.client_id' parameter"); + + validationHelper({ parameters: { + oauth_uri: "http://oauth.test/v1", + client_id: "client_id" + }}, + "Error: Missing 'parameters.content_uri' parameter"); + + validationHelper({ parameters: { + oauth_uri: "http://oauth.test/v1", + client_id: "client_id", + content_uri: "http://content.test" + }}, + "Error: Missing 'parameters.state' parameter"); + + validationHelper({ parameters: { + oauth_uri: "http://oauth.test/v1", + client_id: "client_id", + content_uri: "http://content.test", + state: "complete", + action: "force_auth" + }}, + "Error: parameters.email is required for action 'force_auth'"); + + run_next_test(); +} + +function validationHelper(params, expected) { + try { + new FxAccountsOAuthClient(params); + } catch (e) { + return do_check_eq(e.toString(), expected); + } + throw new Error("Validation helper error"); +} diff --git a/services/fxaccounts/tests/xpcshell/test_oauth_grant_client.js b/services/fxaccounts/tests/xpcshell/test_oauth_grant_client.js new file mode 100644 index 000000000..244b79a5e --- /dev/null +++ b/services/fxaccounts/tests/xpcshell/test_oauth_grant_client.js @@ -0,0 +1,292 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +Cu.import("resource://gre/modules/FxAccountsCommon.js"); +Cu.import("resource://gre/modules/FxAccountsOAuthGrantClient.jsm"); +Cu.import("resource://gre/modules/Services.jsm"); + +const CLIENT_OPTIONS = { + serverURL: "http://127.0.0.1:9010/v1", + client_id: 'abc123' +}; + +const STATUS_SUCCESS = 200; + +/** + * Mock request responder + * @param {String} response + * Mocked raw response from the server + * @returns {Function} + */ +var mockResponse = function (response) { + return function () { + return { + setHeader: function () {}, + post: function () { + this.response = response; + this.onComplete(); + } + }; + }; +}; + +/** + * Mock request error responder + * @param {Error} error + * Error object + * @returns {Function} + */ +var mockResponseError = function (error) { + return function () { + return { + setHeader: function () {}, + post: function () { + this.onComplete(error); + } + }; + }; +}; + +add_test(function missingParams () { + let client = new FxAccountsOAuthGrantClient(CLIENT_OPTIONS); + try { + client.getTokenFromAssertion() + } catch (e) { + do_check_eq(e.message, "Missing 'assertion' parameter"); + } + + try { + client.getTokenFromAssertion("assertion") + } catch (e) { + do_check_eq(e.message, "Missing 'scope' parameter"); + } + + run_next_test(); +}); + +add_test(function successfulResponse () { + let client = new FxAccountsOAuthGrantClient(CLIENT_OPTIONS); + let response = { + success: true, + status: STATUS_SUCCESS, + body: "{\"access_token\":\"http://example.com/image.jpeg\",\"id\":\"0d5c1a89b8c54580b8e3e8adadae864a\"}", + }; + + client._Request = new mockResponse(response); + client.getTokenFromAssertion("assertion", "scope") + .then( + function (result) { + do_check_eq(result.access_token, "http://example.com/image.jpeg"); + run_next_test(); + } + ); +}); + +add_test(function successfulDestroy () { + let client = new FxAccountsOAuthGrantClient(CLIENT_OPTIONS); + let response = { + success: true, + status: STATUS_SUCCESS, + body: "{}", + }; + + client._Request = new mockResponse(response); + client.destroyToken("deadbeef").then(run_next_test); +}); + +add_test(function parseErrorResponse () { + let client = new FxAccountsOAuthGrantClient(CLIENT_OPTIONS); + let response = { + success: true, + status: STATUS_SUCCESS, + body: "unexpected", + }; + + client._Request = new mockResponse(response); + client.getTokenFromAssertion("assertion", "scope") + .then( + null, + function (e) { + do_check_eq(e.name, "FxAccountsOAuthGrantClientError"); + do_check_eq(e.code, STATUS_SUCCESS); + do_check_eq(e.errno, ERRNO_PARSE); + do_check_eq(e.error, ERROR_PARSE); + do_check_eq(e.message, "unexpected"); + run_next_test(); + } + ); +}); + +add_test(function serverErrorResponse () { + let client = new FxAccountsOAuthGrantClient(CLIENT_OPTIONS); + let response = { + status: 400, + body: "{ \"code\": 400, \"errno\": 104, \"error\": \"Bad Request\", \"message\": \"Unauthorized\", \"reason\": \"Invalid fxa assertion\" }", + }; + + client._Request = new mockResponse(response); + client.getTokenFromAssertion("blah", "scope") + .then( + null, + function (e) { + do_check_eq(e.name, "FxAccountsOAuthGrantClientError"); + do_check_eq(e.code, 400); + do_check_eq(e.errno, ERRNO_INVALID_FXA_ASSERTION); + do_check_eq(e.error, "Bad Request"); + do_check_eq(e.message, "Unauthorized"); + run_next_test(); + } + ); +}); + +add_test(function networkErrorResponse () { + let client = new FxAccountsOAuthGrantClient({ + serverURL: "http://", + client_id: "abc123" + }); + Services.prefs.setBoolPref("identity.fxaccounts.skipDeviceRegistration", true); + client.getTokenFromAssertion("assertion", "scope") + .then( + null, + function (e) { + do_check_eq(e.name, "FxAccountsOAuthGrantClientError"); + do_check_eq(e.code, null); + do_check_eq(e.errno, ERRNO_NETWORK); + do_check_eq(e.error, ERROR_NETWORK); + run_next_test(); + } + ).catch(() => {}).then(() => + Services.prefs.clearUserPref("identity.fxaccounts.skipDeviceRegistration")); +}); + +add_test(function unsupportedMethod () { + let client = new FxAccountsOAuthGrantClient(CLIENT_OPTIONS); + + return client._createRequest("/", "PUT") + .then( + null, + function (e) { + do_check_eq(e.name, "FxAccountsOAuthGrantClientError"); + do_check_eq(e.code, ERROR_CODE_METHOD_NOT_ALLOWED); + do_check_eq(e.errno, ERRNO_NETWORK); + do_check_eq(e.error, ERROR_NETWORK); + do_check_eq(e.message, ERROR_MSG_METHOD_NOT_ALLOWED); + run_next_test(); + } + ); +}); + +add_test(function onCompleteRequestError () { + let client = new FxAccountsOAuthGrantClient(CLIENT_OPTIONS); + client._Request = new mockResponseError(new Error("onComplete error")); + client.getTokenFromAssertion("assertion", "scope") + .then( + null, + function (e) { + do_check_eq(e.name, "FxAccountsOAuthGrantClientError"); + do_check_eq(e.code, null); + do_check_eq(e.errno, ERRNO_NETWORK); + do_check_eq(e.error, ERROR_NETWORK); + do_check_eq(e.message, "Error: onComplete error"); + run_next_test(); + } + ); +}); + +add_test(function incorrectErrno() { + let client = new FxAccountsOAuthGrantClient(CLIENT_OPTIONS); + let response = { + status: 400, + body: "{ \"code\": 400, \"errno\": \"bad errno\", \"error\": \"Bad Request\", \"message\": \"Unauthorized\", \"reason\": \"Invalid fxa assertion\" }", + }; + + client._Request = new mockResponse(response); + client.getTokenFromAssertion("blah", "scope") + .then( + null, + function (e) { + do_check_eq(e.name, "FxAccountsOAuthGrantClientError"); + do_check_eq(e.code, 400); + do_check_eq(e.errno, ERRNO_UNKNOWN_ERROR); + do_check_eq(e.error, "Bad Request"); + do_check_eq(e.message, "Unauthorized"); + run_next_test(); + } + ); +}); + +add_test(function constructorTests() { + validationHelper(undefined, + "Error: Missing configuration options"); + + validationHelper({}, + "Error: Missing 'serverURL' parameter"); + + validationHelper({ serverURL: "http://example.com" }, + "Error: Missing 'client_id' parameter"); + + validationHelper({ client_id: "123ABC" }, + "Error: Missing 'serverURL' parameter"); + + validationHelper({ client_id: "123ABC", serverURL: "badUrl" }, + "Error: Invalid 'serverURL'"); + + run_next_test(); +}); + +add_test(function errorTests() { + let error1 = new FxAccountsOAuthGrantClientError(); + do_check_eq(error1.name, "FxAccountsOAuthGrantClientError"); + do_check_eq(error1.code, null); + do_check_eq(error1.errno, ERRNO_UNKNOWN_ERROR); + do_check_eq(error1.error, ERROR_UNKNOWN); + do_check_eq(error1.message, null); + + let error2 = new FxAccountsOAuthGrantClientError({ + code: STATUS_SUCCESS, + errno: 1, + error: "Error", + message: "Something", + }); + let fields2 = error2._toStringFields(); + let statusCode = 1; + + do_check_eq(error2.name, "FxAccountsOAuthGrantClientError"); + do_check_eq(error2.code, STATUS_SUCCESS); + do_check_eq(error2.errno, statusCode); + do_check_eq(error2.error, "Error"); + do_check_eq(error2.message, "Something"); + + do_check_eq(fields2.name, "FxAccountsOAuthGrantClientError"); + do_check_eq(fields2.code, STATUS_SUCCESS); + do_check_eq(fields2.errno, statusCode); + do_check_eq(fields2.error, "Error"); + do_check_eq(fields2.message, "Something"); + + do_check_true(error2.toString().indexOf("Something") >= 0); + run_next_test(); +}); + +function run_test() { + run_next_test(); +} + +/** + * Quick way to test the "FxAccountsOAuthGrantClient" constructor. + * + * @param {Object} options + * FxAccountsOAuthGrantClient constructor options + * @param {String} expected + * Expected error message + * @returns {*} + */ +function validationHelper(options, expected) { + try { + new FxAccountsOAuthGrantClient(options); + } catch (e) { + return do_check_eq(e.toString(), expected); + } + throw new Error("Validation helper error"); +} diff --git a/services/fxaccounts/tests/xpcshell/test_oauth_grant_client_server.js b/services/fxaccounts/tests/xpcshell/test_oauth_grant_client_server.js new file mode 100644 index 000000000..bd446513e --- /dev/null +++ b/services/fxaccounts/tests/xpcshell/test_oauth_grant_client_server.js @@ -0,0 +1,73 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +// A test of FxAccountsOAuthGrantClient but using a real server it can +// hit. +"use strict"; + +Cu.import("resource://gre/modules/FxAccountsCommon.js"); +Cu.import("resource://gre/modules/FxAccountsOAuthGrantClient.jsm"); + +// handlers for our server. +var numTokenFetches; +var activeTokens; + +function authorize(request, response) { + response.setStatusLine("1.1", 200, "OK"); + let token = "token" + numTokenFetches; + numTokenFetches += 1; + activeTokens.add(token); + response.write(JSON.stringify({access_token: token})); +} + +function destroy(request, response) { + // Getting the body seems harder than it should be! + let sis = Cc["@mozilla.org/scriptableinputstream;1"] + .createInstance(Ci.nsIScriptableInputStream); + sis.init(request.bodyInputStream); + let body = JSON.parse(sis.read(sis.available())); + sis.close(); + let token = body.token; + ok(activeTokens.delete(token)); + print("after destroy have", activeTokens.size, "tokens left.") + response.setStatusLine("1.1", 200, "OK"); + response.write('{}'); +} + +function startServer() { + numTokenFetches = 0; + activeTokens = new Set(); + let srv = new HttpServer(); + srv.registerPathHandler("/v1/authorization", authorize); + srv.registerPathHandler("/v1/destroy", destroy); + srv.start(-1); + return srv; +} + +function promiseStopServer(server) { + return new Promise(resolve => { + server.stop(resolve); + }); +} + +add_task(function* getAndRevokeToken () { + let server = startServer(); + let clientOptions = { + serverURL: "http://localhost:" + server.identity.primaryPort + "/v1", + client_id: 'abc123', + } + + let client = new FxAccountsOAuthGrantClient(clientOptions); + let result = yield client.getTokenFromAssertion("assertion", "scope"); + equal(result.access_token, "token0"); + equal(numTokenFetches, 1, "we hit the server to fetch a token"); + yield client.destroyToken("token0"); + equal(activeTokens.size, 0, "We hit the server to revoke it"); + yield promiseStopServer(server); +}); + +// XXX - TODO - we should probably add more tests for unexpected responses etc. + +function run_test() { + run_next_test(); +} diff --git a/services/fxaccounts/tests/xpcshell/test_oauth_token_storage.js b/services/fxaccounts/tests/xpcshell/test_oauth_token_storage.js new file mode 100644 index 000000000..08642846b --- /dev/null +++ b/services/fxaccounts/tests/xpcshell/test_oauth_token_storage.js @@ -0,0 +1,165 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"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. +var {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 Object.entries(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)); + return deferred.promise; + }; + + this.signOut = function() { return Promise.resolve(); }; + this.registerDevice = function() { return Promise.resolve(); }; + this.updateDevice = function() { return Promise.resolve(); }; + this.signOutAndDestroyDevice = function() { return Promise.resolve(); }; + this.getDeviceList = function() { return Promise.resolve(); }; + + FxAccountsClient.apply(this); +} + +MockFxAccountsClient.prototype = { + __proto__: FxAccountsClient.prototype +} + +function MockFxAccounts(device={}) { + return new FxAccounts({ + fxAccountsClient: new MockFxAccountsClient(), + newAccountState(credentials) { + // we use a real accountState but mocked storage. + let storage = new MockStorageManager(); + storage.initialize(credentials); + return new AccountState(storage); + }, + _getDeviceName() { + return "mock device name"; + }, + fxaPushService: { + registerPushEndpoint() { + return new Promise((resolve) => { + resolve({ + endpoint: "http://mochi.test:8888" + }); + }); + }, + }, + }); +} + +function* createMockFxA() { + let fxa = new MockFxAccounts(); + let credentials = { + email: "foo@example.com", + uid: "1234@lcip.org", + assertion: "foobar", + sessionToken: "dead", + kA: "beef", + kB: "cafe", + verified: true + }; + yield fxa.setSignedInUser(credentials); + return fxa; +} + +// The tests. +function run_test() { + run_next_test(); +} + +add_task(function* testCacheStorage() { + let fxa = yield createMockFxA(); + + // Hook what the impl calls to save to disk. + let cas = fxa.internal.currentAccountState; + let origPersistCached = cas._persistCachedTokens.bind(cas) + cas._persistCachedTokens = function() { + return origPersistCached().then(() => { + Services.obs.notifyObservers(null, "testhelper-fxa-cache-persist-done", null); + }); + }; + + 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.oauthTokens, {"bar|foo": tokenData}); + // wait for background write to complete. + yield promiseWritten; + + // 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.oauthTokens, {}); + yield promiseWritten; + 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); + deepEqual(storageManager.accountData, null); +}); diff --git a/services/fxaccounts/tests/xpcshell/test_oauth_tokens.js b/services/fxaccounts/tests/xpcshell/test_oauth_tokens.js new file mode 100644 index 000000000..f758bf405 --- /dev/null +++ b/services/fxaccounts/tests/xpcshell/test_oauth_tokens.js @@ -0,0 +1,251 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"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/FxAccountsOAuthGrantClient.jsm"); +Cu.import("resource://services-common/utils.js"); +var {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); + }); +} + +// Just enough mocks so we can avoid hawk and storage. +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 Object.entries(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.accountStatus = function(uid) { + let deferred = Promise.defer(); + deferred.resolve(!!uid && (!this._deletedOnServer)); + return deferred.promise; + }; + + this.signOut = function() { return Promise.resolve(); }; + this.registerDevice = function() { return Promise.resolve(); }; + this.updateDevice = function() { return Promise.resolve(); }; + this.signOutAndDestroyDevice = function() { return Promise.resolve(); }; + this.getDeviceList = function() { return Promise.resolve(); }; + + FxAccountsClient.apply(this); +} + +MockFxAccountsClient.prototype = { + __proto__: FxAccountsClient.prototype +} + +function MockFxAccounts(mockGrantClient) { + return new FxAccounts({ + fxAccountsClient: new MockFxAccountsClient(), + getAssertion: () => Promise.resolve("assertion"), + newAccountState(credentials) { + // we use a real accountState but mocked storage. + let storage = new MockStorageManager(); + storage.initialize(credentials); + return new AccountState(storage); + }, + _destroyOAuthToken: function(tokenData) { + // somewhat sad duplication of _destroyOAuthToken, but hard to avoid. + return mockGrantClient.destroyToken(tokenData.token).then( () => { + Services.obs.notifyObservers(null, "testhelper-fxa-revoke-complete", null); + }); + }, + _getDeviceName() { + return "mock device name"; + }, + fxaPushService: { + registerPushEndpoint() { + return new Promise((resolve) => { + resolve({ + endpoint: "http://mochi.test:8888" + }); + }); + }, + }, + }); +} + +function* createMockFxA(mockGrantClient) { + let fxa = new MockFxAccounts(mockGrantClient); + let credentials = { + email: "foo@example.com", + uid: "1234@lcip.org", + assertion: "foobar", + sessionToken: "dead", + kA: "beef", + kB: "cafe", + verified: true + }; + + yield fxa.setSignedInUser(credentials); + return fxa; +} + +// The tests. +function run_test() { + run_next_test(); +} + +function MockFxAccountsOAuthGrantClient() { + this.activeTokens = new Set(); +} + +MockFxAccountsOAuthGrantClient.prototype = { + serverURL: {href: "http://localhost"}, + getTokenFromAssertion(assertion, scope) { + let token = "token" + this.numTokenFetches; + this.numTokenFetches += 1; + this.activeTokens.add(token); + print("getTokenFromAssertion returning token", token); + return Promise.resolve({access_token: token}); + }, + destroyToken(token) { + ok(this.activeTokens.delete(token)); + print("after destroy have", this.activeTokens.size, "tokens left."); + return Promise.resolve({}); + }, + // and some stuff used only for tests. + numTokenFetches: 0, + activeTokens: null, +} + +add_task(function* testRevoke() { + let client = new MockFxAccountsOAuthGrantClient(); + let tokenOptions = { scope: "test-scope", client: client }; + let fxa = yield createMockFxA(client); + + // get our first token and check we hit the mock. + let token1 = yield fxa.getOAuthToken(tokenOptions); + equal(client.numTokenFetches, 1); + equal(client.activeTokens.size, 1); + ok(token1, "got a token"); + equal(token1, "token0"); + + // drop the new token from our cache. + yield fxa.removeCachedOAuthToken({token: token1}); + + // FxA fires an observer when the "background" revoke is complete. + yield promiseNotification("testhelper-fxa-revoke-complete"); + // the revoke should have been successful. + equal(client.activeTokens.size, 0); + // fetching it again hits the server. + let token2 = yield fxa.getOAuthToken(tokenOptions); + equal(client.numTokenFetches, 2); + equal(client.activeTokens.size, 1); + ok(token2, "got a token"); + notEqual(token1, token2, "got a different token"); +}); + +add_task(function* testSignOutDestroysTokens() { + let client = new MockFxAccountsOAuthGrantClient(); + let fxa = yield createMockFxA(client); + + // get our first token and check we hit the mock. + let token1 = yield fxa.getOAuthToken({ scope: "test-scope", client: client }); + equal(client.numTokenFetches, 1); + equal(client.activeTokens.size, 1); + ok(token1, "got a token"); + + // get another + let token2 = yield fxa.getOAuthToken({ scope: "test-scope-2", client: client }); + equal(client.numTokenFetches, 2); + equal(client.activeTokens.size, 2); + ok(token2, "got a token"); + notEqual(token1, token2, "got a different token"); + + // now sign out - they should be removed. + yield fxa.signOut(); + // FxA fires an observer when the "background" signout is complete. + yield promiseNotification("testhelper-fxa-signout-complete"); + // No active tokens left. + equal(client.activeTokens.size, 0); +}); + +add_task(function* testTokenRaces() { + // Here we do 2 concurrent fetches each for 2 different token scopes (ie, + // 4 token fetches in total). + // This should provoke a potential race in the token fetching but we should + // handle and detect that leaving us with one of the fetch tokens being + // revoked and the same token value returned to both calls. + let client = new MockFxAccountsOAuthGrantClient(); + let fxa = yield createMockFxA(client); + + // We should see 2 notifications as part of this - set up the listeners + // now (and wait on them later) + let notifications = Promise.all([ + promiseNotification("testhelper-fxa-revoke-complete"), + promiseNotification("testhelper-fxa-revoke-complete"), + ]); + let results = yield Promise.all([ + fxa.getOAuthToken({scope: "test-scope", client: client}), + fxa.getOAuthToken({scope: "test-scope", client: client}), + fxa.getOAuthToken({scope: "test-scope-2", client: client}), + fxa.getOAuthToken({scope: "test-scope-2", client: client}), + ]); + + equal(client.numTokenFetches, 4, "should have fetched 4 tokens."); + // We should see 2 of the 4 revoked due to the race. + yield notifications; + + // Should have 2 unique tokens + results.sort(); + equal(results[0], results[1]); + equal(results[2], results[3]); + // should be 2 active. + equal(client.activeTokens.size, 2); + // Which can each be revoked. + notifications = Promise.all([ + promiseNotification("testhelper-fxa-revoke-complete"), + promiseNotification("testhelper-fxa-revoke-complete"), + ]); + yield fxa.removeCachedOAuthToken({token: results[0]}); + equal(client.activeTokens.size, 1); + yield fxa.removeCachedOAuthToken({token: results[2]}); + equal(client.activeTokens.size, 0); + yield notifications; +}); diff --git a/services/fxaccounts/tests/xpcshell/test_profile.js b/services/fxaccounts/tests/xpcshell/test_profile.js new file mode 100644 index 000000000..13adf8cbb --- /dev/null +++ b/services/fxaccounts/tests/xpcshell/test_profile.js @@ -0,0 +1,409 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +Cu.import("resource://gre/modules/Promise.jsm"); +Cu.import("resource://gre/modules/FxAccountsCommon.js"); +Cu.import("resource://gre/modules/FxAccountsProfileClient.jsm"); +Cu.import("resource://gre/modules/FxAccountsProfile.jsm"); + +const URL_STRING = "https://example.com"; +Services.prefs.setCharPref("identity.fxaccounts.settings.uri", "https://example.com/settings"); + +const STATUS_SUCCESS = 200; + +/** + * Mock request responder + * @param {String} response + * Mocked raw response from the server + * @returns {Function} + */ +var mockResponse = function (response) { + let Request = function (requestUri) { + // Store the request uri so tests can inspect it + Request._requestUri = requestUri; + return { + setHeader: function () {}, + head: function () { + this.response = response; + this.onComplete(); + } + }; + }; + + return Request; +}; + +/** + * Mock request error responder + * @param {Error} error + * Error object + * @returns {Function} + */ +var mockResponseError = function (error) { + return function () { + return { + setHeader: function () {}, + head: function () { + this.onComplete(error); + } + }; + }; +}; + +var mockClient = function (fxa) { + let options = { + serverURL: "http://127.0.0.1:1111/v1", + fxa: fxa, + } + return new FxAccountsProfileClient(options); +}; + +const ACCOUNT_DATA = { + uid: "abc123" +}; + +function FxaMock() { +} +FxaMock.prototype = { + currentAccountState: { + profile: null, + get isCurrent() { + return true; + } + }, + + getSignedInUser: function () { + return Promise.resolve(ACCOUNT_DATA); + } +}; + +var mockFxa = function() { + return new FxaMock(); +}; + +function CreateFxAccountsProfile(fxa = null, client = null) { + if (!fxa) { + fxa = mockFxa(); + } + let options = { + fxa: fxa, + profileServerUrl: "http://127.0.0.1:1111/v1" + } + if (client) { + options.profileClient = client; + } + return new FxAccountsProfile(options); +} + +add_test(function getCachedProfile() { + let profile = CreateFxAccountsProfile(); + // a little pointless until bug 1157529 is fixed... + profile._cachedProfile = { avatar: "myurl" }; + + return profile._getCachedProfile() + .then(function (cached) { + do_check_eq(cached.avatar, "myurl"); + run_next_test(); + }); +}); + +add_test(function cacheProfile_change() { + let fxa = mockFxa(); +/* Saving profile data disabled - bug 1157529 + let setUserAccountDataCalled = false; + fxa.setUserAccountData = function (data) { + setUserAccountDataCalled = true; + do_check_eq(data.profile.avatar, "myurl"); + return Promise.resolve(); + }; +*/ + let profile = CreateFxAccountsProfile(fxa); + + makeObserver(ON_PROFILE_CHANGE_NOTIFICATION, function (subject, topic, data) { + do_check_eq(data, ACCOUNT_DATA.uid); +// do_check_true(setUserAccountDataCalled); - bug 1157529 + run_next_test(); + }); + + return profile._cacheProfile({ avatar: "myurl" }); +}); + +add_test(function cacheProfile_no_change() { + let fxa = mockFxa(); + let profile = CreateFxAccountsProfile(fxa) + profile._cachedProfile = { avatar: "myurl" }; +// XXX - saving is disabled (but we can leave that in for now as we are +// just checking it is *not* called) + fxa.setSignedInUser = function (data) { + throw new Error("should not update account data"); + }; + + return profile._cacheProfile({ avatar: "myurl" }) + .then((result) => { + do_check_false(!!result); + run_next_test(); + }); +}); + +add_test(function fetchAndCacheProfile_ok() { + let client = mockClient(mockFxa()); + client.fetchProfile = function () { + return Promise.resolve({ avatar: "myimg"}); + }; + let profile = CreateFxAccountsProfile(null, client); + + profile._cacheProfile = function (toCache) { + do_check_eq(toCache.avatar, "myimg"); + return Promise.resolve(); + }; + + return profile._fetchAndCacheProfile() + .then(result => { + do_check_eq(result.avatar, "myimg"); + run_next_test(); + }); +}); + +// Check that a second profile request when one is already in-flight reuses +// the in-flight one. +add_task(function* fetchAndCacheProfileOnce() { + // A promise that remains unresolved while we fire off 2 requests for + // a profile. + let resolveProfile; + let promiseProfile = new Promise(resolve => { + resolveProfile = resolve; + }); + let numFetches = 0; + let client = mockClient(mockFxa()); + client.fetchProfile = function () { + numFetches += 1; + return promiseProfile; + }; + let profile = CreateFxAccountsProfile(null, client); + + let request1 = profile._fetchAndCacheProfile(); + let request2 = profile._fetchAndCacheProfile(); + + // should be one request made to fetch the profile (but the promise returned + // by it remains unresolved) + do_check_eq(numFetches, 1); + + // resolve the promise. + resolveProfile({ avatar: "myimg"}); + + // both requests should complete with the same data. + let got1 = yield request1; + do_check_eq(got1.avatar, "myimg"); + let got2 = yield request1; + do_check_eq(got2.avatar, "myimg"); + + // and still only 1 request was made. + do_check_eq(numFetches, 1); +}); + +// Check that sharing a single fetch promise works correctly when the promise +// is rejected. +add_task(function* fetchAndCacheProfileOnce() { + // A promise that remains unresolved while we fire off 2 requests for + // a profile. + let rejectProfile; + let promiseProfile = new Promise((resolve,reject) => { + rejectProfile = reject; + }); + let numFetches = 0; + let client = mockClient(mockFxa()); + client.fetchProfile = function () { + numFetches += 1; + return promiseProfile; + }; + let profile = CreateFxAccountsProfile(null, client); + + let request1 = profile._fetchAndCacheProfile(); + let request2 = profile._fetchAndCacheProfile(); + + // should be one request made to fetch the profile (but the promise returned + // by it remains unresolved) + do_check_eq(numFetches, 1); + + // reject the promise. + rejectProfile("oh noes"); + + // both requests should reject. + try { + yield request1; + throw new Error("should have rejected"); + } catch (ex) { + if (ex != "oh noes") { + throw ex; + } + } + try { + yield request2; + throw new Error("should have rejected"); + } catch (ex) { + if (ex != "oh noes") { + throw ex; + } + } + + // but a new request should work. + client.fetchProfile = function () { + return Promise.resolve({ avatar: "myimg"}); + }; + + let got = yield profile._fetchAndCacheProfile(); + do_check_eq(got.avatar, "myimg"); +}); + +// Check that a new profile request within PROFILE_FRESHNESS_THRESHOLD of the +// last one doesn't kick off a new request to check the cached copy is fresh. +add_task(function* fetchAndCacheProfileAfterThreshold() { + let numFetches = 0; + let client = mockClient(mockFxa()); + client.fetchProfile = function () { + numFetches += 1; + return Promise.resolve({ avatar: "myimg"}); + }; + let profile = CreateFxAccountsProfile(null, client); + profile.PROFILE_FRESHNESS_THRESHOLD = 1000; + + yield profile.getProfile(); + do_check_eq(numFetches, 1); + + yield profile.getProfile(); + do_check_eq(numFetches, 1); + + yield new Promise(resolve => { + do_timeout(1000, resolve); + }); + + yield profile.getProfile(); + do_check_eq(numFetches, 2); +}); + +// Check that a new profile request within PROFILE_FRESHNESS_THRESHOLD of the +// last one *does* kick off a new request if ON_PROFILE_CHANGE_NOTIFICATION +// is sent. +add_task(function* fetchAndCacheProfileBeforeThresholdOnNotification() { + let numFetches = 0; + let client = mockClient(mockFxa()); + client.fetchProfile = function () { + numFetches += 1; + return Promise.resolve({ avatar: "myimg"}); + }; + let profile = CreateFxAccountsProfile(null, client); + profile.PROFILE_FRESHNESS_THRESHOLD = 1000; + + yield profile.getProfile(); + do_check_eq(numFetches, 1); + + Services.obs.notifyObservers(null, ON_PROFILE_CHANGE_NOTIFICATION, null); + + yield profile.getProfile(); + do_check_eq(numFetches, 2); +}); + +add_test(function tearDown_ok() { + let profile = CreateFxAccountsProfile(); + + do_check_true(!!profile.client); + do_check_true(!!profile.fxa); + + profile.tearDown(); + do_check_null(profile.fxa); + do_check_null(profile.client); + + run_next_test(); +}); + +add_test(function getProfile_ok() { + let cachedUrl = "myurl"; + let didFetch = false; + + let profile = CreateFxAccountsProfile(); + profile._getCachedProfile = function () { + return Promise.resolve({ avatar: cachedUrl }); + }; + + profile._fetchAndCacheProfile = function () { + didFetch = true; + return Promise.resolve(); + }; + + return profile.getProfile() + .then(result => { + do_check_eq(result.avatar, cachedUrl); + do_check_true(didFetch); + run_next_test(); + }); +}); + +add_test(function getProfile_no_cache() { + let fetchedUrl = "newUrl"; + let profile = CreateFxAccountsProfile(); + profile._getCachedProfile = function () { + return Promise.resolve(); + }; + + profile._fetchAndCacheProfile = function () { + return Promise.resolve({ avatar: fetchedUrl }); + }; + + return profile.getProfile() + .then(result => { + do_check_eq(result.avatar, fetchedUrl); + run_next_test(); + }); +}); + +add_test(function getProfile_has_cached_fetch_deleted() { + let cachedUrl = "myurl"; + + let fxa = mockFxa(); + let client = mockClient(fxa); + client.fetchProfile = function () { + return Promise.resolve({ avatar: null }); + }; + + let profile = CreateFxAccountsProfile(fxa, client); + profile._cachedProfile = { avatar: cachedUrl }; + +// instead of checking this in a mocked "save" function, just check after the +// observer + makeObserver(ON_PROFILE_CHANGE_NOTIFICATION, function (subject, topic, data) { + profile.getProfile() + .then(profileData => { + do_check_null(profileData.avatar); + run_next_test(); + }); + }); + + return profile.getProfile() + .then(result => { + do_check_eq(result.avatar, "myurl"); + }); +}); + +function run_test() { + run_next_test(); +} + +function makeObserver(aObserveTopic, aObserveFunc) { + let callback = function (aSubject, aTopic, aData) { + log.debug("observed " + aTopic + " " + aData); + if (aTopic == aObserveTopic) { + removeMe(); + aObserveFunc(aSubject, aTopic, aData); + } + }; + + function removeMe() { + log.debug("removing observer for " + aObserveTopic); + Services.obs.removeObserver(callback, aObserveTopic); + } + + Services.obs.addObserver(callback, aObserveTopic, false); + return removeMe; +} diff --git a/services/fxaccounts/tests/xpcshell/test_profile_client.js b/services/fxaccounts/tests/xpcshell/test_profile_client.js new file mode 100644 index 000000000..2243da3aa --- /dev/null +++ b/services/fxaccounts/tests/xpcshell/test_profile_client.js @@ -0,0 +1,411 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +Cu.import("resource://gre/modules/FxAccountsCommon.js"); +Cu.import("resource://gre/modules/FxAccountsProfileClient.jsm"); + +const STATUS_SUCCESS = 200; + +/** + * Mock request responder + * @param {String} response + * Mocked raw response from the server + * @returns {Function} + */ +var mockResponse = function (response) { + let Request = function (requestUri) { + // Store the request uri so tests can inspect it + Request._requestUri = requestUri; + return { + setHeader: function () {}, + get: function () { + this.response = response; + this.onComplete(); + } + }; + }; + + return Request; +}; + +// A simple mock FxA that hands out tokens without checking them and doesn't +// expect tokens to be revoked. We have specific token tests further down that +// has more checks here. +var mockFxa = { + getOAuthToken(options) { + do_check_eq(options.scope, "profile"); + return "token"; + } +} + +const PROFILE_OPTIONS = { + serverURL: "http://127.0.0.1:1111/v1", + fxa: mockFxa, +}; + +/** + * Mock request error responder + * @param {Error} error + * Error object + * @returns {Function} + */ +var mockResponseError = function (error) { + return function () { + return { + setHeader: function () {}, + get: function () { + this.onComplete(error); + } + }; + }; +}; + +add_test(function successfulResponse () { + let client = new FxAccountsProfileClient(PROFILE_OPTIONS); + let response = { + success: true, + status: STATUS_SUCCESS, + body: "{\"email\":\"someone@restmail.net\",\"uid\":\"0d5c1a89b8c54580b8e3e8adadae864a\"}", + }; + + client._Request = new mockResponse(response); + client.fetchProfile() + .then( + function (result) { + do_check_eq(client._Request._requestUri, "http://127.0.0.1:1111/v1/profile"); + do_check_eq(result.email, "someone@restmail.net"); + do_check_eq(result.uid, "0d5c1a89b8c54580b8e3e8adadae864a"); + run_next_test(); + } + ); +}); + +add_test(function parseErrorResponse () { + let client = new FxAccountsProfileClient(PROFILE_OPTIONS); + let response = { + success: true, + status: STATUS_SUCCESS, + body: "unexpected", + }; + + client._Request = new mockResponse(response); + client.fetchProfile() + .then( + null, + function (e) { + do_check_eq(e.name, "FxAccountsProfileClientError"); + do_check_eq(e.code, STATUS_SUCCESS); + do_check_eq(e.errno, ERRNO_PARSE); + do_check_eq(e.error, ERROR_PARSE); + do_check_eq(e.message, "unexpected"); + run_next_test(); + } + ); +}); + +add_test(function serverErrorResponse () { + let client = new FxAccountsProfileClient(PROFILE_OPTIONS); + let response = { + status: 500, + body: "{ \"code\": 500, \"errno\": 100, \"error\": \"Bad Request\", \"message\": \"Something went wrong\", \"reason\": \"Because the internet\" }", + }; + + client._Request = new mockResponse(response); + client.fetchProfile() + .then( + null, + function (e) { + do_check_eq(e.name, "FxAccountsProfileClientError"); + do_check_eq(e.code, 500); + do_check_eq(e.errno, 100); + do_check_eq(e.error, "Bad Request"); + do_check_eq(e.message, "Something went wrong"); + run_next_test(); + } + ); +}); + +// Test that we get a token, then if we get a 401 we revoke it, get a new one +// and retry. +add_test(function server401ResponseThenSuccess () { + // The last token we handed out. + let lastToken = -1; + // The number of times our removeCachedOAuthToken function was called. + let numTokensRemoved = 0; + + let mockFxa = { + getOAuthToken(options) { + do_check_eq(options.scope, "profile"); + return "" + ++lastToken; // tokens are strings. + }, + removeCachedOAuthToken(options) { + // This test never has more than 1 token alive at once, so the token + // being revoked must always be the last token we handed out. + do_check_eq(parseInt(options.token), lastToken); + ++numTokensRemoved; + } + } + let profileOptions = { + serverURL: "http://127.0.0.1:1111/v1", + fxa: mockFxa, + }; + let client = new FxAccountsProfileClient(profileOptions); + + // 2 responses - first one implying the token has expired, second works. + let responses = [ + { + status: 401, + body: "{ \"code\": 401, \"errno\": 100, \"error\": \"Token expired\", \"message\": \"That token is too old\", \"reason\": \"Because security\" }", + }, + { + success: true, + status: STATUS_SUCCESS, + body: "{\"avatar\":\"http://example.com/image.jpg\",\"id\":\"0d5c1a89b8c54580b8e3e8adadae864a\"}", + }, + ]; + + let numRequests = 0; + let numAuthHeaders = 0; + // Like mockResponse but we want access to headers etc. + client._Request = function(requestUri) { + return { + setHeader: function (name, value) { + if (name == "Authorization") { + numAuthHeaders++; + do_check_eq(value, "Bearer " + lastToken); + } + }, + get: function () { + this.response = responses[numRequests]; + ++numRequests; + this.onComplete(); + } + }; + } + + client.fetchProfile() + .then(result => { + do_check_eq(result.avatar, "http://example.com/image.jpg"); + do_check_eq(result.id, "0d5c1a89b8c54580b8e3e8adadae864a"); + // should have been exactly 2 requests and exactly 2 auth headers. + do_check_eq(numRequests, 2); + do_check_eq(numAuthHeaders, 2); + // and we should have seen one token revoked. + do_check_eq(numTokensRemoved, 1); + + run_next_test(); + } + ); +}); + +// Test that we get a token, then if we get a 401 we revoke it, get a new one +// and retry - but we *still* get a 401 on the retry, so the caller sees that. +add_test(function server401ResponsePersists () { + // The last token we handed out. + let lastToken = -1; + // The number of times our removeCachedOAuthToken function was called. + let numTokensRemoved = 0; + + let mockFxa = { + getOAuthToken(options) { + do_check_eq(options.scope, "profile"); + return "" + ++lastToken; // tokens are strings. + }, + removeCachedOAuthToken(options) { + // This test never has more than 1 token alive at once, so the token + // being revoked must always be the last token we handed out. + do_check_eq(parseInt(options.token), lastToken); + ++numTokensRemoved; + } + } + let profileOptions = { + serverURL: "http://127.0.0.1:1111/v1", + fxa: mockFxa, + }; + let client = new FxAccountsProfileClient(profileOptions); + + let response = { + status: 401, + body: "{ \"code\": 401, \"errno\": 100, \"error\": \"It's not your token, it's you!\", \"message\": \"I don't like you\", \"reason\": \"Because security\" }", + }; + + let numRequests = 0; + let numAuthHeaders = 0; + client._Request = function(requestUri) { + return { + setHeader: function (name, value) { + if (name == "Authorization") { + numAuthHeaders++; + do_check_eq(value, "Bearer " + lastToken); + } + }, + get: function () { + this.response = response; + ++numRequests; + this.onComplete(); + } + }; + } + + client.fetchProfile().then( + null, + function (e) { + do_check_eq(e.name, "FxAccountsProfileClientError"); + do_check_eq(e.code, 401); + do_check_eq(e.errno, 100); + do_check_eq(e.error, "It's not your token, it's you!"); + // should have been exactly 2 requests and exactly 2 auth headers. + do_check_eq(numRequests, 2); + do_check_eq(numAuthHeaders, 2); + // and we should have seen both tokens revoked. + do_check_eq(numTokensRemoved, 2); + run_next_test(); + } + ); +}); + +add_test(function networkErrorResponse () { + let client = new FxAccountsProfileClient({ + serverURL: "http://", + fxa: mockFxa, + }); + client.fetchProfile() + .then( + null, + function (e) { + do_check_eq(e.name, "FxAccountsProfileClientError"); + do_check_eq(e.code, null); + do_check_eq(e.errno, ERRNO_NETWORK); + do_check_eq(e.error, ERROR_NETWORK); + run_next_test(); + } + ); +}); + +add_test(function unsupportedMethod () { + let client = new FxAccountsProfileClient(PROFILE_OPTIONS); + + return client._createRequest("/profile", "PUT") + .then( + null, + function (e) { + do_check_eq(e.name, "FxAccountsProfileClientError"); + do_check_eq(e.code, ERROR_CODE_METHOD_NOT_ALLOWED); + do_check_eq(e.errno, ERRNO_NETWORK); + do_check_eq(e.error, ERROR_NETWORK); + do_check_eq(e.message, ERROR_MSG_METHOD_NOT_ALLOWED); + run_next_test(); + } + ); +}); + +add_test(function onCompleteRequestError () { + let client = new FxAccountsProfileClient(PROFILE_OPTIONS); + client._Request = new mockResponseError(new Error("onComplete error")); + client.fetchProfile() + .then( + null, + function (e) { + do_check_eq(e.name, "FxAccountsProfileClientError"); + do_check_eq(e.code, null); + do_check_eq(e.errno, ERRNO_NETWORK); + do_check_eq(e.error, ERROR_NETWORK); + do_check_eq(e.message, "Error: onComplete error"); + run_next_test(); + } + ); +}); + +add_test(function fetchProfileImage_successfulResponse () { + let client = new FxAccountsProfileClient(PROFILE_OPTIONS); + let response = { + success: true, + status: STATUS_SUCCESS, + body: "{\"avatar\":\"http://example.com/image.jpg\",\"id\":\"0d5c1a89b8c54580b8e3e8adadae864a\"}", + }; + + client._Request = new mockResponse(response); + client.fetchProfileImage() + .then( + function (result) { + do_check_eq(client._Request._requestUri, "http://127.0.0.1:1111/v1/avatar"); + do_check_eq(result.avatar, "http://example.com/image.jpg"); + do_check_eq(result.id, "0d5c1a89b8c54580b8e3e8adadae864a"); + run_next_test(); + } + ); +}); + +add_test(function constructorTests() { + validationHelper(undefined, + "Error: Missing 'serverURL' configuration option"); + + validationHelper({}, + "Error: Missing 'serverURL' configuration option"); + + validationHelper({ serverURL: "badUrl" }, + "Error: Invalid 'serverURL'"); + + run_next_test(); +}); + +add_test(function errorTests() { + let error1 = new FxAccountsProfileClientError(); + do_check_eq(error1.name, "FxAccountsProfileClientError"); + do_check_eq(error1.code, null); + do_check_eq(error1.errno, ERRNO_UNKNOWN_ERROR); + do_check_eq(error1.error, ERROR_UNKNOWN); + do_check_eq(error1.message, null); + + let error2 = new FxAccountsProfileClientError({ + code: STATUS_SUCCESS, + errno: 1, + error: "Error", + message: "Something", + }); + let fields2 = error2._toStringFields(); + let statusCode = 1; + + do_check_eq(error2.name, "FxAccountsProfileClientError"); + do_check_eq(error2.code, STATUS_SUCCESS); + do_check_eq(error2.errno, statusCode); + do_check_eq(error2.error, "Error"); + do_check_eq(error2.message, "Something"); + + do_check_eq(fields2.name, "FxAccountsProfileClientError"); + do_check_eq(fields2.code, STATUS_SUCCESS); + do_check_eq(fields2.errno, statusCode); + do_check_eq(fields2.error, "Error"); + do_check_eq(fields2.message, "Something"); + + do_check_true(error2.toString().indexOf("Something") >= 0); + run_next_test(); +}); + +function run_test() { + run_next_test(); +} + +/** + * Quick way to test the "FxAccountsProfileClient" constructor. + * + * @param {Object} options + * FxAccountsProfileClient constructor options + * @param {String} expected + * Expected error message + * @returns {*} + */ +function validationHelper(options, expected) { + // add fxa to options - that missing isn't what we are testing here. + if (options) { + options.fxa = mockFxa; + } + try { + new FxAccountsProfileClient(options); + } catch (e) { + return do_check_eq(e.toString(), expected); + } + throw new Error("Validation helper error"); +} diff --git a/services/fxaccounts/tests/xpcshell/test_push_service.js b/services/fxaccounts/tests/xpcshell/test_push_service.js new file mode 100644 index 000000000..8d66f6fa8 --- /dev/null +++ b/services/fxaccounts/tests/xpcshell/test_push_service.js @@ -0,0 +1,236 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Tests for the FxA push service. + +Cu.import("resource://gre/modules/Task.jsm"); +Cu.import("resource://gre/modules/Services.jsm"); +Cu.import("resource://gre/modules/Promise.jsm"); +Cu.import("resource://gre/modules/FxAccountsCommon.js"); +Cu.import("resource://gre/modules/FxAccountsPush.js"); +Cu.import("resource://gre/modules/Log.jsm"); + +XPCOMUtils.defineLazyServiceGetter(this, "pushService", + "@mozilla.org/push/Service;1", "nsIPushService"); + +initTestLogging("Trace"); +log.level = Log.Level.Trace; + +const MOCK_ENDPOINT = "http://mochi.test:8888"; + +// tests do not allow external connections, mock the PushService +let mockPushService = { + pushTopic: this.pushService.pushTopic, + subscriptionChangeTopic: this.pushService.subscriptionChangeTopic, + subscribe(scope, principal, cb) { + cb(Components.results.NS_OK, { + endpoint: MOCK_ENDPOINT + }); + }, + unsubscribe(scope, principal, cb) { + cb(Components.results.NS_OK, true); + } +}; + +let mockFxAccounts = { + checkVerificationStatus() {}, + updateDeviceRegistration() {} +}; + +let mockLog = { + trace() {}, + debug() {}, + warn() {}, + error() {} +}; + + +add_task(function* initialize() { + let pushService = new FxAccountsPushService(); + equal(pushService.initialize(), false); +}); + +add_task(function* registerPushEndpointSuccess() { + let pushService = new FxAccountsPushService({ + pushService: mockPushService, + fxAccounts: mockFxAccounts, + }); + + let subscription = yield pushService.registerPushEndpoint(); + equal(subscription.endpoint, MOCK_ENDPOINT); +}); + +add_task(function* registerPushEndpointFailure() { + let failPushService = Object.assign(mockPushService, { + subscribe(scope, principal, cb) { + cb(Components.results.NS_ERROR_ABORT); + } + }); + + let pushService = new FxAccountsPushService({ + pushService: failPushService, + fxAccounts: mockFxAccounts, + }); + + let subscription = yield pushService.registerPushEndpoint(); + equal(subscription, null); +}); + +add_task(function* unsubscribeSuccess() { + let pushService = new FxAccountsPushService({ + pushService: mockPushService, + fxAccounts: mockFxAccounts, + }); + + let result = yield pushService.unsubscribe(); + equal(result, true); +}); + +add_task(function* unsubscribeFailure() { + let failPushService = Object.assign(mockPushService, { + unsubscribe(scope, principal, cb) { + cb(Components.results.NS_ERROR_ABORT); + } + }); + + let pushService = new FxAccountsPushService({ + pushService: failPushService, + fxAccounts: mockFxAccounts, + }); + + let result = yield pushService.unsubscribe(); + equal(result, null); +}); + +add_test(function observeLogout() { + let customLog = Object.assign(mockLog, { + trace: function (msg) { + if (msg === "FxAccountsPushService unsubscribe") { + // logout means we unsubscribe + run_next_test(); + } + } + }); + + let pushService = new FxAccountsPushService({ + pushService: mockPushService, + log: customLog + }); + + pushService.observe(null, ONLOGOUT_NOTIFICATION); +}); + +add_test(function observePushTopicVerify() { + let emptyMsg = { + QueryInterface: function() { + return this; + } + }; + let customAccounts = Object.assign(mockFxAccounts, { + checkVerificationStatus: function () { + // checking verification status on push messages without data + run_next_test(); + } + }); + + let pushService = new FxAccountsPushService({ + pushService: mockPushService, + fxAccounts: customAccounts, + }); + + pushService.observe(emptyMsg, mockPushService.pushTopic, FXA_PUSH_SCOPE_ACCOUNT_UPDATE); +}); + +add_test(function observePushTopicDeviceDisconnected() { + const deviceId = "bogusid"; + let msg = { + data: { + json: () => ({ + command: ON_DEVICE_DISCONNECTED_NOTIFICATION, + data: { + id: deviceId + } + }) + }, + QueryInterface: function() { + return this; + } + }; + let customAccounts = Object.assign(mockFxAccounts, { + handleDeviceDisconnection: function () { + // checking verification status on push messages without data + run_next_test(); + } + }); + + let pushService = new FxAccountsPushService({ + pushService: mockPushService, + fxAccounts: customAccounts, + }); + + pushService.observe(msg, mockPushService.pushTopic, FXA_PUSH_SCOPE_ACCOUNT_UPDATE); +}); + +add_test(function observePushTopicPasswordChanged() { + let msg = { + data: { + json: () => ({ + command: ON_PASSWORD_CHANGED_NOTIFICATION + }) + }, + QueryInterface: function() { + return this; + } + }; + + let pushService = new FxAccountsPushService({ + pushService: mockPushService, + }); + + pushService._onPasswordChanged = function () { + run_next_test(); + } + + pushService.observe(msg, mockPushService.pushTopic, FXA_PUSH_SCOPE_ACCOUNT_UPDATE); +}); + +add_test(function observePushTopicPasswordReset() { + let msg = { + data: { + json: () => ({ + command: ON_PASSWORD_RESET_NOTIFICATION + }) + }, + QueryInterface: function() { + return this; + } + }; + + let pushService = new FxAccountsPushService({ + pushService: mockPushService + }); + + pushService._onPasswordChanged = function () { + run_next_test(); + } + + pushService.observe(msg, mockPushService.pushTopic, FXA_PUSH_SCOPE_ACCOUNT_UPDATE); +}); + +add_test(function observeSubscriptionChangeTopic() { + let customAccounts = Object.assign(mockFxAccounts, { + updateDeviceRegistration: function () { + // subscription change means updating the device registration + run_next_test(); + } + }); + + let pushService = new FxAccountsPushService({ + pushService: mockPushService, + fxAccounts: customAccounts, + }); + + pushService.observe(null, mockPushService.subscriptionChangeTopic, FXA_PUSH_SCOPE_ACCOUNT_UPDATE); +}); diff --git a/services/fxaccounts/tests/xpcshell/test_storage_manager.js b/services/fxaccounts/tests/xpcshell/test_storage_manager.js new file mode 100644 index 000000000..6a293a0ff --- /dev/null +++ b/services/fxaccounts/tests/xpcshell/test_storage_manager.js @@ -0,0 +1,477 @@ +/* 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; + +const DEVICE_REGISTRATION_VERSION = 42; + +// 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 = { + fetchCount: 0, + locked: false, + STORAGE_LOCKED: function() {}, + get: Task.async(function* (uid, email) { + this.fetchCount++; + 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({kA: "kA"}), "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", + deviceId: "device id" + }; + sm.plainStorage = new MockedPlainStorage() + if (sm.secureStorage) { + sm.secureStorage = new MockedSecureStorage(null); + } + yield sm.initialize(initialAccountData); + let accountData = yield sm.getAccountData(); + Assert.equal(accountData.uid, initialAccountData.uid); + Assert.equal(accountData.email, initialAccountData.email); + Assert.equal(accountData.kA, initialAccountData.kA); + Assert.equal(accountData.deviceId, initialAccountData.deviceId); + + // and it should have been written to storage. + Assert.equal(sm.plainStorage.data.accountData.uid, initialAccountData.uid); + Assert.equal(sm.plainStorage.data.accountData.email, initialAccountData.email); + Assert.equal(sm.plainStorage.data.accountData.deviceId, initialAccountData.deviceId); + // check secure + if (sm.secureStorage) { + Assert.equal(sm.secureStorage.data.accountData.kA, initialAccountData.kA); + } else { + Assert.equal(sm.plainStorage.data.accountData.kA, initialAccountData.kA); + } +}); + +// Initialized without account data but storage has it available. +add_storage_task(function* checkEverythingRead(sm) { + sm.plainStorage = new MockedPlainStorage({ + uid: "uid", + email: "someone@somewhere.com", + deviceId: "wibble", + deviceRegistrationVersion: null + }); + if (sm.secureStorage) { + sm.secureStorage = new MockedSecureStorage(null); + } + yield sm.initialize(); + let accountData = yield sm.getAccountData(); + Assert.ok(accountData, "read account data"); + Assert.equal(accountData.uid, "uid"); + Assert.equal(accountData.email, "someone@somewhere.com"); + Assert.equal(accountData.deviceId, "wibble"); + Assert.equal(accountData.deviceRegistrationVersion, null); + // Update the data - we should be able to fetch it back and it should appear + // in our storage. + yield sm.updateAccountData({ + verified: true, + kA: "kA", + kB: "kB", + deviceRegistrationVersion: DEVICE_REGISTRATION_VERSION + }); + accountData = yield sm.getAccountData(); + Assert.equal(accountData.kB, "kB"); + Assert.equal(accountData.kA, "kA"); + Assert.equal(accountData.deviceId, "wibble"); + Assert.equal(accountData.deviceRegistrationVersion, DEVICE_REGISTRATION_VERSION); + // Check the new value was written to storage. + yield sm._promiseStorageComplete; // storage is written in the background. + // "verified", "deviceId" and "deviceRegistrationVersion" are plain-text fields. + Assert.equal(sm.plainStorage.data.accountData.verified, true); + Assert.equal(sm.plainStorage.data.accountData.deviceId, "wibble"); + Assert.equal(sm.plainStorage.data.accountData.deviceRegistrationVersion, DEVICE_REGISTRATION_VERSION); + // "kA" and "foo" are secure + if (sm.secureStorage) { + Assert.equal(sm.secureStorage.data.accountData.kA, "kA"); + Assert.equal(sm.secureStorage.data.accountData.kB, "kB"); + } else { + Assert.equal(sm.plainStorage.data.accountData.kA, "kA"); + Assert.equal(sm.plainStorage.data.accountData.kB, "kB"); + } +}); + +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* checkMemoryFieldsNotReturnedByDefault() { + let sm = new FxAccountsStorageManager(); + sm.plainStorage = new MockedPlainStorage({uid: "uid", email: "someone@somewhere.com"}) + sm.secureStorage = new MockedSecureStorage({kA: "kA"}); + yield sm.initialize(); + + // keyPair is a memory field. + yield sm.updateAccountData({keyPair: "the keypair value"}); + let accountData = yield sm.getAccountData(); + + // Requesting everything should *not* return in memory fields. + Assert.strictEqual(accountData.keyPair, undefined); + // But requesting them specifically does get them. + accountData = yield sm.getAccountData("keyPair"); + Assert.strictEqual(accountData.keyPair, "the keypair value"); +}); + +add_task(function* checkExplicitGet() { + 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(["uid", "kA"]); + Assert.ok(accountData, "read account data"); + Assert.equal(accountData.uid, "uid"); + Assert.equal(accountData.kA, "kA"); + // We didn't ask for email so shouldn't have got it. + Assert.strictEqual(accountData.email, undefined); +}); + +add_task(function* checkExplicitGetNoSecureRead() { + let sm = new FxAccountsStorageManager(); + sm.plainStorage = new MockedPlainStorage({uid: "uid", email: "someone@somewhere.com"}) + sm.secureStorage = new MockedSecureStorage({kA: "kA"}); + yield sm.initialize(); + + Assert.equal(sm.secureStorage.fetchCount, 0); + // request 2 fields in secure storage - it should have caused a single fetch. + let accountData = yield sm.getAccountData(["email", "uid"]); + Assert.ok(accountData, "read account data"); + Assert.equal(accountData.uid, "uid"); + Assert.equal(accountData.email, "someone@somewhere.com"); + Assert.strictEqual(accountData.kA, undefined); + Assert.equal(sm.secureStorage.fetchCount, 1); +}); + +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. +var 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(); +} diff --git a/services/fxaccounts/tests/xpcshell/test_web_channel.js b/services/fxaccounts/tests/xpcshell/test_web_channel.js new file mode 100644 index 000000000..3cf566278 --- /dev/null +++ b/services/fxaccounts/tests/xpcshell/test_web_channel.js @@ -0,0 +1,499 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +Cu.import("resource://gre/modules/FxAccountsCommon.js"); +const { FxAccountsWebChannel, FxAccountsWebChannelHelpers } = + Cu.import("resource://gre/modules/FxAccountsWebChannel.jsm"); + +const URL_STRING = "https://example.com"; + +const mockSendingContext = { + browser: {}, + principal: {}, + eventTarget: {} +}; + +add_test(function () { + validationHelper(undefined, + "Error: Missing configuration options"); + + validationHelper({ + channel_id: WEBCHANNEL_ID + }, + "Error: Missing 'content_uri' option"); + + validationHelper({ + content_uri: 'bad uri', + channel_id: WEBCHANNEL_ID + }, + /NS_ERROR_MALFORMED_URI/); + + validationHelper({ + content_uri: URL_STRING + }, + 'Error: Missing \'channel_id\' option'); + + run_next_test(); +}); + +add_task(function* test_rejection_reporting() { + let mockMessage = { + command: 'fxaccounts:login', + messageId: '1234', + data: { email: 'testuser@testuser.com' }, + }; + + let channel = new FxAccountsWebChannel({ + channel_id: WEBCHANNEL_ID, + content_uri: URL_STRING, + helpers: { + login(accountData) { + equal(accountData.email, 'testuser@testuser.com', + 'Should forward incoming message data to the helper'); + return Promise.reject(new Error('oops')); + }, + }, + }); + + let promiseSend = new Promise(resolve => { + channel._channel.send = (message, context) => { + resolve({ message, context }); + }; + }); + + channel._channelCallback(WEBCHANNEL_ID, mockMessage, mockSendingContext); + + let { message, context } = yield promiseSend; + + equal(context, mockSendingContext, 'Should forward the original context'); + equal(message.command, 'fxaccounts:login', + 'Should include the incoming command'); + equal(message.messageId, '1234', 'Should include the message ID'); + equal(message.data.error.message, 'Error: oops', + 'Should convert the error message to a string'); + notStrictEqual(message.data.error.stack, null, + 'Should include the stack for JS error rejections'); +}); + +add_test(function test_exception_reporting() { + let mockMessage = { + command: 'fxaccounts:sync_preferences', + messageId: '5678', + data: { entryPoint: 'fxa:verification_complete' } + }; + + let channel = new FxAccountsWebChannel({ + channel_id: WEBCHANNEL_ID, + content_uri: URL_STRING, + helpers: { + openSyncPreferences(browser, entryPoint) { + equal(entryPoint, 'fxa:verification_complete', + 'Should forward incoming message data to the helper'); + throw new TypeError('splines not reticulated'); + }, + }, + }); + + channel._channel.send = (message, context) => { + equal(context, mockSendingContext, 'Should forward the original context'); + equal(message.command, 'fxaccounts:sync_preferences', + 'Should include the incoming command'); + equal(message.messageId, '5678', 'Should include the message ID'); + equal(message.data.error.message, 'TypeError: splines not reticulated', + 'Should convert the exception to a string'); + notStrictEqual(message.data.error.stack, null, + 'Should include the stack for JS exceptions'); + + run_next_test(); + }; + + channel._channelCallback(WEBCHANNEL_ID, mockMessage, mockSendingContext); +}); + +add_test(function test_profile_image_change_message() { + var mockMessage = { + command: "profile:change", + data: { uid: "foo" } + }; + + makeObserver(ON_PROFILE_CHANGE_NOTIFICATION, function (subject, topic, data) { + do_check_eq(data, "foo"); + run_next_test(); + }); + + var channel = new FxAccountsWebChannel({ + channel_id: WEBCHANNEL_ID, + content_uri: URL_STRING + }); + + channel._channelCallback(WEBCHANNEL_ID, mockMessage, mockSendingContext); +}); + +add_test(function test_login_message() { + let mockMessage = { + command: 'fxaccounts:login', + data: { email: 'testuser@testuser.com' } + }; + + let channel = new FxAccountsWebChannel({ + channel_id: WEBCHANNEL_ID, + content_uri: URL_STRING, + helpers: { + login: function (accountData) { + do_check_eq(accountData.email, 'testuser@testuser.com'); + run_next_test(); + return Promise.resolve(); + } + } + }); + + channel._channelCallback(WEBCHANNEL_ID, mockMessage, mockSendingContext); +}); + +add_test(function test_logout_message() { + let mockMessage = { + command: 'fxaccounts:logout', + data: { uid: "foo" } + }; + + let channel = new FxAccountsWebChannel({ + channel_id: WEBCHANNEL_ID, + content_uri: URL_STRING, + helpers: { + logout: function (uid) { + do_check_eq(uid, 'foo'); + run_next_test(); + return Promise.resolve(); + } + } + }); + + channel._channelCallback(WEBCHANNEL_ID, mockMessage, mockSendingContext); +}); + +add_test(function test_delete_message() { + let mockMessage = { + command: 'fxaccounts:delete', + data: { uid: "foo" } + }; + + let channel = new FxAccountsWebChannel({ + channel_id: WEBCHANNEL_ID, + content_uri: URL_STRING, + helpers: { + logout: function (uid) { + do_check_eq(uid, 'foo'); + run_next_test(); + return Promise.resolve(); + } + } + }); + + channel._channelCallback(WEBCHANNEL_ID, mockMessage, mockSendingContext); +}); + +add_test(function test_can_link_account_message() { + let mockMessage = { + command: 'fxaccounts:can_link_account', + data: { email: 'testuser@testuser.com' } + }; + + let channel = new FxAccountsWebChannel({ + channel_id: WEBCHANNEL_ID, + content_uri: URL_STRING, + helpers: { + shouldAllowRelink: function (email) { + do_check_eq(email, 'testuser@testuser.com'); + run_next_test(); + } + } + }); + + channel._channelCallback(WEBCHANNEL_ID, mockMessage, mockSendingContext); +}); + +add_test(function test_sync_preferences_message() { + let mockMessage = { + command: 'fxaccounts:sync_preferences', + data: { entryPoint: 'fxa:verification_complete' } + }; + + let channel = new FxAccountsWebChannel({ + channel_id: WEBCHANNEL_ID, + content_uri: URL_STRING, + helpers: { + openSyncPreferences: function (browser, entryPoint) { + do_check_eq(entryPoint, 'fxa:verification_complete'); + do_check_eq(browser, mockSendingContext.browser); + run_next_test(); + } + } + }); + + channel._channelCallback(WEBCHANNEL_ID, mockMessage, mockSendingContext); +}); + +add_test(function test_unrecognized_message() { + let mockMessage = { + command: 'fxaccounts:unrecognized', + data: {} + }; + + let channel = new FxAccountsWebChannel({ + channel_id: WEBCHANNEL_ID, + content_uri: URL_STRING + }); + + // no error is expected. + channel._channelCallback(WEBCHANNEL_ID, mockMessage, mockSendingContext); + run_next_test(); +}); + + +add_test(function test_helpers_should_allow_relink_same_email() { + let helpers = new FxAccountsWebChannelHelpers(); + + helpers.setPreviousAccountNameHashPref('testuser@testuser.com'); + do_check_true(helpers.shouldAllowRelink('testuser@testuser.com')); + + run_next_test(); +}); + +add_test(function test_helpers_should_allow_relink_different_email() { + let helpers = new FxAccountsWebChannelHelpers(); + + helpers.setPreviousAccountNameHashPref('testuser@testuser.com'); + + helpers._promptForRelink = (acctName) => { + return acctName === 'allowed_to_relink@testuser.com'; + }; + + do_check_true(helpers.shouldAllowRelink('allowed_to_relink@testuser.com')); + do_check_false(helpers.shouldAllowRelink('not_allowed_to_relink@testuser.com')); + + run_next_test(); +}); + +add_task(function* test_helpers_login_without_customize_sync() { + let helpers = new FxAccountsWebChannelHelpers({ + fxAccounts: { + setSignedInUser: function(accountData) { + return new Promise(resolve => { + // ensure fxAccounts is informed of the new user being signed in. + do_check_eq(accountData.email, 'testuser@testuser.com'); + + // verifiedCanLinkAccount should be stripped in the data. + do_check_false('verifiedCanLinkAccount' in accountData); + + // the customizeSync pref should not update + do_check_false(helpers.getShowCustomizeSyncPref()); + + // previously signed in user preference is updated. + do_check_eq(helpers.getPreviousAccountNameHashPref(), helpers.sha256('testuser@testuser.com')); + + resolve(); + }); + } + } + }); + + // the show customize sync pref should stay the same + helpers.setShowCustomizeSyncPref(false); + + // ensure the previous account pref is overwritten. + helpers.setPreviousAccountNameHashPref('lastuser@testuser.com'); + + yield helpers.login({ + email: 'testuser@testuser.com', + verifiedCanLinkAccount: true, + customizeSync: false + }); +}); + +add_task(function* test_helpers_login_with_customize_sync() { + let helpers = new FxAccountsWebChannelHelpers({ + fxAccounts: { + setSignedInUser: function(accountData) { + return new Promise(resolve => { + // ensure fxAccounts is informed of the new user being signed in. + do_check_eq(accountData.email, 'testuser@testuser.com'); + + // customizeSync should be stripped in the data. + do_check_false('customizeSync' in accountData); + + // the customizeSync pref should not update + do_check_true(helpers.getShowCustomizeSyncPref()); + + resolve(); + }); + } + } + }); + + // the customize sync pref should be overwritten + helpers.setShowCustomizeSyncPref(false); + + yield helpers.login({ + email: 'testuser@testuser.com', + verifiedCanLinkAccount: true, + customizeSync: true + }); +}); + +add_task(function* test_helpers_login_with_customize_sync_and_declined_engines() { + let helpers = new FxAccountsWebChannelHelpers({ + fxAccounts: { + setSignedInUser: function(accountData) { + return new Promise(resolve => { + // ensure fxAccounts is informed of the new user being signed in. + do_check_eq(accountData.email, 'testuser@testuser.com'); + + // customizeSync should be stripped in the data. + do_check_false('customizeSync' in accountData); + do_check_false('declinedSyncEngines' in accountData); + do_check_eq(Services.prefs.getBoolPref("services.sync.engine.addons"), false); + do_check_eq(Services.prefs.getBoolPref("services.sync.engine.bookmarks"), true); + do_check_eq(Services.prefs.getBoolPref("services.sync.engine.history"), true); + do_check_eq(Services.prefs.getBoolPref("services.sync.engine.passwords"), true); + do_check_eq(Services.prefs.getBoolPref("services.sync.engine.prefs"), false); + do_check_eq(Services.prefs.getBoolPref("services.sync.engine.tabs"), true); + + // the customizeSync pref should be disabled + do_check_false(helpers.getShowCustomizeSyncPref()); + + resolve(); + }); + } + } + }); + + // the customize sync pref should be overwritten + helpers.setShowCustomizeSyncPref(true); + + do_check_eq(Services.prefs.getBoolPref("services.sync.engine.addons"), true); + do_check_eq(Services.prefs.getBoolPref("services.sync.engine.bookmarks"), true); + do_check_eq(Services.prefs.getBoolPref("services.sync.engine.history"), true); + do_check_eq(Services.prefs.getBoolPref("services.sync.engine.passwords"), true); + do_check_eq(Services.prefs.getBoolPref("services.sync.engine.prefs"), true); + do_check_eq(Services.prefs.getBoolPref("services.sync.engine.tabs"), true); + yield helpers.login({ + email: 'testuser@testuser.com', + verifiedCanLinkAccount: true, + customizeSync: true, + declinedSyncEngines: ['addons', 'prefs'] + }); +}); + +add_test(function test_helpers_open_sync_preferences() { + let helpers = new FxAccountsWebChannelHelpers({ + fxAccounts: { + } + }); + + let mockBrowser = { + loadURI(uri) { + do_check_eq(uri, "about:preferences?entrypoint=fxa%3Averification_complete#sync"); + run_next_test(); + } + }; + + helpers.openSyncPreferences(mockBrowser, "fxa:verification_complete"); +}); + +add_task(function* test_helpers_change_password() { + let wasCalled = { + updateUserAccountData: false, + updateDeviceRegistration: false + }; + let helpers = new FxAccountsWebChannelHelpers({ + fxAccounts: { + updateUserAccountData(credentials) { + return new Promise(resolve => { + do_check_true(credentials.hasOwnProperty("email")); + do_check_true(credentials.hasOwnProperty("uid")); + do_check_true(credentials.hasOwnProperty("kA")); + do_check_true(credentials.hasOwnProperty("deviceId")); + do_check_null(credentials.deviceId); + // "foo" isn't a field known by storage, so should be dropped. + do_check_false(credentials.hasOwnProperty("foo")); + wasCalled.updateUserAccountData = true; + + resolve(); + }); + }, + + updateDeviceRegistration() { + do_check_eq(arguments.length, 0); + wasCalled.updateDeviceRegistration = true; + return Promise.resolve() + } + } + }); + yield helpers.changePassword({ email: "email", uid: "uid", kA: "kA", foo: "foo" }); + do_check_true(wasCalled.updateUserAccountData); + do_check_true(wasCalled.updateDeviceRegistration); +}); + +add_task(function* test_helpers_change_password_with_error() { + let wasCalled = { + updateUserAccountData: false, + updateDeviceRegistration: false + }; + let helpers = new FxAccountsWebChannelHelpers({ + fxAccounts: { + updateUserAccountData() { + wasCalled.updateUserAccountData = true; + return Promise.reject(); + }, + + updateDeviceRegistration() { + wasCalled.updateDeviceRegistration = true; + return Promise.resolve() + } + } + }); + try { + yield helpers.changePassword({}); + do_check_false('changePassword should have rejected'); + } catch (_) { + do_check_true(wasCalled.updateUserAccountData); + do_check_false(wasCalled.updateDeviceRegistration); + } +}); + +function run_test() { + run_next_test(); +} + +function makeObserver(aObserveTopic, aObserveFunc) { + let callback = function (aSubject, aTopic, aData) { + log.debug("observed " + aTopic + " " + aData); + if (aTopic == aObserveTopic) { + removeMe(); + aObserveFunc(aSubject, aTopic, aData); + } + }; + + function removeMe() { + log.debug("removing observer for " + aObserveTopic); + Services.obs.removeObserver(callback, aObserveTopic); + } + + Services.obs.addObserver(callback, aObserveTopic, false); + return removeMe; +} + +function validationHelper(params, expected) { + try { + new FxAccountsWebChannel(params); + } catch (e) { + if (typeof expected === 'string') { + return do_check_eq(e.toString(), expected); + } else { + return do_check_true(e.toString().match(expected)); + } + } + throw new Error("Validation helper error"); +} diff --git a/services/fxaccounts/tests/xpcshell/xpcshell.ini b/services/fxaccounts/tests/xpcshell/xpcshell.ini new file mode 100644 index 000000000..56a3d2947 --- /dev/null +++ b/services/fxaccounts/tests/xpcshell/xpcshell.ini @@ -0,0 +1,23 @@ +[DEFAULT] +head = head.js ../../../common/tests/unit/head_helpers.js ../../../common/tests/unit/head_http.js +tail = +skip-if = (toolkit == 'android' || appname == 'thunderbird') +support-files = + !/services/common/tests/unit/head_helpers.js + !/services/common/tests/unit/head_http.js + +[test_accounts.js] +[test_accounts_device_registration.js] +[test_client.js] +[test_credentials.js] +[test_loginmgr_storage.js] +[test_oauth_client.js] +[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_push_service.js] +[test_web_channel.js] +[test_profile.js] +[test_storage_manager.js] |