summaryrefslogtreecommitdiffstats
path: root/services/fxaccounts/FxAccounts.jsm
diff options
context:
space:
mode:
Diffstat (limited to 'services/fxaccounts/FxAccounts.jsm')
-rw-r--r--services/fxaccounts/FxAccounts.jsm1735
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;
+});