diff options
Diffstat (limited to 'services/fxaccounts/FxAccounts.jsm')
-rw-r--r-- | services/fxaccounts/FxAccounts.jsm | 1735 |
1 files changed, 1735 insertions, 0 deletions
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; +}); |