summaryrefslogtreecommitdiffstats
path: root/services/fxaccounts/FxAccounts.sys.mjs
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--services/fxaccounts/FxAccounts.sys.mjs1657
1 files changed, 1657 insertions, 0 deletions
diff --git a/services/fxaccounts/FxAccounts.sys.mjs b/services/fxaccounts/FxAccounts.sys.mjs
new file mode 100644
index 0000000000..18169c6b2d
--- /dev/null
+++ b/services/fxaccounts/FxAccounts.sys.mjs
@@ -0,0 +1,1657 @@
+/* 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/. */
+
+import { CryptoUtils } from "resource://services-crypto/utils.sys.mjs";
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+import { clearTimeout, setTimeout } from "resource://gre/modules/Timer.sys.mjs";
+
+import { FxAccountsStorageManager } from "resource://gre/modules/FxAccountsStorage.sys.mjs";
+
+import {
+ ERRNO_INVALID_AUTH_TOKEN,
+ ERROR_AUTH_ERROR,
+ ERROR_INVALID_PARAMETER,
+ ERROR_NO_ACCOUNT,
+ ERROR_TO_GENERAL_ERROR_CLASS,
+ ERROR_UNKNOWN,
+ ERROR_UNVERIFIED_ACCOUNT,
+ FXA_PWDMGR_PLAINTEXT_FIELDS,
+ FXA_PWDMGR_REAUTH_ALLOWLIST,
+ FXA_PWDMGR_SECURE_FIELDS,
+ FX_OAUTH_CLIENT_ID,
+ ON_ACCOUNT_STATE_CHANGE_NOTIFICATION,
+ ONLOGIN_NOTIFICATION,
+ ONLOGOUT_NOTIFICATION,
+ ON_PRELOGOUT_NOTIFICATION,
+ ONVERIFIED_NOTIFICATION,
+ ON_DEVICE_DISCONNECTED_NOTIFICATION,
+ POLL_SESSION,
+ PREF_ACCOUNT_ROOT,
+ PREF_LAST_FXA_USER,
+ SERVER_ERRNO_TO_ERROR,
+ log,
+ logPII,
+ logManager,
+} from "resource://gre/modules/FxAccountsCommon.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ FxAccountsClient: "resource://gre/modules/FxAccountsClient.sys.mjs",
+ FxAccountsCommands: "resource://gre/modules/FxAccountsCommands.sys.mjs",
+ FxAccountsConfig: "resource://gre/modules/FxAccountsConfig.sys.mjs",
+ FxAccountsDevice: "resource://gre/modules/FxAccountsDevice.sys.mjs",
+ FxAccountsKeys: "resource://gre/modules/FxAccountsKeys.sys.mjs",
+ FxAccountsOAuth: "resource://gre/modules/FxAccountsOAuth.sys.mjs",
+ FxAccountsProfile: "resource://gre/modules/FxAccountsProfile.sys.mjs",
+ FxAccountsTelemetry: "resource://gre/modules/FxAccountsTelemetry.sys.mjs",
+});
+
+ChromeUtils.defineLazyGetter(lazy, "mpLocked", () => {
+ return ChromeUtils.importESModule("resource://services-sync/util.sys.mjs")
+ .Utils.mpLocked;
+});
+
+ChromeUtils.defineLazyGetter(lazy, "ensureMPUnlocked", () => {
+ return ChromeUtils.importESModule("resource://services-sync/util.sys.mjs")
+ .Utils.ensureMPUnlocked;
+});
+
+XPCOMUtils.defineLazyPreferenceGetter(
+ lazy,
+ "FXA_ENABLED",
+ "identity.fxaccounts.enabled",
+ true
+);
+
+// An AccountState object holds all state related to one specific account.
+// It is considered "private" to the FxAccounts modules.
+// 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.
+export function AccountState(storageManager) {
+ this.storageManager = storageManager;
+ this.inFlightTokenRequests = new Map();
+ 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("Key fetching aborted; Another user signing in")
+ );
+ this.whenKeysReadyDeferred = null;
+ }
+ this.inFlightTokenRequests.clear();
+ return this.signOut();
+ },
+
+ // Clobber all cached data and write that empty data to storage.
+ async signOut() {
+ this.cert = null;
+ this.keyPair = null;
+ this.oauthTokens = null;
+ this.inFlightTokenRequests.clear();
+
+ // Avoid finalizing the storageManager multiple times (ie, .signOut()
+ // followed by .abort())
+ if (!this.storageManager) {
+ return;
+ }
+ const storageManager = this.storageManager;
+ this.storageManager = null;
+
+ await storageManager.deleteAccountData();
+ await 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);
+ });
+ },
+
+ async updateUserAccountData(updatedFields) {
+ if ("uid" in updatedFields) {
+ const existing = await this.getUserAccountData(["uid"]);
+ if (existing.uid != updatedFields.uid) {
+ throw new Error(
+ "The specified credentials aren't for the current user"
+ );
+ }
+ // We need to nuke uid as storage will complain if we try and
+ // update it (even when the value is the same)
+ updatedFields = Cu.cloneInto(updatedFields, {}); // clone it first
+ delete updatedFields.uid;
+ }
+ if (!this.isCurrent) {
+ return Promise.reject(new Error("Another user has signed in"));
+ }
+ return this.storageManager.updateAccountData(updatedFields);
+ },
+
+ resolve(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(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.
+ // 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("|");
+}
+
+function getPropertyDescriptor(obj, prop) {
+ return (
+ Object.getOwnPropertyDescriptor(obj, prop) ||
+ getPropertyDescriptor(Object.getPrototypeOf(obj), prop)
+ );
+}
+
+/**
+ * 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 thisObj (object)
+ * The object that will be used to .bind() all function properties we find to.
+ * @param keys ([...])
+ * The names of all properties to be copied.
+ */
+function copyObjectProperties(from, to, thisObj, keys) {
+ for (let prop of keys) {
+ // Look for the prop in the prototype chain.
+ let desc = getPropertyDescriptor(from, prop);
+
+ if (typeof desc.value == "function") {
+ desc.value = desc.value.bind(thisObj);
+ }
+
+ if (desc.get) {
+ desc.get = desc.get.bind(thisObj);
+ }
+
+ if (desc.set) {
+ desc.set = desc.set.bind(thisObj);
+ }
+
+ Object.defineProperty(to, prop, desc);
+ }
+}
+
+/**
+ * The public API.
+ *
+ * TODO - *all* non-underscore stuff here should have sphinx docstrings so
+ * that docs magically appear on https://firefox-source-docs.mozilla.org/
+ * (although |./mach doc| is broken on windows (bug 1232403) and on Linux for
+ * markh (some obscure npm issue he gave up on) - so later...)
+ */
+export class FxAccounts {
+ constructor(mocks = null) {
+ this._internal = new FxAccountsInternal();
+ if (mocks) {
+ // it's slightly unfortunate that we need to mock the main "internal" object
+ // before calling initialize, primarily so a mock `newAccountState` is in
+ // place before initialize calls it, but we need to initialize the
+ // "sub-object" mocks after. This can probably be fixed, but whatever...
+ copyObjectProperties(
+ mocks,
+ this._internal,
+ this._internal,
+ Object.keys(mocks).filter(key => !["device", "commands"].includes(key))
+ );
+ }
+ this._internal.initialize();
+ // allow mocking our "sub-objects" too.
+ if (mocks) {
+ for (let subobject of [
+ "currentAccountState",
+ "keys",
+ "fxaPushService",
+ "device",
+ "commands",
+ ]) {
+ if (typeof mocks[subobject] == "object") {
+ copyObjectProperties(
+ mocks[subobject],
+ this._internal[subobject],
+ this._internal[subobject],
+ Object.keys(mocks[subobject])
+ );
+ }
+ }
+ }
+ }
+
+ get commands() {
+ return this._internal.commands;
+ }
+
+ static get config() {
+ return lazy.FxAccountsConfig;
+ }
+
+ get device() {
+ return this._internal.device;
+ }
+
+ get keys() {
+ return this._internal.keys;
+ }
+
+ get telemetry() {
+ return this._internal.telemetry;
+ }
+
+ _withCurrentAccountState(func) {
+ return this._internal.withCurrentAccountState(func);
+ }
+
+ _withVerifiedAccountState(func) {
+ return this._internal.withVerifiedAccountState(func);
+ }
+
+ _withSessionToken(func, mustBeVerified = true) {
+ return this._internal.withSessionToken(func, mustBeVerified);
+ }
+
+ /**
+ * Returns an array listing all the OAuth clients connected to the
+ * authenticated user's account. This includes browsers and web sessions - no
+ * filtering is done of the set returned by the FxA server.
+ *
+ * @typedef {Object} AttachedClient
+ * @property {String} id - OAuth `client_id` of the client.
+ * @property {Number} lastAccessedDaysAgo - How many days ago the client last
+ * accessed the FxA server APIs.
+ *
+ * @returns {Array.<AttachedClient>} A list of attached clients.
+ */
+ async listAttachedOAuthClients() {
+ // We expose last accessed times in 'days ago'
+ const ONE_DAY = 24 * 60 * 60 * 1000;
+
+ return this._withSessionToken(async sessionToken => {
+ const response = await this._internal.fxAccountsClient.attachedClients(
+ sessionToken
+ );
+ const attachedClients = response.body;
+ const timestamp = response.headers["x-timestamp"];
+ const now =
+ timestamp !== undefined
+ ? new Date(parseInt(timestamp, 10))
+ : Date.now();
+ return attachedClients.map(client => {
+ const daysAgo = client.lastAccessTime
+ ? Math.max(Math.floor((now - client.lastAccessTime) / ONE_DAY), 0)
+ : null;
+ return {
+ id: client.clientId,
+ lastAccessedDaysAgo: daysAgo,
+ };
+ });
+ });
+ }
+
+ /**
+ * 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.
+ * ttl: (number) OAuth token TTL in seconds.
+ * }
+ *
+ * @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
+ */
+ async getOAuthToken(options = {}) {
+ try {
+ return await this._internal.getOAuthToken(options);
+ } catch (err) {
+ throw this._internal._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(options) {
+ return this._internal.removeCachedOAuthToken(options);
+ }
+
+ /**
+ * Get details about the user currently signed in to Firefox Accounts.
+ *
+ * @return Promise
+ * The promise resolves to the credentials object of the signed-in user:
+ * {
+ * email: String: The user's email address
+ * uid: String: The user's unique id
+ * verified: Boolean: email verification status
+ * displayName: String or null if not known.
+ * avatar: URL of the avatar for the user. May be the default
+ * avatar, or null in edge-cases (eg, if there's an account
+ * issue, etc
+ * avatarDefault: boolean - whether `avatar` is specific to the user
+ * or the default avatar.
+ * }
+ *
+ * or null if no user is signed in. This function never fails except
+ * in pathological cases (eg, file-system errors, etc)
+ */
+ getSignedInUser() {
+ // Note we don't return the session token, but use it to see if we
+ // should fetch the profile.
+ const ACCT_DATA_FIELDS = ["email", "uid", "verified", "sessionToken"];
+ const PROFILE_FIELDS = ["displayName", "avatar", "avatarDefault"];
+ return this._withCurrentAccountState(async currentState => {
+ const data = await currentState.getUserAccountData(ACCT_DATA_FIELDS);
+ if (!data) {
+ return null;
+ }
+ if (!lazy.FXA_ENABLED) {
+ await this.signOut();
+ return null;
+ }
+ if (!this._internal.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._internal.startVerifiedCheck(data);
+ }
+
+ let profileData = null;
+ if (data.sessionToken) {
+ delete data.sessionToken;
+ try {
+ profileData = await this._internal.profile.getProfile();
+ } catch (error) {
+ log.error("Could not retrieve profile data", error);
+ }
+ }
+ for (let field of PROFILE_FIELDS) {
+ data[field] = profileData ? profileData[field] : null;
+ }
+ // and email is a special case - if we have profile data we prefer the
+ // email from that, as the email we stored for the account itself might
+ // not have been updated if the email changed since the user signed in.
+ if (profileData && profileData.email) {
+ data.email = profileData.email;
+ }
+ return data;
+ });
+ }
+
+ /**
+ * Checks the status of the account. Resolves with Promise<boolean>, where
+ * true indicates the account status is OK and false indicates there's some
+ * issue with the account - either that there's no user currently signed in,
+ * the entire account has been deleted (in which case there will be no user
+ * signed in after this call returns), or that the user must reauthenticate (in
+ * which case `this.hasLocalSession()` will return `false` after this call
+ * returns).
+ *
+ * Typically used when some external code which uses, for example, oauth tokens
+ * received a 401 error using the token, or that this external code has some
+ * other reason to believe the account status may be bad. Note that this will
+ * be called automatically in many cases - for example, if calls to fetch the
+ * profile, or fetch keys, etc return a 401, there's no need to call this
+ * function.
+ *
+ * Because this hits the server, you should only call this method when you have
+ * good reason to believe the session very recently became invalid (eg, because
+ * you saw an auth related exception from a remote service.)
+ */
+ checkAccountStatus() {
+ // Note that we don't use _withCurrentAccountState here because that will
+ // cause an exception to be thrown if we end up signing out due to the
+ // account not existing, which isn't what we want here.
+ let state = this._internal.currentAccountState;
+ return this._internal.checkAccountStatus(state);
+ }
+
+ /**
+ * Checks if we have a valid local session state for the current account.
+ *
+ * @return Promise
+ * Resolves with a boolean, with true indicating that we appear to
+ * have a valid local session, or false if we need to reauthenticate
+ * with the content server to obtain one.
+ * Note that this only checks local state, although typically that's
+ * OK, because we drop the local session information whenever we detect
+ * we are in this state. However, see checkAccountStatus() for a way to
+ * check the account and session status with the server, which can be
+ * considered the canonical, albiet expensive, way to determine the
+ * status of the account.
+ */
+ hasLocalSession() {
+ return this._withCurrentAccountState(async state => {
+ let data = await state.getUserAccountData(["sessionToken"]);
+ return !!(data && data.sessionToken);
+ });
+ }
+
+ /** Returns a promise that resolves to true if we can currently connect (ie,
+ * sign in, or re-connect after a password change) to a Firefox Account.
+ * If this returns false, the caller can assume that some UI was shown
+ * which tells the user why we could not connect.
+ *
+ * Currently, the primary password being locked is the only reason why
+ * this returns false, and in this scenario, the primary password unlock
+ * dialog will have been shown.
+ *
+ * This currently doesn't need to return a promise, but does so that
+ * future enhancements, such as other explanatory UI which requires
+ * async can work without modification of the call-sites.
+ */
+ static canConnectAccount() {
+ return Promise.resolve(!lazy.mpLocked() || lazy.ensureMPUnlocked());
+ }
+
+ /**
+ * Send a message to a set of devices in the same account
+ *
+ * @param deviceIds: (null/string/array) The device IDs to send the message to.
+ * If null, will be sent to all devices.
+ *
+ * @param excludedIds: (null/string/array) If deviceIds is null, this may
+ * list device IDs which should not receive the message.
+ *
+ * @param payload: (object) The payload, which will be JSON.stringified.
+ *
+ * @param TTL: How long the message should be retained before it is discarded.
+ */
+ // XXX - used only by sync to tell other devices that the clients collection
+ // has changed so they should sync asap. The API here is somewhat vague (ie,
+ // "an object"), but to be useful across devices, the payload really needs
+ // formalizing. We should try and do something better here.
+ notifyDevices(deviceIds, excludedIds, payload, TTL) {
+ return this._internal.notifyDevices(deviceIds, excludedIds, payload, TTL);
+ }
+
+ /**
+ * Resend the verification email for the currently signed-in user.
+ *
+ */
+ resendVerificationEmail() {
+ return this._withSessionToken((token, currentState) => {
+ this._internal.startPollEmailStatus(currentState, token, "start");
+ return this._internal.fxAccountsClient.resendVerificationEmail(token);
+ }, false);
+ }
+
+ async signOut(localOnly) {
+ // Note that we do not use _withCurrentAccountState here, otherwise we
+ // end up with an exception due to the user signing out before the call is
+ // complete - but that's the entire point of this method :)
+ return this._internal.signOut(localOnly);
+ }
+
+ // XXX - we should consider killing this - the only reason it is public is
+ // so that sync can change it when it notices the device name being changed,
+ // and that could probably be replaced with a pref observer.
+ updateDeviceRegistration() {
+ return this._withCurrentAccountState(_ => {
+ return this._internal.updateDeviceRegistration();
+ });
+ }
+
+ // we should try and kill this too.
+ whenVerified(data) {
+ return this._withCurrentAccountState(_ => {
+ return this._internal.whenVerified(data);
+ });
+ }
+
+ /**
+ * Generate a log file for the FxA action that just completed
+ * and refresh the input & output streams.
+ */
+ async flushLogFile() {
+ const logType = await logManager.resetFileLog();
+ if (logType == logManager.ERROR_LOG_WRITTEN) {
+ console.error(
+ "FxA encountered an error - see about:sync-log for the log file."
+ );
+ }
+ Services.obs.notifyObservers(null, "service:log-manager:flush-log-file");
+ }
+}
+
+var FxAccountsInternal = function () {};
+
+/**
+ * The internal API's prototype.
+ */
+FxAccountsInternal.prototype = {
+ // Make a local copy of this constant so we can mock it in testing
+ POLL_SESSION,
+
+ // The timeout (in ms) we use to poll for a verified mail for the first
+ // VERIFICATION_POLL_START_SLOWDOWN_THRESHOLD minutes if the user has
+ // logged-in in this session.
+ VERIFICATION_POLL_TIMEOUT_INITIAL: 60000, // 1 minute.
+ // All the other cases (> 5 min, on restart etc).
+ VERIFICATION_POLL_TIMEOUT_SUBSEQUENT: 5 * 60000, // 5 minutes.
+ // After X minutes, the polling will slow down to _SUBSEQUENT if we have
+ // logged-in in this session.
+ VERIFICATION_POLL_START_SLOWDOWN_THRESHOLD: 5,
+
+ _fxAccountsClient: null,
+
+ // All significant initialization should be done in this initialize() method
+ // to help with our mocking story.
+ initialize() {
+ ChromeUtils.defineLazyGetter(this, "fxaPushService", function () {
+ return Cc["@mozilla.org/fxaccounts/push;1"].getService(
+ Ci.nsISupports
+ ).wrappedJSObject;
+ });
+
+ this.keys = new lazy.FxAccountsKeys(this);
+
+ if (!this.observerPreloads) {
+ // A registry of promise-returning functions that `notifyObservers` should
+ // call before sending notifications. Primarily used so parts of Firefox
+ // which have yet to load for performance reasons can be force-loaded, and
+ // thus not miss notifications.
+ this.observerPreloads = [
+ // Sync
+ () => {
+ let { Weave } = ChromeUtils.importESModule(
+ "resource://services-sync/main.sys.mjs"
+ );
+ return Weave.Service.promiseInitialized;
+ },
+ ];
+ }
+
+ this.currentTimer = null;
+ // This object holds details about, and storage for, the current user. It
+ // is replaced when a different user signs in. Instead of using it directly,
+ // you should try and use `withCurrentAccountState`.
+ this.currentAccountState = this.newAccountState();
+ },
+
+ async withCurrentAccountState(func) {
+ const state = this.currentAccountState;
+ let result;
+ try {
+ result = await func(state);
+ } catch (ex) {
+ return state.reject(ex);
+ }
+ return state.resolve(result);
+ },
+
+ async withVerifiedAccountState(func) {
+ return this.withCurrentAccountState(async state => {
+ let data = await state.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);
+ }
+ return func(state);
+ });
+ },
+
+ async withSessionToken(func, mustBeVerified = true) {
+ const state = this.currentAccountState;
+ let data = await state.getUserAccountData();
+ if (!data) {
+ // No signed-in user
+ throw this._error(ERROR_NO_ACCOUNT);
+ }
+
+ if (mustBeVerified && !this.isUserEmailVerified(data)) {
+ // Signed-in user has not verified email
+ throw this._error(ERROR_UNVERIFIED_ACCOUNT);
+ }
+
+ if (!data.sessionToken) {
+ throw this._error(ERROR_AUTH_ERROR, "no session token");
+ }
+ try {
+ // Anyone who needs the session token is going to send it to the server,
+ // so there's a chance we'll see an auth related error - so handle that
+ // here rather than requiring each caller to remember to.
+ let result = await func(data.sessionToken, state);
+ return state.resolve(result);
+ } catch (err) {
+ return this._handleTokenError(err);
+ }
+ },
+
+ get fxAccountsClient() {
+ if (!this._fxAccountsClient) {
+ this._fxAccountsClient = new lazy.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 lazy.FxAccountsProfile({
+ fxa: this,
+ profileServerUrl,
+ });
+ }
+ return this._profile;
+ },
+
+ _commands: null,
+ get commands() {
+ if (!this._commands) {
+ this._commands = new lazy.FxAccountsCommands(this);
+ }
+ return this._commands;
+ },
+
+ _device: null,
+ get device() {
+ if (!this._device) {
+ this._device = new lazy.FxAccountsDevice(this);
+ }
+ return this._device;
+ },
+
+ _oauth: null,
+ get oauth() {
+ if (!this._oauth) {
+ this._oauth = new lazy.FxAccountsOAuth(this.fxAccountsClient);
+ }
+ return this._oauth;
+ },
+
+ _telemetry: null,
+ get telemetry() {
+ if (!this._telemetry) {
+ this._telemetry = new lazy.FxAccountsTelemetry(this);
+ }
+ return this._telemetry;
+ },
+
+ beginOAuthFlow(scopes) {
+ return this.oauth.beginOAuthFlow(scopes);
+ },
+
+ completeOAuthFlow(sessionToken, code, state) {
+ return this.oauth.completeOAuthFlow(sessionToken, code, state);
+ },
+
+ setScopedKeys(scopedKeys) {
+ return this.keys.setScopedKeys(scopedKeys);
+ },
+
+ // 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);
+ },
+
+ notifyDevices(deviceIds, excludedIds, payload, TTL) {
+ if (typeof deviceIds == "string") {
+ deviceIds = [deviceIds];
+ }
+ return this.withSessionToken(sessionToken => {
+ return this.fxAccountsClient.notifyDevices(
+ sessionToken,
+ deviceIds,
+ excludedIds,
+ payload,
+ TTL
+ );
+ });
+ },
+
+ /**
+ * Return the current time in milliseconds as an integer. Allows tests to
+ * manipulate the date to simulate token expiration.
+ */
+ now() {
+ return this.fxAccountsClient.now();
+ },
+
+ /**
+ * 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));
+ },
+
+ // 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()}
+
+ /**
+ * 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.
+ */
+ async setSignedInUser(credentials) {
+ if (!lazy.FXA_ENABLED) {
+ throw new Error("Cannot call setSignedInUser when FxA is disabled.");
+ }
+ for (const pref of Services.prefs.getChildList(PREF_ACCOUNT_ROOT)) {
+ Services.prefs.clearUserPref(pref);
+ }
+ log.debug("setSignedInUser - aborting any existing flows");
+ const signedInUser = await this.currentAccountState.getUserAccountData();
+ if (signedInUser) {
+ await this._signOutServer(
+ signedInUser.sessionToken,
+ signedInUser.oauthTokens
+ );
+ }
+ await this.abortExistingFlow();
+ 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 ;)
+ await currentAccountState.promiseInitialized;
+ // Starting point for polling if new user
+ if (!this.isUserEmailVerified(credentials)) {
+ this.startVerifiedCheck(credentials);
+ }
+ await this.notifyObservers(ONLOGIN_NOTIFICATION);
+ await this.updateDeviceRegistration();
+ 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 the |uid| field and it 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(() => {
+ if (!credentials.uid) {
+ throw new Error("The specified credentials have no uid");
+ }
+ return currentAccountState.updateUserAccountData(credentials);
+ });
+ },
+
+ /*
+ * Reset state such that any previous flow is canceled.
+ */
+ 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;
+ }
+ if (this._commands) {
+ this._commands = null;
+ }
+ if (this._device) {
+ this._device.reset();
+ }
+ // 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();
+ },
+
+ async checkVerificationStatus() {
+ log.trace("checkVerificationStatus");
+ let state = this.currentAccountState;
+ let data = await state.getUserAccountData();
+ 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.startPollEmailStatus(state, data.sessionToken, "push");
+ },
+
+ /** Destroyes an OAuth Token by sending a request to the FxA server
+ * @param { Object } tokenData: The token's data, with `tokenData.token` being the token itself
+ **/
+ destroyOAuthToken(tokenData) {
+ return this.fxAccountsClient.oauthDestroy(
+ FX_OAUTH_CLIENT_ID,
+ tokenData.token
+ );
+ },
+
+ _destroyAllOAuthTokens(tokenInfos) {
+ if (!tokenInfos) {
+ return Promise.resolve();
+ }
+ // let's just destroy them all in parallel...
+ let promises = [];
+ for (let tokenInfo of Object.values(tokenInfos)) {
+ promises.push(this.destroyOAuthToken(tokenInfo));
+ }
+ return Promise.all(promises);
+ },
+
+ async signOut(localOnly) {
+ let sessionToken;
+ let tokensToRevoke;
+ const data = await this.currentAccountState.getUserAccountData();
+ // Save the sessionToken, tokens before resetting them in _signOutLocal().
+ if (data) {
+ sessionToken = data.sessionToken;
+ tokensToRevoke = data.oauthTokens;
+ }
+ await this.notifyObservers(ON_PRELOGOUT_NOTIFICATION);
+ await this._signOutLocal();
+ if (!localOnly) {
+ // Do this in the background so *any* slow request won't
+ // block the local sign out.
+ Services.tm.dispatchToMainThread(async () => {
+ await this._signOutServer(sessionToken, tokensToRevoke);
+ lazy.FxAccountsConfig.resetConfigURLs();
+ 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.
+ lazy.FxAccountsConfig.resetConfigURLs();
+ }
+ return this.notifyObservers(ONLOGOUT_NOTIFICATION);
+ },
+
+ async _signOutLocal() {
+ for (const pref of Services.prefs.getChildList(PREF_ACCOUNT_ROOT)) {
+ Services.prefs.clearUserPref(pref);
+ }
+ await this.currentAccountState.signOut();
+ // this "aborts" this.currentAccountState but doesn't make a new one.
+ await this.abortExistingFlow();
+ this.currentAccountState = this.newAccountState();
+ return this.currentAccountState.promiseInitialized;
+ },
+
+ async _signOutServer(sessionToken, tokensToRevoke) {
+ log.debug("Unsubscribing from FxA push.");
+ try {
+ await this.fxaPushService.unsubscribe();
+ } catch (err) {
+ log.error("Could not unsubscribe from push.", err);
+ }
+ if (sessionToken) {
+ log.debug("Destroying session and device.");
+ try {
+ await this.fxAccountsClient.signOut(sessionToken, { service: "sync" });
+ } catch (err) {
+ log.error("Error during remote sign out of Firefox Accounts", err);
+ }
+ } else {
+ log.warn("Missing session token; skipping remote sign out");
+ }
+ log.debug("Destroying all OAuth tokens.");
+ try {
+ await this._destroyAllOAuthTokens(tokensToRevoke);
+ } catch (err) {
+ log.error("Error during destruction of oauth tokens during signout", err);
+ }
+ },
+
+ getUserAccountData(fieldNames = null) {
+ return this.currentAccountState.getUserAccountData(fieldNames);
+ },
+
+ isUserEmailVerified: function isUserEmailVerified(data) {
+ return !!(data && data.verified);
+ },
+
+ /**
+ * Setup for and if necessary do email verification polling.
+ */
+ loadAndPoll() {
+ let currentState = this.currentAccountState;
+ return currentState.getUserAccountData().then(data => {
+ if (data) {
+ if (!this.isUserEmailVerified(data)) {
+ this.startPollEmailStatus(
+ currentState,
+ data.sessionToken,
+ "browser-startup"
+ );
+ }
+ }
+ return data;
+ });
+ },
+
+ startVerifiedCheck(data) {
+ log.debug("startVerifiedCheck", data && data.verified);
+ if (logPII()) {
+ log.debug("startVerifiedCheck with user data", data);
+ }
+
+ // Get us to the verified state. This returns a promise that will fire when
+ // verification is complete.
+
+ // 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).catch(err =>
+ log.info("startVerifiedCheck promise was rejected: " + err)
+ );
+ },
+
+ whenVerified(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.startPollEmailStatus(currentState, data.sessionToken, "start");
+ }
+ return currentState.whenVerifiedDeferred.promise.then(result =>
+ currentState.resolve(result)
+ );
+ },
+
+ async notifyObservers(topic, data) {
+ for (let f of this.observerPreloads) {
+ try {
+ await f();
+ } catch (O_o) {}
+ }
+ log.debug("Notifying observers of " + topic);
+ Services.obs.notifyObservers(null, topic, data);
+ },
+
+ startPollEmailStatus(currentState, sessionToken, why) {
+ log.debug("entering startPollEmailStatus: " + why);
+ // 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.
+ if (this.currentTimer) {
+ log.debug(
+ "startPollEmailStatus starting while existing timer is running"
+ );
+ clearTimeout(this.currentTimer);
+ this.currentTimer = null;
+ }
+
+ this.pollStartDate = Date.now();
+ if (!currentState.whenVerifiedDeferred) {
+ currentState.whenVerifiedDeferred = Promise.withResolvers();
+ // 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(
+ () => {
+ log.info("the user became verified");
+ // 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);
+ },
+ err => {
+ log.info("the wait for user verification was stopped: " + err);
+ }
+ );
+ }
+ return this.pollEmailStatus(currentState, sessionToken, why);
+ },
+
+ // We return a promise for testing only. Other callers can ignore this,
+ // since verification polling continues in the background.
+ async pollEmailStatus(currentState, sessionToken, why) {
+ log.debug("entering pollEmailStatus: " + why);
+ let nextPollMs;
+ try {
+ const response = await this.checkEmailStatus(sessionToken, {
+ reason: why,
+ });
+ log.debug("checkEmailStatus -> " + JSON.stringify(response));
+ if (response && response.verified) {
+ await this.onPollEmailSuccess(currentState);
+ return;
+ }
+ } catch (error) {
+ if (error && error.code && error.code == 401) {
+ let error = new Error("Verification status check failed");
+ this._rejectWhenVerified(currentState, error);
+ return;
+ }
+ if (error && error.retryAfter) {
+ // If the server told us to back off, back off the requested amount.
+ nextPollMs = (error.retryAfter + 3) * 1000;
+ log.warn(
+ `the server rejected our email status check and told us to try again in ${nextPollMs}ms`
+ );
+ } else {
+ log.error(`checkEmailStatus failed to poll`, error);
+ }
+ }
+ if (why == "push") {
+ return;
+ }
+ let pollDuration = Date.now() - this.pollStartDate;
+ // Polling session expired.
+ if (pollDuration >= 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;
+ }
+ // Poll email status again after a short delay.
+ if (nextPollMs === undefined) {
+ let currentMinute = Math.ceil(pollDuration / 60000);
+ nextPollMs =
+ why == "start" &&
+ currentMinute < this.VERIFICATION_POLL_START_SLOWDOWN_THRESHOLD
+ ? this.VERIFICATION_POLL_TIMEOUT_INITIAL
+ : this.VERIFICATION_POLL_TIMEOUT_SUBSEQUENT;
+ }
+ this._scheduleNextPollEmailStatus(
+ currentState,
+ sessionToken,
+ nextPollMs,
+ why
+ );
+ },
+
+ // Easy-to-mock testable method
+ _scheduleNextPollEmailStatus(currentState, sessionToken, nextPollMs, why) {
+ log.debug("polling with timeout = " + nextPollMs);
+ this.currentTimer = setTimeout(() => {
+ this.pollEmailStatus(currentState, sessionToken, why);
+ }, nextPollMs);
+ },
+
+ async onPollEmailSuccess(currentState) {
+ try {
+ await currentState.updateUserAccountData({ verified: true });
+ const accountData = await currentState.getUserAccountData();
+ this._setLastUserPref(accountData.email);
+ // Now that the user is verified, we can proceed to fetch keys
+ if (currentState.whenVerifiedDeferred) {
+ currentState.whenVerifiedDeferred.resolve(accountData);
+ delete currentState.whenVerifiedDeferred;
+ }
+ } catch (e) {
+ log.error(e);
+ }
+ },
+
+ _rejectWhenVerified(currentState, error) {
+ currentState.whenVerifiedDeferred.reject(error);
+ delete currentState.whenVerifiedDeferred;
+ },
+
+ /**
+ * Does the actual fetch of an oauth token for getOAuthToken()
+ * using the account session token.
+ *
+ * It's split out into a separate method so that we can easily
+ * stash in-flight calls in a cache.
+ *
+ * @param {String} scopeString
+ * @param {Number} ttl
+ * @returns {Promise<string>}
+ * @private
+ */
+ async _doTokenFetchWithSessionToken(sessionToken, scopeString, ttl) {
+ const result = await this.fxAccountsClient.accessTokenWithSessionToken(
+ sessionToken,
+ FX_OAUTH_CLIENT_ID,
+ scopeString,
+ ttl
+ );
+ return result.access_token;
+ },
+
+ getOAuthToken(options = {}) {
+ log.debug("getOAuthToken enter");
+ let scope = options.scope;
+ if (typeof scope === "string") {
+ scope = [scope];
+ }
+
+ if (!scope || !scope.length) {
+ return Promise.reject(
+ this._error(
+ ERROR_INVALID_PARAMETER,
+ "Missing or invalid 'scope' option"
+ )
+ );
+ }
+
+ return this.withSessionToken(async (sessionToken, currentState) => {
+ // Early exit for a cached token.
+ let cached = currentState.getCachedToken(scope);
+ if (cached) {
+ log.debug("getOAuthToken returning a cached token");
+ return cached.token;
+ }
+
+ // Build the string we use in our "inflight" map and that we send to the
+ // server. Because it's used as a key in the map we sort the scopes.
+ let scopeString = scope.sort().join(" ");
+
+ // We keep a map of in-flight requests to avoid multiple promise-based
+ // consumers concurrently requesting the same token.
+ let maybeInFlight = currentState.inFlightTokenRequests.get(scopeString);
+ if (maybeInFlight) {
+ log.debug("getOAuthToken has an in-flight request for this scope");
+ return maybeInFlight;
+ }
+
+ // We need to start a new fetch and stick the promise in our in-flight map
+ // and remove it when it resolves.
+ let promise = this._doTokenFetchWithSessionToken(
+ sessionToken,
+ scopeString,
+ options.ttl
+ )
+ .then(token => {
+ // As a sanity check, ensure something else hasn't raced getting a token
+ // of the same scope. If something has we just make noise rather than
+ // taking any concrete action because it should never actually happen.
+ if (currentState.getCachedToken(scope)) {
+ log.error(`detected a race for oauth token with scope ${scope}`);
+ }
+ // If we got one, cache it.
+ if (token) {
+ let entry = { token };
+ currentState.setCachedToken(scope, entry);
+ }
+ return token;
+ })
+ .finally(() => {
+ // Remove ourself from the in-flight map. There's no need to check the
+ // result of .delete() to handle a signout race, because setCachedToken
+ // above will fail in that case and cause the entire call to fail.
+ currentState.inFlightTokenRequests.delete(scopeString);
+ });
+
+ currentState.inFlightTokenRequests.set(scopeString, promise);
+ return promise;
+ });
+ },
+
+ /**
+ * Remove an OAuth token from the token cache
+ * and makes a network request to FxA server to destroy the token.
+ *
+ * @param options
+ * {
+ * token: (string) A previously fetched token.
+ * }
+ * @return Promise.<undefined> This function will always resolve, even if
+ * an unknown token is passed.
+ */
+ removeCachedOAuthToken(options) {
+ if (!options.token || typeof options.token !== "string") {
+ throw this._error(
+ ERROR_INVALID_PARAMETER,
+ "Missing or invalid 'token' option"
+ );
+ }
+ return this.withCurrentAccountState(currentState => {
+ 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);
+ });
+ }
+ });
+ },
+
+ /** Sets the user to be verified in the account state,
+ * This prevents any polling for the user's verification state from the FxA server
+ **/
+ setUserVerified() {
+ return this.withCurrentAccountState(async currentState => {
+ const userData = await currentState.getUserAccountData();
+ if (!userData.verified) {
+ await currentState.updateAccountData({ verified: true });
+ }
+ });
+ },
+
+ async _getVerifiedAccountOrReject() {
+ let data = await 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);
+ }
+ return data;
+ },
+
+ // _handle* methods used by push, used when the account/device status is
+ // changed on a different device.
+ async _handleAccountDestroyed(uid) {
+ let state = this.currentAccountState;
+ const accountData = await state.getUserAccountData();
+ const localUid = accountData ? accountData.uid : null;
+ if (!localUid) {
+ log.info(
+ `Account destroyed push notification received, but we're already logged-out`
+ );
+ return null;
+ }
+ if (uid == localUid) {
+ const data = JSON.stringify({ isLocalDevice: true });
+ await this.notifyObservers(ON_DEVICE_DISCONNECTED_NOTIFICATION, data);
+ return this.signOut(true);
+ }
+ log.info(
+ `The destroyed account uid doesn't match with the local uid. ` +
+ `Local: ${localUid}, account uid destroyed: ${uid}`
+ );
+ return null;
+ },
+
+ async _handleDeviceDisconnection(deviceId) {
+ let state = this.currentAccountState;
+ const accountData = await state.getUserAccountData();
+ if (!accountData || !accountData.device) {
+ // Nothing we can do here.
+ return;
+ }
+ const localDeviceId = accountData.device.id;
+ const isLocalDevice = deviceId == localDeviceId;
+ if (isLocalDevice) {
+ this.signOut(true);
+ }
+ const data = JSON.stringify({ isLocalDevice });
+ await this.notifyObservers(ON_DEVICE_DISCONNECTED_NOTIFICATION, data);
+ },
+
+ _setLastUserPref(newEmail) {
+ Services.prefs.setStringPref(
+ PREF_LAST_FXA_USER,
+ CryptoUtils.sha256Base64(newEmail)
+ );
+ },
+
+ async _handleEmailUpdated(newEmail) {
+ this._setLastUserPref(newEmail);
+ await this.currentAccountState.updateUserAccountData({ email: newEmail });
+ },
+
+ /*
+ * 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(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(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;
+ },
+
+ // 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.device.updateDeviceRegistration();
+ },
+
+ /**
+ * Delete all the persisted credentials we store for FxA. After calling
+ * this, the user will be forced to re-authenticate to continue.
+ *
+ * @return Promise resolves when the user data has been persisted
+ */
+ dropCredentials(state) {
+ // Delete all fields except those required for the user to
+ // reauthenticate.
+ let updateData = {};
+ let clearField = field => {
+ if (!FXA_PWDMGR_REAUTH_ALLOWLIST.has(field)) {
+ updateData[field] = null;
+ }
+ };
+ FXA_PWDMGR_PLAINTEXT_FIELDS.forEach(clearField);
+ FXA_PWDMGR_SECURE_FIELDS.forEach(clearField);
+
+ return state.updateUserAccountData(updateData);
+ },
+
+ async checkAccountStatus(state) {
+ log.info("checking account status...");
+ let data = await state.getUserAccountData(["uid", "sessionToken"]);
+ if (!data) {
+ log.info("account status: no user");
+ return false;
+ }
+ // If we have a session token, then check if that remains valid - if this
+ // works we know the account must also be OK.
+ if (data.sessionToken) {
+ if (await this.fxAccountsClient.sessionStatus(data.sessionToken)) {
+ log.info("account status: ok");
+ return true;
+ }
+ }
+ let exists = await this.fxAccountsClient.accountStatus(data.uid);
+ if (!exists) {
+ // Delete all local account data. Since the account no longer
+ // exists, we can skip the remote calls.
+ log.info("account status: deleted");
+ await this._handleAccountDestroyed(data.uid);
+ } else {
+ // Note that we may already have been in a "needs reauth" state (ie, if
+ // this function was called when we already had no session token), but
+ // that's OK - re-notifying etc should cause no harm.
+ log.info("account status: needs reauthentication");
+ await this.dropCredentials(this.currentAccountState);
+ // Notify the account state has changed so the UI updates.
+ await this.notifyObservers(ON_ACCOUNT_STATE_CHANGE_NOTIFICATION);
+ }
+ return false;
+ },
+
+ async _handleTokenError(err) {
+ if (!err || err.code != 401 || err.errno != ERRNO_INVALID_AUTH_TOKEN) {
+ throw err;
+ }
+ log.warn("handling invalid token error", err);
+ // Note that we don't use `withCurrentAccountState` here as that will cause
+ // an error to be thrown if we sign out due to the account not existing.
+ let state = this.currentAccountState;
+ let ok = await this.checkAccountStatus(state);
+ if (ok) {
+ log.warn("invalid token error, but account state appears ok?");
+ }
+ // always re-throw the error.
+ throw err;
+ },
+};
+
+let fxAccountsSingleton = null;
+
+export function getFxAccountsSingleton() {
+ if (fxAccountsSingleton) {
+ return fxAccountsSingleton;
+ }
+
+ fxAccountsSingleton = new FxAccounts();
+
+ // XXX Bug 947061 - We need a strategy for resuming email verification after
+ // browser restart
+ fxAccountsSingleton._internal.loadAndPoll();
+
+ return fxAccountsSingleton;
+}
+
+// `AccountState` is exported for tests.