summaryrefslogtreecommitdiffstats
path: root/services/fxaccounts
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--services/fxaccounts/Credentials.sys.mjs134
-rw-r--r--services/fxaccounts/FxAccounts.sys.mjs1657
-rw-r--r--services/fxaccounts/FxAccountsClient.sys.mjs839
-rw-r--r--services/fxaccounts/FxAccountsCommands.sys.mjs467
-rw-r--r--services/fxaccounts/FxAccountsCommon.sys.mjs393
-rw-r--r--services/fxaccounts/FxAccountsConfig.sys.mjs360
-rw-r--r--services/fxaccounts/FxAccountsDevice.sys.mjs640
-rw-r--r--services/fxaccounts/FxAccountsKeys.sys.mjs649
-rw-r--r--services/fxaccounts/FxAccountsOAuth.sys.mjs224
-rw-r--r--services/fxaccounts/FxAccountsPairing.sys.mjs511
-rw-r--r--services/fxaccounts/FxAccountsPairingChannel.sys.mjs3693
-rw-r--r--services/fxaccounts/FxAccountsProfile.sys.mjs193
-rw-r--r--services/fxaccounts/FxAccountsProfileClient.sys.mjs273
-rw-r--r--services/fxaccounts/FxAccountsPush.sys.mjs315
-rw-r--r--services/fxaccounts/FxAccountsStorage.sys.mjs618
-rw-r--r--services/fxaccounts/FxAccountsTelemetry.sys.mjs173
-rw-r--r--services/fxaccounts/FxAccountsWebChannel.sys.mjs824
-rw-r--r--services/fxaccounts/components.conf16
-rw-r--r--services/fxaccounts/moz.build38
-rw-r--r--services/fxaccounts/tests/browser/browser.toml6
-rw-r--r--services/fxaccounts/tests/browser/browser_device_connected.js51
-rw-r--r--services/fxaccounts/tests/browser/browser_verify_login.js33
-rw-r--r--services/fxaccounts/tests/browser/head.js73
-rw-r--r--services/fxaccounts/tests/mochitest/chrome.toml5
-rw-r--r--services/fxaccounts/tests/mochitest/file_invalidEmailCase.sjs81
-rw-r--r--services/fxaccounts/tests/mochitest/test_invalidEmailCase.html129
-rw-r--r--services/fxaccounts/tests/xpcshell/head.js38
-rw-r--r--services/fxaccounts/tests/xpcshell/test_accounts.js1642
-rw-r--r--services/fxaccounts/tests/xpcshell/test_accounts_config.js58
-rw-r--r--services/fxaccounts/tests/xpcshell/test_accounts_device_registration.js1204
-rw-r--r--services/fxaccounts/tests/xpcshell/test_client.js966
-rw-r--r--services/fxaccounts/tests/xpcshell/test_commands.js708
-rw-r--r--services/fxaccounts/tests/xpcshell/test_credentials.js130
-rw-r--r--services/fxaccounts/tests/xpcshell/test_device.js127
-rw-r--r--services/fxaccounts/tests/xpcshell/test_keys.js182
-rw-r--r--services/fxaccounts/tests/xpcshell/test_loginmgr_storage.js307
-rw-r--r--services/fxaccounts/tests/xpcshell/test_oauth_flow.js274
-rw-r--r--services/fxaccounts/tests/xpcshell/test_oauth_token_storage.js180
-rw-r--r--services/fxaccounts/tests/xpcshell/test_oauth_tokens.js255
-rw-r--r--services/fxaccounts/tests/xpcshell/test_pairing.js384
-rw-r--r--services/fxaccounts/tests/xpcshell/test_profile.js677
-rw-r--r--services/fxaccounts/tests/xpcshell/test_profile_client.js422
-rw-r--r--services/fxaccounts/tests/xpcshell/test_push_service.js522
-rw-r--r--services/fxaccounts/tests/xpcshell/test_storage_manager.js586
-rw-r--r--services/fxaccounts/tests/xpcshell/test_telemetry.js52
-rw-r--r--services/fxaccounts/tests/xpcshell/test_web_channel.js1380
-rw-r--r--services/fxaccounts/tests/xpcshell/xpcshell.toml49
47 files changed, 22538 insertions, 0 deletions
diff --git a/services/fxaccounts/Credentials.sys.mjs b/services/fxaccounts/Credentials.sys.mjs
new file mode 100644
index 0000000000..30c88fafdc
--- /dev/null
+++ b/services/fxaccounts/Credentials.sys.mjs
@@ -0,0 +1,134 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * This module implements client-side key stretching for use in Firefox
+ * Accounts account creation and login.
+ *
+ * See https://github.com/mozilla/fxa-auth-server/wiki/onepw-protocol
+ */
+
+import { Log } from "resource://gre/modules/Log.sys.mjs";
+
+import { CryptoUtils } from "resource://services-crypto/utils.sys.mjs";
+
+import { CommonUtils } from "resource://services-common/utils.sys.mjs";
+
+const PROTOCOL_VERSION = "identity.mozilla.com/picl/v1/";
+const PBKDF2_ROUNDS = 1000;
+const STRETCHED_PW_LENGTH_BYTES = 32;
+const HKDF_SALT = CommonUtils.hexToBytes("00");
+const HKDF_LENGTH = 32;
+
+// loglevel preference should be one of: "FATAL", "ERROR", "WARN", "INFO",
+// "CONFIG", "DEBUG", "TRACE" or "ALL". We will be logging error messages by
+// default.
+const PREF_LOG_LEVEL = "identity.fxaccounts.loglevel";
+let LOG_LEVEL = Log.Level.Error;
+try {
+ LOG_LEVEL =
+ Services.prefs.getPrefType(PREF_LOG_LEVEL) ==
+ Ci.nsIPrefBranch.PREF_STRING &&
+ Services.prefs.getStringPref(PREF_LOG_LEVEL);
+} catch (e) {}
+
+var log = Log.repository.getLogger("Identity.FxAccounts");
+log.level = LOG_LEVEL;
+log.addAppender(new Log.ConsoleAppender(new Log.BasicFormatter()));
+
+export var Credentials = Object.freeze({
+ /**
+ * Make constants accessible to tests
+ */
+ constants: {
+ PROTOCOL_VERSION,
+ PBKDF2_ROUNDS,
+ STRETCHED_PW_LENGTH_BYTES,
+ HKDF_SALT,
+ HKDF_LENGTH,
+ },
+
+ /**
+ * KW function from https://github.com/mozilla/fxa-auth-server/wiki/onepw-protocol
+ *
+ * keyWord derivation for use as a salt.
+ *
+ *
+ * @param {String} context String for use in generating salt
+ *
+ * @return {bitArray} the salt
+ *
+ * Note that PROTOCOL_VERSION does not refer in any way to the version of the
+ * Firefox Accounts API.
+ */
+ keyWord(context) {
+ return CommonUtils.stringToBytes(PROTOCOL_VERSION + context);
+ },
+
+ /**
+ * KWE function from https://github.com/mozilla/fxa-auth-server/wiki/onepw-protocol
+ *
+ * keyWord extended with a name and an email.
+ *
+ * @param {String} name The name of the salt
+ * @param {String} email The email of the user.
+ *
+ * @return {bitArray} the salt combination with the namespace
+ *
+ * Note that PROTOCOL_VERSION does not refer in any way to the version of the
+ * Firefox Accounts API.
+ */
+ keyWordExtended(name, email) {
+ return CommonUtils.stringToBytes(PROTOCOL_VERSION + name + ":" + email);
+ },
+
+ setup(emailInput, passwordInput, options = {}) {
+ return new Promise(resolve => {
+ log.debug("setup credentials for " + emailInput);
+
+ let hkdfSalt = options.hkdfSalt || HKDF_SALT;
+ let hkdfLength = options.hkdfLength || HKDF_LENGTH;
+ let stretchedPWLength =
+ options.stretchedPassLength || STRETCHED_PW_LENGTH_BYTES;
+ let pbkdf2Rounds = options.pbkdf2Rounds || PBKDF2_ROUNDS;
+
+ let result = {};
+
+ let password = CommonUtils.encodeUTF8(passwordInput);
+ let salt = this.keyWordExtended("quickStretch", emailInput);
+
+ let runnable = async () => {
+ let start = Date.now();
+ let quickStretchedPW = await CryptoUtils.pbkdf2Generate(
+ password,
+ salt,
+ pbkdf2Rounds,
+ stretchedPWLength
+ );
+
+ result.quickStretchedPW = quickStretchedPW;
+
+ result.authPW = await CryptoUtils.hkdfLegacy(
+ quickStretchedPW,
+ hkdfSalt,
+ this.keyWord("authPW"),
+ hkdfLength
+ );
+
+ result.unwrapBKey = await CryptoUtils.hkdfLegacy(
+ quickStretchedPW,
+ hkdfSalt,
+ this.keyWord("unwrapBkey"),
+ hkdfLength
+ );
+
+ log.debug("Credentials set up after " + (Date.now() - start) + " ms");
+ resolve(result);
+ };
+
+ Services.tm.dispatchToMainThread(runnable);
+ log.debug("Dispatched thread for credentials setup crypto work");
+ });
+ },
+});
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.
diff --git a/services/fxaccounts/FxAccountsClient.sys.mjs b/services/fxaccounts/FxAccountsClient.sys.mjs
new file mode 100644
index 0000000000..9dc80ff419
--- /dev/null
+++ b/services/fxaccounts/FxAccountsClient.sys.mjs
@@ -0,0 +1,839 @@
+/* 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 { CommonUtils } from "resource://services-common/utils.sys.mjs";
+
+import { HawkClient } from "resource://services-common/hawkclient.sys.mjs";
+import { deriveHawkCredentials } from "resource://services-common/hawkrequest.sys.mjs";
+import { CryptoUtils } from "resource://services-crypto/utils.sys.mjs";
+
+import {
+ ERRNO_ACCOUNT_DOES_NOT_EXIST,
+ ERRNO_INCORRECT_EMAIL_CASE,
+ ERRNO_INCORRECT_PASSWORD,
+ ERRNO_INVALID_AUTH_NONCE,
+ ERRNO_INVALID_AUTH_TIMESTAMP,
+ ERRNO_INVALID_AUTH_TOKEN,
+ log,
+} from "resource://gre/modules/FxAccountsCommon.sys.mjs";
+
+import { Credentials } from "resource://gre/modules/Credentials.sys.mjs";
+
+const HOST_PREF = "identity.fxaccounts.auth.uri";
+
+const SIGNIN = "/account/login";
+const SIGNUP = "/account/create";
+// Devices older than this many days will not appear in the devices list
+const DEVICES_FILTER_DAYS = 21;
+
+export var FxAccountsClient = function (
+ host = Services.prefs.getStringPref(HOST_PREF)
+) {
+ this.host = host;
+
+ // The FxA auth server expects requests to certain endpoints to be authorized
+ // using Hawk.
+ this.hawk = new HawkClient(host);
+ this.hawk.observerPrefix = "FxA:hawk";
+
+ // Manage server backoff state. C.f.
+ // https://github.com/mozilla/fxa-auth-server/blob/master/docs/api.md#backoff-protocol
+ this.backoffError = null;
+};
+
+FxAccountsClient.prototype = {
+ /**
+ * Return client clock offset, in milliseconds, as determined by hawk client.
+ * Provided because callers should not have to know about hawk
+ * implementation.
+ *
+ * The offset is the number of milliseconds that must be added to the client
+ * clock to make it equal to the server clock. For example, if the client is
+ * five minutes ahead of the server, the localtimeOffsetMsec will be -300000.
+ */
+ get localtimeOffsetMsec() {
+ return this.hawk.localtimeOffsetMsec;
+ },
+
+ /*
+ * Return current time in milliseconds
+ *
+ * Not used by this module, but made available to the FxAccounts.jsm
+ * that uses this client.
+ */
+ now() {
+ return this.hawk.now();
+ },
+
+ /**
+ * Common code from signIn and signUp.
+ *
+ * @param path
+ * Request URL path. Can be /account/create or /account/login
+ * @param email
+ * The email address for the account (utf8)
+ * @param password
+ * The user's password
+ * @param [getKeys=false]
+ * If set to true the keyFetchToken will be retrieved
+ * @param [retryOK=true]
+ * If capitalization of the email is wrong and retryOK is set to true,
+ * we will retry with the suggested capitalization from the server
+ * @return Promise
+ * Returns a promise that resolves to an object:
+ * {
+ * authAt: authentication time for the session (seconds since epoch)
+ * email: the primary email for this account
+ * keyFetchToken: a key fetch token (hex)
+ * sessionToken: a session token (hex)
+ * uid: the user's unique ID (hex)
+ * unwrapBKey: used to unwrap kB, derived locally from the
+ * password (not revealed to the FxA server)
+ * verified (optional): flag indicating verification status of the
+ * email
+ * }
+ */
+ _createSession(path, email, password, getKeys = false, retryOK = true) {
+ return Credentials.setup(email, password).then(creds => {
+ let data = {
+ authPW: CommonUtils.bytesAsHex(creds.authPW),
+ email,
+ };
+ let keys = getKeys ? "?keys=true" : "";
+
+ return this._request(path + keys, "POST", null, data).then(
+ // Include the canonical capitalization of the email in the response so
+ // the caller can set its signed-in user state accordingly.
+ result => {
+ result.email = data.email;
+ result.unwrapBKey = CommonUtils.bytesAsHex(creds.unwrapBKey);
+
+ return result;
+ },
+ error => {
+ log.debug("Session creation failed", error);
+ // If the user entered an email with different capitalization from
+ // what's stored in the database (e.g., Greta.Garbo@gmail.COM as
+ // opposed to greta.garbo@gmail.com), the server will respond with a
+ // errno 120 (code 400) and the expected capitalization of the email.
+ // We retry with this email exactly once. If successful, we use the
+ // server's version of the email as the signed-in-user's email. This
+ // is necessary because the email also serves as salt; so we must be
+ // in agreement with the server on capitalization.
+ //
+ // API reference:
+ // https://github.com/mozilla/fxa-auth-server/blob/master/docs/api.md
+ if (ERRNO_INCORRECT_EMAIL_CASE === error.errno && retryOK) {
+ if (!error.email) {
+ log.error("Server returned errno 120 but did not provide email");
+ throw error;
+ }
+ return this._createSession(
+ path,
+ error.email,
+ password,
+ getKeys,
+ false
+ );
+ }
+ throw error;
+ }
+ );
+ });
+ },
+
+ /**
+ * Create a new Firefox Account and authenticate
+ *
+ * @param email
+ * The email address for the account (utf8)
+ * @param password
+ * The user's password
+ * @param [getKeys=false]
+ * If set to true the keyFetchToken will be retrieved
+ * @return Promise
+ * Returns a promise that resolves to an object:
+ * {
+ * uid: the user's unique ID (hex)
+ * sessionToken: a session token (hex)
+ * keyFetchToken: a key fetch token (hex),
+ * unwrapBKey: used to unwrap kB, derived locally from the
+ * password (not revealed to the FxA server)
+ * }
+ */
+ signUp(email, password, getKeys = false) {
+ return this._createSession(
+ SIGNUP,
+ email,
+ password,
+ getKeys,
+ false /* no retry */
+ );
+ },
+
+ /**
+ * Authenticate and create a new session with the Firefox Account API server
+ *
+ * @param email
+ * The email address for the account (utf8)
+ * @param password
+ * The user's password
+ * @param [getKeys=false]
+ * If set to true the keyFetchToken will be retrieved
+ * @return Promise
+ * Returns a promise that resolves to an object:
+ * {
+ * authAt: authentication time for the session (seconds since epoch)
+ * email: the primary email for this account
+ * keyFetchToken: a key fetch token (hex)
+ * sessionToken: a session token (hex)
+ * uid: the user's unique ID (hex)
+ * unwrapBKey: used to unwrap kB, derived locally from the
+ * password (not revealed to the FxA server)
+ * verified: flag indicating verification status of the email
+ * }
+ */
+ signIn: function signIn(email, password, getKeys = false) {
+ return this._createSession(
+ SIGNIN,
+ email,
+ password,
+ getKeys,
+ true /* retry */
+ );
+ },
+
+ /**
+ * Check the status of a session given a session token
+ *
+ * @param sessionTokenHex
+ * The session token encoded in hex
+ * @return Promise
+ * Resolves with a boolean indicating if the session is still valid
+ */
+ async sessionStatus(sessionTokenHex) {
+ const credentials = await deriveHawkCredentials(
+ sessionTokenHex,
+ "sessionToken"
+ );
+ return this._request("/session/status", "GET", credentials).then(
+ () => Promise.resolve(true),
+ error => {
+ if (isInvalidTokenError(error)) {
+ return Promise.resolve(false);
+ }
+ throw error;
+ }
+ );
+ },
+
+ /**
+ * List all the clients connected to the authenticated user's account,
+ * including devices, OAuth clients, and web sessions.
+ *
+ * @param sessionTokenHex
+ * The session token encoded in hex
+ * @return Promise
+ */
+ async attachedClients(sessionTokenHex) {
+ const credentials = await deriveHawkCredentials(
+ sessionTokenHex,
+ "sessionToken"
+ );
+ return this._requestWithHeaders(
+ "/account/attached_clients",
+ "GET",
+ credentials
+ );
+ },
+
+ /**
+ * Retrieves an OAuth authorization code.
+ *
+ * @param String sessionTokenHex
+ * The session token encoded in hex
+ * @param {Object} options
+ * @param options.client_id
+ * @param options.state
+ * @param options.scope
+ * @param options.access_type
+ * @param options.code_challenge_method
+ * @param options.code_challenge
+ * @param [options.keys_jwe]
+ * @returns {Promise<Object>} Object containing `code` and `state`.
+ */
+ async oauthAuthorize(sessionTokenHex, options) {
+ const credentials = await deriveHawkCredentials(
+ sessionTokenHex,
+ "sessionToken"
+ );
+ const body = {
+ client_id: options.client_id,
+ response_type: "code",
+ state: options.state,
+ scope: options.scope,
+ access_type: options.access_type,
+ code_challenge: options.code_challenge,
+ code_challenge_method: options.code_challenge_method,
+ };
+ if (options.keys_jwe) {
+ body.keys_jwe = options.keys_jwe;
+ }
+ return this._request("/oauth/authorization", "POST", credentials, body);
+ },
+ /**
+ * Exchanges an OAuth authorization code with a refresh token, access tokens and an optional JWE representing scoped keys
+ * Takes in the sessionToken to tie the device record associated with the session, with the device record associated with the refreshToken
+ *
+ * @param string sessionTokenHex: The session token encoded in hex
+ * @param String code: OAuth authorization code
+ * @param String verifier: OAuth PKCE verifier
+ * @param String clientId: OAuth client ID
+ *
+ * @returns { Object } object containing `refresh_token`, `access_token` and `keys_jwe`
+ **/
+ async oauthToken(sessionTokenHex, code, verifier, clientId) {
+ const credentials = await deriveHawkCredentials(
+ sessionTokenHex,
+ "sessionToken"
+ );
+ const body = {
+ grant_type: "authorization_code",
+ code,
+ client_id: clientId,
+ code_verifier: verifier,
+ };
+ return this._request("/oauth/token", "POST", credentials, body);
+ },
+ /**
+ * Destroy an OAuth access token or refresh token.
+ *
+ * @param String clientId
+ * @param String token The token to be revoked.
+ */
+ async oauthDestroy(clientId, token) {
+ const body = {
+ client_id: clientId,
+ token,
+ };
+ return this._request("/oauth/destroy", "POST", null, body);
+ },
+
+ /**
+ * Query for the information required to derive
+ * scoped encryption keys requested by the specified OAuth client.
+ *
+ * @param sessionTokenHex
+ * The session token encoded in hex
+ * @param clientId
+ * @param scope
+ * Space separated list of scopes
+ * @return Promise
+ */
+ async getScopedKeyData(sessionTokenHex, clientId, scope) {
+ if (!clientId) {
+ throw new Error("Missing 'clientId' parameter");
+ }
+ if (!scope) {
+ throw new Error("Missing 'scope' parameter");
+ }
+ const params = {
+ client_id: clientId,
+ scope,
+ };
+ const credentials = await deriveHawkCredentials(
+ sessionTokenHex,
+ "sessionToken"
+ );
+ return this._request(
+ "/account/scoped-key-data",
+ "POST",
+ credentials,
+ params
+ );
+ },
+
+ /**
+ * Destroy the current session with the Firefox Account API server and its
+ * associated device.
+ *
+ * @param sessionTokenHex
+ * The session token encoded in hex
+ * @return Promise
+ */
+ async signOut(sessionTokenHex, options = {}) {
+ const credentials = await deriveHawkCredentials(
+ sessionTokenHex,
+ "sessionToken"
+ );
+ let path = "/session/destroy";
+ if (options.service) {
+ path += "?service=" + encodeURIComponent(options.service);
+ }
+ return this._request(path, "POST", credentials);
+ },
+
+ /**
+ * Check the verification status of the user's FxA email address
+ *
+ * @param sessionTokenHex
+ * The current session token encoded in hex
+ * @return Promise
+ */
+ async recoveryEmailStatus(sessionTokenHex, options = {}) {
+ const credentials = await deriveHawkCredentials(
+ sessionTokenHex,
+ "sessionToken"
+ );
+ let path = "/recovery_email/status";
+ if (options.reason) {
+ path += "?reason=" + encodeURIComponent(options.reason);
+ }
+
+ return this._request(path, "GET", credentials);
+ },
+
+ /**
+ * Resend the verification email for the user
+ *
+ * @param sessionTokenHex
+ * The current token encoded in hex
+ * @return Promise
+ */
+ async resendVerificationEmail(sessionTokenHex) {
+ const credentials = await deriveHawkCredentials(
+ sessionTokenHex,
+ "sessionToken"
+ );
+ return this._request("/recovery_email/resend_code", "POST", credentials);
+ },
+
+ /**
+ * Retrieve encryption keys
+ *
+ * @param keyFetchTokenHex
+ * A one-time use key fetch token encoded in hex
+ * @return Promise
+ * Returns a promise that resolves to an object:
+ * {
+ * kA: an encryption key for recevorable data (bytes)
+ * wrapKB: an encryption key that requires knowledge of the
+ * user's password (bytes)
+ * }
+ */
+ async accountKeys(keyFetchTokenHex) {
+ let creds = await deriveHawkCredentials(keyFetchTokenHex, "keyFetchToken");
+ let keyRequestKey = creds.extra.slice(0, 32);
+ let morecreds = await CryptoUtils.hkdfLegacy(
+ keyRequestKey,
+ undefined,
+ Credentials.keyWord("account/keys"),
+ 3 * 32
+ );
+ let respHMACKey = morecreds.slice(0, 32);
+ let respXORKey = morecreds.slice(32, 96);
+
+ const resp = await this._request("/account/keys", "GET", creds);
+ if (!resp.bundle) {
+ throw new Error("failed to retrieve keys");
+ }
+
+ let bundle = CommonUtils.hexToBytes(resp.bundle);
+ let mac = bundle.slice(-32);
+ let key = CommonUtils.byteStringToArrayBuffer(respHMACKey);
+ // CryptoUtils.hmac takes ArrayBuffers as inputs for the key and data and
+ // returns an ArrayBuffer.
+ let bundleMAC = await CryptoUtils.hmac(
+ "SHA-256",
+ key,
+ CommonUtils.byteStringToArrayBuffer(bundle.slice(0, -32))
+ );
+ if (mac !== CommonUtils.arrayBufferToByteString(bundleMAC)) {
+ throw new Error("error unbundling encryption keys");
+ }
+
+ let keyAWrapB = CryptoUtils.xor(respXORKey, bundle.slice(0, 64));
+
+ return {
+ kA: keyAWrapB.slice(0, 32),
+ wrapKB: keyAWrapB.slice(32),
+ };
+ },
+
+ /**
+ * Obtain an OAuth access token by authenticating using a session token.
+ *
+ * @param {String} sessionTokenHex
+ * The session token encoded in hex
+ * @param {String} clientId
+ * @param {String} scope
+ * List of space-separated scopes.
+ * @param {Number} ttl
+ * Token time to live.
+ * @return {Promise<Object>} Object containing an `access_token`.
+ */
+ async accessTokenWithSessionToken(sessionTokenHex, clientId, scope, ttl) {
+ const credentials = await deriveHawkCredentials(
+ sessionTokenHex,
+ "sessionToken"
+ );
+ const body = {
+ client_id: clientId,
+ grant_type: "fxa-credentials",
+ scope,
+ ttl,
+ };
+ return this._request("/oauth/token", "POST", credentials, body);
+ },
+
+ /**
+ * Determine if an account exists
+ *
+ * @param email
+ * The email address to check
+ * @return Promise
+ * The promise resolves to true if the account exists, or false
+ * if it doesn't. The promise is rejected on other errors.
+ */
+ accountExists(email) {
+ return this.signIn(email, "").then(
+ cantHappen => {
+ throw new Error("How did I sign in with an empty password?");
+ },
+ expectedError => {
+ switch (expectedError.errno) {
+ case ERRNO_ACCOUNT_DOES_NOT_EXIST:
+ return false;
+ case ERRNO_INCORRECT_PASSWORD:
+ return true;
+ default:
+ // not so expected, any more ...
+ throw expectedError;
+ }
+ }
+ );
+ },
+
+ /**
+ * Given the uid of an existing account (not an arbitrary email), ask
+ * the server if it still exists via /account/status.
+ *
+ * Used for differentiating between password change and account deletion.
+ */
+ accountStatus(uid) {
+ return this._request("/account/status?uid=" + uid, "GET").then(
+ result => {
+ return result.exists;
+ },
+ error => {
+ log.error("accountStatus failed", error);
+ return Promise.reject(error);
+ }
+ );
+ },
+
+ /**
+ * Register a new device
+ *
+ * @method registerDevice
+ * @param sessionTokenHex
+ * Session token obtained from signIn
+ * @param name
+ * Device name
+ * @param type
+ * Device type (mobile|desktop)
+ * @param [options]
+ * Extra device options
+ * @param [options.availableCommands]
+ * Available commands for this device
+ * @param [options.pushCallback]
+ * `pushCallback` push endpoint callback
+ * @param [options.pushPublicKey]
+ * `pushPublicKey` push public key (URLSafe Base64 string)
+ * @param [options.pushAuthKey]
+ * `pushAuthKey` push auth secret (URLSafe Base64 string)
+ * @return Promise
+ * Resolves to an object:
+ * {
+ * id: Device identifier
+ * createdAt: Creation time (milliseconds since epoch)
+ * name: Name of device
+ * type: Type of device (mobile|desktop)
+ * }
+ */
+ async registerDevice(sessionTokenHex, name, type, options = {}) {
+ let path = "/account/device";
+
+ let creds = await deriveHawkCredentials(sessionTokenHex, "sessionToken");
+ let body = { name, type };
+
+ if (options.pushCallback) {
+ body.pushCallback = options.pushCallback;
+ }
+ if (options.pushPublicKey && options.pushAuthKey) {
+ body.pushPublicKey = options.pushPublicKey;
+ body.pushAuthKey = options.pushAuthKey;
+ }
+ body.availableCommands = options.availableCommands;
+
+ return this._request(path, "POST", creds, body);
+ },
+
+ /**
+ * Sends a message to other devices. Must conform with the push payload schema:
+ * https://github.com/mozilla/fxa-auth-server/blob/master/docs/pushpayloads.schema.json
+ *
+ * @method notifyDevice
+ * @param sessionTokenHex
+ * Session token obtained from signIn
+ * @param deviceIds
+ * Devices to send the message to. If null, will be sent to all devices.
+ * @param excludedIds
+ * Devices to exclude when sending to all devices (deviceIds must be null).
+ * @param payload
+ * Data to send with the message
+ * @return Promise
+ * Resolves to an empty object:
+ * {}
+ */
+ async notifyDevices(
+ sessionTokenHex,
+ deviceIds,
+ excludedIds,
+ payload,
+ TTL = 0
+ ) {
+ const credentials = await deriveHawkCredentials(
+ sessionTokenHex,
+ "sessionToken"
+ );
+ if (deviceIds && excludedIds) {
+ throw new Error(
+ "You cannot specify excluded devices if deviceIds is set."
+ );
+ }
+ const body = {
+ to: deviceIds || "all",
+ payload,
+ TTL,
+ };
+ if (excludedIds) {
+ body.excluded = excludedIds;
+ }
+ return this._request("/account/devices/notify", "POST", credentials, body);
+ },
+
+ /**
+ * Retrieves pending commands for our device.
+ *
+ * @method getCommands
+ * @param sessionTokenHex - Session token obtained from signIn
+ * @param [index] - If specified, only messages received after the one who
+ * had that index will be retrieved.
+ * @param [limit] - Maximum number of messages to retrieve.
+ */
+ async getCommands(sessionTokenHex, { index, limit }) {
+ const credentials = await deriveHawkCredentials(
+ sessionTokenHex,
+ "sessionToken"
+ );
+ const params = new URLSearchParams();
+ if (index != undefined) {
+ params.set("index", index);
+ }
+ if (limit != undefined) {
+ params.set("limit", limit);
+ }
+ const path = `/account/device/commands?${params.toString()}`;
+ return this._request(path, "GET", credentials);
+ },
+
+ /**
+ * Invokes a command on another device.
+ *
+ * @method invokeCommand
+ * @param sessionTokenHex - Session token obtained from signIn
+ * @param command - Name of the command to invoke
+ * @param target - Recipient device ID.
+ * @param payload
+ * @return Promise
+ * Resolves to the request's response, (which should be an empty object)
+ */
+ async invokeCommand(sessionTokenHex, command, target, payload) {
+ const credentials = await deriveHawkCredentials(
+ sessionTokenHex,
+ "sessionToken"
+ );
+ const body = {
+ command,
+ target,
+ payload,
+ };
+ return this._request(
+ "/account/devices/invoke_command",
+ "POST",
+ credentials,
+ body
+ );
+ },
+
+ /**
+ * Update the session or name for an existing device
+ *
+ * @method updateDevice
+ * @param sessionTokenHex
+ * Session token obtained from signIn
+ * @param id
+ * Device identifier
+ * @param name
+ * Device name
+ * @param [options]
+ * Extra device options
+ * @param [options.availableCommands]
+ * Available commands for this device
+ * @param [options.pushCallback]
+ * `pushCallback` push endpoint callback
+ * @param [options.pushPublicKey]
+ * `pushPublicKey` push public key (URLSafe Base64 string)
+ * @param [options.pushAuthKey]
+ * `pushAuthKey` push auth secret (URLSafe Base64 string)
+ * @return Promise
+ * Resolves to an object:
+ * {
+ * id: Device identifier
+ * name: Device name
+ * }
+ */
+ async updateDevice(sessionTokenHex, id, name, options = {}) {
+ let path = "/account/device";
+
+ let creds = await deriveHawkCredentials(sessionTokenHex, "sessionToken");
+ let body = { id, name };
+ if (options.pushCallback) {
+ body.pushCallback = options.pushCallback;
+ }
+ if (options.pushPublicKey && options.pushAuthKey) {
+ body.pushPublicKey = options.pushPublicKey;
+ body.pushAuthKey = options.pushAuthKey;
+ }
+ body.availableCommands = options.availableCommands;
+
+ return this._request(path, "POST", creds, body);
+ },
+
+ /**
+ * Get a list of currently registered devices that have been accessed
+ * in the last `DEVICES_FILTER_DAYS` days
+ *
+ * @method getDeviceList
+ * @param sessionTokenHex
+ * Session token obtained from signIn
+ * @return Promise
+ * Resolves to an array of objects:
+ * [
+ * {
+ * id: Device id
+ * isCurrentDevice: Boolean indicating whether the item
+ * represents the current device
+ * name: Device name
+ * type: Device type (mobile|desktop)
+ * },
+ * ...
+ * ]
+ */
+ async getDeviceList(sessionTokenHex) {
+ let timestamp = Date.now() - 1000 * 60 * 60 * 24 * DEVICES_FILTER_DAYS;
+ let path = `/account/devices?filterIdleDevicesTimestamp=${timestamp}`;
+ let creds = await deriveHawkCredentials(sessionTokenHex, "sessionToken");
+ return this._request(path, "GET", creds, {});
+ },
+
+ _clearBackoff() {
+ this.backoffError = null;
+ },
+
+ /**
+ * A general method for sending raw API calls to the FxA auth server.
+ * All request bodies and responses are JSON.
+ *
+ * @param path
+ * API endpoint path
+ * @param method
+ * The HTTP request method
+ * @param credentials
+ * Hawk credentials
+ * @param jsonPayload
+ * A JSON payload
+ * @return Promise
+ * Returns a promise that resolves to the JSON response of the API call,
+ * or is rejected with an error. Error responses have the following properties:
+ * {
+ * "code": 400, // matches the HTTP status code
+ * "errno": 107, // stable application-level error number
+ * "error": "Bad Request", // string description of the error type
+ * "message": "the value of salt is not allowed to be undefined",
+ * "info": "https://docs.dev.lcip.og/errors/1234" // link to more info on the error
+ * }
+ */
+ async _requestWithHeaders(path, method, credentials, jsonPayload) {
+ // We were asked to back off.
+ if (this.backoffError) {
+ log.debug("Received new request during backoff, re-rejecting.");
+ throw this.backoffError;
+ }
+ let response;
+ try {
+ response = await this.hawk.request(
+ path,
+ method,
+ credentials,
+ jsonPayload
+ );
+ } catch (error) {
+ log.error(`error ${method}ing ${path}`, error);
+ if (error.retryAfter) {
+ log.debug("Received backoff response; caching error as flag.");
+ this.backoffError = error;
+ // Schedule clearing of cached-error-as-flag.
+ CommonUtils.namedTimer(
+ this._clearBackoff,
+ error.retryAfter * 1000,
+ this,
+ "fxaBackoffTimer"
+ );
+ }
+ throw error;
+ }
+ try {
+ return { body: JSON.parse(response.body), headers: response.headers };
+ } catch (error) {
+ log.error("json parse error on response: " + response.body);
+ // eslint-disable-next-line no-throw-literal
+ throw { error };
+ }
+ },
+
+ async _request(path, method, credentials, jsonPayload) {
+ const response = await this._requestWithHeaders(
+ path,
+ method,
+ credentials,
+ jsonPayload
+ );
+ return response.body;
+ },
+};
+
+function isInvalidTokenError(error) {
+ if (error.code != 401) {
+ return false;
+ }
+ switch (error.errno) {
+ case ERRNO_INVALID_AUTH_TOKEN:
+ case ERRNO_INVALID_AUTH_TIMESTAMP:
+ case ERRNO_INVALID_AUTH_NONCE:
+ return true;
+ }
+ return false;
+}
diff --git a/services/fxaccounts/FxAccountsCommands.sys.mjs b/services/fxaccounts/FxAccountsCommands.sys.mjs
new file mode 100644
index 0000000000..40fcc7f925
--- /dev/null
+++ b/services/fxaccounts/FxAccountsCommands.sys.mjs
@@ -0,0 +1,467 @@
+/* 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 {
+ COMMAND_SENDTAB,
+ COMMAND_SENDTAB_TAIL,
+ SCOPE_OLD_SYNC,
+ log,
+} from "resource://gre/modules/FxAccountsCommon.sys.mjs";
+
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+
+import { Observers } from "resource://services-common/observers.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ BulkKeyBundle: "resource://services-sync/keys.sys.mjs",
+ CryptoWrapper: "resource://services-sync/record.sys.mjs",
+ PushCrypto: "resource://gre/modules/PushCrypto.sys.mjs",
+});
+
+XPCOMUtils.defineLazyPreferenceGetter(
+ lazy,
+ "INVALID_SHAREABLE_SCHEMES",
+ "services.sync.engine.tabs.filteredSchemes",
+ "",
+ null,
+ val => {
+ return new Set(val.split("|"));
+ }
+);
+
+export class FxAccountsCommands {
+ constructor(fxAccountsInternal) {
+ this._fxai = fxAccountsInternal;
+ this.sendTab = new SendTab(this, fxAccountsInternal);
+ this._invokeRateLimitExpiry = 0;
+ }
+
+ async availableCommands() {
+ const encryptedSendTabKeys = await this.sendTab.getEncryptedSendTabKeys();
+ if (!encryptedSendTabKeys) {
+ // This will happen if the account is not verified yet.
+ return {};
+ }
+ return {
+ [COMMAND_SENDTAB]: encryptedSendTabKeys,
+ };
+ }
+
+ async invoke(command, device, payload) {
+ const { sessionToken } = await this._fxai.getUserAccountData([
+ "sessionToken",
+ ]);
+ const client = this._fxai.fxAccountsClient;
+ const now = Date.now();
+ if (now < this._invokeRateLimitExpiry) {
+ const remaining = (this._invokeRateLimitExpiry - now) / 1000;
+ throw new Error(
+ `Invoke for ${command} is rate-limited for ${remaining} seconds.`
+ );
+ }
+ try {
+ let info = await client.invokeCommand(
+ sessionToken,
+ command,
+ device.id,
+ payload
+ );
+ if (!info.enqueued || !info.notified) {
+ // We want an error log here to help diagnose users who report failure.
+ log.error("Sending was only partially successful", info);
+ } else {
+ log.info("Successfully sent", info);
+ }
+ } catch (err) {
+ if (err.code && err.code === 429 && err.retryAfter) {
+ this._invokeRateLimitExpiry = Date.now() + err.retryAfter * 1000;
+ }
+ throw err;
+ }
+ log.info(`Payload sent to device ${device.id}.`);
+ }
+
+ /**
+ * Poll and handle device commands for the current device.
+ * This method can be called either in response to a Push message,
+ * or by itself as a "commands recovery" mechanism.
+ *
+ * @param {Number} notifiedIndex "Command received" push messages include
+ * the index of the command that triggered the message. We use it as a
+ * hint when we have no "last command index" stored.
+ */
+ async pollDeviceCommands(notifiedIndex = 0) {
+ // Whether the call to `pollDeviceCommands` was initiated by a Push message from the FxA
+ // servers in response to a message being received or simply scheduled in order
+ // to fetch missed messages.
+ log.info(`Polling device commands.`);
+ await this._fxai.withCurrentAccountState(async state => {
+ const { device } = await state.getUserAccountData(["device"]);
+ if (!device) {
+ throw new Error("No device registration.");
+ }
+ // We increment lastCommandIndex by 1 because the server response includes the current index.
+ // If we don't have a `lastCommandIndex` stored, we fall back on the index from the push message we just got.
+ const lastCommandIndex = device.lastCommandIndex + 1 || notifiedIndex;
+ // We have already received this message before.
+ if (notifiedIndex > 0 && notifiedIndex < lastCommandIndex) {
+ return;
+ }
+ const { index, messages } = await this._fetchDeviceCommands(
+ lastCommandIndex
+ );
+ if (messages.length) {
+ await state.updateUserAccountData({
+ device: { ...device, lastCommandIndex: index },
+ });
+ log.info(`Handling ${messages.length} messages`);
+ await this._handleCommands(messages, notifiedIndex);
+ }
+ });
+ return true;
+ }
+
+ async _fetchDeviceCommands(index, limit = null) {
+ const userData = await this._fxai.getUserAccountData();
+ if (!userData) {
+ throw new Error("No user.");
+ }
+ const { sessionToken } = userData;
+ if (!sessionToken) {
+ throw new Error("No session token.");
+ }
+ const client = this._fxai.fxAccountsClient;
+ const opts = { index };
+ if (limit != null) {
+ opts.limit = limit;
+ }
+ return client.getCommands(sessionToken, opts);
+ }
+
+ _getReason(notifiedIndex, messageIndex) {
+ // The returned reason value represents an explanation for why the command associated with the
+ // message of the given `messageIndex` is being handled. If `notifiedIndex` is zero the command
+ // is a part of a fallback polling process initiated by "Sync Now" ["poll"]. If `notifiedIndex` is
+ // greater than `messageIndex` this is a push command that was previously missed ["push-missed"],
+ // otherwise we assume this is a push command with no missed messages ["push"].
+ if (notifiedIndex == 0) {
+ return "poll";
+ } else if (notifiedIndex > messageIndex) {
+ return "push-missed";
+ }
+ // Note: The returned reason might be "push" in the case where a user sends multiple tabs
+ // in quick succession. We are not attempting to distinguish this from other push cases at
+ // present.
+ return "push";
+ }
+
+ async _handleCommands(messages, notifiedIndex) {
+ try {
+ await this._fxai.device.refreshDeviceList();
+ } catch (e) {
+ log.warn("Error refreshing device list", e);
+ }
+ // We debounce multiple incoming tabs so we show a single notification.
+ const tabsReceived = [];
+ for (const { index, data } of messages) {
+ const { command, payload, sender: senderId } = data;
+ const reason = this._getReason(notifiedIndex, index);
+ const sender =
+ senderId && this._fxai.device.recentDeviceList
+ ? this._fxai.device.recentDeviceList.find(d => d.id == senderId)
+ : null;
+ if (!sender) {
+ log.warn(
+ "Incoming command is from an unknown device (maybe disconnected?)"
+ );
+ }
+ switch (command) {
+ case COMMAND_SENDTAB:
+ try {
+ const { title, uri } = await this.sendTab.handle(
+ senderId,
+ payload,
+ reason
+ );
+ log.info(
+ `Tab received with FxA commands: "${
+ title || "<no title>"
+ }" from ${sender ? sender.name : "Unknown device"}.`
+ );
+ // URLs are PII, so only logged at trace.
+ log.trace(`Tab received URL: ${uri}`);
+ // This should eventually be rare to hit as all platforms will be using the same
+ // scheme filter list, but we have this here in the case other platforms
+ // haven't caught up and/or trying to send invalid uris using older versions
+ const scheme = Services.io.newURI(uri).scheme;
+ if (lazy.INVALID_SHAREABLE_SCHEMES.has(scheme)) {
+ throw new Error("Invalid scheme found for received URI.");
+ }
+ tabsReceived.push({ title, uri, sender });
+ } catch (e) {
+ log.error(`Error while handling incoming Send Tab payload.`, e);
+ }
+ break;
+ default:
+ log.info(`Unknown command: ${command}.`);
+ }
+ }
+ if (tabsReceived.length) {
+ this._notifyFxATabsReceived(tabsReceived);
+ }
+ }
+
+ _notifyFxATabsReceived(tabsReceived) {
+ Observers.notify("fxaccounts:commands:open-uri", tabsReceived);
+ }
+}
+
+/**
+ * Send Tab is built on top of FxA commands.
+ *
+ * Devices exchange keys wrapped in the oldsync key between themselves (getEncryptedSendTabKeys)
+ * during the device registration flow. The FxA server can theoretically never
+ * retrieve the send tab keys since it doesn't know the oldsync key.
+ *
+ * Note about the keys:
+ * The server has the `pushPublicKey`. The FxA server encrypt the send-tab payload again using the
+ * push keys - after the client has encrypted the payload using the send-tab keys.
+ * The push keys are different from the send-tab keys. The FxA server uses
+ * the push keys to deliver the tabs using same mechanism we use for web-push.
+ * However, clients use the send-tab keys for end-to-end encryption.
+ */
+export class SendTab {
+ constructor(commands, fxAccountsInternal) {
+ this._commands = commands;
+ this._fxai = fxAccountsInternal;
+ }
+ /**
+ * @param {Device[]} to - Device objects (typically returned by fxAccounts.getDevicesList()).
+ * @param {Object} tab
+ * @param {string} tab.url
+ * @param {string} tab.title
+ * @returns A report object, in the shape of
+ * {succeded: [Device], error: [{device: Device, error: Exception}]}
+ */
+ async send(to, tab) {
+ log.info(`Sending a tab to ${to.length} devices.`);
+ const flowID = this._fxai.telemetry.generateFlowID();
+ const encoder = new TextEncoder();
+ const data = { entries: [{ title: tab.title, url: tab.url }] };
+ const report = {
+ succeeded: [],
+ failed: [],
+ };
+ for (let device of to) {
+ try {
+ const streamID = this._fxai.telemetry.generateFlowID();
+ const targetData = Object.assign({ flowID, streamID }, data);
+ const bytes = encoder.encode(JSON.stringify(targetData));
+ const encrypted = await this._encrypt(bytes, device);
+ // FxA expects an object as the payload, but we only have a single encrypted string; wrap it.
+ // If you add any plaintext items to this payload, please carefully consider the privacy implications
+ // of revealing that data to the FxA server.
+ const payload = { encrypted };
+ await this._commands.invoke(COMMAND_SENDTAB, device, payload);
+ this._fxai.telemetry.recordEvent(
+ "command-sent",
+ COMMAND_SENDTAB_TAIL,
+ this._fxai.telemetry.sanitizeDeviceId(device.id),
+ { flowID, streamID }
+ );
+ report.succeeded.push(device);
+ } catch (error) {
+ log.error("Error while invoking a send tab command.", error);
+ report.failed.push({ device, error });
+ }
+ }
+ return report;
+ }
+
+ // Returns true if the target device is compatible with FxA Commands Send tab.
+ isDeviceCompatible(device) {
+ return (
+ device.availableCommands && device.availableCommands[COMMAND_SENDTAB]
+ );
+ }
+
+ // Handle incoming send tab payload, called by FxAccountsCommands.
+ async handle(senderID, { encrypted }, reason) {
+ const bytes = await this._decrypt(encrypted);
+ const decoder = new TextDecoder("utf8");
+ const data = JSON.parse(decoder.decode(bytes));
+ const { flowID, streamID, entries } = data;
+ const current = data.hasOwnProperty("current")
+ ? data.current
+ : entries.length - 1;
+ const { title, url: uri } = entries[current];
+ // `flowID` and `streamID` are in the top-level of the JSON, `entries` is
+ // an array of "tabs" with `current` being what index is the one we care
+ // about, or the last one if not specified.
+ this._fxai.telemetry.recordEvent(
+ "command-received",
+ COMMAND_SENDTAB_TAIL,
+ this._fxai.telemetry.sanitizeDeviceId(senderID),
+ { flowID, streamID, reason }
+ );
+
+ return {
+ title,
+ uri,
+ };
+ }
+
+ async _encrypt(bytes, device) {
+ let bundle = device.availableCommands[COMMAND_SENDTAB];
+ if (!bundle) {
+ throw new Error(`Device ${device.id} does not have send tab keys.`);
+ }
+ const oldsyncKey = await this._fxai.keys.getKeyForScope(SCOPE_OLD_SYNC);
+ // Older clients expect this to be hex, due to pre-JWK sync key ids :-(
+ const ourKid = this._fxai.keys.kidAsHex(oldsyncKey);
+ const { kid: theirKid } = JSON.parse(
+ device.availableCommands[COMMAND_SENDTAB]
+ );
+ if (theirKid != ourKid) {
+ throw new Error("Target Send Tab key ID is different from ours");
+ }
+ const json = JSON.parse(bundle);
+ const wrapper = new lazy.CryptoWrapper();
+ wrapper.deserialize({ payload: json });
+ const syncKeyBundle = lazy.BulkKeyBundle.fromJWK(oldsyncKey);
+ let { publicKey, authSecret } = await wrapper.decrypt(syncKeyBundle);
+ authSecret = urlsafeBase64Decode(authSecret);
+ publicKey = urlsafeBase64Decode(publicKey);
+
+ const { ciphertext: encrypted } = await lazy.PushCrypto.encrypt(
+ bytes,
+ publicKey,
+ authSecret
+ );
+ return urlsafeBase64Encode(encrypted);
+ }
+
+ async _getPersistedSendTabKeys() {
+ const { device } = await this._fxai.getUserAccountData(["device"]);
+ return device && device.sendTabKeys;
+ }
+
+ async _decrypt(ciphertext) {
+ let { privateKey, publicKey, authSecret } =
+ await this._getPersistedSendTabKeys();
+ publicKey = urlsafeBase64Decode(publicKey);
+ authSecret = urlsafeBase64Decode(authSecret);
+ ciphertext = new Uint8Array(urlsafeBase64Decode(ciphertext));
+ return lazy.PushCrypto.decrypt(
+ privateKey,
+ publicKey,
+ authSecret,
+ // The only Push encoding we support.
+ { encoding: "aes128gcm" },
+ ciphertext
+ );
+ }
+
+ async _generateAndPersistSendTabKeys() {
+ let [publicKey, privateKey] = await lazy.PushCrypto.generateKeys();
+ publicKey = urlsafeBase64Encode(publicKey);
+ let authSecret = lazy.PushCrypto.generateAuthenticationSecret();
+ authSecret = urlsafeBase64Encode(authSecret);
+ const sendTabKeys = {
+ publicKey,
+ privateKey,
+ authSecret,
+ };
+ await this._fxai.withCurrentAccountState(async state => {
+ const { device } = await state.getUserAccountData(["device"]);
+ await state.updateUserAccountData({
+ device: {
+ ...device,
+ sendTabKeys,
+ },
+ });
+ });
+ return sendTabKeys;
+ }
+
+ async _getPersistedEncryptedSendTabKey() {
+ const { encryptedSendTabKeys } = await this._fxai.getUserAccountData([
+ "encryptedSendTabKeys",
+ ]);
+ return encryptedSendTabKeys;
+ }
+
+ async _generateAndPersistEncryptedSendTabKey() {
+ let sendTabKeys = await this._getPersistedSendTabKeys();
+ if (!sendTabKeys) {
+ log.info("Could not find sendtab keys, generating them");
+ sendTabKeys = await this._generateAndPersistSendTabKeys();
+ }
+ // Strip the private key from the bundle to encrypt.
+ const keyToEncrypt = {
+ publicKey: sendTabKeys.publicKey,
+ authSecret: sendTabKeys.authSecret,
+ };
+ if (!(await this._fxai.keys.canGetKeyForScope(SCOPE_OLD_SYNC))) {
+ log.info("Can't fetch keys, so unable to determine sendtab keys");
+ return null;
+ }
+ let oldsyncKey;
+ try {
+ oldsyncKey = await this._fxai.keys.getKeyForScope(SCOPE_OLD_SYNC);
+ } catch (ex) {
+ log.warn("Failed to fetch keys, so unable to determine sendtab keys", ex);
+ return null;
+ }
+ const wrapper = new lazy.CryptoWrapper();
+ wrapper.cleartext = keyToEncrypt;
+ const keyBundle = lazy.BulkKeyBundle.fromJWK(oldsyncKey);
+ await wrapper.encrypt(keyBundle);
+ const encryptedSendTabKeys = JSON.stringify({
+ // This is expected in hex, due to pre-JWK sync key ids :-(
+ kid: this._fxai.keys.kidAsHex(oldsyncKey),
+ IV: wrapper.IV,
+ hmac: wrapper.hmac,
+ ciphertext: wrapper.ciphertext,
+ });
+ await this._fxai.withCurrentAccountState(async state => {
+ await state.updateUserAccountData({
+ encryptedSendTabKeys,
+ });
+ });
+ return encryptedSendTabKeys;
+ }
+
+ async getEncryptedSendTabKeys() {
+ let encryptedSendTabKeys = await this._getPersistedEncryptedSendTabKey();
+ const sendTabKeys = await this._getPersistedSendTabKeys();
+ if (!encryptedSendTabKeys || !sendTabKeys) {
+ log.info("Generating and persisting encrypted sendtab keys");
+ // `_generateAndPersistEncryptedKeys` requires the sync key
+ // which cannot be accessed if the login manager is locked
+ // (i.e when the primary password is locked) or if the sync keys
+ // aren't accessible (account isn't verified)
+ // so this function could fail to retrieve the keys
+ // however, device registration will trigger when the account
+ // is verified, so it's OK
+ // Note that it's okay to persist those keys, because they are
+ // already persisted in plaintext and the encrypted bundle
+ // does not include the sync-key (the sync key is used to encrypt
+ // it though)
+ encryptedSendTabKeys =
+ await this._generateAndPersistEncryptedSendTabKey();
+ }
+ return encryptedSendTabKeys;
+ }
+}
+
+function urlsafeBase64Encode(buffer) {
+ return ChromeUtils.base64URLEncode(new Uint8Array(buffer), { pad: false });
+}
+
+function urlsafeBase64Decode(str) {
+ return ChromeUtils.base64URLDecode(str, { padding: "reject" });
+}
diff --git a/services/fxaccounts/FxAccountsCommon.sys.mjs b/services/fxaccounts/FxAccountsCommon.sys.mjs
new file mode 100644
index 0000000000..2688fc3c0a
--- /dev/null
+++ b/services/fxaccounts/FxAccountsCommon.sys.mjs
@@ -0,0 +1,393 @@
+/* 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 { Log } from "resource://gre/modules/Log.sys.mjs";
+import { LogManager } from "resource://services-common/logmanager.sys.mjs";
+
+// loglevel should be one of "Fatal", "Error", "Warn", "Info", "Config",
+// "Debug", "Trace" or "All". If none is specified, "Debug" will be used by
+// default. Note "Debug" is usually appropriate so that when this log is
+// included in the Sync file logs we get verbose output.
+const PREF_LOG_LEVEL = "identity.fxaccounts.loglevel";
+
+// A pref that can be set so "sensitive" information (eg, personally
+// identifiable info, credentials, etc) will be logged.
+const PREF_LOG_SENSITIVE_DETAILS = "identity.fxaccounts.log.sensitive";
+
+export let log = Log.repository.getLogger("FirefoxAccounts");
+log.manageLevelFromPref(PREF_LOG_LEVEL);
+
+let logs = [
+ "Sync",
+ "Services.Common",
+ "FirefoxAccounts",
+ "Hawk",
+ "browserwindow.syncui",
+ "BookmarkSyncUtils",
+ "addons.xpi",
+];
+
+// For legacy reasons, the log manager still thinks it's part of sync.
+export let logManager = new LogManager("services.sync.", logs, "sync");
+
+// A boolean to indicate if personally identifiable information (or anything
+// else sensitive, such as credentials) should be logged.
+export let logPII = () =>
+ Services.prefs.getBoolPref(PREF_LOG_SENSITIVE_DETAILS, false);
+
+export let FXACCOUNTS_PERMISSION = "firefox-accounts";
+
+export let DATA_FORMAT_VERSION = 1;
+export let DEFAULT_STORAGE_FILENAME = "signedInUser.json";
+
+export let OAUTH_TOKEN_FOR_SYNC_LIFETIME_SECONDS = 3600 * 6; // 6 hours
+
+// After we start polling for account verification, we stop polling when this
+// many milliseconds have elapsed.
+export let POLL_SESSION = 1000 * 60 * 20; // 20 minutes
+
+// Observer notifications.
+export let ONLOGIN_NOTIFICATION = "fxaccounts:onlogin";
+export let ONVERIFIED_NOTIFICATION = "fxaccounts:onverified";
+export let ONLOGOUT_NOTIFICATION = "fxaccounts:onlogout";
+export let ON_PRELOGOUT_NOTIFICATION = "fxaccounts:on_pre_logout";
+// Internal to services/fxaccounts only
+export let ON_DEVICE_CONNECTED_NOTIFICATION = "fxaccounts:device_connected";
+export let ON_DEVICE_DISCONNECTED_NOTIFICATION =
+ "fxaccounts:device_disconnected";
+export let ON_PROFILE_UPDATED_NOTIFICATION = "fxaccounts:profile_updated"; // Push
+export let ON_PASSWORD_CHANGED_NOTIFICATION = "fxaccounts:password_changed";
+export let ON_PASSWORD_RESET_NOTIFICATION = "fxaccounts:password_reset";
+export let ON_ACCOUNT_DESTROYED_NOTIFICATION = "fxaccounts:account_destroyed";
+export let ON_COLLECTION_CHANGED_NOTIFICATION = "sync:collection_changed";
+export let ON_VERIFY_LOGIN_NOTIFICATION = "fxaccounts:verify_login";
+export let ON_COMMAND_RECEIVED_NOTIFICATION = "fxaccounts:command_received";
+
+export let FXA_PUSH_SCOPE_ACCOUNT_UPDATE = "chrome://fxa-device-update";
+
+export let ON_PROFILE_CHANGE_NOTIFICATION = "fxaccounts:profilechange"; // WebChannel
+export let ON_ACCOUNT_STATE_CHANGE_NOTIFICATION = "fxaccounts:statechange";
+export let ON_NEW_DEVICE_ID = "fxaccounts:new_device_id";
+export let ON_DEVICELIST_UPDATED = "fxaccounts:devicelist_updated";
+
+// The common prefix for all commands.
+export let COMMAND_PREFIX = "https://identity.mozilla.com/cmd/";
+
+// The commands we support - only the _TAIL values are recorded in telemetry.
+export let COMMAND_SENDTAB_TAIL = "open-uri";
+export let COMMAND_SENDTAB = COMMAND_PREFIX + COMMAND_SENDTAB_TAIL;
+
+// OAuth
+export let FX_OAUTH_CLIENT_ID = "5882386c6d801776";
+export let SCOPE_PROFILE = "profile";
+export let SCOPE_PROFILE_WRITE = "profile:write";
+export let SCOPE_OLD_SYNC = "https://identity.mozilla.com/apps/oldsync";
+
+// This scope was previously used to calculate a telemetry tracking identifier for
+// the account, but that system has since been decommissioned. It's here entirely
+// so that we can remove the corresponding key from storage if present. We should
+// be safe to remove it after some sensible period of time has elapsed to allow
+// most clients to update; ref Bug 1697596.
+export let DEPRECATED_SCOPE_ECOSYSTEM_TELEMETRY =
+ "https://identity.mozilla.com/ids/ecosystem_telemetry";
+
+// OAuth metadata for other Firefox-related services that we might need to know about
+// in order to provide an enhanced user experience.
+export let FX_MONITOR_OAUTH_CLIENT_ID = "802d56ef2a9af9fa";
+export let FX_RELAY_OAUTH_CLIENT_ID = "9ebfe2c2f9ea3c58";
+export let VPN_OAUTH_CLIENT_ID = "e6eb0d1e856335fc";
+
+// UI Requests.
+export let UI_REQUEST_SIGN_IN_FLOW = "signInFlow";
+export let UI_REQUEST_REFRESH_AUTH = "refreshAuthentication";
+
+// Firefox Accounts WebChannel ID
+export let WEBCHANNEL_ID = "account_updates";
+
+// WebChannel commands
+export let COMMAND_PAIR_HEARTBEAT = "fxaccounts:pair_heartbeat";
+export let COMMAND_PAIR_SUPP_METADATA = "fxaccounts:pair_supplicant_metadata";
+export let COMMAND_PAIR_AUTHORIZE = "fxaccounts:pair_authorize";
+export let COMMAND_PAIR_DECLINE = "fxaccounts:pair_decline";
+export let COMMAND_PAIR_COMPLETE = "fxaccounts:pair_complete";
+
+export let COMMAND_PROFILE_CHANGE = "profile:change";
+export let COMMAND_CAN_LINK_ACCOUNT = "fxaccounts:can_link_account";
+export let COMMAND_LOGIN = "fxaccounts:login";
+export let COMMAND_OAUTH = "fxaccounts:oauth_login";
+export let COMMAND_LOGOUT = "fxaccounts:logout";
+export let COMMAND_DELETE = "fxaccounts:delete";
+export let COMMAND_SYNC_PREFERENCES = "fxaccounts:sync_preferences";
+export let COMMAND_CHANGE_PASSWORD = "fxaccounts:change_password";
+export let COMMAND_FXA_STATUS = "fxaccounts:fxa_status";
+export let COMMAND_PAIR_PREFERENCES = "fxaccounts:pair_preferences";
+export let COMMAND_FIREFOX_VIEW = "fxaccounts:firefox_view";
+
+// The pref branch where any prefs which relate to a specific account should
+// be stored. This branch will be reset on account signout and signin.
+export let PREF_ACCOUNT_ROOT = "identity.fxaccounts.account.";
+
+export let PREF_LAST_FXA_USER = "identity.fxaccounts.lastSignedInUserHash";
+export let PREF_REMOTE_PAIRING_URI = "identity.fxaccounts.remote.pairing.uri";
+
+// Server errno.
+// From https://github.com/mozilla/fxa-auth-server/blob/master/docs/api.md#response-format
+export let ERRNO_ACCOUNT_ALREADY_EXISTS = 101;
+export let ERRNO_ACCOUNT_DOES_NOT_EXIST = 102;
+export let ERRNO_INCORRECT_PASSWORD = 103;
+export let ERRNO_UNVERIFIED_ACCOUNT = 104;
+export let ERRNO_INVALID_VERIFICATION_CODE = 105;
+export let ERRNO_NOT_VALID_JSON_BODY = 106;
+export let ERRNO_INVALID_BODY_PARAMETERS = 107;
+export let ERRNO_MISSING_BODY_PARAMETERS = 108;
+export let ERRNO_INVALID_REQUEST_SIGNATURE = 109;
+export let ERRNO_INVALID_AUTH_TOKEN = 110;
+export let ERRNO_INVALID_AUTH_TIMESTAMP = 111;
+export let ERRNO_MISSING_CONTENT_LENGTH = 112;
+export let ERRNO_REQUEST_BODY_TOO_LARGE = 113;
+export let ERRNO_TOO_MANY_CLIENT_REQUESTS = 114;
+export let ERRNO_INVALID_AUTH_NONCE = 115;
+export let ERRNO_ENDPOINT_NO_LONGER_SUPPORTED = 116;
+export let ERRNO_INCORRECT_LOGIN_METHOD = 117;
+export let ERRNO_INCORRECT_KEY_RETRIEVAL_METHOD = 118;
+export let ERRNO_INCORRECT_API_VERSION = 119;
+export let ERRNO_INCORRECT_EMAIL_CASE = 120;
+export let ERRNO_ACCOUNT_LOCKED = 121;
+export let ERRNO_ACCOUNT_UNLOCKED = 122;
+export let ERRNO_UNKNOWN_DEVICE = 123;
+export let ERRNO_DEVICE_SESSION_CONFLICT = 124;
+export let ERRNO_SERVICE_TEMP_UNAVAILABLE = 201;
+export let ERRNO_PARSE = 997;
+export let ERRNO_NETWORK = 998;
+export let ERRNO_UNKNOWN_ERROR = 999;
+
+// Offset oauth server errnos so they don't conflict with auth server errnos
+export let OAUTH_SERVER_ERRNO_OFFSET = 1000;
+
+// OAuth Server errno.
+export let ERRNO_UNKNOWN_CLIENT_ID = 101 + OAUTH_SERVER_ERRNO_OFFSET;
+export let ERRNO_INCORRECT_CLIENT_SECRET = 102 + OAUTH_SERVER_ERRNO_OFFSET;
+export let ERRNO_INCORRECT_REDIRECT_URI = 103 + OAUTH_SERVER_ERRNO_OFFSET;
+export let ERRNO_INVALID_FXA_ASSERTION = 104 + OAUTH_SERVER_ERRNO_OFFSET;
+export let ERRNO_UNKNOWN_CODE = 105 + OAUTH_SERVER_ERRNO_OFFSET;
+export let ERRNO_INCORRECT_CODE = 106 + OAUTH_SERVER_ERRNO_OFFSET;
+export let ERRNO_EXPIRED_CODE = 107 + OAUTH_SERVER_ERRNO_OFFSET;
+export let ERRNO_OAUTH_INVALID_TOKEN = 108 + OAUTH_SERVER_ERRNO_OFFSET;
+export let ERRNO_INVALID_REQUEST_PARAM = 109 + OAUTH_SERVER_ERRNO_OFFSET;
+export let ERRNO_INVALID_RESPONSE_TYPE = 110 + OAUTH_SERVER_ERRNO_OFFSET;
+export let ERRNO_UNAUTHORIZED = 111 + OAUTH_SERVER_ERRNO_OFFSET;
+export let ERRNO_FORBIDDEN = 112 + OAUTH_SERVER_ERRNO_OFFSET;
+export let ERRNO_INVALID_CONTENT_TYPE = 113 + OAUTH_SERVER_ERRNO_OFFSET;
+
+// Errors.
+export let ERROR_ACCOUNT_ALREADY_EXISTS = "ACCOUNT_ALREADY_EXISTS";
+export let ERROR_ACCOUNT_DOES_NOT_EXIST = "ACCOUNT_DOES_NOT_EXIST ";
+export let ERROR_ACCOUNT_LOCKED = "ACCOUNT_LOCKED";
+export let ERROR_ACCOUNT_UNLOCKED = "ACCOUNT_UNLOCKED";
+export let ERROR_ALREADY_SIGNED_IN_USER = "ALREADY_SIGNED_IN_USER";
+export let ERROR_DEVICE_SESSION_CONFLICT = "DEVICE_SESSION_CONFLICT";
+export let ERROR_ENDPOINT_NO_LONGER_SUPPORTED = "ENDPOINT_NO_LONGER_SUPPORTED";
+export let ERROR_INCORRECT_API_VERSION = "INCORRECT_API_VERSION";
+export let ERROR_INCORRECT_EMAIL_CASE = "INCORRECT_EMAIL_CASE";
+export let ERROR_INCORRECT_KEY_RETRIEVAL_METHOD =
+ "INCORRECT_KEY_RETRIEVAL_METHOD";
+export let ERROR_INCORRECT_LOGIN_METHOD = "INCORRECT_LOGIN_METHOD";
+export let ERROR_INVALID_EMAIL = "INVALID_EMAIL";
+export let ERROR_INVALID_AUDIENCE = "INVALID_AUDIENCE";
+export let ERROR_INVALID_AUTH_TOKEN = "INVALID_AUTH_TOKEN";
+export let ERROR_INVALID_AUTH_TIMESTAMP = "INVALID_AUTH_TIMESTAMP";
+export let ERROR_INVALID_AUTH_NONCE = "INVALID_AUTH_NONCE";
+export let ERROR_INVALID_BODY_PARAMETERS = "INVALID_BODY_PARAMETERS";
+export let ERROR_INVALID_PASSWORD = "INVALID_PASSWORD";
+export let ERROR_INVALID_VERIFICATION_CODE = "INVALID_VERIFICATION_CODE";
+export let ERROR_INVALID_REFRESH_AUTH_VALUE = "INVALID_REFRESH_AUTH_VALUE";
+export let ERROR_INVALID_REQUEST_SIGNATURE = "INVALID_REQUEST_SIGNATURE";
+export let ERROR_INTERNAL_INVALID_USER = "INTERNAL_ERROR_INVALID_USER";
+export let ERROR_MISSING_BODY_PARAMETERS = "MISSING_BODY_PARAMETERS";
+export let ERROR_MISSING_CONTENT_LENGTH = "MISSING_CONTENT_LENGTH";
+export let ERROR_NO_TOKEN_SESSION = "NO_TOKEN_SESSION";
+export let ERROR_NO_SILENT_REFRESH_AUTH = "NO_SILENT_REFRESH_AUTH";
+export let ERROR_NOT_VALID_JSON_BODY = "NOT_VALID_JSON_BODY";
+export let ERROR_OFFLINE = "OFFLINE";
+export let ERROR_PERMISSION_DENIED = "PERMISSION_DENIED";
+export let ERROR_REQUEST_BODY_TOO_LARGE = "REQUEST_BODY_TOO_LARGE";
+export let ERROR_SERVER_ERROR = "SERVER_ERROR";
+export let ERROR_SYNC_DISABLED = "SYNC_DISABLED";
+export let ERROR_TOO_MANY_CLIENT_REQUESTS = "TOO_MANY_CLIENT_REQUESTS";
+export let ERROR_SERVICE_TEMP_UNAVAILABLE = "SERVICE_TEMPORARY_UNAVAILABLE";
+export let ERROR_UI_ERROR = "UI_ERROR";
+export let ERROR_UI_REQUEST = "UI_REQUEST";
+export let ERROR_PARSE = "PARSE_ERROR";
+export let ERROR_NETWORK = "NETWORK_ERROR";
+export let ERROR_UNKNOWN = "UNKNOWN_ERROR";
+export let ERROR_UNKNOWN_DEVICE = "UNKNOWN_DEVICE";
+export let ERROR_UNVERIFIED_ACCOUNT = "UNVERIFIED_ACCOUNT";
+
+// OAuth errors.
+export let ERROR_UNKNOWN_CLIENT_ID = "UNKNOWN_CLIENT_ID";
+export let ERROR_INCORRECT_CLIENT_SECRET = "INCORRECT_CLIENT_SECRET";
+export let ERROR_INCORRECT_REDIRECT_URI = "INCORRECT_REDIRECT_URI";
+export let ERROR_INVALID_FXA_ASSERTION = "INVALID_FXA_ASSERTION";
+export let ERROR_UNKNOWN_CODE = "UNKNOWN_CODE";
+export let ERROR_INCORRECT_CODE = "INCORRECT_CODE";
+export let ERROR_EXPIRED_CODE = "EXPIRED_CODE";
+export let ERROR_OAUTH_INVALID_TOKEN = "OAUTH_INVALID_TOKEN";
+export let ERROR_INVALID_REQUEST_PARAM = "INVALID_REQUEST_PARAM";
+export let ERROR_INVALID_RESPONSE_TYPE = "INVALID_RESPONSE_TYPE";
+export let ERROR_UNAUTHORIZED = "UNAUTHORIZED";
+export let ERROR_FORBIDDEN = "FORBIDDEN";
+export let ERROR_INVALID_CONTENT_TYPE = "INVALID_CONTENT_TYPE";
+
+// Additional generic error classes for external consumers
+export let ERROR_NO_ACCOUNT = "NO_ACCOUNT";
+export let ERROR_AUTH_ERROR = "AUTH_ERROR";
+export let ERROR_INVALID_PARAMETER = "INVALID_PARAMETER";
+
+// Status code errors
+export let ERROR_CODE_METHOD_NOT_ALLOWED = 405;
+export let ERROR_MSG_METHOD_NOT_ALLOWED = "METHOD_NOT_ALLOWED";
+
+// FxAccounts has the ability to "split" the credentials between a plain-text
+// JSON file in the profile dir and in the login manager.
+// In order to prevent new fields accidentally ending up in the "wrong" place,
+// all fields stored are listed here.
+
+// The fields we save in the plaintext JSON.
+// See bug 1013064 comments 23-25 for why the sessionToken is "safe"
+export let FXA_PWDMGR_PLAINTEXT_FIELDS = new Set([
+ "email",
+ "verified",
+ "authAt",
+ "sessionToken",
+ "uid",
+ "oauthTokens",
+ "profile",
+ "device",
+ "profileCache",
+ "encryptedSendTabKeys",
+]);
+
+// Fields we store in secure storage if it exists.
+export let FXA_PWDMGR_SECURE_FIELDS = new Set([
+ "keyFetchToken",
+ "unwrapBKey",
+ "scopedKeys",
+]);
+
+// An allowlist of fields that remain in storage when the user needs to
+// reauthenticate. All other fields will be removed.
+export let FXA_PWDMGR_REAUTH_ALLOWLIST = new Set([
+ "email",
+ "uid",
+ "profile",
+ "device",
+ "verified",
+]);
+
+// The pseudo-host we use in the login manager
+export let FXA_PWDMGR_HOST = "chrome://FirefoxAccounts";
+// The realm we use in the login manager.
+export let FXA_PWDMGR_REALM = "Firefox Accounts credentials";
+
+// Error matching.
+export let SERVER_ERRNO_TO_ERROR = {
+ [ERRNO_ACCOUNT_ALREADY_EXISTS]: ERROR_ACCOUNT_ALREADY_EXISTS,
+ [ERRNO_ACCOUNT_DOES_NOT_EXIST]: ERROR_ACCOUNT_DOES_NOT_EXIST,
+ [ERRNO_INCORRECT_PASSWORD]: ERROR_INVALID_PASSWORD,
+ [ERRNO_UNVERIFIED_ACCOUNT]: ERROR_UNVERIFIED_ACCOUNT,
+ [ERRNO_INVALID_VERIFICATION_CODE]: ERROR_INVALID_VERIFICATION_CODE,
+ [ERRNO_NOT_VALID_JSON_BODY]: ERROR_NOT_VALID_JSON_BODY,
+ [ERRNO_INVALID_BODY_PARAMETERS]: ERROR_INVALID_BODY_PARAMETERS,
+ [ERRNO_MISSING_BODY_PARAMETERS]: ERROR_MISSING_BODY_PARAMETERS,
+ [ERRNO_INVALID_REQUEST_SIGNATURE]: ERROR_INVALID_REQUEST_SIGNATURE,
+ [ERRNO_INVALID_AUTH_TOKEN]: ERROR_INVALID_AUTH_TOKEN,
+ [ERRNO_INVALID_AUTH_TIMESTAMP]: ERROR_INVALID_AUTH_TIMESTAMP,
+ [ERRNO_MISSING_CONTENT_LENGTH]: ERROR_MISSING_CONTENT_LENGTH,
+ [ERRNO_REQUEST_BODY_TOO_LARGE]: ERROR_REQUEST_BODY_TOO_LARGE,
+ [ERRNO_TOO_MANY_CLIENT_REQUESTS]: ERROR_TOO_MANY_CLIENT_REQUESTS,
+ [ERRNO_INVALID_AUTH_NONCE]: ERROR_INVALID_AUTH_NONCE,
+ [ERRNO_ENDPOINT_NO_LONGER_SUPPORTED]: ERROR_ENDPOINT_NO_LONGER_SUPPORTED,
+ [ERRNO_INCORRECT_LOGIN_METHOD]: ERROR_INCORRECT_LOGIN_METHOD,
+ [ERRNO_INCORRECT_KEY_RETRIEVAL_METHOD]: ERROR_INCORRECT_KEY_RETRIEVAL_METHOD,
+ [ERRNO_INCORRECT_API_VERSION]: ERROR_INCORRECT_API_VERSION,
+ [ERRNO_INCORRECT_EMAIL_CASE]: ERROR_INCORRECT_EMAIL_CASE,
+ [ERRNO_ACCOUNT_LOCKED]: ERROR_ACCOUNT_LOCKED,
+ [ERRNO_ACCOUNT_UNLOCKED]: ERROR_ACCOUNT_UNLOCKED,
+ [ERRNO_UNKNOWN_DEVICE]: ERROR_UNKNOWN_DEVICE,
+ [ERRNO_DEVICE_SESSION_CONFLICT]: ERROR_DEVICE_SESSION_CONFLICT,
+ [ERRNO_SERVICE_TEMP_UNAVAILABLE]: ERROR_SERVICE_TEMP_UNAVAILABLE,
+ [ERRNO_UNKNOWN_ERROR]: ERROR_UNKNOWN,
+ [ERRNO_NETWORK]: ERROR_NETWORK,
+ // oauth
+ [ERRNO_UNKNOWN_CLIENT_ID]: ERROR_UNKNOWN_CLIENT_ID,
+ [ERRNO_INCORRECT_CLIENT_SECRET]: ERROR_INCORRECT_CLIENT_SECRET,
+ [ERRNO_INCORRECT_REDIRECT_URI]: ERROR_INCORRECT_REDIRECT_URI,
+ [ERRNO_INVALID_FXA_ASSERTION]: ERROR_INVALID_FXA_ASSERTION,
+ [ERRNO_UNKNOWN_CODE]: ERROR_UNKNOWN_CODE,
+ [ERRNO_INCORRECT_CODE]: ERROR_INCORRECT_CODE,
+ [ERRNO_EXPIRED_CODE]: ERROR_EXPIRED_CODE,
+ [ERRNO_OAUTH_INVALID_TOKEN]: ERROR_OAUTH_INVALID_TOKEN,
+ [ERRNO_INVALID_REQUEST_PARAM]: ERROR_INVALID_REQUEST_PARAM,
+ [ERRNO_INVALID_RESPONSE_TYPE]: ERROR_INVALID_RESPONSE_TYPE,
+ [ERRNO_UNAUTHORIZED]: ERROR_UNAUTHORIZED,
+ [ERRNO_FORBIDDEN]: ERROR_FORBIDDEN,
+ [ERRNO_INVALID_CONTENT_TYPE]: ERROR_INVALID_CONTENT_TYPE,
+};
+
+// Map internal errors to more generic error classes for consumers
+export let ERROR_TO_GENERAL_ERROR_CLASS = {
+ [ERROR_ACCOUNT_ALREADY_EXISTS]: ERROR_AUTH_ERROR,
+ [ERROR_ACCOUNT_DOES_NOT_EXIST]: ERROR_AUTH_ERROR,
+ [ERROR_ACCOUNT_LOCKED]: ERROR_AUTH_ERROR,
+ [ERROR_ACCOUNT_UNLOCKED]: ERROR_AUTH_ERROR,
+ [ERROR_ALREADY_SIGNED_IN_USER]: ERROR_AUTH_ERROR,
+ [ERROR_DEVICE_SESSION_CONFLICT]: ERROR_AUTH_ERROR,
+ [ERROR_ENDPOINT_NO_LONGER_SUPPORTED]: ERROR_AUTH_ERROR,
+ [ERROR_INCORRECT_API_VERSION]: ERROR_AUTH_ERROR,
+ [ERROR_INCORRECT_EMAIL_CASE]: ERROR_AUTH_ERROR,
+ [ERROR_INCORRECT_KEY_RETRIEVAL_METHOD]: ERROR_AUTH_ERROR,
+ [ERROR_INCORRECT_LOGIN_METHOD]: ERROR_AUTH_ERROR,
+ [ERROR_INVALID_EMAIL]: ERROR_AUTH_ERROR,
+ [ERROR_INVALID_AUDIENCE]: ERROR_AUTH_ERROR,
+ [ERROR_INVALID_AUTH_TOKEN]: ERROR_AUTH_ERROR,
+ [ERROR_INVALID_AUTH_TIMESTAMP]: ERROR_AUTH_ERROR,
+ [ERROR_INVALID_AUTH_NONCE]: ERROR_AUTH_ERROR,
+ [ERROR_INVALID_BODY_PARAMETERS]: ERROR_AUTH_ERROR,
+ [ERROR_INVALID_PASSWORD]: ERROR_AUTH_ERROR,
+ [ERROR_INVALID_VERIFICATION_CODE]: ERROR_AUTH_ERROR,
+ [ERROR_INVALID_REFRESH_AUTH_VALUE]: ERROR_AUTH_ERROR,
+ [ERROR_INVALID_REQUEST_SIGNATURE]: ERROR_AUTH_ERROR,
+ [ERROR_INTERNAL_INVALID_USER]: ERROR_AUTH_ERROR,
+ [ERROR_MISSING_BODY_PARAMETERS]: ERROR_AUTH_ERROR,
+ [ERROR_MISSING_CONTENT_LENGTH]: ERROR_AUTH_ERROR,
+ [ERROR_NO_TOKEN_SESSION]: ERROR_AUTH_ERROR,
+ [ERROR_NO_SILENT_REFRESH_AUTH]: ERROR_AUTH_ERROR,
+ [ERROR_NOT_VALID_JSON_BODY]: ERROR_AUTH_ERROR,
+ [ERROR_PERMISSION_DENIED]: ERROR_AUTH_ERROR,
+ [ERROR_REQUEST_BODY_TOO_LARGE]: ERROR_AUTH_ERROR,
+ [ERROR_UNKNOWN_DEVICE]: ERROR_AUTH_ERROR,
+ [ERROR_UNVERIFIED_ACCOUNT]: ERROR_AUTH_ERROR,
+ [ERROR_UI_ERROR]: ERROR_AUTH_ERROR,
+ [ERROR_UI_REQUEST]: ERROR_AUTH_ERROR,
+ [ERROR_OFFLINE]: ERROR_NETWORK,
+ [ERROR_SERVER_ERROR]: ERROR_NETWORK,
+ [ERROR_TOO_MANY_CLIENT_REQUESTS]: ERROR_NETWORK,
+ [ERROR_SERVICE_TEMP_UNAVAILABLE]: ERROR_NETWORK,
+ [ERROR_PARSE]: ERROR_NETWORK,
+ [ERROR_NETWORK]: ERROR_NETWORK,
+
+ // oauth
+ [ERROR_INCORRECT_CLIENT_SECRET]: ERROR_AUTH_ERROR,
+ [ERROR_INCORRECT_REDIRECT_URI]: ERROR_AUTH_ERROR,
+ [ERROR_INVALID_FXA_ASSERTION]: ERROR_AUTH_ERROR,
+ [ERROR_UNKNOWN_CODE]: ERROR_AUTH_ERROR,
+ [ERROR_INCORRECT_CODE]: ERROR_AUTH_ERROR,
+ [ERROR_EXPIRED_CODE]: ERROR_AUTH_ERROR,
+ [ERROR_OAUTH_INVALID_TOKEN]: ERROR_AUTH_ERROR,
+ [ERROR_INVALID_REQUEST_PARAM]: ERROR_AUTH_ERROR,
+ [ERROR_INVALID_RESPONSE_TYPE]: ERROR_AUTH_ERROR,
+ [ERROR_UNAUTHORIZED]: ERROR_AUTH_ERROR,
+ [ERROR_FORBIDDEN]: ERROR_AUTH_ERROR,
+ [ERROR_INVALID_CONTENT_TYPE]: ERROR_AUTH_ERROR,
+};
diff --git a/services/fxaccounts/FxAccountsConfig.sys.mjs b/services/fxaccounts/FxAccountsConfig.sys.mjs
new file mode 100644
index 0000000000..cf26704a50
--- /dev/null
+++ b/services/fxaccounts/FxAccountsConfig.sys.mjs
@@ -0,0 +1,360 @@
+/* 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 { RESTRequest } from "resource://services-common/rest.sys.mjs";
+
+import {
+ log,
+ SCOPE_OLD_SYNC,
+ SCOPE_PROFILE,
+} from "resource://gre/modules/FxAccountsCommon.sys.mjs";
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineLazyGetter(lazy, "fxAccounts", () => {
+ return ChromeUtils.importESModule(
+ "resource://gre/modules/FxAccounts.sys.mjs"
+ ).getFxAccountsSingleton();
+});
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ EnsureFxAccountsWebChannel:
+ "resource://gre/modules/FxAccountsWebChannel.sys.mjs",
+});
+
+XPCOMUtils.defineLazyPreferenceGetter(
+ lazy,
+ "ROOT_URL",
+ "identity.fxaccounts.remote.root"
+);
+XPCOMUtils.defineLazyPreferenceGetter(
+ lazy,
+ "CONTEXT_PARAM",
+ "identity.fxaccounts.contextParam"
+);
+XPCOMUtils.defineLazyPreferenceGetter(
+ lazy,
+ "REQUIRES_HTTPS",
+ "identity.fxaccounts.allowHttp",
+ false,
+ null,
+ val => !val
+);
+
+const CONFIG_PREFS = [
+ "identity.fxaccounts.remote.root",
+ "identity.fxaccounts.auth.uri",
+ "identity.fxaccounts.remote.oauth.uri",
+ "identity.fxaccounts.remote.profile.uri",
+ "identity.fxaccounts.remote.pairing.uri",
+ "identity.sync.tokenserver.uri",
+];
+const SYNC_PARAM = "sync";
+
+export var FxAccountsConfig = {
+ async promiseEmailURI(email, entrypoint, extraParams = {}) {
+ const authParams = await this._getAuthParams();
+ return this._buildURL("", {
+ extraParams: {
+ entrypoint,
+ email,
+ ...authParams,
+ ...extraParams,
+ },
+ });
+ },
+
+ async promiseConnectAccountURI(entrypoint, extraParams = {}) {
+ const authParams = await this._getAuthParams();
+ return this._buildURL("", {
+ extraParams: {
+ entrypoint,
+ action: "email",
+ ...authParams,
+ ...extraParams,
+ },
+ });
+ },
+
+ async promiseForceSigninURI(entrypoint, extraParams = {}) {
+ const authParams = await this._getAuthParams();
+ return this._buildURL("force_auth", {
+ extraParams: { entrypoint, ...authParams, ...extraParams },
+ addAccountIdentifiers: true,
+ });
+ },
+
+ async promiseManageURI(entrypoint, extraParams = {}) {
+ return this._buildURL("settings", {
+ extraParams: { entrypoint, ...extraParams },
+ addAccountIdentifiers: true,
+ });
+ },
+
+ async promiseChangeAvatarURI(entrypoint, extraParams = {}) {
+ return this._buildURL("settings/avatar/change", {
+ extraParams: { entrypoint, ...extraParams },
+ addAccountIdentifiers: true,
+ });
+ },
+
+ async promiseManageDevicesURI(entrypoint, extraParams = {}) {
+ return this._buildURL("settings/clients", {
+ extraParams: { entrypoint, ...extraParams },
+ addAccountIdentifiers: true,
+ });
+ },
+
+ async promiseConnectDeviceURI(entrypoint, extraParams = {}) {
+ return this._buildURL("connect_another_device", {
+ extraParams: { entrypoint, service: SYNC_PARAM, ...extraParams },
+ addAccountIdentifiers: true,
+ });
+ },
+
+ async promisePairingURI(extraParams = {}) {
+ return this._buildURL("pair", {
+ extraParams,
+ includeDefaultParams: false,
+ });
+ },
+
+ async promiseOAuthURI(extraParams = {}) {
+ return this._buildURL("oauth", {
+ extraParams,
+ includeDefaultParams: false,
+ });
+ },
+
+ async promiseMetricsFlowURI(entrypoint, extraParams = {}) {
+ return this._buildURL("metrics-flow", {
+ extraParams: { entrypoint, ...extraParams },
+ includeDefaultParams: false,
+ });
+ },
+
+ get defaultParams() {
+ return { context: lazy.CONTEXT_PARAM };
+ },
+
+ /**
+ * @param path should be parsable by the URL constructor first parameter.
+ * @param {bool} [options.includeDefaultParams] If true include the default search params.
+ * @param {Object.<string, string>} [options.extraParams] Additionnal search params.
+ * @param {bool} [options.addAccountIdentifiers] if true we add the current logged-in user uid and email to the search params.
+ */
+ async _buildURL(
+ path,
+ {
+ includeDefaultParams = true,
+ extraParams = {},
+ addAccountIdentifiers = false,
+ }
+ ) {
+ await this.ensureConfigured();
+ const url = new URL(path, lazy.ROOT_URL);
+ if (lazy.REQUIRES_HTTPS && url.protocol != "https:") {
+ throw new Error("Firefox Accounts server must use HTTPS");
+ }
+ const params = {
+ ...(includeDefaultParams ? this.defaultParams : null),
+ ...extraParams,
+ };
+ for (let [k, v] of Object.entries(params)) {
+ url.searchParams.append(k, v);
+ }
+ if (addAccountIdentifiers) {
+ const accountData = await this.getSignedInUser();
+ if (!accountData) {
+ return null;
+ }
+ url.searchParams.append("uid", accountData.uid);
+ url.searchParams.append("email", accountData.email);
+ }
+ return url.href;
+ },
+
+ async _buildURLFromString(href, extraParams = {}) {
+ const url = new URL(href);
+ for (let [k, v] of Object.entries(extraParams)) {
+ url.searchParams.append(k, v);
+ }
+ return url.href;
+ },
+
+ resetConfigURLs() {
+ let autoconfigURL = this.getAutoConfigURL();
+ if (!autoconfigURL) {
+ return;
+ }
+ // They have the autoconfig uri pref set, so we clear all the prefs that we
+ // will have initialized, which will leave them pointing at production.
+ for (let pref of CONFIG_PREFS) {
+ Services.prefs.clearUserPref(pref);
+ }
+ // Reset the webchannel.
+ lazy.EnsureFxAccountsWebChannel();
+ },
+
+ getAutoConfigURL() {
+ let pref = Services.prefs.getStringPref(
+ "identity.fxaccounts.autoconfig.uri",
+ ""
+ );
+ if (!pref) {
+ // no pref / empty pref means we don't bother here.
+ return "";
+ }
+ let rootURL = Services.urlFormatter.formatURL(pref);
+ if (rootURL.endsWith("/")) {
+ rootURL = rootURL.slice(0, -1);
+ }
+ return rootURL;
+ },
+
+ async ensureConfigured() {
+ let isSignedIn = !!(await this.getSignedInUser());
+ if (!isSignedIn) {
+ await this.updateConfigURLs();
+ }
+ },
+
+ // Returns true if this user is using the FxA "production" systems, false
+ // if using any other configuration, including self-hosting or the FxA
+ // non-production systems such as "dev" or "staging".
+ // It's typically used as a proxy for "is this likely to be a self-hosted
+ // user?", but it's named this way to make the implementation less
+ // surprising. As a result, it's fairly conservative and would prefer to have
+ // a false-negative than a false-position as it determines things which users
+ // might consider sensitive (notably, telemetry).
+ // Note also that while it's possible to self-host just sync and not FxA, we
+ // don't make that distinction - that's a self-hoster from the POV of this
+ // function.
+ isProductionConfig() {
+ // Specifically, if the autoconfig URLs, or *any* of the URLs that
+ // we consider configurable are modified, we assume self-hosted.
+ if (this.getAutoConfigURL()) {
+ return false;
+ }
+ for (let pref of CONFIG_PREFS) {
+ if (Services.prefs.prefHasUserValue(pref)) {
+ return false;
+ }
+ }
+ return true;
+ },
+
+ // Read expected client configuration from the fxa auth server
+ // (from `identity.fxaccounts.autoconfig.uri`/.well-known/fxa-client-configuration)
+ // and replace all the relevant our prefs with the information found there.
+ // This is only done before sign-in and sign-up, and even then only if the
+ // `identity.fxaccounts.autoconfig.uri` preference is set.
+ async updateConfigURLs() {
+ let rootURL = this.getAutoConfigURL();
+ if (!rootURL) {
+ return;
+ }
+ const config = await this.fetchConfigDocument(rootURL);
+ try {
+ // Update the prefs directly specified by the config.
+ let authServerBase = config.auth_server_base_url;
+ if (!authServerBase.endsWith("/v1")) {
+ authServerBase += "/v1";
+ }
+ Services.prefs.setStringPref(
+ "identity.fxaccounts.auth.uri",
+ authServerBase
+ );
+ Services.prefs.setStringPref(
+ "identity.fxaccounts.remote.oauth.uri",
+ config.oauth_server_base_url + "/v1"
+ );
+ // At the time of landing this, our servers didn't yet answer with pairing_server_base_uri.
+ // Remove this condition check once Firefox 68 is stable.
+ if (config.pairing_server_base_uri) {
+ Services.prefs.setStringPref(
+ "identity.fxaccounts.remote.pairing.uri",
+ config.pairing_server_base_uri
+ );
+ }
+ Services.prefs.setStringPref(
+ "identity.fxaccounts.remote.profile.uri",
+ config.profile_server_base_url + "/v1"
+ );
+ Services.prefs.setStringPref(
+ "identity.sync.tokenserver.uri",
+ config.sync_tokenserver_base_url + "/1.0/sync/1.5"
+ );
+ Services.prefs.setStringPref("identity.fxaccounts.remote.root", rootURL);
+
+ // Ensure the webchannel is pointed at the correct uri
+ lazy.EnsureFxAccountsWebChannel();
+ } catch (e) {
+ log.error(
+ "Failed to initialize configuration preferences from autoconfig object",
+ e
+ );
+ throw e;
+ }
+ },
+
+ // Read expected client configuration from the fxa auth server
+ // (or from the provided rootURL, if present) and return it as an object.
+ async fetchConfigDocument(rootURL = null) {
+ if (!rootURL) {
+ rootURL = lazy.ROOT_URL;
+ }
+ let configURL = rootURL + "/.well-known/fxa-client-configuration";
+ let request = new RESTRequest(configURL);
+ request.setHeader("Accept", "application/json");
+
+ // Catch and rethrow the error inline.
+ let resp = await request.get().catch(e => {
+ log.error(`Failed to get configuration object from "${configURL}"`, e);
+ throw e;
+ });
+ if (!resp.success) {
+ // Note: 'resp.body' is included with the error log below as we are not concerned
+ // that the body will contain PII, but if that changes it should be excluded.
+ log.error(
+ `Received HTTP response code ${resp.status} from configuration object request:
+ ${resp.body}`
+ );
+ throw new Error(
+ `HTTP status ${resp.status} from configuration object request`
+ );
+ }
+ log.debug("Got successful configuration response", resp.body);
+ try {
+ return JSON.parse(resp.body);
+ } catch (e) {
+ log.error(
+ `Failed to parse configuration preferences from ${configURL}`,
+ e
+ );
+ throw e;
+ }
+ },
+
+ // For test purposes, returns a Promise.
+ getSignedInUser() {
+ return lazy.fxAccounts.getSignedInUser();
+ },
+
+ _isOAuthFlow() {
+ return Services.prefs.getBoolPref(
+ "identity.fxaccounts.oauth.enabled",
+ false
+ );
+ },
+
+ async _getAuthParams() {
+ if (this._isOAuthFlow()) {
+ const scopes = [SCOPE_OLD_SYNC, SCOPE_PROFILE];
+ return lazy.fxAccounts._internal.beginOAuthFlow(scopes);
+ }
+ return { service: SYNC_PARAM };
+ },
+};
diff --git a/services/fxaccounts/FxAccountsDevice.sys.mjs b/services/fxaccounts/FxAccountsDevice.sys.mjs
new file mode 100644
index 0000000000..6b2089739c
--- /dev/null
+++ b/services/fxaccounts/FxAccountsDevice.sys.mjs
@@ -0,0 +1,640 @@
+/* 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 { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+
+import {
+ log,
+ ERRNO_DEVICE_SESSION_CONFLICT,
+ ERRNO_UNKNOWN_DEVICE,
+ ON_NEW_DEVICE_ID,
+ ON_DEVICELIST_UPDATED,
+ ON_DEVICE_CONNECTED_NOTIFICATION,
+ ON_DEVICE_DISCONNECTED_NOTIFICATION,
+ ONVERIFIED_NOTIFICATION,
+ PREF_ACCOUNT_ROOT,
+} from "resource://gre/modules/FxAccountsCommon.sys.mjs";
+
+import { DEVICE_TYPE_DESKTOP } from "resource://services-sync/constants.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ CommonUtils: "resource://services-common/utils.sys.mjs",
+});
+
+const PREF_LOCAL_DEVICE_NAME = PREF_ACCOUNT_ROOT + "device.name";
+XPCOMUtils.defineLazyPreferenceGetter(
+ lazy,
+ "pref_localDeviceName",
+ PREF_LOCAL_DEVICE_NAME,
+ ""
+);
+
+const PREF_DEPRECATED_DEVICE_NAME = "services.sync.client.name";
+
+// Sanitizes all characters which the FxA server considers invalid, replacing
+// them with the unicode replacement character.
+// At time of writing, FxA has a regex DISPLAY_SAFE_UNICODE_WITH_NON_BMP, which
+// the regex below is based on.
+const INVALID_NAME_CHARS =
+ // eslint-disable-next-line no-control-regex
+ /[\u0000-\u001F\u007F\u0080-\u009F\u2028-\u2029\uE000-\uF8FF\uFFF9-\uFFFC\uFFFE-\uFFFF]/g;
+const MAX_NAME_LEN = 255;
+const REPLACEMENT_CHAR = "\uFFFD";
+
+function sanitizeDeviceName(name) {
+ return name
+ .substr(0, MAX_NAME_LEN)
+ .replace(INVALID_NAME_CHARS, REPLACEMENT_CHAR);
+}
+
+// Everything to do with FxA devices.
+export class FxAccountsDevice {
+ constructor(fxai) {
+ this._fxai = fxai;
+ this._deviceListCache = null;
+ this._fetchAndCacheDeviceListPromise = null;
+
+ // The current version of the device registration, we use this to re-register
+ // devices after we update what we send on device registration.
+ this.DEVICE_REGISTRATION_VERSION = 2;
+
+ // This is to avoid multiple sequential syncs ending up calling
+ // this expensive endpoint multiple times in a row.
+ this.TIME_BETWEEN_FXA_DEVICES_FETCH_MS = 1 * 60 * 1000; // 1 minute
+
+ // Invalidate our cached device list when a device is connected or disconnected.
+ Services.obs.addObserver(this, ON_DEVICE_CONNECTED_NOTIFICATION, true);
+ Services.obs.addObserver(this, ON_DEVICE_DISCONNECTED_NOTIFICATION, true);
+ // A user becoming verified probably means we need to re-register the device
+ // because we are now able to get the sendtab keys.
+ Services.obs.addObserver(this, ONVERIFIED_NOTIFICATION, true);
+ }
+
+ async getLocalId() {
+ return this._withCurrentAccountState(currentState => {
+ // It turns out _updateDeviceRegistrationIfNecessary() does exactly what we
+ // need.
+ return this._updateDeviceRegistrationIfNecessary(currentState);
+ });
+ }
+
+ // Generate a client name if we don't have a useful one yet
+ getDefaultLocalName() {
+ let user = Services.env.get("USER") || Services.env.get("USERNAME");
+ // Note that we used to fall back to the "services.sync.username" pref here,
+ // but that's no longer suitable in a world where sync might not be
+ // configured. However, we almost never *actually* fell back to that, and
+ // doing so sanely here would mean making this function async, which we don't
+ // really want to do yet.
+
+ // A little hack for people using the the moz-build environment on Windows
+ // which sets USER to the literal "%USERNAME%" (yes, really)
+ if (user == "%USERNAME%" && Services.env.get("USERNAME")) {
+ user = Services.env.get("USERNAME");
+ }
+
+ // The DNS service may fail to provide a hostname in edge-cases we don't
+ // fully understand - bug 1391488.
+ let hostname;
+ try {
+ // hostname of the system, usually assigned by the user or admin
+ hostname = Services.dns.myHostName;
+ } catch (ex) {
+ console.error(ex);
+ }
+ let system =
+ // 'device' is defined on unix systems
+ Services.sysinfo.get("device") ||
+ hostname ||
+ // fall back on ua info string
+ Cc["@mozilla.org/network/protocol;1?name=http"].getService(
+ Ci.nsIHttpProtocolHandler
+ ).oscpu;
+
+ const l10n = new Localization(
+ ["services/accounts.ftl", "branding/brand.ftl"],
+ true
+ );
+ return sanitizeDeviceName(
+ l10n.formatValueSync("account-client-name", { user, system })
+ );
+ }
+
+ getLocalName() {
+ // We used to store this in services.sync.client.name, but now store it
+ // under an fxa-specific location.
+ let deprecated_value = Services.prefs.getStringPref(
+ PREF_DEPRECATED_DEVICE_NAME,
+ ""
+ );
+ if (deprecated_value) {
+ Services.prefs.setStringPref(PREF_LOCAL_DEVICE_NAME, deprecated_value);
+ Services.prefs.clearUserPref(PREF_DEPRECATED_DEVICE_NAME);
+ }
+ let name = lazy.pref_localDeviceName;
+ if (!name) {
+ name = this.getDefaultLocalName();
+ Services.prefs.setStringPref(PREF_LOCAL_DEVICE_NAME, name);
+ }
+ // We need to sanitize here because some names were generated before we
+ // started sanitizing.
+ return sanitizeDeviceName(name);
+ }
+
+ setLocalName(newName) {
+ Services.prefs.clearUserPref(PREF_DEPRECATED_DEVICE_NAME);
+ Services.prefs.setStringPref(
+ PREF_LOCAL_DEVICE_NAME,
+ sanitizeDeviceName(newName)
+ );
+ // Update the registration in the background.
+ this.updateDeviceRegistration().catch(error => {
+ log.warn("failed to update fxa device registration", error);
+ });
+ }
+
+ getLocalType() {
+ return DEVICE_TYPE_DESKTOP;
+ }
+
+ /**
+ * Returns the most recently fetched device list, or `null` if the list
+ * hasn't been fetched yet. This is synchronous, so that consumers like
+ * Send Tab can render the device list right away, without waiting for
+ * it to refresh.
+ *
+ * @type {?Array}
+ */
+ get recentDeviceList() {
+ return this._deviceListCache ? this._deviceListCache.devices : null;
+ }
+
+ /**
+ * Refreshes the device list. After this function returns, consumers can
+ * access the new list using the `recentDeviceList` getter. Note that
+ * multiple concurrent calls to `refreshDeviceList` will only refresh the
+ * list once.
+ *
+ * @param {Boolean} [options.ignoreCached]
+ * If `true`, forces a refresh, even if the cached device list is
+ * still fresh. Defaults to `false`.
+ * @return {Promise<Boolean>}
+ * `true` if the list was refreshed, `false` if the cached list is
+ * fresh. Rejects if an error occurs refreshing the list or device
+ * push registration.
+ */
+ async refreshDeviceList({ ignoreCached = false } = {}) {
+ // If we're already refreshing the list in the background, let that finish.
+ if (this._fetchAndCacheDeviceListPromise) {
+ log.info("Already fetching device list, return existing promise");
+ return this._fetchAndCacheDeviceListPromise;
+ }
+
+ // If the cache is fresh enough, don't refresh it again.
+ if (!ignoreCached && this._deviceListCache) {
+ const ageOfCache = this._fxai.now() - this._deviceListCache.lastFetch;
+ if (ageOfCache < this.TIME_BETWEEN_FXA_DEVICES_FETCH_MS) {
+ log.info("Device list cache is fresh, re-using it");
+ return false;
+ }
+ }
+
+ log.info("fetching updated device list");
+ this._fetchAndCacheDeviceListPromise = (async () => {
+ try {
+ const devices = await this._withVerifiedAccountState(
+ async currentState => {
+ const accountData = await currentState.getUserAccountData([
+ "sessionToken",
+ "device",
+ ]);
+ const devices = await this._fxai.fxAccountsClient.getDeviceList(
+ accountData.sessionToken
+ );
+ log.info(
+ `Got new device list: ${devices.map(d => d.id).join(", ")}`
+ );
+
+ await this._refreshRemoteDevice(currentState, accountData, devices);
+ return devices;
+ }
+ );
+ log.info("updating the cache");
+ // Be careful to only update the cache once the above has resolved, so
+ // we know that the current account state didn't change underneath us.
+ this._deviceListCache = {
+ lastFetch: this._fxai.now(),
+ devices,
+ };
+ Services.obs.notifyObservers(null, ON_DEVICELIST_UPDATED);
+ return true;
+ } finally {
+ this._fetchAndCacheDeviceListPromise = null;
+ }
+ })();
+ return this._fetchAndCacheDeviceListPromise;
+ }
+
+ async _refreshRemoteDevice(currentState, accountData, remoteDevices) {
+ // Check if our push registration previously succeeded and is still
+ // good (although background device registration means it's possible
+ // we'll be fetching the device list before we've actually
+ // registered ourself!)
+ // (For a missing subscription we check for an explicit 'null' -
+ // both to help tests and as a safety valve - missing might mean
+ // "no push available" for self-hosters or similar?)
+ const ourDevice = remoteDevices.find(device => device.isCurrentDevice);
+ const subscription = await this._fxai.fxaPushService.getSubscription();
+ if (
+ ourDevice &&
+ (ourDevice.pushCallback === null || // fxa server doesn't know our subscription.
+ ourDevice.pushEndpointExpired || // fxa server thinks it has expired.
+ !subscription || // we don't have a local subscription.
+ subscription.isExpired() || // our local subscription is expired.
+ ourDevice.pushCallback != subscription.endpoint) // we don't agree with fxa.
+ ) {
+ log.warn(`Our push endpoint needs resubscription`);
+ await this._fxai.fxaPushService.unsubscribe();
+ await this._registerOrUpdateDevice(currentState, accountData);
+ // and there's a reasonable chance there are commands waiting.
+ await this._fxai.commands.pollDeviceCommands();
+ } else if (
+ ourDevice &&
+ (await this._checkRemoteCommandsUpdateNeeded(ourDevice.availableCommands))
+ ) {
+ log.warn(`Our commands need to be updated on the server`);
+ await this._registerOrUpdateDevice(currentState, accountData);
+ } else {
+ log.trace(`Our push subscription looks OK`);
+ }
+ }
+
+ async updateDeviceRegistration() {
+ return this._withCurrentAccountState(async currentState => {
+ const signedInUser = await currentState.getUserAccountData([
+ "sessionToken",
+ "device",
+ ]);
+ if (signedInUser) {
+ await this._registerOrUpdateDevice(currentState, signedInUser);
+ }
+ });
+ }
+
+ async updateDeviceRegistrationIfNecessary() {
+ return this._withCurrentAccountState(currentState => {
+ return this._updateDeviceRegistrationIfNecessary(currentState);
+ });
+ }
+
+ reset() {
+ this._deviceListCache = null;
+ this._fetchAndCacheDeviceListPromise = null;
+ }
+
+ /**
+ * Here begin our internal helper methods.
+ *
+ * Many of these methods take the current account state as first argument,
+ * in order to avoid racing our state updates with e.g. the uer signing
+ * out while we're in the middle of an update. If this does happen, the
+ * resulting promise will be rejected rather than persisting stale state.
+ *
+ */
+
+ _withCurrentAccountState(func) {
+ return this._fxai.withCurrentAccountState(async currentState => {
+ try {
+ return await func(currentState);
+ } catch (err) {
+ // `_handleTokenError` always throws, this syntax keeps the linter happy.
+ // TODO: probably `_handleTokenError` could be done by `_fxai.withCurrentAccountState`
+ // internally rather than us having to remember to do it here.
+ throw await this._fxai._handleTokenError(err);
+ }
+ });
+ }
+
+ _withVerifiedAccountState(func) {
+ return this._fxai.withVerifiedAccountState(async currentState => {
+ try {
+ return await func(currentState);
+ } catch (err) {
+ // `_handleTokenError` always throws, this syntax keeps the linter happy.
+ throw await this._fxai._handleTokenError(err);
+ }
+ });
+ }
+
+ async _checkDeviceUpdateNeeded(device) {
+ // There is no device registered or the device registration is outdated.
+ // Either way, we should register the device with FxA
+ // before returning the id to the caller.
+ const availableCommandsKeys = Object.keys(
+ await this._fxai.commands.availableCommands()
+ ).sort();
+ return (
+ !device ||
+ !device.registrationVersion ||
+ device.registrationVersion < this.DEVICE_REGISTRATION_VERSION ||
+ !device.registeredCommandsKeys ||
+ !lazy.CommonUtils.arrayEqual(
+ device.registeredCommandsKeys,
+ availableCommandsKeys
+ )
+ );
+ }
+
+ async _checkRemoteCommandsUpdateNeeded(remoteAvailableCommands) {
+ if (!remoteAvailableCommands) {
+ return true;
+ }
+ const remoteAvailableCommandsKeys = Object.keys(
+ remoteAvailableCommands
+ ).sort();
+ const localAvailableCommands =
+ await this._fxai.commands.availableCommands();
+ const localAvailableCommandsKeys = Object.keys(
+ localAvailableCommands
+ ).sort();
+
+ if (
+ !lazy.CommonUtils.arrayEqual(
+ localAvailableCommandsKeys,
+ remoteAvailableCommandsKeys
+ )
+ ) {
+ return true;
+ }
+
+ for (const key of localAvailableCommandsKeys) {
+ if (remoteAvailableCommands[key] !== localAvailableCommands[key]) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ async _updateDeviceRegistrationIfNecessary(currentState) {
+ let data = await currentState.getUserAccountData([
+ "sessionToken",
+ "device",
+ ]);
+ if (!data) {
+ // Can't register a device without a signed-in user.
+ return null;
+ }
+ const { device } = data;
+ if (await this._checkDeviceUpdateNeeded(device)) {
+ return this._registerOrUpdateDevice(currentState, data);
+ }
+ // Return the device ID we already had.
+ return device.id;
+ }
+
+ // 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.
+ async _registerOrUpdateDevice(currentState, signedInUser) {
+ // This method has the side-effect of setting some account-related prefs
+ // (e.g. for caching the device name) so it's important we don't execute it
+ // if the signed-in state has changed.
+ if (!currentState.isCurrent) {
+ throw new Error(
+ "_registerOrUpdateDevice called after a different user has signed in"
+ );
+ }
+
+ const { sessionToken, device: currentDevice } = signedInUser;
+ if (!sessionToken) {
+ throw new Error("_registerOrUpdateDevice called without a session token");
+ }
+
+ try {
+ const subscription =
+ await this._fxai.fxaPushService.registerPushEndpoint();
+ const deviceName = this.getLocalName();
+ 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);
+ }
+ }
+ deviceOptions.availableCommands =
+ await this._fxai.commands.availableCommands();
+ const availableCommandsKeys = Object.keys(
+ deviceOptions.availableCommands
+ ).sort();
+ log.info("registering with available commands", availableCommandsKeys);
+
+ let device;
+ let is_existing = currentDevice && currentDevice.id;
+ if (is_existing) {
+ log.debug("updating existing device details");
+ device = await this._fxai.fxAccountsClient.updateDevice(
+ sessionToken,
+ currentDevice.id,
+ deviceName,
+ deviceOptions
+ );
+ } else {
+ log.debug("registering new device details");
+ device = await this._fxai.fxAccountsClient.registerDevice(
+ sessionToken,
+ deviceName,
+ this.getLocalType(),
+ deviceOptions
+ );
+ }
+
+ // Get the freshest device props before updating them.
+ let { device: deviceProps } = await currentState.getUserAccountData([
+ "device",
+ ]);
+ await currentState.updateUserAccountData({
+ device: {
+ ...deviceProps, // Copy the other properties (e.g. handledCommands).
+ id: device.id,
+ registrationVersion: this.DEVICE_REGISTRATION_VERSION,
+ registeredCommandsKeys: availableCommandsKeys,
+ },
+ });
+ // Must send the notification after we've written the storage.
+ if (!is_existing) {
+ Services.obs.notifyObservers(null, ON_NEW_DEVICE_ID);
+ }
+ return device.id;
+ } catch (error) {
+ return this._handleDeviceError(currentState, error, sessionToken);
+ }
+ }
+
+ async _handleDeviceError(currentState, error, sessionToken) {
+ try {
+ if (error.code === 400) {
+ if (error.errno === ERRNO_UNKNOWN_DEVICE) {
+ return this._recoverFromUnknownDevice(currentState);
+ }
+
+ if (error.errno === ERRNO_DEVICE_SESSION_CONFLICT) {
+ return this._recoverFromDeviceSessionConflict(
+ currentState,
+ error,
+ sessionToken
+ );
+ }
+ }
+
+ // `_handleTokenError` always throws, this syntax keeps the linter happy.
+ // Note that the re-thrown error is immediately caught, logged and ignored
+ // by the containing scope here, which is why we have to `_handleTokenError`
+ // ourselves rather than letting it bubble up for handling by the caller.
+ throw await this._fxai._handleTokenError(error);
+ } catch (error) {
+ await this._logErrorAndResetDeviceRegistrationVersion(
+ currentState,
+ error
+ );
+ return null;
+ }
+ }
+
+ async _recoverFromUnknownDevice(currentState) {
+ // 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");
+ try {
+ await currentState.updateUserAccountData({
+ device: null,
+ encryptedSendTabKeys: null,
+ });
+ } catch (error) {
+ await this._logErrorAndResetDeviceRegistrationVersion(
+ currentState,
+ error
+ );
+ }
+ return null;
+ }
+
+ async _recoverFromDeviceSessionConflict(currentState, 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"
+ );
+ try {
+ const devices = await this._fxai.fxAccountsClient.getDeviceList(
+ sessionToken
+ );
+ const matchingDevices = devices.filter(device => device.isCurrentDevice);
+ const length = matchingDevices.length;
+ if (length === 1) {
+ const deviceId = matchingDevices[0].id;
+ await currentState.updateUserAccountData({
+ device: {
+ id: deviceId,
+ registrationVersion: null,
+ },
+ encryptedSendTabKeys: null,
+ });
+ return deviceId;
+ }
+ if (length > 1) {
+ log.error(
+ "insane server state, " + length + " devices for this session"
+ );
+ }
+ await this._logErrorAndResetDeviceRegistrationVersion(
+ currentState,
+ error
+ );
+ } catch (secondError) {
+ log.error("failed to recover from device-session conflict", secondError);
+ await this._logErrorAndResetDeviceRegistrationVersion(
+ currentState,
+ error
+ );
+ }
+ return null;
+ }
+
+ async _logErrorAndResetDeviceRegistrationVersion(currentState, error) {
+ // Device registration should never cause other operations to fail.
+ // If we've reached this point, just log the error and reset the device
+ // on the account data. At next sync or next sign-in,
+ // registration will be retried.
+ log.error("device registration failed", error);
+ try {
+ await currentState.updateUserAccountData({
+ device: null,
+ encryptedSendTabKeys: null,
+ });
+ } catch (secondError) {
+ log.error(
+ "failed to reset the device registration version, device registration won't be retried",
+ secondError
+ );
+ }
+ }
+
+ // Kick off a background refresh when a device is connected or disconnected.
+ observe(subject, topic, data) {
+ switch (topic) {
+ case ON_DEVICE_CONNECTED_NOTIFICATION:
+ this.refreshDeviceList({ ignoreCached: true }).catch(error => {
+ log.warn(
+ "failed to refresh devices after connecting a new device",
+ error
+ );
+ });
+ break;
+ case ON_DEVICE_DISCONNECTED_NOTIFICATION:
+ let json = JSON.parse(data);
+ if (!json.isLocalDevice) {
+ // If we're the device being disconnected, don't bother fetching a new
+ // list, since our session token is now invalid.
+ this.refreshDeviceList({ ignoreCached: true }).catch(error => {
+ log.warn(
+ "failed to refresh devices after disconnecting a device",
+ error
+ );
+ });
+ }
+ break;
+ case ONVERIFIED_NOTIFICATION:
+ this.updateDeviceRegistrationIfNecessary().catch(error => {
+ log.warn(
+ "updateDeviceRegistrationIfNecessary failed after verification",
+ error
+ );
+ });
+ break;
+ }
+ }
+}
+
+FxAccountsDevice.prototype.QueryInterface = ChromeUtils.generateQI([
+ "nsIObserver",
+ "nsISupportsWeakReference",
+]);
+
+function urlsafeBase64Encode(buffer) {
+ return ChromeUtils.base64URLEncode(new Uint8Array(buffer), { pad: false });
+}
diff --git a/services/fxaccounts/FxAccountsKeys.sys.mjs b/services/fxaccounts/FxAccountsKeys.sys.mjs
new file mode 100644
index 0000000000..ad19df31be
--- /dev/null
+++ b/services/fxaccounts/FxAccountsKeys.sys.mjs
@@ -0,0 +1,649 @@
+/* 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 { CommonUtils } from "resource://services-common/utils.sys.mjs";
+
+import { CryptoUtils } from "resource://services-crypto/utils.sys.mjs";
+
+import {
+ SCOPE_OLD_SYNC,
+ DEPRECATED_SCOPE_ECOSYSTEM_TELEMETRY,
+ FX_OAUTH_CLIENT_ID,
+ log,
+ logPII,
+} from "resource://gre/modules/FxAccountsCommon.sys.mjs";
+
+// The following top-level fields have since been deprecated and exist here purely
+// to be removed from the account state when seen. After a reasonable period of time
+// has passed, where users have been migrated away from those keys they should be safe to be removed
+const DEPRECATED_DERIVED_KEYS_NAMES = [
+ "kSync",
+ "kXCS",
+ "kExtSync",
+ "kExtKbHash",
+ "ecosystemUserId",
+ "ecosystemAnonId",
+];
+
+// This scope and its associated key material were used by the old Kinto webextension
+// storage backend, but has since been decommissioned. It's here entirely so that we
+// remove the corresponding key from storage if present. We should be safe to remove it
+// after some sensible period of time has elapsed to allow most clients to update.
+const DEPRECATED_SCOPE_WEBEXT_SYNC = "sync:addon_storage";
+
+// These are the scopes that correspond to new storage for the `LEGACY_DERIVED_KEYS_NAMES`.
+// We will, if necessary, migrate storage for those keys so that it's associated with
+// these scopes.
+const LEGACY_DERIVED_KEY_SCOPES = [SCOPE_OLD_SYNC];
+
+// These are scopes that we used to store, but are no longer using,
+// and hence should be deleted from storage if present.
+const DEPRECATED_KEY_SCOPES = [
+ DEPRECATED_SCOPE_ECOSYSTEM_TELEMETRY,
+ DEPRECATED_SCOPE_WEBEXT_SYNC,
+];
+
+/**
+ * Utilities for working with key material linked to the user's account.
+ *
+ * Each Firefox Account has 32 bytes of root key material called `kB` which is
+ * linked to the user's password, and which is used to derive purpose-specific
+ * subkeys for things like encrypting the user's sync data. This class provides
+ * the interface for working with such key material.
+ *
+ * Most recent FxA clients obtain appropriate key material directly as part of
+ * their sign-in flow, using a special extension of the OAuth2.0 protocol to
+ * securely deliver the derived keys without revealing `kB`. Keys obtained in
+ * in this way are called "scoped keys" since each corresponds to a particular
+ * OAuth scope, and this class provides a `getKeyForScope` method that is the
+ * preferred method for consumers to work with such keys.
+ *
+ * However, since the FxA integration in Firefox Desktop pre-dates the use of
+ * OAuth2.0, we also have a lot of code for fetching keys via an older flow.
+ * This flow uses a special `keyFetchToken` to obtain `kB` and then derive various
+ * sub-keys from it. Consumers should consider this an internal implementation
+ * detail of the `FxAccountsKeys` class and should prefer `getKeyForScope` where
+ * possible. We intend to remove support for Firefox ever directly handling `kB`
+ * at some point in the future.
+ */
+export class FxAccountsKeys {
+ constructor(fxAccountsInternal) {
+ this._fxai = fxAccountsInternal;
+ }
+
+ /**
+ * Checks if we currently have the key for a given scope, or if we have enough to
+ * be able to successfully fetch and unwrap it for the signed-in-user.
+ *
+ * Unlike `getKeyForScope`, this will not hit the network to fetch wrapped keys if
+ * they aren't available locally.
+ */
+ canGetKeyForScope(scope) {
+ return this._fxai.withCurrentAccountState(async currentState => {
+ let userData = await currentState.getUserAccountData();
+ if (!userData) {
+ throw new Error("Can't possibly get keys; User is not signed in");
+ }
+ if (!userData.verified) {
+ log.info("Can't get keys; user is not verified");
+ return false;
+ }
+
+ if (userData.scopedKeys && userData.scopedKeys.hasOwnProperty(scope)) {
+ return true;
+ }
+
+ // If we have a `keyFetchToken` we can fetch `kB`.
+ if (userData.keyFetchToken) {
+ return true;
+ }
+
+ log.info("Can't get keys; no key material or tokens available");
+ return false;
+ });
+ }
+
+ /**
+ * Get the key for a specified OAuth scope.
+ *
+ * @param {String} scope The OAuth scope whose key should be returned
+ *
+ * @return Promise<JWK>
+ * If no key is available the promise resolves to `null`.
+ * If a key is available for the given scope, th promise resolves to a JWK with fields:
+ * {
+ * scope: The requested scope
+ * kid: Key identifier
+ * k: Derived key material
+ * kty: Always "oct" for scoped keys
+ * }
+ *
+ */
+ async getKeyForScope(scope) {
+ const { scopedKeys } = await this._loadOrFetchKeys();
+ if (!scopedKeys.hasOwnProperty(scope)) {
+ throw new Error(`Key not available for scope "${scope}"`);
+ }
+ return {
+ scope,
+ ...scopedKeys[scope],
+ };
+ }
+
+ /**
+ * Format a JWK kid as hex rather than base64.
+ *
+ * This is a backwards-compatibility helper for code that needs a raw key fingerprint
+ * for use as a key identifier, rather than the timestamp+fingerprint format used by
+ * FxA scoped keys.
+ *
+ * @param {Object} jwk The JWK from which to extract the `kid` field as hex.
+ */
+ kidAsHex(jwk) {
+ // The kid format is "{timestamp}-{b64url(fingerprint)}", but we have to be careful
+ // because the fingerprint component may contain "-" as well, and we want to ensure
+ // the timestamp component was non-empty.
+ const idx = jwk.kid.indexOf("-") + 1;
+ if (idx <= 1) {
+ throw new Error(`Invalid kid: ${jwk.kid}`);
+ }
+ return CommonUtils.base64urlToHex(jwk.kid.slice(idx));
+ }
+
+ /**
+ * Fetch encryption keys for the signed-in-user from the FxA API server.
+ *
+ * Not for user consumption. Exists to cause the keys to be fetched.
+ *
+ * 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
+ * scopedKeys: Object mapping OAuth scopes to corresponding derived keys
+ * verified: email verification status
+ * }
+ * @throws If there is no user signed in.
+ */
+ async _loadOrFetchKeys() {
+ return this._fxai.withCurrentAccountState(async currentState => {
+ try {
+ let userData = await currentState.getUserAccountData();
+ if (!userData) {
+ throw new Error("Can't get keys; User is not signed in");
+ }
+ // If we have all the keys in latest storage location, we're good.
+ if (userData.scopedKeys) {
+ if (
+ LEGACY_DERIVED_KEY_SCOPES.every(scope =>
+ userData.scopedKeys.hasOwnProperty(scope)
+ ) &&
+ !DEPRECATED_KEY_SCOPES.some(scope =>
+ userData.scopedKeys.hasOwnProperty(scope)
+ ) &&
+ !DEPRECATED_DERIVED_KEYS_NAMES.some(keyName =>
+ userData.hasOwnProperty(keyName)
+ )
+ ) {
+ return userData;
+ }
+ }
+ // If not, we've got work to do, and we debounce to avoid duplicating it.
+ if (!currentState.whenKeysReadyDeferred) {
+ currentState.whenKeysReadyDeferred = Promise.withResolvers();
+ // N.B. we deliberately don't `await` here, and instead use the promise
+ // to resolve `whenKeysReadyDeferred` (which we then `await` below).
+ this._migrateOrFetchKeys(currentState, userData).then(
+ dataWithKeys => {
+ currentState.whenKeysReadyDeferred.resolve(dataWithKeys);
+ currentState.whenKeysReadyDeferred = null;
+ },
+ err => {
+ currentState.whenKeysReadyDeferred.reject(err);
+ currentState.whenKeysReadyDeferred = null;
+ }
+ );
+ }
+ return await currentState.whenKeysReadyDeferred.promise;
+ } catch (err) {
+ return this._fxai._handleTokenError(err);
+ }
+ });
+ }
+
+ /**
+ * Set externally derived scoped keys in internal storage
+ * @param { Object } scopedKeys: The scoped keys object derived by the oauth flow
+ *
+ * @return { Promise }: A promise that resolves if the keys were successfully stored,
+ * or rejects if we failed to persist the keys, or if the user is not signed in already
+ */
+ async setScopedKeys(scopedKeys) {
+ return this._fxai.withCurrentAccountState(async currentState => {
+ const userData = await currentState.getUserAccountData();
+ if (!userData) {
+ throw new Error("Cannot persist keys, no user signed in");
+ }
+ await currentState.updateUserAccountData({
+ scopedKeys,
+ });
+ });
+ }
+
+ /**
+ * Key storage migration or fetching logic.
+ *
+ * This method contains the doing-expensive-operations part of the logic of
+ * _loadOrFetchKeys(), factored out into a separate method so we can debounce it.
+ *
+ */
+ async _migrateOrFetchKeys(currentState, userData) {
+ // If the required scopes are present in `scopedKeys`, then we know that we've
+ // previously applied all earlier migrations
+ // so we are safe to delete deprecated fields that older migrations
+ // might have depended on.
+ if (
+ userData.scopedKeys &&
+ LEGACY_DERIVED_KEY_SCOPES.every(scope =>
+ userData.scopedKeys.hasOwnProperty(scope)
+ )
+ ) {
+ return this._removeDeprecatedKeys(currentState, userData);
+ }
+
+ // Otherwise, we need to fetch from the network and unwrap.
+ if (!userData.sessionToken) {
+ throw new Error("No sessionToken");
+ }
+ if (!userData.keyFetchToken) {
+ throw new Error("No keyFetchToken");
+ }
+ return this._fetchAndUnwrapAndDeriveKeys(
+ currentState,
+ userData.sessionToken,
+ userData.keyFetchToken
+ );
+ }
+
+ /**
+ * Removes deprecated keys from storage and returns an
+ * updated user data object
+ */
+ async _removeDeprecatedKeys(currentState, userData) {
+ // Bug 1838708: Delete any deprecated high level keys from storage
+ const keysToRemove = DEPRECATED_DERIVED_KEYS_NAMES.filter(keyName =>
+ userData.hasOwnProperty(keyName)
+ );
+ if (keysToRemove.length) {
+ const removedKeys = {};
+ for (const keyName of keysToRemove) {
+ removedKeys[keyName] = null;
+ }
+ await currentState.updateUserAccountData({
+ ...removedKeys,
+ });
+ userData = await currentState.getUserAccountData();
+ }
+ // Bug 1697596 - delete any deprecated scoped keys from storage.
+ const scopesToRemove = DEPRECATED_KEY_SCOPES.filter(scope =>
+ userData.scopedKeys.hasOwnProperty(scope)
+ );
+ if (scopesToRemove.length) {
+ const updatedScopedKeys = {
+ ...userData.scopedKeys,
+ };
+ for (const scope of scopesToRemove) {
+ delete updatedScopedKeys[scope];
+ }
+ await currentState.updateUserAccountData({
+ scopedKeys: updatedScopedKeys,
+ });
+ userData = await currentState.getUserAccountData();
+ }
+ return userData;
+ }
+
+ /**
+ * Fetch keys from the server, unwrap them, and derive required sub-keys.
+ *
+ * Once the user's email is verified, we can resquest the root key `kB` from the
+ * FxA server, unwrap it using the client-side secret `unwrapBKey`, and then
+ * derive all the sub-keys required for operation of the browser.
+ */
+ async _fetchAndUnwrapAndDeriveKeys(
+ currentState,
+ sessionToken,
+ keyFetchToken
+ ) {
+ if (logPII()) {
+ log.debug(
+ `fetchAndUnwrapKeys: sessionToken: ${sessionToken}, keyFetchToken: ${keyFetchToken}`
+ );
+ }
+
+ // Sign out if we don't have the necessary tokens.
+ if (!sessionToken || !keyFetchToken) {
+ // this seems really bad and we should remove this - bug 1572313.
+ log.warn("improper _fetchAndUnwrapKeys() call: token missing");
+ await this._fxai.signOut();
+ return null;
+ }
+
+ // Deriving OAuth scoped keys requires additional metadata from the server.
+ // We fetch this first, before fetching the actual key material, because the
+ // keyFetchToken is single-use and we don't want to do a potentially-fallible
+ // operation after consuming it.
+ const scopedKeysMetadata = await this._fetchScopedKeysMetadata(
+ sessionToken
+ );
+
+ // Fetch the wrapped keys.
+ // It would be nice to be able to fetch this in a single operation with fetching
+ // the metadata above, but that requires server-side changes in FxA.
+ let { wrapKB } = await this._fetchKeys(keyFetchToken);
+
+ let data = await currentState.getUserAccountData();
+
+ // Sanity check that the user hasn't changed out from under us (which should
+ // be impossible given this is called within _withCurrentAccountState, but...)
+ if (data.keyFetchToken !== keyFetchToken) {
+ throw new Error("Signed in user changed while fetching keys!");
+ }
+
+ let kBbytes = CryptoUtils.xor(
+ CommonUtils.hexToBytes(data.unwrapBKey),
+ wrapKB
+ );
+
+ if (logPII()) {
+ log.debug("kBbytes: " + kBbytes);
+ }
+
+ let updateData = {
+ ...(await this._deriveKeys(data.uid, kBbytes, scopedKeysMetadata)),
+ keyFetchToken: null, // null values cause the item to be removed.
+ unwrapBKey: null,
+ };
+
+ if (logPII()) {
+ log.debug(`Keys Obtained: ${updateData.scopedKeys}`);
+ } else {
+ log.debug(
+ "Keys Obtained: " + Object.keys(updateData.scopedKeys).join(", ")
+ );
+ }
+
+ // Just double-check that scoped keys are there now
+ if (!updateData.scopedKeys) {
+ throw new Error(`user data missing: scopedKeys`);
+ }
+
+ await currentState.updateUserAccountData(updateData);
+ return currentState.getUserAccountData();
+ }
+
+ /**
+ * Fetch the wrapped root key `wrapKB` from the FxA server.
+ *
+ * This consumes the single-use `keyFetchToken`.
+ */
+ _fetchKeys(keyFetchToken) {
+ let client = this._fxai.fxAccountsClient;
+ log.debug(
+ `Fetching keys with token ${!!keyFetchToken} from ${client.host}`
+ );
+ if (logPII()) {
+ log.debug("fetchKeys - the token is " + keyFetchToken);
+ }
+ return client.accountKeys(keyFetchToken);
+ }
+
+ /**
+ * Fetch additional metadata required for deriving scoped keys.
+ *
+ * This includes timestamps and a server-provided secret to mix in to
+ * the derived value in order to support key rotation.
+ */
+ async _fetchScopedKeysMetadata(sessionToken) {
+ // Hard-coded list of scopes that we know about.
+ // This list will probably grow in future.
+ const scopes = [SCOPE_OLD_SYNC].join(" ");
+ const scopedKeysMetadata =
+ await this._fxai.fxAccountsClient.getScopedKeyData(
+ sessionToken,
+ FX_OAUTH_CLIENT_ID,
+ scopes
+ );
+ // The server may decline us permission for some of those scopes, although it really shouldn't.
+ // We can live without them...except for the OLDSYNC scope, whose absence would be catastrophic.
+ if (!scopedKeysMetadata.hasOwnProperty(SCOPE_OLD_SYNC)) {
+ log.warn(
+ "The FxA server did not grant Firefox the `oldsync` scope; this is most unexpected!" +
+ ` scopes were: ${Object.keys(scopedKeysMetadata)}`
+ );
+ throw new Error(
+ "The FxA server did not grant Firefox the `oldsync` scope"
+ );
+ }
+ return scopedKeysMetadata;
+ }
+
+ /**
+ * Derive purpose-specific keys from the root FxA key `kB`.
+ *
+ * Everything that uses an encryption key from FxA uses a purpose-specific derived
+ * key. For new uses this is derived in a structured way based on OAuth scopes,
+ * while for legacy uses (mainly Firefox Sync) it is derived in a more ad-hoc fashion.
+ * This method does all the derivations for the uses that we know about.
+ *
+ */
+ async _deriveKeys(uid, kBbytes, scopedKeysMetadata) {
+ const scopedKeys = await this._deriveScopedKeys(
+ uid,
+ kBbytes,
+ scopedKeysMetadata
+ );
+ return {
+ scopedKeys,
+ };
+ }
+
+ /**
+ * Derive various scoped keys from the root FxA key `kB`.
+ *
+ * The `scopedKeysMetadata` object is additional information fetched from the server that
+ * that gets mixed in to the key derivation, with each member of the object corresponding
+ * to an OAuth scope that keys its own scoped key.
+ *
+ * As a special case for backwards-compatibility, sync-related scopes get special
+ * treatment to use a legacy derivation algorithm.
+ *
+ */
+ async _deriveScopedKeys(uid, kBbytes, scopedKeysMetadata) {
+ const scopedKeys = {};
+ for (const scope in scopedKeysMetadata) {
+ if (LEGACY_DERIVED_KEY_SCOPES.includes(scope)) {
+ scopedKeys[scope] = await this._deriveLegacyScopedKey(
+ uid,
+ kBbytes,
+ scope,
+ scopedKeysMetadata[scope]
+ );
+ } else {
+ scopedKeys[scope] = await this._deriveScopedKey(
+ uid,
+ kBbytes,
+ scope,
+ scopedKeysMetadata[scope]
+ );
+ }
+ }
+ return scopedKeys;
+ }
+
+ /**
+ * Derive a scoped key for an individual OAuth scope.
+ *
+ * The derivation here uses HKDF to combine:
+ * - the root key material kB
+ * - a unique identifier for this scoped key
+ * - a server-provided secret that allows for key rotation
+ * - the account uid as an additional salt
+ *
+ * It produces 32 bytes of (secret) key material along with a (potentially public)
+ * key identifier, formatted as a JWK.
+ *
+ * The full details are in the technical docs at
+ * https://docs.google.com/document/d/1IvQJFEBFz0PnL4uVlIvt8fBS_IPwSK-avK0BRIHucxQ/
+ */
+ async _deriveScopedKey(uid, kBbytes, scope, scopedKeyMetadata) {
+ kBbytes = CommonUtils.byteStringToArrayBuffer(kBbytes);
+
+ const FINGERPRINT_LENGTH = 16;
+ const KEY_LENGTH = 32;
+ const VALID_UID = /^[0-9a-f]{32}$/i;
+ const VALID_ROTATION_SECRET = /^[0-9a-f]{64}$/i;
+
+ // Engage paranoia mode for input data.
+ if (!VALID_UID.test(uid)) {
+ throw new Error("uid must be a 32-character hex string");
+ }
+ if (kBbytes.length != 32) {
+ throw new Error("kBbytes must be exactly 32 bytes");
+ }
+ if (
+ typeof scopedKeyMetadata.identifier !== "string" ||
+ scopedKeyMetadata.identifier.length < 10
+ ) {
+ throw new Error("identifier must be a string of length >= 10");
+ }
+ if (typeof scopedKeyMetadata.keyRotationTimestamp !== "number") {
+ throw new Error("keyRotationTimestamp must be a number");
+ }
+ if (!VALID_ROTATION_SECRET.test(scopedKeyMetadata.keyRotationSecret)) {
+ throw new Error("keyRotationSecret must be a 64-character hex string");
+ }
+
+ // The server returns milliseconds, we want seconds as a string.
+ const keyRotationTimestamp =
+ "" + Math.round(scopedKeyMetadata.keyRotationTimestamp / 1000);
+ if (keyRotationTimestamp.length < 10) {
+ throw new Error("keyRotationTimestamp must round to a 10-digit number");
+ }
+
+ const keyRotationSecret = CommonUtils.hexToArrayBuffer(
+ scopedKeyMetadata.keyRotationSecret
+ );
+ const salt = CommonUtils.hexToArrayBuffer(uid);
+ const context = new TextEncoder().encode(
+ "identity.mozilla.com/picl/v1/scoped_key\n" + scopedKeyMetadata.identifier
+ );
+
+ const inputKey = new Uint8Array(64);
+ inputKey.set(kBbytes, 0);
+ inputKey.set(keyRotationSecret, 32);
+
+ const derivedKeyMaterial = await CryptoUtils.hkdf(
+ inputKey,
+ salt,
+ context,
+ FINGERPRINT_LENGTH + KEY_LENGTH
+ );
+ const fingerprint = derivedKeyMaterial.slice(0, FINGERPRINT_LENGTH);
+ const key = derivedKeyMaterial.slice(
+ FINGERPRINT_LENGTH,
+ FINGERPRINT_LENGTH + KEY_LENGTH
+ );
+
+ return {
+ kid:
+ keyRotationTimestamp +
+ "-" +
+ ChromeUtils.base64URLEncode(fingerprint, {
+ pad: false,
+ }),
+ k: ChromeUtils.base64URLEncode(key, {
+ pad: false,
+ }),
+ kty: "oct",
+ };
+ }
+
+ /**
+ * Derive the scoped key for the one of our legacy sync-related scopes.
+ *
+ * These uses a different key-derivation algoritm that incorporates less server-provided
+ * data, for backwards-compatibility reasons.
+ *
+ */
+ async _deriveLegacyScopedKey(uid, kBbytes, scope, scopedKeyMetadata) {
+ let kid, key;
+ if (scope == SCOPE_OLD_SYNC) {
+ kid = await this._deriveXClientState(kBbytes);
+ key = await this._deriveSyncKey(kBbytes);
+ } else {
+ throw new Error(`Unexpected legacy key-bearing scope: ${scope}`);
+ }
+ kid = CommonUtils.byteStringToArrayBuffer(kid);
+ key = CommonUtils.byteStringToArrayBuffer(key);
+ return this._formatLegacyScopedKey(kid, key, scope, scopedKeyMetadata);
+ }
+
+ /**
+ * Format key material for a legacy scyne-related scope as a JWK.
+ *
+ * @param {ArrayBuffer} kid bytes of the key hash to use in the key identifier
+ * @param {ArrayBuffer} key bytes of the derived sync key
+ * @param {String} scope the scope with which this key is associated
+ * @param {Number} keyRotationTimestamp server-provided timestamp of last key rotation
+ * @returns {Object} key material formatted as a JWK object
+ */
+ _formatLegacyScopedKey(kid, key, scope, { keyRotationTimestamp }) {
+ kid = ChromeUtils.base64URLEncode(kid, {
+ pad: false,
+ });
+ key = ChromeUtils.base64URLEncode(key, {
+ pad: false,
+ });
+ return {
+ kid: `${keyRotationTimestamp}-${kid}`,
+ k: key,
+ kty: "oct",
+ };
+ }
+
+ /**
+ * Derive the Sync Key given the byte string kB.
+ *
+ * @returns Promise<HKDF(kB, undefined, "identity.mozilla.com/picl/v1/oldsync", 64)>
+ */
+ async _deriveSyncKey(kBbytes) {
+ return CryptoUtils.hkdfLegacy(
+ kBbytes,
+ undefined,
+ "identity.mozilla.com/picl/v1/oldsync",
+ 2 * 32
+ );
+ }
+
+ /**
+ * Derive the X-Client-State header given the byte string kB.
+ *
+ * @returns Promise<SHA256(kB)[:16]>
+ */
+ async _deriveXClientState(kBbytes) {
+ return this._sha256(kBbytes).slice(0, 16);
+ }
+
+ _sha256(bytes) {
+ let hasher = Cc["@mozilla.org/security/hash;1"].createInstance(
+ Ci.nsICryptoHash
+ );
+ hasher.init(hasher.SHA256);
+ return CryptoUtils.digestBytes(bytes, hasher);
+ }
+}
diff --git a/services/fxaccounts/FxAccountsOAuth.sys.mjs b/services/fxaccounts/FxAccountsOAuth.sys.mjs
new file mode 100644
index 0000000000..1935decff2
--- /dev/null
+++ b/services/fxaccounts/FxAccountsOAuth.sys.mjs
@@ -0,0 +1,224 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ jwcrypto: "resource://services-crypto/jwcrypto.sys.mjs",
+});
+
+import {
+ FX_OAUTH_CLIENT_ID,
+ SCOPE_PROFILE,
+ SCOPE_PROFILE_WRITE,
+ SCOPE_OLD_SYNC,
+} from "resource://gre/modules/FxAccountsCommon.sys.mjs";
+
+const VALID_SCOPES = [SCOPE_PROFILE, SCOPE_PROFILE_WRITE, SCOPE_OLD_SYNC];
+
+export const ERROR_INVALID_SCOPES = "INVALID_SCOPES";
+export const ERROR_INVALID_STATE = "INVALID_STATE";
+export const ERROR_SYNC_SCOPE_NOT_GRANTED = "ERROR_SYNC_SCOPE_NOT_GRANTED";
+export const ERROR_NO_KEYS_JWE = "ERROR_NO_KEYS_JWE";
+export const ERROR_OAUTH_FLOW_ABANDONED = "ERROR_OAUTH_FLOW_ABANDONED";
+
+/**
+ * Handles all logic and state related to initializing, and completing OAuth flows
+ * with FxA
+ * It's possible to start multiple OAuth flow, but only one can be completed, and once one flow is completed
+ * all the other in-flight flows will be concluded, and attempting to complete those flows will result in errors.
+ */
+export class FxAccountsOAuth {
+ #flow;
+ #fxaClient;
+ /**
+ * Creates a new FxAccountsOAuth
+ *
+ * @param { Object } fxaClient: The fxa client used to send http request to the oauth server
+ */
+ constructor(fxaClient) {
+ this.#flow = {};
+ this.#fxaClient = fxaClient;
+ }
+
+ /**
+ * Stores a flow in-memory
+ * @param { string } state: A base-64 URL-safe string represnting a random value created at the start of the flow
+ * @param { Object } value: The data needed to complete a flow, once the oauth code is available.
+ * in practice, `value` is:
+ * - `verifier`: A base=64 URL-safe string representing the PKCE code verifier
+ * - `key`: The private key need to decrypt the JWE we recieve from the auth server
+ * - `requestedScopes`: The scopes the caller requested, meant to be compared against the scopes the server authorized
+ */
+ addFlow(state, value) {
+ this.#flow[state] = value;
+ }
+
+ /**
+ * Clears all started flows
+ */
+ clearAllFlows() {
+ this.#flow = {};
+ }
+
+ /*
+ * Gets a stored flow
+ * @param { string } state: The base-64 URL-safe state string that was created at the start of the flow
+ * @returns { Object }: The values initially stored when startign th eoauth flow
+ * in practice, the return value is:
+ * - `verifier`: A base=64 URL-safe string representing the PKCE code verifier
+ * - `key`: The private key need to decrypt the JWE we recieve from the auth server
+ * - ``requestedScopes`: The scopes the caller requested, meant to be compared against the scopes the server authorized
+ */
+ getFlow(state) {
+ return this.#flow[state];
+ }
+
+ /* Returns the number of flows, used by tests
+ *
+ */
+ numOfFlows() {
+ return Object.keys(this.#flow).length;
+ }
+
+ /**
+ * Begins an OAuth flow, to be completed with a an OAuth code and state.
+ *
+ * This function stores needed information to complete the flow. You must call `completeOAuthFlow`
+ * on the same instance of `FxAccountsOAuth`, otherwise the completing of the oauth flow will fail.
+ *
+ * @param { string[] } scopes: The OAuth scopes the client should request from FxA
+ *
+ * @returns { Object }: Returns an object representing the query parameters that should be
+ * added to the FxA authorization URL to initialize an oAuth flow.
+ * In practice, the query parameters are:
+ * - `client_id`: The OAuth client ID for Firefox Desktop
+ * - `scope`: The scopes given by the caller, space seperated
+ * - `action`: This will always be `email`
+ * - `response_type`: This will always be `code`
+ * - `access_type`: This will always be `offline`
+ * - `state`: A URL-safe base-64 string randomly generated
+ * - `code_challenge`: A URL-safe base-64 string representing the PKCE challenge
+ * - `code_challenge_method`: This will always be `S256`
+ * For more informatio about PKCE, read https://datatracker.ietf.org/doc/html/rfc7636
+ * - `keys_jwk`: A URL-safe base-64 representing a JWK to be used as a public key by the server
+ * to generate a JWE
+ */
+ async beginOAuthFlow(scopes) {
+ if (
+ !Array.isArray(scopes) ||
+ scopes.some(scope => !VALID_SCOPES.includes(scope))
+ ) {
+ throw new Error(ERROR_INVALID_SCOPES);
+ }
+ const queryParams = {
+ client_id: FX_OAUTH_CLIENT_ID,
+ action: "email",
+ response_type: "code",
+ access_type: "offline",
+ scope: scopes.join(" "),
+ };
+
+ // Generate a random, 16 byte value to represent a state that we verify
+ // once we complete the oauth flow, to ensure that we only conclude
+ // an oauth flow that we started
+ const state = new Uint8Array(16);
+ crypto.getRandomValues(state);
+ const stateB64 = ChromeUtils.base64URLEncode(state, { pad: false });
+ queryParams.state = stateB64;
+
+ // Generate a 43 byte code verifier for PKCE, in accordance with
+ // https://datatracker.ietf.org/doc/html/rfc7636#section-7.1 which recommends a
+ // 43-octet URL safe string
+ const codeVerifier = new Uint8Array(43);
+ crypto.getRandomValues(codeVerifier);
+ const codeVerifierB64 = ChromeUtils.base64URLEncode(codeVerifier, {
+ pad: false,
+ });
+ const challenge = await crypto.subtle.digest(
+ "SHA-256",
+ new TextEncoder().encode(codeVerifierB64)
+ );
+ const challengeB64 = ChromeUtils.base64URLEncode(challenge, { pad: false });
+ queryParams.code_challenge = challengeB64;
+ queryParams.code_challenge_method = "S256";
+
+ // Generate a public, private key pair to be used during the oauth flow
+ // to encrypt scoped-keys as they roundtrip through the auth server
+ const ECDH_KEY = { name: "ECDH", namedCurve: "P-256" };
+ const key = await crypto.subtle.generateKey(ECDH_KEY, true, ["deriveKey"]);
+ const publicKey = await crypto.subtle.exportKey("jwk", key.publicKey);
+ const privateKey = key.privateKey;
+
+ // We encode the public key as URL-safe base64 to be included in the query parameters
+ const encodedPublicKey = ChromeUtils.base64URLEncode(
+ new TextEncoder().encode(JSON.stringify(publicKey)),
+ { pad: false }
+ );
+ queryParams.keys_jwk = encodedPublicKey;
+
+ // We store the state in-memory, to verify once the oauth flow is completed
+ this.addFlow(stateB64, {
+ key: privateKey,
+ verifier: codeVerifierB64,
+ requestedScopes: scopes.join(" "),
+ });
+ return queryParams;
+ }
+
+ /** Completes an OAuth flow and invalidates any other ongoing flows
+ * @param { string } sessionTokenHex: The session token encoded in hexadecimal
+ * @param { string } code: OAuth authorization code provided by running an OAuth flow
+ * @param { string } state: The state first provided by `beginOAuthFlow`, then roundtripped through the server
+ *
+ * @returns { Object }: Returns an object representing the result of completing the oauth flow.
+ * The object includes the following:
+ * - 'scopedKeys': The encryption keys provided by the server, already decrypted
+ * - 'refreshToken': The refresh token provided by the server
+ * - 'accessToken': The access token provided by the server
+ * */
+ async completeOAuthFlow(sessionTokenHex, code, state) {
+ const flow = this.getFlow(state);
+ if (!flow) {
+ throw new Error(ERROR_INVALID_STATE);
+ }
+ const { key, verifier, requestedScopes } = flow;
+ const { keys_jwe, refresh_token, access_token, scope } =
+ await this.#fxaClient.oauthToken(
+ sessionTokenHex,
+ code,
+ verifier,
+ FX_OAUTH_CLIENT_ID
+ );
+ if (
+ requestedScopes.includes(SCOPE_OLD_SYNC) &&
+ !scope.includes(SCOPE_OLD_SYNC)
+ ) {
+ throw new Error(ERROR_SYNC_SCOPE_NOT_GRANTED);
+ }
+ if (scope.includes(SCOPE_OLD_SYNC) && !keys_jwe) {
+ throw new Error(ERROR_NO_KEYS_JWE);
+ }
+ let scopedKeys;
+ if (keys_jwe) {
+ scopedKeys = JSON.parse(
+ new TextDecoder().decode(await lazy.jwcrypto.decryptJWE(keys_jwe, key))
+ );
+ }
+
+ // We make sure no other flow snuck in, and completed before we did
+ if (!this.getFlow(state)) {
+ throw new Error(ERROR_OAUTH_FLOW_ABANDONED);
+ }
+
+ // Clear all flows, so any in-flight or future flows trigger an error as the browser
+ // would have been signed in
+ this.clearAllFlows();
+ return {
+ scopedKeys,
+ refreshToken: refresh_token,
+ accessToken: access_token,
+ };
+ }
+}
diff --git a/services/fxaccounts/FxAccountsPairing.sys.mjs b/services/fxaccounts/FxAccountsPairing.sys.mjs
new file mode 100644
index 0000000000..e68554f7ab
--- /dev/null
+++ b/services/fxaccounts/FxAccountsPairing.sys.mjs
@@ -0,0 +1,511 @@
+// 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 {
+ log,
+ PREF_REMOTE_PAIRING_URI,
+ COMMAND_PAIR_SUPP_METADATA,
+ COMMAND_PAIR_AUTHORIZE,
+ COMMAND_PAIR_DECLINE,
+ COMMAND_PAIR_HEARTBEAT,
+ COMMAND_PAIR_COMPLETE,
+} from "resource://gre/modules/FxAccountsCommon.sys.mjs";
+
+import {
+ getFxAccountsSingleton,
+ FxAccounts,
+} from "resource://gre/modules/FxAccounts.sys.mjs";
+
+const fxAccounts = getFxAccountsSingleton();
+import { setTimeout, clearTimeout } from "resource://gre/modules/Timer.sys.mjs";
+
+ChromeUtils.importESModule("resource://services-common/utils.sys.mjs");
+const lazy = {};
+ChromeUtils.defineESModuleGetters(lazy, {
+ FxAccountsPairingChannel:
+ "resource://gre/modules/FxAccountsPairingChannel.sys.mjs",
+
+ Weave: "resource://services-sync/main.sys.mjs",
+ jwcrypto: "resource://services-crypto/jwcrypto.sys.mjs",
+});
+
+const PAIRING_REDIRECT_URI = "urn:ietf:wg:oauth:2.0:oob:pair-auth-webchannel";
+// A pairing flow is not tied to a specific browser window, can also finish in
+// various ways and subsequently might leak a Web Socket, so just in case we
+// time out and free-up the resources after a specified amount of time.
+const FLOW_TIMEOUT_MS = 15 * 60 * 1000; // 15 minutes.
+
+class PairingStateMachine {
+ constructor(emitter) {
+ this._emitter = emitter;
+ this._transition(SuppConnectionPending);
+ }
+
+ get currentState() {
+ return this._currentState;
+ }
+
+ _transition(StateCtor, ...args) {
+ const state = new StateCtor(this, ...args);
+ this._currentState = state;
+ }
+
+ assertState(RequiredStates, messagePrefix = null) {
+ if (!(RequiredStates instanceof Array)) {
+ RequiredStates = [RequiredStates];
+ }
+ if (
+ !RequiredStates.some(
+ RequiredState => this._currentState instanceof RequiredState
+ )
+ ) {
+ const msg = `${
+ messagePrefix ? `${messagePrefix}. ` : ""
+ }Valid expected states: ${RequiredStates.map(({ name }) => name).join(
+ ", "
+ )}. Current state: ${this._currentState.label}.`;
+ throw new Error(msg);
+ }
+ }
+}
+
+/**
+ * The pairing flow can be modeled by a finite state machine:
+ * We start by connecting to a WebSocket channel (SuppConnectionPending).
+ * Then the other party connects and requests some metadata from us (PendingConfirmations).
+ * A confirmation happens locally first (PendingRemoteConfirmation)
+ * or the oppposite (PendingLocalConfirmation).
+ * Any side can decline this confirmation (Aborted).
+ * Once both sides have confirmed, the pairing flow is finished (Completed).
+ * During this flow errors can happen and should be handled (Errored).
+ */
+class State {
+ constructor(stateMachine, ...args) {
+ this._transition = (...args) => stateMachine._transition(...args);
+ this._notify = (...args) => stateMachine._emitter.emit(...args);
+ this.init(...args);
+ }
+
+ init() {
+ /* Does nothing by default but can be re-implemented. */
+ }
+
+ get label() {
+ return this.constructor.name;
+ }
+
+ hasErrored(error) {
+ this._notify("view:Error", error);
+ this._transition(Errored, error);
+ }
+
+ hasAborted() {
+ this._transition(Aborted);
+ }
+}
+class SuppConnectionPending extends State {
+ suppConnected(sender, oauthOptions) {
+ this._transition(PendingConfirmations, sender, oauthOptions);
+ }
+}
+class PendingConfirmationsState extends State {
+ localConfirmed() {
+ throw new Error("Subclasses must implement this method.");
+ }
+ remoteConfirmed() {
+ throw new Error("Subclasses must implement this method.");
+ }
+}
+class PendingConfirmations extends PendingConfirmationsState {
+ init(sender, oauthOptions) {
+ this.sender = sender;
+ this.oauthOptions = oauthOptions;
+ }
+
+ localConfirmed() {
+ this._transition(PendingRemoteConfirmation);
+ }
+
+ remoteConfirmed() {
+ this._transition(PendingLocalConfirmation, this.sender, this.oauthOptions);
+ }
+}
+class PendingLocalConfirmation extends PendingConfirmationsState {
+ init(sender, oauthOptions) {
+ this.sender = sender;
+ this.oauthOptions = oauthOptions;
+ }
+
+ localConfirmed() {
+ this._transition(Completed);
+ }
+
+ remoteConfirmed() {
+ throw new Error(
+ "Insane state! Remote has already been confirmed at this point."
+ );
+ }
+}
+class PendingRemoteConfirmation extends PendingConfirmationsState {
+ localConfirmed() {
+ throw new Error(
+ "Insane state! Local has already been confirmed at this point."
+ );
+ }
+
+ remoteConfirmed() {
+ this._transition(Completed);
+ }
+}
+class Completed extends State {}
+class Aborted extends State {}
+class Errored extends State {
+ init(error) {
+ this.error = error;
+ }
+}
+
+const flows = new Map();
+
+export class FxAccountsPairingFlow {
+ static get(channelId) {
+ return flows.get(channelId);
+ }
+
+ static finalizeAll() {
+ for (const flow of flows) {
+ flow.finalize();
+ }
+ }
+
+ static async start(options) {
+ const { emitter } = options;
+ const fxaConfig = options.fxaConfig || FxAccounts.config;
+ const fxa = options.fxAccounts || fxAccounts;
+ const weave = options.weave || lazy.Weave;
+ const flowTimeout = options.flowTimeout || FLOW_TIMEOUT_MS;
+
+ const contentPairingURI = await fxaConfig.promisePairingURI();
+ const wsUri = Services.urlFormatter.formatURLPref(PREF_REMOTE_PAIRING_URI);
+ const pairingChannel =
+ options.pairingChannel ||
+ (await lazy.FxAccountsPairingChannel.create(wsUri));
+ const { channelId, channelKey } = pairingChannel;
+ const channelKeyB64 = ChromeUtils.base64URLEncode(channelKey, {
+ pad: false,
+ });
+ const pairingFlow = new FxAccountsPairingFlow({
+ channelId,
+ pairingChannel,
+ emitter,
+ fxa,
+ fxaConfig,
+ flowTimeout,
+ weave,
+ });
+ flows.set(channelId, pairingFlow);
+
+ return `${contentPairingURI}#channel_id=${channelId}&channel_key=${channelKeyB64}`;
+ }
+
+ constructor(options) {
+ this._channelId = options.channelId;
+ this._pairingChannel = options.pairingChannel;
+ this._emitter = options.emitter;
+ this._fxa = options.fxa;
+ this._fxai = options.fxai || this._fxa._internal;
+ this._fxaConfig = options.fxaConfig;
+ this._weave = options.weave;
+ this._stateMachine = new PairingStateMachine(this._emitter);
+ this._setupListeners();
+ this._flowTimeoutId = setTimeout(
+ () => this._onFlowTimeout(),
+ options.flowTimeout
+ );
+ }
+
+ _onFlowTimeout() {
+ log.warn(`The pairing flow ${this._channelId} timed out.`);
+ this._onError(new Error("Timeout"));
+ this.finalize();
+ }
+
+ _closeChannel() {
+ if (!this._closed && !this._pairingChannel.closed) {
+ this._pairingChannel.close();
+ this._closed = true;
+ }
+ }
+
+ finalize() {
+ this._closeChannel();
+ clearTimeout(this._flowTimeoutId);
+ // Free up resources and let the GC do its thing.
+ flows.delete(this._channelId);
+ }
+
+ _setupListeners() {
+ this._pairingChannel.addEventListener(
+ "message",
+ ({ detail: { sender, data } }) =>
+ this.onPairingChannelMessage(sender, data)
+ );
+ this._pairingChannel.addEventListener("error", event =>
+ this._onPairingChannelError(event.detail.error)
+ );
+ this._emitter.on("view:Closed", () => this.onPrefViewClosed());
+ }
+
+ _onAbort() {
+ this._stateMachine.currentState.hasAborted();
+ this.finalize();
+ }
+
+ _onError(error) {
+ this._stateMachine.currentState.hasErrored(error);
+ this._closeChannel();
+ }
+
+ _onPairingChannelError(error) {
+ log.error("Pairing channel error", error);
+ this._onError(error);
+ }
+
+ // Any non-falsy returned value is sent back through WebChannel.
+ async onWebChannelMessage(command) {
+ const stateMachine = this._stateMachine;
+ const curState = stateMachine.currentState;
+ try {
+ switch (command) {
+ case COMMAND_PAIR_SUPP_METADATA:
+ stateMachine.assertState(
+ [PendingConfirmations, PendingLocalConfirmation],
+ `Wrong state for ${command}`
+ );
+ const {
+ ua,
+ city,
+ region,
+ country,
+ remote: ipAddress,
+ } = curState.sender;
+ return { ua, city, region, country, ipAddress };
+ case COMMAND_PAIR_AUTHORIZE:
+ stateMachine.assertState(
+ [PendingConfirmations, PendingLocalConfirmation],
+ `Wrong state for ${command}`
+ );
+ const {
+ client_id,
+ state,
+ scope,
+ code_challenge,
+ code_challenge_method,
+ keys_jwk,
+ } = curState.oauthOptions;
+ const authorizeParams = {
+ client_id,
+ access_type: "offline",
+ state,
+ scope,
+ code_challenge,
+ code_challenge_method,
+ keys_jwk,
+ };
+ const codeAndState = await this._authorizeOAuthCode(authorizeParams);
+ if (codeAndState.state != state) {
+ throw new Error(`OAuth state mismatch`);
+ }
+ await this._pairingChannel.send({
+ message: "pair:auth:authorize",
+ data: {
+ ...codeAndState,
+ },
+ });
+ curState.localConfirmed();
+ break;
+ case COMMAND_PAIR_DECLINE:
+ this._onAbort();
+ break;
+ case COMMAND_PAIR_HEARTBEAT:
+ if (curState instanceof Errored || this._pairingChannel.closed) {
+ return { err: curState.error.message || "Pairing channel closed" };
+ }
+ const suppAuthorized = !(
+ curState instanceof PendingConfirmations ||
+ curState instanceof PendingRemoteConfirmation
+ );
+ return { suppAuthorized };
+ case COMMAND_PAIR_COMPLETE:
+ this.finalize();
+ break;
+ default:
+ throw new Error(`Received unknown WebChannel command: ${command}`);
+ }
+ } catch (e) {
+ log.error(e);
+ curState.hasErrored(e);
+ }
+ return {};
+ }
+
+ async onPairingChannelMessage(sender, payload) {
+ const { message } = payload;
+ const stateMachine = this._stateMachine;
+ const curState = stateMachine.currentState;
+ try {
+ switch (message) {
+ case "pair:supp:request":
+ stateMachine.assertState(
+ SuppConnectionPending,
+ `Wrong state for ${message}`
+ );
+ const oauthUri = await this._fxaConfig.promiseOAuthURI();
+ const { uid, email, avatar, displayName } =
+ await this._fxa.getSignedInUser();
+ const deviceName = this._weave.Service.clientsEngine.localName;
+ await this._pairingChannel.send({
+ message: "pair:auth:metadata",
+ data: {
+ email,
+ avatar,
+ displayName,
+ deviceName,
+ },
+ });
+ const {
+ client_id,
+ state,
+ scope,
+ code_challenge,
+ code_challenge_method,
+ keys_jwk,
+ } = payload.data;
+ const url = new URL(oauthUri);
+ url.searchParams.append("client_id", client_id);
+ url.searchParams.append("scope", scope);
+ url.searchParams.append("email", email);
+ url.searchParams.append("uid", uid);
+ url.searchParams.append("channel_id", this._channelId);
+ url.searchParams.append("redirect_uri", PAIRING_REDIRECT_URI);
+ this._emitter.emit("view:SwitchToWebContent", url.href);
+ curState.suppConnected(sender, {
+ client_id,
+ state,
+ scope,
+ code_challenge,
+ code_challenge_method,
+ keys_jwk,
+ });
+ break;
+ case "pair:supp:authorize":
+ stateMachine.assertState(
+ [PendingConfirmations, PendingRemoteConfirmation],
+ `Wrong state for ${message}`
+ );
+ curState.remoteConfirmed();
+ break;
+ default:
+ throw new Error(
+ `Received unknown Pairing Channel message: ${message}`
+ );
+ }
+ } catch (e) {
+ log.error(e);
+ curState.hasErrored(e);
+ }
+ }
+
+ onPrefViewClosed() {
+ const curState = this._stateMachine.currentState;
+ // We don't want to stop the pairing process in the later stages.
+ if (
+ curState instanceof SuppConnectionPending ||
+ curState instanceof Aborted ||
+ curState instanceof Errored
+ ) {
+ this.finalize();
+ }
+ }
+
+ /**
+ * Grant an OAuth authorization code for the connecting client.
+ *
+ * @param {Object} options
+ * @param options.client_id
+ * @param options.state
+ * @param options.scope
+ * @param options.access_type
+ * @param options.code_challenge_method
+ * @param options.code_challenge
+ * @param [options.keys_jwe]
+ * @returns {Promise<Object>} Object containing "code" and "state" properties.
+ */
+ _authorizeOAuthCode(options) {
+ return this._fxa._withVerifiedAccountState(async state => {
+ const { sessionToken } = await state.getUserAccountData(["sessionToken"]);
+ const params = { ...options };
+ if (params.keys_jwk) {
+ const jwk = JSON.parse(
+ new TextDecoder().decode(
+ ChromeUtils.base64URLDecode(params.keys_jwk, { padding: "reject" })
+ )
+ );
+ params.keys_jwe = await this._createKeysJWE(
+ sessionToken,
+ params.client_id,
+ params.scope,
+ jwk
+ );
+ delete params.keys_jwk;
+ }
+ try {
+ return await this._fxai.fxAccountsClient.oauthAuthorize(
+ sessionToken,
+ params
+ );
+ } catch (err) {
+ throw this._fxai._errorToErrorClass(err);
+ }
+ });
+ }
+
+ /**
+ * Create a JWE to deliver keys to another client via the OAuth scoped-keys flow.
+ *
+ * This method is used to transfer key material to another client, by providing
+ * an appropriately-encrypted value for the `keys_jwe` OAuth response parameter.
+ * Since we're transferring keys from one client to another, two things must be
+ * true:
+ *
+ * * This client must actually have the key.
+ * * The other client must be allowed to request that key.
+ *
+ * @param {String} sessionToken the sessionToken to use when fetching key metadata
+ * @param {String} clientId the client requesting access to our keys
+ * @param {String} scopes Space separated requested scopes being requested
+ * @param {Object} jwk Ephemeral JWK provided by the client for secure key transfer
+ */
+ async _createKeysJWE(sessionToken, clientId, scopes, jwk) {
+ // This checks with the FxA server about what scopes the client is allowed.
+ // Note that we pass the requesting client_id here, not our own client_id.
+ const clientKeyData = await this._fxai.fxAccountsClient.getScopedKeyData(
+ sessionToken,
+ clientId,
+ scopes
+ );
+ const scopedKeys = {};
+ for (const scope of Object.keys(clientKeyData)) {
+ const key = await this._fxai.keys.getKeyForScope(scope);
+ if (!key) {
+ throw new Error(`Key not available for scope "${scope}"`);
+ }
+ scopedKeys[scope] = key;
+ }
+ return lazy.jwcrypto.generateJWE(
+ jwk,
+ new TextEncoder().encode(JSON.stringify(scopedKeys))
+ );
+ }
+}
diff --git a/services/fxaccounts/FxAccountsPairingChannel.sys.mjs b/services/fxaccounts/FxAccountsPairingChannel.sys.mjs
new file mode 100644
index 0000000000..cb6d3fdb91
--- /dev/null
+++ b/services/fxaccounts/FxAccountsPairingChannel.sys.mjs
@@ -0,0 +1,3693 @@
+/*!
+ *
+ * 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/.
+ *
+ * The following bundle is from an external repository at github.com/mozilla/fxa-pairing-channel,
+ * it implements a shared library for two javascript environments to create an encrypted and authenticated
+ * communication channel by sharing a secret key and by relaying messages through a websocket server.
+ *
+ * It is used by the Firefox Accounts pairing flow, with one side of the channel being web
+ * content from https://accounts.firefox.com and the other side of the channel being chrome native code.
+ *
+ * This uses the event-target-shim node library published under the MIT license:
+ * https://github.com/mysticatea/event-target-shim/blob/master/LICENSE
+ *
+ * Bundle generated from https://github.com/mozilla/fxa-pairing-channel.git. Hash:c8ec3119920b4ffa833b, Chunkhash:378a5f51445e7aa7630e.
+ *
+ */
+
+// This header provides a little bit of plumbing to use `FxAccountsPairingChannel`
+// from Firefox browser code, hence the presence of these privileged browser APIs.
+// If you're trying to use this from ordinary web content you're in for a bad time.
+
+import { setTimeout } from "resource://gre/modules/Timer.sys.mjs";
+
+// We cannot use WebSocket from chrome code without a window,
+// see https://bugzilla.mozilla.org/show_bug.cgi?id=784686
+const browser = Services.appShell.createWindowlessBrowser(true);
+const {WebSocket} = browser.document.ownerGlobal;
+
+export var FxAccountsPairingChannel =
+/******/ (function(modules) { // webpackBootstrap
+/******/ // The module cache
+/******/ var installedModules = {};
+/******/
+/******/ // The require function
+/******/ function __webpack_require__(moduleId) {
+/******/
+/******/ // Check if module is in cache
+/******/ if(installedModules[moduleId]) {
+/******/ return installedModules[moduleId].exports;
+/******/ }
+/******/ // Create a new module (and put it into the cache)
+/******/ var module = installedModules[moduleId] = {
+/******/ i: moduleId,
+/******/ l: false,
+/******/ exports: {}
+/******/ };
+/******/
+/******/ // Execute the module function
+/******/ modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);
+/******/
+/******/ // Flag the module as loaded
+/******/ module.l = true;
+/******/
+/******/ // Return the exports of the module
+/******/ return module.exports;
+/******/ }
+/******/
+/******/
+/******/ // expose the modules object (__webpack_modules__)
+/******/ __webpack_require__.m = modules;
+/******/
+/******/ // expose the module cache
+/******/ __webpack_require__.c = installedModules;
+/******/
+/******/ // define getter function for harmony exports
+/******/ __webpack_require__.d = function(exports, name, getter) {
+/******/ if(!__webpack_require__.o(exports, name)) {
+/******/ Object.defineProperty(exports, name, { enumerable: true, get: getter });
+/******/ }
+/******/ };
+/******/
+/******/ // define __esModule on exports
+/******/ __webpack_require__.r = function(exports) {
+/******/ if(typeof Symbol !== 'undefined' && Symbol.toStringTag) {
+/******/ Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
+/******/ }
+/******/ Object.defineProperty(exports, '__esModule', { value: true });
+/******/ };
+/******/
+/******/ // create a fake namespace object
+/******/ // mode & 1: value is a module id, require it
+/******/ // mode & 2: merge all properties of value into the ns
+/******/ // mode & 4: return value when already ns object
+/******/ // mode & 8|1: behave like require
+/******/ __webpack_require__.t = function(value, mode) {
+/******/ if(mode & 1) value = __webpack_require__(value);
+/******/ if(mode & 8) return value;
+/******/ if((mode & 4) && typeof value === 'object' && value && value.__esModule) return value;
+/******/ var ns = Object.create(null);
+/******/ __webpack_require__.r(ns);
+/******/ Object.defineProperty(ns, 'default', { enumerable: true, value: value });
+/******/ if(mode & 2 && typeof value != 'string') for(var key in value) __webpack_require__.d(ns, key, function(key) { return value[key]; }.bind(null, key));
+/******/ return ns;
+/******/ };
+/******/
+/******/ // getDefaultExport function for compatibility with non-harmony modules
+/******/ __webpack_require__.n = function(module) {
+/******/ var getter = module && module.__esModule ?
+/******/ function getDefault() { return module['default']; } :
+/******/ function getModuleExports() { return module; };
+/******/ __webpack_require__.d(getter, 'a', getter);
+/******/ return getter;
+/******/ };
+/******/
+/******/ // Object.prototype.hasOwnProperty.call
+/******/ __webpack_require__.o = function(object, property) { return Object.prototype.hasOwnProperty.call(object, property); };
+/******/
+/******/ // __webpack_public_path__
+/******/ __webpack_require__.p = "";
+/******/
+/******/
+/******/ // Load entry module and return exports
+/******/ return __webpack_require__(__webpack_require__.s = 0);
+/******/ })
+/************************************************************************/
+/******/ ([
+/* 0 */
+/***/ (function(module, __webpack_exports__, __webpack_require__) {
+
+"use strict";
+// ESM COMPAT FLAG
+__webpack_require__.r(__webpack_exports__);
+
+// EXPORTS
+__webpack_require__.d(__webpack_exports__, "PairingChannel", function() { return /* binding */ src_PairingChannel; });
+__webpack_require__.d(__webpack_exports__, "base64urlToBytes", function() { return /* reexport */ base64urlToBytes; });
+__webpack_require__.d(__webpack_exports__, "bytesToBase64url", function() { return /* reexport */ bytesToBase64url; });
+__webpack_require__.d(__webpack_exports__, "bytesToHex", function() { return /* reexport */ bytesToHex; });
+__webpack_require__.d(__webpack_exports__, "bytesToUtf8", function() { return /* reexport */ bytesToUtf8; });
+__webpack_require__.d(__webpack_exports__, "hexToBytes", function() { return /* reexport */ hexToBytes; });
+__webpack_require__.d(__webpack_exports__, "TLSCloseNotify", function() { return /* reexport */ TLSCloseNotify; });
+__webpack_require__.d(__webpack_exports__, "TLSError", function() { return /* reexport */ TLSError; });
+__webpack_require__.d(__webpack_exports__, "utf8ToBytes", function() { return /* reexport */ utf8ToBytes; });
+__webpack_require__.d(__webpack_exports__, "_internals", function() { return /* binding */ _internals; });
+
+// CONCATENATED MODULE: ./src/alerts.js
+/* 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/. */
+
+/* eslint-disable sorting/sort-object-props */
+const ALERT_LEVEL = {
+ WARNING: 1,
+ FATAL: 2
+};
+
+const ALERT_DESCRIPTION = {
+ CLOSE_NOTIFY: 0,
+ UNEXPECTED_MESSAGE: 10,
+ BAD_RECORD_MAC: 20,
+ RECORD_OVERFLOW: 22,
+ HANDSHAKE_FAILURE: 40,
+ ILLEGAL_PARAMETER: 47,
+ DECODE_ERROR: 50,
+ DECRYPT_ERROR: 51,
+ PROTOCOL_VERSION: 70,
+ INTERNAL_ERROR: 80,
+ MISSING_EXTENSION: 109,
+ UNSUPPORTED_EXTENSION: 110,
+ UNKNOWN_PSK_IDENTITY: 115,
+ NO_APPLICATION_PROTOCOL: 120,
+};
+/* eslint-enable sorting/sort-object-props */
+
+function alertTypeToName(type) {
+ for (const name in ALERT_DESCRIPTION) {
+ if (ALERT_DESCRIPTION[name] === type) {
+ return `${name} (${type})`;
+ }
+ }
+ return `UNKNOWN (${type})`;
+}
+
+class TLSAlert extends Error {
+ constructor(description, level) {
+ super(`TLS Alert: ${alertTypeToName(description)}`);
+ this.description = description;
+ this.level = level;
+ }
+
+ static fromBytes(bytes) {
+ if (bytes.byteLength !== 2) {
+ throw new TLSError(ALERT_DESCRIPTION.DECODE_ERROR);
+ }
+ switch (bytes[1]) {
+ case ALERT_DESCRIPTION.CLOSE_NOTIFY:
+ if (bytes[0] !== ALERT_LEVEL.WARNING) {
+ // Close notifications should be fatal.
+ throw new TLSError(ALERT_DESCRIPTION.ILLEGAL_PARAMETER);
+ }
+ return new TLSCloseNotify();
+ default:
+ return new TLSError(bytes[1]);
+ }
+ }
+
+ toBytes() {
+ return new Uint8Array([this.level, this.description]);
+ }
+}
+
+class TLSCloseNotify extends TLSAlert {
+ constructor() {
+ super(ALERT_DESCRIPTION.CLOSE_NOTIFY, ALERT_LEVEL.WARNING);
+ }
+}
+
+class TLSError extends TLSAlert {
+ constructor(description = ALERT_DESCRIPTION.INTERNAL_ERROR) {
+ super(description, ALERT_LEVEL.FATAL);
+ }
+}
+
+// CONCATENATED MODULE: ./src/utils.js
+/* 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/. */
+
+
+
+//
+// Various low-level utility functions.
+//
+// These are mostly conveniences for working with Uint8Arrays as
+// the primitive "bytes" type.
+//
+
+const UTF8_ENCODER = new TextEncoder();
+const UTF8_DECODER = new TextDecoder();
+
+function noop() {}
+
+function assert(cond, msg) {
+ if (! cond) {
+ throw new Error('assert failed: ' + msg);
+ }
+}
+
+function assertIsBytes(value, msg = 'value must be a Uint8Array') {
+ // Using `value instanceof Uint8Array` seems to fail in Firefox chrome code
+ // for inscrutable reasons, so we do a less direct check.
+ assert(ArrayBuffer.isView(value), msg);
+ assert(value.BYTES_PER_ELEMENT === 1, msg);
+ return value;
+}
+
+const EMPTY = new Uint8Array(0);
+
+function zeros(n) {
+ return new Uint8Array(n);
+}
+
+function arrayToBytes(value) {
+ return new Uint8Array(value);
+}
+
+function bytesToHex(bytes) {
+ return Array.prototype.map.call(bytes, byte => {
+ let s = byte.toString(16);
+ if (s.length === 1) {
+ s = '0' + s;
+ }
+ return s;
+ }).join('');
+}
+
+function hexToBytes(hexstr) {
+ assert(hexstr.length % 2 === 0, 'hexstr.length must be even');
+ return new Uint8Array(Array.prototype.map.call(hexstr, (c, n) => {
+ if (n % 2 === 1) {
+ return hexstr[n - 1] + c;
+ } else {
+ return '';
+ }
+ }).filter(s => {
+ return !! s;
+ }).map(s => {
+ return parseInt(s, 16);
+ }));
+}
+
+function bytesToUtf8(bytes) {
+ return UTF8_DECODER.decode(bytes);
+}
+
+function utf8ToBytes(str) {
+ return UTF8_ENCODER.encode(str);
+}
+
+function bytesToBase64url(bytes) {
+ // XXX TODO: try to use something constant-time, in case calling code
+ // uses it to encode secrets?
+ const charCodes = String.fromCharCode.apply(String, bytes);
+ return btoa(charCodes).replace(/\+/g, '-').replace(/\//g, '_');
+}
+
+function base64urlToBytes(str) {
+ // XXX TODO: try to use something constant-time, in case calling code
+ // uses it to decode secrets?
+ str = atob(str.replace(/-/g, '+').replace(/_/g, '/'));
+ const bytes = new Uint8Array(str.length);
+ for (let i = 0; i < str.length; i++) {
+ bytes[i] = str.charCodeAt(i);
+ }
+ return bytes;
+}
+
+function bytesAreEqual(v1, v2) {
+ assertIsBytes(v1);
+ assertIsBytes(v2);
+ if (v1.length !== v2.length) {
+ return false;
+ }
+ for (let i = 0; i < v1.length; i++) {
+ if (v1[i] !== v2[i]) {
+ return false;
+ }
+ }
+ return true;
+}
+
+// The `BufferReader` and `BufferWriter` classes are helpers for dealing with the
+// binary struct format that's used for various TLS message. Think of them as a
+// buffer with a pointer to the "current position" and a bunch of helper methods
+// to read/write structured data and advance said pointer.
+
+class utils_BufferWithPointer {
+ constructor(buf) {
+ this._buffer = buf;
+ this._dataview = new DataView(buf.buffer, buf.byteOffset, buf.byteLength);
+ this._pos = 0;
+ }
+
+ length() {
+ return this._buffer.byteLength;
+ }
+
+ tell() {
+ return this._pos;
+ }
+
+ seek(pos) {
+ if (pos < 0) {
+ throw new TLSError(ALERT_DESCRIPTION.DECODE_ERROR);
+ }
+ if (pos > this.length()) {
+ throw new TLSError(ALERT_DESCRIPTION.DECODE_ERROR);
+ }
+ this._pos = pos;
+ }
+
+ incr(offset) {
+ this.seek(this._pos + offset);
+ }
+}
+
+// The `BufferReader` class helps you read structured data from a byte array.
+// It offers methods for reading both primitive values, and the variable-length
+// vector structures defined in https://tools.ietf.org/html/rfc8446#section-3.4.
+//
+// Such vectors are represented as a length followed by the concatenated
+// bytes of each item, and the size of the length field is determined by
+// the maximum allowed number of bytes in the vector. For example
+// to read a vector that may contain up to 65535 bytes, use `readVector16`.
+//
+// To read a variable-length vector of between 1 and 100 uint16 values,
+// defined in the RFC like this:
+//
+// uint16 items<2..200>;
+//
+// You would do something like this:
+//
+// const items = []
+// buf.readVector8(buf => {
+// items.push(buf.readUint16())
+// })
+//
+// The various `read` will throw `DECODE_ERROR` if you attempt to read path
+// the end of the buffer, or past the end of a variable-length list.
+//
+class utils_BufferReader extends utils_BufferWithPointer {
+
+ hasMoreBytes() {
+ return this.tell() < this.length();
+ }
+
+ readBytes(length) {
+ // This avoids copies by returning a view onto the existing buffer.
+ const start = this._buffer.byteOffset + this.tell();
+ this.incr(length);
+ return new Uint8Array(this._buffer.buffer, start, length);
+ }
+
+ _rangeErrorToAlert(cb) {
+ try {
+ return cb(this);
+ } catch (err) {
+ if (err instanceof RangeError) {
+ throw new TLSError(ALERT_DESCRIPTION.DECODE_ERROR);
+ }
+ throw err;
+ }
+ }
+
+ readUint8() {
+ return this._rangeErrorToAlert(() => {
+ const n = this._dataview.getUint8(this._pos);
+ this.incr(1);
+ return n;
+ });
+ }
+
+ readUint16() {
+ return this._rangeErrorToAlert(() => {
+ const n = this._dataview.getUint16(this._pos);
+ this.incr(2);
+ return n;
+ });
+ }
+
+ readUint24() {
+ return this._rangeErrorToAlert(() => {
+ let n = this._dataview.getUint16(this._pos);
+ n = (n << 8) | this._dataview.getUint8(this._pos + 2);
+ this.incr(3);
+ return n;
+ });
+ }
+
+ readUint32() {
+ return this._rangeErrorToAlert(() => {
+ const n = this._dataview.getUint32(this._pos);
+ this.incr(4);
+ return n;
+ });
+ }
+
+ _readVector(length, cb) {
+ const contentsBuf = new utils_BufferReader(this.readBytes(length));
+ const expectedEnd = this.tell();
+ // Keep calling the callback until we've consumed the expected number of bytes.
+ let n = 0;
+ while (contentsBuf.hasMoreBytes()) {
+ const prevPos = contentsBuf.tell();
+ cb(contentsBuf, n);
+ // Check that the callback made forward progress, otherwise we'll infinite loop.
+ if (contentsBuf.tell() <= prevPos) {
+ throw new TLSError(ALERT_DESCRIPTION.DECODE_ERROR);
+ }
+ n += 1;
+ }
+ // Check that the callback correctly consumed the vector's entire contents.
+ if (this.tell() !== expectedEnd) {
+ throw new TLSError(ALERT_DESCRIPTION.DECODE_ERROR);
+ }
+ }
+
+ readVector8(cb) {
+ const length = this.readUint8();
+ return this._readVector(length, cb);
+ }
+
+ readVector16(cb) {
+ const length = this.readUint16();
+ return this._readVector(length, cb);
+ }
+
+ readVector24(cb) {
+ const length = this.readUint24();
+ return this._readVector(length, cb);
+ }
+
+ readVectorBytes8() {
+ return this.readBytes(this.readUint8());
+ }
+
+ readVectorBytes16() {
+ return this.readBytes(this.readUint16());
+ }
+
+ readVectorBytes24() {
+ return this.readBytes(this.readUint24());
+ }
+}
+
+
+class utils_BufferWriter extends utils_BufferWithPointer {
+ constructor(size = 1024) {
+ super(new Uint8Array(size));
+ }
+
+ _maybeGrow(n) {
+ const curSize = this._buffer.byteLength;
+ const newPos = this._pos + n;
+ const shortfall = newPos - curSize;
+ if (shortfall > 0) {
+ // Classic grow-by-doubling, up to 4kB max increment.
+ // This formula was not arrived at by any particular science.
+ const incr = Math.min(curSize, 4 * 1024);
+ const newbuf = new Uint8Array(curSize + Math.ceil(shortfall / incr) * incr);
+ newbuf.set(this._buffer, 0);
+ this._buffer = newbuf;
+ this._dataview = new DataView(newbuf.buffer, newbuf.byteOffset, newbuf.byteLength);
+ }
+ }
+
+ slice(start = 0, end = this.tell()) {
+ if (end < 0) {
+ end = this.tell() + end;
+ }
+ if (start < 0) {
+ throw new TLSError(ALERT_DESCRIPTION.INTERNAL_ERROR);
+ }
+ if (end < 0) {
+ throw new TLSError(ALERT_DESCRIPTION.INTERNAL_ERROR);
+ }
+ if (end > this.length()) {
+ throw new TLSError(ALERT_DESCRIPTION.INTERNAL_ERROR);
+ }
+ return this._buffer.slice(start, end);
+ }
+
+ flush() {
+ const slice = this.slice();
+ this.seek(0);
+ return slice;
+ }
+
+ writeBytes(data) {
+ this._maybeGrow(data.byteLength);
+ this._buffer.set(data, this.tell());
+ this.incr(data.byteLength);
+ }
+
+ writeUint8(n) {
+ this._maybeGrow(1);
+ this._dataview.setUint8(this._pos, n);
+ this.incr(1);
+ }
+
+ writeUint16(n) {
+ this._maybeGrow(2);
+ this._dataview.setUint16(this._pos, n);
+ this.incr(2);
+ }
+
+ writeUint24(n) {
+ this._maybeGrow(3);
+ this._dataview.setUint16(this._pos, n >> 8);
+ this._dataview.setUint8(this._pos + 2, n & 0xFF);
+ this.incr(3);
+ }
+
+ writeUint32(n) {
+ this._maybeGrow(4);
+ this._dataview.setUint32(this._pos, n);
+ this.incr(4);
+ }
+
+ // These are helpers for writing the variable-length vector structure
+ // defined in https://tools.ietf.org/html/rfc8446#section-3.4.
+ //
+ // Such vectors are represented as a length followed by the concatenated
+ // bytes of each item, and the size of the length field is determined by
+ // the maximum allowed size of the vector. For example to write a vector
+ // that may contain up to 65535 bytes, use `writeVector16`.
+ //
+ // To write a variable-length vector of between 1 and 100 uint16 values,
+ // defined in the RFC like this:
+ //
+ // uint16 items<2..200>;
+ //
+ // You would do something like this:
+ //
+ // buf.writeVector8(buf => {
+ // for (let item of items) {
+ // buf.writeUint16(item)
+ // }
+ // })
+ //
+ // The helper will automatically take care of writing the appropriate
+ // length field once the callback completes.
+
+ _writeVector(maxLength, writeLength, cb) {
+ // Initially, write the length field as zero.
+ const lengthPos = this.tell();
+ writeLength(0);
+ // Call the callback to write the vector items.
+ const bodyPos = this.tell();
+ cb(this);
+ const length = this.tell() - bodyPos;
+ if (length >= maxLength) {
+ throw new TLSError(ALERT_DESCRIPTION.INTERNAL_ERROR);
+ }
+ // Backfill the actual length field.
+ this.seek(lengthPos);
+ writeLength(length);
+ this.incr(length);
+ return length;
+ }
+
+ writeVector8(cb) {
+ return this._writeVector(Math.pow(2, 8), len => this.writeUint8(len), cb);
+ }
+
+ writeVector16(cb) {
+ return this._writeVector(Math.pow(2, 16), len => this.writeUint16(len), cb);
+ }
+
+ writeVector24(cb) {
+ return this._writeVector(Math.pow(2, 24), len => this.writeUint24(len), cb);
+ }
+
+ writeVectorBytes8(bytes) {
+ return this.writeVector8(buf => {
+ buf.writeBytes(bytes);
+ });
+ }
+
+ writeVectorBytes16(bytes) {
+ return this.writeVector16(buf => {
+ buf.writeBytes(bytes);
+ });
+ }
+
+ writeVectorBytes24(bytes) {
+ return this.writeVector24(buf => {
+ buf.writeBytes(bytes);
+ });
+ }
+}
+
+// CONCATENATED MODULE: ./src/crypto.js
+/* 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/. */
+
+//
+// Low-level crypto primitives.
+//
+// This file implements the AEAD encrypt/decrypt and hashing routines
+// for the TLS_AES_128_GCM_SHA256 ciphersuite. They are (thankfully)
+// fairly light-weight wrappers around what's available via the WebCrypto
+// API.
+//
+
+
+
+
+const AEAD_SIZE_INFLATION = 16;
+const KEY_LENGTH = 16;
+const IV_LENGTH = 12;
+const HASH_LENGTH = 32;
+
+async function prepareKey(key, mode) {
+ return crypto.subtle.importKey('raw', key, { name: 'AES-GCM' }, false, [mode]);
+}
+
+async function encrypt(key, iv, plaintext, additionalData) {
+ const ciphertext = await crypto.subtle.encrypt({
+ additionalData,
+ iv,
+ name: 'AES-GCM',
+ tagLength: AEAD_SIZE_INFLATION * 8
+ }, key, plaintext);
+ return new Uint8Array(ciphertext);
+}
+
+async function decrypt(key, iv, ciphertext, additionalData) {
+ try {
+ const plaintext = await crypto.subtle.decrypt({
+ additionalData,
+ iv,
+ name: 'AES-GCM',
+ tagLength: AEAD_SIZE_INFLATION * 8
+ }, key, ciphertext);
+ return new Uint8Array(plaintext);
+ } catch (err) {
+ // Yes, we really do throw 'decrypt_error' when failing to verify a HMAC,
+ // and a 'bad_record_mac' error when failing to decrypt.
+ throw new TLSError(ALERT_DESCRIPTION.BAD_RECORD_MAC);
+ }
+}
+
+async function hash(message) {
+ return new Uint8Array(await crypto.subtle.digest({ name: 'SHA-256' }, message));
+}
+
+async function hmac(keyBytes, message) {
+ const key = await crypto.subtle.importKey('raw', keyBytes, {
+ hash: { name: 'SHA-256' },
+ name: 'HMAC',
+ }, false, ['sign']);
+ const sig = await crypto.subtle.sign({ name: 'HMAC' }, key, message);
+ return new Uint8Array(sig);
+}
+
+async function verifyHmac(keyBytes, signature, message) {
+ const key = await crypto.subtle.importKey('raw', keyBytes, {
+ hash: { name: 'SHA-256' },
+ name: 'HMAC',
+ }, false, ['verify']);
+ if (! (await crypto.subtle.verify({ name: 'HMAC' }, key, signature, message))) {
+ // Yes, we really do throw 'decrypt_error' when failing to verify a HMAC,
+ // and a 'bad_record_mac' error when failing to decrypt.
+ throw new TLSError(ALERT_DESCRIPTION.DECRYPT_ERROR);
+ }
+}
+
+async function hkdfExtract(salt, ikm) {
+ // Ref https://tools.ietf.org/html/rfc5869#section-2.2
+ return await hmac(salt, ikm);
+}
+
+async function hkdfExpand(prk, info, length) {
+ // Ref https://tools.ietf.org/html/rfc5869#section-2.3
+ const N = Math.ceil(length / HASH_LENGTH);
+ if (N <= 0) {
+ throw new TLSError(ALERT_DESCRIPTION.INTERNAL_ERROR);
+ }
+ if (N >= 255) {
+ throw new TLSError(ALERT_DESCRIPTION.INTERNAL_ERROR);
+ }
+ const input = new utils_BufferWriter();
+ const output = new utils_BufferWriter();
+ let T = new Uint8Array(0);
+ for (let i = 1; i <= N; i++) {
+ input.writeBytes(T);
+ input.writeBytes(info);
+ input.writeUint8(i);
+ T = await hmac(prk, input.flush());
+ output.writeBytes(T);
+ }
+ return output.slice(0, length);
+}
+
+async function hkdfExpandLabel(secret, label, context, length) {
+ // struct {
+ // uint16 length = Length;
+ // opaque label < 7..255 > = "tls13 " + Label;
+ // opaque context < 0..255 > = Context;
+ // } HkdfLabel;
+ const hkdfLabel = new utils_BufferWriter();
+ hkdfLabel.writeUint16(length);
+ hkdfLabel.writeVectorBytes8(utf8ToBytes('tls13 ' + label));
+ hkdfLabel.writeVectorBytes8(context);
+ return hkdfExpand(secret, hkdfLabel.flush(), length);
+}
+
+async function getRandomBytes(size) {
+ const bytes = new Uint8Array(size);
+ crypto.getRandomValues(bytes);
+ return bytes;
+}
+
+// CONCATENATED MODULE: ./src/extensions.js
+/* 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/. */
+
+//
+// Extension parsing.
+//
+// This file contains some helpers for reading/writing the various kinds
+// of Extension that might appear in a HandshakeMessage.
+//
+// "Extensions" are how TLS signals the presence of particular bits of optional
+// functionality in the protocol. Lots of parts of TLS1.3 that don't seem like
+// they're optional are implemented in terms of an extension, IIUC because that's
+// what was needed for a clean deployment in amongst earlier versions of the protocol.
+//
+
+
+
+
+
+/* eslint-disable sorting/sort-object-props */
+const EXTENSION_TYPE = {
+ PRE_SHARED_KEY: 41,
+ SUPPORTED_VERSIONS: 43,
+ PSK_KEY_EXCHANGE_MODES: 45,
+};
+/* eslint-enable sorting/sort-object-props */
+
+// Base class for generic reading/writing of extensions,
+// which are all uniformly formatted as:
+//
+// struct {
+// ExtensionType extension_type;
+// opaque extension_data<0..2^16-1>;
+// } Extension;
+//
+// Extensions always appear inside of a handshake message,
+// and their internal structure may differ based on the
+// type of that message.
+
+class extensions_Extension {
+
+ get TYPE_TAG() {
+ throw new Error('not implemented');
+ }
+
+ static read(messageType, buf) {
+ const type = buf.readUint16();
+ let ext = {
+ TYPE_TAG: type,
+ };
+ buf.readVector16(buf => {
+ switch (type) {
+ case EXTENSION_TYPE.PRE_SHARED_KEY:
+ ext = extensions_PreSharedKeyExtension._read(messageType, buf);
+ break;
+ case EXTENSION_TYPE.SUPPORTED_VERSIONS:
+ ext = extensions_SupportedVersionsExtension._read(messageType, buf);
+ break;
+ case EXTENSION_TYPE.PSK_KEY_EXCHANGE_MODES:
+ ext = extensions_PskKeyExchangeModesExtension._read(messageType, buf);
+ break;
+ default:
+ // Skip over unrecognised extensions.
+ buf.incr(buf.length());
+ }
+ if (buf.hasMoreBytes()) {
+ throw new TLSError(ALERT_DESCRIPTION.DECODE_ERROR);
+ }
+ });
+ return ext;
+ }
+
+ write(messageType, buf) {
+ buf.writeUint16(this.TYPE_TAG);
+ buf.writeVector16(buf => {
+ this._write(messageType, buf);
+ });
+ }
+
+ static _read(messageType, buf) {
+ throw new Error('not implemented');
+ }
+
+ static _write(messageType, buf) {
+ throw new Error('not implemented');
+ }
+}
+
+// The PreSharedKey extension:
+//
+// struct {
+// opaque identity<1..2^16-1>;
+// uint32 obfuscated_ticket_age;
+// } PskIdentity;
+// opaque PskBinderEntry<32..255>;
+// struct {
+// PskIdentity identities<7..2^16-1>;
+// PskBinderEntry binders<33..2^16-1>;
+// } OfferedPsks;
+// struct {
+// select(Handshake.msg_type) {
+// case client_hello: OfferedPsks;
+// case server_hello: uint16 selected_identity;
+// };
+// } PreSharedKeyExtension;
+
+class extensions_PreSharedKeyExtension extends extensions_Extension {
+ constructor(identities, binders, selectedIdentity) {
+ super();
+ this.identities = identities;
+ this.binders = binders;
+ this.selectedIdentity = selectedIdentity;
+ }
+
+ get TYPE_TAG() {
+ return EXTENSION_TYPE.PRE_SHARED_KEY;
+ }
+
+ static _read(messageType, buf) {
+ let identities = null, binders = null, selectedIdentity = null;
+ switch (messageType) {
+ case HANDSHAKE_TYPE.CLIENT_HELLO:
+ identities = []; binders = [];
+ buf.readVector16(buf => {
+ const identity = buf.readVectorBytes16();
+ buf.readBytes(4); // Skip over the ticket age.
+ identities.push(identity);
+ });
+ buf.readVector16(buf => {
+ const binder = buf.readVectorBytes8();
+ if (binder.byteLength < HASH_LENGTH) {
+ throw new TLSError(ALERT_DESCRIPTION.ILLEGAL_PARAMETER);
+ }
+ binders.push(binder);
+ });
+ if (identities.length !== binders.length) {
+ throw new TLSError(ALERT_DESCRIPTION.ILLEGAL_PARAMETER);
+ }
+ break;
+ case HANDSHAKE_TYPE.SERVER_HELLO:
+ selectedIdentity = buf.readUint16();
+ break;
+ default:
+ throw new TLSError(ALERT_DESCRIPTION.ILLEGAL_PARAMETER);
+ }
+ return new this(identities, binders, selectedIdentity);
+ }
+
+ _write(messageType, buf) {
+ switch (messageType) {
+ case HANDSHAKE_TYPE.CLIENT_HELLO:
+ buf.writeVector16(buf => {
+ this.identities.forEach(pskId => {
+ buf.writeVectorBytes16(pskId);
+ buf.writeUint32(0); // Zero for "tag age" field.
+ });
+ });
+ buf.writeVector16(buf => {
+ this.binders.forEach(pskBinder => {
+ buf.writeVectorBytes8(pskBinder);
+ });
+ });
+ break;
+ case HANDSHAKE_TYPE.SERVER_HELLO:
+ buf.writeUint16(this.selectedIdentity);
+ break;
+ default:
+ throw new TLSError(ALERT_DESCRIPTION.INTERNAL_ERROR);
+ }
+ }
+}
+
+
+// The SupportedVersions extension:
+//
+// struct {
+// select(Handshake.msg_type) {
+// case client_hello:
+// ProtocolVersion versions < 2..254 >;
+// case server_hello:
+// ProtocolVersion selected_version;
+// };
+// } SupportedVersions;
+
+class extensions_SupportedVersionsExtension extends extensions_Extension {
+ constructor(versions, selectedVersion) {
+ super();
+ this.versions = versions;
+ this.selectedVersion = selectedVersion;
+ }
+
+ get TYPE_TAG() {
+ return EXTENSION_TYPE.SUPPORTED_VERSIONS;
+ }
+
+ static _read(messageType, buf) {
+ let versions = null, selectedVersion = null;
+ switch (messageType) {
+ case HANDSHAKE_TYPE.CLIENT_HELLO:
+ versions = [];
+ buf.readVector8(buf => {
+ versions.push(buf.readUint16());
+ });
+ break;
+ case HANDSHAKE_TYPE.SERVER_HELLO:
+ selectedVersion = buf.readUint16();
+ break;
+ default:
+ throw new TLSError(ALERT_DESCRIPTION.ILLEGAL_PARAMETER);
+ }
+ return new this(versions, selectedVersion);
+ }
+
+ _write(messageType, buf) {
+ switch (messageType) {
+ case HANDSHAKE_TYPE.CLIENT_HELLO:
+ buf.writeVector8(buf => {
+ this.versions.forEach(version => {
+ buf.writeUint16(version);
+ });
+ });
+ break;
+ case HANDSHAKE_TYPE.SERVER_HELLO:
+ buf.writeUint16(this.selectedVersion);
+ break;
+ default:
+ throw new TLSError(ALERT_DESCRIPTION.INTERNAL_ERROR);
+ }
+ }
+}
+
+
+class extensions_PskKeyExchangeModesExtension extends extensions_Extension {
+ constructor(modes) {
+ super();
+ this.modes = modes;
+ }
+
+ get TYPE_TAG() {
+ return EXTENSION_TYPE.PSK_KEY_EXCHANGE_MODES;
+ }
+
+ static _read(messageType, buf) {
+ const modes = [];
+ switch (messageType) {
+ case HANDSHAKE_TYPE.CLIENT_HELLO:
+ buf.readVector8(buf => {
+ modes.push(buf.readUint8());
+ });
+ break;
+ default:
+ throw new TLSError(ALERT_DESCRIPTION.ILLEGAL_PARAMETER);
+ }
+ return new this(modes);
+ }
+
+ _write(messageType, buf) {
+ switch (messageType) {
+ case HANDSHAKE_TYPE.CLIENT_HELLO:
+ buf.writeVector8(buf => {
+ this.modes.forEach(mode => {
+ buf.writeUint8(mode);
+ });
+ });
+ break;
+ default:
+ throw new TLSError(ALERT_DESCRIPTION.INTERNAL_ERROR);
+ }
+ }
+}
+
+// CONCATENATED MODULE: ./src/constants.js
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const VERSION_TLS_1_0 = 0x0301;
+const VERSION_TLS_1_2 = 0x0303;
+const VERSION_TLS_1_3 = 0x0304;
+const TLS_AES_128_GCM_SHA256 = 0x1301;
+const PSK_MODE_KE = 0;
+
+// CONCATENATED MODULE: ./src/messages.js
+/* 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/. */
+
+//
+// Message parsing.
+//
+// Herein we have code for reading and writing the various Handshake
+// messages involved in the TLS protocol.
+//
+
+
+
+
+
+
+
+/* eslint-disable sorting/sort-object-props */
+const HANDSHAKE_TYPE = {
+ CLIENT_HELLO: 1,
+ SERVER_HELLO: 2,
+ NEW_SESSION_TICKET: 4,
+ ENCRYPTED_EXTENSIONS: 8,
+ FINISHED: 20,
+};
+/* eslint-enable sorting/sort-object-props */
+
+// Base class for generic reading/writing of handshake messages,
+// which are all uniformly formatted as:
+//
+// struct {
+// HandshakeType msg_type; /* handshake type */
+// uint24 length; /* bytes in message */
+// select(Handshake.msg_type) {
+// ... type specific cases here ...
+// };
+// } Handshake;
+
+class messages_HandshakeMessage {
+
+ get TYPE_TAG() {
+ throw new Error('not implemented');
+ }
+
+ static fromBytes(bytes) {
+ // Each handshake message has a type and length prefix, per
+ // https://tools.ietf.org/html/rfc8446#appendix-B.3
+ const buf = new utils_BufferReader(bytes);
+ const msg = this.read(buf);
+ if (buf.hasMoreBytes()) {
+ throw new TLSError(ALERT_DESCRIPTION.DECODE_ERROR);
+ }
+ return msg;
+ }
+
+ toBytes() {
+ const buf = new utils_BufferWriter();
+ this.write(buf);
+ return buf.flush();
+ }
+
+ static read(buf) {
+ const type = buf.readUint8();
+ let msg = null;
+ buf.readVector24(buf => {
+ switch (type) {
+ case HANDSHAKE_TYPE.CLIENT_HELLO:
+ msg = messages_ClientHello._read(buf);
+ break;
+ case HANDSHAKE_TYPE.SERVER_HELLO:
+ msg = messages_ServerHello._read(buf);
+ break;
+ case HANDSHAKE_TYPE.NEW_SESSION_TICKET:
+ msg = messages_NewSessionTicket._read(buf);
+ break;
+ case HANDSHAKE_TYPE.ENCRYPTED_EXTENSIONS:
+ msg = EncryptedExtensions._read(buf);
+ break;
+ case HANDSHAKE_TYPE.FINISHED:
+ msg = messages_Finished._read(buf);
+ break;
+ }
+ if (buf.hasMoreBytes()) {
+ throw new TLSError(ALERT_DESCRIPTION.DECODE_ERROR);
+ }
+ });
+ if (msg === null) {
+ throw new TLSError(ALERT_DESCRIPTION.UNEXPECTED_MESSAGE);
+ }
+ return msg;
+ }
+
+ write(buf) {
+ buf.writeUint8(this.TYPE_TAG);
+ buf.writeVector24(buf => {
+ this._write(buf);
+ });
+ }
+
+ static _read(buf) {
+ throw new Error('not implemented');
+ }
+
+ _write(buf) {
+ throw new Error('not implemented');
+ }
+
+ // Some little helpers for reading a list of extensions,
+ // which is uniformly represented as:
+ //
+ // Extension extensions<8..2^16-1>;
+ //
+ // Recognized extensions are returned as a Map from extension type
+ // to extension data object, with a special `lastSeenExtension`
+ // property to make it easy to check which one came last.
+
+ static _readExtensions(messageType, buf) {
+ const extensions = new Map();
+ buf.readVector16(buf => {
+ const ext = extensions_Extension.read(messageType, buf);
+ if (extensions.has(ext.TYPE_TAG)) {
+ throw new TLSError(ALERT_DESCRIPTION.DECODE_ERROR);
+ }
+ extensions.set(ext.TYPE_TAG, ext);
+ extensions.lastSeenExtension = ext.TYPE_TAG;
+ });
+ return extensions;
+ }
+
+ _writeExtensions(buf, extensions) {
+ buf.writeVector16(buf => {
+ extensions.forEach(ext => {
+ ext.write(this.TYPE_TAG, buf);
+ });
+ });
+ }
+}
+
+
+// The ClientHello message:
+//
+// struct {
+// ProtocolVersion legacy_version = 0x0303;
+// Random random;
+// opaque legacy_session_id<0..32>;
+// CipherSuite cipher_suites<2..2^16-2>;
+// opaque legacy_compression_methods<1..2^8-1>;
+// Extension extensions<8..2^16-1>;
+// } ClientHello;
+
+class messages_ClientHello extends messages_HandshakeMessage {
+
+ constructor(random, sessionId, extensions) {
+ super();
+ this.random = random;
+ this.sessionId = sessionId;
+ this.extensions = extensions;
+ }
+
+ get TYPE_TAG() {
+ return HANDSHAKE_TYPE.CLIENT_HELLO;
+ }
+
+ static _read(buf) {
+ // The legacy_version field may indicate an earlier version of TLS
+ // for backwards compatibility, but must not predate TLS 1.0!
+ if (buf.readUint16() < VERSION_TLS_1_0) {
+ throw new TLSError(ALERT_DESCRIPTION.PROTOCOL_VERSION);
+ }
+ // The random bytes provided by the peer.
+ const random = buf.readBytes(32);
+ // Read legacy_session_id, so the server can echo it.
+ const sessionId = buf.readVectorBytes8();
+ // We only support a single ciphersuite, but the peer may offer several.
+ // Scan the list to confirm that the one we want is present.
+ let found = false;
+ buf.readVector16(buf => {
+ const cipherSuite = buf.readUint16();
+ if (cipherSuite === TLS_AES_128_GCM_SHA256) {
+ found = true;
+ }
+ });
+ if (! found) {
+ throw new TLSError(ALERT_DESCRIPTION.HANDSHAKE_FAILURE);
+ }
+ // legacy_compression_methods must be a single zero byte for TLS1.3 ClientHellos.
+ // It can be non-zero in previous versions of TLS, but we're not going to
+ // make a successful handshake with such versions, so better to just bail out now.
+ const legacyCompressionMethods = buf.readVectorBytes8();
+ if (legacyCompressionMethods.byteLength !== 1) {
+ throw new TLSError(ALERT_DESCRIPTION.ILLEGAL_PARAMETER);
+ }
+ if (legacyCompressionMethods[0] !== 0x00) {
+ throw new TLSError(ALERT_DESCRIPTION.ILLEGAL_PARAMETER);
+ }
+ // Read and check the extensions.
+ const extensions = this._readExtensions(HANDSHAKE_TYPE.CLIENT_HELLO, buf);
+ if (! extensions.has(EXTENSION_TYPE.SUPPORTED_VERSIONS)) {
+ throw new TLSError(ALERT_DESCRIPTION.MISSING_EXTENSION);
+ }
+ if (extensions.get(EXTENSION_TYPE.SUPPORTED_VERSIONS).versions.indexOf(VERSION_TLS_1_3) === -1) {
+ throw new TLSError(ALERT_DESCRIPTION.PROTOCOL_VERSION);
+ }
+ // Was the PreSharedKey extension the last one?
+ if (extensions.has(EXTENSION_TYPE.PRE_SHARED_KEY)) {
+ if (extensions.lastSeenExtension !== EXTENSION_TYPE.PRE_SHARED_KEY) {
+ throw new TLSError(ALERT_DESCRIPTION.ILLEGAL_PARAMETER);
+ }
+ }
+ return new this(random, sessionId, extensions);
+ }
+
+ _write(buf) {
+ buf.writeUint16(VERSION_TLS_1_2);
+ buf.writeBytes(this.random);
+ buf.writeVectorBytes8(this.sessionId);
+ // Our single supported ciphersuite
+ buf.writeVector16(buf => {
+ buf.writeUint16(TLS_AES_128_GCM_SHA256);
+ });
+ // A single zero byte for legacy_compression_methods
+ buf.writeVectorBytes8(new Uint8Array(1));
+ this._writeExtensions(buf, this.extensions);
+ }
+}
+
+
+// The ServerHello message:
+//
+// struct {
+// ProtocolVersion legacy_version = 0x0303; /* TLS v1.2 */
+// Random random;
+// opaque legacy_session_id_echo<0..32>;
+// CipherSuite cipher_suite;
+// uint8 legacy_compression_method = 0;
+// Extension extensions < 6..2 ^ 16 - 1 >;
+// } ServerHello;
+
+class messages_ServerHello extends messages_HandshakeMessage {
+
+ constructor(random, sessionId, extensions) {
+ super();
+ this.random = random;
+ this.sessionId = sessionId;
+ this.extensions = extensions;
+ }
+
+ get TYPE_TAG() {
+ return HANDSHAKE_TYPE.SERVER_HELLO;
+ }
+
+ static _read(buf) {
+ // Fixed value for legacy_version.
+ if (buf.readUint16() !== VERSION_TLS_1_2) {
+ throw new TLSError(ALERT_DESCRIPTION.ILLEGAL_PARAMETER);
+ }
+ // Random bytes from the server.
+ const random = buf.readBytes(32);
+ // It should have echoed our vector for legacy_session_id.
+ const sessionId = buf.readVectorBytes8();
+ // It should have selected our single offered ciphersuite.
+ if (buf.readUint16() !== TLS_AES_128_GCM_SHA256) {
+ throw new TLSError(ALERT_DESCRIPTION.ILLEGAL_PARAMETER);
+ }
+ // legacy_compression_method must be zero.
+ if (buf.readUint8() !== 0) {
+ throw new TLSError(ALERT_DESCRIPTION.ILLEGAL_PARAMETER);
+ }
+ const extensions = this._readExtensions(HANDSHAKE_TYPE.SERVER_HELLO, buf);
+ if (! extensions.has(EXTENSION_TYPE.SUPPORTED_VERSIONS)) {
+ throw new TLSError(ALERT_DESCRIPTION.MISSING_EXTENSION);
+ }
+ if (extensions.get(EXTENSION_TYPE.SUPPORTED_VERSIONS).selectedVersion !== VERSION_TLS_1_3) {
+ throw new TLSError(ALERT_DESCRIPTION.ILLEGAL_PARAMETER);
+ }
+ return new this(random, sessionId, extensions);
+ }
+
+ _write(buf) {
+ buf.writeUint16(VERSION_TLS_1_2);
+ buf.writeBytes(this.random);
+ buf.writeVectorBytes8(this.sessionId);
+ // Our single supported ciphersuite
+ buf.writeUint16(TLS_AES_128_GCM_SHA256);
+ // A single zero byte for legacy_compression_method
+ buf.writeUint8(0);
+ this._writeExtensions(buf, this.extensions);
+ }
+}
+
+
+// The EncryptedExtensions message:
+//
+// struct {
+// Extension extensions < 0..2 ^ 16 - 1 >;
+// } EncryptedExtensions;
+//
+// We don't actually send any EncryptedExtensions,
+// but still have to send an empty message.
+
+class EncryptedExtensions extends messages_HandshakeMessage {
+ constructor(extensions) {
+ super();
+ this.extensions = extensions;
+ }
+
+ get TYPE_TAG() {
+ return HANDSHAKE_TYPE.ENCRYPTED_EXTENSIONS;
+ }
+
+ static _read(buf) {
+ const extensions = this._readExtensions(HANDSHAKE_TYPE.ENCRYPTED_EXTENSIONS, buf);
+ return new this(extensions);
+ }
+
+ _write(buf) {
+ this._writeExtensions(buf, this.extensions);
+ }
+}
+
+
+// The Finished message:
+//
+// struct {
+// opaque verify_data[Hash.length];
+// } Finished;
+
+class messages_Finished extends messages_HandshakeMessage {
+
+ constructor(verifyData) {
+ super();
+ this.verifyData = verifyData;
+ }
+
+ get TYPE_TAG() {
+ return HANDSHAKE_TYPE.FINISHED;
+ }
+
+ static _read(buf) {
+ const verifyData = buf.readBytes(HASH_LENGTH);
+ return new this(verifyData);
+ }
+
+ _write(buf) {
+ buf.writeBytes(this.verifyData);
+ }
+}
+
+
+// The NewSessionTicket message:
+//
+// struct {
+// uint32 ticket_lifetime;
+// uint32 ticket_age_add;
+// opaque ticket_nonce < 0..255 >;
+// opaque ticket < 1..2 ^ 16 - 1 >;
+// Extension extensions < 0..2 ^ 16 - 2 >;
+// } NewSessionTicket;
+//
+// We don't actually make use of these, but we need to be able
+// to accept them and do basic validation.
+
+class messages_NewSessionTicket extends messages_HandshakeMessage {
+ constructor(ticketLifetime, ticketAgeAdd, ticketNonce, ticket, extensions) {
+ super();
+ this.ticketLifetime = ticketLifetime;
+ this.ticketAgeAdd = ticketAgeAdd;
+ this.ticketNonce = ticketNonce;
+ this.ticket = ticket;
+ this.extensions = extensions;
+ }
+
+ get TYPE_TAG() {
+ return HANDSHAKE_TYPE.NEW_SESSION_TICKET;
+ }
+
+ static _read(buf) {
+ const ticketLifetime = buf.readUint32();
+ const ticketAgeAdd = buf.readUint32();
+ const ticketNonce = buf.readVectorBytes8();
+ const ticket = buf.readVectorBytes16();
+ if (ticket.byteLength < 1) {
+ throw new TLSError(ALERT_DESCRIPTION.DECODE_ERROR);
+ }
+ const extensions = this._readExtensions(HANDSHAKE_TYPE.NEW_SESSION_TICKET, buf);
+ return new this(ticketLifetime, ticketAgeAdd, ticketNonce, ticket, extensions);
+ }
+
+ _write(buf) {
+ buf.writeUint32(this.ticketLifetime);
+ buf.writeUint32(this.ticketAgeAdd);
+ buf.writeVectorBytes8(this.ticketNonce);
+ buf.writeVectorBytes16(this.ticket);
+ this._writeExtensions(buf, this.extensions);
+ }
+}
+
+// CONCATENATED MODULE: ./src/states.js
+/* 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/. */
+
+
+
+
+
+
+
+
+//
+// State-machine for TLS Handshake Management.
+//
+// Internally, we manage the TLS connection by explicitly modelling the
+// client and server state-machines from RFC8446. You can think of
+// these `State` objects as little plugins for the `Connection` class
+// that provide different behaviours of `send` and `receive` depending
+// on the state of the connection.
+//
+
+class states_State {
+
+ constructor(conn) {
+ this.conn = conn;
+ }
+
+ async initialize() {
+ // By default, nothing to do when entering the state.
+ }
+
+ async sendApplicationData(bytes) {
+ // By default, assume we're not ready to send yet and the caller
+ // should be blocking on the connection promise before reaching here.
+ throw new TLSError(ALERT_DESCRIPTION.INTERNAL_ERROR);
+ }
+
+ async recvApplicationData(bytes) {
+ throw new TLSError(ALERT_DESCRIPTION.UNEXPECTED_MESSAGE);
+ }
+
+ async recvHandshakeMessage(msg) {
+ throw new TLSError(ALERT_DESCRIPTION.UNEXPECTED_MESSAGE);
+ }
+
+ async recvAlertMessage(alert) {
+ switch (alert.description) {
+ case ALERT_DESCRIPTION.CLOSE_NOTIFY:
+ this.conn._closeForRecv(alert);
+ throw alert;
+ default:
+ return await this.handleErrorAndRethrow(alert);
+ }
+ }
+
+ async recvChangeCipherSpec(bytes) {
+ throw new TLSError(ALERT_DESCRIPTION.UNEXPECTED_MESSAGE);
+ }
+
+ async handleErrorAndRethrow(err) {
+ let alert = err;
+ if (! (alert instanceof TLSAlert)) {
+ alert = new TLSError(ALERT_DESCRIPTION.INTERNAL_ERROR);
+ }
+ // Try to send error alert to the peer, but we may not
+ // be able to if the outgoing connection was already closed.
+ try {
+ await this.conn._sendAlertMessage(alert);
+ } catch (_) { }
+ await this.conn._transition(ERROR, err);
+ throw err;
+ }
+
+ async close() {
+ const alert = new TLSCloseNotify();
+ await this.conn._sendAlertMessage(alert);
+ this.conn._closeForSend(alert);
+ }
+
+}
+
+// A special "guard" state to prevent us from using
+// an improperly-initialized Connection.
+
+class UNINITIALIZED extends states_State {
+ async initialize() {
+ throw new Error('uninitialized state');
+ }
+ async sendApplicationData(bytes) {
+ throw new Error('uninitialized state');
+ }
+ async recvApplicationData(bytes) {
+ throw new Error('uninitialized state');
+ }
+ async recvHandshakeMessage(msg) {
+ throw new Error('uninitialized state');
+ }
+ async recvChangeCipherSpec(bytes) {
+ throw new Error('uninitialized state');
+ }
+ async handleErrorAndRethrow(err) {
+ throw err;
+ }
+ async close() {
+ throw new Error('uninitialized state');
+ }
+}
+
+// A special "error" state for when something goes wrong.
+// This state never transitions to another state, effectively
+// terminating the connection.
+
+class ERROR extends states_State {
+ async initialize(err) {
+ this.error = err;
+ this.conn._setConnectionFailure(err);
+ // Unceremoniously shut down the record layer on error.
+ this.conn._recordlayer.setSendError(err);
+ this.conn._recordlayer.setRecvError(err);
+ }
+ async sendApplicationData(bytes) {
+ throw this.error;
+ }
+ async recvApplicationData(bytes) {
+ throw this.error;
+ }
+ async recvHandshakeMessage(msg) {
+ throw this.error;
+ }
+ async recvAlertMessage(err) {
+ throw this.error;
+ }
+ async recvChangeCipherSpec(bytes) {
+ throw this.error;
+ }
+ async handleErrorAndRethrow(err) {
+ throw err;
+ }
+ async close() {
+ throw this.error;
+ }
+}
+
+// The "connected" state, for when the handshake is complete
+// and we're ready to send application-level data.
+// The logic for this is largely symmetric between client and server.
+
+class states_CONNECTED extends states_State {
+ async initialize() {
+ this.conn._setConnectionSuccess();
+ }
+ async sendApplicationData(bytes) {
+ await this.conn._sendApplicationData(bytes);
+ }
+ async recvApplicationData(bytes) {
+ return bytes;
+ }
+ async recvChangeCipherSpec(bytes) {
+ throw new TLSError(ALERT_DESCRIPTION.UNEXPECTED_MESSAGE);
+ }
+}
+
+// A base class for states that occur in the middle of the handshake
+// (that is, between ClientHello and Finished). These states may receive
+// CHANGE_CIPHER_SPEC records for b/w compat reasons, which must contain
+// exactly a single 0x01 byte and must otherwise be ignored.
+
+class states_MidHandshakeState extends states_State {
+ async recvChangeCipherSpec(bytes) {
+ if (this.conn._hasSeenChangeCipherSpec) {
+ throw new TLSError(ALERT_DESCRIPTION.UNEXPECTED_MESSAGE);
+ }
+ if (bytes.byteLength !== 1 || bytes[0] !== 1) {
+ throw new TLSError(ALERT_DESCRIPTION.UNEXPECTED_MESSAGE);
+ }
+ this.conn._hasSeenChangeCipherSpec = true;
+ }
+}
+
+// These states implement (part of) the client state-machine from
+// https://tools.ietf.org/html/rfc8446#appendix-A.1
+//
+// Since we're only implementing a small subset of TLS1.3,
+// we only need a small subset of the handshake. It basically goes:
+//
+// * send ClientHello
+// * receive ServerHello
+// * receive EncryptedExtensions
+// * receive server Finished
+// * send client Finished
+//
+// We include some unused states for completeness, so that it's easier
+// to check the implementation against the diagrams in the RFC.
+
+class states_CLIENT_START extends states_State {
+ async initialize() {
+ const keyschedule = this.conn._keyschedule;
+ await keyschedule.addPSK(this.conn.psk);
+ // Construct a ClientHello message with our single PSK.
+ // We can't know the PSK binder value yet, so we initially write zeros.
+ const clientHello = new messages_ClientHello(
+ // Client random salt.
+ await getRandomBytes(32),
+ // Random legacy_session_id; we *could* send an empty string here,
+ // but sending a random one makes it easier to be compatible with
+ // the data emitted by tlslite-ng for test-case generation.
+ await getRandomBytes(32),
+ [
+ new extensions_SupportedVersionsExtension([VERSION_TLS_1_3]),
+ new extensions_PskKeyExchangeModesExtension([PSK_MODE_KE]),
+ new extensions_PreSharedKeyExtension([this.conn.pskId], [zeros(HASH_LENGTH)]),
+ ],
+ );
+ const buf = new utils_BufferWriter();
+ clientHello.write(buf);
+ // Now that we know what the ClientHello looks like,
+ // go back and calculate the appropriate PSK binder value.
+ // We only support a single PSK, so the length of the binders field is the
+ // length of the hash plus one for rendering it as a variable-length byte array,
+ // plus two for rendering the variable-length list of PSK binders.
+ const PSK_BINDERS_SIZE = HASH_LENGTH + 1 + 2;
+ const truncatedTranscript = buf.slice(0, buf.tell() - PSK_BINDERS_SIZE);
+ const pskBinder = await keyschedule.calculateFinishedMAC(keyschedule.extBinderKey, truncatedTranscript);
+ buf.incr(-HASH_LENGTH);
+ buf.writeBytes(pskBinder);
+ await this.conn._sendHandshakeMessageBytes(buf.flush());
+ await this.conn._transition(states_CLIENT_WAIT_SH, clientHello.sessionId);
+ }
+}
+
+class states_CLIENT_WAIT_SH extends states_State {
+ async initialize(sessionId) {
+ this._sessionId = sessionId;
+ }
+ async recvHandshakeMessage(msg) {
+ if (! (msg instanceof messages_ServerHello)) {
+ throw new TLSError(ALERT_DESCRIPTION.UNEXPECTED_MESSAGE);
+ }
+ if (! bytesAreEqual(msg.sessionId, this._sessionId)) {
+ throw new TLSError(ALERT_DESCRIPTION.ILLEGAL_PARAMETER);
+ }
+ const pskExt = msg.extensions.get(EXTENSION_TYPE.PRE_SHARED_KEY);
+ if (! pskExt) {
+ throw new TLSError(ALERT_DESCRIPTION.MISSING_EXTENSION);
+ }
+ // We expect only the SUPPORTED_VERSIONS and PRE_SHARED_KEY extensions.
+ if (msg.extensions.size !== 2) {
+ throw new TLSError(ALERT_DESCRIPTION.UNSUPPORTED_EXTENSION);
+ }
+ if (pskExt.selectedIdentity !== 0) {
+ throw new TLSError(ALERT_DESCRIPTION.ILLEGAL_PARAMETER);
+ }
+ await this.conn._keyschedule.addECDHE(null);
+ await this.conn._setSendKey(this.conn._keyschedule.clientHandshakeTrafficSecret);
+ await this.conn._setRecvKey(this.conn._keyschedule.serverHandshakeTrafficSecret);
+ await this.conn._transition(states_CLIENT_WAIT_EE);
+ }
+}
+
+class states_CLIENT_WAIT_EE extends states_MidHandshakeState {
+ async recvHandshakeMessage(msg) {
+ // We don't make use of any encrypted extensions, but we still
+ // have to wait for the server to send the (empty) list of them.
+ if (! (msg instanceof EncryptedExtensions)) {
+ throw new TLSError(ALERT_DESCRIPTION.UNEXPECTED_MESSAGE);
+ }
+ // We do not support any EncryptedExtensions.
+ if (msg.extensions.size !== 0) {
+ throw new TLSError(ALERT_DESCRIPTION.UNSUPPORTED_EXTENSION);
+ }
+ const keyschedule = this.conn._keyschedule;
+ const serverFinishedTranscript = keyschedule.getTranscript();
+ await this.conn._transition(states_CLIENT_WAIT_FINISHED, serverFinishedTranscript);
+ }
+}
+
+class states_CLIENT_WAIT_FINISHED extends states_State {
+ async initialize(serverFinishedTranscript) {
+ this._serverFinishedTranscript = serverFinishedTranscript;
+ }
+ async recvHandshakeMessage(msg) {
+ if (! (msg instanceof messages_Finished)) {
+ throw new TLSError(ALERT_DESCRIPTION.UNEXPECTED_MESSAGE);
+ }
+ // Verify server Finished MAC.
+ const keyschedule = this.conn._keyschedule;
+ await keyschedule.verifyFinishedMAC(keyschedule.serverHandshakeTrafficSecret, msg.verifyData, this._serverFinishedTranscript);
+ // Send our own Finished message in return.
+ // This must be encrypted with the handshake traffic key,
+ // but must not appear in the transcript used to calculate the application keys.
+ const clientFinishedMAC = await keyschedule.calculateFinishedMAC(keyschedule.clientHandshakeTrafficSecret);
+ await keyschedule.finalize();
+ await this.conn._sendHandshakeMessage(new messages_Finished(clientFinishedMAC));
+ await this.conn._setSendKey(keyschedule.clientApplicationTrafficSecret);
+ await this.conn._setRecvKey(keyschedule.serverApplicationTrafficSecret);
+ await this.conn._transition(states_CLIENT_CONNECTED);
+ }
+}
+
+class states_CLIENT_CONNECTED extends states_CONNECTED {
+ async recvHandshakeMessage(msg) {
+ // A connected client must be prepared to accept NewSessionTicket
+ // messages. We never use them, but other server implementations
+ // might send them.
+ if (! (msg instanceof messages_NewSessionTicket)) {
+ throw new TLSError(ALERT_DESCRIPTION.UNEXPECTED_MESSAGE);
+ }
+ }
+}
+
+// These states implement (part of) the server state-machine from
+// https://tools.ietf.org/html/rfc8446#appendix-A.2
+//
+// Since we're only implementing a small subset of TLS1.3,
+// we only need a small subset of the handshake. It basically goes:
+//
+// * receive ClientHello
+// * send ServerHello
+// * send empty EncryptedExtensions
+// * send server Finished
+// * receive client Finished
+//
+// We include some unused states for completeness, so that it's easier
+// to check the implementation against the diagrams in the RFC.
+
+class states_SERVER_START extends states_State {
+ async recvHandshakeMessage(msg) {
+ if (! (msg instanceof messages_ClientHello)) {
+ throw new TLSError(ALERT_DESCRIPTION.UNEXPECTED_MESSAGE);
+ }
+ // In the spec, this is where we select connection parameters, and maybe
+ // tell the client to try again if we can't find a compatible set.
+ // Since we only support a fixed cipherset, the only thing to "negotiate"
+ // is whether they provided an acceptable PSK.
+ const pskExt = msg.extensions.get(EXTENSION_TYPE.PRE_SHARED_KEY);
+ const pskModesExt = msg.extensions.get(EXTENSION_TYPE.PSK_KEY_EXCHANGE_MODES);
+ if (! pskExt || ! pskModesExt) {
+ throw new TLSError(ALERT_DESCRIPTION.MISSING_EXTENSION);
+ }
+ if (pskModesExt.modes.indexOf(PSK_MODE_KE) === -1) {
+ throw new TLSError(ALERT_DESCRIPTION.HANDSHAKE_FAILURE);
+ }
+ const pskIndex = pskExt.identities.findIndex(pskId => bytesAreEqual(pskId, this.conn.pskId));
+ if (pskIndex === -1) {
+ throw new TLSError(ALERT_DESCRIPTION.UNKNOWN_PSK_IDENTITY);
+ }
+ await this.conn._keyschedule.addPSK(this.conn.psk);
+ // Validate the PSK binder.
+ const keyschedule = this.conn._keyschedule;
+ const transcript = keyschedule.getTranscript();
+ // Calculate size occupied by the PSK binders.
+ let pskBindersSize = 2; // Vector16 representation overhead.
+ for (const binder of pskExt.binders) {
+ pskBindersSize += binder.byteLength + 1; // Vector8 representation overhead.
+ }
+ await keyschedule.verifyFinishedMAC(keyschedule.extBinderKey, pskExt.binders[pskIndex], transcript.slice(0, -pskBindersSize));
+ await this.conn._transition(states_SERVER_NEGOTIATED, msg.sessionId, pskIndex);
+ }
+}
+
+class states_SERVER_NEGOTIATED extends states_MidHandshakeState {
+ async initialize(sessionId, pskIndex) {
+ await this.conn._sendHandshakeMessage(new messages_ServerHello(
+ // Server random
+ await getRandomBytes(32),
+ sessionId,
+ [
+ new extensions_SupportedVersionsExtension(null, VERSION_TLS_1_3),
+ new extensions_PreSharedKeyExtension(null, null, pskIndex),
+ ]
+ ));
+ // If the client sent a non-empty sessionId, the server *must* send a change-cipher-spec for b/w compat.
+ if (sessionId.byteLength > 0) {
+ await this.conn._sendChangeCipherSpec();
+ }
+ // We can now transition to the encrypted part of the handshake.
+ const keyschedule = this.conn._keyschedule;
+ await keyschedule.addECDHE(null);
+ await this.conn._setSendKey(keyschedule.serverHandshakeTrafficSecret);
+ await this.conn._setRecvKey(keyschedule.clientHandshakeTrafficSecret);
+ // Send an empty EncryptedExtensions message.
+ await this.conn._sendHandshakeMessage(new EncryptedExtensions([]));
+ // Send the Finished message.
+ const serverFinishedMAC = await keyschedule.calculateFinishedMAC(keyschedule.serverHandshakeTrafficSecret);
+ await this.conn._sendHandshakeMessage(new messages_Finished(serverFinishedMAC));
+ // We can now *send* using the application traffic key,
+ // but have to wait to receive the client Finished before receiving under that key.
+ // We need to remember the handshake state from before the client Finished
+ // in order to successfully verify the client Finished.
+ const clientFinishedTranscript = await keyschedule.getTranscript();
+ const clientHandshakeTrafficSecret = keyschedule.clientHandshakeTrafficSecret;
+ await keyschedule.finalize();
+ await this.conn._setSendKey(keyschedule.serverApplicationTrafficSecret);
+ await this.conn._transition(states_SERVER_WAIT_FINISHED, clientHandshakeTrafficSecret, clientFinishedTranscript);
+ }
+}
+
+class states_SERVER_WAIT_FINISHED extends states_MidHandshakeState {
+ async initialize(clientHandshakeTrafficSecret, clientFinishedTranscript) {
+ this._clientHandshakeTrafficSecret = clientHandshakeTrafficSecret;
+ this._clientFinishedTranscript = clientFinishedTranscript;
+ }
+ async recvHandshakeMessage(msg) {
+ if (! (msg instanceof messages_Finished)) {
+ throw new TLSError(ALERT_DESCRIPTION.UNEXPECTED_MESSAGE);
+ }
+ const keyschedule = this.conn._keyschedule;
+ await keyschedule.verifyFinishedMAC(this._clientHandshakeTrafficSecret, msg.verifyData, this._clientFinishedTranscript);
+ this._clientHandshakeTrafficSecret = this._clientFinishedTranscript = null;
+ await this.conn._setRecvKey(keyschedule.clientApplicationTrafficSecret);
+ await this.conn._transition(states_CONNECTED);
+ }
+}
+
+// CONCATENATED MODULE: ./src/keyschedule.js
+/* 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/. */
+
+// TLS1.3 Key Schedule.
+//
+// In this file we implement the "key schedule" from
+// https://tools.ietf.org/html/rfc8446#section-7.1, which
+// defines how to calculate various keys as the handshake
+// state progresses.
+
+
+
+
+
+
+
+// The `KeySchedule` class progresses through three stages corresponding
+// to the three phases of the TLS1.3 key schedule:
+//
+// UNINITIALIZED
+// |
+// | addPSK()
+// v
+// EARLY_SECRET
+// |
+// | addECDHE()
+// v
+// HANDSHAKE_SECRET
+// |
+// | finalize()
+// v
+// MASTER_SECRET
+//
+// It will error out if the calling code attempts to add key material
+// in the wrong order.
+
+const STAGE_UNINITIALIZED = 0;
+const STAGE_EARLY_SECRET = 1;
+const STAGE_HANDSHAKE_SECRET = 2;
+const STAGE_MASTER_SECRET = 3;
+
+class keyschedule_KeySchedule {
+ constructor() {
+ this.stage = STAGE_UNINITIALIZED;
+ // WebCrypto doesn't support a rolling hash construct, so we have to
+ // keep the entire message transcript in memory.
+ this.transcript = new utils_BufferWriter();
+ // This tracks the main secret from with other keys are derived at each stage.
+ this.secret = null;
+ // And these are all the various keys we'll derive as the handshake progresses.
+ this.extBinderKey = null;
+ this.clientHandshakeTrafficSecret = null;
+ this.serverHandshakeTrafficSecret = null;
+ this.clientApplicationTrafficSecret = null;
+ this.serverApplicationTrafficSecret = null;
+ }
+
+ async addPSK(psk) {
+ // Use the selected PSK (if any) to calculate the "early secret".
+ if (psk === null) {
+ psk = zeros(HASH_LENGTH);
+ }
+ if (this.stage !== STAGE_UNINITIALIZED) {
+ throw new TLSError(ALERT_DESCRIPTION.INTERNAL_ERROR);
+ }
+ this.stage = STAGE_EARLY_SECRET;
+ this.secret = await hkdfExtract(zeros(HASH_LENGTH), psk);
+ this.extBinderKey = await this.deriveSecret('ext binder', EMPTY);
+ this.secret = await this.deriveSecret('derived', EMPTY);
+ }
+
+ async addECDHE(ecdhe) {
+ // Mix in the ECDHE output (if any) to calculate the "handshake secret".
+ if (ecdhe === null) {
+ ecdhe = zeros(HASH_LENGTH);
+ }
+ if (this.stage !== STAGE_EARLY_SECRET) {
+ throw new TLSError(ALERT_DESCRIPTION.INTERNAL_ERROR);
+ }
+ this.stage = STAGE_HANDSHAKE_SECRET;
+ this.extBinderKey = null;
+ this.secret = await hkdfExtract(this.secret, ecdhe);
+ this.clientHandshakeTrafficSecret = await this.deriveSecret('c hs traffic');
+ this.serverHandshakeTrafficSecret = await this.deriveSecret('s hs traffic');
+ this.secret = await this.deriveSecret('derived', EMPTY);
+ }
+
+ async finalize() {
+ if (this.stage !== STAGE_HANDSHAKE_SECRET) {
+ throw new TLSError(ALERT_DESCRIPTION.INTERNAL_ERROR);
+ }
+ this.stage = STAGE_MASTER_SECRET;
+ this.clientHandshakeTrafficSecret = null;
+ this.serverHandshakeTrafficSecret = null;
+ this.secret = await hkdfExtract(this.secret, zeros(HASH_LENGTH));
+ this.clientApplicationTrafficSecret = await this.deriveSecret('c ap traffic');
+ this.serverApplicationTrafficSecret = await this.deriveSecret('s ap traffic');
+ this.secret = null;
+ }
+
+ addToTranscript(bytes) {
+ this.transcript.writeBytes(bytes);
+ }
+
+ getTranscript() {
+ return this.transcript.slice();
+ }
+
+ async deriveSecret(label, transcript = undefined) {
+ transcript = transcript || this.getTranscript();
+ return await hkdfExpandLabel(this.secret, label, await hash(transcript), HASH_LENGTH);
+ }
+
+ async calculateFinishedMAC(baseKey, transcript = undefined) {
+ transcript = transcript || this.getTranscript();
+ const finishedKey = await hkdfExpandLabel(baseKey, 'finished', EMPTY, HASH_LENGTH);
+ return await hmac(finishedKey, await hash(transcript));
+ }
+
+ async verifyFinishedMAC(baseKey, mac, transcript = undefined) {
+ transcript = transcript || this.getTranscript();
+ const finishedKey = await hkdfExpandLabel(baseKey, 'finished', EMPTY, HASH_LENGTH);
+ await verifyHmac(finishedKey, mac, await hash(transcript));
+ }
+}
+
+// CONCATENATED MODULE: ./src/recordlayer.js
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+//
+// This file implements the "record layer" for TLS1.3, as defined in
+// https://tools.ietf.org/html/rfc8446#section-5.
+//
+// The record layer is responsible for encrypting/decrypting bytes to be
+// sent over the wire, including stateful management of sequence numbers
+// for the incoming and outgoing stream.
+//
+// The main interface is the RecordLayer class, which takes a callback function
+// sending data and can be used like so:
+//
+// rl = new RecordLayer(async function send_encrypted_data(data) {
+// // application-specific sending logic here.
+// });
+//
+// // Records are sent and received in plaintext by default,
+// // until you specify the key to use.
+// await rl.setSendKey(key)
+//
+// // Send some data by specifying the record type and the bytes.
+// // Where allowed by the record type, it will be buffered until
+// // explicitly flushed, and then sent by calling the callback.
+// await rl.send(RECORD_TYPE.HANDSHAKE, <bytes for a handshake message>)
+// await rl.send(RECORD_TYPE.HANDSHAKE, <bytes for another handshake message>)
+// await rl.flush()
+//
+// // Separate keys are used for sending and receiving.
+// rl.setRecvKey(key);
+//
+// // When data is received, push it into the RecordLayer
+// // and pass a callback that will be called with a [type, bytes]
+// // pair for each message parsed from the data.
+// rl.recv(dataReceivedFromPeer, async (type, bytes) => {
+// switch (type) {
+// case RECORD_TYPE.APPLICATION_DATA:
+// // do something with application data
+// case RECORD_TYPE.HANDSHAKE:
+// // do something with a handshake message
+// default:
+// // etc...
+// }
+// });
+//
+
+
+
+
+
+
+
+/* eslint-disable sorting/sort-object-props */
+const RECORD_TYPE = {
+ CHANGE_CIPHER_SPEC: 20,
+ ALERT: 21,
+ HANDSHAKE: 22,
+ APPLICATION_DATA: 23,
+};
+/* eslint-enable sorting/sort-object-props */
+
+// Encrypting at most 2^24 records will force us to stay
+// below data limits on AES-GCM encryption key use, and also
+// means we can accurately represent the sequence number as
+// a javascript double.
+const MAX_SEQUENCE_NUMBER = Math.pow(2, 24);
+const MAX_RECORD_SIZE = Math.pow(2, 14);
+const MAX_ENCRYPTED_RECORD_SIZE = MAX_RECORD_SIZE + 256;
+const RECORD_HEADER_SIZE = 5;
+
+// These are some helper classes to manage the encryption/decryption state
+// for a particular key.
+
+class recordlayer_CipherState {
+ constructor(key, iv) {
+ this.key = key;
+ this.iv = iv;
+ this.seqnum = 0;
+ }
+
+ static async create(baseKey, mode) {
+ // Derive key and iv per https://tools.ietf.org/html/rfc8446#section-7.3
+ const key = await prepareKey(await hkdfExpandLabel(baseKey, 'key', EMPTY, KEY_LENGTH), mode);
+ const iv = await hkdfExpandLabel(baseKey, 'iv', EMPTY, IV_LENGTH);
+ return new this(key, iv);
+ }
+
+ nonce() {
+ // Ref https://tools.ietf.org/html/rfc8446#section-5.3:
+ // * left-pad the sequence number with zeros to IV_LENGTH
+ // * xor with the provided iv
+ // Our sequence numbers are always less than 2^24, so fit in a Uint32
+ // in the last 4 bytes of the nonce.
+ const nonce = this.iv.slice();
+ const dv = new DataView(nonce.buffer, nonce.byteLength - 4, 4);
+ dv.setUint32(0, dv.getUint32(0) ^ this.seqnum);
+ this.seqnum += 1;
+ if (this.seqnum > MAX_SEQUENCE_NUMBER) {
+ throw new TLSError(ALERT_DESCRIPTION.INTERNAL_ERROR);
+ }
+ return nonce;
+ }
+}
+
+class recordlayer_EncryptionState extends recordlayer_CipherState {
+ static async create(key) {
+ return super.create(key, 'encrypt');
+ }
+
+ async encrypt(plaintext, additionalData) {
+ return await encrypt(this.key, this.nonce(), plaintext, additionalData);
+ }
+}
+
+class recordlayer_DecryptionState extends recordlayer_CipherState {
+ static async create(key) {
+ return super.create(key, 'decrypt');
+ }
+
+ async decrypt(ciphertext, additionalData) {
+ return await decrypt(this.key, this.nonce(), ciphertext, additionalData);
+ }
+}
+
+// The main RecordLayer class.
+
+class recordlayer_RecordLayer {
+ constructor(sendCallback) {
+ this.sendCallback = sendCallback;
+ this._sendEncryptState = null;
+ this._sendError = null;
+ this._recvDecryptState = null;
+ this._recvError = null;
+ this._pendingRecordType = 0;
+ this._pendingRecordBuf = null;
+ }
+
+ async setSendKey(key) {
+ await this.flush();
+ this._sendEncryptState = await recordlayer_EncryptionState.create(key);
+ }
+
+ async setRecvKey(key) {
+ this._recvDecryptState = await recordlayer_DecryptionState.create(key);
+ }
+
+ async setSendError(err) {
+ this._sendError = err;
+ }
+
+ async setRecvError(err) {
+ this._recvError = err;
+ }
+
+ async send(type, data) {
+ if (this._sendError !== null) {
+ throw this._sendError;
+ }
+ // Forbid sending data that doesn't fit into a single record.
+ // We do not support fragmentation over multiple records.
+ if (data.byteLength > MAX_RECORD_SIZE) {
+ throw new TLSError(ALERT_DESCRIPTION.INTERNAL_ERROR);
+ }
+ // Flush if we're switching to a different record type.
+ if (this._pendingRecordType && this._pendingRecordType !== type) {
+ await this.flush();
+ }
+ // Flush if we would overflow the max size of a record.
+ if (this._pendingRecordBuf !== null) {
+ if (this._pendingRecordBuf.tell() + data.byteLength > MAX_RECORD_SIZE) {
+ await this.flush();
+ }
+ }
+ // Start a new pending record if necessary.
+ // We reserve space at the start of the buffer for the record header,
+ // which is conveniently always a fixed size.
+ if (this._pendingRecordBuf === null) {
+ this._pendingRecordType = type;
+ this._pendingRecordBuf = new utils_BufferWriter();
+ this._pendingRecordBuf.incr(RECORD_HEADER_SIZE);
+ }
+ this._pendingRecordBuf.writeBytes(data);
+ }
+
+ async flush() {
+ // If there's nothing to flush, bail out early.
+ // Don't throw `_sendError` if we're not sending anything, because `flush()`
+ // can be called when we're trying to transition into an error state.
+ const buf = this._pendingRecordBuf;
+ let type = this._pendingRecordType;
+ if (! type) {
+ if (buf !== null) {
+ throw new TLSError(ALERT_DESCRIPTION.INTERNAL_ERROR);
+ }
+ return;
+ }
+ if (this._sendError !== null) {
+ throw this._sendError;
+ }
+ // If we're encrypting, turn the existing buffer contents into a `TLSInnerPlaintext` by
+ // appending the type. We don't do any zero-padding, although the spec allows it.
+ let inflation = 0, innerPlaintext = null;
+ if (this._sendEncryptState !== null) {
+ buf.writeUint8(type);
+ innerPlaintext = buf.slice(RECORD_HEADER_SIZE);
+ inflation = AEAD_SIZE_INFLATION;
+ type = RECORD_TYPE.APPLICATION_DATA;
+ }
+ // Write the common header for either `TLSPlaintext` or `TLSCiphertext` record.
+ const length = buf.tell() - RECORD_HEADER_SIZE + inflation;
+ buf.seek(0);
+ buf.writeUint8(type);
+ buf.writeUint16(VERSION_TLS_1_2);
+ buf.writeUint16(length);
+ // Followed by different payload depending on encryption status.
+ if (this._sendEncryptState !== null) {
+ const additionalData = buf.slice(0, RECORD_HEADER_SIZE);
+ const ciphertext = await this._sendEncryptState.encrypt(innerPlaintext, additionalData);
+ buf.writeBytes(ciphertext);
+ } else {
+ buf.incr(length);
+ }
+ this._pendingRecordBuf = null;
+ this._pendingRecordType = 0;
+ await this.sendCallback(buf.flush());
+ }
+
+ async recv(data) {
+ if (this._recvError !== null) {
+ throw this._recvError;
+ }
+ // For simplicity, we assume that the given data contains exactly one record.
+ // Peers using this library will send one record at a time over the websocket
+ // connection, and we can assume that the server-side websocket bridge will split
+ // up any traffic into individual records if we ever start interoperating with
+ // peers using a different TLS implementation.
+ // Similarly, we assume that handshake messages will not be fragmented across
+ // multiple records. This should be trivially true for the PSK-only mode used
+ // by this library, but we may want to relax it in future for interoperability
+ // with e.g. large ClientHello messages that contain lots of different options.
+ const buf = new utils_BufferReader(data);
+ // The data to read is either a TLSPlaintext or TLSCiphertext struct,
+ // depending on whether record protection has been enabled yet:
+ //
+ // struct {
+ // ContentType type;
+ // ProtocolVersion legacy_record_version;
+ // uint16 length;
+ // opaque fragment[TLSPlaintext.length];
+ // } TLSPlaintext;
+ //
+ // struct {
+ // ContentType opaque_type = application_data; /* 23 */
+ // ProtocolVersion legacy_record_version = 0x0303; /* TLS v1.2 */
+ // uint16 length;
+ // opaque encrypted_record[TLSCiphertext.length];
+ // } TLSCiphertext;
+ //
+ let type = buf.readUint8();
+ // The spec says legacy_record_version "MUST be ignored for all purposes",
+ // but we know TLS1.3 implementations will only ever emit two possible values,
+ // so it seems useful to bail out early if we receive anything else.
+ const version = buf.readUint16();
+ if (version !== VERSION_TLS_1_2) {
+ // TLS1.0 is only acceptable on initial plaintext records.
+ if (this._recvDecryptState !== null || version !== VERSION_TLS_1_0) {
+ throw new TLSError(ALERT_DESCRIPTION.DECODE_ERROR);
+ }
+ }
+ const length = buf.readUint16();
+ let plaintext;
+ if (this._recvDecryptState === null || type === RECORD_TYPE.CHANGE_CIPHER_SPEC) {
+ [type, plaintext] = await this._readPlaintextRecord(type, length, buf);
+ } else {
+ [type, plaintext] = await this._readEncryptedRecord(type, length, buf);
+ }
+ // Sanity-check that we received exactly one record.
+ if (buf.hasMoreBytes()) {
+ throw new TLSError(ALERT_DESCRIPTION.DECODE_ERROR);
+ }
+ return [type, plaintext];
+ }
+
+ // Helper to read an unencrypted `TLSPlaintext` struct
+
+ async _readPlaintextRecord(type, length, buf) {
+ if (length > MAX_RECORD_SIZE) {
+ throw new TLSError(ALERT_DESCRIPTION.RECORD_OVERFLOW);
+ }
+ return [type, buf.readBytes(length)];
+ }
+
+ // Helper to read an encrypted `TLSCiphertext` struct,
+ // decrypting it into plaintext.
+
+ async _readEncryptedRecord(type, length, buf) {
+ if (length > MAX_ENCRYPTED_RECORD_SIZE) {
+ throw new TLSError(ALERT_DESCRIPTION.RECORD_OVERFLOW);
+ }
+ // The outer type for encrypted records is always APPLICATION_DATA.
+ if (type !== RECORD_TYPE.APPLICATION_DATA) {
+ throw new TLSError(ALERT_DESCRIPTION.DECODE_ERROR);
+ }
+ // Decrypt and decode the contained `TLSInnerPlaintext` struct:
+ //
+ // struct {
+ // opaque content[TLSPlaintext.length];
+ // ContentType type;
+ // uint8 zeros[length_of_padding];
+ // } TLSInnerPlaintext;
+ //
+ // The additional data for the decryption is the `TLSCiphertext` record
+ // header, which is a fixed size and immediately prior to current buffer position.
+ buf.incr(-RECORD_HEADER_SIZE);
+ const additionalData = buf.readBytes(RECORD_HEADER_SIZE);
+ const ciphertext = buf.readBytes(length);
+ const paddedPlaintext = await this._recvDecryptState.decrypt(ciphertext, additionalData);
+ // We have to scan backwards over the zero padding at the end of the struct
+ // in order to find the non-zero `type` byte.
+ let i;
+ for (i = paddedPlaintext.byteLength - 1; i >= 0; i--) {
+ if (paddedPlaintext[i] !== 0) {
+ break;
+ }
+ }
+ if (i < 0) {
+ throw new TLSError(ALERT_DESCRIPTION.UNEXPECTED_MESSAGE);
+ }
+ type = paddedPlaintext[i];
+ // `change_cipher_spec` records must always be plaintext.
+ if (type === RECORD_TYPE.CHANGE_CIPHER_SPEC) {
+ throw new TLSError(ALERT_DESCRIPTION.DECODE_ERROR);
+ }
+ return [type, paddedPlaintext.slice(0, i)];
+ }
+}
+
+// CONCATENATED MODULE: ./src/tlsconnection.js
+/* 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/. */
+
+// The top-level APIs offered by this module are `ClientConnection` and
+// `ServerConnection` classes, which provide authenticated and encrypted
+// communication via the "externally-provisioned PSK" mode of TLS1.3.
+// They each take a callback to be used for sending data to the remote peer,
+// and operate like this:
+//
+// conn = await ClientConnection.create(psk, pskId, async function send_data_to_server(data) {
+// // application-specific sending logic here.
+// })
+//
+// // Send data to the server by calling `send`,
+// // which will use the callback provided in the constructor.
+// // A single `send()` by the application may result in multiple
+// // invokations of the callback.
+//
+// await conn.send('application-level data')
+//
+// // When data is received from the server, push it into
+// // the connection and let it return any decrypted app-level data.
+// // There might not be any app-level data if it was a protocol control
+// // message, and the receipt of the data might trigger additional calls
+// // to the send callback for protocol control purposes.
+//
+// serverSocket.on('data', async encrypted_data => {
+// const plaintext = await conn.recv(data)
+// if (plaintext !== null) {
+// do_something_with_app_level_data(plaintext)
+// }
+// })
+//
+// // It's good practice to explicitly close the connection
+// // when finished. This will send a "closed" notification
+// // to the server.
+//
+// await conn.close()
+//
+// // When the peer sends a "closed" notification it will show up
+// // as a `TLSCloseNotify` exception from recv:
+//
+// try {
+// data = await conn.recv(data);
+// } catch (err) {
+// if (! (err instanceof TLSCloseNotify) { throw err }
+// do_something_to_cleanly_close_data_connection();
+// }
+//
+// The `ServerConnection` API operates similarly; the distinction is mainly
+// in which side is expected to send vs receieve during the protocol handshake.
+
+
+
+
+
+
+
+
+
+
+class tlsconnection_Connection {
+ constructor(psk, pskId, sendCallback) {
+ this.psk = assertIsBytes(psk);
+ this.pskId = assertIsBytes(pskId);
+ this.connected = new Promise((resolve, reject) => {
+ this._onConnectionSuccess = resolve;
+ this._onConnectionFailure = reject;
+ });
+ this._state = new UNINITIALIZED(this);
+ this._handshakeRecvBuffer = null;
+ this._hasSeenChangeCipherSpec = false;
+ this._recordlayer = new recordlayer_RecordLayer(sendCallback);
+ this._keyschedule = new keyschedule_KeySchedule();
+ this._lastPromise = Promise.resolve();
+ }
+
+ // Subclasses will override this with some async initialization logic.
+ static async create(psk, pskId, sendCallback) {
+ return new this(psk, pskId, sendCallback);
+ }
+
+ // These are the three public API methods that consumers can use
+ // to send and receive data encrypted with TLS1.3.
+
+ async send(data) {
+ assertIsBytes(data);
+ await this.connected;
+ await this._synchronized(async () => {
+ await this._state.sendApplicationData(data);
+ });
+ }
+
+ async recv(data) {
+ assertIsBytes(data);
+ return await this._synchronized(async () => {
+ // Decrypt the data using the record layer.
+ // We expect to receive precisely one record at a time.
+ const [type, bytes] = await this._recordlayer.recv(data);
+ // Dispatch based on the type of the record.
+ switch (type) {
+ case RECORD_TYPE.CHANGE_CIPHER_SPEC:
+ await this._state.recvChangeCipherSpec(bytes);
+ return null;
+ case RECORD_TYPE.ALERT:
+ await this._state.recvAlertMessage(TLSAlert.fromBytes(bytes));
+ return null;
+ case RECORD_TYPE.APPLICATION_DATA:
+ return await this._state.recvApplicationData(bytes);
+ case RECORD_TYPE.HANDSHAKE:
+ // Multiple handshake messages may be coalesced into a single record.
+ // Store the in-progress record buffer on `this` so that we can guard
+ // against handshake messages that span a change in keys.
+ this._handshakeRecvBuffer = new utils_BufferReader(bytes);
+ if (! this._handshakeRecvBuffer.hasMoreBytes()) {
+ throw new TLSError(ALERT_DESCRIPTION.UNEXPECTED_MESSAGE);
+ }
+ do {
+ // Each handshake messages has a type and length prefix, per
+ // https://tools.ietf.org/html/rfc8446#appendix-B.3
+ this._handshakeRecvBuffer.incr(1);
+ const mlength = this._handshakeRecvBuffer.readUint24();
+ this._handshakeRecvBuffer.incr(-4);
+ const messageBytes = this._handshakeRecvBuffer.readBytes(mlength + 4);
+ this._keyschedule.addToTranscript(messageBytes);
+ await this._state.recvHandshakeMessage(messages_HandshakeMessage.fromBytes(messageBytes));
+ } while (this._handshakeRecvBuffer.hasMoreBytes());
+ this._handshakeRecvBuffer = null;
+ return null;
+ default:
+ throw new TLSError(ALERT_DESCRIPTION.UNEXPECTED_MESSAGE);
+ }
+ });
+ }
+
+ async close() {
+ await this._synchronized(async () => {
+ await this._state.close();
+ });
+ }
+
+ // Ensure that async functions execute one at a time,
+ // by waiting for the previous call to `_synchronized()` to complete
+ // before starting a new one. This helps ensure that we complete
+ // one state-machine transition before starting to do the next.
+ // It's also a convenient place to catch and alert on errors.
+
+ _synchronized(cb) {
+ const nextPromise = this._lastPromise.then(() => {
+ return cb();
+ }).catch(async err => {
+ if (err instanceof TLSCloseNotify) {
+ throw err;
+ }
+ await this._state.handleErrorAndRethrow(err);
+ });
+ // We don't want to hold on to the return value or error,
+ // just synchronize on the fact that it completed.
+ this._lastPromise = nextPromise.then(noop, noop);
+ return nextPromise;
+ }
+
+ // This drives internal transition of the state-machine,
+ // ensuring that the new state is properly initialized.
+
+ async _transition(State, ...args) {
+ this._state = new State(this);
+ await this._state.initialize(...args);
+ await this._recordlayer.flush();
+ }
+
+ // These are helpers to allow the State to manipulate the recordlayer
+ // and send out various types of data.
+
+ async _sendApplicationData(bytes) {
+ await this._recordlayer.send(RECORD_TYPE.APPLICATION_DATA, bytes);
+ await this._recordlayer.flush();
+ }
+
+ async _sendHandshakeMessage(msg) {
+ await this._sendHandshakeMessageBytes(msg.toBytes());
+ }
+
+ async _sendHandshakeMessageBytes(bytes) {
+ this._keyschedule.addToTranscript(bytes);
+ await this._recordlayer.send(RECORD_TYPE.HANDSHAKE, bytes);
+ // Don't flush after each handshake message, since we can probably
+ // coalesce multiple messages into a single record.
+ }
+
+ async _sendAlertMessage(err) {
+ await this._recordlayer.send(RECORD_TYPE.ALERT, err.toBytes());
+ await this._recordlayer.flush();
+ }
+
+ async _sendChangeCipherSpec() {
+ await this._recordlayer.send(RECORD_TYPE.CHANGE_CIPHER_SPEC, new Uint8Array([0x01]));
+ await this._recordlayer.flush();
+ }
+
+ async _setSendKey(key) {
+ return await this._recordlayer.setSendKey(key);
+ }
+
+ async _setRecvKey(key) {
+ // Handshake messages that change keys must be on a record boundary.
+ if (this._handshakeRecvBuffer && this._handshakeRecvBuffer.hasMoreBytes()) {
+ throw new TLSError(ALERT_DESCRIPTION.UNEXPECTED_MESSAGE);
+ }
+ return await this._recordlayer.setRecvKey(key);
+ }
+
+ _setConnectionSuccess() {
+ if (this._onConnectionSuccess !== null) {
+ this._onConnectionSuccess();
+ this._onConnectionSuccess = null;
+ this._onConnectionFailure = null;
+ }
+ }
+
+ _setConnectionFailure(err) {
+ if (this._onConnectionFailure !== null) {
+ this._onConnectionFailure(err);
+ this._onConnectionSuccess = null;
+ this._onConnectionFailure = null;
+ }
+ }
+
+ _closeForSend(alert) {
+ this._recordlayer.setSendError(alert);
+ }
+
+ _closeForRecv(alert) {
+ this._recordlayer.setRecvError(alert);
+ }
+}
+
+class tlsconnection_ClientConnection extends tlsconnection_Connection {
+ static async create(psk, pskId, sendCallback) {
+ const instance = await super.create(psk, pskId, sendCallback);
+ await instance._transition(states_CLIENT_START);
+ return instance;
+ }
+}
+
+class tlsconnection_ServerConnection extends tlsconnection_Connection {
+ static async create(psk, pskId, sendCallback) {
+ const instance = await super.create(psk, pskId, sendCallback);
+ await instance._transition(states_SERVER_START);
+ return instance;
+ }
+}
+
+// CONCATENATED MODULE: ./node_modules/event-target-shim/dist/event-target-shim.mjs
+/**
+ * @author Toru Nagashima <https://github.com/mysticatea>
+ * @copyright 2015 Toru Nagashima. All rights reserved.
+ * See LICENSE file in root directory for full license.
+ */
+/**
+ * @typedef {object} PrivateData
+ * @property {EventTarget} eventTarget The event target.
+ * @property {{type:string}} event The original event object.
+ * @property {number} eventPhase The current event phase.
+ * @property {EventTarget|null} currentTarget The current event target.
+ * @property {boolean} canceled The flag to prevent default.
+ * @property {boolean} stopped The flag to stop propagation.
+ * @property {boolean} immediateStopped The flag to stop propagation immediately.
+ * @property {Function|null} passiveListener The listener if the current listener is passive. Otherwise this is null.
+ * @property {number} timeStamp The unix time.
+ * @private
+ */
+
+/**
+ * Private data for event wrappers.
+ * @type {WeakMap<Event, PrivateData>}
+ * @private
+ */
+const privateData = new WeakMap();
+
+/**
+ * Cache for wrapper classes.
+ * @type {WeakMap<Object, Function>}
+ * @private
+ */
+const wrappers = new WeakMap();
+
+/**
+ * Get private data.
+ * @param {Event} event The event object to get private data.
+ * @returns {PrivateData} The private data of the event.
+ * @private
+ */
+function pd(event) {
+ const retv = privateData.get(event);
+ console.assert(
+ retv != null,
+ "'this' is expected an Event object, but got",
+ event
+ );
+ return retv
+}
+
+/**
+ * https://dom.spec.whatwg.org/#set-the-canceled-flag
+ * @param data {PrivateData} private data.
+ */
+function setCancelFlag(data) {
+ if (data.passiveListener != null) {
+ if (
+ typeof console !== "undefined" &&
+ typeof console.error === "function"
+ ) {
+ console.error(
+ "Unable to preventDefault inside passive event listener invocation.",
+ data.passiveListener
+ );
+ }
+ return
+ }
+ if (!data.event.cancelable) {
+ return
+ }
+
+ data.canceled = true;
+ if (typeof data.event.preventDefault === "function") {
+ data.event.preventDefault();
+ }
+}
+
+/**
+ * @see https://dom.spec.whatwg.org/#interface-event
+ * @private
+ */
+/**
+ * The event wrapper.
+ * @constructor
+ * @param {EventTarget} eventTarget The event target of this dispatching.
+ * @param {Event|{type:string}} event The original event to wrap.
+ */
+function Event(eventTarget, event) {
+ privateData.set(this, {
+ eventTarget,
+ event,
+ eventPhase: 2,
+ currentTarget: eventTarget,
+ canceled: false,
+ stopped: false,
+ immediateStopped: false,
+ passiveListener: null,
+ timeStamp: event.timeStamp || Date.now(),
+ });
+
+ // https://heycam.github.io/webidl/#Unforgeable
+ Object.defineProperty(this, "isTrusted", { value: false, enumerable: true });
+
+ // Define accessors
+ const keys = Object.keys(event);
+ for (let i = 0; i < keys.length; ++i) {
+ const key = keys[i];
+ if (!(key in this)) {
+ Object.defineProperty(this, key, defineRedirectDescriptor(key));
+ }
+ }
+}
+
+// Should be enumerable, but class methods are not enumerable.
+Event.prototype = {
+ /**
+ * The type of this event.
+ * @type {string}
+ */
+ get type() {
+ return pd(this).event.type
+ },
+
+ /**
+ * The target of this event.
+ * @type {EventTarget}
+ */
+ get target() {
+ return pd(this).eventTarget
+ },
+
+ /**
+ * The target of this event.
+ * @type {EventTarget}
+ */
+ get currentTarget() {
+ return pd(this).currentTarget
+ },
+
+ /**
+ * @returns {EventTarget[]} The composed path of this event.
+ */
+ composedPath() {
+ const currentTarget = pd(this).currentTarget;
+ if (currentTarget == null) {
+ return []
+ }
+ return [currentTarget]
+ },
+
+ /**
+ * Constant of NONE.
+ * @type {number}
+ */
+ get NONE() {
+ return 0
+ },
+
+ /**
+ * Constant of CAPTURING_PHASE.
+ * @type {number}
+ */
+ get CAPTURING_PHASE() {
+ return 1
+ },
+
+ /**
+ * Constant of AT_TARGET.
+ * @type {number}
+ */
+ get AT_TARGET() {
+ return 2
+ },
+
+ /**
+ * Constant of BUBBLING_PHASE.
+ * @type {number}
+ */
+ get BUBBLING_PHASE() {
+ return 3
+ },
+
+ /**
+ * The target of this event.
+ * @type {number}
+ */
+ get eventPhase() {
+ return pd(this).eventPhase
+ },
+
+ /**
+ * Stop event bubbling.
+ * @returns {void}
+ */
+ stopPropagation() {
+ const data = pd(this);
+
+ data.stopped = true;
+ if (typeof data.event.stopPropagation === "function") {
+ data.event.stopPropagation();
+ }
+ },
+
+ /**
+ * Stop event bubbling.
+ * @returns {void}
+ */
+ stopImmediatePropagation() {
+ const data = pd(this);
+
+ data.stopped = true;
+ data.immediateStopped = true;
+ if (typeof data.event.stopImmediatePropagation === "function") {
+ data.event.stopImmediatePropagation();
+ }
+ },
+
+ /**
+ * The flag to be bubbling.
+ * @type {boolean}
+ */
+ get bubbles() {
+ return Boolean(pd(this).event.bubbles)
+ },
+
+ /**
+ * The flag to be cancelable.
+ * @type {boolean}
+ */
+ get cancelable() {
+ return Boolean(pd(this).event.cancelable)
+ },
+
+ /**
+ * Cancel this event.
+ * @returns {void}
+ */
+ preventDefault() {
+ setCancelFlag(pd(this));
+ },
+
+ /**
+ * The flag to indicate cancellation state.
+ * @type {boolean}
+ */
+ get defaultPrevented() {
+ return pd(this).canceled
+ },
+
+ /**
+ * The flag to be composed.
+ * @type {boolean}
+ */
+ get composed() {
+ return Boolean(pd(this).event.composed)
+ },
+
+ /**
+ * The unix time of this event.
+ * @type {number}
+ */
+ get timeStamp() {
+ return pd(this).timeStamp
+ },
+
+ /**
+ * The target of this event.
+ * @type {EventTarget}
+ * @deprecated
+ */
+ get srcElement() {
+ return pd(this).eventTarget
+ },
+
+ /**
+ * The flag to stop event bubbling.
+ * @type {boolean}
+ * @deprecated
+ */
+ get cancelBubble() {
+ return pd(this).stopped
+ },
+ set cancelBubble(value) {
+ if (!value) {
+ return
+ }
+ const data = pd(this);
+
+ data.stopped = true;
+ if (typeof data.event.cancelBubble === "boolean") {
+ data.event.cancelBubble = true;
+ }
+ },
+
+ /**
+ * The flag to indicate cancellation state.
+ * @type {boolean}
+ * @deprecated
+ */
+ get returnValue() {
+ return !pd(this).canceled
+ },
+ set returnValue(value) {
+ if (!value) {
+ setCancelFlag(pd(this));
+ }
+ },
+
+ /**
+ * Initialize this event object. But do nothing under event dispatching.
+ * @param {string} type The event type.
+ * @param {boolean} [bubbles=false] The flag to be possible to bubble up.
+ * @param {boolean} [cancelable=false] The flag to be possible to cancel.
+ * @deprecated
+ */
+ initEvent() {
+ // Do nothing.
+ },
+};
+
+// `constructor` is not enumerable.
+Object.defineProperty(Event.prototype, "constructor", {
+ value: Event,
+ configurable: true,
+ writable: true,
+});
+
+// Ensure `event instanceof window.Event` is `true`.
+if (typeof window !== "undefined" && typeof window.Event !== "undefined") {
+ Object.setPrototypeOf(Event.prototype, window.Event.prototype);
+
+ // Make association for wrappers.
+ wrappers.set(window.Event.prototype, Event);
+}
+
+/**
+ * Get the property descriptor to redirect a given property.
+ * @param {string} key Property name to define property descriptor.
+ * @returns {PropertyDescriptor} The property descriptor to redirect the property.
+ * @private
+ */
+function defineRedirectDescriptor(key) {
+ return {
+ get() {
+ return pd(this).event[key]
+ },
+ set(value) {
+ pd(this).event[key] = value;
+ },
+ configurable: true,
+ enumerable: true,
+ }
+}
+
+/**
+ * Get the property descriptor to call a given method property.
+ * @param {string} key Property name to define property descriptor.
+ * @returns {PropertyDescriptor} The property descriptor to call the method property.
+ * @private
+ */
+function defineCallDescriptor(key) {
+ return {
+ value() {
+ const event = pd(this).event;
+ return event[key].apply(event, arguments)
+ },
+ configurable: true,
+ enumerable: true,
+ }
+}
+
+/**
+ * Define new wrapper class.
+ * @param {Function} BaseEvent The base wrapper class.
+ * @param {Object} proto The prototype of the original event.
+ * @returns {Function} The defined wrapper class.
+ * @private
+ */
+function defineWrapper(BaseEvent, proto) {
+ const keys = Object.keys(proto);
+ if (keys.length === 0) {
+ return BaseEvent
+ }
+
+ /** CustomEvent */
+ function CustomEvent(eventTarget, event) {
+ BaseEvent.call(this, eventTarget, event);
+ }
+
+ CustomEvent.prototype = Object.create(BaseEvent.prototype, {
+ constructor: { value: CustomEvent, configurable: true, writable: true },
+ });
+
+ // Define accessors.
+ for (let i = 0; i < keys.length; ++i) {
+ const key = keys[i];
+ if (!(key in BaseEvent.prototype)) {
+ const descriptor = Object.getOwnPropertyDescriptor(proto, key);
+ const isFunc = typeof descriptor.value === "function";
+ Object.defineProperty(
+ CustomEvent.prototype,
+ key,
+ isFunc
+ ? defineCallDescriptor(key)
+ : defineRedirectDescriptor(key)
+ );
+ }
+ }
+
+ return CustomEvent
+}
+
+/**
+ * Get the wrapper class of a given prototype.
+ * @param {Object} proto The prototype of the original event to get its wrapper.
+ * @returns {Function} The wrapper class.
+ * @private
+ */
+function getWrapper(proto) {
+ if (proto == null || proto === Object.prototype) {
+ return Event
+ }
+
+ let wrapper = wrappers.get(proto);
+ if (wrapper == null) {
+ wrapper = defineWrapper(getWrapper(Object.getPrototypeOf(proto)), proto);
+ wrappers.set(proto, wrapper);
+ }
+ return wrapper
+}
+
+/**
+ * Wrap a given event to management a dispatching.
+ * @param {EventTarget} eventTarget The event target of this dispatching.
+ * @param {Object} event The event to wrap.
+ * @returns {Event} The wrapper instance.
+ * @private
+ */
+function wrapEvent(eventTarget, event) {
+ const Wrapper = getWrapper(Object.getPrototypeOf(event));
+ return new Wrapper(eventTarget, event)
+}
+
+/**
+ * Get the immediateStopped flag of a given event.
+ * @param {Event} event The event to get.
+ * @returns {boolean} The flag to stop propagation immediately.
+ * @private
+ */
+function isStopped(event) {
+ return pd(event).immediateStopped
+}
+
+/**
+ * Set the current event phase of a given event.
+ * @param {Event} event The event to set current target.
+ * @param {number} eventPhase New event phase.
+ * @returns {void}
+ * @private
+ */
+function setEventPhase(event, eventPhase) {
+ pd(event).eventPhase = eventPhase;
+}
+
+/**
+ * Set the current target of a given event.
+ * @param {Event} event The event to set current target.
+ * @param {EventTarget|null} currentTarget New current target.
+ * @returns {void}
+ * @private
+ */
+function setCurrentTarget(event, currentTarget) {
+ pd(event).currentTarget = currentTarget;
+}
+
+/**
+ * Set a passive listener of a given event.
+ * @param {Event} event The event to set current target.
+ * @param {Function|null} passiveListener New passive listener.
+ * @returns {void}
+ * @private
+ */
+function setPassiveListener(event, passiveListener) {
+ pd(event).passiveListener = passiveListener;
+}
+
+/**
+ * @typedef {object} ListenerNode
+ * @property {Function} listener
+ * @property {1|2|3} listenerType
+ * @property {boolean} passive
+ * @property {boolean} once
+ * @property {ListenerNode|null} next
+ * @private
+ */
+
+/**
+ * @type {WeakMap<object, Map<string, ListenerNode>>}
+ * @private
+ */
+const listenersMap = new WeakMap();
+
+// Listener types
+const CAPTURE = 1;
+const BUBBLE = 2;
+const ATTRIBUTE = 3;
+
+/**
+ * Check whether a given value is an object or not.
+ * @param {any} x The value to check.
+ * @returns {boolean} `true` if the value is an object.
+ */
+function isObject(x) {
+ return x !== null && typeof x === "object" //eslint-disable-line no-restricted-syntax
+}
+
+/**
+ * Get listeners.
+ * @param {EventTarget} eventTarget The event target to get.
+ * @returns {Map<string, ListenerNode>} The listeners.
+ * @private
+ */
+function getListeners(eventTarget) {
+ const listeners = listenersMap.get(eventTarget);
+ if (listeners == null) {
+ throw new TypeError(
+ "'this' is expected an EventTarget object, but got another value."
+ )
+ }
+ return listeners
+}
+
+/**
+ * Get the property descriptor for the event attribute of a given event.
+ * @param {string} eventName The event name to get property descriptor.
+ * @returns {PropertyDescriptor} The property descriptor.
+ * @private
+ */
+function defineEventAttributeDescriptor(eventName) {
+ return {
+ get() {
+ const listeners = getListeners(this);
+ let node = listeners.get(eventName);
+ while (node != null) {
+ if (node.listenerType === ATTRIBUTE) {
+ return node.listener
+ }
+ node = node.next;
+ }
+ return null
+ },
+
+ set(listener) {
+ if (typeof listener !== "function" && !isObject(listener)) {
+ listener = null; // eslint-disable-line no-param-reassign
+ }
+ const listeners = getListeners(this);
+
+ // Traverse to the tail while removing old value.
+ let prev = null;
+ let node = listeners.get(eventName);
+ while (node != null) {
+ if (node.listenerType === ATTRIBUTE) {
+ // Remove old value.
+ if (prev !== null) {
+ prev.next = node.next;
+ } else if (node.next !== null) {
+ listeners.set(eventName, node.next);
+ } else {
+ listeners.delete(eventName);
+ }
+ } else {
+ prev = node;
+ }
+
+ node = node.next;
+ }
+
+ // Add new value.
+ if (listener !== null) {
+ const newNode = {
+ listener,
+ listenerType: ATTRIBUTE,
+ passive: false,
+ once: false,
+ next: null,
+ };
+ if (prev === null) {
+ listeners.set(eventName, newNode);
+ } else {
+ prev.next = newNode;
+ }
+ }
+ },
+ configurable: true,
+ enumerable: true,
+ }
+}
+
+/**
+ * Define an event attribute (e.g. `eventTarget.onclick`).
+ * @param {Object} eventTargetPrototype The event target prototype to define an event attrbite.
+ * @param {string} eventName The event name to define.
+ * @returns {void}
+ */
+function defineEventAttribute(eventTargetPrototype, eventName) {
+ Object.defineProperty(
+ eventTargetPrototype,
+ `on${eventName}`,
+ defineEventAttributeDescriptor(eventName)
+ );
+}
+
+/**
+ * Define a custom EventTarget with event attributes.
+ * @param {string[]} eventNames Event names for event attributes.
+ * @returns {EventTarget} The custom EventTarget.
+ * @private
+ */
+function defineCustomEventTarget(eventNames) {
+ /** CustomEventTarget */
+ function CustomEventTarget() {
+ EventTarget.call(this);
+ }
+
+ CustomEventTarget.prototype = Object.create(EventTarget.prototype, {
+ constructor: {
+ value: CustomEventTarget,
+ configurable: true,
+ writable: true,
+ },
+ });
+
+ for (let i = 0; i < eventNames.length; ++i) {
+ defineEventAttribute(CustomEventTarget.prototype, eventNames[i]);
+ }
+
+ return CustomEventTarget
+}
+
+/**
+ * EventTarget.
+ *
+ * - This is constructor if no arguments.
+ * - This is a function which returns a CustomEventTarget constructor if there are arguments.
+ *
+ * For example:
+ *
+ * class A extends EventTarget {}
+ * class B extends EventTarget("message") {}
+ * class C extends EventTarget("message", "error") {}
+ * class D extends EventTarget(["message", "error"]) {}
+ */
+function EventTarget() {
+ /*eslint-disable consistent-return */
+ if (this instanceof EventTarget) {
+ listenersMap.set(this, new Map());
+ return
+ }
+ if (arguments.length === 1 && Array.isArray(arguments[0])) {
+ return defineCustomEventTarget(arguments[0])
+ }
+ if (arguments.length > 0) {
+ const types = new Array(arguments.length);
+ for (let i = 0; i < arguments.length; ++i) {
+ types[i] = arguments[i];
+ }
+ return defineCustomEventTarget(types)
+ }
+ throw new TypeError("Cannot call a class as a function")
+ /*eslint-enable consistent-return */
+}
+
+// Should be enumerable, but class methods are not enumerable.
+EventTarget.prototype = {
+ /**
+ * Add a given listener to this event target.
+ * @param {string} eventName The event name to add.
+ * @param {Function} listener The listener to add.
+ * @param {boolean|{capture?:boolean,passive?:boolean,once?:boolean}} [options] The options for this listener.
+ * @returns {void}
+ */
+ addEventListener(eventName, listener, options) {
+ if (listener == null) {
+ return
+ }
+ if (typeof listener !== "function" && !isObject(listener)) {
+ throw new TypeError("'listener' should be a function or an object.")
+ }
+
+ const listeners = getListeners(this);
+ const optionsIsObj = isObject(options);
+ const capture = optionsIsObj
+ ? Boolean(options.capture)
+ : Boolean(options);
+ const listenerType = capture ? CAPTURE : BUBBLE;
+ const newNode = {
+ listener,
+ listenerType,
+ passive: optionsIsObj && Boolean(options.passive),
+ once: optionsIsObj && Boolean(options.once),
+ next: null,
+ };
+
+ // Set it as the first node if the first node is null.
+ let node = listeners.get(eventName);
+ if (node === undefined) {
+ listeners.set(eventName, newNode);
+ return
+ }
+
+ // Traverse to the tail while checking duplication..
+ let prev = null;
+ while (node != null) {
+ if (
+ node.listener === listener &&
+ node.listenerType === listenerType
+ ) {
+ // Should ignore duplication.
+ return
+ }
+ prev = node;
+ node = node.next;
+ }
+
+ // Add it.
+ prev.next = newNode;
+ },
+
+ /**
+ * Remove a given listener from this event target.
+ * @param {string} eventName The event name to remove.
+ * @param {Function} listener The listener to remove.
+ * @param {boolean|{capture?:boolean,passive?:boolean,once?:boolean}} [options] The options for this listener.
+ * @returns {void}
+ */
+ removeEventListener(eventName, listener, options) {
+ if (listener == null) {
+ return
+ }
+
+ const listeners = getListeners(this);
+ const capture = isObject(options)
+ ? Boolean(options.capture)
+ : Boolean(options);
+ const listenerType = capture ? CAPTURE : BUBBLE;
+
+ let prev = null;
+ let node = listeners.get(eventName);
+ while (node != null) {
+ if (
+ node.listener === listener &&
+ node.listenerType === listenerType
+ ) {
+ if (prev !== null) {
+ prev.next = node.next;
+ } else if (node.next !== null) {
+ listeners.set(eventName, node.next);
+ } else {
+ listeners.delete(eventName);
+ }
+ return
+ }
+
+ prev = node;
+ node = node.next;
+ }
+ },
+
+ /**
+ * Dispatch a given event.
+ * @param {Event|{type:string}} event The event to dispatch.
+ * @returns {boolean} `false` if canceled.
+ */
+ dispatchEvent(event) {
+ if (event == null || typeof event.type !== "string") {
+ throw new TypeError('"event.type" should be a string.')
+ }
+
+ // If listeners aren't registered, terminate.
+ const listeners = getListeners(this);
+ const eventName = event.type;
+ let node = listeners.get(eventName);
+ if (node == null) {
+ return true
+ }
+
+ // Since we cannot rewrite several properties, so wrap object.
+ const wrappedEvent = wrapEvent(this, event);
+
+ // This doesn't process capturing phase and bubbling phase.
+ // This isn't participating in a tree.
+ let prev = null;
+ while (node != null) {
+ // Remove this listener if it's once
+ if (node.once) {
+ if (prev !== null) {
+ prev.next = node.next;
+ } else if (node.next !== null) {
+ listeners.set(eventName, node.next);
+ } else {
+ listeners.delete(eventName);
+ }
+ } else {
+ prev = node;
+ }
+
+ // Call this listener
+ setPassiveListener(
+ wrappedEvent,
+ node.passive ? node.listener : null
+ );
+ if (typeof node.listener === "function") {
+ try {
+ node.listener.call(this, wrappedEvent);
+ } catch (err) {
+ if (
+ typeof console !== "undefined" &&
+ typeof console.error === "function"
+ ) {
+ console.error(err);
+ }
+ }
+ } else if (
+ node.listenerType !== ATTRIBUTE &&
+ typeof node.listener.handleEvent === "function"
+ ) {
+ node.listener.handleEvent(wrappedEvent);
+ }
+
+ // Break if `event.stopImmediatePropagation` was called.
+ if (isStopped(wrappedEvent)) {
+ break
+ }
+
+ node = node.next;
+ }
+ setPassiveListener(wrappedEvent, null);
+ setEventPhase(wrappedEvent, 0);
+ setCurrentTarget(wrappedEvent, null);
+
+ return !wrappedEvent.defaultPrevented
+ },
+};
+
+// `constructor` is not enumerable.
+Object.defineProperty(EventTarget.prototype, "constructor", {
+ value: EventTarget,
+ configurable: true,
+ writable: true,
+});
+
+// Ensure `eventTarget instanceof window.EventTarget` is `true`.
+if (
+ typeof window !== "undefined" &&
+ typeof window.EventTarget !== "undefined"
+) {
+ Object.setPrototypeOf(EventTarget.prototype, window.EventTarget.prototype);
+}
+
+/* harmony default export */ var event_target_shim = (EventTarget);
+
+
+// CONCATENATED MODULE: ./src/index.js
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+// A wrapper that combines a WebSocket to the channelserver
+// with some client-side encryption for securing the channel.
+//
+// This code is responsible for the event handling and the consumer API.
+// All the details of encrypting the messages are delegated to`./tlsconnection.js`.
+
+
+
+
+
+
+
+const CLOSE_FLUSH_BUFFER_INTERVAL_MS = 200;
+const CLOSE_FLUSH_BUFFER_MAX_TRIES = 5;
+
+class src_PairingChannel extends EventTarget {
+ constructor(channelId, channelKey, socket, connection) {
+ super();
+ this._channelId = channelId;
+ this._channelKey = channelKey;
+ this._socket = socket;
+ this._connection = connection;
+ this._selfClosed = false;
+ this._peerClosed = false;
+ this._setupListeners();
+ }
+
+ /**
+ * Create a new pairing channel.
+ *
+ * This will open a channel on the channelserver, and generate a random client-side
+ * encryption key. When the promise resolves, `this.channelId` and `this.channelKey`
+ * can be transferred to another client to allow it to securely connect to the channel.
+ *
+ * @returns Promise<PairingChannel>
+ */
+ static create(channelServerURI) {
+ const wsURI = new URL('/v1/ws/', channelServerURI).href;
+ const channelKey = crypto.getRandomValues(new Uint8Array(32));
+ // The one who creates the channel plays the role of 'server' in the underlying TLS exchange.
+ return this._makePairingChannel(wsURI, tlsconnection_ServerConnection, channelKey);
+ }
+
+ /**
+ * Connect to an existing pairing channel.
+ *
+ * This will connect to a channel on the channelserver previously established by
+ * another client calling `create`. The `channelId` and `channelKey` must have been
+ * obtained via some out-of-band mechanism (such as by scanning from a QR code).
+ *
+ * @returns Promise<PairingChannel>
+ */
+ static connect(channelServerURI, channelId, channelKey) {
+ const wsURI = new URL(`/v1/ws/${channelId}`, channelServerURI).href;
+ // The one who connects to an existing channel plays the role of 'client'
+ // in the underlying TLS exchange.
+ return this._makePairingChannel(wsURI, tlsconnection_ClientConnection, channelKey);
+ }
+
+ static _makePairingChannel(wsUri, ConnectionClass, psk) {
+ const socket = new WebSocket(wsUri);
+ return new Promise((resolve, reject) => {
+ // eslint-disable-next-line prefer-const
+ let stopListening;
+ const onConnectionError = async () => {
+ stopListening();
+ reject(new Error('Error while creating the pairing channel'));
+ };
+ const onFirstMessage = async event => {
+ stopListening();
+ try {
+ // The channelserver echos back the channel id, and we use it as an
+ // additional input to the TLS handshake via the "psk id" field.
+ const {channelid: channelId} = JSON.parse(event.data);
+ const pskId = utf8ToBytes(channelId);
+ const connection = await ConnectionClass.create(psk, pskId, data => {
+ // Send data by forwarding it via the channelserver websocket.
+ // The TLS connection gives us `data` as raw bytes, but channelserver
+ // expects b64urlsafe strings, because it wraps them in a JSON object envelope.
+ socket.send(bytesToBase64url(data));
+ });
+ const instance = new this(channelId, psk, socket, connection);
+ resolve(instance);
+ } catch (err) {
+ reject(err);
+ }
+ };
+ stopListening = () => {
+ socket.removeEventListener('close', onConnectionError);
+ socket.removeEventListener('error', onConnectionError);
+ socket.removeEventListener('message', onFirstMessage);
+ };
+ socket.addEventListener('close', onConnectionError);
+ socket.addEventListener('error', onConnectionError);
+ socket.addEventListener('message', onFirstMessage);
+ });
+ }
+
+ _setupListeners() {
+ this._socket.addEventListener('message', async event => {
+ try {
+ // When we receive data from the channelserver, pump it through the TLS connection
+ // to decrypt it, then echo it back out to consumers as an event.
+ const channelServerEnvelope = JSON.parse(event.data);
+ const payload = await this._connection.recv(base64urlToBytes(channelServerEnvelope.message));
+ if (payload !== null) {
+ const data = JSON.parse(bytesToUtf8(payload));
+ this.dispatchEvent(new CustomEvent('message', {
+ detail: {
+ data,
+ sender: channelServerEnvelope.sender,
+ },
+ }));
+ }
+ } catch (error) {
+ let event;
+ // The underlying TLS connection will signal a clean shutdown of the channel
+ // by throwing a special error, because it doesn't really have a better
+ // signally mechanism available.
+ if (error instanceof TLSCloseNotify) {
+ this._peerClosed = true;
+ if (this._selfClosed) {
+ this._shutdown();
+ }
+ event = new CustomEvent('close');
+ } else {
+ event = new CustomEvent('error', {
+ detail: {
+ error,
+ }
+ });
+ }
+ this.dispatchEvent(event);
+ }
+ });
+ // Relay the WebSocket events.
+ this._socket.addEventListener('error', () => {
+ this._shutdown();
+ // The dispatched event that we receive has no useful information.
+ this.dispatchEvent(new CustomEvent('error', {
+ detail: {
+ error: new Error('WebSocket error.'),
+ },
+ }));
+ });
+ // In TLS, the peer has to explicitly send a close notification,
+ // which we dispatch above. Unexpected socket close is an error.
+ this._socket.addEventListener('close', () => {
+ this._shutdown();
+ if (! this._peerClosed) {
+ this.dispatchEvent(new CustomEvent('error', {
+ detail: {
+ error: new Error('WebSocket unexpectedly closed'),
+ }
+ }));
+ }
+ });
+ }
+
+ /**
+ * @param {Object} data
+ */
+ async send(data) {
+ const payload = utf8ToBytes(JSON.stringify(data));
+ await this._connection.send(payload);
+ }
+
+ async close() {
+ this._selfClosed = true;
+ await this._connection.close();
+ try {
+ // Ensure all queued bytes have been sent before closing the connection.
+ let tries = 0;
+ while (this._socket.bufferedAmount > 0) {
+ if (++tries > CLOSE_FLUSH_BUFFER_MAX_TRIES) {
+ throw new Error('Could not flush the outgoing buffer in time.');
+ }
+ await new Promise(res => setTimeout(res, CLOSE_FLUSH_BUFFER_INTERVAL_MS));
+ }
+ } finally {
+ // If the peer hasn't closed, we might still receive some data.
+ if (this._peerClosed) {
+ this._shutdown();
+ }
+ }
+ }
+
+ _shutdown() {
+ if (this._socket) {
+ this._socket.close();
+ this._socket = null;
+ this._connection = null;
+ }
+ }
+
+ get closed() {
+ return (! this._socket) || (this._socket.readyState === 3);
+ }
+
+ get channelId() {
+ return this._channelId;
+ }
+
+ get channelKey() {
+ return this._channelKey;
+ }
+}
+
+// Re-export helpful utilities for calling code to use.
+
+
+// For running tests using the built bundle,
+// expose a bunch of implementation details.
+
+
+
+
+
+
+
+const _internals = {
+ arrayToBytes: arrayToBytes,
+ BufferReader: utils_BufferReader,
+ BufferWriter: utils_BufferWriter,
+ bytesAreEqual: bytesAreEqual,
+ bytesToHex: bytesToHex,
+ bytesToUtf8: bytesToUtf8,
+ ClientConnection: tlsconnection_ClientConnection,
+ Connection: tlsconnection_Connection,
+ DecryptionState: recordlayer_DecryptionState,
+ EncryptedExtensions: EncryptedExtensions,
+ EncryptionState: recordlayer_EncryptionState,
+ Finished: messages_Finished,
+ HASH_LENGTH: HASH_LENGTH,
+ hexToBytes: hexToBytes,
+ hkdfExpand: hkdfExpand,
+ KeySchedule: keyschedule_KeySchedule,
+ NewSessionTicket: messages_NewSessionTicket,
+ RecordLayer: recordlayer_RecordLayer,
+ ServerConnection: tlsconnection_ServerConnection,
+ utf8ToBytes: utf8ToBytes,
+ zeros: zeros,
+};
+
+
+/***/ })
+/******/ ])["PairingChannel"];
diff --git a/services/fxaccounts/FxAccountsProfile.sys.mjs b/services/fxaccounts/FxAccountsProfile.sys.mjs
new file mode 100644
index 0000000000..de8bdb2f0e
--- /dev/null
+++ b/services/fxaccounts/FxAccountsProfile.sys.mjs
@@ -0,0 +1,193 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * Firefox Accounts Profile helper.
+ *
+ * This class abstracts interaction with the profile server for an account.
+ * It will handle things like fetching profile data, listening for updates to
+ * the user's profile in open browser tabs, and cacheing/invalidating profile data.
+ */
+
+import {
+ ON_PROFILE_CHANGE_NOTIFICATION,
+ log,
+} from "resource://gre/modules/FxAccountsCommon.sys.mjs";
+
+import { getFxAccountsSingleton } from "resource://gre/modules/FxAccounts.sys.mjs";
+
+const fxAccounts = getFxAccountsSingleton();
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ FxAccountsProfileClient:
+ "resource://gre/modules/FxAccountsProfileClient.sys.mjs",
+});
+
+export var FxAccountsProfile = function (options = {}) {
+ this._currentFetchPromise = null;
+ this._cachedAt = 0; // when we saved the cached version.
+ this._isNotifying = false; // are we sending a notification?
+ this.fxai = options.fxai || fxAccounts._internal;
+ this.client =
+ options.profileClient ||
+ new lazy.FxAccountsProfileClient({
+ fxai: this.fxai,
+ serverURL: options.profileServerUrl,
+ });
+
+ // An observer to invalidate our _cachedAt optimization. We use a weak-ref
+ // just incase this.tearDown isn't called in some cases.
+ Services.obs.addObserver(this, ON_PROFILE_CHANGE_NOTIFICATION, true);
+ // for testing
+ if (options.channel) {
+ this.channel = options.channel;
+ }
+};
+
+FxAccountsProfile.prototype = {
+ // If we get subsequent requests for a profile within this period, don't bother
+ // making another request to determine if it is fresh or not.
+ PROFILE_FRESHNESS_THRESHOLD: 120000, // 2 minutes
+
+ observe(subject, topic, data) {
+ // If we get a profile change notification from our webchannel it means
+ // the user has just changed their profile via the web, so we want to
+ // ignore our "freshness threshold"
+ if (topic == ON_PROFILE_CHANGE_NOTIFICATION && !this._isNotifying) {
+ log.debug("FxAccountsProfile observed profile change");
+ this._cachedAt = 0;
+ }
+ },
+
+ tearDown() {
+ this.fxai = null;
+ this.client = null;
+ Services.obs.removeObserver(this, ON_PROFILE_CHANGE_NOTIFICATION);
+ },
+
+ _notifyProfileChange(uid) {
+ this._isNotifying = true;
+ Services.obs.notifyObservers(null, ON_PROFILE_CHANGE_NOTIFICATION, uid);
+ this._isNotifying = false;
+ },
+
+ // Cache fetched data and send out a notification so that UI can update.
+ _cacheProfile(response) {
+ return this.fxai.withCurrentAccountState(async state => {
+ const profile = response.body;
+ const userData = await state.getUserAccountData();
+ if (profile.uid != userData.uid) {
+ throw new Error(
+ "The fetched profile does not correspond with the current account."
+ );
+ }
+ let profileCache = {
+ profile,
+ etag: response.etag,
+ };
+ await state.updateUserAccountData({ profileCache });
+ if (profile.email != userData.email) {
+ await this.fxai._handleEmailUpdated(profile.email);
+ }
+ log.debug("notifying profile changed for user ${uid}", userData);
+ this._notifyProfileChange(userData.uid);
+ return profile;
+ });
+ },
+
+ async _getProfileCache() {
+ let data = await this.fxai.currentAccountState.getUserAccountData([
+ "profileCache",
+ ]);
+ return data ? data.profileCache : null;
+ },
+
+ async _fetchAndCacheProfileInternal() {
+ try {
+ const profileCache = await this._getProfileCache();
+ const etag = profileCache ? profileCache.etag : null;
+ let response;
+ try {
+ response = await this.client.fetchProfile(etag);
+ } catch (err) {
+ await this.fxai._handleTokenError(err);
+ // _handleTokenError always re-throws.
+ throw new Error("not reached!");
+ }
+
+ // response may be null if the profile was not modified (same ETag).
+ if (!response) {
+ return null;
+ }
+ return await this._cacheProfile(response);
+ } finally {
+ this._cachedAt = Date.now();
+ this._currentFetchPromise = null;
+ }
+ },
+
+ _fetchAndCacheProfile() {
+ if (!this._currentFetchPromise) {
+ this._currentFetchPromise = this._fetchAndCacheProfileInternal();
+ }
+ return this._currentFetchPromise;
+ },
+
+ // Returns cached data right away if available, otherwise returns null - if
+ // it returns null, or if the profile is possibly stale, it attempts to
+ // fetch the latest profile data in the background. After data is fetched a
+ // notification will be sent out if the profile has changed.
+ async getProfile() {
+ const profileCache = await this._getProfileCache();
+ if (!profileCache) {
+ // fetch and cache it in the background.
+ this._fetchAndCacheProfile().catch(err => {
+ log.error("Background refresh of initial profile failed", err);
+ });
+ return null;
+ }
+ if (Date.now() > this._cachedAt + this.PROFILE_FRESHNESS_THRESHOLD) {
+ // Note that _fetchAndCacheProfile isn't returned, so continues
+ // in the background.
+ this._fetchAndCacheProfile().catch(err => {
+ log.error("Background refresh of profile failed", err);
+ });
+ } else {
+ log.trace("not checking freshness of profile as it remains recent");
+ }
+ return profileCache.profile;
+ },
+
+ // Get the user's profile data, fetching from the network if necessary.
+ // Most callers should instead use `getProfile()`; this methods exists to support
+ // callers who need to await the underlying network request.
+ async ensureProfile({ staleOk = false, forceFresh = false } = {}) {
+ if (staleOk && forceFresh) {
+ throw new Error("contradictory options specified");
+ }
+ const profileCache = await this._getProfileCache();
+ if (
+ forceFresh ||
+ !profileCache ||
+ (Date.now() > this._cachedAt + this.PROFILE_FRESHNESS_THRESHOLD &&
+ !staleOk)
+ ) {
+ const profile = await this._fetchAndCacheProfile().catch(err => {
+ log.error("Background refresh of profile failed", err);
+ });
+ if (profile) {
+ return profile;
+ }
+ }
+ log.trace("not checking freshness of profile as it remains recent");
+ return profileCache ? profileCache.profile : null;
+ },
+
+ QueryInterface: ChromeUtils.generateQI([
+ "nsIObserver",
+ "nsISupportsWeakReference",
+ ]),
+};
diff --git a/services/fxaccounts/FxAccountsProfileClient.sys.mjs b/services/fxaccounts/FxAccountsProfileClient.sys.mjs
new file mode 100644
index 0000000000..7ae1bd95db
--- /dev/null
+++ b/services/fxaccounts/FxAccountsProfileClient.sys.mjs
@@ -0,0 +1,273 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * A client to fetch profile information for a Firefox Account.
+ */
+"use strict;";
+
+import {
+ ERRNO_NETWORK,
+ ERRNO_PARSE,
+ ERRNO_UNKNOWN_ERROR,
+ ERROR_CODE_METHOD_NOT_ALLOWED,
+ ERROR_MSG_METHOD_NOT_ALLOWED,
+ ERROR_NETWORK,
+ ERROR_PARSE,
+ ERROR_UNKNOWN,
+ log,
+ SCOPE_PROFILE,
+ SCOPE_PROFILE_WRITE,
+} from "resource://gre/modules/FxAccountsCommon.sys.mjs";
+
+import { getFxAccountsSingleton } from "resource://gre/modules/FxAccounts.sys.mjs";
+
+const fxAccounts = getFxAccountsSingleton();
+import { RESTRequest } from "resource://services-common/rest.sys.mjs";
+
+/**
+ * Create a new FxAccountsProfileClient to be able to fetch Firefox Account profile information.
+ *
+ * @param {Object} options Options
+ * @param {String} options.serverURL
+ * The URL of the profile server to query.
+ * Example: https://profile.accounts.firefox.com/v1
+ * @param {String} options.token
+ * The bearer token to access the profile server
+ * @constructor
+ */
+export var FxAccountsProfileClient = function (options) {
+ if (!options || !options.serverURL) {
+ throw new Error("Missing 'serverURL' configuration option");
+ }
+
+ this.fxai = options.fxai || fxAccounts._internal;
+
+ try {
+ this.serverURL = new URL(options.serverURL);
+ } catch (e) {
+ throw new Error("Invalid 'serverURL'");
+ }
+ log.debug("FxAccountsProfileClient: Initialized");
+};
+
+FxAccountsProfileClient.prototype = {
+ /**
+ * {nsIURI}
+ * The server to fetch profile information from.
+ */
+ serverURL: null,
+
+ /**
+ * Interface for making remote requests.
+ */
+ _Request: RESTRequest,
+
+ /**
+ * Remote request helper which abstracts authentication away.
+ *
+ * @param {String} path
+ * Profile server path, i.e "/profile".
+ * @param {String} [method]
+ * Type of request, e.g. "GET".
+ * @param {String} [etag]
+ * Optional ETag used for caching purposes.
+ * @param {Object} [body]
+ * Optional request body, to be sent as application/json.
+ * @return Promise
+ * Resolves: {body: Object, etag: Object} Successful response from the Profile server.
+ * Rejects: {FxAccountsProfileClientError} Profile client error.
+ * @private
+ */
+ async _createRequest(path, method = "GET", etag = null, body = null) {
+ method = method.toUpperCase();
+ let token = await this._getTokenForRequest(method);
+ try {
+ return await this._rawRequest(path, method, token, etag, body);
+ } catch (ex) {
+ if (!(ex instanceof FxAccountsProfileClientError) || ex.code != 401) {
+ throw ex;
+ }
+ // it's an auth error - assume our token expired and retry.
+ log.info(
+ "Fetching the profile returned a 401 - revoking our token and retrying"
+ );
+ await this.fxai.removeCachedOAuthToken({ token });
+ token = await this._getTokenForRequest(method);
+ // and try with the new token - if that also fails then we fail after
+ // revoking the token.
+ try {
+ return await this._rawRequest(path, method, token, etag, body);
+ } catch (ex) {
+ if (!(ex instanceof FxAccountsProfileClientError) || ex.code != 401) {
+ throw ex;
+ }
+ log.info(
+ "Retry fetching the profile still returned a 401 - revoking our token and failing"
+ );
+ await this.fxai.removeCachedOAuthToken({ token });
+ throw ex;
+ }
+ }
+ },
+
+ /**
+ * Helper to get an OAuth token for a request.
+ *
+ * OAuth tokens are cached, so it's fine to call this for each request.
+ *
+ * @param {String} [method]
+ * Type of request, i.e "GET".
+ * @return Promise
+ * Resolves: Object containing "scope", "token" and "key" properties
+ * Rejects: {FxAccountsProfileClientError} Profile client error.
+ * @private
+ */
+ async _getTokenForRequest(method) {
+ let scope = SCOPE_PROFILE;
+ if (method === "POST") {
+ scope = SCOPE_PROFILE_WRITE;
+ }
+ return this.fxai.getOAuthToken({ scope });
+ },
+
+ /**
+ * Remote "raw" request helper - doesn't handle auth errors and tokens.
+ *
+ * @param {String} path
+ * Profile server path, i.e "/profile".
+ * @param {String} method
+ * Type of request, i.e "GET".
+ * @param {String} token
+ * @param {String} etag
+ * @param {Object} payload
+ * The payload of the request, if any.
+ * @return Promise
+ * Resolves: {body: Object, etag: Object} Successful response from the Profile server
+ or null if 304 is hit (same ETag).
+ * Rejects: {FxAccountsProfileClientError} Profile client error.
+ * @private
+ */
+ async _rawRequest(path, method, token, etag = null, payload = null) {
+ let profileDataUrl = this.serverURL + path;
+ let request = new this._Request(profileDataUrl);
+
+ request.setHeader("Authorization", "Bearer " + token);
+ request.setHeader("Accept", "application/json");
+ if (etag) {
+ request.setHeader("If-None-Match", etag);
+ }
+
+ if (method != "GET" && method != "POST") {
+ // method not supported
+ throw new FxAccountsProfileClientError({
+ error: ERROR_NETWORK,
+ errno: ERRNO_NETWORK,
+ code: ERROR_CODE_METHOD_NOT_ALLOWED,
+ message: ERROR_MSG_METHOD_NOT_ALLOWED,
+ });
+ }
+ try {
+ await request.dispatch(method, payload);
+ } catch (error) {
+ throw new FxAccountsProfileClientError({
+ error: ERROR_NETWORK,
+ errno: ERRNO_NETWORK,
+ message: error.toString(),
+ });
+ }
+
+ let body = null;
+ try {
+ if (request.response.status == 304) {
+ return null;
+ }
+ body = JSON.parse(request.response.body);
+ } catch (e) {
+ throw new FxAccountsProfileClientError({
+ error: ERROR_PARSE,
+ errno: ERRNO_PARSE,
+ code: request.response.status,
+ message: request.response.body,
+ });
+ }
+
+ // "response.success" means status code is 200
+ if (!request.response.success) {
+ throw new FxAccountsProfileClientError({
+ error: body.error || ERROR_UNKNOWN,
+ errno: body.errno || ERRNO_UNKNOWN_ERROR,
+ code: request.response.status,
+ message: body.message || body,
+ });
+ }
+ return {
+ body,
+ etag: request.response.headers.etag,
+ };
+ },
+
+ /**
+ * Retrieve user's profile from the server
+ *
+ * @param {String} [etag]
+ * Optional ETag used for caching purposes. (may generate a 304 exception)
+ * @return Promise
+ * Resolves: {body: Object, etag: Object} Successful response from the '/profile' endpoint.
+ * Rejects: {FxAccountsProfileClientError} profile client error.
+ */
+ fetchProfile(etag) {
+ log.debug("FxAccountsProfileClient: Requested profile");
+ return this._createRequest("/profile", "GET", etag);
+ },
+};
+
+/**
+ * Normalized profile client errors
+ * @param {Object} [details]
+ * Error details object
+ * @param {number} [details.code]
+ * Error code
+ * @param {number} [details.errno]
+ * Error number
+ * @param {String} [details.error]
+ * Error description
+ * @param {String|null} [details.message]
+ * Error message
+ * @constructor
+ */
+export var FxAccountsProfileClientError = function (details) {
+ details = details || {};
+
+ this.name = "FxAccountsProfileClientError";
+ this.code = details.code || null;
+ this.errno = details.errno || ERRNO_UNKNOWN_ERROR;
+ this.error = details.error || ERROR_UNKNOWN;
+ this.message = details.message || null;
+};
+
+/**
+ * Returns error object properties
+ *
+ * @returns {{name: *, code: *, errno: *, error: *, message: *}}
+ * @private
+ */
+FxAccountsProfileClientError.prototype._toStringFields = function () {
+ return {
+ name: this.name,
+ code: this.code,
+ errno: this.errno,
+ error: this.error,
+ message: this.message,
+ };
+};
+
+/**
+ * String representation of a profile client error
+ *
+ * @returns {String}
+ */
+FxAccountsProfileClientError.prototype.toString = function () {
+ return this.name + "(" + JSON.stringify(this._toStringFields()) + ")";
+};
diff --git a/services/fxaccounts/FxAccountsPush.sys.mjs b/services/fxaccounts/FxAccountsPush.sys.mjs
new file mode 100644
index 0000000000..e3e5f32de5
--- /dev/null
+++ b/services/fxaccounts/FxAccountsPush.sys.mjs
@@ -0,0 +1,315 @@
+/* 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 { Async } from "resource://services-common/async.sys.mjs";
+
+import {
+ FXA_PUSH_SCOPE_ACCOUNT_UPDATE,
+ ONLOGOUT_NOTIFICATION,
+ ON_ACCOUNT_DESTROYED_NOTIFICATION,
+ ON_COLLECTION_CHANGED_NOTIFICATION,
+ ON_COMMAND_RECEIVED_NOTIFICATION,
+ ON_DEVICE_CONNECTED_NOTIFICATION,
+ ON_DEVICE_DISCONNECTED_NOTIFICATION,
+ ON_PASSWORD_CHANGED_NOTIFICATION,
+ ON_PASSWORD_RESET_NOTIFICATION,
+ ON_PROFILE_CHANGE_NOTIFICATION,
+ ON_PROFILE_UPDATED_NOTIFICATION,
+ ON_VERIFY_LOGIN_NOTIFICATION,
+ log,
+} from "resource://gre/modules/FxAccountsCommon.sys.mjs";
+
+/**
+ * FxAccountsPushService manages Push notifications for Firefox Accounts in the browser
+ *
+ * @param [options]
+ * Object, custom options that used for testing
+ * @constructor
+ */
+export function FxAccountsPushService(options = {}) {
+ this.log = log;
+
+ if (options.log) {
+ // allow custom log for testing purposes
+ this.log = options.log;
+ }
+
+ this.log.debug("FxAccountsPush loading service");
+ this.wrappedJSObject = this;
+ this.initialize(options);
+}
+
+FxAccountsPushService.prototype = {
+ /**
+ * Helps only initialize observers once.
+ */
+ _initialized: false,
+ /**
+ * Instance of the nsIPushService or a mocked object.
+ */
+ pushService: null,
+ /**
+ * Instance of FxAccountsInternal or a mocked object.
+ */
+ fxai: null,
+ /**
+ * Component ID of this service, helps register this component.
+ */
+ classID: Components.ID("{1b7db999-2ecd-4abf-bb95-a726896798ca}"),
+ /**
+ * Register used interfaces in this service
+ */
+ QueryInterface: ChromeUtils.generateQI(["nsIObserver"]),
+ /**
+ * Initialize the service and register all the required observers.
+ *
+ * @param [options]
+ */
+ initialize(options) {
+ if (this._initialized) {
+ return false;
+ }
+
+ this._initialized = true;
+
+ if (options.pushService) {
+ this.pushService = options.pushService;
+ } else {
+ this.pushService = Cc["@mozilla.org/push/Service;1"].getService(
+ Ci.nsIPushService
+ );
+ }
+
+ if (options.fxai) {
+ this.fxai = options.fxai;
+ } else {
+ const { getFxAccountsSingleton } = ChromeUtils.importESModule(
+ "resource://gre/modules/FxAccounts.sys.mjs"
+ );
+ const fxAccounts = getFxAccountsSingleton();
+ this.fxai = fxAccounts._internal;
+ }
+
+ this.asyncObserver = Async.asyncObserver(this, this.log);
+ // We use an async observer because a device waking up can
+ // observe multiple "Send Tab received" push notifications at the same time.
+ // The way these notifications are handled is as follows:
+ // Read index from storage, make network request, update the index.
+ // You can imagine what happens when multiple calls race: we load
+ // the same index multiple times and receive the same exact tabs, multiple times.
+ // The async observer will ensure we make these network requests serially.
+ Services.obs.addObserver(this.asyncObserver, this.pushService.pushTopic);
+ Services.obs.addObserver(
+ this.asyncObserver,
+ this.pushService.subscriptionChangeTopic
+ );
+ Services.obs.addObserver(this.asyncObserver, ONLOGOUT_NOTIFICATION);
+
+ this.log.debug("FxAccountsPush initialized");
+ return true;
+ },
+ /**
+ * Registers a new endpoint with the Push Server
+ *
+ * @returns {Promise}
+ * Promise always resolves with a subscription or a null if failed to subscribe.
+ */
+ registerPushEndpoint() {
+ this.log.trace("FxAccountsPush registerPushEndpoint");
+
+ return new Promise(resolve => {
+ this.pushService.subscribe(
+ FXA_PUSH_SCOPE_ACCOUNT_UPDATE,
+ Services.scriptSecurityManager.getSystemPrincipal(),
+ (result, subscription) => {
+ if (Components.isSuccessCode(result)) {
+ this.log.debug("FxAccountsPush got subscription");
+ resolve(subscription);
+ } else {
+ this.log.warn("FxAccountsPush failed to subscribe", result);
+ resolve(null);
+ }
+ }
+ );
+ });
+ },
+ /**
+ * Async observer interface to listen to push messages, changes and logout.
+ *
+ * @param subject
+ * @param topic
+ * @param data
+ * @returns {Promise}
+ */
+ async observe(subject, topic, data) {
+ try {
+ this.log.trace(
+ `observed topic=${topic}, data=${data}, subject=${subject}`
+ );
+ switch (topic) {
+ case this.pushService.pushTopic:
+ if (data === FXA_PUSH_SCOPE_ACCOUNT_UPDATE) {
+ let message = subject.QueryInterface(Ci.nsIPushMessage);
+ await this._onPushMessage(message);
+ }
+ break;
+ case this.pushService.subscriptionChangeTopic:
+ if (data === FXA_PUSH_SCOPE_ACCOUNT_UPDATE) {
+ await this._onPushSubscriptionChange();
+ }
+ break;
+ case ONLOGOUT_NOTIFICATION:
+ // user signed out, we need to stop polling the Push Server
+ await this.unsubscribe();
+ break;
+ }
+ } catch (err) {
+ this.log.error(err);
+ }
+ },
+
+ /**
+ * Fired when the Push server sends a notification.
+ *
+ * @private
+ * @returns {Promise}
+ */
+ async _onPushMessage(message) {
+ this.log.trace("FxAccountsPushService _onPushMessage");
+ if (!message.data) {
+ // Use the empty signal to check the verification state of the account right away
+ this.log.debug("empty push message - checking account status");
+ this.fxai.checkVerificationStatus();
+ return;
+ }
+ let payload = message.data.json();
+ this.log.debug(`push command: ${payload.command}`);
+ switch (payload.command) {
+ case ON_COMMAND_RECEIVED_NOTIFICATION:
+ await this.fxai.commands.pollDeviceCommands(payload.data.index);
+ break;
+ case ON_DEVICE_CONNECTED_NOTIFICATION:
+ Services.obs.notifyObservers(
+ null,
+ ON_DEVICE_CONNECTED_NOTIFICATION,
+ payload.data.deviceName
+ );
+ break;
+ case ON_DEVICE_DISCONNECTED_NOTIFICATION:
+ this.fxai._handleDeviceDisconnection(payload.data.id);
+ return;
+ case ON_PROFILE_UPDATED_NOTIFICATION:
+ // We already have a "profile updated" notification sent via WebChannel,
+ // let's just re-use that.
+ Services.obs.notifyObservers(null, ON_PROFILE_CHANGE_NOTIFICATION);
+ return;
+ case ON_PASSWORD_CHANGED_NOTIFICATION:
+ case ON_PASSWORD_RESET_NOTIFICATION:
+ this._onPasswordChanged();
+ return;
+ case ON_ACCOUNT_DESTROYED_NOTIFICATION:
+ this.fxai._handleAccountDestroyed(payload.data.uid);
+ return;
+ case ON_COLLECTION_CHANGED_NOTIFICATION:
+ Services.obs.notifyObservers(
+ null,
+ ON_COLLECTION_CHANGED_NOTIFICATION,
+ payload.data.collections
+ );
+ return;
+ case ON_VERIFY_LOGIN_NOTIFICATION:
+ Services.obs.notifyObservers(
+ null,
+ ON_VERIFY_LOGIN_NOTIFICATION,
+ JSON.stringify(payload.data)
+ );
+ break;
+ default:
+ this.log.warn("FxA Push command unrecognized: " + payload.command);
+ }
+ },
+ /**
+ * Check the FxA session status after a password change/reset event.
+ * If the session is invalid, reset credentials and notify listeners of
+ * ON_ACCOUNT_STATE_CHANGE_NOTIFICATION that the account may have changed
+ *
+ * @returns {Promise}
+ * @private
+ */
+ _onPasswordChanged() {
+ return this.fxai.withCurrentAccountState(async state => {
+ return this.fxai.checkAccountStatus(state);
+ });
+ },
+ /**
+ * Fired when the Push server drops a subscription, or the subscription identifier changes.
+ *
+ * https://developer.mozilla.org/en-US/docs/Mozilla/Tech/XPCOM/Reference/Interface/nsIPushService#Receiving_Push_Messages
+ *
+ * @returns {Promise}
+ * @private
+ */
+ _onPushSubscriptionChange() {
+ this.log.trace("FxAccountsPushService _onPushSubscriptionChange");
+ return this.fxai.updateDeviceRegistration();
+ },
+ /**
+ * Unsubscribe from the Push server
+ *
+ * Ref: https://developer.mozilla.org/en-US/docs/Mozilla/Tech/XPCOM/Reference/Interface/nsIPushService#unsubscribe()
+ *
+ * @returns {Promise} - The promise resolves with a bool to indicate if we successfully unsubscribed.
+ * The promise never rejects.
+ * @private
+ */
+ unsubscribe() {
+ this.log.trace("FxAccountsPushService unsubscribe");
+ return new Promise(resolve => {
+ this.pushService.unsubscribe(
+ FXA_PUSH_SCOPE_ACCOUNT_UPDATE,
+ Services.scriptSecurityManager.getSystemPrincipal(),
+ (result, ok) => {
+ if (Components.isSuccessCode(result)) {
+ if (ok === true) {
+ this.log.debug("FxAccountsPushService unsubscribed");
+ } else {
+ this.log.debug(
+ "FxAccountsPushService had no subscription to unsubscribe"
+ );
+ }
+ } else {
+ this.log.warn(
+ "FxAccountsPushService failed to unsubscribe",
+ result
+ );
+ }
+ return resolve(ok);
+ }
+ );
+ });
+ },
+
+ /**
+ * Get our Push server subscription.
+ *
+ * Ref: https://developer.mozilla.org/en-US/docs/Mozilla/Tech/XPCOM/Reference/Interface/nsIPushService#getSubscription()
+ *
+ * @returns {Promise} - resolves with the subscription or null. Never rejects.
+ */
+ getSubscription() {
+ return new Promise(resolve => {
+ this.pushService.getSubscription(
+ FXA_PUSH_SCOPE_ACCOUNT_UPDATE,
+ Services.scriptSecurityManager.getSystemPrincipal(),
+ (result, subscription) => {
+ if (!subscription) {
+ this.log.info("FxAccountsPushService no subscription found");
+ return resolve(null);
+ }
+ return resolve(subscription);
+ }
+ );
+ });
+ },
+};
diff --git a/services/fxaccounts/FxAccountsStorage.sys.mjs b/services/fxaccounts/FxAccountsStorage.sys.mjs
new file mode 100644
index 0000000000..24c85dbc2d
--- /dev/null
+++ b/services/fxaccounts/FxAccountsStorage.sys.mjs
@@ -0,0 +1,618 @@
+/* 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 {
+ DATA_FORMAT_VERSION,
+ DEFAULT_STORAGE_FILENAME,
+ FXA_PWDMGR_HOST,
+ FXA_PWDMGR_PLAINTEXT_FIELDS,
+ FXA_PWDMGR_REALM,
+ FXA_PWDMGR_SECURE_FIELDS,
+ log,
+} from "resource://gre/modules/FxAccountsCommon.sys.mjs";
+
+// A helper function so code can check what fields are able to be stored by
+// the storage manager without having a reference to a manager instance.
+export function FxAccountsStorageManagerCanStoreField(fieldName) {
+ return (
+ FXA_PWDMGR_PLAINTEXT_FIELDS.has(fieldName) ||
+ FXA_PWDMGR_SECURE_FIELDS.has(fieldName)
+ );
+}
+
+// The storage manager object.
+export var FxAccountsStorageManager = function (options = {}) {
+ this.options = {
+ filename: options.filename || DEFAULT_STORAGE_FILENAME,
+ baseDir: options.baseDir || Services.dirsvc.get("ProfD", Ci.nsIFile).path,
+ };
+ this.plainStorage = new JSONStorage(this.options);
+ // Tests may want to pretend secure storage isn't available.
+ let useSecure = "useSecure" in options ? options.useSecure : true;
+ if (useSecure) {
+ this.secureStorage = new LoginManagerStorage();
+ } else {
+ this.secureStorage = null;
+ }
+ this._clearCachedData();
+ // See .initialize() below - this protects against it not being called.
+ this._promiseInitialized = Promise.reject("initialize not called");
+ // A promise to avoid storage races - see _queueStorageOperation
+ this._promiseStorageComplete = Promise.resolve();
+};
+
+FxAccountsStorageManager.prototype = {
+ _initialized: false,
+ _needToReadSecure: true,
+
+ // An initialization routine that *looks* synchronous to the callers, but
+ // is actually async as everything else waits for it to complete.
+ initialize(accountData) {
+ if (this._initialized) {
+ throw new Error("already initialized");
+ }
+ this._initialized = true;
+ // If we just throw away our pre-rejected promise it is reported as an
+ // unhandled exception when it is GCd - so add an empty .catch handler here
+ // to prevent this.
+ this._promiseInitialized.catch(() => {});
+ this._promiseInitialized = this._initialize(accountData);
+ },
+
+ async _initialize(accountData) {
+ log.trace("initializing new storage manager");
+ try {
+ if (accountData) {
+ // If accountData is passed we don't need to read any storage.
+ this._needToReadSecure = false;
+ // split it into the 2 parts, write it and we are done.
+ for (let [name, val] of Object.entries(accountData)) {
+ if (FXA_PWDMGR_PLAINTEXT_FIELDS.has(name)) {
+ this.cachedPlain[name] = val;
+ } else if (FXA_PWDMGR_SECURE_FIELDS.has(name)) {
+ this.cachedSecure[name] = val;
+ } else {
+ // Unknown fields are silently discarded, because there is no way
+ // for them to be read back later.
+ log.error(
+ "Unknown FxA field name in user data, it will be ignored",
+ name
+ );
+ }
+ }
+ // write it out and we are done.
+ await this._write();
+ return;
+ }
+ // So we were initialized without account data - that means we need to
+ // read the state from storage. We try and read plain storage first and
+ // only attempt to read secure storage if the plain storage had a user.
+ this._needToReadSecure = await this._readPlainStorage();
+ if (this._needToReadSecure && this.secureStorage) {
+ await this._doReadAndUpdateSecure();
+ }
+ } finally {
+ log.trace("initializing of new storage manager done");
+ }
+ },
+
+ finalize() {
+ // We can't throw this instance away while it is still writing or we may
+ // end up racing with the newly created one.
+ log.trace("StorageManager finalizing");
+ return this._promiseInitialized
+ .then(() => {
+ return this._promiseStorageComplete;
+ })
+ .then(() => {
+ this._promiseStorageComplete = null;
+ this._promiseInitialized = null;
+ this._clearCachedData();
+ log.trace("StorageManager finalized");
+ });
+ },
+
+ // We want to make sure we don't end up doing multiple storage requests
+ // concurrently - which has a small window for reads if the master-password
+ // is locked at initialization time and becomes unlocked later, and always
+ // has an opportunity for updates.
+ // We also want to make sure we finished writing when finalizing, so we
+ // can't accidentally end up with the previous user's write finishing after
+ // a signOut attempts to clear it.
+ // So all such operations "queue" themselves via this.
+ _queueStorageOperation(func) {
+ // |result| is the promise we return - it has no .catch handler, so callers
+ // of the storage operation still see failure as a normal rejection.
+ let result = this._promiseStorageComplete.then(func);
+ // But the promise we assign to _promiseStorageComplete *does* have a catch
+ // handler so that rejections in one storage operation does not prevent
+ // future operations from starting (ie, _promiseStorageComplete must never
+ // be in a rejected state)
+ this._promiseStorageComplete = result.catch(err => {
+ log.error("${func} failed: ${err}", { func, err });
+ });
+ return result;
+ },
+
+ // Get the account data by combining the plain and secure storage.
+ // If fieldNames is specified, it may be a string or an array of strings,
+ // and only those fields are returned. If not specified the entire account
+ // data is returned except for "in memory" fields. Note that not specifying
+ // field names will soon be deprecated/removed - we want all callers to
+ // specify the fields they care about.
+ async getAccountData(fieldNames = null) {
+ await this._promiseInitialized;
+ // We know we are initialized - this means our .cachedPlain is accurate
+ // and doesn't need to be read (it was read if necessary by initialize).
+ // So if there's no uid, there's no user signed in.
+ if (!("uid" in this.cachedPlain)) {
+ return null;
+ }
+ let result = {};
+ if (fieldNames === null) {
+ // The "old" deprecated way of fetching a logged in user.
+ for (let [name, value] of Object.entries(this.cachedPlain)) {
+ result[name] = value;
+ }
+ // But the secure data may not have been read, so try that now.
+ await this._maybeReadAndUpdateSecure();
+ // .cachedSecure now has as much as it possibly can (which is possibly
+ // nothing if (a) secure storage remains locked and (b) we've never updated
+ // a field to be stored in secure storage.)
+ for (let [name, value] of Object.entries(this.cachedSecure)) {
+ result[name] = value;
+ }
+ return result;
+ }
+ // The new explicit way of getting attributes.
+ if (!Array.isArray(fieldNames)) {
+ fieldNames = [fieldNames];
+ }
+ let checkedSecure = false;
+ for (let fieldName of fieldNames) {
+ if (FXA_PWDMGR_PLAINTEXT_FIELDS.has(fieldName)) {
+ if (this.cachedPlain[fieldName] !== undefined) {
+ result[fieldName] = this.cachedPlain[fieldName];
+ }
+ } else if (FXA_PWDMGR_SECURE_FIELDS.has(fieldName)) {
+ // We may not have read secure storage yet.
+ if (!checkedSecure) {
+ await this._maybeReadAndUpdateSecure();
+ checkedSecure = true;
+ }
+ if (this.cachedSecure[fieldName] !== undefined) {
+ result[fieldName] = this.cachedSecure[fieldName];
+ }
+ } else {
+ throw new Error("unexpected field '" + fieldName + "'");
+ }
+ }
+ return result;
+ },
+
+ // Update just the specified fields. This DOES NOT allow you to change to
+ // a different user, nor to set the user as signed-out.
+ async updateAccountData(newFields) {
+ await this._promiseInitialized;
+ if (!("uid" in this.cachedPlain)) {
+ // If this storage instance shows no logged in user, then you can't
+ // update fields.
+ throw new Error("No user is logged in");
+ }
+ if (!newFields || "uid" in newFields) {
+ throw new Error("Can't change uid");
+ }
+ log.debug("_updateAccountData with items", Object.keys(newFields));
+ // work out what bucket.
+ for (let [name, value] of Object.entries(newFields)) {
+ if (value == null) {
+ delete this.cachedPlain[name];
+ // no need to do the "delete on null" thing for this.cachedSecure -
+ // we need to keep it until we have managed to read so we can nuke
+ // it on write.
+ this.cachedSecure[name] = null;
+ } else if (FXA_PWDMGR_PLAINTEXT_FIELDS.has(name)) {
+ this.cachedPlain[name] = value;
+ } else if (FXA_PWDMGR_SECURE_FIELDS.has(name)) {
+ this.cachedSecure[name] = value;
+ } else {
+ // Throwing seems reasonable here as some client code has explicitly
+ // specified the field name, so it's either confused or needs to update
+ // how this field is to be treated.
+ throw new Error("unexpected field '" + name + "'");
+ }
+ }
+ // If we haven't yet read the secure data, do so now, else we may write
+ // out partial data.
+ await this._maybeReadAndUpdateSecure();
+ // Now save it - but don't wait on the _write promise - it's queued up as
+ // a storage operation, so .finalize() will wait for completion, but no need
+ // for us to.
+ this._write();
+ },
+
+ _clearCachedData() {
+ this.cachedPlain = {};
+ // If we don't have secure storage available we have cachedPlain and
+ // cachedSecure be the same object.
+ this.cachedSecure = this.secureStorage == null ? this.cachedPlain : {};
+ },
+
+ /* Reads the plain storage and caches the read values in this.cachedPlain.
+ Only ever called once and unlike the "secure" storage, is expected to never
+ fail (ie, plain storage is considered always available, whereas secure
+ storage may be unavailable if it is locked).
+
+ Returns a promise that resolves with true if valid account data was found,
+ false otherwise.
+
+ Note: _readPlainStorage is only called during initialize, so isn't
+ protected via _queueStorageOperation() nor _promiseInitialized.
+ */
+ async _readPlainStorage() {
+ let got;
+ try {
+ got = await this.plainStorage.get();
+ } catch (err) {
+ // File hasn't been created yet. That will be done
+ // when write is called.
+ if (!err.name == "NotFoundError") {
+ log.error("Failed to read plain storage", err);
+ }
+ // either way, we return null.
+ got = null;
+ }
+ if (
+ !got ||
+ !got.accountData ||
+ !got.accountData.uid ||
+ got.version != DATA_FORMAT_VERSION
+ ) {
+ return false;
+ }
+ // We need to update our .cachedPlain, but can't just assign to it as
+ // it may need to be the exact same object as .cachedSecure
+ // As a sanity check, .cachedPlain must be empty (as we are called by init)
+ // XXX - this would be a good use-case for a RuntimeAssert or similar, as
+ // being added in bug 1080457.
+ if (Object.keys(this.cachedPlain).length) {
+ throw new Error("should be impossible to have cached data already.");
+ }
+ for (let [name, value] of Object.entries(got.accountData)) {
+ this.cachedPlain[name] = value;
+ }
+ return true;
+ },
+
+ /* If we haven't managed to read the secure storage, try now, so
+ we can merge our cached data with the data that's already been set.
+ */
+ _maybeReadAndUpdateSecure() {
+ if (this.secureStorage == null || !this._needToReadSecure) {
+ return null;
+ }
+ return this._queueStorageOperation(() => {
+ if (this._needToReadSecure) {
+ // we might have read it by now!
+ return this._doReadAndUpdateSecure();
+ }
+ return null;
+ });
+ },
+
+ /* Unconditionally read the secure storage and merge our cached data (ie, data
+ which has already been set while the secure storage was locked) with
+ the read data
+ */
+ async _doReadAndUpdateSecure() {
+ let { uid, email } = this.cachedPlain;
+ try {
+ log.debug(
+ "reading secure storage with existing",
+ Object.keys(this.cachedSecure)
+ );
+ // If we already have anything in .cachedSecure it means something has
+ // updated cachedSecure before we've read it. That means that after we do
+ // manage to read we must write back the merged data.
+ let needWrite = !!Object.keys(this.cachedSecure).length;
+ let readSecure = await this.secureStorage.get(uid, email);
+ // and update our cached data with it - anything already in .cachedSecure
+ // wins (including the fact it may be null or undefined, the latter
+ // which means it will be removed from storage.
+ if (readSecure && readSecure.version != DATA_FORMAT_VERSION) {
+ log.warn("got secure data but the data format version doesn't match");
+ readSecure = null;
+ }
+ if (readSecure && readSecure.accountData) {
+ log.debug(
+ "secure read fetched items",
+ Object.keys(readSecure.accountData)
+ );
+ for (let [name, value] of Object.entries(readSecure.accountData)) {
+ if (!(name in this.cachedSecure)) {
+ this.cachedSecure[name] = value;
+ }
+ }
+ if (needWrite) {
+ log.debug("successfully read secure data; writing updated data back");
+ await this._doWriteSecure();
+ }
+ }
+ this._needToReadSecure = false;
+ } catch (ex) {
+ if (ex instanceof this.secureStorage.STORAGE_LOCKED) {
+ log.debug("setAccountData: secure storage is locked trying to read");
+ } else {
+ log.error("failed to read secure storage", ex);
+ throw ex;
+ }
+ }
+ },
+
+ _write() {
+ // We don't want multiple writes happening concurrently, and we also need to
+ // know when an "old" storage manager is done (this.finalize() waits for this)
+ return this._queueStorageOperation(() => this.__write());
+ },
+
+ async __write() {
+ // Write everything back - later we could track what's actually dirty,
+ // but for now we write it all.
+ log.debug("writing plain storage", Object.keys(this.cachedPlain));
+ let toWritePlain = {
+ version: DATA_FORMAT_VERSION,
+ accountData: this.cachedPlain,
+ };
+ await this.plainStorage.set(toWritePlain);
+
+ // If we have no secure storage manager we are done.
+ if (this.secureStorage == null) {
+ return;
+ }
+ // and only attempt to write to secure storage if we've managed to read it,
+ // otherwise we might clobber data that's already there.
+ if (!this._needToReadSecure) {
+ await this._doWriteSecure();
+ }
+ },
+
+ /* Do the actual write of secure data. Caller is expected to check if we actually
+ need to write and to ensure we are in a queued storage operation.
+ */
+ async _doWriteSecure() {
+ // We need to remove null items here.
+ for (let [name, value] of Object.entries(this.cachedSecure)) {
+ if (value == null) {
+ delete this.cachedSecure[name];
+ }
+ }
+ log.debug("writing secure storage", Object.keys(this.cachedSecure));
+ let toWriteSecure = {
+ version: DATA_FORMAT_VERSION,
+ accountData: this.cachedSecure,
+ };
+ try {
+ await this.secureStorage.set(this.cachedPlain.uid, toWriteSecure);
+ } catch (ex) {
+ if (!(ex instanceof this.secureStorage.STORAGE_LOCKED)) {
+ throw ex;
+ }
+ // This shouldn't be possible as once it is unlocked it can't be
+ // re-locked, and we can only be here if we've previously managed to
+ // read.
+ log.error("setAccountData: secure storage is locked trying to write");
+ }
+ },
+
+ // Delete the data for an account - ie, called on "sign out".
+ deleteAccountData() {
+ return this._queueStorageOperation(() => this._deleteAccountData());
+ },
+
+ async _deleteAccountData() {
+ log.debug("removing account data");
+ await this._promiseInitialized;
+ await this.plainStorage.set(null);
+ if (this.secureStorage) {
+ await this.secureStorage.set(null);
+ }
+ this._clearCachedData();
+ log.debug("account data reset");
+ },
+};
+
+/**
+ * JSONStorage constructor that creates instances that may set/get
+ * to a specified file, in a directory that will be created if it
+ * doesn't exist.
+ *
+ * @param options {
+ * filename: of the file to write to
+ * baseDir: directory where the file resides
+ * }
+ * @return instance
+ */
+function JSONStorage(options) {
+ this.baseDir = options.baseDir;
+ this.path = PathUtils.join(options.baseDir, options.filename);
+}
+
+JSONStorage.prototype = {
+ set(contents) {
+ log.trace(
+ "starting write of json user data",
+ contents ? Object.keys(contents.accountData) : "null"
+ );
+ let start = Date.now();
+ return IOUtils.makeDirectory(this.baseDir, { ignoreExisting: true })
+ .then(IOUtils.writeJSON.bind(null, this.path, contents))
+ .then(result => {
+ log.trace(
+ "finished write of json user data - took",
+ Date.now() - start
+ );
+ return result;
+ });
+ },
+
+ get() {
+ log.trace("starting fetch of json user data");
+ let start = Date.now();
+ return IOUtils.readJSON(this.path).then(result => {
+ log.trace("finished fetch of json user data - took", Date.now() - start);
+ return result;
+ });
+ },
+};
+
+function StorageLockedError() {}
+
+/**
+ * LoginManagerStorage constructor that creates instances that set/get
+ * data stored securely in the nsILoginManager.
+ *
+ * @return instance
+ */
+
+export function LoginManagerStorage() {}
+
+LoginManagerStorage.prototype = {
+ STORAGE_LOCKED: StorageLockedError,
+ // The fields in the credentials JSON object that are stored in plain-text
+ // in the profile directory. All other fields are stored in the login manager,
+ // and thus are only available when the master-password is unlocked.
+
+ // a hook point for testing.
+ get _isLoggedIn() {
+ return Services.logins.isLoggedIn;
+ },
+
+ // Clear any data from the login manager. Returns true if the login manager
+ // was unlocked (even if no existing logins existed) or false if it was
+ // locked (meaning we don't even know if it existed or not.)
+ async _clearLoginMgrData() {
+ try {
+ // Services.logins might be third-party and broken...
+ await Services.logins.initializationPromise;
+ if (!this._isLoggedIn) {
+ return false;
+ }
+ let logins = await Services.logins.searchLoginsAsync({
+ origin: FXA_PWDMGR_HOST,
+ httpRealm: FXA_PWDMGR_REALM,
+ });
+ for (let login of logins) {
+ Services.logins.removeLogin(login);
+ }
+ return true;
+ } catch (ex) {
+ log.error("Failed to clear login data: ${}", ex);
+ return false;
+ }
+ },
+
+ async set(uid, contents) {
+ if (!contents) {
+ // Nuke it from the login manager.
+ let cleared = await this._clearLoginMgrData();
+ if (!cleared) {
+ // just log a message - we verify that the uid matches when
+ // we reload it, so having a stale entry doesn't really hurt.
+ log.info("not removing credentials from login manager - not logged in");
+ }
+ log.trace("storage set finished clearing account data");
+ return;
+ }
+
+ // We are saving actual data.
+ log.trace("starting write of user data to the login manager");
+ try {
+ // Services.logins might be third-party and broken...
+ // and the stuff into the login manager.
+ await Services.logins.initializationPromise;
+ // If MP is locked we silently fail - the user may need to re-auth
+ // next startup.
+ if (!this._isLoggedIn) {
+ log.info("not saving credentials to login manager - not logged in");
+ throw new this.STORAGE_LOCKED();
+ }
+ // write the data to the login manager.
+ let loginInfo = new Components.Constructor(
+ "@mozilla.org/login-manager/loginInfo;1",
+ Ci.nsILoginInfo,
+ "init"
+ );
+ let login = new loginInfo(
+ FXA_PWDMGR_HOST,
+ null, // aFormActionOrigin,
+ FXA_PWDMGR_REALM, // aHttpRealm,
+ uid, // aUsername
+ JSON.stringify(contents), // aPassword
+ "", // aUsernameField
+ ""
+ ); // aPasswordField
+
+ let existingLogins = await Services.logins.searchLoginsAsync({
+ origin: FXA_PWDMGR_HOST,
+ httpRealm: FXA_PWDMGR_REALM,
+ });
+ if (existingLogins.length) {
+ Services.logins.modifyLogin(existingLogins[0], login);
+ } else {
+ await Services.logins.addLoginAsync(login);
+ }
+ log.trace("finished write of user data to the login manager");
+ } catch (ex) {
+ if (ex instanceof this.STORAGE_LOCKED) {
+ throw ex;
+ }
+ // just log and consume the error here - it may be a 3rd party login
+ // manager replacement that's simply broken.
+ log.error("Failed to save data to the login manager", ex);
+ }
+ },
+
+ async get(uid, email) {
+ log.trace("starting fetch of user data from the login manager");
+
+ try {
+ // Services.logins might be third-party and broken...
+ // read the data from the login manager and merge it for return.
+ await Services.logins.initializationPromise;
+
+ if (!this._isLoggedIn) {
+ log.info(
+ "returning partial account data as the login manager is locked."
+ );
+ throw new this.STORAGE_LOCKED();
+ }
+
+ let logins = await Services.logins.searchLoginsAsync({
+ origin: FXA_PWDMGR_HOST,
+ httpRealm: FXA_PWDMGR_REALM,
+ });
+ if (!logins.length) {
+ // This could happen if the MP was locked when we wrote the data.
+ log.info("Can't find any credentials in the login manager");
+ return null;
+ }
+ let login = logins[0];
+ // Support either the uid or the email as the username - as of bug 1183951
+ // we store the uid, but we support having either for b/w compat.
+ if (login.username == uid || login.username == email) {
+ return JSON.parse(login.password);
+ }
+ log.info("username in the login manager doesn't match - ignoring it");
+ await this._clearLoginMgrData();
+ } catch (ex) {
+ if (ex instanceof this.STORAGE_LOCKED) {
+ throw ex;
+ }
+ // just log and consume the error here - it may be a 3rd party login
+ // manager replacement that's simply broken.
+ log.error("Failed to get data from the login manager", ex);
+ }
+ return null;
+ },
+};
diff --git a/services/fxaccounts/FxAccountsTelemetry.sys.mjs b/services/fxaccounts/FxAccountsTelemetry.sys.mjs
new file mode 100644
index 0000000000..1d7b3d4954
--- /dev/null
+++ b/services/fxaccounts/FxAccountsTelemetry.sys.mjs
@@ -0,0 +1,173 @@
+/* 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/. */
+
+// FxA Telemetry support. For hysterical raisins, the actual implementation
+// is inside "sync". We should move the core implementation somewhere that's
+// sanely shared (eg, services-common?), but let's wait and see where we end up
+// first...
+
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ CryptoUtils: "resource://services-crypto/utils.sys.mjs",
+
+ // We use this observers module because we leverage its support for richer
+ // "subject" data.
+ Observers: "resource://services-common/observers.sys.mjs",
+});
+
+import {
+ PREF_ACCOUNT_ROOT,
+ log,
+} from "resource://gre/modules/FxAccountsCommon.sys.mjs";
+
+const PREF_SANITIZED_UID = PREF_ACCOUNT_ROOT + "telemetry.sanitized_uid";
+XPCOMUtils.defineLazyPreferenceGetter(
+ lazy,
+ "pref_sanitizedUid",
+ PREF_SANITIZED_UID,
+ ""
+);
+
+export class FxAccountsTelemetry {
+ constructor(fxai) {
+ this._fxai = fxai;
+ Services.telemetry.setEventRecordingEnabled("fxa", true);
+ }
+
+ // Records an event *in the Fxa/Sync ping*.
+ recordEvent(object, method, value, extra = undefined) {
+ // We need to ensure the telemetry module is loaded.
+ ChromeUtils.importESModule("resource://services-sync/telemetry.sys.mjs");
+ // Now it will be listening for the notifications...
+ lazy.Observers.notify("fxa:telemetry:event", {
+ object,
+ method,
+ value,
+ extra,
+ });
+ }
+
+ generateUUID() {
+ return Services.uuid.generateUUID().toString().slice(1, -1);
+ }
+
+ // A flow ID can be anything that's "probably" unique, so for now use a UUID.
+ generateFlowID() {
+ return this.generateUUID();
+ }
+
+ // FxA- and Sync-related metrics are submitted in a special-purpose "sync ping". This ping
+ // identifies the user by a version of their FxA uid that is HMAC-ed with a server-side secret
+ // key, in an attempt to provide a bit of anonymity.
+
+ // Secret back-channel by which tokenserver client code can set the hashed UID.
+ // This value conceptually belongs to FxA, but we currently get it from tokenserver,
+ // so there's some light hackery to put it in the right place.
+ _setHashedUID(hashedUID) {
+ if (!hashedUID) {
+ Services.prefs.clearUserPref(PREF_SANITIZED_UID);
+ } else {
+ Services.prefs.setStringPref(PREF_SANITIZED_UID, hashedUID);
+ }
+ }
+
+ getSanitizedUID() {
+ // Sadly, we can only currently obtain this value if the user has enabled sync.
+ return lazy.pref_sanitizedUid || null;
+ }
+
+ // Sanitize the ID of a device into something suitable for including in the
+ // ping. Returns null if no transformation is possible.
+ sanitizeDeviceId(deviceId) {
+ const uid = this.getSanitizedUID();
+ if (!uid) {
+ // Sadly, we can only currently get this if the user has enabled sync.
+ return null;
+ }
+ // Combine the raw device id with the sanitized uid to create a stable
+ // unique identifier that can't be mapped back to the user's FxA
+ // identity without knowing the metrics HMAC key.
+ // The result is 64 bytes long, which in retrospect is probably excessive,
+ // but it's already shipping...
+ return lazy.CryptoUtils.sha256(deviceId + uid);
+ }
+
+ // Record the connection of FxA or one of its services.
+ // Note that you must call this before performing the actual connection
+ // or we may record incorrect data - for example, we will not be able to
+ // determine whether FxA itself was connected before this call.
+ //
+ // Currently sends an event in the main telemetry event ping rather than the
+ // FxA/Sync ping (although this might change in the future)
+ //
+ // @param services - An array of service names which should be recorded. FxA
+ // itself is not counted as a "service" - ie, an empty array should be passed
+ // if the account is connected without anything else .
+ //
+ // @param how - How the connection was done.
+ async recordConnection(services, how = null) {
+ try {
+ let extra = {};
+ // Record that fxa was connected if it isn't currently - it will be soon.
+ if (!(await this._fxai.getUserAccountData())) {
+ extra.fxa = "true";
+ }
+ // Events.yaml only declares "sync" as a valid service.
+ if (services.includes("sync")) {
+ extra.sync = "true";
+ }
+ Services.telemetry.recordEvent("fxa", "connect", "account", how, extra);
+ } catch (ex) {
+ log.error("Failed to record connection telemetry", ex);
+ console.error("Failed to record connection telemetry", ex);
+ }
+ }
+
+ // Record the disconnection of FxA or one of its services.
+ // Note that you must call this before performing the actual disconnection
+ // or we may record incomplete data - for example, if this is called after
+ // disconnection, we've almost certainly lost the ability to record what
+ // services were enabled prior to disconnection.
+ //
+ // Currently sends an event in the main telemetry event ping rather than the
+ // FxA/Sync ping (although this might change in the future)
+ //
+ // @param service - the service being disconnected. If null, the account
+ // itself is being disconnected, so all connected services are too.
+ //
+ // @param how - how the disconnection was done.
+ async recordDisconnection(service = null, how = null) {
+ try {
+ let extra = {};
+ if (!service) {
+ extra.fxa = "true";
+ // We need a way to enumerate all services - but for now we just hard-code
+ // all possibilities here.
+ if (Services.prefs.prefHasUserValue("services.sync.username")) {
+ extra.sync = "true";
+ }
+ } else if (service == "sync") {
+ extra[service] = "true";
+ } else {
+ // Events.yaml only declares "sync" as a valid service.
+ log.warn(
+ `recordDisconnection has invalid value for service: ${service}`
+ );
+ }
+ Services.telemetry.recordEvent(
+ "fxa",
+ "disconnect",
+ "account",
+ how,
+ extra
+ );
+ } catch (ex) {
+ log.error("Failed to record disconnection telemetry", ex);
+ console.error("Failed to record disconnection telemetry", ex);
+ }
+ }
+}
diff --git a/services/fxaccounts/FxAccountsWebChannel.sys.mjs b/services/fxaccounts/FxAccountsWebChannel.sys.mjs
new file mode 100644
index 0000000000..fdd0b75e93
--- /dev/null
+++ b/services/fxaccounts/FxAccountsWebChannel.sys.mjs
@@ -0,0 +1,824 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * Firefox Accounts Web Channel.
+ *
+ * Uses the WebChannel component to receive messages
+ * about account state changes.
+ */
+
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+
+import {
+ COMMAND_PROFILE_CHANGE,
+ COMMAND_LOGIN,
+ COMMAND_LOGOUT,
+ COMMAND_OAUTH,
+ COMMAND_DELETE,
+ COMMAND_CAN_LINK_ACCOUNT,
+ COMMAND_SYNC_PREFERENCES,
+ COMMAND_CHANGE_PASSWORD,
+ COMMAND_FXA_STATUS,
+ COMMAND_PAIR_HEARTBEAT,
+ COMMAND_PAIR_SUPP_METADATA,
+ COMMAND_PAIR_AUTHORIZE,
+ COMMAND_PAIR_DECLINE,
+ COMMAND_PAIR_COMPLETE,
+ COMMAND_PAIR_PREFERENCES,
+ COMMAND_FIREFOX_VIEW,
+ FX_OAUTH_CLIENT_ID,
+ ON_PROFILE_CHANGE_NOTIFICATION,
+ PREF_LAST_FXA_USER,
+ WEBCHANNEL_ID,
+ log,
+ logPII,
+} from "resource://gre/modules/FxAccountsCommon.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ CryptoUtils: "resource://services-crypto/utils.sys.mjs",
+ FxAccountsPairingFlow: "resource://gre/modules/FxAccountsPairing.sys.mjs",
+ FxAccountsStorageManagerCanStoreField:
+ "resource://gre/modules/FxAccountsStorage.sys.mjs",
+ PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs",
+ Weave: "resource://services-sync/main.sys.mjs",
+ WebChannel: "resource://gre/modules/WebChannel.sys.mjs",
+});
+ChromeUtils.defineLazyGetter(lazy, "fxAccounts", () => {
+ return ChromeUtils.importESModule(
+ "resource://gre/modules/FxAccounts.sys.mjs"
+ ).getFxAccountsSingleton();
+});
+XPCOMUtils.defineLazyPreferenceGetter(
+ lazy,
+ "pairingEnabled",
+ "identity.fxaccounts.pairing.enabled"
+);
+XPCOMUtils.defineLazyPreferenceGetter(
+ lazy,
+ "separatePrivilegedMozillaWebContentProcess",
+ "browser.tabs.remote.separatePrivilegedMozillaWebContentProcess",
+ false
+);
+XPCOMUtils.defineLazyPreferenceGetter(
+ lazy,
+ "separatedMozillaDomains",
+ "browser.tabs.remote.separatedMozillaDomains",
+ "",
+ false,
+ val => val.split(",")
+);
+XPCOMUtils.defineLazyPreferenceGetter(
+ lazy,
+ "accountServer",
+ "identity.fxaccounts.remote.root",
+ null,
+ false,
+ val => Services.io.newURI(val)
+);
+
+// These engines were added years after Sync had been introduced, they need
+// special handling since they are system add-ons and are un-available on
+// older versions of Firefox.
+const EXTRA_ENGINES = ["addresses", "creditcards"];
+
+// These engines will be displayed to the user to pick which they would like to
+// use
+const CHOOSE_WHAT_TO_SYNC = [
+ "addons",
+ "addresses",
+ "bookmarks",
+ "creditcards",
+ "history",
+ "passwords",
+ "preferences",
+ "tabs",
+];
+
+/**
+ * A helper function that extracts the message and stack from an error object.
+ * Returns a `{ message, stack }` tuple. `stack` will be null if the error
+ * doesn't have a stack trace.
+ */
+function getErrorDetails(error) {
+ // Replace anything that looks like it might be a filepath on Windows or Unix
+ let cleanMessage = String(error)
+ .replace(/\\.*\\/gm, "[REDACTED]")
+ .replace(/\/.*\//gm, "[REDACTED]");
+ let details = { message: cleanMessage, stack: null };
+
+ // Adapted from Console.sys.mjs.
+ if (error.stack) {
+ let frames = [];
+ for (let frame = error.stack; frame; frame = frame.caller) {
+ frames.push(String(frame).padStart(4));
+ }
+ details.stack = frames.join("\n");
+ }
+
+ return details;
+}
+
+/**
+ * Create a new FxAccountsWebChannel to listen for account updates
+ *
+ * @param {Object} options Options
+ * @param {Object} options
+ * @param {String} options.content_uri
+ * The FxA Content server uri
+ * @param {String} options.channel_id
+ * The ID of the WebChannel
+ * @param {String} options.helpers
+ * Helpers functions. Should only be passed in for testing.
+ * @constructor
+ */
+export function FxAccountsWebChannel(options) {
+ if (!options) {
+ throw new Error("Missing configuration options");
+ }
+ if (!options.content_uri) {
+ throw new Error("Missing 'content_uri' option");
+ }
+ this._contentUri = options.content_uri;
+
+ if (!options.channel_id) {
+ throw new Error("Missing 'channel_id' option");
+ }
+ this._webChannelId = options.channel_id;
+
+ // options.helpers is only specified by tests.
+ ChromeUtils.defineLazyGetter(this, "_helpers", () => {
+ return options.helpers || new FxAccountsWebChannelHelpers(options);
+ });
+
+ this._setupChannel();
+}
+
+FxAccountsWebChannel.prototype = {
+ /**
+ * WebChannel that is used to communicate with content page
+ */
+ _channel: null,
+
+ /**
+ * Helpers interface that does the heavy lifting.
+ */
+ _helpers: null,
+
+ /**
+ * WebChannel ID.
+ */
+ _webChannelId: null,
+ /**
+ * WebChannel origin, used to validate origin of messages
+ */
+ _webChannelOrigin: null,
+
+ /**
+ * Release all resources that are in use.
+ */
+ tearDown() {
+ this._channel.stopListening();
+ this._channel = null;
+ this._channelCallback = null;
+ },
+
+ /**
+ * Configures and registers a new WebChannel
+ *
+ * @private
+ */
+ _setupChannel() {
+ // if this.contentUri is present but not a valid URI, then this will throw an error.
+ try {
+ this._webChannelOrigin = Services.io.newURI(this._contentUri);
+ this._registerChannel();
+ } catch (e) {
+ log.error(e);
+ throw e;
+ }
+ },
+
+ _receiveMessage(message, sendingContext) {
+ const { command, data } = message;
+ let shouldCheckRemoteType =
+ lazy.separatePrivilegedMozillaWebContentProcess &&
+ lazy.separatedMozillaDomains.some(function (val) {
+ return (
+ lazy.accountServer.asciiHost == val ||
+ lazy.accountServer.asciiHost.endsWith("." + val)
+ );
+ });
+ let { currentRemoteType } = sendingContext.browsingContext;
+ if (shouldCheckRemoteType && currentRemoteType != "privilegedmozilla") {
+ log.error(
+ `Rejected FxA webchannel message from remoteType = ${currentRemoteType}`
+ );
+ return;
+ }
+
+ let browser = sendingContext.browsingContext.top.embedderElement;
+ switch (command) {
+ case COMMAND_PROFILE_CHANGE:
+ Services.obs.notifyObservers(
+ null,
+ ON_PROFILE_CHANGE_NOTIFICATION,
+ data.uid
+ );
+ break;
+ case COMMAND_LOGIN:
+ this._helpers
+ .login(data)
+ .catch(error => this._sendError(error, message, sendingContext));
+ break;
+ case COMMAND_OAUTH:
+ this._helpers
+ .oauthLogin(data)
+ .catch(error => this._sendError(error, message, sendingContext));
+ break;
+ case COMMAND_LOGOUT:
+ case COMMAND_DELETE:
+ this._helpers
+ .logout(data.uid)
+ .catch(error => this._sendError(error, message, sendingContext));
+ break;
+ case COMMAND_CAN_LINK_ACCOUNT:
+ let canLinkAccount = this._helpers.shouldAllowRelink(data.email);
+
+ let response = {
+ command,
+ messageId: message.messageId,
+ data: { ok: canLinkAccount },
+ };
+
+ log.debug("FxAccountsWebChannel response", response);
+ this._channel.send(response, sendingContext);
+ break;
+ case COMMAND_SYNC_PREFERENCES:
+ this._helpers.openSyncPreferences(browser, data.entryPoint);
+ break;
+ case COMMAND_PAIR_PREFERENCES:
+ if (lazy.pairingEnabled) {
+ let window = browser.ownerGlobal;
+ // We should close the FxA tab after we open our pref page
+ let selectedTab = window.gBrowser.selectedTab;
+ window.switchToTabHavingURI(
+ "about:preferences?action=pair#sync",
+ true,
+ {
+ ignoreQueryString: true,
+ replaceQueryString: true,
+ adoptIntoActiveWindow: true,
+ ignoreFragment: "whenComparing",
+ triggeringPrincipal:
+ Services.scriptSecurityManager.getSystemPrincipal(),
+ }
+ );
+ // close the tab
+ window.gBrowser.removeTab(selectedTab);
+ }
+ break;
+ case COMMAND_FIREFOX_VIEW:
+ this._helpers.openFirefoxView(browser, data.entryPoint);
+ break;
+ case COMMAND_CHANGE_PASSWORD:
+ this._helpers
+ .changePassword(data)
+ .catch(error => this._sendError(error, message, sendingContext));
+ break;
+ case COMMAND_FXA_STATUS:
+ log.debug("fxa_status received");
+
+ const service = data && data.service;
+ const isPairing = data && data.isPairing;
+ const context = data && data.context;
+ this._helpers
+ .getFxaStatus(service, sendingContext, isPairing, context)
+ .then(fxaStatus => {
+ let response = {
+ command,
+ messageId: message.messageId,
+ data: fxaStatus,
+ };
+ this._channel.send(response, sendingContext);
+ })
+ .catch(error => this._sendError(error, message, sendingContext));
+ break;
+ case COMMAND_PAIR_HEARTBEAT:
+ case COMMAND_PAIR_SUPP_METADATA:
+ case COMMAND_PAIR_AUTHORIZE:
+ case COMMAND_PAIR_DECLINE:
+ case COMMAND_PAIR_COMPLETE:
+ log.debug(`Pairing command ${command} received`);
+ const { channel_id: channelId } = data;
+ delete data.channel_id;
+ const flow = lazy.FxAccountsPairingFlow.get(channelId);
+ if (!flow) {
+ log.warn(`Could not find a pairing flow for ${channelId}`);
+ return;
+ }
+ flow.onWebChannelMessage(command, data).then(replyData => {
+ this._channel.send(
+ {
+ command,
+ messageId: message.messageId,
+ data: replyData,
+ },
+ sendingContext
+ );
+ });
+ break;
+ default:
+ log.warn("Unrecognized FxAccountsWebChannel command", command);
+ // As a safety measure we also terminate any pending FxA pairing flow.
+ lazy.FxAccountsPairingFlow.finalizeAll();
+ break;
+ }
+ },
+
+ _sendError(error, incomingMessage, sendingContext) {
+ log.error("Failed to handle FxAccountsWebChannel message", error);
+ this._channel.send(
+ {
+ command: incomingMessage.command,
+ messageId: incomingMessage.messageId,
+ data: {
+ error: getErrorDetails(error),
+ },
+ },
+ sendingContext
+ );
+ },
+
+ /**
+ * Create a new channel with the WebChannelBroker, setup a callback listener
+ * @private
+ */
+ _registerChannel() {
+ /**
+ * Processes messages that are called back from the FxAccountsChannel
+ *
+ * @param webChannelId {String}
+ * Command webChannelId
+ * @param message {Object}
+ * Command message
+ * @param sendingContext {Object}
+ * Message sending context.
+ * @param sendingContext.browsingContext {BrowsingContext}
+ * The browsingcontext from which the
+ * WebChannelMessageToChrome was sent.
+ * @param sendingContext.eventTarget {EventTarget}
+ * The <EventTarget> where the message was sent.
+ * @param sendingContext.principal {Principal}
+ * The <Principal> of the EventTarget where the message was sent.
+ * @private
+ *
+ */
+ let listener = (webChannelId, message, sendingContext) => {
+ if (message) {
+ log.debug("FxAccountsWebChannel message received", message.command);
+ if (logPII()) {
+ log.debug("FxAccountsWebChannel message details", message);
+ }
+ try {
+ this._receiveMessage(message, sendingContext);
+ } catch (error) {
+ this._sendError(error, message, sendingContext);
+ }
+ }
+ };
+
+ this._channelCallback = listener;
+ this._channel = new lazy.WebChannel(
+ this._webChannelId,
+ this._webChannelOrigin
+ );
+ this._channel.listen(listener);
+ log.debug(
+ "FxAccountsWebChannel registered: " +
+ this._webChannelId +
+ " with origin " +
+ this._webChannelOrigin.prePath
+ );
+ },
+};
+
+export function FxAccountsWebChannelHelpers(options) {
+ options = options || {};
+
+ this._fxAccounts = options.fxAccounts || lazy.fxAccounts;
+ this._weaveXPCOM = options.weaveXPCOM || null;
+ this._privateBrowsingUtils =
+ options.privateBrowsingUtils || lazy.PrivateBrowsingUtils;
+}
+
+FxAccountsWebChannelHelpers.prototype = {
+ // If the last fxa account used for sync isn't this account, we display
+ // a modal dialog checking they really really want to do this...
+ // (This is sync-specific, so ideally would be in sync's identity module,
+ // but it's a little more seamless to do here, and sync is currently the
+ // only fxa consumer, so...
+ shouldAllowRelink(acctName) {
+ return (
+ !this._needRelinkWarning(acctName) || this._promptForRelink(acctName)
+ );
+ },
+
+ async _initializeSync() {
+ // A sync-specific hack - we want to ensure sync has been initialized
+ // before we set the signed-in user.
+ // XXX - probably not true any more, especially now we have observerPreloads
+ // in FxAccounts.jsm?
+ let xps =
+ this._weaveXPCOM ||
+ Cc["@mozilla.org/weave/service;1"].getService(Ci.nsISupports)
+ .wrappedJSObject;
+ await xps.whenLoaded();
+ return xps;
+ },
+
+ _setEnabledEngines(offeredEngines, declinedEngines) {
+ if (offeredEngines && declinedEngines) {
+ EXTRA_ENGINES.forEach(engine => {
+ if (
+ offeredEngines.includes(engine) &&
+ !declinedEngines.includes(engine)
+ ) {
+ // These extra engines are disabled by default.
+ Services.prefs.setBoolPref(`services.sync.engine.${engine}`, true);
+ }
+ });
+ log.debug("Received declined engines", declinedEngines);
+ lazy.Weave.Service.engineManager.setDeclined(declinedEngines);
+ declinedEngines.forEach(engine => {
+ Services.prefs.setBoolPref(`services.sync.engine.${engine}`, false);
+ });
+ }
+ },
+ /**
+ * stores sync login info it in the fxaccounts service
+ *
+ * @param accountData the user's account data and credentials
+ */
+ async login(accountData) {
+ // We don't act on customizeSync anymore, it used to open a dialog inside
+ // the browser to selecte the engines to sync but we do it on the web now.
+ log.debug("Webchannel is logging a user in.");
+ delete accountData.customizeSync;
+
+ // Save requested services for later.
+ const requestedServices = accountData.services;
+ delete accountData.services;
+
+ // the user has already been shown the "can link account"
+ // screen. No need to keep this data around.
+ delete accountData.verifiedCanLinkAccount;
+
+ // Remember who it was so we can log out next time.
+ if (accountData.verified) {
+ this.setPreviousAccountNameHashPref(accountData.email);
+ }
+
+ await this._fxAccounts.telemetry.recordConnection(
+ Object.keys(requestedServices || {}),
+ "webchannel"
+ );
+
+ const xps = await this._initializeSync();
+ await this._fxAccounts._internal.setSignedInUser(accountData);
+
+ if (requestedServices) {
+ // User has enabled Sync.
+ if (requestedServices.sync) {
+ const { offeredEngines, declinedEngines } = requestedServices.sync;
+ this._setEnabledEngines(offeredEngines, declinedEngines);
+ log.debug("Webchannel is enabling sync");
+ await xps.Weave.Service.configure();
+ }
+ }
+ },
+
+ /**
+ * Logins in to sync by completing an OAuth flow
+ * @param { Object } oauthData: The oauth code and state as returned by the server */
+ async oauthLogin(oauthData) {
+ log.debug("Webchannel is completing the oauth flow");
+ const xps = await this._initializeSync();
+ const { code, state, declinedSyncEngines, offeredSyncEngines } = oauthData;
+ const { sessionToken } =
+ await this._fxAccounts._internal.getUserAccountData(["sessionToken"]);
+ // First we finish the ongoing oauth flow
+ const { scopedKeys, refreshToken } =
+ await this._fxAccounts._internal.completeOAuthFlow(
+ sessionToken,
+ code,
+ state
+ );
+
+ // We don't currently use the refresh token in Firefox Desktop, lets be good citizens and revoke it.
+ await this._fxAccounts._internal.destroyOAuthToken({ token: refreshToken });
+
+ // Then, we persist the sync keys
+ await this._fxAccounts._internal.setScopedKeys(scopedKeys);
+
+ // Now that we have the scoped keys, we set our status to verified
+ await this._fxAccounts._internal.setUserVerified();
+ this._setEnabledEngines(offeredSyncEngines, declinedSyncEngines);
+ log.debug("Webchannel is enabling sync");
+ xps.Weave.Service.configure();
+ },
+
+ /**
+ * logout the fxaccounts service
+ *
+ * @param the uid of the account which have been logged out
+ */
+ async logout(uid) {
+ let fxa = this._fxAccounts;
+ let userData = await fxa._internal.getUserAccountData(["uid"]);
+ if (userData && userData.uid === uid) {
+ await fxa.telemetry.recordDisconnection(null, "webchannel");
+ // true argument is `localOnly`, because server-side stuff
+ // has already been taken care of by the content server
+ await fxa.signOut(true);
+ }
+ },
+
+ /**
+ * Check if `sendingContext` is in private browsing mode.
+ */
+ isPrivateBrowsingMode(sendingContext) {
+ if (!sendingContext) {
+ log.error("Unable to check for private browsing mode, assuming true");
+ return true;
+ }
+
+ let browser = sendingContext.browsingContext.top.embedderElement;
+ const isPrivateBrowsing =
+ this._privateBrowsingUtils.isBrowserPrivate(browser);
+ log.debug("is private browsing", isPrivateBrowsing);
+ return isPrivateBrowsing;
+ },
+
+ /**
+ * Check whether sending fxa_status data should be allowed.
+ */
+ shouldAllowFxaStatus(service, sendingContext, isPairing, context) {
+ // Return user data for any service in non-PB mode. In PB mode,
+ // only return user data if service==="sync" or is in pairing mode
+ // (as service will be equal to the OAuth client ID and not "sync").
+ //
+ // This behaviour allows users to click the "Manage Account"
+ // link from about:preferences#sync while in PB mode and things
+ // "just work". While in non-PB mode, users can sign into
+ // Pocket w/o entering their password a 2nd time, while in PB
+ // mode they *will* have to enter their email/password again.
+ //
+ // The difference in behaviour is to try to match user
+ // expectations as to what is and what isn't part of the browser.
+ // Sync is viewed as an integral part of the browser, interacting
+ // with FxA as part of a Sync flow should work all the time. If
+ // Sync is broken in PB mode, users will think Firefox is broken.
+ // See https://bugzilla.mozilla.org/show_bug.cgi?id=1323853
+ log.debug("service", service);
+ return (
+ !this.isPrivateBrowsingMode(sendingContext) ||
+ service === "sync" ||
+ context === "fx_desktop_v3" ||
+ isPairing
+ );
+ },
+
+ /**
+ * Get fxa_status information. Resolves to { signedInUser: <user_data> }.
+ * If returning status information is not allowed or no user is signed into
+ * Sync, `user_data` will be null.
+ */
+ async getFxaStatus(service, sendingContext, isPairing, context) {
+ let signedInUser = null;
+
+ if (
+ this.shouldAllowFxaStatus(service, sendingContext, isPairing, context)
+ ) {
+ const userData = await this._fxAccounts._internal.getUserAccountData([
+ "email",
+ "sessionToken",
+ "uid",
+ "verified",
+ ]);
+ if (userData) {
+ signedInUser = {
+ email: userData.email,
+ sessionToken: userData.sessionToken,
+ uid: userData.uid,
+ verified: userData.verified,
+ };
+ }
+ }
+
+ const capabilities = this._getCapabilities();
+
+ return {
+ signedInUser,
+ clientId: FX_OAUTH_CLIENT_ID,
+ capabilities,
+ };
+ },
+ _getCapabilities() {
+ if (
+ Services.prefs.getBoolPref("identity.fxaccounts.oauth.enabled", false)
+ ) {
+ return {
+ multiService: true,
+ pairing: lazy.pairingEnabled,
+ choose_what_to_sync: true,
+ engines: CHOOSE_WHAT_TO_SYNC,
+ };
+ }
+ return {
+ multiService: true,
+ pairing: lazy.pairingEnabled,
+ engines: this._getAvailableExtraEngines(),
+ };
+ },
+
+ _getAvailableExtraEngines() {
+ return EXTRA_ENGINES.filter(engineName => {
+ try {
+ return Services.prefs.getBoolPref(
+ `services.sync.engine.${engineName}.available`
+ );
+ } catch (e) {
+ return false;
+ }
+ });
+ },
+
+ async changePassword(credentials) {
+ // If |credentials| has fields that aren't handled by accounts storage,
+ // updateUserAccountData will throw - mainly to prevent errors in code
+ // that hard-codes field names.
+ // However, in this case the field names aren't really in our control.
+ // We *could* still insist the server know what fields names are valid,
+ // but that makes life difficult for the server when Firefox adds new
+ // features (ie, new fields) - forcing the server to track a map of
+ // versions to supported field names doesn't buy us much.
+ // So we just remove field names we know aren't handled.
+ let newCredentials = {
+ device: null, // Force a brand new device registration.
+ // We force the re-encryption of the send tab keys using the new sync key after the password change
+ encryptedSendTabKeys: null,
+ };
+ for (let name of Object.keys(credentials)) {
+ if (
+ name == "email" ||
+ name == "uid" ||
+ lazy.FxAccountsStorageManagerCanStoreField(name)
+ ) {
+ newCredentials[name] = credentials[name];
+ } else {
+ log.info("changePassword ignoring unsupported field", name);
+ }
+ }
+ await this._fxAccounts._internal.updateUserAccountData(newCredentials);
+ await this._fxAccounts._internal.updateDeviceRegistration();
+ },
+
+ /**
+ * Get the hash of account name of the previously signed in account
+ */
+ getPreviousAccountNameHashPref() {
+ try {
+ return Services.prefs.getStringPref(PREF_LAST_FXA_USER);
+ } catch (_) {
+ return "";
+ }
+ },
+
+ /**
+ * Given an account name, set the hash of the previously signed in account
+ *
+ * @param acctName the account name of the user's account.
+ */
+ setPreviousAccountNameHashPref(acctName) {
+ Services.prefs.setStringPref(
+ PREF_LAST_FXA_USER,
+ lazy.CryptoUtils.sha256Base64(acctName)
+ );
+ },
+
+ /**
+ * Open Sync Preferences in the current tab of the browser
+ *
+ * @param {Object} browser the browser in which to open preferences
+ * @param {String} [entryPoint] entryPoint to use for logging
+ */
+ openSyncPreferences(browser, entryPoint) {
+ let uri = "about:preferences";
+ if (entryPoint) {
+ uri += "?entrypoint=" + encodeURIComponent(entryPoint);
+ }
+ uri += "#sync";
+
+ browser.loadURI(Services.io.newURI(uri), {
+ triggeringPrincipal: Services.scriptSecurityManager.getSystemPrincipal(),
+ });
+ },
+
+ /**
+ * Open Firefox View in the browser's window
+ *
+ * @param {Object} browser the browser in whose window we'll open Firefox View
+ */
+ openFirefoxView(browser) {
+ browser.ownerGlobal.FirefoxViewHandler.openTab("syncedtabs");
+ },
+
+ /**
+ * If a user signs in using a different account, the data from the
+ * previous account and the new account will be merged. Ask the user
+ * if they want to continue.
+ *
+ * @private
+ */
+ _needRelinkWarning(acctName) {
+ let prevAcctHash = this.getPreviousAccountNameHashPref();
+ return (
+ prevAcctHash && prevAcctHash != lazy.CryptoUtils.sha256Base64(acctName)
+ );
+ },
+
+ /**
+ * Show the user a warning dialog that the data from the previous account
+ * and the new account will be merged.
+ *
+ * @private
+ */
+ _promptForRelink(acctName) {
+ let sb = Services.strings.createBundle(
+ "chrome://browser/locale/syncSetup.properties"
+ );
+ let continueLabel = sb.GetStringFromName("continue.label");
+ let title = sb.GetStringFromName("relinkVerify.title");
+ let description = sb.formatStringFromName("relinkVerify.description", [
+ acctName,
+ ]);
+ let body =
+ sb.GetStringFromName("relinkVerify.heading") + "\n\n" + description;
+ let ps = Services.prompt;
+ let buttonFlags =
+ ps.BUTTON_POS_0 * ps.BUTTON_TITLE_IS_STRING +
+ ps.BUTTON_POS_1 * ps.BUTTON_TITLE_CANCEL +
+ ps.BUTTON_POS_1_DEFAULT;
+
+ // If running in context of the browser chrome, window does not exist.
+ let pressed = Services.prompt.confirmEx(
+ null,
+ title,
+ body,
+ buttonFlags,
+ continueLabel,
+ null,
+ null,
+ null,
+ {}
+ );
+ return pressed === 0; // 0 is the "continue" button
+ },
+};
+
+var singleton;
+
+// The entry-point for this module, which ensures only one of our channels is
+// ever created - we require this because the WebChannel is global in scope
+// (eg, it uses the observer service to tell interested parties of interesting
+// things) and allowing multiple channels would cause such notifications to be
+// sent multiple times.
+export var EnsureFxAccountsWebChannel = () => {
+ let contentUri = Services.urlFormatter.formatURLPref(
+ "identity.fxaccounts.remote.root"
+ );
+ if (singleton && singleton._contentUri !== contentUri) {
+ singleton.tearDown();
+ singleton = null;
+ }
+ if (!singleton) {
+ try {
+ if (contentUri) {
+ // The FxAccountsWebChannel listens for events and updates
+ // the state machine accordingly.
+ singleton = new FxAccountsWebChannel({
+ content_uri: contentUri,
+ channel_id: WEBCHANNEL_ID,
+ });
+ } else {
+ log.warn("FxA WebChannel functionaly is disabled due to no URI pref.");
+ }
+ } catch (ex) {
+ log.error("Failed to create FxA WebChannel", ex);
+ }
+ }
+};
diff --git a/services/fxaccounts/components.conf b/services/fxaccounts/components.conf
new file mode 100644
index 0000000000..992c88d0cb
--- /dev/null
+++ b/services/fxaccounts/components.conf
@@ -0,0 +1,16 @@
+# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+Classes = [
+ {
+ 'cid': '{1b7db999-2ecd-4abf-bb95-a726896798ca}',
+ 'contract_ids': ['@mozilla.org/fxaccounts/push;1'],
+ 'esModule': 'resource://gre/modules/FxAccountsPush.sys.mjs',
+ 'constructor': 'FxAccountsPushService',
+ 'processes': ProcessSelector.MAIN_PROCESS_ONLY,
+ 'categories': {'push': 'chrome://fxa-device-update'},
+ },
+]
diff --git a/services/fxaccounts/moz.build b/services/fxaccounts/moz.build
new file mode 100644
index 0000000000..0047122c2d
--- /dev/null
+++ b/services/fxaccounts/moz.build
@@ -0,0 +1,38 @@
+# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+with Files("**"):
+ BUG_COMPONENT = ("Firefox", "Firefox Accounts")
+
+MOCHITEST_CHROME_MANIFESTS += ["tests/mochitest/chrome.toml"]
+
+BROWSER_CHROME_MANIFESTS += ["tests/browser/browser.toml"]
+
+XPCSHELL_TESTS_MANIFESTS += ["tests/xpcshell/xpcshell.toml"]
+
+EXTRA_JS_MODULES += [
+ "Credentials.sys.mjs",
+ "FxAccounts.sys.mjs",
+ "FxAccountsClient.sys.mjs",
+ "FxAccountsCommands.sys.mjs",
+ "FxAccountsCommon.sys.mjs",
+ "FxAccountsConfig.sys.mjs",
+ "FxAccountsDevice.sys.mjs",
+ "FxAccountsKeys.sys.mjs",
+ "FxAccountsOAuth.sys.mjs",
+ "FxAccountsPairing.sys.mjs",
+ "FxAccountsPairingChannel.sys.mjs",
+ "FxAccountsProfile.sys.mjs",
+ "FxAccountsProfileClient.sys.mjs",
+ "FxAccountsPush.sys.mjs",
+ "FxAccountsStorage.sys.mjs",
+ "FxAccountsTelemetry.sys.mjs",
+ "FxAccountsWebChannel.sys.mjs",
+]
+
+XPCOM_MANIFESTS += [
+ "components.conf",
+]
diff --git a/services/fxaccounts/tests/browser/browser.toml b/services/fxaccounts/tests/browser/browser.toml
new file mode 100644
index 0000000000..7cb752b59a
--- /dev/null
+++ b/services/fxaccounts/tests/browser/browser.toml
@@ -0,0 +1,6 @@
+[DEFAULT]
+support-files = ["head.js"]
+
+["browser_device_connected.js"]
+
+["browser_verify_login.js"]
diff --git a/services/fxaccounts/tests/browser/browser_device_connected.js b/services/fxaccounts/tests/browser/browser_device_connected.js
new file mode 100644
index 0000000000..8e567ddf35
--- /dev/null
+++ b/services/fxaccounts/tests/browser/browser_device_connected.js
@@ -0,0 +1,51 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+const { FxAccounts } = ChromeUtils.importESModule(
+ "resource://gre/modules/FxAccounts.sys.mjs"
+);
+
+const gBrowserGlue = Cc["@mozilla.org/browser/browserglue;1"].getService(
+ Ci.nsIObserver
+);
+const DEVICES_URL = "https://example.com/devices";
+
+add_setup(async function () {
+ const origManageDevicesURI = FxAccounts.config.promiseManageDevicesURI;
+ FxAccounts.config.promiseManageDevicesURI = () =>
+ Promise.resolve(DEVICES_URL);
+ setupMockAlertsService();
+
+ registerCleanupFunction(function () {
+ FxAccounts.config.promiseManageDevicesURI = origManageDevicesURI;
+ delete window.FxAccounts;
+ });
+});
+
+async function testDeviceConnected(deviceName) {
+ info("testDeviceConnected with deviceName=" + deviceName);
+ BrowserTestUtils.startLoadingURIString(
+ gBrowser.selectedBrowser,
+ "about:mozilla"
+ );
+ await waitForDocLoadComplete();
+
+ let waitForTabPromise = BrowserTestUtils.waitForNewTab(gBrowser);
+
+ Services.obs.notifyObservers(null, "fxaccounts:device_connected", deviceName);
+
+ let tab = await waitForTabPromise;
+ Assert.ok("Tab successfully opened");
+
+ Assert.equal(tab.linkedBrowser.currentURI.spec, DEVICES_URL);
+
+ BrowserTestUtils.removeTab(tab);
+}
+
+add_task(async function () {
+ await testDeviceConnected("My phone");
+});
+
+add_task(async function () {
+ await testDeviceConnected(null);
+});
diff --git a/services/fxaccounts/tests/browser/browser_verify_login.js b/services/fxaccounts/tests/browser/browser_verify_login.js
new file mode 100644
index 0000000000..fa9d952a52
--- /dev/null
+++ b/services/fxaccounts/tests/browser/browser_verify_login.js
@@ -0,0 +1,33 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+add_task(async function () {
+ let payload = {
+ data: {
+ deviceName: "Laptop",
+ url: "https://example.com/newLogin",
+ title: "Sign-in Request",
+ body: "New sign-in request from vershwal's Nighty on Intel Mac OS X 10.12",
+ },
+ };
+ info("testVerifyNewSignin");
+ setupMockAlertsService();
+ BrowserTestUtils.startLoadingURIString(
+ gBrowser.selectedBrowser,
+ "about:mozilla"
+ );
+ await waitForDocLoadComplete();
+
+ let waitForTabPromise = BrowserTestUtils.waitForNewTab(gBrowser);
+
+ Services.obs.notifyObservers(
+ null,
+ "fxaccounts:verify_login",
+ JSON.stringify(payload.data)
+ );
+
+ let tab = await waitForTabPromise;
+ Assert.ok("Tab successfully opened");
+ Assert.equal(tab.linkedBrowser.currentURI.spec, payload.data.url);
+ BrowserTestUtils.removeTab(tab);
+});
diff --git a/services/fxaccounts/tests/browser/head.js b/services/fxaccounts/tests/browser/head.js
new file mode 100644
index 0000000000..e9fb8ad0dc
--- /dev/null
+++ b/services/fxaccounts/tests/browser/head.js
@@ -0,0 +1,73 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/**
+ * Waits for the next load to complete in any browser or the given browser.
+ * If a <tabbrowser> is given it waits for a load in any of its browsers.
+ *
+ * @return promise
+ */
+function waitForDocLoadComplete(aBrowser = gBrowser) {
+ return new Promise(resolve => {
+ let listener = {
+ onStateChange(webProgress, req, flags, status) {
+ let docStop =
+ Ci.nsIWebProgressListener.STATE_IS_NETWORK |
+ Ci.nsIWebProgressListener.STATE_STOP;
+ info(
+ "Saw state " +
+ flags.toString(16) +
+ " and status " +
+ status.toString(16)
+ );
+
+ // When a load needs to be retargetted to a new process it is cancelled
+ // with NS_BINDING_ABORTED so ignore that case
+ if ((flags & docStop) == docStop && status != Cr.NS_BINDING_ABORTED) {
+ aBrowser.removeProgressListener(this);
+ waitForDocLoadComplete.listeners.delete(this);
+
+ let chan = req.QueryInterface(Ci.nsIChannel);
+ info("Browser loaded " + chan.originalURI.spec);
+ resolve();
+ }
+ },
+ QueryInterface: ChromeUtils.generateQI([
+ "nsIWebProgressListener",
+ "nsISupportsWeakReference",
+ ]),
+ };
+ aBrowser.addProgressListener(listener);
+ waitForDocLoadComplete.listeners.add(listener);
+ info("Waiting for browser load");
+ });
+}
+
+function setupMockAlertsService() {
+ const alertsService = {
+ showAlertNotification: (
+ image,
+ title,
+ text,
+ clickable,
+ cookie,
+ clickCallback
+ ) => {
+ // We are invoking the event handler ourselves directly.
+ clickCallback(null, "alertclickcallback", null);
+ },
+ };
+ const gBrowserGlue = Cc["@mozilla.org/browser/browserglue;1"].getService(
+ Ci.nsIObserver
+ );
+ gBrowserGlue.observe(
+ { wrappedJSObject: alertsService },
+ "browser-glue-test",
+ "mock-alerts-service"
+ );
+}
+
+// Keep a set of progress listeners for waitForDocLoadComplete() to make sure
+// they're not GC'ed before we saw the page load.
+waitForDocLoadComplete.listeners = new Set();
+registerCleanupFunction(() => waitForDocLoadComplete.listeners.clear());
diff --git a/services/fxaccounts/tests/mochitest/chrome.toml b/services/fxaccounts/tests/mochitest/chrome.toml
new file mode 100644
index 0000000000..5e88133317
--- /dev/null
+++ b/services/fxaccounts/tests/mochitest/chrome.toml
@@ -0,0 +1,5 @@
+[DEFAULT]
+skip-if = ["os == 'android'"]
+support-files = ["file_invalidEmailCase.sjs"]
+
+["test_invalidEmailCase.html"]
diff --git a/services/fxaccounts/tests/mochitest/file_invalidEmailCase.sjs b/services/fxaccounts/tests/mochitest/file_invalidEmailCase.sjs
new file mode 100644
index 0000000000..971cf52bba
--- /dev/null
+++ b/services/fxaccounts/tests/mochitest/file_invalidEmailCase.sjs
@@ -0,0 +1,81 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/**
+ * This server simulates the behavior of /account/login on the Firefox Accounts
+ * auth server in the case where the user is trying to sign in with an email
+ * with the wrong capitalization.
+ *
+ * https://github.com/mozilla/fxa-auth-server/blob/master/docs/api.md#post-v1accountlogin
+ *
+ * The expected behavior is that on the first attempt, with the wrong email,
+ * the server will respond with a 400 and the canonical email capitalization
+ * that the client should use. The client then has one chance to sign in with
+ * this different capitalization.
+ *
+ * In this test, the user with the account id "Greta.Garbo@gmail.COM" initially
+ * tries to sign in as "greta.garbo@gmail.com".
+ *
+ * On success, the client is responsible for updating its sign-in user state
+ * and recording the proper email capitalization.
+ */
+
+const CC = Components.Constructor;
+const BinaryInputStream = CC(
+ "@mozilla.org/binaryinputstream;1",
+ "nsIBinaryInputStream",
+ "setInputStream"
+);
+
+const goodEmail = "Greta.Garbo@gmail.COM";
+const badEmail = "greta.garbo@gmail.com";
+
+function handleRequest(request, response) {
+ let body = new BinaryInputStream(request.bodyInputStream);
+ let bytes = [];
+ let available;
+ while ((available = body.available()) > 0) {
+ Array.prototype.push.apply(bytes, body.readByteArray(available));
+ }
+
+ let data = JSON.parse(String.fromCharCode.apply(null, bytes));
+ let message;
+
+ switch (data.email) {
+ case badEmail:
+ // Almost - try again with fixed email case
+ message = {
+ code: 400,
+ errno: 120,
+ error: "Incorrect email case",
+ email: goodEmail,
+ };
+ response.setStatusLine(request.httpVersion, 400, "Almost");
+ break;
+
+ case goodEmail:
+ // Successful login.
+ message = {
+ uid: "your-uid",
+ sessionToken: "your-sessionToken",
+ keyFetchToken: "your-keyFetchToken",
+ verified: true,
+ authAt: 1392144866,
+ };
+ response.setStatusLine(request.httpVersion, 200, "Yay");
+ break;
+
+ default:
+ // Anything else happening in this test is a failure.
+ message = {
+ code: 400,
+ errno: 999,
+ error: "What happened!?",
+ };
+ response.setStatusLine(request.httpVersion, 400, "Ouch");
+ break;
+ }
+
+ let messageStr = JSON.stringify(message);
+ response.bodyOutputStream.write(messageStr, messageStr.length);
+}
diff --git a/services/fxaccounts/tests/mochitest/test_invalidEmailCase.html b/services/fxaccounts/tests/mochitest/test_invalidEmailCase.html
new file mode 100644
index 0000000000..4b5e943591
--- /dev/null
+++ b/services/fxaccounts/tests/mochitest/test_invalidEmailCase.html
@@ -0,0 +1,129 @@
+<!--
+ Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/
+-->
+<!DOCTYPE HTML>
+<html>
+<!--
+Tests for Firefox Accounts signin with invalid email case
+https://bugzilla.mozilla.org/show_bug.cgi?id=963835
+-->
+<head>
+ <title>Test for Firefox Accounts (Bug 963835)</title>
+ <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script>
+ <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css" />
+</head>
+<body>
+
+<a target="_blank" href="https://bugzilla.mozilla.org/show_bug.cgi?id=963835">Mozilla Bug 963835</a>
+<p id="display"></p>
+<div id="content" style="display: none">
+ Test for correction of invalid email case in Fx Accounts signIn
+</div>
+<pre id="test">
+<script class="testbody" type="text/javascript">
+
+SimpleTest.waitForExplicitFinish();
+
+const {FxAccounts} = ChromeUtils.importESModule(
+ "resource://gre/modules/FxAccounts.sys.mjs"
+);
+const {FxAccountsClient} = ChromeUtils.importESModule(
+ "resource://gre/modules/FxAccountsClient.sys.mjs"
+);
+
+const TEST_SERVER =
+ "http://mochi.test:8888/chrome/services/fxaccounts/tests/mochitest/file_invalidEmailCase.sjs?path=";
+
+let MockStorage = function() {
+ this.data = null;
+};
+MockStorage.prototype = Object.freeze({
+ set(contents) {
+ this.data = contents;
+ return Promise.resolve(null);
+ },
+ get() {
+ return Promise.resolve(this.data);
+ },
+ getOAuthTokens() {
+ return Promise.resolve(null);
+ },
+ setOAuthTokens(contents) {
+ return Promise.resolve();
+ },
+});
+
+function MockFxAccounts() {
+ return new FxAccounts({
+ _now_is: new Date(),
+
+ now() {
+ return this._now_is;
+ },
+
+ signedInUserStorage: new MockStorage(),
+
+ fxAccountsClient: new FxAccountsClient(TEST_SERVER),
+ });
+}
+
+let wrongEmail = "greta.garbo@gmail.com";
+let rightEmail = "Greta.Garbo@gmail.COM";
+let password = "123456";
+
+function runTest() {
+ is(Services.prefs.getStringPref("identity.fxaccounts.auth.uri"), TEST_SERVER,
+ "Pref for auth.uri should be set to test server");
+
+ let fxa = new MockFxAccounts();
+ let client = fxa._internal.fxAccountsClient;
+
+ is(true, !!fxa, "Couldn't mock fxa");
+ is(true, !!client, "Couldn't mock fxa client");
+ is(client.host, TEST_SERVER, "Should be using the test auth server uri");
+
+ // First try to sign in using the email with the wrong capitalization. The
+ // FxAccountsClient will receive a 400 from the server with the corrected email.
+ // It will automatically try to sign in again. We expect this to succeed.
+ client.signIn(wrongEmail, password).then(
+ user => {
+ // Now store the signed-in user state. This will include the correct
+ // email capitalization.
+ fxa._internal.setSignedInUser(user).then(
+ () => {
+ // Confirm that the correct email got stored.
+ fxa.getSignedInUser().then(
+ data => {
+ is(data.email, rightEmail);
+ SimpleTest.finish();
+ },
+ getUserError => {
+ ok(false, JSON.stringify(getUserError));
+ }
+ );
+ },
+ setSignedInUserError => {
+ ok(false, JSON.stringify(setSignedInUserError));
+ }
+ );
+ },
+ signInError => {
+ ok(false, JSON.stringify(signInError));
+ }
+ );
+}
+
+SpecialPowers.pushPrefEnv({"set": [
+ ["identity.fxaccounts.enabled", true], // fx accounts
+ ["identity.fxaccounts.auth.uri", TEST_SERVER], // our sjs server
+ ["browser.dom.window.dump.enabled", true],
+ ["devtools.console.stdout.chrome", true],
+ ]},
+ function() { runTest(); }
+);
+
+</script>
+</pre>
+</body>
+</html>
diff --git a/services/fxaccounts/tests/xpcshell/head.js b/services/fxaccounts/tests/xpcshell/head.js
new file mode 100644
index 0000000000..921888e2e3
--- /dev/null
+++ b/services/fxaccounts/tests/xpcshell/head.js
@@ -0,0 +1,38 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/* import-globals-from ../../../common/tests/unit/head_helpers.js */
+/* import-globals-from ../../../common/tests/unit/head_http.js */
+
+"use strict";
+
+const { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+const { sinon } = ChromeUtils.importESModule(
+ "resource://testing-common/Sinon.sys.mjs"
+);
+const { SCOPE_OLD_SYNC } = ChromeUtils.importESModule(
+ "resource://gre/modules/FxAccountsCommon.sys.mjs"
+);
+
+// Some mock key data, in both scoped-key and legacy field formats.
+const MOCK_ACCOUNT_KEYS = {
+ scopedKeys: {
+ [SCOPE_OLD_SYNC]: {
+ kid: "1234567890123-u7u7u7u7u7u7u7u7u7u7uw",
+ k: "qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqg",
+ kty: "oct",
+ },
+ },
+};
+
+(function initFxAccountsTestingInfrastructure() {
+ do_get_profile();
+
+ let { initTestLogging } = ChromeUtils.importESModule(
+ "resource://testing-common/services/common/logging.sys.mjs"
+ );
+
+ initTestLogging("Trace");
+}).call(this);
diff --git a/services/fxaccounts/tests/xpcshell/test_accounts.js b/services/fxaccounts/tests/xpcshell/test_accounts.js
new file mode 100644
index 0000000000..c4aec73a03
--- /dev/null
+++ b/services/fxaccounts/tests/xpcshell/test_accounts.js
@@ -0,0 +1,1642 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { CryptoUtils } = ChromeUtils.importESModule(
+ "resource://services-crypto/utils.sys.mjs"
+);
+const { FxAccounts } = ChromeUtils.importESModule(
+ "resource://gre/modules/FxAccounts.sys.mjs"
+);
+const { FxAccountsClient } = ChromeUtils.importESModule(
+ "resource://gre/modules/FxAccountsClient.sys.mjs"
+);
+const {
+ ERRNO_INVALID_AUTH_TOKEN,
+ ERROR_NO_ACCOUNT,
+ FX_OAUTH_CLIENT_ID,
+ ONLOGIN_NOTIFICATION,
+ ONLOGOUT_NOTIFICATION,
+ ONVERIFIED_NOTIFICATION,
+ DEPRECATED_SCOPE_ECOSYSTEM_TELEMETRY,
+ PREF_LAST_FXA_USER,
+} = ChromeUtils.importESModule(
+ "resource://gre/modules/FxAccountsCommon.sys.mjs"
+);
+
+// We grab some additional stuff via backstage passes.
+var { AccountState } = ChromeUtils.importESModule(
+ "resource://gre/modules/FxAccounts.sys.mjs"
+);
+
+const MOCK_TOKEN_RESPONSE = {
+ access_token:
+ "43793fdfffec22eb39fc3c44ed09193a6fde4c24e5d6a73f73178597b268af69",
+ token_type: "bearer",
+ scope: "https://identity.mozilla.com/apps/oldsync",
+ expires_in: 21600,
+ auth_at: 1589579900,
+};
+
+initTestLogging("Trace");
+
+var log = Log.repository.getLogger("Services.FxAccounts.test");
+log.level = Log.Level.Debug;
+
+// See verbose logging from FxAccounts.jsm and jwcrypto.jsm.
+Services.prefs.setStringPref("identity.fxaccounts.loglevel", "Trace");
+Log.repository.getLogger("FirefoxAccounts").level = Log.Level.Trace;
+Services.prefs.setStringPref("services.crypto.jwcrypto.log.level", "Debug");
+
+/*
+ * The FxAccountsClient communicates with the remote Firefox
+ * Accounts auth server. Mock the server calls, with a little
+ * lag time to simulate some latency.
+ *
+ * We add the _verified attribute to mock the change in verification
+ * state on the FXA server.
+ */
+
+function MockStorageManager() {}
+
+MockStorageManager.prototype = {
+ promiseInitialized: Promise.resolve(),
+
+ initialize(accountData) {
+ this.accountData = accountData;
+ },
+
+ finalize() {
+ return Promise.resolve();
+ },
+
+ getAccountData(fields = null) {
+ let result;
+ if (!this.accountData) {
+ result = null;
+ } else if (fields == null) {
+ // can't use cloneInto as the keys get upset...
+ result = {};
+ for (let field of Object.keys(this.accountData)) {
+ result[field] = this.accountData[field];
+ }
+ } else {
+ if (!Array.isArray(fields)) {
+ fields = [fields];
+ }
+ result = {};
+ for (let field of fields) {
+ result[field] = this.accountData[field];
+ }
+ }
+ return Promise.resolve(result);
+ },
+
+ updateAccountData(updatedFields) {
+ if (!this.accountData) {
+ return Promise.resolve();
+ }
+ for (let [name, value] of Object.entries(updatedFields)) {
+ if (value == null) {
+ delete this.accountData[name];
+ } else {
+ this.accountData[name] = value;
+ }
+ }
+ return Promise.resolve();
+ },
+
+ deleteAccountData() {
+ this.accountData = null;
+ return Promise.resolve();
+ },
+};
+
+function MockFxAccountsClient() {
+ this._email = "nobody@example.com";
+ this._verified = false;
+ this._deletedOnServer = false; // for our accountStatus mock
+
+ // mock calls up to the auth server to determine whether the
+ // user account has been verified
+ this.recoveryEmailStatus = async function (sessionToken) {
+ // simulate a call to /recovery_email/status
+ return {
+ email: this._email,
+ verified: this._verified,
+ };
+ };
+
+ this.accountStatus = async function (uid) {
+ return !!uid && !this._deletedOnServer;
+ };
+
+ this.sessionStatus = async function () {
+ // If the sessionStatus check says an account is OK, we typically will not
+ // end up calling accountStatus - so this must return false if accountStatus
+ // would.
+ return !this._deletedOnServer;
+ };
+
+ this.accountKeys = function (keyFetchToken) {
+ return new Promise(resolve => {
+ do_timeout(50, () => {
+ resolve({
+ kA: expandBytes("11"),
+ wrapKB: expandBytes("22"),
+ });
+ });
+ });
+ };
+
+ this.getScopedKeyData = function (sessionToken, client_id, scopes) {
+ Assert.ok(sessionToken);
+ Assert.equal(client_id, FX_OAUTH_CLIENT_ID);
+ Assert.equal(scopes, SCOPE_OLD_SYNC);
+ return new Promise(resolve => {
+ do_timeout(50, () => {
+ resolve({
+ "https://identity.mozilla.com/apps/oldsync": {
+ identifier: "https://identity.mozilla.com/apps/oldsync",
+ keyRotationSecret:
+ "0000000000000000000000000000000000000000000000000000000000000000",
+ keyRotationTimestamp: 1234567890123,
+ },
+ });
+ });
+ });
+ };
+
+ this.resendVerificationEmail = function (sessionToken) {
+ // Return the session token to show that we received it in the first place
+ return Promise.resolve(sessionToken);
+ };
+
+ this.signOut = () => Promise.resolve();
+
+ FxAccountsClient.apply(this);
+}
+MockFxAccountsClient.prototype = {};
+Object.setPrototypeOf(
+ MockFxAccountsClient.prototype,
+ FxAccountsClient.prototype
+);
+/*
+ * We need to mock the FxAccounts module's interfaces to external
+ * services, such as storage and the FxAccounts client. We also
+ * mock the now() method, so that we can simulate the passing of
+ * time and verify that signatures expire correctly.
+ */
+function MockFxAccounts(credentials = null) {
+ let result = new FxAccounts({
+ VERIFICATION_POLL_TIMEOUT_INITIAL: 100, // 100ms
+
+ _getCertificateSigned_calls: [],
+ _d_signCertificate: Promise.withResolvers(),
+ _now_is: new Date(),
+ now() {
+ return this._now_is;
+ },
+ newAccountState(newCredentials) {
+ // we use a real accountState but mocked storage.
+ let storage = new MockStorageManager();
+ storage.initialize(newCredentials);
+ return new AccountState(storage);
+ },
+ fxAccountsClient: new MockFxAccountsClient(),
+ observerPreloads: [],
+ device: {
+ _registerOrUpdateDevice() {},
+ _checkRemoteCommandsUpdateNeeded: async () => false,
+ },
+ profile: {
+ getProfile() {
+ return null;
+ },
+ },
+ });
+ // and for convenience so we don't have to touch as many lines in this test
+ // when we refactored FxAccounts.jsm :)
+ result.setSignedInUser = function (creds) {
+ return result._internal.setSignedInUser(creds);
+ };
+ return result;
+}
+
+/*
+ * Some tests want a "real" fxa instance - however, we still mock the storage
+ * to keep the tests fast on b2g.
+ */
+async function MakeFxAccounts({ internal = {}, credentials } = {}) {
+ if (!internal.newAccountState) {
+ // we use a real accountState but mocked storage.
+ internal.newAccountState = function (newCredentials) {
+ let storage = new MockStorageManager();
+ storage.initialize(newCredentials);
+ return new AccountState(storage);
+ };
+ }
+ if (!internal._signOutServer) {
+ internal._signOutServer = () => Promise.resolve();
+ }
+ if (internal.device) {
+ if (!internal.device._registerOrUpdateDevice) {
+ internal.device._registerOrUpdateDevice = () => Promise.resolve();
+ internal.device._checkRemoteCommandsUpdateNeeded = async () => false;
+ }
+ } else {
+ internal.device = {
+ _registerOrUpdateDevice() {},
+ _checkRemoteCommandsUpdateNeeded: async () => false,
+ };
+ }
+ if (!internal.observerPreloads) {
+ internal.observerPreloads = [];
+ }
+ let result = new FxAccounts(internal);
+
+ if (credentials) {
+ await result._internal.setSignedInUser(credentials);
+ }
+ return result;
+}
+
+add_task(async function test_get_signed_in_user_initially_unset() {
+ _("Check getSignedInUser initially and after signout reports no user");
+ let account = await MakeFxAccounts();
+ let credentials = {
+ email: "foo@example.com",
+ uid: "1234567890abcdef1234567890abcdef",
+ sessionToken: "dead",
+ verified: true,
+ ...MOCK_ACCOUNT_KEYS,
+ };
+ let result = await account.getSignedInUser();
+ Assert.equal(result, null);
+
+ await account._internal.setSignedInUser(credentials);
+
+ // getSignedInUser only returns a subset.
+ result = await account.getSignedInUser();
+ Assert.deepEqual(result.email, credentials.email);
+ Assert.deepEqual(result.scopedKeys, undefined);
+
+ // for the sake of testing, use the low-level function to check it's all there
+ result = await account._internal.currentAccountState.getUserAccountData();
+ Assert.deepEqual(result.email, credentials.email);
+ Assert.deepEqual(result.scopedKeys, credentials.scopedKeys);
+
+ // sign out
+ let localOnly = true;
+ await account.signOut(localOnly);
+
+ // user should be undefined after sign out
+ result = await account.getSignedInUser();
+ Assert.equal(result, null);
+});
+
+add_task(async function test_set_signed_in_user_signs_out_previous_account() {
+ _("Check setSignedInUser signs out the previous account.");
+ let signOutServerCalled = false;
+ let credentials = {
+ email: "foo@example.com",
+ uid: "1234567890abcdef1234567890abcdef",
+ sessionToken: "dead",
+ verified: true,
+ ...MOCK_ACCOUNT_KEYS,
+ };
+ let account = await MakeFxAccounts({ credentials });
+
+ account._internal._signOutServer = () => {
+ signOutServerCalled = true;
+ return Promise.resolve(true);
+ };
+
+ await account._internal.setSignedInUser(credentials);
+ Assert.ok(signOutServerCalled);
+});
+
+add_task(async function test_update_account_data() {
+ _("Check updateUserAccountData does the right thing.");
+ let credentials = {
+ email: "foo@example.com",
+ uid: "1234567890abcdef1234567890abcdef",
+ sessionToken: "dead",
+ verified: true,
+ ...MOCK_ACCOUNT_KEYS,
+ };
+ let account = await MakeFxAccounts({ credentials });
+
+ let newCreds = {
+ email: credentials.email,
+ uid: credentials.uid,
+ sessionToken: "alive",
+ };
+ await account._internal.updateUserAccountData(newCreds);
+ Assert.equal(
+ (await account._internal.getUserAccountData()).sessionToken,
+ "alive",
+ "new field value was saved"
+ );
+
+ // but we should fail attempting to change the uid.
+ newCreds = {
+ email: credentials.email,
+ uid: "11111111111111111111222222222222",
+ sessionToken: "alive",
+ };
+ await Assert.rejects(
+ account._internal.updateUserAccountData(newCreds),
+ /The specified credentials aren't for the current user/
+ );
+
+ // should fail without the uid.
+ newCreds = {
+ sessionToken: "alive",
+ };
+ await Assert.rejects(
+ account._internal.updateUserAccountData(newCreds),
+ /The specified credentials have no uid/
+ );
+
+ // and should fail with a field name that's not known by storage.
+ newCreds = {
+ email: credentials.email,
+ uid: "11111111111111111111222222222222",
+ foo: "bar",
+ };
+ await Assert.rejects(
+ account._internal.updateUserAccountData(newCreds),
+ /The specified credentials aren't for the current user/
+ );
+});
+
+// Sanity-check that our mocked client is working correctly
+add_test(function test_client_mock() {
+ let fxa = new MockFxAccounts();
+ let client = fxa._internal.fxAccountsClient;
+ Assert.equal(client._verified, false);
+ Assert.equal(typeof client.signIn, "function");
+
+ // The recoveryEmailStatus function eventually fulfills its promise
+ client.recoveryEmailStatus().then(response => {
+ Assert.equal(response.verified, false);
+ run_next_test();
+ });
+});
+
+// Sign in a user, and after a little while, verify the user's email.
+// Right after signing in the user, we should get the 'onlogin' notification.
+// Polling should detect that the email is verified, and eventually
+// 'onverified' should be observed
+add_test(function test_verification_poll() {
+ let fxa = new MockFxAccounts();
+ let test_user = getTestUser("francine");
+ let login_notification_received = false;
+
+ makeObserver(ONVERIFIED_NOTIFICATION, function () {
+ log.debug("test_verification_poll observed onverified");
+ // Once email verification is complete, we will observe onverified
+ fxa._internal
+ .getUserAccountData()
+ .then(user => {
+ // And confirm that the user's state has changed
+ Assert.equal(user.verified, true);
+ Assert.equal(user.email, test_user.email);
+ Assert.equal(
+ Services.prefs.getStringPref(PREF_LAST_FXA_USER),
+ CryptoUtils.sha256Base64(test_user.email)
+ );
+ Assert.ok(login_notification_received);
+ })
+ .finally(run_next_test);
+ });
+
+ makeObserver(ONLOGIN_NOTIFICATION, function () {
+ log.debug("test_verification_poll observer onlogin");
+ login_notification_received = true;
+ });
+
+ fxa.setSignedInUser(test_user).then(() => {
+ fxa._internal.getUserAccountData().then(user => {
+ // The user is signing in, but email has not been verified yet
+ Assert.equal(user.verified, false);
+ do_timeout(200, function () {
+ log.debug("Mocking verification of francine's email");
+ fxa._internal.fxAccountsClient._email = test_user.email;
+ fxa._internal.fxAccountsClient._verified = true;
+ });
+ });
+ });
+});
+
+// Sign in the user, but never verify the email. The check-email
+// poll should time out. No verifiedlogin event should be observed, and the
+// internal whenVerified promise should be rejected
+add_test(function test_polling_timeout() {
+ // This test could be better - the onverified observer might fire on
+ // somebody else's stack, and we're not making sure that we're not receiving
+ // such a message. In other words, this tests either failure, or success, but
+ // not both.
+
+ let fxa = new MockFxAccounts();
+ let test_user = getTestUser("carol");
+
+ let removeObserver = makeObserver(ONVERIFIED_NOTIFICATION, function () {
+ do_throw("We should not be getting a login event!");
+ });
+
+ fxa._internal.POLL_SESSION = 1;
+
+ let p = fxa._internal.whenVerified({});
+
+ fxa.setSignedInUser(test_user).then(() => {
+ p.then(
+ success => {
+ do_throw("this should not succeed");
+ },
+ fail => {
+ removeObserver();
+ fxa.signOut().then(run_next_test);
+ }
+ );
+ });
+});
+
+// For bug 1585299 - ensure we only get a single ONVERIFIED notification.
+add_task(async function test_onverified_once() {
+ let fxa = new MockFxAccounts();
+ let user = getTestUser("francine");
+
+ let numNotifications = 0;
+
+ function observe(aSubject, aTopic, aData) {
+ numNotifications += 1;
+ }
+ Services.obs.addObserver(observe, ONVERIFIED_NOTIFICATION);
+
+ fxa._internal.POLL_SESSION = 1;
+
+ await fxa.setSignedInUser(user);
+
+ Assert.ok(!(await fxa.getSignedInUser()).verified, "starts unverified");
+
+ await fxa._internal.startPollEmailStatus(
+ fxa._internal.currentAccountState,
+ user.sessionToken,
+ "start"
+ );
+
+ Assert.ok(!(await fxa.getSignedInUser()).verified, "still unverified");
+
+ log.debug("Mocking verification of francine's email");
+ fxa._internal.fxAccountsClient._email = user.email;
+ fxa._internal.fxAccountsClient._verified = true;
+
+ await fxa._internal.startPollEmailStatus(
+ fxa._internal.currentAccountState,
+ user.sessionToken,
+ "again"
+ );
+
+ Assert.ok((await fxa.getSignedInUser()).verified, "now verified");
+
+ Assert.equal(numNotifications, 1, "expect exactly 1 ONVERIFIED");
+
+ Services.obs.removeObserver(observe, ONVERIFIED_NOTIFICATION);
+ await fxa.signOut();
+});
+
+add_test(function test_pollEmailStatus_start_verified() {
+ let fxa = new MockFxAccounts();
+ let test_user = getTestUser("carol");
+
+ fxa._internal.POLL_SESSION = 20 * 60000;
+ fxa._internal.VERIFICATION_POLL_TIMEOUT_INITIAL = 50000;
+
+ fxa.setSignedInUser(test_user).then(() => {
+ fxa._internal.getUserAccountData().then(user => {
+ fxa._internal.fxAccountsClient._email = test_user.email;
+ fxa._internal.fxAccountsClient._verified = true;
+ const mock = sinon.mock(fxa._internal);
+ mock.expects("_scheduleNextPollEmailStatus").never();
+ fxa._internal
+ .startPollEmailStatus(
+ fxa._internal.currentAccountState,
+ user.sessionToken,
+ "start"
+ )
+ .then(() => {
+ mock.verify();
+ mock.restore();
+ run_next_test();
+ });
+ });
+ });
+});
+
+add_test(function test_pollEmailStatus_start() {
+ let fxa = new MockFxAccounts();
+ let test_user = getTestUser("carol");
+
+ fxa._internal.POLL_SESSION = 20 * 60000;
+ fxa._internal.VERIFICATION_POLL_TIMEOUT_INITIAL = 123456;
+
+ fxa.setSignedInUser(test_user).then(() => {
+ fxa._internal.getUserAccountData().then(user => {
+ const mock = sinon.mock(fxa._internal);
+ mock
+ .expects("_scheduleNextPollEmailStatus")
+ .once()
+ .withArgs(
+ fxa._internal.currentAccountState,
+ user.sessionToken,
+ 123456,
+ "start"
+ );
+ fxa._internal
+ .startPollEmailStatus(
+ fxa._internal.currentAccountState,
+ user.sessionToken,
+ "start"
+ )
+ .then(() => {
+ mock.verify();
+ mock.restore();
+ run_next_test();
+ });
+ });
+ });
+});
+
+add_test(function test_pollEmailStatus_start_subsequent() {
+ let fxa = new MockFxAccounts();
+ let test_user = getTestUser("carol");
+
+ fxa._internal.POLL_SESSION = 20 * 60000;
+ fxa._internal.VERIFICATION_POLL_TIMEOUT_INITIAL = 123456;
+ fxa._internal.VERIFICATION_POLL_TIMEOUT_SUBSEQUENT = 654321;
+ fxa._internal.VERIFICATION_POLL_START_SLOWDOWN_THRESHOLD = -1;
+
+ fxa.setSignedInUser(test_user).then(() => {
+ fxa._internal.getUserAccountData().then(user => {
+ const mock = sinon.mock(fxa._internal);
+ mock
+ .expects("_scheduleNextPollEmailStatus")
+ .once()
+ .withArgs(
+ fxa._internal.currentAccountState,
+ user.sessionToken,
+ 654321,
+ "start"
+ );
+ fxa._internal
+ .startPollEmailStatus(
+ fxa._internal.currentAccountState,
+ user.sessionToken,
+ "start"
+ )
+ .then(() => {
+ mock.verify();
+ mock.restore();
+ run_next_test();
+ });
+ });
+ });
+});
+
+add_test(function test_pollEmailStatus_browser_startup() {
+ let fxa = new MockFxAccounts();
+ let test_user = getTestUser("carol");
+
+ fxa._internal.POLL_SESSION = 20 * 60000;
+ fxa._internal.VERIFICATION_POLL_TIMEOUT_SUBSEQUENT = 654321;
+
+ fxa.setSignedInUser(test_user).then(() => {
+ fxa._internal.getUserAccountData().then(user => {
+ const mock = sinon.mock(fxa._internal);
+ mock
+ .expects("_scheduleNextPollEmailStatus")
+ .once()
+ .withArgs(
+ fxa._internal.currentAccountState,
+ user.sessionToken,
+ 654321,
+ "browser-startup"
+ );
+ fxa._internal
+ .startPollEmailStatus(
+ fxa._internal.currentAccountState,
+ user.sessionToken,
+ "browser-startup"
+ )
+ .then(() => {
+ mock.verify();
+ mock.restore();
+ run_next_test();
+ });
+ });
+ });
+});
+
+add_test(function test_pollEmailStatus_push() {
+ let fxa = new MockFxAccounts();
+ let test_user = getTestUser("carol");
+
+ fxa.setSignedInUser(test_user).then(() => {
+ fxa._internal.getUserAccountData().then(user => {
+ const mock = sinon.mock(fxa._internal);
+ mock.expects("_scheduleNextPollEmailStatus").never();
+ fxa._internal
+ .startPollEmailStatus(
+ fxa._internal.currentAccountState,
+ user.sessionToken,
+ "push"
+ )
+ .then(() => {
+ mock.verify();
+ mock.restore();
+ run_next_test();
+ });
+ });
+ });
+});
+
+add_test(function test_getKeyForScope() {
+ let fxa = new MockFxAccounts();
+ let user = getTestUser("eusebius");
+
+ // Once email has been verified, we will be able to get keys
+ user.verified = true;
+
+ fxa.setSignedInUser(user).then(() => {
+ fxa._internal.getUserAccountData().then(user2 => {
+ // Before getKeyForScope, we have no keys
+ Assert.equal(!!user2.scopedKeys, false);
+ // And we still have a key-fetch token and unwrapBKey to use
+ Assert.equal(!!user2.keyFetchToken, true);
+ Assert.equal(!!user2.unwrapBKey, true);
+
+ fxa.keys.getKeyForScope(SCOPE_OLD_SYNC).then(() => {
+ fxa._internal.getUserAccountData().then(user3 => {
+ // Now we should have keys
+ Assert.equal(fxa._internal.isUserEmailVerified(user3), true);
+ Assert.equal(!!user3.verified, true);
+ Assert.notEqual(null, user3.scopedKeys);
+ Assert.equal(user3.keyFetchToken, undefined);
+ Assert.equal(user3.unwrapBKey, undefined);
+ run_next_test();
+ });
+ });
+ });
+ });
+});
+
+add_task(
+ async function test_getKeyForScope_scopedKeys_migration_removes_deprecated_high_level_keys() {
+ let fxa = new MockFxAccounts();
+ let user = getTestUser("eusebius");
+
+ user.verified = true;
+
+ // An account state with the deprecated kinto extension sync keys...
+ user.kExtSync =
+ "f5ccd9cfdefd9b1ac4d02c56964f59239d8dfa1ca326e63696982765c1352cdc" +
+ "5d78a5a9c633a6d25edfea0a6c221a3480332a49fd866f311c2e3508ddd07395";
+ user.kExtKbHash =
+ "6192f1cc7dce95334455ba135fa1d8fca8f70e8f594ae318528de06f24ed0273";
+ user.scopedKeys = {
+ ...MOCK_ACCOUNT_KEYS.scopedKeys,
+ };
+
+ await fxa.setSignedInUser(user);
+ // getKeyForScope will run the migration
+ await fxa.keys.getKeyForScope(SCOPE_OLD_SYNC);
+ let newUser = await fxa._internal.getUserAccountData();
+ // Then, the deprecated keys will be removed
+ Assert.strictEqual(newUser.kExtSync, undefined);
+ Assert.strictEqual(newUser.kExtKbHash, undefined);
+ }
+);
+
+add_task(
+ async function test_getKeyForScope_scopedKeys_migration_removes_deprecated_scoped_keys() {
+ let fxa = new MockFxAccounts();
+ let user = getTestUser("eusebius");
+ const DEPRECATED_SCOPE_WEBEXT_SYNC = "sync:addon_storage";
+ const EXTRA_SCOPE = "an unknown, but non-deprecated scope";
+ user.verified = true;
+ user.ecosystemUserId = "ecoUserId";
+ user.ecosystemAnonId = "ecoAnonId";
+ user.scopedKeys = {
+ ...MOCK_ACCOUNT_KEYS.scopedKeys,
+ [DEPRECATED_SCOPE_ECOSYSTEM_TELEMETRY]:
+ MOCK_ACCOUNT_KEYS.scopedKeys[SCOPE_OLD_SYNC],
+ [DEPRECATED_SCOPE_WEBEXT_SYNC]:
+ MOCK_ACCOUNT_KEYS.scopedKeys[SCOPE_OLD_SYNC],
+ [EXTRA_SCOPE]: MOCK_ACCOUNT_KEYS.scopedKeys[SCOPE_OLD_SYNC],
+ };
+
+ await fxa.setSignedInUser(user);
+ await fxa.keys.getKeyForScope(SCOPE_OLD_SYNC);
+ let newUser = await fxa._internal.getUserAccountData();
+ // It should have removed the deprecated ecosystem_telemetry key,
+ // and the old kinto extension sync key
+ // but left the other keys intact.
+ const expectedScopedKeys = {
+ ...MOCK_ACCOUNT_KEYS.scopedKeys,
+ [EXTRA_SCOPE]: MOCK_ACCOUNT_KEYS.scopedKeys[SCOPE_OLD_SYNC],
+ };
+ Assert.deepEqual(newUser.scopedKeys, expectedScopedKeys);
+ Assert.equal(newUser.ecosystemUserId, null);
+ Assert.equal(newUser.ecosystemAnonId, null);
+ }
+);
+
+add_task(async function test_getKeyForScope_nonexistent_account() {
+ let fxa = new MockFxAccounts();
+ let bismarck = getTestUser("bismarck");
+
+ let client = fxa._internal.fxAccountsClient;
+ client.accountStatus = () => Promise.resolve(false);
+ client.sessionStatus = () => Promise.resolve(false);
+ client.accountKeys = () => {
+ return Promise.reject({
+ code: 401,
+ errno: ERRNO_INVALID_AUTH_TOKEN,
+ });
+ };
+
+ await fxa.setSignedInUser(bismarck);
+
+ let promiseLogout = new Promise(resolve => {
+ makeObserver(ONLOGOUT_NOTIFICATION, function () {
+ log.debug("test_getKeyForScope_nonexistent_account observed logout");
+ resolve();
+ });
+ });
+
+ // XXX - the exception message here isn't ideal, but doesn't really matter...
+ await Assert.rejects(
+ fxa.keys.getKeyForScope(SCOPE_OLD_SYNC),
+ /A different user signed in/
+ );
+
+ await promiseLogout;
+
+ let user = await fxa._internal.getUserAccountData();
+ Assert.equal(user, null);
+});
+
+// getKeyForScope with invalid keyFetchToken should delete keyFetchToken from storage
+add_task(async function test_getKeyForScope_invalid_token() {
+ let fxa = new MockFxAccounts();
+ let yusuf = getTestUser("yusuf");
+
+ let client = fxa._internal.fxAccountsClient;
+ client.accountStatus = () => Promise.resolve(true); // account exists.
+ client.sessionStatus = () => Promise.resolve(false); // session is invalid.
+ client.accountKeys = () => {
+ return Promise.reject({
+ code: 401,
+ errno: ERRNO_INVALID_AUTH_TOKEN,
+ });
+ };
+
+ await fxa.setSignedInUser(yusuf);
+ let user = await fxa._internal.getUserAccountData();
+ Assert.notEqual(user.encryptedSendTabKeys, null);
+
+ try {
+ await fxa.keys.getKeyForScope(SCOPE_OLD_SYNC);
+ Assert.ok(false);
+ } catch (err) {
+ Assert.equal(err.code, 401);
+ Assert.equal(err.errno, ERRNO_INVALID_AUTH_TOKEN);
+ }
+
+ user = await fxa._internal.getUserAccountData();
+ Assert.equal(user.email, yusuf.email);
+ Assert.equal(user.keyFetchToken, null);
+ // We verify that encryptedSendTabKeys are also wiped
+ // when a user's credentials are wiped
+ Assert.equal(user.encryptedSendTabKeys, null);
+ await fxa._internal.abortExistingFlow();
+});
+
+// Test vectors from
+// https://wiki.mozilla.org/Identity/AttachedServices/KeyServerProtocol#Test_Vectors
+add_task(async function test_getKeyForScope_oldsync() {
+ let fxa = new MockFxAccounts();
+ let client = fxa._internal.fxAccountsClient;
+ client.getScopedKeyData = () =>
+ Promise.resolve({
+ "https://identity.mozilla.com/apps/oldsync": {
+ identifier: "https://identity.mozilla.com/apps/oldsync",
+ keyRotationSecret:
+ "0000000000000000000000000000000000000000000000000000000000000000",
+ keyRotationTimestamp: 1510726317123,
+ },
+ });
+
+ // We mock the server returning the wrapKB from our test vectors
+ client.accountKeys = async () => {
+ return {
+ wrapKB: CommonUtils.hexToBytes(
+ "404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f"
+ ),
+ };
+ };
+
+ // We set the user to have the keyFetchToken and unwrapBKey from our test vectors
+ let user = {
+ ...getTestUser("eusebius"),
+ uid: "aeaa1725c7a24ff983c6295725d5fc9b",
+ keyFetchToken:
+ "808182838485868788898a8b8c8d8e8f909192939495969798999a9b9c9d9e9f",
+ unwrapBKey:
+ "6ea660be9c89ec355397f89afb282ea0bf21095760c8c5009bbcc894155bbe2a",
+ sessionToken: "mock session token, used in metadata request",
+ verified: true,
+ };
+ await fxa.setSignedInUser(user);
+
+ // We derive, persist and return the sync key
+ const key = await fxa.keys.getKeyForScope(SCOPE_OLD_SYNC);
+
+ // We verify the key returned matches what we would expect from the test vectors
+ // kb = 2ee722fdd8ccaa721bdeb2d1b76560efef705b04349d9357c3e592cf4906e075 (from test vectors)
+ //
+ // kid can be verified by "${keyRotationTimestamp}-${sha256(kb)[0:16]}"
+ //
+ // k can be verified by HKDF(kb, undefined, "identity.mozilla.com/picl/v1/oldsync", 64)
+ Assert.deepEqual(key, {
+ scope: SCOPE_OLD_SYNC,
+ kid: "1510726317123-BAik7hEOdpGnPZnPBSdaTg",
+ k: "fwM5VZu0Spf5XcFRZYX2zk6MrqZP7zvovCBcvuKwgYMif3hz98FHmIVa3qjKjrW0J244Zj-P5oWaOcQbvypmpw",
+ kty: "oct",
+ });
+});
+
+add_task(async function test_getScopedKeys_cached_key() {
+ let fxa = new MockFxAccounts();
+ let user = {
+ ...getTestUser("eusebius"),
+ uid: "aeaa1725c7a24ff983c6295725d5fc9b",
+ verified: true,
+ ...MOCK_ACCOUNT_KEYS,
+ };
+
+ await fxa.setSignedInUser(user);
+ let key = await fxa.keys.getKeyForScope(SCOPE_OLD_SYNC);
+ Assert.deepEqual(key, {
+ scope: SCOPE_OLD_SYNC,
+ ...MOCK_ACCOUNT_KEYS.scopedKeys[SCOPE_OLD_SYNC],
+ });
+});
+
+add_task(async function test_getScopedKeys_unavailable_scope() {
+ let fxa = new MockFxAccounts();
+ let user = {
+ ...getTestUser("eusebius"),
+ uid: "aeaa1725c7a24ff983c6295725d5fc9b",
+ verified: true,
+ ...MOCK_ACCOUNT_KEYS,
+ };
+ await fxa.setSignedInUser(user);
+ await Assert.rejects(
+ fxa.keys.getKeyForScope("otherkeybearingscope"),
+ /Key not available for scope/
+ );
+});
+
+add_task(async function test_getScopedKeys_misconfigured_fxa_server() {
+ let fxa = new MockFxAccounts();
+ let client = fxa._internal.fxAccountsClient;
+ client.getScopedKeyData = () =>
+ Promise.resolve({
+ wrongscope: {
+ identifier: "wrongscope",
+ keyRotationSecret:
+ "0000000000000000000000000000000000000000000000000000000000000000",
+ keyRotationTimestamp: 1510726331712,
+ },
+ });
+ let user = {
+ ...getTestUser("eusebius"),
+ uid: "aeaa1725c7a24ff983c6295725d5fc9b",
+ verified: true,
+ keyFetchToken:
+ "808182838485868788898a8b8c8d8e8f909192939495969798999a9b9c9d9e9f",
+ unwrapBKey:
+ "6ea660be9c89ec355397f89afb282ea0bf21095760c8c5009bbcc894155bbe2a",
+ sessionToken: "mock session token, used in metadata request",
+ };
+ await fxa.setSignedInUser(user);
+ await Assert.rejects(
+ fxa.keys.getKeyForScope(SCOPE_OLD_SYNC),
+ /The FxA server did not grant Firefox the `oldsync` scope/
+ );
+});
+
+add_task(async function test_setScopedKeys() {
+ const fxa = new MockFxAccounts();
+ const user = {
+ ...getTestUser("foo"),
+ verified: true,
+ };
+ await fxa.setSignedInUser(user);
+ await fxa.keys.setScopedKeys(MOCK_ACCOUNT_KEYS.scopedKeys);
+ const key = await fxa.keys.getKeyForScope(SCOPE_OLD_SYNC);
+ Assert.deepEqual(key, {
+ scope: SCOPE_OLD_SYNC,
+ ...MOCK_ACCOUNT_KEYS.scopedKeys[SCOPE_OLD_SYNC],
+ });
+});
+
+add_task(async function test_setScopedKeys_user_not_signed_in() {
+ const fxa = new MockFxAccounts();
+ await Assert.rejects(
+ fxa.keys.setScopedKeys(MOCK_ACCOUNT_KEYS.scopedKeys),
+ /Cannot persist keys, no user signed in/
+ );
+});
+
+// _fetchAndUnwrapAndDeriveKeys with no keyFetchToken should trigger signOut
+// XXX - actually, it probably shouldn't - bug 1572313.
+add_test(function test_fetchAndUnwrapAndDeriveKeys_no_token() {
+ let fxa = new MockFxAccounts();
+ let user = getTestUser("lettuce.protheroe");
+ delete user.keyFetchToken;
+
+ makeObserver(ONLOGOUT_NOTIFICATION, function () {
+ log.debug("test_fetchAndUnwrapKeys_no_token observed logout");
+ fxa._internal.getUserAccountData().then(user2 => {
+ fxa._internal.abortExistingFlow().then(run_next_test);
+ });
+ });
+
+ fxa
+ .setSignedInUser(user)
+ .then(user2 => {
+ return fxa.keys._fetchAndUnwrapAndDeriveKeys();
+ })
+ .catch(error => {
+ log.info("setSignedInUser correctly rejected");
+ });
+});
+
+// Alice (User A) signs up but never verifies her email. Then Bob (User B)
+// signs in with a verified email. Ensure that no sign-in events are triggered
+// on Alice's behalf. In the end, Bob should be the signed-in user.
+add_test(function test_overlapping_signins() {
+ let fxa = new MockFxAccounts();
+ let alice = getTestUser("alice");
+ let bob = getTestUser("bob");
+
+ makeObserver(ONVERIFIED_NOTIFICATION, function () {
+ log.debug("test_overlapping_signins observed onverified");
+ // Once email verification is complete, we will observe onverified
+ fxa._internal.getUserAccountData().then(user => {
+ Assert.equal(user.email, bob.email);
+ Assert.equal(user.verified, true);
+ run_next_test();
+ });
+ });
+
+ // Alice is the user signing in; her email is unverified.
+ fxa.setSignedInUser(alice).then(() => {
+ log.debug("Alice signing in ...");
+ fxa._internal.getUserAccountData().then(user => {
+ Assert.equal(user.email, alice.email);
+ Assert.equal(user.verified, false);
+ log.debug("Alice has not verified her email ...");
+
+ // Now Bob signs in instead and actually verifies his email
+ log.debug("Bob signing in ...");
+ fxa.setSignedInUser(bob).then(() => {
+ do_timeout(200, function () {
+ // Mock email verification ...
+ log.debug("Bob verifying his email ...");
+ fxa._internal.fxAccountsClient._verified = true;
+ });
+ });
+ });
+ });
+});
+
+add_task(async function test_resend_email_not_signed_in() {
+ let fxa = new MockFxAccounts();
+
+ try {
+ await fxa.resendVerificationEmail();
+ } catch (err) {
+ Assert.equal(err.message, ERROR_NO_ACCOUNT);
+ return;
+ }
+ do_throw("Should not be able to resend email when nobody is signed in");
+});
+
+add_task(async function test_accountStatus() {
+ let fxa = new MockFxAccounts();
+ let alice = getTestUser("alice");
+
+ // If we have no user, we have no account server-side
+ let result = await fxa.checkAccountStatus();
+ Assert.ok(!result);
+ // Set a user - the fxAccountsClient mock will say "ok".
+ await fxa.setSignedInUser(alice);
+ result = await fxa.checkAccountStatus();
+ Assert.ok(result);
+ // flag the item as deleted on the server.
+ fxa._internal.fxAccountsClient._deletedOnServer = true;
+ result = await fxa.checkAccountStatus();
+ Assert.ok(!result);
+ fxa._internal.fxAccountsClient._deletedOnServer = false;
+ await fxa.signOut();
+});
+
+add_task(async function test_resend_email_invalid_token() {
+ let fxa = new MockFxAccounts();
+ let sophia = getTestUser("sophia");
+ Assert.notEqual(sophia.sessionToken, null);
+
+ let client = fxa._internal.fxAccountsClient;
+ client.resendVerificationEmail = () => {
+ return Promise.reject({
+ code: 401,
+ errno: ERRNO_INVALID_AUTH_TOKEN,
+ });
+ };
+ // This test wants the account to exist but the local session invalid.
+ client.accountStatus = uid => {
+ Assert.ok(uid, "got a uid to check");
+ return Promise.resolve(true);
+ };
+ client.sessionStatus = token => {
+ Assert.ok(token, "got a token to check");
+ return Promise.resolve(false);
+ };
+
+ await fxa.setSignedInUser(sophia);
+ let user = await fxa._internal.getUserAccountData();
+ Assert.equal(user.email, sophia.email);
+ Assert.equal(user.verified, false);
+ log.debug("Sophia wants verification email resent");
+
+ try {
+ await fxa.resendVerificationEmail();
+ Assert.ok(
+ false,
+ "resendVerificationEmail should reject invalid session token"
+ );
+ } catch (err) {
+ Assert.equal(err.code, 401);
+ Assert.equal(err.errno, ERRNO_INVALID_AUTH_TOKEN);
+ }
+
+ user = await fxa._internal.getUserAccountData();
+ Assert.equal(user.email, sophia.email);
+ Assert.equal(user.sessionToken, null);
+ await fxa._internal.abortExistingFlow();
+});
+
+add_test(function test_resend_email() {
+ let fxa = new MockFxAccounts();
+ let alice = getTestUser("alice");
+
+ let initialState = fxa._internal.currentAccountState;
+
+ // Alice is the user signing in; her email is unverified.
+ fxa.setSignedInUser(alice).then(() => {
+ log.debug("Alice signing in");
+
+ // We're polling for the first email
+ Assert.ok(fxa._internal.currentAccountState !== initialState);
+ let aliceState = fxa._internal.currentAccountState;
+
+ // The polling timer is ticking
+ Assert.ok(fxa._internal.currentTimer > 0);
+
+ fxa._internal.getUserAccountData().then(user => {
+ Assert.equal(user.email, alice.email);
+ Assert.equal(user.verified, false);
+ log.debug("Alice wants verification email resent");
+
+ fxa.resendVerificationEmail().then(result => {
+ // Mock server response; ensures that the session token actually was
+ // passed to the client to make the hawk call
+ Assert.equal(result, "alice's session token");
+
+ // Timer was not restarted
+ Assert.ok(fxa._internal.currentAccountState === aliceState);
+
+ // Timer is still ticking
+ Assert.ok(fxa._internal.currentTimer > 0);
+
+ // Ok abort polling before we go on to the next test
+ fxa._internal.abortExistingFlow();
+ run_next_test();
+ });
+ });
+ });
+});
+
+Services.prefs.setStringPref(
+ "identity.fxaccounts.remote.oauth.uri",
+ "https://example.com/v1"
+);
+
+add_test(async function test_getOAuthTokenWithSessionToken() {
+ Services.prefs.setBoolPref(
+ "identity.fxaccounts.useSessionTokensForOAuth",
+ true
+ );
+ let fxa = new MockFxAccounts();
+ let alice = getTestUser("alice");
+ alice.verified = true;
+ let oauthTokenCalled = false;
+
+ let client = fxa._internal.fxAccountsClient;
+ client.accessTokenWithSessionToken = async (
+ sessionTokenHex,
+ clientId,
+ scope,
+ ttl
+ ) => {
+ oauthTokenCalled = true;
+ Assert.equal(sessionTokenHex, "alice's session token");
+ Assert.equal(clientId, "5882386c6d801776");
+ Assert.equal(scope, "profile");
+ Assert.equal(ttl, undefined);
+ return MOCK_TOKEN_RESPONSE;
+ };
+
+ await fxa.setSignedInUser(alice);
+ const result = await fxa.getOAuthToken({ scope: "profile" });
+ Assert.ok(oauthTokenCalled);
+ Assert.equal(result, MOCK_TOKEN_RESPONSE.access_token);
+ Services.prefs.setBoolPref(
+ "identity.fxaccounts.useSessionTokensForOAuth",
+ false
+ );
+ run_next_test();
+});
+
+add_task(async function test_getOAuthTokenCachedWithSessionToken() {
+ Services.prefs.setBoolPref(
+ "identity.fxaccounts.useSessionTokensForOAuth",
+ true
+ );
+ let fxa = new MockFxAccounts();
+ let alice = getTestUser("alice");
+ alice.verified = true;
+ let numOauthTokenCalls = 0;
+
+ let client = fxa._internal.fxAccountsClient;
+ client.accessTokenWithSessionToken = async () => {
+ numOauthTokenCalls++;
+ return MOCK_TOKEN_RESPONSE;
+ };
+
+ await fxa.setSignedInUser(alice);
+ let result = await fxa.getOAuthToken({
+ scope: "profile",
+ service: "test-service",
+ });
+ Assert.equal(numOauthTokenCalls, 1);
+ Assert.equal(
+ result,
+ "43793fdfffec22eb39fc3c44ed09193a6fde4c24e5d6a73f73178597b268af69"
+ );
+
+ // requesting it again should not re-fetch the token.
+ result = await fxa.getOAuthToken({
+ scope: "profile",
+ service: "test-service",
+ });
+ Assert.equal(numOauthTokenCalls, 1);
+ Assert.equal(
+ result,
+ "43793fdfffec22eb39fc3c44ed09193a6fde4c24e5d6a73f73178597b268af69"
+ );
+ // But requesting the same service and a different scope *will* get a new one.
+ result = await fxa.getOAuthToken({
+ scope: "something-else",
+ service: "test-service",
+ });
+ Assert.equal(numOauthTokenCalls, 2);
+ Assert.equal(
+ result,
+ "43793fdfffec22eb39fc3c44ed09193a6fde4c24e5d6a73f73178597b268af69"
+ );
+ Services.prefs.setBoolPref(
+ "identity.fxaccounts.useSessionTokensForOAuth",
+ false
+ );
+});
+
+add_test(function test_getOAuthTokenScopedWithSessionToken() {
+ let fxa = new MockFxAccounts();
+ let alice = getTestUser("alice");
+ alice.verified = true;
+ let numOauthTokenCalls = 0;
+
+ let client = fxa._internal.fxAccountsClient;
+ client.accessTokenWithSessionToken = async (
+ _sessionTokenHex,
+ _clientId,
+ scopeString
+ ) => {
+ equal(scopeString, "bar foo"); // scopes are sorted locally before request.
+ numOauthTokenCalls++;
+ return MOCK_TOKEN_RESPONSE;
+ };
+
+ fxa.setSignedInUser(alice).then(() => {
+ fxa.getOAuthToken({ scope: ["foo", "bar"] }).then(result => {
+ Assert.equal(numOauthTokenCalls, 1);
+ Assert.equal(
+ result,
+ "43793fdfffec22eb39fc3c44ed09193a6fde4c24e5d6a73f73178597b268af69"
+ );
+ run_next_test();
+ });
+ });
+});
+
+add_task(async function test_getOAuthTokenCachedScopeNormalization() {
+ let fxa = new MockFxAccounts();
+ let alice = getTestUser("alice");
+ alice.verified = true;
+ let numOAuthTokenCalls = 0;
+
+ let client = fxa._internal.fxAccountsClient;
+ client.accessTokenWithSessionToken = async (
+ _sessionTokenHex,
+ _clientId,
+ scopeString
+ ) => {
+ numOAuthTokenCalls++;
+ return MOCK_TOKEN_RESPONSE;
+ };
+
+ await fxa.setSignedInUser(alice);
+ let result = await fxa.getOAuthToken({
+ scope: ["foo", "bar"],
+ service: "test-service",
+ });
+ Assert.equal(numOAuthTokenCalls, 1);
+ Assert.equal(
+ result,
+ "43793fdfffec22eb39fc3c44ed09193a6fde4c24e5d6a73f73178597b268af69"
+ );
+
+ // requesting it again with the scope array in a different order should not re-fetch the token.
+ result = await fxa.getOAuthToken({
+ scope: ["bar", "foo"],
+ service: "test-service",
+ });
+ Assert.equal(numOAuthTokenCalls, 1);
+ Assert.equal(
+ result,
+ "43793fdfffec22eb39fc3c44ed09193a6fde4c24e5d6a73f73178597b268af69"
+ );
+ // requesting it again with the scope array in different case should not re-fetch the token.
+ result = await fxa.getOAuthToken({
+ scope: ["Bar", "Foo"],
+ service: "test-service",
+ });
+ Assert.equal(numOAuthTokenCalls, 1);
+ Assert.equal(
+ result,
+ "43793fdfffec22eb39fc3c44ed09193a6fde4c24e5d6a73f73178597b268af69"
+ );
+ // But requesting with a new entry in the array does fetch one.
+ result = await fxa.getOAuthToken({
+ scope: ["foo", "bar", "etc"],
+ service: "test-service",
+ });
+ Assert.equal(numOAuthTokenCalls, 2);
+ Assert.equal(
+ result,
+ "43793fdfffec22eb39fc3c44ed09193a6fde4c24e5d6a73f73178597b268af69"
+ );
+});
+
+add_test(function test_getOAuthToken_invalid_param() {
+ let fxa = new MockFxAccounts();
+
+ fxa.getOAuthToken().catch(err => {
+ Assert.equal(err.message, "INVALID_PARAMETER");
+ fxa.signOut().then(run_next_test);
+ });
+});
+
+add_test(function test_getOAuthToken_invalid_scope_array() {
+ let fxa = new MockFxAccounts();
+
+ fxa.getOAuthToken({ scope: [] }).catch(err => {
+ Assert.equal(err.message, "INVALID_PARAMETER");
+ fxa.signOut().then(run_next_test);
+ });
+});
+
+add_test(function test_getOAuthToken_misconfigure_oauth_uri() {
+ let fxa = new MockFxAccounts();
+
+ const prevServerURL = Services.prefs.getStringPref(
+ "identity.fxaccounts.remote.oauth.uri"
+ );
+ Services.prefs.deleteBranch("identity.fxaccounts.remote.oauth.uri");
+
+ fxa.getOAuthToken().catch(err => {
+ Assert.equal(err.message, "INVALID_PARAMETER");
+ // revert the pref
+ Services.prefs.setStringPref(
+ "identity.fxaccounts.remote.oauth.uri",
+ prevServerURL
+ );
+ fxa.signOut().then(run_next_test);
+ });
+});
+
+add_test(function test_getOAuthToken_no_account() {
+ let fxa = new MockFxAccounts();
+
+ fxa._internal.currentAccountState.getUserAccountData = function () {
+ return Promise.resolve(null);
+ };
+
+ fxa.getOAuthToken({ scope: "profile" }).catch(err => {
+ Assert.equal(err.message, "NO_ACCOUNT");
+ fxa.signOut().then(run_next_test);
+ });
+});
+
+add_test(function test_getOAuthToken_unverified() {
+ let fxa = new MockFxAccounts();
+ let alice = getTestUser("alice");
+
+ fxa.setSignedInUser(alice).then(() => {
+ fxa.getOAuthToken({ scope: "profile" }).catch(err => {
+ Assert.equal(err.message, "UNVERIFIED_ACCOUNT");
+ fxa.signOut().then(run_next_test);
+ });
+ });
+});
+
+add_test(function test_getOAuthToken_error() {
+ let fxa = new MockFxAccounts();
+ let alice = getTestUser("alice");
+ alice.verified = true;
+
+ let client = fxa._internal.fxAccountsClient;
+ client.accessTokenWithSessionToken = () => {
+ return Promise.reject("boom");
+ };
+
+ fxa.setSignedInUser(alice).then(() => {
+ fxa.getOAuthToken({ scope: "profile" }).catch(err => {
+ equal(err.details, "boom");
+ run_next_test();
+ });
+ });
+});
+
+add_task(async function test_listAttachedOAuthClients() {
+ const ONE_HOUR = 60 * 60 * 1000;
+ const ONE_DAY = 24 * ONE_HOUR;
+
+ const timestamp = Date.now();
+
+ let fxa = new MockFxAccounts();
+ let alice = getTestUser("alice");
+ alice.verified = true;
+
+ let client = fxa._internal.fxAccountsClient;
+ client.attachedClients = async () => {
+ return {
+ body: [
+ // This entry was previously filtered but no longer is!
+ {
+ clientId: "a2270f727f45f648",
+ deviceId: "deadbeef",
+ sessionTokenId: null,
+ name: "Firefox Preview (no session token)",
+ scope: ["profile", "https://identity.mozilla.com/apps/oldsync"],
+ lastAccessTime: Date.now(),
+ },
+ {
+ clientId: "802d56ef2a9af9fa",
+ deviceId: null,
+ sessionTokenId: null,
+ name: "Firefox Monitor",
+ scope: ["profile"],
+ lastAccessTime: Date.now() - ONE_DAY - ONE_HOUR,
+ },
+ {
+ clientId: "1f30e32975ae5112",
+ deviceId: null,
+ sessionTokenId: null,
+ name: "Firefox Send",
+ scope: ["profile", "https://identity.mozilla.com/apps/send"],
+ lastAccessTime: Date.now() - ONE_DAY * 2 - ONE_HOUR,
+ },
+ // One with a future date should be impossible, but having a negative
+ // result here would almost certainly confuse something!
+ {
+ clientId: "future-date",
+ deviceId: null,
+ sessionTokenId: null,
+ name: "Whatever",
+ lastAccessTime: Date.now() + ONE_DAY,
+ },
+ // A missing/null lastAccessTime should end up with a missing lastAccessedDaysAgo
+ {
+ clientId: "missing-date",
+ deviceId: null,
+ sessionTokenId: null,
+ name: "Whatever",
+ },
+ ],
+ headers: { "x-timestamp": timestamp.toString() },
+ };
+ };
+
+ await fxa.setSignedInUser(alice);
+ const clients = await fxa.listAttachedOAuthClients();
+ Assert.deepEqual(clients, [
+ {
+ id: "a2270f727f45f648",
+ lastAccessedDaysAgo: 0,
+ },
+ {
+ id: "802d56ef2a9af9fa",
+ lastAccessedDaysAgo: 1,
+ },
+ {
+ id: "1f30e32975ae5112",
+ lastAccessedDaysAgo: 2,
+ },
+ {
+ id: "future-date",
+ lastAccessedDaysAgo: 0,
+ },
+ {
+ id: "missing-date",
+ lastAccessedDaysAgo: null,
+ },
+ ]);
+});
+
+add_task(async function test_getSignedInUserProfile() {
+ let alice = getTestUser("alice");
+ alice.verified = true;
+
+ let mockProfile = {
+ getProfile() {
+ return Promise.resolve({ avatar: "image" });
+ },
+ tearDown() {},
+ };
+ let fxa = new FxAccounts({
+ _signOutServer() {
+ return Promise.resolve();
+ },
+ device: {
+ _registerOrUpdateDevice() {
+ return Promise.resolve();
+ },
+ },
+ });
+
+ await fxa._internal.setSignedInUser(alice);
+ fxa._internal._profile = mockProfile;
+ let result = await fxa.getSignedInUser();
+ Assert.ok(!!result);
+ Assert.equal(result.avatar, "image");
+});
+
+add_task(async function test_getSignedInUserProfile_error_uses_account_data() {
+ let fxa = new MockFxAccounts();
+ let alice = getTestUser("alice");
+ alice.verified = true;
+
+ fxa._internal.getSignedInUser = function () {
+ return Promise.resolve({ email: "foo@bar.com" });
+ };
+ fxa._internal._profile = {
+ getProfile() {
+ return Promise.reject("boom");
+ },
+ tearDown() {
+ teardownCalled = true;
+ },
+ };
+
+ let teardownCalled = false;
+ await fxa.setSignedInUser(alice);
+ let result = await fxa.getSignedInUser();
+ Assert.deepEqual(result.avatar, null);
+ await fxa.signOut();
+ Assert.ok(teardownCalled);
+});
+
+add_task(async function test_checkVerificationStatusFailed() {
+ let fxa = new MockFxAccounts();
+ let alice = getTestUser("alice");
+ alice.verified = true;
+
+ let client = fxa._internal.fxAccountsClient;
+ client.recoveryEmailStatus = () => {
+ return Promise.reject({
+ code: 401,
+ errno: ERRNO_INVALID_AUTH_TOKEN,
+ });
+ };
+ client.accountStatus = () => Promise.resolve(true);
+ client.sessionStatus = () => Promise.resolve(false);
+
+ await fxa.setSignedInUser(alice);
+ let user = await fxa._internal.getUserAccountData();
+ Assert.notEqual(alice.sessionToken, null);
+ Assert.equal(user.email, alice.email);
+ Assert.equal(user.verified, true);
+
+ await fxa._internal.checkVerificationStatus();
+
+ user = await fxa._internal.getUserAccountData();
+ Assert.equal(user.email, alice.email);
+ Assert.equal(user.sessionToken, null);
+});
+
+add_task(async function test_flushLogFile() {
+ _("Tests flushLogFile");
+ let account = await MakeFxAccounts();
+ let promiseObserved = new Promise(res => {
+ log.info("Adding flush-log-file observer.");
+ Services.obs.addObserver(function onFlushLogFile() {
+ Services.obs.removeObserver(
+ onFlushLogFile,
+ "service:log-manager:flush-log-file"
+ );
+ res();
+ }, "service:log-manager:flush-log-file");
+ });
+ account.flushLogFile();
+ await promiseObserved;
+});
+
+/*
+ * End of tests.
+ * Utility functions follow.
+ */
+
+function expandHex(two_hex) {
+ // Return a 64-character hex string, encoding 32 identical bytes.
+ let eight_hex = two_hex + two_hex + two_hex + two_hex;
+ let thirtytwo_hex = eight_hex + eight_hex + eight_hex + eight_hex;
+ return thirtytwo_hex + thirtytwo_hex;
+}
+
+function expandBytes(two_hex) {
+ return CommonUtils.hexToBytes(expandHex(two_hex));
+}
+
+function getTestUser(name) {
+ return {
+ email: name + "@example.com",
+ uid: "1ad7f5024cc74ec1a209071fd2fae348",
+ sessionToken: name + "'s session token",
+ keyFetchToken: name + "'s keyfetch token",
+ unwrapBKey: expandHex("44"),
+ verified: false,
+ encryptedSendTabKeys: name + "'s encrypted Send tab keys",
+ };
+}
+
+function makeObserver(aObserveTopic, aObserveFunc) {
+ let observer = {
+ // nsISupports provides type management in C++
+ // nsIObserver is to be an observer
+ QueryInterface: ChromeUtils.generateQI(["nsIObserver"]),
+
+ observe(aSubject, aTopic, aData) {
+ log.debug("observed " + aTopic + " " + aData);
+ if (aTopic == aObserveTopic) {
+ removeMe();
+ aObserveFunc(aSubject, aTopic, aData);
+ }
+ },
+ };
+
+ function removeMe() {
+ log.debug("removing observer for " + aObserveTopic);
+ Services.obs.removeObserver(observer, aObserveTopic);
+ }
+
+ Services.obs.addObserver(observer, aObserveTopic);
+ return removeMe;
+}
diff --git a/services/fxaccounts/tests/xpcshell/test_accounts_config.js b/services/fxaccounts/tests/xpcshell/test_accounts_config.js
new file mode 100644
index 0000000000..33ace13c47
--- /dev/null
+++ b/services/fxaccounts/tests/xpcshell/test_accounts_config.js
@@ -0,0 +1,58 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { FxAccounts } = ChromeUtils.importESModule(
+ "resource://gre/modules/FxAccounts.sys.mjs"
+);
+
+add_task(
+ async function test_non_https_remote_server_uri_with_requireHttps_false() {
+ Services.prefs.setBoolPref("identity.fxaccounts.allowHttp", true);
+ Services.prefs.setStringPref(
+ "identity.fxaccounts.remote.root",
+ "http://example.com/"
+ );
+ Assert.equal(
+ await FxAccounts.config.promiseConnectAccountURI("test"),
+ "http://example.com/?context=fx_desktop_v3&entrypoint=test&action=email&service=sync"
+ );
+
+ Services.prefs.clearUserPref("identity.fxaccounts.remote.root");
+ Services.prefs.clearUserPref("identity.fxaccounts.allowHttp");
+ }
+);
+
+add_task(async function test_non_https_remote_server_uri() {
+ Services.prefs.setStringPref(
+ "identity.fxaccounts.remote.root",
+ "http://example.com/"
+ );
+ await Assert.rejects(
+ FxAccounts.config.promiseConnectAccountURI(),
+ /Firefox Accounts server must use HTTPS/
+ );
+ Services.prefs.clearUserPref("identity.fxaccounts.remote.root");
+});
+
+add_task(async function test_is_production_config() {
+ // should start with no auto-config URL.
+ Assert.ok(!FxAccounts.config.getAutoConfigURL());
+ // which means we are using prod.
+ Assert.ok(FxAccounts.config.isProductionConfig());
+
+ // Set an auto-config URL.
+ Services.prefs.setStringPref(
+ "identity.fxaccounts.autoconfig.uri",
+ "http://x"
+ );
+ Assert.equal(FxAccounts.config.getAutoConfigURL(), "http://x");
+ Assert.ok(!FxAccounts.config.isProductionConfig());
+
+ // Clear the auto-config URL, but set one of the other config params.
+ Services.prefs.clearUserPref("identity.fxaccounts.autoconfig.uri");
+ Services.prefs.setStringPref("identity.sync.tokenserver.uri", "http://t");
+ Assert.ok(!FxAccounts.config.isProductionConfig());
+ Services.prefs.clearUserPref("identity.sync.tokenserver.uri");
+});
diff --git a/services/fxaccounts/tests/xpcshell/test_accounts_device_registration.js b/services/fxaccounts/tests/xpcshell/test_accounts_device_registration.js
new file mode 100644
index 0000000000..68337eb69e
--- /dev/null
+++ b/services/fxaccounts/tests/xpcshell/test_accounts_device_registration.js
@@ -0,0 +1,1204 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { FxAccounts } = ChromeUtils.importESModule(
+ "resource://gre/modules/FxAccounts.sys.mjs"
+);
+const { FxAccountsClient } = ChromeUtils.importESModule(
+ "resource://gre/modules/FxAccountsClient.sys.mjs"
+);
+const { FxAccountsDevice } = ChromeUtils.importESModule(
+ "resource://gre/modules/FxAccountsDevice.sys.mjs"
+);
+const {
+ ERRNO_DEVICE_SESSION_CONFLICT,
+ ERRNO_TOO_MANY_CLIENT_REQUESTS,
+ ERRNO_UNKNOWN_DEVICE,
+ ON_DEVICE_CONNECTED_NOTIFICATION,
+ ON_DEVICE_DISCONNECTED_NOTIFICATION,
+ ON_DEVICELIST_UPDATED,
+} = ChromeUtils.importESModule(
+ "resource://gre/modules/FxAccountsCommon.sys.mjs"
+);
+var { AccountState } = ChromeUtils.importESModule(
+ "resource://gre/modules/FxAccounts.sys.mjs"
+);
+
+initTestLogging("Trace");
+
+var log = Log.repository.getLogger("Services.FxAccounts.test");
+log.level = Log.Level.Debug;
+
+const BOGUS_PUBLICKEY =
+ "BBXOKjUb84pzws1wionFpfCBjDuCh4-s_1b52WA46K5wYL2gCWEOmFKWn_NkS5nmJwTBuO8qxxdjAIDtNeklvQc";
+const BOGUS_AUTHKEY = "GSsIiaD2Mr83iPqwFNK4rw";
+
+Services.prefs.setStringPref("identity.fxaccounts.loglevel", "Trace");
+
+const DEVICE_REGISTRATION_VERSION = 42;
+
+function MockStorageManager() {}
+
+MockStorageManager.prototype = {
+ initialize(accountData) {
+ this.accountData = accountData;
+ },
+
+ finalize() {
+ return Promise.resolve();
+ },
+
+ getAccountData() {
+ return Promise.resolve(this.accountData);
+ },
+
+ updateAccountData(updatedFields) {
+ for (let [name, value] of Object.entries(updatedFields)) {
+ if (value == null) {
+ delete this.accountData[name];
+ } else {
+ this.accountData[name] = value;
+ }
+ }
+ return Promise.resolve();
+ },
+
+ deleteAccountData() {
+ this.accountData = null;
+ return Promise.resolve();
+ },
+};
+
+function MockFxAccountsClient(device) {
+ this._email = "nobody@example.com";
+ // Be careful relying on `this._verified` as it doesn't change if the user's
+ // state does via setting the `verified` flag in the user data.
+ this._verified = false;
+ this._deletedOnServer = false; // for testing accountStatus
+
+ // mock calls up to the auth server to determine whether the
+ // user account has been verified
+ this.recoveryEmailStatus = function (sessionToken) {
+ // simulate a call to /recovery_email/status
+ return Promise.resolve({
+ email: this._email,
+ verified: this._verified,
+ });
+ };
+
+ this.accountKeys = function (keyFetchToken) {
+ Assert.ok(keyFetchToken, "must be called with a key-fetch-token");
+ // ideally we'd check the verification status here to more closely simulate
+ // the server, but `this._verified` is a test-only construct and doesn't
+ // update when the user changes verification status.
+ Assert.ok(!this._deletedOnServer, "this test thinks the acct is deleted!");
+ return {
+ kA: "test-ka",
+ wrapKB: "X".repeat(32),
+ };
+ };
+
+ this.accountStatus = function (uid) {
+ return Promise.resolve(!!uid && !this._deletedOnServer);
+ };
+
+ this.registerDevice = (st, name, type) =>
+ Promise.resolve({ id: device.id, name });
+ this.updateDevice = (st, id, name) => Promise.resolve({ id, name });
+ this.signOut = () => Promise.resolve({});
+ this.getDeviceList = st =>
+ Promise.resolve([
+ {
+ id: device.id,
+ name: device.name,
+ type: device.type,
+ pushCallback: device.pushCallback,
+ pushEndpointExpired: device.pushEndpointExpired,
+ isCurrentDevice: st === device.sessionToken,
+ },
+ ]);
+
+ FxAccountsClient.apply(this);
+}
+MockFxAccountsClient.prototype = {};
+Object.setPrototypeOf(
+ MockFxAccountsClient.prototype,
+ FxAccountsClient.prototype
+);
+
+async function MockFxAccounts(credentials, device = {}) {
+ let fxa = new FxAccounts({
+ newAccountState(creds) {
+ // we use a real accountState but mocked storage.
+ let storage = new MockStorageManager();
+ storage.initialize(creds);
+ return new AccountState(storage);
+ },
+ fxAccountsClient: new MockFxAccountsClient(device, credentials),
+ fxaPushService: {
+ registerPushEndpoint() {
+ return new Promise(resolve => {
+ resolve({
+ endpoint: "http://mochi.test:8888",
+ getKey(type) {
+ return ChromeUtils.base64URLDecode(
+ type === "auth" ? BOGUS_AUTHKEY : BOGUS_PUBLICKEY,
+ { padding: "ignore" }
+ );
+ },
+ });
+ });
+ },
+ unsubscribe() {
+ return Promise.resolve();
+ },
+ },
+ commands: {
+ async availableCommands() {
+ return {};
+ },
+ },
+ device: {
+ DEVICE_REGISTRATION_VERSION,
+ _checkRemoteCommandsUpdateNeeded: async () => false,
+ },
+ VERIFICATION_POLL_TIMEOUT_INITIAL: 1,
+ });
+ fxa._internal.device._fxai = fxa._internal;
+ await fxa._internal.setSignedInUser(credentials);
+ Services.prefs.setStringPref(
+ "identity.fxaccounts.account.device.name",
+ device.name || "mock device name"
+ );
+ return fxa;
+}
+
+function updateUserAccountData(fxa, data) {
+ return fxa._internal.updateUserAccountData(data);
+}
+
+add_task(async function test_updateDeviceRegistration_with_new_device() {
+ const deviceName = "foo";
+ const deviceType = "bar";
+
+ const credentials = getTestUser("baz");
+ const fxa = await MockFxAccounts(credentials, { name: deviceName });
+ // Remove the current device registration (setSignedInUser does one!).
+ await updateUserAccountData(fxa, { uid: credentials.uid, device: null });
+
+ const spy = {
+ registerDevice: { count: 0, args: [] },
+ updateDevice: { count: 0, args: [] },
+ getDeviceList: { count: 0, args: [] },
+ };
+ const client = fxa._internal.fxAccountsClient;
+ client.registerDevice = function () {
+ spy.registerDevice.count += 1;
+ spy.registerDevice.args.push(arguments);
+ return Promise.resolve({
+ id: "newly-generated device id",
+ createdAt: Date.now(),
+ name: deviceName,
+ type: deviceType,
+ });
+ };
+ client.updateDevice = function () {
+ spy.updateDevice.count += 1;
+ spy.updateDevice.args.push(arguments);
+ return Promise.resolve({});
+ };
+ client.getDeviceList = function () {
+ spy.getDeviceList.count += 1;
+ spy.getDeviceList.args.push(arguments);
+ return Promise.resolve([]);
+ };
+
+ await fxa.updateDeviceRegistration();
+
+ Assert.equal(spy.updateDevice.count, 0);
+ Assert.equal(spy.getDeviceList.count, 0);
+ Assert.equal(spy.registerDevice.count, 1);
+ Assert.equal(spy.registerDevice.args[0].length, 4);
+ Assert.equal(spy.registerDevice.args[0][0], credentials.sessionToken);
+ Assert.equal(spy.registerDevice.args[0][1], deviceName);
+ Assert.equal(spy.registerDevice.args[0][2], "desktop");
+ Assert.equal(
+ spy.registerDevice.args[0][3].pushCallback,
+ "http://mochi.test:8888"
+ );
+ Assert.equal(spy.registerDevice.args[0][3].pushPublicKey, BOGUS_PUBLICKEY);
+ Assert.equal(spy.registerDevice.args[0][3].pushAuthKey, BOGUS_AUTHKEY);
+
+ const state = fxa._internal.currentAccountState;
+ const data = await state.getUserAccountData();
+
+ Assert.equal(data.device.id, "newly-generated device id");
+ Assert.equal(data.device.registrationVersion, DEVICE_REGISTRATION_VERSION);
+ await fxa.signOut(true);
+});
+
+add_task(async function test_updateDeviceRegistration_with_existing_device() {
+ const deviceId = "my device id";
+ const deviceName = "phil's device";
+
+ const credentials = getTestUser("pb");
+ const fxa = await MockFxAccounts(credentials, { name: deviceName });
+ await updateUserAccountData(fxa, {
+ uid: credentials.uid,
+ device: {
+ id: deviceId,
+ registeredCommandsKeys: [],
+ registrationVersion: 1, // < 42
+ },
+ });
+
+ const spy = {
+ registerDevice: { count: 0, args: [] },
+ updateDevice: { count: 0, args: [] },
+ getDeviceList: { count: 0, args: [] },
+ };
+ const client = fxa._internal.fxAccountsClient;
+ client.registerDevice = function () {
+ spy.registerDevice.count += 1;
+ spy.registerDevice.args.push(arguments);
+ return Promise.resolve({});
+ };
+ client.updateDevice = function () {
+ spy.updateDevice.count += 1;
+ spy.updateDevice.args.push(arguments);
+ return Promise.resolve({
+ id: deviceId,
+ name: deviceName,
+ });
+ };
+ client.getDeviceList = function () {
+ spy.getDeviceList.count += 1;
+ spy.getDeviceList.args.push(arguments);
+ return Promise.resolve([]);
+ };
+ await fxa.updateDeviceRegistration();
+
+ Assert.equal(spy.registerDevice.count, 0);
+ Assert.equal(spy.getDeviceList.count, 0);
+ Assert.equal(spy.updateDevice.count, 1);
+ Assert.equal(spy.updateDevice.args[0].length, 4);
+ Assert.equal(spy.updateDevice.args[0][0], credentials.sessionToken);
+ Assert.equal(spy.updateDevice.args[0][1], deviceId);
+ Assert.equal(spy.updateDevice.args[0][2], deviceName);
+ Assert.equal(
+ spy.updateDevice.args[0][3].pushCallback,
+ "http://mochi.test:8888"
+ );
+ Assert.equal(spy.updateDevice.args[0][3].pushPublicKey, BOGUS_PUBLICKEY);
+ Assert.equal(spy.updateDevice.args[0][3].pushAuthKey, BOGUS_AUTHKEY);
+
+ const state = fxa._internal.currentAccountState;
+ const data = await state.getUserAccountData();
+
+ Assert.equal(data.device.id, deviceId);
+ Assert.equal(data.device.registrationVersion, DEVICE_REGISTRATION_VERSION);
+ await fxa.signOut(true);
+});
+
+add_task(
+ async function test_updateDeviceRegistration_with_unknown_device_error() {
+ const deviceName = "foo";
+ const deviceType = "bar";
+ const currentDeviceId = "my device id";
+
+ const credentials = getTestUser("baz");
+ const fxa = await MockFxAccounts(credentials, { name: deviceName });
+ await updateUserAccountData(fxa, {
+ uid: credentials.uid,
+ device: {
+ id: currentDeviceId,
+ registeredCommandsKeys: [],
+ registrationVersion: 1, // < 42
+ },
+ });
+
+ const spy = {
+ registerDevice: { count: 0, args: [] },
+ updateDevice: { count: 0, args: [] },
+ getDeviceList: { count: 0, args: [] },
+ };
+ const client = fxa._internal.fxAccountsClient;
+ client.registerDevice = function () {
+ spy.registerDevice.count += 1;
+ spy.registerDevice.args.push(arguments);
+ return Promise.resolve({
+ id: "a different newly-generated device id",
+ createdAt: Date.now(),
+ name: deviceName,
+ type: deviceType,
+ });
+ };
+ client.updateDevice = function () {
+ spy.updateDevice.count += 1;
+ spy.updateDevice.args.push(arguments);
+ return Promise.reject({
+ code: 400,
+ errno: ERRNO_UNKNOWN_DEVICE,
+ });
+ };
+ client.getDeviceList = function () {
+ spy.getDeviceList.count += 1;
+ spy.getDeviceList.args.push(arguments);
+ return Promise.resolve([]);
+ };
+
+ await fxa.updateDeviceRegistration();
+
+ Assert.equal(spy.getDeviceList.count, 0);
+ Assert.equal(spy.registerDevice.count, 0);
+ Assert.equal(spy.updateDevice.count, 1);
+ Assert.equal(spy.updateDevice.args[0].length, 4);
+ Assert.equal(spy.updateDevice.args[0][0], credentials.sessionToken);
+ Assert.equal(spy.updateDevice.args[0][1], currentDeviceId);
+ Assert.equal(spy.updateDevice.args[0][2], deviceName);
+ Assert.equal(
+ spy.updateDevice.args[0][3].pushCallback,
+ "http://mochi.test:8888"
+ );
+ Assert.equal(spy.updateDevice.args[0][3].pushPublicKey, BOGUS_PUBLICKEY);
+ Assert.equal(spy.updateDevice.args[0][3].pushAuthKey, BOGUS_AUTHKEY);
+
+ const state = fxa._internal.currentAccountState;
+ const data = await state.getUserAccountData();
+
+ Assert.equal(null, data.device);
+ await fxa.signOut(true);
+ }
+);
+
+add_task(
+ async function test_updateDeviceRegistration_with_device_session_conflict_error() {
+ const deviceName = "foo";
+ const deviceType = "bar";
+ const currentDeviceId = "my device id";
+ const conflictingDeviceId = "conflicting device id";
+
+ const credentials = getTestUser("baz");
+ const fxa = await MockFxAccounts(credentials, { name: deviceName });
+ await updateUserAccountData(fxa, {
+ uid: credentials.uid,
+ device: {
+ id: currentDeviceId,
+ registeredCommandsKeys: [],
+ registrationVersion: 1, // < 42
+ },
+ });
+
+ const spy = {
+ registerDevice: { count: 0, args: [] },
+ updateDevice: { count: 0, args: [], times: [] },
+ getDeviceList: { count: 0, args: [] },
+ };
+ const client = fxa._internal.fxAccountsClient;
+ client.registerDevice = function () {
+ spy.registerDevice.count += 1;
+ spy.registerDevice.args.push(arguments);
+ return Promise.resolve({});
+ };
+ client.updateDevice = function () {
+ spy.updateDevice.count += 1;
+ spy.updateDevice.args.push(arguments);
+ spy.updateDevice.time = Date.now();
+ if (spy.updateDevice.count === 1) {
+ return Promise.reject({
+ code: 400,
+ errno: ERRNO_DEVICE_SESSION_CONFLICT,
+ });
+ }
+ return Promise.resolve({
+ id: conflictingDeviceId,
+ name: deviceName,
+ });
+ };
+ client.getDeviceList = function () {
+ spy.getDeviceList.count += 1;
+ spy.getDeviceList.args.push(arguments);
+ spy.getDeviceList.time = Date.now();
+ return Promise.resolve([
+ {
+ id: "ignore",
+ name: "ignore",
+ type: "ignore",
+ isCurrentDevice: false,
+ },
+ {
+ id: conflictingDeviceId,
+ name: deviceName,
+ type: deviceType,
+ isCurrentDevice: true,
+ },
+ ]);
+ };
+
+ await fxa.updateDeviceRegistration();
+
+ Assert.equal(spy.registerDevice.count, 0);
+ Assert.equal(spy.updateDevice.count, 1);
+ Assert.equal(spy.updateDevice.args[0].length, 4);
+ Assert.equal(spy.updateDevice.args[0][0], credentials.sessionToken);
+ Assert.equal(spy.updateDevice.args[0][1], currentDeviceId);
+ Assert.equal(spy.updateDevice.args[0][2], deviceName);
+ Assert.equal(
+ spy.updateDevice.args[0][3].pushCallback,
+ "http://mochi.test:8888"
+ );
+ Assert.equal(spy.updateDevice.args[0][3].pushPublicKey, BOGUS_PUBLICKEY);
+ Assert.equal(spy.updateDevice.args[0][3].pushAuthKey, BOGUS_AUTHKEY);
+ Assert.equal(spy.getDeviceList.count, 1);
+ Assert.equal(spy.getDeviceList.args[0].length, 1);
+ Assert.equal(spy.getDeviceList.args[0][0], credentials.sessionToken);
+ Assert.ok(spy.getDeviceList.time >= spy.updateDevice.time);
+
+ const state = fxa._internal.currentAccountState;
+ const data = await state.getUserAccountData();
+
+ Assert.equal(data.device.id, conflictingDeviceId);
+ Assert.equal(data.device.registrationVersion, null);
+ await fxa.signOut(true);
+ }
+);
+
+add_task(
+ async function test_updateDeviceRegistration_with_unrecoverable_error() {
+ const deviceName = "foo";
+
+ const credentials = getTestUser("baz");
+ const fxa = await MockFxAccounts(credentials, { name: deviceName });
+ await updateUserAccountData(fxa, { uid: credentials.uid, device: null });
+
+ const spy = {
+ registerDevice: { count: 0, args: [] },
+ updateDevice: { count: 0, args: [] },
+ getDeviceList: { count: 0, args: [] },
+ };
+ const client = fxa._internal.fxAccountsClient;
+ client.registerDevice = function () {
+ spy.registerDevice.count += 1;
+ spy.registerDevice.args.push(arguments);
+ return Promise.reject({
+ code: 400,
+ errno: ERRNO_TOO_MANY_CLIENT_REQUESTS,
+ });
+ };
+ client.updateDevice = function () {
+ spy.updateDevice.count += 1;
+ spy.updateDevice.args.push(arguments);
+ return Promise.resolve({});
+ };
+ client.getDeviceList = function () {
+ spy.getDeviceList.count += 1;
+ spy.getDeviceList.args.push(arguments);
+ return Promise.resolve([]);
+ };
+
+ await fxa.updateDeviceRegistration();
+
+ Assert.equal(spy.getDeviceList.count, 0);
+ Assert.equal(spy.updateDevice.count, 0);
+ Assert.equal(spy.registerDevice.count, 1);
+ Assert.equal(spy.registerDevice.args[0].length, 4);
+
+ const state = fxa._internal.currentAccountState;
+ const data = await state.getUserAccountData();
+
+ Assert.equal(null, data.device);
+ await fxa.signOut(true);
+ }
+);
+
+add_task(
+ async function test_getDeviceId_with_no_device_id_invokes_device_registration() {
+ const credentials = getTestUser("foo");
+ credentials.verified = true;
+ const fxa = await MockFxAccounts(credentials);
+ await updateUserAccountData(fxa, { uid: credentials.uid, device: null });
+
+ const spy = { count: 0, args: [] };
+ fxa._internal.currentAccountState.getUserAccountData = () =>
+ Promise.resolve({
+ email: credentials.email,
+ registrationVersion: DEVICE_REGISTRATION_VERSION,
+ });
+ fxa._internal.device._registerOrUpdateDevice = function () {
+ spy.count += 1;
+ spy.args.push(arguments);
+ return Promise.resolve("bar");
+ };
+
+ const result = await fxa.device.getLocalId();
+
+ Assert.equal(spy.count, 1);
+ Assert.equal(spy.args[0].length, 2);
+ Assert.equal(spy.args[0][1].email, credentials.email);
+ Assert.equal(null, spy.args[0][1].device);
+ Assert.equal(result, "bar");
+ await fxa.signOut(true);
+ }
+);
+
+add_task(
+ async function test_getDeviceId_with_registration_version_outdated_invokes_device_registration() {
+ const credentials = getTestUser("foo");
+ credentials.verified = true;
+ const fxa = await MockFxAccounts(credentials);
+
+ const spy = { count: 0, args: [] };
+ fxa._internal.currentAccountState.getUserAccountData = () =>
+ Promise.resolve({
+ device: {
+ id: "my id",
+ registrationVersion: 0,
+ registeredCommandsKeys: [],
+ },
+ });
+ fxa._internal.device._registerOrUpdateDevice = function () {
+ spy.count += 1;
+ spy.args.push(arguments);
+ return Promise.resolve("wibble");
+ };
+
+ const result = await fxa.device.getLocalId();
+
+ Assert.equal(spy.count, 1);
+ Assert.equal(spy.args[0].length, 2);
+ Assert.equal(spy.args[0][1].device.id, "my id");
+ Assert.equal(result, "wibble");
+ await fxa.signOut(true);
+ }
+);
+
+add_task(
+ async function test_getDeviceId_with_device_id_and_uptodate_registration_version_doesnt_invoke_device_registration() {
+ const credentials = getTestUser("foo");
+ credentials.verified = true;
+ const fxa = await MockFxAccounts(credentials);
+
+ const spy = { count: 0 };
+ fxa._internal.currentAccountState.getUserAccountData = async () => ({
+ device: {
+ id: "foo's device id",
+ registrationVersion: DEVICE_REGISTRATION_VERSION,
+ registeredCommandsKeys: [],
+ },
+ });
+ fxa._internal.device._registerOrUpdateDevice = function () {
+ spy.count += 1;
+ return Promise.resolve("bar");
+ };
+
+ const result = await fxa.device.getLocalId();
+
+ Assert.equal(spy.count, 0);
+ Assert.equal(result, "foo's device id");
+ await fxa.signOut(true);
+ }
+);
+
+add_task(
+ async function test_getDeviceId_with_device_id_and_with_no_registration_version_invokes_device_registration() {
+ const credentials = getTestUser("foo");
+ credentials.verified = true;
+ const fxa = await MockFxAccounts(credentials);
+
+ const spy = { count: 0, args: [] };
+ fxa._internal.currentAccountState.getUserAccountData = () =>
+ Promise.resolve({ device: { id: "wibble" } });
+ fxa._internal.device._registerOrUpdateDevice = function () {
+ spy.count += 1;
+ spy.args.push(arguments);
+ return Promise.resolve("wibble");
+ };
+
+ const result = await fxa.device.getLocalId();
+
+ Assert.equal(spy.count, 1);
+ Assert.equal(spy.args[0].length, 2);
+ Assert.equal(spy.args[0][1].device.id, "wibble");
+ Assert.equal(result, "wibble");
+ await fxa.signOut(true);
+ }
+);
+
+add_task(async function test_verification_updates_registration() {
+ const deviceName = "foo";
+
+ const credentials = getTestUser("baz");
+ const fxa = await MockFxAccounts(credentials, {
+ id: "device-id",
+ name: deviceName,
+ });
+
+ // We should already have a device registration, but without send-tab due to
+ // our inability to fetch keys for an unverified users.
+ const state = fxa._internal.currentAccountState;
+ const { device } = await state.getUserAccountData();
+ Assert.equal(device.registeredCommandsKeys.length, 0);
+
+ let updatePromise = new Promise(resolve => {
+ const old_registerOrUpdateDevice = fxa.device._registerOrUpdateDevice.bind(
+ fxa.device
+ );
+ fxa.device._registerOrUpdateDevice = async function (
+ currentState,
+ signedInUser
+ ) {
+ await old_registerOrUpdateDevice(currentState, signedInUser);
+ fxa.device._registerOrUpdateDevice = old_registerOrUpdateDevice;
+ resolve();
+ };
+ });
+
+ fxa._internal.checkEmailStatus = async function (sessionToken) {
+ credentials.verified = true;
+ return credentials;
+ };
+
+ await updatePromise;
+
+ const { device: newDevice, encryptedSendTabKeys } =
+ await state.getUserAccountData();
+ Assert.equal(newDevice.registeredCommandsKeys.length, 1);
+ Assert.notEqual(encryptedSendTabKeys, null);
+ await fxa.signOut(true);
+});
+
+add_task(async function test_devicelist_pushendpointexpired() {
+ const deviceId = "mydeviceid";
+ const credentials = getTestUser("baz");
+ credentials.verified = true;
+ const fxa = await MockFxAccounts(credentials);
+ await updateUserAccountData(fxa, {
+ uid: credentials.uid,
+ device: {
+ id: deviceId,
+ registeredCommandsKeys: [],
+ registrationVersion: 1, // < 42
+ },
+ });
+
+ const spy = {
+ updateDevice: { count: 0, args: [] },
+ getDeviceList: { count: 0, args: [] },
+ };
+ const client = fxa._internal.fxAccountsClient;
+ client.updateDevice = function () {
+ spy.updateDevice.count += 1;
+ spy.updateDevice.args.push(arguments);
+ return Promise.resolve({});
+ };
+ client.getDeviceList = function () {
+ spy.getDeviceList.count += 1;
+ spy.getDeviceList.args.push(arguments);
+ return Promise.resolve([
+ {
+ id: "mydeviceid",
+ name: "foo",
+ type: "desktop",
+ isCurrentDevice: true,
+ pushEndpointExpired: true,
+ pushCallback: "https://example.com",
+ },
+ ]);
+ };
+ let polledForMissedCommands = false;
+ fxa._internal.commands.pollDeviceCommands = () => {
+ polledForMissedCommands = true;
+ };
+
+ await fxa.device.refreshDeviceList();
+
+ Assert.equal(spy.getDeviceList.count, 1);
+ Assert.equal(spy.updateDevice.count, 1);
+ Assert.ok(polledForMissedCommands);
+ await fxa.signOut(true);
+});
+
+add_task(async function test_devicelist_nopushcallback() {
+ const deviceId = "mydeviceid";
+ const credentials = getTestUser("baz");
+ credentials.verified = true;
+ const fxa = await MockFxAccounts(credentials);
+ await updateUserAccountData(fxa, {
+ uid: credentials.uid,
+ device: {
+ id: deviceId,
+ registeredCommandsKeys: [],
+ registrationVersion: 1,
+ },
+ });
+
+ const spy = {
+ updateDevice: { count: 0, args: [] },
+ getDeviceList: { count: 0, args: [] },
+ };
+ const client = fxa._internal.fxAccountsClient;
+ client.updateDevice = function () {
+ spy.updateDevice.count += 1;
+ spy.updateDevice.args.push(arguments);
+ return Promise.resolve({});
+ };
+ client.getDeviceList = function () {
+ spy.getDeviceList.count += 1;
+ spy.getDeviceList.args.push(arguments);
+ return Promise.resolve([
+ {
+ id: "mydeviceid",
+ name: "foo",
+ type: "desktop",
+ isCurrentDevice: true,
+ pushEndpointExpired: false,
+ pushCallback: null,
+ },
+ ]);
+ };
+
+ let polledForMissedCommands = false;
+ fxa._internal.commands.pollDeviceCommands = () => {
+ polledForMissedCommands = true;
+ };
+
+ await fxa.device.refreshDeviceList();
+
+ Assert.equal(spy.getDeviceList.count, 1);
+ Assert.equal(spy.updateDevice.count, 1);
+ Assert.ok(polledForMissedCommands);
+ await fxa.signOut(true);
+});
+
+add_task(async function test_refreshDeviceList() {
+ let credentials = getTestUser("baz");
+
+ let storage = new MockStorageManager();
+ storage.initialize(credentials);
+ let state = new AccountState(storage);
+
+ let fxAccountsClient = new MockFxAccountsClient({
+ id: "deviceAAAAAA",
+ name: "iPhone",
+ type: "phone",
+ pushCallback: "http://mochi.test:8888",
+ pushEndpointExpired: false,
+ sessionToken: credentials.sessionToken,
+ });
+ let spy = {
+ getDeviceList: { count: 0 },
+ };
+ const deviceListUpdateObserver = {
+ count: 0,
+ observe(subject, topic, data) {
+ this.count++;
+ },
+ };
+ Services.obs.addObserver(deviceListUpdateObserver, ON_DEVICELIST_UPDATED);
+
+ fxAccountsClient.getDeviceList = (function (old) {
+ return function getDeviceList() {
+ spy.getDeviceList.count += 1;
+ return old.apply(this, arguments);
+ };
+ })(fxAccountsClient.getDeviceList);
+ let fxai = {
+ _now: Date.now(),
+ _generation: 0,
+ fxAccountsClient,
+ now() {
+ return this._now;
+ },
+ withVerifiedAccountState(func) {
+ // Ensure `func` is called asynchronously, and simulate the possibility
+ // of a different user signng in while the promise is in-flight.
+ const currentGeneration = this._generation;
+ return Promise.resolve()
+ .then(_ => func(state))
+ .then(result => {
+ if (currentGeneration < this._generation) {
+ throw new Error("Another user has signed in");
+ }
+ return result;
+ });
+ },
+ fxaPushService: {
+ registerPushEndpoint() {
+ return new Promise(resolve => {
+ resolve({
+ endpoint: "http://mochi.test:8888",
+ getKey(type) {
+ return ChromeUtils.base64URLDecode(
+ type === "auth" ? BOGUS_AUTHKEY : BOGUS_PUBLICKEY,
+ { padding: "ignore" }
+ );
+ },
+ });
+ });
+ },
+ unsubscribe() {
+ return Promise.resolve();
+ },
+ getSubscription() {
+ return Promise.resolve({
+ isExpired: () => {
+ return false;
+ },
+ endpoint: "http://mochi.test:8888",
+ });
+ },
+ },
+ async _handleTokenError(e) {
+ _(`Test failure: ${e} - ${e.stack}`);
+ throw e;
+ },
+ };
+ let device = new FxAccountsDevice(fxai);
+ device._checkRemoteCommandsUpdateNeeded = async () => false;
+
+ Assert.equal(
+ device.recentDeviceList,
+ null,
+ "Should not have device list initially"
+ );
+ Assert.ok(await device.refreshDeviceList(), "Should refresh list");
+ Assert.equal(
+ deviceListUpdateObserver.count,
+ 1,
+ `${ON_DEVICELIST_UPDATED} was notified`
+ );
+ Assert.deepEqual(
+ device.recentDeviceList,
+ [
+ {
+ id: "deviceAAAAAA",
+ name: "iPhone",
+ type: "phone",
+ pushCallback: "http://mochi.test:8888",
+ pushEndpointExpired: false,
+ isCurrentDevice: true,
+ },
+ ],
+ "Should fetch device list"
+ );
+ Assert.equal(
+ spy.getDeviceList.count,
+ 1,
+ "Should make request to refresh list"
+ );
+ Assert.ok(
+ !(await device.refreshDeviceList()),
+ "Should not refresh device list if fresh"
+ );
+ Assert.equal(
+ deviceListUpdateObserver.count,
+ 1,
+ `${ON_DEVICELIST_UPDATED} was not notified`
+ );
+
+ fxai._now += device.TIME_BETWEEN_FXA_DEVICES_FETCH_MS;
+
+ let refreshPromise = device.refreshDeviceList();
+ let secondRefreshPromise = device.refreshDeviceList();
+ Assert.ok(
+ await Promise.all([refreshPromise, secondRefreshPromise]),
+ "Should refresh list if stale"
+ );
+ Assert.equal(
+ spy.getDeviceList.count,
+ 2,
+ "Should only make one request if called with pending request"
+ );
+ Assert.equal(
+ deviceListUpdateObserver.count,
+ 2,
+ `${ON_DEVICELIST_UPDATED} only notified once`
+ );
+
+ device.observe(null, ON_DEVICE_CONNECTED_NOTIFICATION);
+ await device.refreshDeviceList();
+ Assert.equal(
+ spy.getDeviceList.count,
+ 3,
+ "Should refresh device list after connecting new device"
+ );
+ Assert.equal(
+ deviceListUpdateObserver.count,
+ 3,
+ `${ON_DEVICELIST_UPDATED} notified when new device connects`
+ );
+ device.observe(
+ null,
+ ON_DEVICE_DISCONNECTED_NOTIFICATION,
+ JSON.stringify({ isLocalDevice: false })
+ );
+ await device.refreshDeviceList();
+ Assert.equal(
+ spy.getDeviceList.count,
+ 4,
+ "Should refresh device list after disconnecting device"
+ );
+ Assert.equal(
+ deviceListUpdateObserver.count,
+ 4,
+ `${ON_DEVICELIST_UPDATED} notified when device disconnects`
+ );
+ device.observe(
+ null,
+ ON_DEVICE_DISCONNECTED_NOTIFICATION,
+ JSON.stringify({ isLocalDevice: true })
+ );
+ await device.refreshDeviceList();
+ Assert.equal(
+ spy.getDeviceList.count,
+ 4,
+ "Should not refresh device list after disconnecting this device"
+ );
+ Assert.equal(
+ deviceListUpdateObserver.count,
+ 4,
+ `${ON_DEVICELIST_UPDATED} not notified again`
+ );
+
+ let refreshBeforeResetPromise = device.refreshDeviceList({
+ ignoreCached: true,
+ });
+ fxai._generation++;
+ Assert.equal(
+ deviceListUpdateObserver.count,
+ 4,
+ `${ON_DEVICELIST_UPDATED} not notified`
+ );
+ await Assert.rejects(refreshBeforeResetPromise, /Another user has signed in/);
+
+ device.reset();
+ Assert.equal(
+ device.recentDeviceList,
+ null,
+ "Should clear device list after resetting"
+ );
+ Assert.ok(
+ await device.refreshDeviceList(),
+ "Should fetch new list after resetting"
+ );
+ Assert.equal(
+ deviceListUpdateObserver.count,
+ 5,
+ `${ON_DEVICELIST_UPDATED} notified after reset`
+ );
+ Services.obs.removeObserver(deviceListUpdateObserver, ON_DEVICELIST_UPDATED);
+});
+
+add_task(async function test_push_resubscribe() {
+ let credentials = getTestUser("baz");
+
+ let storage = new MockStorageManager();
+ storage.initialize(credentials);
+ let state = new AccountState(storage);
+
+ let mockDevice = {
+ id: "deviceAAAAAA",
+ name: "iPhone",
+ type: "phone",
+ pushCallback: "http://mochi.test:8888",
+ pushEndpointExpired: false,
+ sessionToken: credentials.sessionToken,
+ };
+
+ var mockSubscription = {
+ isExpired: () => {
+ return false;
+ },
+ endpoint: "http://mochi.test:8888",
+ };
+
+ let fxAccountsClient = new MockFxAccountsClient(mockDevice);
+
+ const spy = {
+ _registerOrUpdateDevice: { count: 0 },
+ };
+
+ let fxai = {
+ _now: Date.now(),
+ _generation: 0,
+ fxAccountsClient,
+ now() {
+ return this._now;
+ },
+ withVerifiedAccountState(func) {
+ // Ensure `func` is called asynchronously, and simulate the possibility
+ // of a different user signng in while the promise is in-flight.
+ const currentGeneration = this._generation;
+ return Promise.resolve()
+ .then(_ => func(state))
+ .then(result => {
+ if (currentGeneration < this._generation) {
+ throw new Error("Another user has signed in");
+ }
+ return result;
+ });
+ },
+ fxaPushService: {
+ registerPushEndpoint() {
+ return new Promise(resolve => {
+ resolve({
+ endpoint: "http://mochi.test:8888",
+ getKey(type) {
+ return ChromeUtils.base64URLDecode(
+ type === "auth" ? BOGUS_AUTHKEY : BOGUS_PUBLICKEY,
+ { padding: "ignore" }
+ );
+ },
+ });
+ });
+ },
+ unsubscribe() {
+ return Promise.resolve();
+ },
+ getSubscription() {
+ return Promise.resolve(mockSubscription);
+ },
+ },
+ commands: {
+ async pollDeviceCommands() {},
+ },
+ async _handleTokenError(e) {
+ _(`Test failure: ${e} - ${e.stack}`);
+ throw e;
+ },
+ };
+ let device = new FxAccountsDevice(fxai);
+ device._checkRemoteCommandsUpdateNeeded = async () => false;
+ device._registerOrUpdateDevice = async () => {
+ spy._registerOrUpdateDevice.count += 1;
+ };
+
+ Assert.ok(await device.refreshDeviceList(), "Should refresh list");
+ Assert.equal(spy._registerOrUpdateDevice.count, 0, "not expecting a refresh");
+
+ mockDevice.pushEndpointExpired = true;
+ Assert.ok(
+ await device.refreshDeviceList({ ignoreCached: true }),
+ "Should refresh list"
+ );
+ Assert.equal(
+ spy._registerOrUpdateDevice.count,
+ 1,
+ "end-point expired means should resubscribe"
+ );
+
+ mockDevice.pushEndpointExpired = false;
+ mockSubscription.isExpired = () => true;
+ Assert.ok(
+ await device.refreshDeviceList({ ignoreCached: true }),
+ "Should refresh list"
+ );
+ Assert.equal(
+ spy._registerOrUpdateDevice.count,
+ 2,
+ "push service saying expired should resubscribe"
+ );
+
+ mockSubscription.isExpired = () => false;
+ mockSubscription.endpoint = "something-else";
+ Assert.ok(
+ await device.refreshDeviceList({ ignoreCached: true }),
+ "Should refresh list"
+ );
+ Assert.equal(
+ spy._registerOrUpdateDevice.count,
+ 3,
+ "push service endpoint diff should resubscribe"
+ );
+
+ mockSubscription = null;
+ Assert.ok(
+ await device.refreshDeviceList({ ignoreCached: true }),
+ "Should refresh list"
+ );
+ Assert.equal(
+ spy._registerOrUpdateDevice.count,
+ 4,
+ "push service saying no sub should resubscribe"
+ );
+
+ // reset everything to make sure we didn't leave something behind causing the above to
+ // not check what we thought it was.
+ mockSubscription = {
+ isExpired: () => {
+ return false;
+ },
+ endpoint: "http://mochi.test:8888",
+ };
+ Assert.ok(
+ await device.refreshDeviceList({ ignoreCached: true }),
+ "Should refresh list"
+ );
+ Assert.equal(
+ spy._registerOrUpdateDevice.count,
+ 4,
+ "resetting to good data should not resubscribe"
+ );
+});
+
+add_task(async function test_checking_remote_availableCommands_mismatch() {
+ const credentials = getTestUser("baz");
+ credentials.verified = true;
+ const fxa = await MockFxAccounts(credentials);
+ fxa.device._checkRemoteCommandsUpdateNeeded =
+ FxAccountsDevice.prototype._checkRemoteCommandsUpdateNeeded;
+ fxa.commands.availableCommands = async () => {
+ return {
+ "https://identity.mozilla.com/cmd/open-uri": "local-keys",
+ };
+ };
+
+ const ourDevice = {
+ isCurrentDevice: true,
+ availableCommands: {
+ "https://identity.mozilla.com/cmd/open-uri": "remote-keys",
+ },
+ };
+ Assert.ok(
+ await fxa.device._checkRemoteCommandsUpdateNeeded(
+ ourDevice.availableCommands
+ )
+ );
+});
+
+add_task(async function test_checking_remote_availableCommands_match() {
+ const credentials = getTestUser("baz");
+ credentials.verified = true;
+ const fxa = await MockFxAccounts(credentials);
+ fxa.device._checkRemoteCommandsUpdateNeeded =
+ FxAccountsDevice.prototype._checkRemoteCommandsUpdateNeeded;
+ fxa.commands.availableCommands = async () => {
+ return {
+ "https://identity.mozilla.com/cmd/open-uri": "local-keys",
+ };
+ };
+
+ const ourDevice = {
+ isCurrentDevice: true,
+ availableCommands: {
+ "https://identity.mozilla.com/cmd/open-uri": "local-keys",
+ },
+ };
+ Assert.ok(
+ !(await fxa.device._checkRemoteCommandsUpdateNeeded(
+ ourDevice.availableCommands
+ ))
+ );
+});
+
+function getTestUser(name) {
+ return {
+ email: name + "@example.com",
+ uid: "1ad7f502-4cc7-4ec1-a209-071fd2fae348",
+ sessionToken: name + "'s session token",
+ verified: false,
+ ...MOCK_ACCOUNT_KEYS,
+ };
+}
diff --git a/services/fxaccounts/tests/xpcshell/test_client.js b/services/fxaccounts/tests/xpcshell/test_client.js
new file mode 100644
index 0000000000..f3cc48a70e
--- /dev/null
+++ b/services/fxaccounts/tests/xpcshell/test_client.js
@@ -0,0 +1,966 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { FxAccountsClient } = ChromeUtils.importESModule(
+ "resource://gre/modules/FxAccountsClient.sys.mjs"
+);
+
+const FAKE_SESSION_TOKEN =
+ "a0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebf";
+
+// https://wiki.mozilla.org/Identity/AttachedServices/KeyServerProtocol#.2Faccount.2Fkeys
+var ACCOUNT_KEYS = {
+ keyFetch: h(
+ // eslint-disable-next-line no-useless-concat
+ "8081828384858687 88898a8b8c8d8e8f" + "9091929394959697 98999a9b9c9d9e9f"
+ ),
+
+ response: h(
+ "ee5c58845c7c9412 b11bbd20920c2fdd" +
+ "d83c33c9cd2c2de2 d66b222613364636" +
+ "c2c0f8cfbb7c6304 72c0bd88451342c6" +
+ "c05b14ce342c5ad4 6ad89e84464c993c" +
+ "3927d30230157d08 17a077eef4b20d97" +
+ "6f7a97363faf3f06 4c003ada7d01aa70"
+ ),
+
+ kA: h(
+ // eslint-disable-next-line no-useless-concat
+ "2021222324252627 28292a2b2c2d2e2f" + "3031323334353637 38393a3b3c3d3e3f"
+ ),
+
+ wrapKB: h(
+ // eslint-disable-next-line no-useless-concat
+ "4041424344454647 48494a4b4c4d4e4f" + "5051525354555657 58595a5b5c5d5e5f"
+ ),
+};
+
+add_task(async function test_authenticated_get_request() {
+ let message = '{"msg": "Great Success!"}';
+ let credentials = {
+ id: "eyJleHBpcmVzIjogMTM2NTAxMDg5OC4x",
+ key: "qTZf4ZFpAMpMoeSsX3zVRjiqmNs=",
+ algorithm: "sha256",
+ };
+ let method = "GET";
+
+ let server = httpd_setup({
+ "/foo": function (request, response) {
+ Assert.ok(request.hasHeader("Authorization"));
+
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.bodyOutputStream.write(message, message.length);
+ },
+ });
+
+ let client = new FxAccountsClient(server.baseURI);
+
+ let result = await client._request("/foo", method, credentials);
+ Assert.equal("Great Success!", result.msg);
+
+ await promiseStopServer(server);
+});
+
+add_task(async function test_authenticated_post_request() {
+ let credentials = {
+ id: "eyJleHBpcmVzIjogMTM2NTAxMDg5OC4x",
+ key: "qTZf4ZFpAMpMoeSsX3zVRjiqmNs=",
+ algorithm: "sha256",
+ };
+ let method = "POST";
+
+ let server = httpd_setup({
+ "/foo": function (request, response) {
+ Assert.ok(request.hasHeader("Authorization"));
+
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.setHeader("Content-Type", "application/json");
+ response.bodyOutputStream.writeFrom(
+ request.bodyInputStream,
+ request.bodyInputStream.available()
+ );
+ },
+ });
+
+ let client = new FxAccountsClient(server.baseURI);
+
+ let result = await client._request("/foo", method, credentials, {
+ foo: "bar",
+ });
+ Assert.equal("bar", result.foo);
+
+ await promiseStopServer(server);
+});
+
+add_task(async function test_500_error() {
+ let message = "<h1>Ooops!</h1>";
+ let method = "GET";
+
+ let server = httpd_setup({
+ "/foo": function (request, response) {
+ response.setStatusLine(request.httpVersion, 500, "Internal Server Error");
+ response.bodyOutputStream.write(message, message.length);
+ },
+ });
+
+ let client = new FxAccountsClient(server.baseURI);
+
+ try {
+ await client._request("/foo", method);
+ do_throw("Expected to catch an exception");
+ } catch (e) {
+ Assert.equal(500, e.code);
+ Assert.equal("Internal Server Error", e.message);
+ }
+
+ await promiseStopServer(server);
+});
+
+add_task(async function test_backoffError() {
+ let method = "GET";
+ let server = httpd_setup({
+ "/retryDelay": function (request, response) {
+ response.setHeader("Retry-After", "30");
+ response.setStatusLine(
+ request.httpVersion,
+ 429,
+ "Client has sent too many requests"
+ );
+ let message = "<h1>Ooops!</h1>";
+ response.bodyOutputStream.write(message, message.length);
+ },
+ "/duringDelayIShouldNotBeCalled": function (request, response) {
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ let jsonMessage = '{"working": "yes"}';
+ response.bodyOutputStream.write(jsonMessage, jsonMessage.length);
+ },
+ });
+
+ let client = new FxAccountsClient(server.baseURI);
+
+ // Retry-After header sets client.backoffError
+ Assert.equal(client.backoffError, null);
+ try {
+ await client._request("/retryDelay", method);
+ } catch (e) {
+ Assert.equal(429, e.code);
+ Assert.equal(30, e.retryAfter);
+ Assert.notEqual(typeof client.fxaBackoffTimer, "undefined");
+ Assert.notEqual(client.backoffError, null);
+ }
+ // While delay is in effect, client short-circuits any requests
+ // and re-rejects with previous error.
+ try {
+ await client._request("/duringDelayIShouldNotBeCalled", method);
+ throw new Error("I should not be reached");
+ } catch (e) {
+ Assert.equal(e.retryAfter, 30);
+ Assert.equal(e.message, "Client has sent too many requests");
+ Assert.notEqual(client.backoffError, null);
+ }
+ // Once timer fires, client nulls error out and HTTP calls work again.
+ client._clearBackoff();
+ let result = await client._request("/duringDelayIShouldNotBeCalled", method);
+ Assert.equal(client.backoffError, null);
+ Assert.equal(result.working, "yes");
+
+ await promiseStopServer(server);
+});
+
+add_task(async function test_signUp() {
+ let creationMessage_noKey = JSON.stringify({
+ uid: "uid",
+ sessionToken: "sessionToken",
+ });
+ let creationMessage_withKey = JSON.stringify({
+ uid: "uid",
+ sessionToken: "sessionToken",
+ keyFetchToken: "keyFetchToken",
+ });
+ let errorMessage = JSON.stringify({
+ code: 400,
+ errno: 101,
+ error: "account exists",
+ });
+ let created = false;
+
+ // Note these strings must be unicode and not already utf-8 encoded.
+ let unicodeUsername = "andr\xe9@example.org"; // 'andré@example.org'
+ let unicodePassword = "p\xe4ssw\xf6rd"; // 'pässwörd'
+ let server = httpd_setup({
+ "/account/create": function (request, response) {
+ let body = CommonUtils.readBytesFromInputStream(request.bodyInputStream);
+ body = CommonUtils.decodeUTF8(body);
+ let jsonBody = JSON.parse(body);
+
+ // https://github.com/mozilla/fxa-auth-server/wiki/onepw-protocol#wiki-test-vectors
+
+ if (created) {
+ // Error trying to create same account a second time
+ response.setStatusLine(request.httpVersion, 400, "Bad request");
+ response.bodyOutputStream.write(errorMessage, errorMessage.length);
+ return;
+ }
+
+ if (jsonBody.email == unicodeUsername) {
+ Assert.equal("", request._queryString);
+ Assert.equal(
+ jsonBody.authPW,
+ "247b675ffb4c46310bc87e26d712153abe5e1c90ef00a4784594f97ef54f2375"
+ );
+
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.bodyOutputStream.write(
+ creationMessage_noKey,
+ creationMessage_noKey.length
+ );
+ return;
+ }
+
+ if (jsonBody.email == "you@example.org") {
+ Assert.equal("keys=true", request._queryString);
+ Assert.equal(
+ jsonBody.authPW,
+ "e5c1cdfdaa5fcee06142db865b212cc8ba8abee2a27d639d42c139f006cdb930"
+ );
+ created = true;
+
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.bodyOutputStream.write(
+ creationMessage_withKey,
+ creationMessage_withKey.length
+ );
+ return;
+ }
+ // just throwing here doesn't make any log noise, so have an assertion
+ // fail instead.
+ Assert.ok(false, "unexpected email: " + jsonBody.email);
+ },
+ });
+
+ // Try to create an account without retrieving optional keys.
+ let client = new FxAccountsClient(server.baseURI);
+ let result = await client.signUp(unicodeUsername, unicodePassword);
+ Assert.equal("uid", result.uid);
+ Assert.equal("sessionToken", result.sessionToken);
+ Assert.equal(undefined, result.keyFetchToken);
+ Assert.equal(
+ result.unwrapBKey,
+ "de6a2648b78284fcb9ffa81ba95803309cfba7af583c01a8a1a63e567234dd28"
+ );
+
+ // Try to create an account retrieving optional keys.
+ result = await client.signUp("you@example.org", "pässwörd", true);
+ Assert.equal("uid", result.uid);
+ Assert.equal("sessionToken", result.sessionToken);
+ Assert.equal("keyFetchToken", result.keyFetchToken);
+ Assert.equal(
+ result.unwrapBKey,
+ "f589225b609e56075d76eb74f771ff9ab18a4dc0e901e131ba8f984c7fb0ca8c"
+ );
+
+ // Try to create an existing account. Triggers error path.
+ try {
+ result = await client.signUp(unicodeUsername, unicodePassword);
+ do_throw("Expected to catch an exception");
+ } catch (expectedError) {
+ Assert.equal(101, expectedError.errno);
+ }
+
+ await promiseStopServer(server);
+});
+
+add_task(async function test_signIn() {
+ let sessionMessage_noKey = JSON.stringify({
+ sessionToken: FAKE_SESSION_TOKEN,
+ });
+ let sessionMessage_withKey = JSON.stringify({
+ sessionToken: FAKE_SESSION_TOKEN,
+ keyFetchToken: "keyFetchToken",
+ });
+ let errorMessage_notExistent = JSON.stringify({
+ code: 400,
+ errno: 102,
+ error: "doesn't exist",
+ });
+ let errorMessage_wrongCap = JSON.stringify({
+ code: 400,
+ errno: 120,
+ error: "Incorrect email case",
+ email: "you@example.com",
+ });
+
+ // Note this strings must be unicode and not already utf-8 encoded.
+ let unicodeUsername = "m\xe9@example.com"; // 'mé@example.com'
+ let server = httpd_setup({
+ "/account/login": function (request, response) {
+ let body = CommonUtils.readBytesFromInputStream(request.bodyInputStream);
+ body = CommonUtils.decodeUTF8(body);
+ let jsonBody = JSON.parse(body);
+
+ if (jsonBody.email == unicodeUsername) {
+ Assert.equal("", request._queryString);
+ Assert.equal(
+ jsonBody.authPW,
+ "08b9d111196b8408e8ed92439da49206c8ecfbf343df0ae1ecefcd1e0174a8b6"
+ );
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.bodyOutputStream.write(
+ sessionMessage_noKey,
+ sessionMessage_noKey.length
+ );
+ } else if (jsonBody.email == "you@example.com") {
+ Assert.equal("keys=true", request._queryString);
+ Assert.equal(
+ jsonBody.authPW,
+ "93d20ec50304d496d0707ec20d7e8c89459b6396ec5dd5b9e92809c5e42856c7"
+ );
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.bodyOutputStream.write(
+ sessionMessage_withKey,
+ sessionMessage_withKey.length
+ );
+ } else if (jsonBody.email == "You@example.com") {
+ // Error trying to sign in with a wrong capitalization
+ response.setStatusLine(request.httpVersion, 400, "Bad request");
+ response.bodyOutputStream.write(
+ errorMessage_wrongCap,
+ errorMessage_wrongCap.length
+ );
+ } else {
+ // Error trying to sign in to nonexistent account
+ response.setStatusLine(request.httpVersion, 400, "Bad request");
+ response.bodyOutputStream.write(
+ errorMessage_notExistent,
+ errorMessage_notExistent.length
+ );
+ }
+ },
+ });
+
+ // Login without retrieving optional keys
+ let client = new FxAccountsClient(server.baseURI);
+ let result = await client.signIn(unicodeUsername, "bigsecret");
+ Assert.equal(FAKE_SESSION_TOKEN, result.sessionToken);
+ Assert.equal(
+ result.unwrapBKey,
+ "c076ec3f4af123a615157154c6e1d0d6293e514fd7b0221e32d50517ecf002b8"
+ );
+ Assert.equal(undefined, result.keyFetchToken);
+
+ // Login with retrieving optional keys
+ result = await client.signIn("you@example.com", "bigsecret", true);
+ Assert.equal(FAKE_SESSION_TOKEN, result.sessionToken);
+ Assert.equal(
+ result.unwrapBKey,
+ "65970516211062112e955d6420bebe020269d6b6a91ebd288319fc8d0cb49624"
+ );
+ Assert.equal("keyFetchToken", result.keyFetchToken);
+
+ // Retry due to wrong email capitalization
+ result = await client.signIn("You@example.com", "bigsecret", true);
+ Assert.equal(FAKE_SESSION_TOKEN, result.sessionToken);
+ Assert.equal(
+ result.unwrapBKey,
+ "65970516211062112e955d6420bebe020269d6b6a91ebd288319fc8d0cb49624"
+ );
+ Assert.equal("keyFetchToken", result.keyFetchToken);
+
+ // Trigger error path
+ try {
+ result = await client.signIn("yøü@bad.example.org", "nofear");
+ do_throw("Expected to catch an exception");
+ } catch (expectedError) {
+ Assert.equal(102, expectedError.errno);
+ }
+
+ await promiseStopServer(server);
+});
+
+add_task(async function test_signOut() {
+ let signoutMessage = JSON.stringify({});
+ let errorMessage = JSON.stringify({
+ code: 400,
+ errno: 102,
+ error: "doesn't exist",
+ });
+ let signedOut = false;
+
+ let server = httpd_setup({
+ "/session/destroy": function (request, response) {
+ if (!signedOut) {
+ signedOut = true;
+ Assert.ok(request.hasHeader("Authorization"));
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.bodyOutputStream.write(signoutMessage, signoutMessage.length);
+ return;
+ }
+
+ // Error trying to sign out of nonexistent account
+ response.setStatusLine(request.httpVersion, 400, "Bad request");
+ response.bodyOutputStream.write(errorMessage, errorMessage.length);
+ },
+ });
+
+ let client = new FxAccountsClient(server.baseURI);
+ let result = await client.signOut("FakeSession");
+ Assert.equal(typeof result, "object");
+
+ // Trigger error path
+ try {
+ result = await client.signOut("FakeSession");
+ do_throw("Expected to catch an exception");
+ } catch (expectedError) {
+ Assert.equal(102, expectedError.errno);
+ }
+
+ await promiseStopServer(server);
+});
+
+add_task(async function test_recoveryEmailStatus() {
+ let emailStatus = JSON.stringify({ verified: true });
+ let errorMessage = JSON.stringify({
+ code: 400,
+ errno: 102,
+ error: "doesn't exist",
+ });
+ let tries = 0;
+
+ let server = httpd_setup({
+ "/recovery_email/status": function (request, response) {
+ Assert.ok(request.hasHeader("Authorization"));
+ Assert.equal("", request._queryString);
+
+ if (tries === 0) {
+ tries += 1;
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.bodyOutputStream.write(emailStatus, emailStatus.length);
+ return;
+ }
+
+ // Second call gets an error trying to query a nonexistent account
+ response.setStatusLine(request.httpVersion, 400, "Bad request");
+ response.bodyOutputStream.write(errorMessage, errorMessage.length);
+ },
+ });
+
+ let client = new FxAccountsClient(server.baseURI);
+ let result = await client.recoveryEmailStatus(FAKE_SESSION_TOKEN);
+ Assert.equal(result.verified, true);
+
+ // Trigger error path
+ try {
+ result = await client.recoveryEmailStatus("some bogus session");
+ do_throw("Expected to catch an exception");
+ } catch (expectedError) {
+ Assert.equal(102, expectedError.errno);
+ }
+
+ await promiseStopServer(server);
+});
+
+add_task(async function test_recoveryEmailStatusWithReason() {
+ let emailStatus = JSON.stringify({ verified: true });
+ let server = httpd_setup({
+ "/recovery_email/status": function (request, response) {
+ Assert.ok(request.hasHeader("Authorization"));
+ // if there is a query string then it will have a reason
+ Assert.equal("reason=push", request._queryString);
+
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.bodyOutputStream.write(emailStatus, emailStatus.length);
+ },
+ });
+
+ let client = new FxAccountsClient(server.baseURI);
+ let result = await client.recoveryEmailStatus(FAKE_SESSION_TOKEN, {
+ reason: "push",
+ });
+ Assert.equal(result.verified, true);
+ await promiseStopServer(server);
+});
+
+add_task(async function test_resendVerificationEmail() {
+ let emptyMessage = "{}";
+ let errorMessage = JSON.stringify({
+ code: 400,
+ errno: 102,
+ error: "doesn't exist",
+ });
+ let tries = 0;
+
+ let server = httpd_setup({
+ "/recovery_email/resend_code": function (request, response) {
+ Assert.ok(request.hasHeader("Authorization"));
+ if (tries === 0) {
+ tries += 1;
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.bodyOutputStream.write(emptyMessage, emptyMessage.length);
+ return;
+ }
+
+ // Second call gets an error trying to query a nonexistent account
+ response.setStatusLine(request.httpVersion, 400, "Bad request");
+ response.bodyOutputStream.write(errorMessage, errorMessage.length);
+ },
+ });
+
+ let client = new FxAccountsClient(server.baseURI);
+ let result = await client.resendVerificationEmail(FAKE_SESSION_TOKEN);
+ Assert.equal(JSON.stringify(result), emptyMessage);
+
+ // Trigger error path
+ try {
+ result = await client.resendVerificationEmail("some bogus session");
+ do_throw("Expected to catch an exception");
+ } catch (expectedError) {
+ Assert.equal(102, expectedError.errno);
+ }
+
+ await promiseStopServer(server);
+});
+
+add_task(async function test_accountKeys() {
+ // Four calls to accountKeys(). The first one should work correctly, and we
+ // should get a valid bundle back, in exchange for our keyFetch token, from
+ // which we correctly derive kA and wrapKB. The subsequent three calls
+ // should all trigger separate error paths.
+ let responseMessage = JSON.stringify({ bundle: ACCOUNT_KEYS.response });
+ let errorMessage = JSON.stringify({
+ code: 400,
+ errno: 102,
+ error: "doesn't exist",
+ });
+ let emptyMessage = "{}";
+ let attempt = 0;
+
+ let server = httpd_setup({
+ "/account/keys": function (request, response) {
+ Assert.ok(request.hasHeader("Authorization"));
+ attempt += 1;
+
+ switch (attempt) {
+ case 1:
+ // First time succeeds
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.bodyOutputStream.write(
+ responseMessage,
+ responseMessage.length
+ );
+ break;
+
+ case 2:
+ // Second time, return no bundle to trigger client error
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.bodyOutputStream.write(emptyMessage, emptyMessage.length);
+ break;
+
+ case 3:
+ // Return gibberish to trigger client MAC error
+ // Tweak a byte
+ let garbageResponse = JSON.stringify({
+ bundle: ACCOUNT_KEYS.response.slice(0, -1) + "1",
+ });
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.bodyOutputStream.write(
+ garbageResponse,
+ garbageResponse.length
+ );
+ break;
+
+ case 4:
+ // Trigger error for nonexistent account
+ response.setStatusLine(request.httpVersion, 400, "Bad request");
+ response.bodyOutputStream.write(errorMessage, errorMessage.length);
+ break;
+ }
+ },
+ });
+
+ let client = new FxAccountsClient(server.baseURI);
+
+ // First try, all should be good
+ let result = await client.accountKeys(ACCOUNT_KEYS.keyFetch);
+ Assert.equal(CommonUtils.hexToBytes(ACCOUNT_KEYS.kA), result.kA);
+ Assert.equal(CommonUtils.hexToBytes(ACCOUNT_KEYS.wrapKB), result.wrapKB);
+
+ // Second try, empty bundle should trigger error
+ try {
+ result = await client.accountKeys(ACCOUNT_KEYS.keyFetch);
+ do_throw("Expected to catch an exception");
+ } catch (expectedError) {
+ Assert.equal(expectedError.message, "failed to retrieve keys");
+ }
+
+ // Third try, bad bundle results in MAC error
+ try {
+ result = await client.accountKeys(ACCOUNT_KEYS.keyFetch);
+ do_throw("Expected to catch an exception");
+ } catch (expectedError) {
+ Assert.equal(expectedError.message, "error unbundling encryption keys");
+ }
+
+ // Fourth try, pretend account doesn't exist
+ try {
+ result = await client.accountKeys(ACCOUNT_KEYS.keyFetch);
+ do_throw("Expected to catch an exception");
+ } catch (expectedError) {
+ Assert.equal(102, expectedError.errno);
+ }
+
+ await promiseStopServer(server);
+});
+
+add_task(async function test_accessTokenWithSessionToken() {
+ let server = httpd_setup({
+ "/oauth/token": function (request, response) {
+ const responseMessage = JSON.stringify({
+ access_token:
+ "43793fdfffec22eb39fc3c44ed09193a6fde4c24e5d6a73f73178597b268af69",
+ token_type: "bearer",
+ scope: "https://identity.mozilla.com/apps/oldsync",
+ expires_in: 21600,
+ auth_at: 1589579900,
+ });
+
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.bodyOutputStream.write(responseMessage, responseMessage.length);
+ },
+ });
+
+ let client = new FxAccountsClient(server.baseURI);
+ let sessionTokenHex =
+ "0599c36ebb5cad6feb9285b9547b65342b5434d55c07b33bffd4307ab8f82dc4";
+ let clientId = "5882386c6d801776";
+ let scope = "https://identity.mozilla.com/apps/oldsync";
+ let ttl = 100;
+ let result = await client.accessTokenWithSessionToken(
+ sessionTokenHex,
+ clientId,
+ scope,
+ ttl
+ );
+ Assert.ok(result);
+
+ await promiseStopServer(server);
+});
+
+add_task(async function test_accountExists() {
+ let existsMessage = JSON.stringify({
+ error: "wrong password",
+ code: 400,
+ errno: 103,
+ });
+ let doesntExistMessage = JSON.stringify({
+ error: "no such account",
+ code: 400,
+ errno: 102,
+ });
+ let emptyMessage = "{}";
+
+ let server = httpd_setup({
+ "/account/login": function (request, response) {
+ let body = CommonUtils.readBytesFromInputStream(request.bodyInputStream);
+ let jsonBody = JSON.parse(body);
+
+ switch (jsonBody.email) {
+ // We'll test that these users' accounts exist
+ case "i.exist@example.com":
+ case "i.also.exist@example.com":
+ response.setStatusLine(request.httpVersion, 400, "Bad request");
+ response.bodyOutputStream.write(existsMessage, existsMessage.length);
+ break;
+
+ // This user's account doesn't exist
+ case "i.dont.exist@example.com":
+ response.setStatusLine(request.httpVersion, 400, "Bad request");
+ response.bodyOutputStream.write(
+ doesntExistMessage,
+ doesntExistMessage.length
+ );
+ break;
+
+ // This user throws an unexpected response
+ // This will reject the client signIn promise
+ case "i.break.things@example.com":
+ response.setStatusLine(request.httpVersion, 500, "Alas");
+ response.bodyOutputStream.write(emptyMessage, emptyMessage.length);
+ break;
+
+ default:
+ throw new Error("Unexpected login from " + jsonBody.email);
+ }
+ },
+ });
+
+ let client = new FxAccountsClient(server.baseURI);
+ let result;
+
+ result = await client.accountExists("i.exist@example.com");
+ Assert.ok(result);
+
+ result = await client.accountExists("i.also.exist@example.com");
+ Assert.ok(result);
+
+ result = await client.accountExists("i.dont.exist@example.com");
+ Assert.ok(!result);
+
+ try {
+ result = await client.accountExists("i.break.things@example.com");
+ do_throw("Expected to catch an exception");
+ } catch (unexpectedError) {
+ Assert.equal(unexpectedError.code, 500);
+ }
+
+ await promiseStopServer(server);
+});
+
+add_task(async function test_registerDevice() {
+ const DEVICE_ID = "device id";
+ const DEVICE_NAME = "device name";
+ const DEVICE_TYPE = "device type";
+ const ERROR_NAME = "test that the client promise rejects";
+
+ const server = httpd_setup({
+ "/account/device": function (request, response) {
+ const body = JSON.parse(
+ CommonUtils.readBytesFromInputStream(request.bodyInputStream)
+ );
+
+ if (
+ body.id ||
+ !body.name ||
+ !body.type ||
+ Object.keys(body).length !== 2
+ ) {
+ response.setStatusLine(request.httpVersion, 400, "Invalid request");
+ response.bodyOutputStream.write("{}", 2);
+ return;
+ }
+
+ if (body.name === ERROR_NAME) {
+ response.setStatusLine(request.httpVersion, 500, "Alas");
+ response.bodyOutputStream.write("{}", 2);
+ return;
+ }
+
+ body.id = DEVICE_ID;
+ body.createdAt = Date.now();
+
+ const responseMessage = JSON.stringify(body);
+
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.bodyOutputStream.write(responseMessage, responseMessage.length);
+ },
+ });
+
+ const client = new FxAccountsClient(server.baseURI);
+ const result = await client.registerDevice(
+ FAKE_SESSION_TOKEN,
+ DEVICE_NAME,
+ DEVICE_TYPE
+ );
+
+ Assert.ok(result);
+ Assert.equal(Object.keys(result).length, 4);
+ Assert.equal(result.id, DEVICE_ID);
+ Assert.equal(typeof result.createdAt, "number");
+ Assert.ok(result.createdAt > 0);
+ Assert.equal(result.name, DEVICE_NAME);
+ Assert.equal(result.type, DEVICE_TYPE);
+
+ try {
+ await client.registerDevice(FAKE_SESSION_TOKEN, ERROR_NAME, DEVICE_TYPE);
+ do_throw("Expected to catch an exception");
+ } catch (unexpectedError) {
+ Assert.equal(unexpectedError.code, 500);
+ }
+
+ await promiseStopServer(server);
+});
+
+add_task(async function test_updateDevice() {
+ const DEVICE_ID = "some other id";
+ const DEVICE_NAME = "some other name";
+ const ERROR_ID = "test that the client promise rejects";
+
+ const server = httpd_setup({
+ "/account/device": function (request, response) {
+ const body = JSON.parse(
+ CommonUtils.readBytesFromInputStream(request.bodyInputStream)
+ );
+
+ if (
+ !body.id ||
+ !body.name ||
+ body.type ||
+ Object.keys(body).length !== 2
+ ) {
+ response.setStatusLine(request.httpVersion, 400, "Invalid request");
+ response.bodyOutputStream.write("{}", 2);
+ return;
+ }
+
+ if (body.id === ERROR_ID) {
+ response.setStatusLine(request.httpVersion, 500, "Alas");
+ response.bodyOutputStream.write("{}", 2);
+ return;
+ }
+
+ const responseMessage = JSON.stringify(body);
+
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.bodyOutputStream.write(responseMessage, responseMessage.length);
+ },
+ });
+
+ const client = new FxAccountsClient(server.baseURI);
+ const result = await client.updateDevice(
+ FAKE_SESSION_TOKEN,
+ DEVICE_ID,
+ DEVICE_NAME
+ );
+
+ Assert.ok(result);
+ Assert.equal(Object.keys(result).length, 2);
+ Assert.equal(result.id, DEVICE_ID);
+ Assert.equal(result.name, DEVICE_NAME);
+
+ try {
+ await client.updateDevice(FAKE_SESSION_TOKEN, ERROR_ID, DEVICE_NAME);
+ do_throw("Expected to catch an exception");
+ } catch (unexpectedError) {
+ Assert.equal(unexpectedError.code, 500);
+ }
+
+ await promiseStopServer(server);
+});
+
+add_task(async function test_getDeviceList() {
+ let canReturnDevices;
+
+ const server = httpd_setup({
+ "/account/devices": function (request, response) {
+ if (canReturnDevices) {
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.bodyOutputStream.write("[]", 2);
+ } else {
+ response.setStatusLine(request.httpVersion, 500, "Alas");
+ response.bodyOutputStream.write("{}", 2);
+ }
+ },
+ });
+
+ const client = new FxAccountsClient(server.baseURI);
+
+ canReturnDevices = true;
+ const result = await client.getDeviceList(FAKE_SESSION_TOKEN);
+ Assert.ok(Array.isArray(result));
+ Assert.equal(result.length, 0);
+
+ try {
+ canReturnDevices = false;
+ await client.getDeviceList(FAKE_SESSION_TOKEN);
+ do_throw("Expected to catch an exception");
+ } catch (unexpectedError) {
+ Assert.equal(unexpectedError.code, 500);
+ }
+
+ await promiseStopServer(server);
+});
+
+add_task(async function test_client_metrics() {
+ function writeResp(response, msg) {
+ if (typeof msg === "object") {
+ msg = JSON.stringify(msg);
+ }
+ response.bodyOutputStream.write(msg, msg.length);
+ }
+
+ let server = httpd_setup({
+ "/session/destroy": function (request, response) {
+ response.setHeader("Content-Type", "application/json; charset=utf-8");
+ response.setStatusLine(request.httpVersion, 401, "Unauthorized");
+ writeResp(response, {
+ error: "invalid authentication timestamp",
+ code: 401,
+ errno: 111,
+ });
+ },
+ });
+
+ let client = new FxAccountsClient(server.baseURI);
+
+ await Assert.rejects(
+ client.signOut(FAKE_SESSION_TOKEN, {
+ service: "sync",
+ }),
+ function (err) {
+ return err.errno == 111;
+ }
+ );
+
+ await promiseStopServer(server);
+});
+
+add_task(async function test_email_case() {
+ let canonicalEmail = "greta.garbo@gmail.com";
+ let clientEmail = "Greta.Garbo@gmail.COM";
+ let attempts = 0;
+
+ function writeResp(response, msg) {
+ if (typeof msg === "object") {
+ msg = JSON.stringify(msg);
+ }
+ response.bodyOutputStream.write(msg, msg.length);
+ }
+
+ let server = httpd_setup({
+ "/account/login": function (request, response) {
+ response.setHeader("Content-Type", "application/json; charset=utf-8");
+ attempts += 1;
+ if (attempts > 2) {
+ response.setStatusLine(
+ request.httpVersion,
+ 429,
+ "Sorry, you had your chance"
+ );
+ return writeResp(response, "");
+ }
+
+ let body = CommonUtils.readBytesFromInputStream(request.bodyInputStream);
+ let jsonBody = JSON.parse(body);
+ let email = jsonBody.email;
+
+ // If the client has the wrong case on the email, we return a 400, with
+ // the capitalization of the email as saved in the accounts database.
+ if (email == canonicalEmail) {
+ response.setStatusLine(request.httpVersion, 200, "Yay");
+ return writeResp(response, { areWeHappy: "yes" });
+ }
+
+ response.setStatusLine(request.httpVersion, 400, "Incorrect email case");
+ return writeResp(response, {
+ code: 400,
+ errno: 120,
+ error: "Incorrect email case",
+ email: canonicalEmail,
+ });
+ },
+ });
+
+ let client = new FxAccountsClient(server.baseURI);
+
+ let result = await client.signIn(clientEmail, "123456");
+ Assert.equal(result.areWeHappy, "yes");
+ Assert.equal(attempts, 2);
+
+ await promiseStopServer(server);
+});
+
+// turn formatted test vectors into normal hex strings
+function h(hexStr) {
+ return hexStr.replace(/\s+/g, "");
+}
diff --git a/services/fxaccounts/tests/xpcshell/test_commands.js b/services/fxaccounts/tests/xpcshell/test_commands.js
new file mode 100644
index 0000000000..3fa42da439
--- /dev/null
+++ b/services/fxaccounts/tests/xpcshell/test_commands.js
@@ -0,0 +1,708 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { FxAccountsCommands, SendTab } = ChromeUtils.importESModule(
+ "resource://gre/modules/FxAccountsCommands.sys.mjs"
+);
+
+const { FxAccountsClient } = ChromeUtils.importESModule(
+ "resource://gre/modules/FxAccountsClient.sys.mjs"
+);
+
+const { COMMAND_SENDTAB, COMMAND_SENDTAB_TAIL } = ChromeUtils.importESModule(
+ "resource://gre/modules/FxAccountsCommon.sys.mjs"
+);
+
+class TelemetryMock {
+ constructor() {
+ this._events = [];
+ this._uuid_counter = 0;
+ }
+
+ recordEvent(object, method, value, extra = undefined) {
+ this._events.push({ object, method, value, extra });
+ }
+
+ generateFlowID() {
+ this._uuid_counter += 1;
+ return this._uuid_counter.toString();
+ }
+
+ sanitizeDeviceId(id) {
+ return id + "-san";
+ }
+}
+
+function FxaInternalMock() {
+ return {
+ telemetry: new TelemetryMock(),
+ };
+}
+
+function MockFxAccountsClient() {
+ FxAccountsClient.apply(this);
+}
+
+MockFxAccountsClient.prototype = {};
+Object.setPrototypeOf(
+ MockFxAccountsClient.prototype,
+ FxAccountsClient.prototype
+);
+
+add_task(async function test_sendtab_isDeviceCompatible() {
+ const sendTab = new SendTab(null, null);
+ let device = { name: "My device" };
+ Assert.ok(!sendTab.isDeviceCompatible(device));
+ device = { name: "My device", availableCommands: {} };
+ Assert.ok(!sendTab.isDeviceCompatible(device));
+ device = {
+ name: "My device",
+ availableCommands: {
+ "https://identity.mozilla.com/cmd/open-uri": "payload",
+ },
+ };
+ Assert.ok(sendTab.isDeviceCompatible(device));
+});
+
+add_task(async function test_sendtab_send() {
+ const commands = {
+ invoke: sinon.spy((cmd, device, payload) => {
+ if (device.name == "Device 1") {
+ throw new Error("Invoke error!");
+ }
+ Assert.equal(payload.encrypted, "encryptedpayload");
+ }),
+ };
+ const fxai = FxaInternalMock();
+ const sendTab = new SendTab(commands, fxai);
+ sendTab._encrypt = (bytes, device) => {
+ if (device.name == "Device 2") {
+ throw new Error("Encrypt error!");
+ }
+ return "encryptedpayload";
+ };
+ const to = [
+ { name: "Device 1" },
+ { name: "Device 2" },
+ { id: "dev3", name: "Device 3" },
+ ];
+ // although we are sending to 3 devices, only 1 is successful - so there's
+ // only 1 streamID we care about. However, we've created IDs even for the
+ // failing items - so it's "4"
+ const expectedTelemetryStreamID = "4";
+ const tab = { title: "Foo", url: "https://foo.bar/" };
+ const report = await sendTab.send(to, tab);
+ Assert.equal(report.succeeded.length, 1);
+ Assert.equal(report.failed.length, 2);
+ Assert.equal(report.succeeded[0].name, "Device 3");
+ Assert.equal(report.failed[0].device.name, "Device 1");
+ Assert.equal(report.failed[0].error.message, "Invoke error!");
+ Assert.equal(report.failed[1].device.name, "Device 2");
+ Assert.equal(report.failed[1].error.message, "Encrypt error!");
+ Assert.ok(commands.invoke.calledTwice);
+ Assert.deepEqual(fxai.telemetry._events, [
+ {
+ object: "command-sent",
+ method: COMMAND_SENDTAB_TAIL,
+ value: "dev3-san",
+ extra: { flowID: "1", streamID: expectedTelemetryStreamID },
+ },
+ ]);
+});
+
+add_task(async function test_sendtab_send_rate_limit() {
+ const rateLimitReject = {
+ code: 429,
+ retryAfter: 5,
+ retryAfterLocalized: "retry after 5 seconds",
+ };
+ const fxAccounts = {
+ fxAccountsClient: new MockFxAccountsClient(),
+ getUserAccountData() {
+ return {};
+ },
+ telemetry: new TelemetryMock(),
+ };
+ let rejected = false;
+ let invoked = 0;
+ fxAccounts.fxAccountsClient.invokeCommand = async function invokeCommand() {
+ invoked++;
+ Assert.ok(invoked <= 2, "only called twice and not more");
+ if (rejected) {
+ return {};
+ }
+ rejected = true;
+ return Promise.reject(rateLimitReject);
+ };
+ const commands = new FxAccountsCommands(fxAccounts);
+ const sendTab = new SendTab(commands, fxAccounts);
+ sendTab._encrypt = () => "encryptedpayload";
+
+ const tab = { title: "Foo", url: "https://foo.bar/" };
+ let report = await sendTab.send([{ name: "Device 1" }], tab);
+ Assert.equal(report.succeeded.length, 0);
+ Assert.equal(report.failed.length, 1);
+ Assert.equal(report.failed[0].error, rateLimitReject);
+
+ report = await sendTab.send([{ name: "Device 1" }], tab);
+ Assert.equal(report.succeeded.length, 0);
+ Assert.equal(report.failed.length, 1);
+ Assert.ok(
+ report.failed[0].error.message.includes(
+ "Invoke for " +
+ "https://identity.mozilla.com/cmd/open-uri is rate-limited"
+ )
+ );
+
+ commands._invokeRateLimitExpiry = Date.now() - 1000;
+ report = await sendTab.send([{ name: "Device 1" }], tab);
+ Assert.equal(report.succeeded.length, 1);
+ Assert.equal(report.failed.length, 0);
+});
+
+add_task(async function test_sendtab_receive() {
+ // We are testing 'receive' here, but might as well go through 'send'
+ // to package the data and for additional testing...
+ const commands = {
+ _invokes: [],
+ invoke(cmd, device, payload) {
+ this._invokes.push({ cmd, device, payload });
+ },
+ };
+
+ const fxai = FxaInternalMock();
+ const sendTab = new SendTab(commands, fxai);
+ sendTab._encrypt = (bytes, device) => {
+ return bytes;
+ };
+ sendTab._decrypt = bytes => {
+ return bytes;
+ };
+ const tab = { title: "tab title", url: "http://example.com" };
+ const to = [{ id: "devid", name: "The Device" }];
+ const reason = "push";
+
+ await sendTab.send(to, tab);
+ Assert.equal(commands._invokes.length, 1);
+
+ for (let { cmd, device, payload } of commands._invokes) {
+ Assert.equal(cmd, COMMAND_SENDTAB);
+ // Older Firefoxes would send a plaintext flowID in the top-level payload.
+ // Test that we sensibly ignore it.
+ Assert.ok(!payload.hasOwnProperty("flowID"));
+ // change it - ensure we still get what we expect in telemetry later.
+ payload.flowID = "ignore-me";
+ Assert.deepEqual(await sendTab.handle(device.id, payload, reason), {
+ title: "tab title",
+ uri: "http://example.com",
+ });
+ }
+
+ Assert.deepEqual(fxai.telemetry._events, [
+ {
+ object: "command-sent",
+ method: COMMAND_SENDTAB_TAIL,
+ value: "devid-san",
+ extra: { flowID: "1", streamID: "2" },
+ },
+ {
+ object: "command-received",
+ method: COMMAND_SENDTAB_TAIL,
+ value: "devid-san",
+ extra: { flowID: "1", streamID: "2", reason },
+ },
+ ]);
+});
+
+// Test that a client which only sends the flowID in the envelope and not in the
+// encrypted body gets recorded without the flowID.
+add_task(async function test_sendtab_receive_old_client() {
+ const fxai = FxaInternalMock();
+ const sendTab = new SendTab(null, fxai);
+ sendTab._decrypt = bytes => {
+ return bytes;
+ };
+ const data = { entries: [{ title: "title", url: "url" }] };
+ // No 'flowID' in the encrypted payload, no 'streamID' anywhere.
+ const payload = {
+ flowID: "flow-id",
+ encrypted: new TextEncoder().encode(JSON.stringify(data)),
+ };
+ const reason = "push";
+ await sendTab.handle("sender-id", payload, reason);
+ Assert.deepEqual(fxai.telemetry._events, [
+ {
+ object: "command-received",
+ method: COMMAND_SENDTAB_TAIL,
+ value: "sender-id-san",
+ // deepEqual doesn't ignore undefined, but our telemetry code and
+ // JSON.stringify() do...
+ extra: { flowID: undefined, streamID: undefined, reason },
+ },
+ ]);
+});
+
+add_task(function test_commands_getReason() {
+ const fxAccounts = {
+ async withCurrentAccountState(cb) {
+ await cb({});
+ },
+ };
+ const commands = new FxAccountsCommands(fxAccounts);
+ const testCases = [
+ {
+ receivedIndex: 0,
+ currentIndex: 0,
+ expectedReason: "poll",
+ message: "should return reason 'poll'",
+ },
+ {
+ receivedIndex: 7,
+ currentIndex: 3,
+ expectedReason: "push-missed",
+ message: "should return reason 'push-missed'",
+ },
+ {
+ receivedIndex: 2,
+ currentIndex: 8,
+ expectedReason: "push",
+ message: "should return reason 'push'",
+ },
+ ];
+ for (const tc of testCases) {
+ const reason = commands._getReason(tc.receivedIndex, tc.currentIndex);
+ Assert.equal(reason, tc.expectedReason, tc.message);
+ }
+});
+
+add_task(async function test_commands_pollDeviceCommands_push() {
+ // Server state.
+ const remoteMessages = [
+ {
+ index: 11,
+ data: {},
+ },
+ {
+ index: 12,
+ data: {},
+ },
+ ];
+ const remoteIndex = 12;
+
+ // Local state.
+ const pushIndexReceived = 11;
+ const accountState = {
+ data: {
+ device: {
+ lastCommandIndex: 10,
+ },
+ },
+ getUserAccountData() {
+ return this.data;
+ },
+ updateUserAccountData(data) {
+ this.data = data;
+ },
+ };
+
+ const fxAccounts = {
+ async withCurrentAccountState(cb) {
+ await cb(accountState);
+ },
+ };
+ const commands = new FxAccountsCommands(fxAccounts);
+ const mockCommands = sinon.mock(commands);
+ mockCommands.expects("_fetchDeviceCommands").once().withArgs(11).returns({
+ index: remoteIndex,
+ messages: remoteMessages,
+ });
+ mockCommands
+ .expects("_handleCommands")
+ .once()
+ .withArgs(remoteMessages, pushIndexReceived);
+ await commands.pollDeviceCommands(pushIndexReceived);
+
+ mockCommands.verify();
+ Assert.equal(accountState.data.device.lastCommandIndex, 12);
+});
+
+add_task(
+ async function test_commands_pollDeviceCommands_push_already_fetched() {
+ // Local state.
+ const pushIndexReceived = 12;
+ const accountState = {
+ data: {
+ device: {
+ lastCommandIndex: 12,
+ },
+ },
+ getUserAccountData() {
+ return this.data;
+ },
+ updateUserAccountData(data) {
+ this.data = data;
+ },
+ };
+
+ const fxAccounts = {
+ async withCurrentAccountState(cb) {
+ await cb(accountState);
+ },
+ };
+ const commands = new FxAccountsCommands(fxAccounts);
+ const mockCommands = sinon.mock(commands);
+ mockCommands.expects("_fetchDeviceCommands").never();
+ mockCommands.expects("_handleCommands").never();
+ await commands.pollDeviceCommands(pushIndexReceived);
+
+ mockCommands.verify();
+ Assert.equal(accountState.data.device.lastCommandIndex, 12);
+ }
+);
+
+add_task(async function test_commands_handleCommands() {
+ // This test ensures that `_getReason` is being called by
+ // `_handleCommands` with the expected parameters.
+ const pushIndexReceived = 12;
+ const senderID = "6d09f6c4-89b2-41b3-a0ac-e4c2502b5485";
+ const remoteMessageIndex = 8;
+ const remoteMessages = [
+ {
+ index: remoteMessageIndex,
+ data: {
+ command: COMMAND_SENDTAB,
+ payload: {
+ encrypted: {},
+ },
+ sender: senderID,
+ },
+ },
+ ];
+
+ const fxAccounts = {
+ async withCurrentAccountState(cb) {
+ await cb({});
+ },
+ };
+ const commands = new FxAccountsCommands(fxAccounts);
+ commands.sendTab.handle = (sender, data, reason) => {
+ return {
+ title: "testTitle",
+ uri: "https://testURI",
+ };
+ };
+ commands._fxai.device = {
+ refreshDeviceList: () => {},
+ recentDeviceList: [
+ {
+ id: senderID,
+ },
+ ],
+ };
+ const mockCommands = sinon.mock(commands);
+ mockCommands
+ .expects("_getReason")
+ .once()
+ .withExactArgs(pushIndexReceived, remoteMessageIndex);
+ mockCommands.expects("_notifyFxATabsReceived").once();
+ await commands._handleCommands(remoteMessages, pushIndexReceived);
+ mockCommands.verify();
+});
+
+add_task(async function test_commands_handleCommands_invalid_tab() {
+ // This test ensures that `_getReason` is being called by
+ // `_handleCommands` with the expected parameters.
+ const pushIndexReceived = 12;
+ const senderID = "6d09f6c4-89b2-41b3-a0ac-e4c2502b5485";
+ const remoteMessageIndex = 8;
+ const remoteMessages = [
+ {
+ index: remoteMessageIndex,
+ data: {
+ command: COMMAND_SENDTAB,
+ payload: {
+ encrypted: {},
+ },
+ sender: senderID,
+ },
+ },
+ ];
+
+ const fxAccounts = {
+ async withCurrentAccountState(cb) {
+ await cb({});
+ },
+ };
+ const commands = new FxAccountsCommands(fxAccounts);
+ commands.sendTab.handle = (sender, data, reason) => {
+ return {
+ title: "badUriTab",
+ uri: "file://path/to/pdf",
+ };
+ };
+ commands._fxai.device = {
+ refreshDeviceList: () => {},
+ recentDeviceList: [
+ {
+ id: senderID,
+ },
+ ],
+ };
+ const mockCommands = sinon.mock(commands);
+ mockCommands
+ .expects("_getReason")
+ .once()
+ .withExactArgs(pushIndexReceived, remoteMessageIndex);
+ // We shouldn't have tried to open a tab with an invalid uri
+ mockCommands.expects("_notifyFxATabsReceived").never();
+
+ await commands._handleCommands(remoteMessages, pushIndexReceived);
+ mockCommands.verify();
+});
+
+add_task(
+ async function test_commands_pollDeviceCommands_push_local_state_empty() {
+ // Server state.
+ const remoteMessages = [
+ {
+ index: 11,
+ data: {},
+ },
+ {
+ index: 12,
+ data: {},
+ },
+ ];
+ const remoteIndex = 12;
+
+ // Local state.
+ const pushIndexReceived = 11;
+ const accountState = {
+ data: {
+ device: {},
+ },
+ getUserAccountData() {
+ return this.data;
+ },
+ updateUserAccountData(data) {
+ this.data = data;
+ },
+ };
+
+ const fxAccounts = {
+ async withCurrentAccountState(cb) {
+ await cb(accountState);
+ },
+ };
+ const commands = new FxAccountsCommands(fxAccounts);
+ const mockCommands = sinon.mock(commands);
+ mockCommands.expects("_fetchDeviceCommands").once().withArgs(11).returns({
+ index: remoteIndex,
+ messages: remoteMessages,
+ });
+ mockCommands
+ .expects("_handleCommands")
+ .once()
+ .withArgs(remoteMessages, pushIndexReceived);
+ await commands.pollDeviceCommands(pushIndexReceived);
+
+ mockCommands.verify();
+ Assert.equal(accountState.data.device.lastCommandIndex, 12);
+ }
+);
+
+add_task(async function test_commands_pollDeviceCommands_scheduled_local() {
+ // Server state.
+ const remoteMessages = [
+ {
+ index: 11,
+ data: {},
+ },
+ {
+ index: 12,
+ data: {},
+ },
+ ];
+ const remoteIndex = 12;
+ const pushIndexReceived = 0;
+ // Local state.
+ const accountState = {
+ data: {
+ device: {
+ lastCommandIndex: 10,
+ },
+ },
+ getUserAccountData() {
+ return this.data;
+ },
+ updateUserAccountData(data) {
+ this.data = data;
+ },
+ };
+
+ const fxAccounts = {
+ async withCurrentAccountState(cb) {
+ await cb(accountState);
+ },
+ };
+ const commands = new FxAccountsCommands(fxAccounts);
+ const mockCommands = sinon.mock(commands);
+ mockCommands.expects("_fetchDeviceCommands").once().withArgs(11).returns({
+ index: remoteIndex,
+ messages: remoteMessages,
+ });
+ mockCommands
+ .expects("_handleCommands")
+ .once()
+ .withArgs(remoteMessages, pushIndexReceived);
+ await commands.pollDeviceCommands();
+
+ mockCommands.verify();
+ Assert.equal(accountState.data.device.lastCommandIndex, 12);
+});
+
+add_task(
+ async function test_commands_pollDeviceCommands_scheduled_local_state_empty() {
+ // Server state.
+ const remoteMessages = [
+ {
+ index: 11,
+ data: {},
+ },
+ {
+ index: 12,
+ data: {},
+ },
+ ];
+ const remoteIndex = 12;
+ const pushIndexReceived = 0;
+ // Local state.
+ const accountState = {
+ data: {
+ device: {},
+ },
+ getUserAccountData() {
+ return this.data;
+ },
+ updateUserAccountData(data) {
+ this.data = data;
+ },
+ };
+
+ const fxAccounts = {
+ async withCurrentAccountState(cb) {
+ await cb(accountState);
+ },
+ };
+ const commands = new FxAccountsCommands(fxAccounts);
+ const mockCommands = sinon.mock(commands);
+ mockCommands.expects("_fetchDeviceCommands").once().withArgs(0).returns({
+ index: remoteIndex,
+ messages: remoteMessages,
+ });
+ mockCommands
+ .expects("_handleCommands")
+ .once()
+ .withArgs(remoteMessages, pushIndexReceived);
+ await commands.pollDeviceCommands();
+
+ mockCommands.verify();
+ Assert.equal(accountState.data.device.lastCommandIndex, 12);
+ }
+);
+
+add_task(async function test_send_tab_keys_regenerated_if_lost() {
+ const commands = {
+ _invokes: [],
+ invoke(cmd, device, payload) {
+ this._invokes.push({ cmd, device, payload });
+ },
+ };
+
+ // Local state.
+ const accountState = {
+ data: {
+ // Since the device object has no
+ // sendTabKeys, it will recover
+ // when we attempt to get the
+ // encryptedSendTabKeys
+ device: {
+ lastCommandIndex: 10,
+ },
+ encryptedSendTabKeys: "keys",
+ },
+ getUserAccountData() {
+ return this.data;
+ },
+ updateUserAccountData(data) {
+ this.data = data;
+ },
+ };
+
+ const fxAccounts = {
+ async withCurrentAccountState(cb) {
+ await cb(accountState);
+ },
+ async getUserAccountData(data) {
+ return accountState.getUserAccountData(data);
+ },
+ telemetry: new TelemetryMock(),
+ };
+ const sendTab = new SendTab(commands, fxAccounts);
+ let generateEncryptedKeysCalled = false;
+ sendTab._generateAndPersistEncryptedSendTabKey = async () => {
+ generateEncryptedKeysCalled = true;
+ };
+ await sendTab.getEncryptedSendTabKeys();
+ Assert.ok(generateEncryptedKeysCalled);
+});
+
+add_task(async function test_send_tab_keys_are_not_regenerated_if_not_lost() {
+ const commands = {
+ _invokes: [],
+ invoke(cmd, device, payload) {
+ this._invokes.push({ cmd, device, payload });
+ },
+ };
+
+ // Local state.
+ const accountState = {
+ data: {
+ // Since the device object has
+ // sendTabKeys, it will not try
+ // to regenerate them
+ // when we attempt to get the
+ // encryptedSendTabKeys
+ device: {
+ lastCommandIndex: 10,
+ sendTabKeys: "keys",
+ },
+ encryptedSendTabKeys: "encrypted-keys",
+ },
+ getUserAccountData() {
+ return this.data;
+ },
+ updateUserAccountData(data) {
+ this.data = data;
+ },
+ };
+
+ const fxAccounts = {
+ async withCurrentAccountState(cb) {
+ await cb(accountState);
+ },
+ async getUserAccountData(data) {
+ return accountState.getUserAccountData(data);
+ },
+ telemetry: new TelemetryMock(),
+ };
+ const sendTab = new SendTab(commands, fxAccounts);
+ let generateEncryptedKeysCalled = false;
+ sendTab._generateAndPersistEncryptedSendTabKey = async () => {
+ generateEncryptedKeysCalled = true;
+ };
+ await sendTab.getEncryptedSendTabKeys();
+ Assert.ok(!generateEncryptedKeysCalled);
+});
diff --git a/services/fxaccounts/tests/xpcshell/test_credentials.js b/services/fxaccounts/tests/xpcshell/test_credentials.js
new file mode 100644
index 0000000000..c3656f219d
--- /dev/null
+++ b/services/fxaccounts/tests/xpcshell/test_credentials.js
@@ -0,0 +1,130 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+const { Credentials } = ChromeUtils.importESModule(
+ "resource://gre/modules/Credentials.sys.mjs"
+);
+const { CryptoUtils } = ChromeUtils.importESModule(
+ "resource://services-crypto/utils.sys.mjs"
+);
+
+var { hexToBytes: h2b, hexAsString: h2s, bytesAsHex: b2h } = CommonUtils;
+
+// Test vectors for the "onepw" protocol:
+// https://github.com/mozilla/fxa-auth-server/wiki/onepw-protocol#wiki-test-vectors
+var vectors = {
+ "client stretch-KDF": {
+ email: h("616e6472c3a94065 78616d706c652e6f 7267"),
+ password: h("70c3a4737377c3b6 7264"),
+ quickStretchedPW: h(
+ "e4e8889bd8bd61ad 6de6b95c059d56e7 b50dacdaf62bd846 44af7e2add84345d"
+ ),
+ authPW: h(
+ "247b675ffb4c4631 0bc87e26d712153a be5e1c90ef00a478 4594f97ef54f2375"
+ ),
+ authSalt: h(
+ "00f0000000000000 0000000000000000 0000000000000000 0000000000000000"
+ ),
+ },
+};
+
+// A simple test suite with no utf8 encoding madness.
+add_task(async function test_onepw_setup_credentials() {
+ let email = "francine@example.org";
+ let password = CommonUtils.encodeUTF8("i like pie");
+
+ let pbkdf2 = CryptoUtils.pbkdf2Generate;
+ let hkdf = CryptoUtils.hkdfLegacy;
+
+ // quickStretch the email
+ let saltyEmail = Credentials.keyWordExtended("quickStretch", email);
+
+ Assert.equal(
+ b2h(saltyEmail),
+ "6964656e746974792e6d6f7a696c6c612e636f6d2f7069636c2f76312f717569636b537472657463683a6672616e63696e65406578616d706c652e6f7267"
+ );
+
+ let pbkdf2Rounds = 1000;
+ let pbkdf2Len = 32;
+
+ let quickStretchedPW = await pbkdf2(
+ password,
+ saltyEmail,
+ pbkdf2Rounds,
+ pbkdf2Len
+ );
+ let quickStretchedActual =
+ "6b88094c1c73bbf133223f300d101ed70837af48d9d2c1b6e7d38804b20cdde4";
+ Assert.equal(b2h(quickStretchedPW), quickStretchedActual);
+
+ // obtain hkdf info
+ let authKeyInfo = Credentials.keyWord("authPW");
+ Assert.equal(
+ b2h(authKeyInfo),
+ "6964656e746974792e6d6f7a696c6c612e636f6d2f7069636c2f76312f617574685057"
+ );
+
+ // derive auth password
+ let hkdfSalt = h2b("00");
+ let hkdfLen = 32;
+ let authPW = await hkdf(quickStretchedPW, hkdfSalt, authKeyInfo, hkdfLen);
+
+ Assert.equal(
+ b2h(authPW),
+ "4b8dec7f48e7852658163601ff766124c312f9392af6c3d4e1a247eb439be342"
+ );
+
+ // derive unwrap key
+ let unwrapKeyInfo = Credentials.keyWord("unwrapBkey");
+ let unwrapKey = await hkdf(
+ quickStretchedPW,
+ hkdfSalt,
+ unwrapKeyInfo,
+ hkdfLen
+ );
+
+ Assert.equal(
+ b2h(unwrapKey),
+ "8ff58975be391338e4ec5d7138b5ed7b65c7d1bfd1f3a4f93e05aa47d5b72be9"
+ );
+});
+
+add_task(async function test_client_stretch_kdf() {
+ let expected = vectors["client stretch-KDF"];
+
+ let email = h2s(expected.email);
+ let password = h2s(expected.password);
+
+ // Intermediate value from sjcl implementation in fxa-js-client
+ // The key thing is the c3a9 sequence in "andré"
+ let salt = Credentials.keyWordExtended("quickStretch", email);
+ Assert.equal(
+ b2h(salt),
+ "6964656e746974792e6d6f7a696c6c612e636f6d2f7069636c2f76312f717569636b537472657463683a616e6472c3a9406578616d706c652e6f7267"
+ );
+
+ let options = {
+ stretchedPassLength: 32,
+ pbkdf2Rounds: 1000,
+ hkdfSalt: h2b("00"),
+ hkdfLength: 32,
+ };
+
+ let results = await Credentials.setup(email, password, options);
+
+ Assert.equal(
+ expected.quickStretchedPW,
+ b2h(results.quickStretchedPW),
+ "quickStretchedPW is wrong"
+ );
+
+ Assert.equal(expected.authPW, b2h(results.authPW), "authPW is wrong");
+});
+
+// End of tests
+// Utility functions follow
+
+// turn formatted test vectors into normal hex strings
+function h(hexStr) {
+ return hexStr.replace(/\s+/g, "");
+}
diff --git a/services/fxaccounts/tests/xpcshell/test_device.js b/services/fxaccounts/tests/xpcshell/test_device.js
new file mode 100644
index 0000000000..037db2b101
--- /dev/null
+++ b/services/fxaccounts/tests/xpcshell/test_device.js
@@ -0,0 +1,127 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { getFxAccountsSingleton } = ChromeUtils.importESModule(
+ "resource://gre/modules/FxAccounts.sys.mjs"
+);
+const fxAccounts = getFxAccountsSingleton();
+
+const { ON_NEW_DEVICE_ID, PREF_ACCOUNT_ROOT } = ChromeUtils.importESModule(
+ "resource://gre/modules/FxAccountsCommon.sys.mjs"
+);
+
+function promiseObserved(topic) {
+ return new Promise(res => {
+ Services.obs.addObserver(res, topic);
+ });
+}
+
+_("Misc tests for FxAccounts.device");
+
+fxAccounts.device._checkRemoteCommandsUpdateNeeded = async () => false;
+
+add_test(function test_default_device_name() {
+ // Note that head_helpers overrides getDefaultLocalName - this test is
+ // really just to ensure the actual implementation is sane - we can't
+ // really check the value it uses is correct.
+ // We are just hoping to avoid a repeat of bug 1369285.
+ let def = fxAccounts.device.getDefaultLocalName(); // make sure it doesn't throw.
+ _("default value is " + def);
+ ok(!!def.length);
+
+ // This is obviously tied to the implementation, but we want early warning
+ // if any of these things fail.
+ // We really want one of these 2 to provide a value.
+ let hostname = Services.sysinfo.get("device") || Services.dns.myHostName;
+ _("hostname is " + hostname);
+ ok(!!hostname.length);
+ // the hostname should be in the default.
+ ok(def.includes(hostname));
+ // We expect the following to work as a fallback to the above.
+ let fallback = Cc["@mozilla.org/network/protocol;1?name=http"].getService(
+ Ci.nsIHttpProtocolHandler
+ ).oscpu;
+ _("UA fallback is " + fallback);
+ ok(!!fallback.length);
+ // the fallback should not be in the default
+ ok(!def.includes(fallback));
+
+ run_next_test();
+});
+
+add_test(function test_migration() {
+ Services.prefs.clearUserPref("identity.fxaccounts.account.device.name");
+ Services.prefs.setStringPref("services.sync.client.name", "my client name");
+ // calling getLocalName() should move the name to the new pref and reset the old.
+ equal(fxAccounts.device.getLocalName(), "my client name");
+ equal(
+ Services.prefs.getStringPref("identity.fxaccounts.account.device.name"),
+ "my client name"
+ );
+ ok(!Services.prefs.prefHasUserValue("services.sync.client.name"));
+ run_next_test();
+});
+
+add_test(function test_migration_set_before_get() {
+ Services.prefs.setStringPref("services.sync.client.name", "old client name");
+ fxAccounts.device.setLocalName("new client name");
+ equal(fxAccounts.device.getLocalName(), "new client name");
+ run_next_test();
+});
+
+add_task(async function test_reset() {
+ // We don't test the client name specifically here because the client name
+ // is set as part of signing the user in via the attempt to register the
+ // device.
+ const testPref = PREF_ACCOUNT_ROOT + "test-pref";
+ Services.prefs.setStringPref(testPref, "whatever");
+ let credentials = {
+ email: "foo@example.com",
+ uid: "1234@lcip.org",
+ sessionToken: "dead",
+ verified: true,
+ ...MOCK_ACCOUNT_KEYS,
+ };
+ // FxA will try to register its device record in the background after signin.
+ const registerDevice = sinon
+ .stub(fxAccounts._internal.fxAccountsClient, "registerDevice")
+ .callsFake(async () => {
+ return { id: "foo" };
+ });
+ await fxAccounts._internal.setSignedInUser(credentials);
+ // wait for device registration to complete.
+ await promiseObserved(ON_NEW_DEVICE_ID);
+ ok(!Services.prefs.prefHasUserValue(testPref));
+ // signing the user out should reset the name pref.
+ const namePref = PREF_ACCOUNT_ROOT + "device.name";
+ ok(Services.prefs.prefHasUserValue(namePref));
+ await fxAccounts.signOut(/* localOnly = */ true);
+ ok(!Services.prefs.prefHasUserValue(namePref));
+ registerDevice.restore();
+});
+
+add_task(async function test_name_sanitization() {
+ fxAccounts.device.setLocalName("emoji is valid \u2665");
+ Assert.equal(fxAccounts.device.getLocalName(), "emoji is valid \u2665");
+
+ let invalid = "x\uFFFD\n\r\t" + "x".repeat(255);
+ let sanitized = "x\uFFFD\uFFFD\uFFFD\uFFFD" + "x".repeat(250); // 255 total.
+
+ // If the pref already has the invalid value we still get the valid one back.
+ Services.prefs.setStringPref(
+ "identity.fxaccounts.account.device.name",
+ invalid
+ );
+ Assert.equal(fxAccounts.device.getLocalName(), sanitized);
+
+ // But if we explicitly set it to an invalid name, the sanitized value ends
+ // up in the pref.
+ fxAccounts.device.setLocalName(invalid);
+ Assert.equal(fxAccounts.device.getLocalName(), sanitized);
+ Assert.equal(
+ Services.prefs.getStringPref("identity.fxaccounts.account.device.name"),
+ sanitized
+ );
+});
diff --git a/services/fxaccounts/tests/xpcshell/test_keys.js b/services/fxaccounts/tests/xpcshell/test_keys.js
new file mode 100644
index 0000000000..6e650a1609
--- /dev/null
+++ b/services/fxaccounts/tests/xpcshell/test_keys.js
@@ -0,0 +1,182 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { FxAccountsKeys } = ChromeUtils.importESModule(
+ "resource://gre/modules/FxAccountsKeys.sys.mjs"
+);
+
+// Ref https://github.com/mozilla/fxa-crypto-relier/ for the details
+// of these test vectors.
+
+add_task(async function test_derive_scoped_key_test_vector() {
+ const keys = new FxAccountsKeys(null);
+ const uid = "aeaa1725c7a24ff983c6295725d5fc9b";
+ const kB = "8b2e1303e21eee06a945683b8d495b9bf079ca30baa37eb8392d9ffa4767be45";
+ const scopedKeyMetadata = {
+ identifier: "app_key:https%3A//example.com",
+ keyRotationTimestamp: 1510726317000,
+ keyRotationSecret:
+ "517d478cb4f994aa69930416648a416fdaa1762c5abf401a2acf11a0f185e98d",
+ };
+
+ const scopedKey = await keys._deriveScopedKey(
+ uid,
+ CommonUtils.hexToBytes(kB),
+ "app_key",
+ scopedKeyMetadata
+ );
+
+ Assert.deepEqual(scopedKey, {
+ kty: "oct",
+ kid: "1510726317-Voc-Eb9IpoTINuo9ll7bjA",
+ k: "Kkbk1_Q0oCcTmggeDH6880bQrxin2RLu5D00NcJazdQ",
+ });
+});
+
+add_task(async function test_derive_legacy_sync_key_test_vector() {
+ const keys = new FxAccountsKeys(null);
+ const uid = "aeaa1725c7a24ff983c6295725d5fc9b";
+ const kB = "eaf9570b7219a4187d3d6bf3cec2770c2e0719b7cc0dfbb38243d6f1881675e9";
+ const scopedKeyMetadata = {
+ identifier: "https://identity.mozilla.com/apps/oldsync",
+ keyRotationTimestamp: 1510726317123,
+ keyRotationSecret:
+ "0000000000000000000000000000000000000000000000000000000000000000",
+ };
+
+ const scopedKey = await keys._deriveLegacyScopedKey(
+ uid,
+ CommonUtils.hexToBytes(kB),
+ "https://identity.mozilla.com/apps/oldsync",
+ scopedKeyMetadata
+ );
+
+ Assert.deepEqual(scopedKey, {
+ kty: "oct",
+ kid: "1510726317123-IqQv4onc7VcVE1kTQkyyOw",
+ k: "DW_ll5GwX6SJ5GPqJVAuMUP2t6kDqhUulc2cbt26xbTcaKGQl-9l29FHAQ7kUiJETma4s9fIpEHrt909zgFang",
+ });
+});
+
+add_task(async function test_derive_multiple_keys_at_once() {
+ const keys = new FxAccountsKeys(null);
+ const uid = "aeaa1725c7a24ff983c6295725d5fc9b";
+ const kB = "eaf9570b7219a4187d3d6bf3cec2770c2e0719b7cc0dfbb38243d6f1881675e9";
+ const scopedKeysMetadata = {
+ app_key: {
+ identifier: "app_key:https%3A//example.com",
+ keyRotationTimestamp: 1510726317000,
+ keyRotationSecret:
+ "517d478cb4f994aa69930416648a416fdaa1762c5abf401a2acf11a0f185e98d",
+ },
+ "https://identity.mozilla.com/apps/oldsync": {
+ identifier: "https://identity.mozilla.com/apps/oldsync",
+ keyRotationTimestamp: 1510726318123,
+ keyRotationSecret:
+ "0000000000000000000000000000000000000000000000000000000000000000",
+ },
+ };
+
+ const scopedKeys = await keys._deriveScopedKeys(
+ uid,
+ CommonUtils.hexToBytes(kB),
+ scopedKeysMetadata
+ );
+
+ Assert.deepEqual(scopedKeys, {
+ app_key: {
+ kty: "oct",
+ kid: "1510726317-tUkxiR1lTlFrTgkF0tJidA",
+ k: "TYK6Hmj86PfKiqsk9DZmX61nxk9VsExGrwo94HP-0wU",
+ },
+ "https://identity.mozilla.com/apps/oldsync": {
+ kty: "oct",
+ kid: "1510726318123-IqQv4onc7VcVE1kTQkyyOw",
+ k: "DW_ll5GwX6SJ5GPqJVAuMUP2t6kDqhUulc2cbt26xbTcaKGQl-9l29FHAQ7kUiJETma4s9fIpEHrt909zgFang",
+ },
+ });
+});
+
+add_task(async function test_rejects_bad_scoped_key_data() {
+ const keys = new FxAccountsKeys(null);
+ const uid = "aeaa1725c7a24ff983c6295725d5fc9b";
+ const kB = "8b2e1303e21eee06a945683b8d495b9bf079ca30baa37eb8392d9ffa4767be45";
+ const scopedKeyMetadata = {
+ identifier: "app_key:https%3A//example.com",
+ keyRotationTimestamp: 1510726317000,
+ keyRotationSecret:
+ "517d478cb4f994aa69930416648a416fdaa1762c5abf401a2acf11a0f185e98d",
+ };
+
+ await Assert.rejects(
+ keys._deriveScopedKey(
+ uid.slice(0, -1),
+ CommonUtils.hexToBytes(kB),
+ "app_key",
+ scopedKeyMetadata
+ ),
+ /uid must be a 32-character hex string/
+ );
+ await Assert.rejects(
+ keys._deriveScopedKey(
+ uid.slice(0, -1) + "Q",
+ CommonUtils.hexToBytes(kB),
+ "app_key",
+ scopedKeyMetadata
+ ),
+ /uid must be a 32-character hex string/
+ );
+ await Assert.rejects(
+ keys._deriveScopedKey(
+ uid,
+ CommonUtils.hexToBytes(kB).slice(0, -1),
+ "app_key",
+ scopedKeyMetadata
+ ),
+ /kBbytes must be exactly 32 bytes/
+ );
+ await Assert.rejects(
+ keys._deriveScopedKey(uid, CommonUtils.hexToBytes(kB), "app_key", {
+ ...scopedKeyMetadata,
+ identifier: "foo",
+ }),
+ /identifier must be a string of length >= 10/
+ );
+ await Assert.rejects(
+ keys._deriveScopedKey(uid, CommonUtils.hexToBytes(kB), "app_key", {
+ ...scopedKeyMetadata,
+ identifier: {},
+ }),
+ /identifier must be a string of length >= 10/
+ );
+ await Assert.rejects(
+ keys._deriveScopedKey(uid, CommonUtils.hexToBytes(kB), "app_key", {
+ ...scopedKeyMetadata,
+ keyRotationTimestamp: "xyz",
+ }),
+ /keyRotationTimestamp must be a number/
+ );
+ await Assert.rejects(
+ keys._deriveScopedKey(uid, CommonUtils.hexToBytes(kB), "app_key", {
+ ...scopedKeyMetadata,
+ keyRotationTimestamp: 12345,
+ }),
+ /keyRotationTimestamp must round to a 10-digit number/
+ );
+ await Assert.rejects(
+ keys._deriveScopedKey(uid, CommonUtils.hexToBytes(kB), "app_key", {
+ ...scopedKeyMetadata,
+ keyRotationSecret: scopedKeyMetadata.keyRotationSecret.slice(0, -1),
+ }),
+ /keyRotationSecret must be a 64-character hex string/
+ );
+ await Assert.rejects(
+ keys._deriveScopedKey(uid, CommonUtils.hexToBytes(kB), "app_key", {
+ ...scopedKeyMetadata,
+ keyRotationSecret: scopedKeyMetadata.keyRotationSecret.slice(0, -1) + "z",
+ }),
+ /keyRotationSecret must be a 64-character hex string/
+ );
+});
diff --git a/services/fxaccounts/tests/xpcshell/test_loginmgr_storage.js b/services/fxaccounts/tests/xpcshell/test_loginmgr_storage.js
new file mode 100644
index 0000000000..5b80035418
--- /dev/null
+++ b/services/fxaccounts/tests/xpcshell/test_loginmgr_storage.js
@@ -0,0 +1,307 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Tests for FxAccounts, storage and the master password.
+
+// See verbose logging from FxAccounts.jsm
+Services.prefs.setStringPref("identity.fxaccounts.loglevel", "Trace");
+
+const { FxAccounts } = ChromeUtils.importESModule(
+ "resource://gre/modules/FxAccounts.sys.mjs"
+);
+const { FXA_PWDMGR_HOST, FXA_PWDMGR_REALM } = ChromeUtils.importESModule(
+ "resource://gre/modules/FxAccountsCommon.sys.mjs"
+);
+
+// Use a backstage pass to get at our LoginManagerStorage object, so we can
+// mock the prototype.
+var { LoginManagerStorage } = ChromeUtils.importESModule(
+ "resource://gre/modules/FxAccountsStorage.sys.mjs"
+);
+var isLoggedIn = true;
+LoginManagerStorage.prototype.__defineGetter__("_isLoggedIn", () => isLoggedIn);
+
+function setLoginMgrLoggedInState(loggedIn) {
+ isLoggedIn = loggedIn;
+}
+
+initTestLogging("Trace");
+
+async function getLoginMgrData() {
+ let logins = await Services.logins.searchLoginsAsync({
+ origin: FXA_PWDMGR_HOST,
+ httpRealm: FXA_PWDMGR_REALM,
+ });
+ if (!logins.length) {
+ return null;
+ }
+ Assert.equal(logins.length, 1, "only 1 login available");
+ return logins[0];
+}
+
+function createFxAccounts() {
+ return new FxAccounts({
+ _fxAccountsClient: {
+ async registerDevice() {
+ return { id: "deviceAAAAAA" };
+ },
+ async recoveryEmailStatus() {
+ return { verified: true };
+ },
+ async signOut() {},
+ },
+ updateDeviceRegistration() {},
+ _getDeviceName() {
+ return "mock device name";
+ },
+ observerPreloads: [],
+ fxaPushService: {
+ async registerPushEndpoint() {
+ return {
+ endpoint: "http://mochi.test:8888",
+ getKey() {
+ return null;
+ },
+ };
+ },
+ async unsubscribe() {
+ return true;
+ },
+ },
+ });
+}
+
+add_task(async function test_simple() {
+ let fxa = createFxAccounts();
+
+ let creds = {
+ uid: "abcd",
+ email: "test@example.com",
+ sessionToken: "sessionToken",
+ scopedKeys: {
+ ...MOCK_ACCOUNT_KEYS.scopedKeys,
+ },
+ verified: true,
+ };
+ await fxa._internal.setSignedInUser(creds);
+
+ // This should have stored stuff in both the .json file in the profile
+ // dir, and the login dir.
+ let path = PathUtils.join(PathUtils.profileDir, "signedInUser.json");
+ let data = await IOUtils.readJSON(path);
+
+ Assert.strictEqual(
+ data.accountData.email,
+ creds.email,
+ "correct email in the clear text"
+ );
+ Assert.strictEqual(
+ data.accountData.sessionToken,
+ creds.sessionToken,
+ "correct sessionToken in the clear text"
+ );
+ Assert.strictEqual(
+ data.accountData.verified,
+ creds.verified,
+ "correct verified flag"
+ );
+
+ Assert.ok(
+ !("scopedKeys" in data.accountData),
+ "scopedKeys not stored in clear text"
+ );
+
+ let login = await getLoginMgrData();
+ Assert.strictEqual(login.username, creds.uid, "uid used for username");
+ let loginData = JSON.parse(login.password);
+ Assert.strictEqual(
+ loginData.version,
+ data.version,
+ "same version flag in both places"
+ );
+ Assert.deepEqual(
+ loginData.accountData.scopedKeys,
+ creds.scopedKeys,
+ "correct scoped keys in the login mgr"
+ );
+ Assert.ok(!("email" in loginData), "email not stored in the login mgr json");
+ Assert.ok(
+ !("sessionToken" in loginData),
+ "sessionToken not stored in the login mgr json"
+ );
+ Assert.ok(
+ !("verified" in loginData),
+ "verified not stored in the login mgr json"
+ );
+
+ await fxa.signOut(/* localOnly = */ true);
+ Assert.strictEqual(
+ await getLoginMgrData(),
+ null,
+ "login mgr data deleted on logout"
+ );
+});
+
+add_task(async function test_MPLocked() {
+ let fxa = createFxAccounts();
+
+ let creds = {
+ uid: "abcd",
+ email: "test@example.com",
+ sessionToken: "sessionToken",
+ scopedKeys: {
+ ...MOCK_ACCOUNT_KEYS.scopedKeys,
+ },
+ verified: true,
+ };
+
+ Assert.strictEqual(
+ await getLoginMgrData(),
+ null,
+ "no login mgr at the start"
+ );
+ // tell the storage that the MP is locked.
+ setLoginMgrLoggedInState(false);
+ await fxa._internal.setSignedInUser(creds);
+
+ // This should have stored stuff in the .json, and the login manager stuff
+ // will not exist.
+ let path = PathUtils.join(PathUtils.profileDir, "signedInUser.json");
+ let data = await IOUtils.readJSON(path);
+
+ Assert.strictEqual(
+ data.accountData.email,
+ creds.email,
+ "correct email in the clear text"
+ );
+ Assert.strictEqual(
+ data.accountData.sessionToken,
+ creds.sessionToken,
+ "correct sessionToken in the clear text"
+ );
+ Assert.strictEqual(
+ data.accountData.verified,
+ creds.verified,
+ "correct verified flag"
+ );
+
+ Assert.ok(
+ !("scopedKeys" in data.accountData),
+ "scopedKeys not stored in clear text"
+ );
+
+ Assert.strictEqual(
+ await getLoginMgrData(),
+ null,
+ "login mgr data doesn't exist"
+ );
+ await fxa.signOut(/* localOnly = */ true);
+});
+
+add_task(async function test_consistentWithMPEdgeCases() {
+ setLoginMgrLoggedInState(true);
+
+ let fxa = createFxAccounts();
+
+ let creds1 = {
+ uid: "uid1",
+ email: "test@example.com",
+ sessionToken: "sessionToken",
+ scopedKeys: {
+ [SCOPE_OLD_SYNC]: {
+ kid: "key id 1",
+ k: "key material 1",
+ kty: "oct",
+ },
+ },
+ verified: true,
+ };
+
+ let creds2 = {
+ uid: "uid2",
+ email: "test2@example.com",
+ sessionToken: "sessionToken2",
+ [SCOPE_OLD_SYNC]: {
+ kid: "key id 2",
+ k: "key material 2",
+ kty: "oct",
+ },
+ verified: false,
+ };
+
+ // Log a user in while MP is unlocked.
+ await fxa._internal.setSignedInUser(creds1);
+
+ // tell the storage that the MP is locked - this will prevent logout from
+ // being able to clear the data.
+ setLoginMgrLoggedInState(false);
+
+ // now set the second credentials.
+ await fxa._internal.setSignedInUser(creds2);
+
+ // We should still have creds1 data in the login manager.
+ let login = await getLoginMgrData();
+ Assert.strictEqual(login.username, creds1.uid);
+ // and that we do have the first scopedKeys in the login manager.
+ Assert.deepEqual(
+ JSON.parse(login.password).accountData.scopedKeys,
+ creds1.scopedKeys,
+ "stale data still in login mgr"
+ );
+
+ // Make a new FxA instance (otherwise the values in memory will be used)
+ // and we want the login manager to be unlocked.
+ setLoginMgrLoggedInState(true);
+ fxa = createFxAccounts();
+
+ let accountData = await fxa.getSignedInUser();
+ Assert.strictEqual(accountData.email, creds2.email);
+ // we should have no scopedKeys at all.
+ Assert.strictEqual(
+ accountData.scopedKeys,
+ undefined,
+ "stale scopedKey wasn't used"
+ );
+ await fxa.signOut(/* localOnly = */ true);
+});
+
+// A test for the fact we will accept either a UID or email when looking in
+// the login manager.
+add_task(async function test_uidMigration() {
+ setLoginMgrLoggedInState(true);
+ Assert.strictEqual(
+ await getLoginMgrData(),
+ null,
+ "expect no logins at the start"
+ );
+
+ // create the login entry using email as a key.
+ let contents = {
+ scopedKeys: {
+ ...MOCK_ACCOUNT_KEYS.scopedKeys,
+ },
+ };
+
+ let loginInfo = new Components.Constructor(
+ "@mozilla.org/login-manager/loginInfo;1",
+ Ci.nsILoginInfo,
+ "init"
+ );
+ let login = new loginInfo(
+ FXA_PWDMGR_HOST,
+ null, // aFormActionOrigin,
+ FXA_PWDMGR_REALM, // aHttpRealm,
+ "foo@bar.com", // aUsername
+ JSON.stringify(contents), // aPassword
+ "", // aUsernameField
+ ""
+ ); // aPasswordField
+ await Services.logins.addLoginAsync(login);
+
+ // ensure we read it.
+ let storage = new LoginManagerStorage();
+ let got = await storage.get("uid", "foo@bar.com");
+ Assert.deepEqual(got, contents);
+});
diff --git a/services/fxaccounts/tests/xpcshell/test_oauth_flow.js b/services/fxaccounts/tests/xpcshell/test_oauth_flow.js
new file mode 100644
index 0000000000..ef5102ae17
--- /dev/null
+++ b/services/fxaccounts/tests/xpcshell/test_oauth_flow.js
@@ -0,0 +1,274 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/* global crypto */
+
+"use strict";
+
+const {
+ FxAccountsOAuth,
+ ERROR_INVALID_SCOPES,
+ ERROR_INVALID_STATE,
+ ERROR_SYNC_SCOPE_NOT_GRANTED,
+ ERROR_NO_KEYS_JWE,
+ ERROR_OAUTH_FLOW_ABANDONED,
+} = ChromeUtils.importESModule(
+ "resource://gre/modules/FxAccountsOAuth.sys.mjs"
+);
+
+const { SCOPE_PROFILE, FX_OAUTH_CLIENT_ID } = ChromeUtils.importESModule(
+ "resource://gre/modules/FxAccountsCommon.sys.mjs"
+);
+
+ChromeUtils.defineESModuleGetters(this, {
+ jwcrypto: "resource://services-crypto/jwcrypto.sys.mjs",
+});
+
+initTestLogging("Trace");
+
+add_task(function test_begin_oauth_flow() {
+ const oauth = new FxAccountsOAuth();
+ add_task(async function test_begin_oauth_flow_invalid_scopes() {
+ try {
+ await oauth.beginOAuthFlow("foo,fi,fum", "foo");
+ Assert.fail("Should have thrown error, scopes must be an array");
+ } catch (e) {
+ Assert.equal(e.message, ERROR_INVALID_SCOPES);
+ }
+ try {
+ await oauth.beginOAuthFlow(["not-a-real-scope", SCOPE_PROFILE]);
+ Assert.fail("Should have thrown an error, must use a valid scope");
+ } catch (e) {
+ Assert.equal(e.message, ERROR_INVALID_SCOPES);
+ }
+ });
+ add_task(async function test_begin_oauth_flow_ok() {
+ const scopes = [SCOPE_PROFILE, SCOPE_OLD_SYNC];
+ const queryParams = await oauth.beginOAuthFlow(scopes);
+
+ // First verify default query parameters
+ Assert.equal(queryParams.client_id, FX_OAUTH_CLIENT_ID);
+ Assert.equal(queryParams.action, "email");
+ Assert.equal(queryParams.response_type, "code");
+ Assert.equal(queryParams.access_type, "offline");
+ Assert.equal(queryParams.scope, [SCOPE_PROFILE, SCOPE_OLD_SYNC].join(" "));
+
+ // Then, we verify that the state is a valid Base64 value
+ const state = queryParams.state;
+ ChromeUtils.base64URLDecode(state, { padding: "reject" });
+
+ // Then, we verify that the codeVerifier, can be used to verify the code_challenge
+ const code_challenge = queryParams.code_challenge;
+ Assert.equal(queryParams.code_challenge_method, "S256");
+ const oauthFlow = oauth.getFlow(state);
+ const codeVerifierB64 = oauthFlow.verifier;
+ const expectedChallenge = await crypto.subtle.digest(
+ "SHA-256",
+ new TextEncoder().encode(codeVerifierB64)
+ );
+ const expectedChallengeB64 = ChromeUtils.base64URLEncode(
+ expectedChallenge,
+ { pad: false }
+ );
+ Assert.equal(expectedChallengeB64, code_challenge);
+
+ // Then, we verify that something encrypted with the `keys_jwk`, can be decrypted using the private key
+ const keysJwk = queryParams.keys_jwk;
+ const decodedKeysJwk = JSON.parse(
+ new TextDecoder().decode(
+ ChromeUtils.base64URLDecode(keysJwk, { padding: "reject" })
+ )
+ );
+ const plaintext = "text to be encrypted and decrypted!";
+ delete decodedKeysJwk.key_ops;
+ const jwe = await jwcrypto.generateJWE(
+ decodedKeysJwk,
+ new TextEncoder().encode(plaintext)
+ );
+ const privateKey = oauthFlow.key;
+ const decrypted = await jwcrypto.decryptJWE(jwe, privateKey);
+ Assert.equal(new TextDecoder().decode(decrypted), plaintext);
+
+ // Finally, we verify that we stored the requested scopes
+ Assert.deepEqual(oauthFlow.requestedScopes, scopes.join(" "));
+ });
+});
+
+add_task(function test_complete_oauth_flow() {
+ add_task(async function test_invalid_state() {
+ const oauth = new FxAccountsOAuth();
+ const code = "foo";
+ const state = "bar";
+ const sessionToken = "01abcef12";
+ try {
+ await oauth.completeOAuthFlow(sessionToken, code, state);
+ Assert.fail("Should have thrown an error");
+ } catch (err) {
+ Assert.equal(err.message, ERROR_INVALID_STATE);
+ }
+ });
+ add_task(async function test_sync_scope_not_authorized() {
+ const fxaClient = {
+ oauthToken: () =>
+ Promise.resolve({
+ access_token: "access_token",
+ refresh_token: "refresh_token",
+ // Note that the scope does not include the sync scope
+ scope: SCOPE_PROFILE,
+ }),
+ };
+ const oauth = new FxAccountsOAuth(fxaClient);
+ const scopes = [SCOPE_PROFILE, SCOPE_OLD_SYNC];
+ const sessionToken = "01abcef12";
+ const queryParams = await oauth.beginOAuthFlow(scopes);
+ try {
+ await oauth.completeOAuthFlow(sessionToken, "foo", queryParams.state);
+ Assert.fail(
+ "Should have thrown an error because the sync scope was not authorized"
+ );
+ } catch (err) {
+ Assert.equal(err.message, ERROR_SYNC_SCOPE_NOT_GRANTED);
+ }
+ });
+ add_task(async function test_jwe_not_returned() {
+ const scopes = [SCOPE_PROFILE, SCOPE_OLD_SYNC];
+ const fxaClient = {
+ oauthToken: () =>
+ Promise.resolve({
+ access_token: "access_token",
+ refresh_token: "refresh_token",
+ scope: scopes.join(" "),
+ }),
+ };
+ const oauth = new FxAccountsOAuth(fxaClient);
+ const queryParams = await oauth.beginOAuthFlow(scopes);
+ const sessionToken = "01abcef12";
+ try {
+ await oauth.completeOAuthFlow(sessionToken, "foo", queryParams.state);
+ Assert.fail(
+ "Should have thrown an error because we didn't get back a keys_nwe"
+ );
+ } catch (err) {
+ Assert.equal(err.message, ERROR_NO_KEYS_JWE);
+ }
+ });
+ add_task(async function test_complete_oauth_ok() {
+ // First, we initialize some fake values we would typically get
+ // from outside our system
+ const scopes = [SCOPE_PROFILE, SCOPE_OLD_SYNC];
+ const oauthCode = "fake oauth code";
+ const sessionToken = "01abcef12";
+ const plainTextScopedKeys = {
+ kid: "fake key id",
+ k: "fake key",
+ kty: "oct",
+ };
+ const fakeAccessToken = "fake access token";
+ const fakeRefreshToken = "fake refresh token";
+ // Then, we initialize a fake http client, we'll add our fake oauthToken call
+ // once we have started the oauth flow (so we have the public keys!)
+ const fxaClient = {};
+ // Then, we initialize our oauth object with the given client and begin a new flow
+ const oauth = new FxAccountsOAuth(fxaClient);
+ const queryParams = await oauth.beginOAuthFlow(scopes);
+ // Now that we have the public keys in `keys_jwk`, we use it to generate a JWE
+ // representing our scoped keys
+ const keysJwk = queryParams.keys_jwk;
+ const decodedKeysJwk = JSON.parse(
+ new TextDecoder().decode(
+ ChromeUtils.base64URLDecode(keysJwk, { padding: "reject" })
+ )
+ );
+ delete decodedKeysJwk.key_ops;
+ const jwe = await jwcrypto.generateJWE(
+ decodedKeysJwk,
+ new TextEncoder().encode(JSON.stringify(plainTextScopedKeys))
+ );
+ // We also grab the stored PKCE verifier that the oauth object stored internally
+ // to verify that we correctly send it as a part of our HTTP request
+ const storedVerifier = oauth.getFlow(queryParams.state).verifier;
+
+ // To test what happens when more than one flow is completed simulatniously
+ // We mimic a slow network call on the first oauthToken call and let the second
+ // one win
+ let callCount = 0;
+ let slowResolve;
+ const resolveFn = (payload, resolve) => {
+ if (callCount === 1) {
+ // This is the second call
+ // lets resolve it so the second call wins
+ resolve(payload);
+ } else {
+ callCount += 1;
+ // This is the first call, let store our resolve function for later
+ // it will be resolved once the fast flow is fully completed
+ slowResolve = () => resolve(payload);
+ }
+ };
+
+ // Now we initialize our mock of the HTTP request, it verifies we passed in all the correct
+ // parameters and returns what we'd expect a healthy HTTP Response would look like
+ fxaClient.oauthToken = (sessionTokenHex, code, verifier, clientId) => {
+ Assert.equal(sessionTokenHex, sessionToken);
+ Assert.equal(code, oauthCode);
+ Assert.equal(verifier, storedVerifier);
+ Assert.equal(clientId, queryParams.client_id);
+ const response = {
+ access_token: fakeAccessToken,
+ refresh_token: fakeRefreshToken,
+ scope: scopes.join(" "),
+ keys_jwe: jwe,
+ };
+ return new Promise(resolve => {
+ resolveFn(response, resolve);
+ });
+ };
+
+ // Then, we call the completeOAuthFlow function, and get back our access token,
+ // refresh token and scopedKeys
+
+ // To test what happens when multiple flows race, we create two flows,
+ // A slow one that will start first, but finish last
+ // And a fast one that will beat the slow one
+ const firstCompleteOAuthFlow = oauth
+ .completeOAuthFlow(sessionToken, oauthCode, queryParams.state)
+ .then(res => {
+ // To mimic the slow network connection on the slowCompleteOAuthFlow
+ // We resume the slow completeOAuthFlow once this one is complete
+ slowResolve();
+ return res;
+ });
+ const secondCompleteOAuthFlow = oauth
+ .completeOAuthFlow(sessionToken, oauthCode, queryParams.state)
+ .then(res => {
+ // since we can't fully gaurentee which oauth flow finishes first, we also resolve here
+ slowResolve();
+ return res;
+ });
+
+ const { accessToken, refreshToken, scopedKeys } = await Promise.allSettled([
+ firstCompleteOAuthFlow,
+ secondCompleteOAuthFlow,
+ ]).then(results => {
+ let fast;
+ let slow;
+ for (const result of results) {
+ if (result.status === "fulfilled") {
+ fast = result.value;
+ } else {
+ slow = result.reason;
+ }
+ }
+ // We make sure that we indeed have one slow flow that lost
+ Assert.equal(slow.message, ERROR_OAUTH_FLOW_ABANDONED);
+ return fast;
+ });
+
+ Assert.equal(accessToken, fakeAccessToken);
+ Assert.equal(refreshToken, fakeRefreshToken);
+ Assert.deepEqual(scopedKeys, plainTextScopedKeys);
+
+ // Finally, we verify that all stored flows were cleared
+ Assert.equal(oauth.numOfFlows(), 0);
+ });
+});
diff --git a/services/fxaccounts/tests/xpcshell/test_oauth_token_storage.js b/services/fxaccounts/tests/xpcshell/test_oauth_token_storage.js
new file mode 100644
index 0000000000..798c439212
--- /dev/null
+++ b/services/fxaccounts/tests/xpcshell/test_oauth_token_storage.js
@@ -0,0 +1,180 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { FxAccounts } = ChromeUtils.importESModule(
+ "resource://gre/modules/FxAccounts.sys.mjs"
+);
+const { FxAccountsClient } = ChromeUtils.importESModule(
+ "resource://gre/modules/FxAccountsClient.sys.mjs"
+);
+
+// We grab some additional stuff via backstage passes.
+var { AccountState } = ChromeUtils.importESModule(
+ "resource://gre/modules/FxAccounts.sys.mjs"
+);
+
+function promiseNotification(topic) {
+ return new Promise(resolve => {
+ let observe = () => {
+ Services.obs.removeObserver(observe, topic);
+ resolve();
+ };
+ Services.obs.addObserver(observe, topic);
+ });
+}
+
+// A storage manager that doesn't actually write anywhere.
+function MockStorageManager() {}
+
+MockStorageManager.prototype = {
+ promiseInitialized: Promise.resolve(),
+
+ initialize(accountData) {
+ this.accountData = accountData;
+ },
+
+ finalize() {
+ return Promise.resolve();
+ },
+
+ getAccountData() {
+ return Promise.resolve(this.accountData);
+ },
+
+ updateAccountData(updatedFields) {
+ for (let [name, value] of Object.entries(updatedFields)) {
+ if (value == null) {
+ delete this.accountData[name];
+ } else {
+ this.accountData[name] = value;
+ }
+ }
+ return Promise.resolve();
+ },
+
+ deleteAccountData() {
+ this.accountData = null;
+ return Promise.resolve();
+ },
+};
+
+// Just enough mocks so we can avoid hawk etc.
+function MockFxAccountsClient() {
+ this._email = "nobody@example.com";
+ this._verified = false;
+
+ this.accountStatus = function (uid) {
+ return Promise.resolve(!!uid && !this._deletedOnServer);
+ };
+
+ this.signOut = function () {
+ return Promise.resolve();
+ };
+ this.registerDevice = function () {
+ return Promise.resolve();
+ };
+ this.updateDevice = function () {
+ return Promise.resolve();
+ };
+ this.signOutAndDestroyDevice = function () {
+ return Promise.resolve();
+ };
+ this.getDeviceList = function () {
+ return Promise.resolve();
+ };
+
+ FxAccountsClient.apply(this);
+}
+
+MockFxAccountsClient.prototype = {};
+Object.setPrototypeOf(
+ MockFxAccountsClient.prototype,
+ FxAccountsClient.prototype
+);
+
+function MockFxAccounts(device = {}) {
+ return new FxAccounts({
+ fxAccountsClient: new MockFxAccountsClient(),
+ newAccountState(credentials) {
+ // we use a real accountState but mocked storage.
+ let storage = new MockStorageManager();
+ storage.initialize(credentials);
+ return new AccountState(storage);
+ },
+ _getDeviceName() {
+ return "mock device name";
+ },
+ fxaPushService: {
+ registerPushEndpoint() {
+ return new Promise(resolve => {
+ resolve({
+ endpoint: "http://mochi.test:8888",
+ });
+ });
+ },
+ },
+ });
+}
+
+async function createMockFxA() {
+ let fxa = new MockFxAccounts();
+ let credentials = {
+ email: "foo@example.com",
+ uid: "1234@lcip.org",
+ sessionToken: "dead",
+ scopedKeys: {
+ [SCOPE_OLD_SYNC]: {
+ kid: "key id for sync key",
+ k: "key material for sync key",
+ kty: "oct",
+ },
+ },
+ verified: true,
+ };
+ await fxa._internal.setSignedInUser(credentials);
+ return fxa;
+}
+
+// The tests.
+
+add_task(async function testCacheStorage() {
+ let fxa = await createMockFxA();
+
+ // Hook what the impl calls to save to disk.
+ let cas = fxa._internal.currentAccountState;
+ let origPersistCached = cas._persistCachedTokens.bind(cas);
+ cas._persistCachedTokens = function () {
+ return origPersistCached().then(() => {
+ Services.obs.notifyObservers(null, "testhelper-fxa-cache-persist-done");
+ });
+ };
+
+ let promiseWritten = promiseNotification("testhelper-fxa-cache-persist-done");
+ let tokenData = { token: "token1", somethingelse: "something else" };
+ let scopeArray = ["foo", "bar"];
+ cas.setCachedToken(scopeArray, tokenData);
+ deepEqual(cas.getCachedToken(scopeArray), tokenData);
+
+ deepEqual(cas.oauthTokens, { "bar|foo": tokenData });
+ // wait for background write to complete.
+ await promiseWritten;
+
+ // Check the token cache made it to our mocked storage.
+ deepEqual(cas.storageManager.accountData.oauthTokens, {
+ "bar|foo": tokenData,
+ });
+
+ // Drop the token from the cache and ensure it is removed from the json.
+ promiseWritten = promiseNotification("testhelper-fxa-cache-persist-done");
+ await cas.removeCachedToken("token1");
+ deepEqual(cas.oauthTokens, {});
+ await promiseWritten;
+ deepEqual(cas.storageManager.accountData.oauthTokens, {});
+
+ // sign out and the token storage should end up with null.
+ let storageManager = cas.storageManager; // .signOut() removes the attribute.
+ await fxa.signOut(/* localOnly = */ true);
+ deepEqual(storageManager.accountData, null);
+});
diff --git a/services/fxaccounts/tests/xpcshell/test_oauth_tokens.js b/services/fxaccounts/tests/xpcshell/test_oauth_tokens.js
new file mode 100644
index 0000000000..82f174edd1
--- /dev/null
+++ b/services/fxaccounts/tests/xpcshell/test_oauth_tokens.js
@@ -0,0 +1,255 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { FxAccounts } = ChromeUtils.importESModule(
+ "resource://gre/modules/FxAccounts.sys.mjs"
+);
+const { FxAccountsClient } = ChromeUtils.importESModule(
+ "resource://gre/modules/FxAccountsClient.sys.mjs"
+);
+var { AccountState } = ChromeUtils.importESModule(
+ "resource://gre/modules/FxAccounts.sys.mjs"
+);
+
+function promiseNotification(topic) {
+ return new Promise(resolve => {
+ let observe = () => {
+ Services.obs.removeObserver(observe, topic);
+ resolve();
+ };
+ Services.obs.addObserver(observe, topic);
+ });
+}
+
+// Just enough mocks so we can avoid hawk and storage.
+function MockStorageManager() {}
+
+MockStorageManager.prototype = {
+ promiseInitialized: Promise.resolve(),
+
+ initialize(accountData) {
+ this.accountData = accountData;
+ },
+
+ finalize() {
+ return Promise.resolve();
+ },
+
+ getAccountData() {
+ return Promise.resolve(this.accountData);
+ },
+
+ updateAccountData(updatedFields) {
+ for (let [name, value] of Object.entries(updatedFields)) {
+ if (value == null) {
+ delete this.accountData[name];
+ } else {
+ this.accountData[name] = value;
+ }
+ }
+ return Promise.resolve();
+ },
+
+ deleteAccountData() {
+ this.accountData = null;
+ return Promise.resolve();
+ },
+};
+
+function MockFxAccountsClient(activeTokens) {
+ this._email = "nobody@example.com";
+ this._verified = false;
+
+ this.accountStatus = function (uid) {
+ return Promise.resolve(!!uid && !this._deletedOnServer);
+ };
+
+ this.signOut = function () {
+ return Promise.resolve();
+ };
+ this.registerDevice = function () {
+ return Promise.resolve();
+ };
+ this.updateDevice = function () {
+ return Promise.resolve();
+ };
+ this.signOutAndDestroyDevice = function () {
+ return Promise.resolve();
+ };
+ this.getDeviceList = function () {
+ return Promise.resolve();
+ };
+ this.accessTokenWithSessionToken = function (
+ sessionTokenHex,
+ clientId,
+ scope,
+ ttl
+ ) {
+ let token = `token${this.numTokenFetches}`;
+ if (ttl) {
+ token += `-ttl-${ttl}`;
+ }
+ this.numTokenFetches += 1;
+ this.activeTokens.add(token);
+ print("accessTokenWithSessionToken returning token", token);
+ return Promise.resolve({ access_token: token, ttl });
+ };
+ this.oauthDestroy = sinon.stub().callsFake((_clientId, token) => {
+ this.activeTokens.delete(token);
+ return Promise.resolve();
+ });
+
+ // Test only stuff.
+ this.activeTokens = activeTokens;
+ this.numTokenFetches = 0;
+
+ FxAccountsClient.apply(this);
+}
+
+MockFxAccountsClient.prototype = {};
+Object.setPrototypeOf(
+ MockFxAccountsClient.prototype,
+ FxAccountsClient.prototype
+);
+
+function MockFxAccounts() {
+ // The FxA "auth" and "oauth" servers both share the same db of tokens,
+ // so we need to simulate the same here in the tests.
+ const activeTokens = new Set();
+ return new FxAccounts({
+ fxAccountsClient: new MockFxAccountsClient(activeTokens),
+ newAccountState(credentials) {
+ // we use a real accountState but mocked storage.
+ let storage = new MockStorageManager();
+ storage.initialize(credentials);
+ return new AccountState(storage);
+ },
+ _getDeviceName() {
+ return "mock device name";
+ },
+ fxaPushService: {
+ registerPushEndpoint() {
+ return new Promise(resolve => {
+ resolve({
+ endpoint: "http://mochi.test:8888",
+ });
+ });
+ },
+ },
+ });
+}
+
+async function createMockFxA() {
+ let fxa = new MockFxAccounts();
+ let credentials = {
+ email: "foo@example.com",
+ uid: "1234@lcip.org",
+ sessionToken: "dead",
+ scopedKeys: {
+ [SCOPE_OLD_SYNC]: {
+ kid: "key id for sync key",
+ k: "key material for sync key",
+ kty: "oct",
+ },
+ },
+ verified: true,
+ };
+
+ await fxa._internal.setSignedInUser(credentials);
+ return fxa;
+}
+
+// The tests.
+
+add_task(async function testRevoke() {
+ let tokenOptions = { scope: "test-scope" };
+ let fxa = await createMockFxA();
+ let client = fxa._internal.fxAccountsClient;
+
+ // get our first token and check we hit the mock.
+ let token1 = await fxa.getOAuthToken(tokenOptions);
+ equal(client.numTokenFetches, 1);
+ equal(client.activeTokens.size, 1);
+ ok(token1, "got a token");
+ equal(token1, "token0");
+
+ // drop the new token from our cache.
+ await fxa.removeCachedOAuthToken({ token: token1 });
+ ok(client.oauthDestroy.calledOnce);
+
+ // the revoke should have been successful.
+ equal(client.activeTokens.size, 0);
+ // fetching it again hits the server.
+ let token2 = await fxa.getOAuthToken(tokenOptions);
+ equal(client.numTokenFetches, 2);
+ equal(client.activeTokens.size, 1);
+ ok(token2, "got a token");
+ notEqual(token1, token2, "got a different token");
+});
+
+add_task(async function testSignOutDestroysTokens() {
+ let fxa = await createMockFxA();
+ let client = fxa._internal.fxAccountsClient;
+
+ // get our first token and check we hit the mock.
+ let token1 = await fxa.getOAuthToken({ scope: "test-scope" });
+ equal(client.numTokenFetches, 1);
+ equal(client.activeTokens.size, 1);
+ ok(token1, "got a token");
+
+ // get another
+ let token2 = await fxa.getOAuthToken({ scope: "test-scope-2" });
+ equal(client.numTokenFetches, 2);
+ equal(client.activeTokens.size, 2);
+ ok(token2, "got a token");
+ notEqual(token1, token2, "got a different token");
+
+ // FxA fires an observer when the "background" signout is complete.
+ let signoutComplete = promiseNotification("testhelper-fxa-signout-complete");
+ // now sign out - they should be removed.
+ await fxa.signOut();
+ await signoutComplete;
+ ok(client.oauthDestroy.calledTwice);
+ // No active tokens left.
+ equal(client.activeTokens.size, 0);
+});
+
+add_task(async function testTokenRaces() {
+ // Here we do 2 concurrent fetches each for 2 different token scopes (ie,
+ // 4 token fetches in total).
+ // This should provoke a potential race in the token fetching but we use
+ // a map of in-flight token fetches, so we should still only perform 2
+ // fetches, but each of the 4 calls should resolve with the correct values.
+ let fxa = await createMockFxA();
+ let client = fxa._internal.fxAccountsClient;
+
+ let results = await Promise.all([
+ fxa.getOAuthToken({ scope: "test-scope" }),
+ fxa.getOAuthToken({ scope: "test-scope" }),
+ fxa.getOAuthToken({ scope: "test-scope-2" }),
+ fxa.getOAuthToken({ scope: "test-scope-2" }),
+ ]);
+
+ equal(client.numTokenFetches, 2, "should have fetched 2 tokens.");
+
+ // Should have 2 unique tokens
+ results.sort();
+ equal(results[0], results[1]);
+ equal(results[2], results[3]);
+ // should be 2 active.
+ equal(client.activeTokens.size, 2);
+ await fxa.removeCachedOAuthToken({ token: results[0] });
+ equal(client.activeTokens.size, 1);
+ await fxa.removeCachedOAuthToken({ token: results[2] });
+ equal(client.activeTokens.size, 0);
+ ok(client.oauthDestroy.calledTwice);
+});
+
+add_task(async function testTokenTTL() {
+ // This tests the TTL option passed into the method
+ let fxa = await createMockFxA();
+ let token = await fxa.getOAuthToken({ scope: "test-ttl", ttl: 1000 });
+ equal(token, "token0-ttl-1000");
+});
diff --git a/services/fxaccounts/tests/xpcshell/test_pairing.js b/services/fxaccounts/tests/xpcshell/test_pairing.js
new file mode 100644
index 0000000000..eac3112242
--- /dev/null
+++ b/services/fxaccounts/tests/xpcshell/test_pairing.js
@@ -0,0 +1,384 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { FxAccountsPairingFlow } = ChromeUtils.importESModule(
+ "resource://gre/modules/FxAccountsPairing.sys.mjs"
+);
+const { EventEmitter } = ChromeUtils.importESModule(
+ "resource://gre/modules/EventEmitter.sys.mjs"
+);
+ChromeUtils.defineESModuleGetters(this, {
+ jwcrypto: "resource://services-crypto/jwcrypto.sys.mjs",
+});
+
+const CHANNEL_ID = "sW-UA97Q6Dljqen7XRlYPw";
+const CHANNEL_KEY = crypto.getRandomValues(new Uint8Array(32));
+
+const SENDER_SUPP = {
+ ua: "Firefox Supp",
+ city: "Nice",
+ region: "PACA",
+ country: "France",
+ remote: "127.0.0.1",
+};
+const UID = "abcd";
+const EMAIL = "foo@bar.com";
+const AVATAR = "https://foo.bar/avatar";
+const DISPLAY_NAME = "Foo bar";
+const DEVICE_NAME = "Foo's computer";
+
+const PAIR_URI = "https://foo.bar/pair";
+const OAUTH_URI = "https://foo.bar/oauth";
+const KSYNC = "myksync";
+const SESSION = "mysession";
+const fxaConfig = {
+ promisePairingURI() {
+ return PAIR_URI;
+ },
+ promiseOAuthURI() {
+ return OAUTH_URI;
+ },
+};
+const fxAccounts = {
+ getSignedInUser() {
+ return {
+ uid: UID,
+ email: EMAIL,
+ avatar: AVATAR,
+ displayName: DISPLAY_NAME,
+ };
+ },
+ async _withVerifiedAccountState(cb) {
+ return cb({
+ async getUserAccountData() {
+ return {
+ sessionToken: SESSION,
+ };
+ },
+ });
+ },
+ _internal: {
+ keys: {
+ getKeyForScope(scope) {
+ return {
+ kid: "123456",
+ k: KSYNC,
+ kty: "oct",
+ };
+ },
+ },
+ fxAccountsClient: {
+ async getScopedKeyData() {
+ return {
+ "https://identity.mozilla.com/apps/oldsync": {
+ identifier: "https://identity.mozilla.com/apps/oldsync",
+ keyRotationTimestamp: 12345678,
+ },
+ };
+ },
+ async oauthAuthorize() {
+ return { code: "mycode", state: "mystate" };
+ },
+ },
+ },
+};
+const weave = {
+ Service: { clientsEngine: { localName: DEVICE_NAME } },
+};
+
+class MockPairingChannel extends EventTarget {
+ get channelId() {
+ return CHANNEL_ID;
+ }
+
+ get channelKey() {
+ return CHANNEL_KEY;
+ }
+
+ send(data) {
+ this.dispatchEvent(
+ new CustomEvent("send", {
+ detail: { data },
+ })
+ );
+ }
+
+ simulateIncoming(data) {
+ this.dispatchEvent(
+ new CustomEvent("message", {
+ detail: { data, sender: SENDER_SUPP },
+ })
+ );
+ }
+
+ close() {
+ this.closed = true;
+ }
+}
+
+add_task(async function testFullFlow() {
+ const emitter = new EventEmitter();
+ const pairingChannel = new MockPairingChannel();
+ const pairingUri = await FxAccountsPairingFlow.start({
+ emitter,
+ pairingChannel,
+ fxAccounts,
+ fxaConfig,
+ weave,
+ });
+ Assert.equal(
+ pairingUri,
+ `${PAIR_URI}#channel_id=${CHANNEL_ID}&channel_key=${ChromeUtils.base64URLEncode(
+ CHANNEL_KEY,
+ { pad: false }
+ )}`
+ );
+
+ const flow = FxAccountsPairingFlow.get(CHANNEL_ID);
+
+ const promiseSwitchToWebContent = emitter.once("view:SwitchToWebContent");
+ const promiseMetadataSent = promiseOutgoingMessage(pairingChannel);
+ const epk = await generateEphemeralKeypair();
+
+ pairingChannel.simulateIncoming({
+ message: "pair:supp:request",
+ data: {
+ client_id: "client_id_1",
+ state: "mystate",
+ keys_jwk: ChromeUtils.base64URLEncode(
+ new TextEncoder().encode(JSON.stringify(epk.publicJWK)),
+ { pad: false }
+ ),
+ scope: "profile https://identity.mozilla.com/apps/oldsync",
+ code_challenge: "chal",
+ code_challenge_method: "S256",
+ },
+ });
+ const sentAuthMetadata = await promiseMetadataSent;
+ Assert.deepEqual(sentAuthMetadata, {
+ message: "pair:auth:metadata",
+ data: {
+ email: EMAIL,
+ avatar: AVATAR,
+ displayName: DISPLAY_NAME,
+ deviceName: DEVICE_NAME,
+ },
+ });
+ const oauthUrl = await promiseSwitchToWebContent;
+ Assert.equal(
+ oauthUrl,
+ `${OAUTH_URI}?client_id=client_id_1&scope=profile+https%3A%2F%2Fidentity.mozilla.com%2Fapps%2Foldsync&email=foo%40bar.com&uid=abcd&channel_id=${CHANNEL_ID}&redirect_uri=urn%3Aietf%3Awg%3Aoauth%3A2.0%3Aoob%3Apair-auth-webchannel`
+ );
+
+ let pairSuppMetadata = await simulateIncomingWebChannel(
+ flow,
+ "fxaccounts:pair_supplicant_metadata"
+ );
+ Assert.deepEqual(
+ {
+ ua: "Firefox Supp",
+ city: "Nice",
+ region: "PACA",
+ country: "France",
+ ipAddress: "127.0.0.1",
+ },
+ pairSuppMetadata
+ );
+
+ const generateJWE = sinon.spy(jwcrypto, "generateJWE");
+ const oauthAuthorize = sinon.spy(
+ fxAccounts._internal.fxAccountsClient,
+ "oauthAuthorize"
+ );
+ const promiseOAuthParamsMsg = promiseOutgoingMessage(pairingChannel);
+ await simulateIncomingWebChannel(flow, "fxaccounts:pair_authorize");
+ // We should have generated the expected JWE.
+ Assert.ok(generateJWE.calledOnce);
+ const generateArgs = generateJWE.firstCall.args;
+ Assert.deepEqual(generateArgs[0], epk.publicJWK);
+ Assert.deepEqual(JSON.parse(new TextDecoder().decode(generateArgs[1])), {
+ "https://identity.mozilla.com/apps/oldsync": {
+ kid: "123456",
+ k: KSYNC,
+ kty: "oct",
+ },
+ });
+ // We should have authorized an oauth code with expected parameters.
+ Assert.ok(oauthAuthorize.calledOnce);
+ const oauthCodeArgs = oauthAuthorize.firstCall.args[1];
+ console.log(oauthCodeArgs);
+ Assert.ok(!oauthCodeArgs.keys_jwk);
+ Assert.deepEqual(
+ oauthCodeArgs.keys_jwe,
+ await generateJWE.firstCall.returnValue
+ );
+ Assert.equal(oauthCodeArgs.client_id, "client_id_1");
+ Assert.equal(oauthCodeArgs.access_type, "offline");
+ Assert.equal(oauthCodeArgs.state, "mystate");
+ Assert.equal(
+ oauthCodeArgs.scope,
+ "profile https://identity.mozilla.com/apps/oldsync"
+ );
+ Assert.equal(oauthCodeArgs.code_challenge, "chal");
+ Assert.equal(oauthCodeArgs.code_challenge_method, "S256");
+
+ const oAuthParams = await promiseOAuthParamsMsg;
+ Assert.deepEqual(oAuthParams, {
+ message: "pair:auth:authorize",
+ data: { code: "mycode", state: "mystate" },
+ });
+
+ let heartbeat = await simulateIncomingWebChannel(
+ flow,
+ "fxaccounts:pair_heartbeat"
+ );
+ Assert.ok(!heartbeat.suppAuthorized);
+
+ await pairingChannel.simulateIncoming({
+ message: "pair:supp:authorize",
+ });
+
+ heartbeat = await simulateIncomingWebChannel(
+ flow,
+ "fxaccounts:pair_heartbeat"
+ );
+ Assert.ok(heartbeat.suppAuthorized);
+
+ await simulateIncomingWebChannel(flow, "fxaccounts:pair_complete");
+ // The flow should have been destroyed!
+ Assert.ok(!FxAccountsPairingFlow.get(CHANNEL_ID));
+ Assert.ok(pairingChannel.closed);
+ generateJWE.restore();
+ oauthAuthorize.restore();
+});
+
+add_task(async function testUnknownPairingMessage() {
+ const emitter = new EventEmitter();
+ const pairingChannel = new MockPairingChannel();
+ await FxAccountsPairingFlow.start({
+ emitter,
+ pairingChannel,
+ fxAccounts,
+ fxaConfig,
+ weave,
+ });
+ const flow = FxAccountsPairingFlow.get(CHANNEL_ID);
+ const viewErrorObserved = emitter.once("view:Error");
+ pairingChannel.simulateIncoming({
+ message: "pair:boom",
+ });
+ await viewErrorObserved;
+ let heartbeat = await simulateIncomingWebChannel(
+ flow,
+ "fxaccounts:pair_heartbeat"
+ );
+ Assert.ok(heartbeat.err);
+});
+
+add_task(async function testUnknownWebChannelCommand() {
+ const emitter = new EventEmitter();
+ const pairingChannel = new MockPairingChannel();
+ await FxAccountsPairingFlow.start({
+ emitter,
+ pairingChannel,
+ fxAccounts,
+ fxaConfig,
+ weave,
+ });
+ const flow = FxAccountsPairingFlow.get(CHANNEL_ID);
+ const viewErrorObserved = emitter.once("view:Error");
+ await simulateIncomingWebChannel(flow, "fxaccounts:boom");
+ await viewErrorObserved;
+ let heartbeat = await simulateIncomingWebChannel(
+ flow,
+ "fxaccounts:pair_heartbeat"
+ );
+ Assert.ok(heartbeat.err);
+});
+
+add_task(async function testPairingChannelFailure() {
+ const emitter = new EventEmitter();
+ const pairingChannel = new MockPairingChannel();
+ await FxAccountsPairingFlow.start({
+ emitter,
+ pairingChannel,
+ fxAccounts,
+ fxaConfig,
+ weave,
+ });
+ const flow = FxAccountsPairingFlow.get(CHANNEL_ID);
+ const viewErrorObserved = emitter.once("view:Error");
+ sinon.stub(pairingChannel, "send").callsFake(() => {
+ throw new Error("Boom!");
+ });
+ pairingChannel.simulateIncoming({
+ message: "pair:supp:request",
+ data: {
+ client_id: "client_id_1",
+ state: "mystate",
+ scope: "profile https://identity.mozilla.com/apps/oldsync",
+ code_challenge: "chal",
+ code_challenge_method: "S256",
+ },
+ });
+ await viewErrorObserved;
+
+ let heartbeat = await simulateIncomingWebChannel(
+ flow,
+ "fxaccounts:pair_heartbeat"
+ );
+ Assert.ok(heartbeat.err);
+});
+
+add_task(async function testFlowTimeout() {
+ const emitter = new EventEmitter();
+ const pairingChannel = new MockPairingChannel();
+ const viewErrorObserved = emitter.once("view:Error");
+ await FxAccountsPairingFlow.start({
+ emitter,
+ pairingChannel,
+ fxAccounts,
+ fxaConfig,
+ weave,
+ flowTimeout: 1,
+ });
+ const flow = FxAccountsPairingFlow.get(CHANNEL_ID);
+ await viewErrorObserved;
+
+ let heartbeat = await simulateIncomingWebChannel(
+ flow,
+ "fxaccounts:pair_heartbeat"
+ );
+ Assert.ok(heartbeat.err.match(/Timeout/));
+});
+
+async function simulateIncomingWebChannel(flow, command) {
+ return flow.onWebChannelMessage(command);
+}
+
+async function promiseOutgoingMessage(pairingChannel) {
+ return new Promise(res => {
+ const onMessage = event => {
+ pairingChannel.removeEventListener("send", onMessage);
+ res(event.detail.data);
+ };
+ pairingChannel.addEventListener("send", onMessage);
+ });
+}
+
+async function generateEphemeralKeypair() {
+ const keypair = await crypto.subtle.generateKey(
+ { name: "ECDH", namedCurve: "P-256" },
+ true,
+ ["deriveKey"]
+ );
+ const publicJWK = await crypto.subtle.exportKey("jwk", keypair.publicKey);
+ const privateJWK = await crypto.subtle.exportKey("jwk", keypair.privateKey);
+ delete publicJWK.key_ops;
+ return {
+ publicJWK,
+ privateJWK,
+ };
+}
diff --git a/services/fxaccounts/tests/xpcshell/test_profile.js b/services/fxaccounts/tests/xpcshell/test_profile.js
new file mode 100644
index 0000000000..f8137b5691
--- /dev/null
+++ b/services/fxaccounts/tests/xpcshell/test_profile.js
@@ -0,0 +1,677 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { ON_PROFILE_CHANGE_NOTIFICATION, log } = ChromeUtils.importESModule(
+ "resource://gre/modules/FxAccountsCommon.sys.mjs"
+);
+const { FxAccountsProfileClient } = ChromeUtils.importESModule(
+ "resource://gre/modules/FxAccountsProfileClient.sys.mjs"
+);
+const { FxAccountsProfile } = ChromeUtils.importESModule(
+ "resource://gre/modules/FxAccountsProfile.sys.mjs"
+);
+const { setTimeout } = ChromeUtils.importESModule(
+ "resource://gre/modules/Timer.sys.mjs"
+);
+
+let mockClient = function (fxa) {
+ let options = {
+ serverURL: "http://127.0.0.1:1111/v1",
+ fxa,
+ };
+ return new FxAccountsProfileClient(options);
+};
+
+const ACCOUNT_UID = "abc123";
+const ACCOUNT_EMAIL = "foo@bar.com";
+const ACCOUNT_DATA = {
+ uid: ACCOUNT_UID,
+ email: ACCOUNT_EMAIL,
+};
+
+let mockFxa = function () {
+ let fxa = {
+ // helpers to make the tests using this mock less verbose...
+ set _testProfileCache(profileCache) {
+ this._internal.currentAccountState._data.profileCache = profileCache;
+ },
+ get _testProfileCache() {
+ return this._internal.currentAccountState._data.profileCache;
+ },
+ };
+ fxa._internal = Object.assign(
+ {},
+ {
+ currentAccountState: Object.assign(
+ {},
+ {
+ _data: Object.assign({}, ACCOUNT_DATA),
+
+ get isCurrent() {
+ return true;
+ },
+
+ async getUserAccountData() {
+ return this._data;
+ },
+
+ async updateUserAccountData(data) {
+ this._data = Object.assign(this._data, data);
+ },
+ }
+ ),
+
+ withCurrentAccountState(cb) {
+ return cb(this.currentAccountState);
+ },
+
+ async _handleTokenError(err) {
+ // handleTokenError always rethrows.
+ throw err;
+ },
+ }
+ );
+ return fxa;
+};
+
+function CreateFxAccountsProfile(fxa = null, client = null) {
+ if (!fxa) {
+ fxa = mockFxa();
+ }
+ let options = {
+ fxai: fxa._internal,
+ profileServerUrl: "http://127.0.0.1:1111/v1",
+ };
+ if (client) {
+ options.profileClient = client;
+ }
+ return new FxAccountsProfile(options);
+}
+
+add_test(function cacheProfile_change() {
+ let setProfileCacheCalled = false;
+ let fxa = mockFxa();
+ fxa._internal.currentAccountState.updateUserAccountData = data => {
+ setProfileCacheCalled = true;
+ Assert.equal(data.profileCache.profile.avatar, "myurl");
+ Assert.equal(data.profileCache.etag, "bogusetag");
+ return Promise.resolve();
+ };
+ let profile = CreateFxAccountsProfile(fxa);
+
+ makeObserver(ON_PROFILE_CHANGE_NOTIFICATION, function (subject, topic, data) {
+ Assert.equal(data, ACCOUNT_DATA.uid);
+ Assert.ok(setProfileCacheCalled);
+ run_next_test();
+ });
+
+ return profile._cacheProfile({
+ body: { uid: ACCOUNT_UID, email: ACCOUNT_EMAIL, avatar: "myurl" },
+ etag: "bogusetag",
+ });
+});
+
+add_test(function fetchAndCacheProfile_ok() {
+ let client = mockClient(mockFxa());
+ client.fetchProfile = function () {
+ return Promise.resolve({ body: { uid: ACCOUNT_UID, avatar: "myimg" } });
+ };
+ let profile = CreateFxAccountsProfile(null, client);
+ profile._cachedAt = 12345;
+
+ profile._cacheProfile = function (toCache) {
+ Assert.equal(toCache.body.avatar, "myimg");
+ return Promise.resolve(toCache.body);
+ };
+
+ return profile._fetchAndCacheProfile().then(result => {
+ Assert.equal(result.avatar, "myimg");
+ Assert.notEqual(profile._cachedAt, 12345, "cachedAt has been bumped");
+ run_next_test();
+ });
+});
+
+add_test(function fetchAndCacheProfile_always_bumps_cachedAt() {
+ let client = mockClient(mockFxa());
+ client.fetchProfile = function () {
+ return Promise.reject(new Error("oops"));
+ };
+ let profile = CreateFxAccountsProfile(null, client);
+ profile._cachedAt = 12345;
+
+ return profile._fetchAndCacheProfile().then(
+ result => {
+ do_throw("Should not succeed");
+ },
+ err => {
+ Assert.notEqual(profile._cachedAt, 12345, "cachedAt has been bumped");
+ run_next_test();
+ }
+ );
+});
+
+add_test(function fetchAndCacheProfile_sendsETag() {
+ let fxa = mockFxa();
+ fxa._testProfileCache = { profile: {}, etag: "bogusETag" };
+ let client = mockClient(fxa);
+ client.fetchProfile = function (etag) {
+ Assert.equal(etag, "bogusETag");
+ return Promise.resolve({
+ body: { uid: ACCOUNT_UID, email: ACCOUNT_EMAIL, avatar: "myimg" },
+ });
+ };
+ let profile = CreateFxAccountsProfile(fxa, client);
+
+ return profile._fetchAndCacheProfile().then(result => {
+ run_next_test();
+ });
+});
+
+// Check that a second profile request when one is already in-flight reuses
+// the in-flight one.
+add_task(async function fetchAndCacheProfileOnce() {
+ // A promise that remains unresolved while we fire off 2 requests for
+ // a profile.
+ let resolveProfile;
+ let promiseProfile = new Promise(resolve => {
+ resolveProfile = resolve;
+ });
+ let numFetches = 0;
+ let client = mockClient(mockFxa());
+ client.fetchProfile = function () {
+ numFetches += 1;
+ return promiseProfile;
+ };
+ let fxa = mockFxa();
+ let profile = CreateFxAccountsProfile(fxa, client);
+
+ let request1 = profile._fetchAndCacheProfile();
+ profile._fetchAndCacheProfile();
+ await new Promise(res => setTimeout(res, 0)); // Yield so fetchProfile() is called (promise)
+
+ // should be one request made to fetch the profile (but the promise returned
+ // by it remains unresolved)
+ Assert.equal(numFetches, 1);
+
+ // resolve the promise.
+ resolveProfile({
+ body: { uid: ACCOUNT_UID, email: ACCOUNT_EMAIL, avatar: "myimg" },
+ });
+
+ // both requests should complete with the same data.
+ let got1 = await request1;
+ Assert.equal(got1.avatar, "myimg");
+ let got2 = await request1;
+ Assert.equal(got2.avatar, "myimg");
+
+ // and still only 1 request was made.
+ Assert.equal(numFetches, 1);
+});
+
+// Check that sharing a single fetch promise works correctly when the promise
+// is rejected.
+add_task(async function fetchAndCacheProfileOnce() {
+ // A promise that remains unresolved while we fire off 2 requests for
+ // a profile.
+ let rejectProfile;
+ let promiseProfile = new Promise((resolve, reject) => {
+ rejectProfile = reject;
+ });
+ let numFetches = 0;
+ let client = mockClient(mockFxa());
+ client.fetchProfile = function () {
+ numFetches += 1;
+ return promiseProfile;
+ };
+ let fxa = mockFxa();
+ let profile = CreateFxAccountsProfile(fxa, client);
+
+ let request1 = profile._fetchAndCacheProfile();
+ let request2 = profile._fetchAndCacheProfile();
+ await new Promise(res => setTimeout(res, 0)); // Yield so fetchProfile() is called (promise)
+
+ // should be one request made to fetch the profile (but the promise returned
+ // by it remains unresolved)
+ Assert.equal(numFetches, 1);
+
+ // reject the promise.
+ rejectProfile("oh noes");
+
+ // both requests should reject.
+ try {
+ await request1;
+ throw new Error("should have rejected");
+ } catch (ex) {
+ if (ex != "oh noes") {
+ throw ex;
+ }
+ }
+ try {
+ await request2;
+ throw new Error("should have rejected");
+ } catch (ex) {
+ if (ex != "oh noes") {
+ throw ex;
+ }
+ }
+
+ // but a new request should works.
+ client.fetchProfile = function () {
+ return Promise.resolve({
+ body: { uid: ACCOUNT_UID, email: ACCOUNT_EMAIL, avatar: "myimg" },
+ });
+ };
+
+ let got = await profile._fetchAndCacheProfile();
+ Assert.equal(got.avatar, "myimg");
+});
+
+add_test(function fetchAndCacheProfile_alreadyCached() {
+ let cachedUrl = "cachedurl";
+ let fxa = mockFxa();
+ fxa._testProfileCache = {
+ profile: { uid: ACCOUNT_UID, avatar: cachedUrl },
+ etag: "bogusETag",
+ };
+ let client = mockClient(fxa);
+ client.fetchProfile = function (etag) {
+ Assert.equal(etag, "bogusETag");
+ return Promise.resolve(null);
+ };
+
+ let profile = CreateFxAccountsProfile(fxa, client);
+ profile._cacheProfile = function (toCache) {
+ do_throw("This method should not be called.");
+ };
+
+ return profile._fetchAndCacheProfile().then(result => {
+ Assert.equal(result, null);
+ Assert.equal(fxa._testProfileCache.profile.avatar, cachedUrl);
+ run_next_test();
+ });
+});
+
+// Check that a new profile request within PROFILE_FRESHNESS_THRESHOLD of the
+// last one doesn't kick off a new request to check the cached copy is fresh.
+add_task(async function fetchAndCacheProfileAfterThreshold() {
+ /*
+ * This test was observed to cause a timeout for... any timer precision reduction.
+ * Even 1 us. Exact reason is still undiagnosed.
+ */
+ Services.prefs.setBoolPref("privacy.reduceTimerPrecision", false);
+
+ registerCleanupFunction(async () => {
+ Services.prefs.clearUserPref("privacy.reduceTimerPrecision");
+ });
+
+ let numFetches = 0;
+ let client = mockClient(mockFxa());
+ client.fetchProfile = async function () {
+ numFetches += 1;
+ return {
+ body: { uid: ACCOUNT_UID, email: ACCOUNT_EMAIL, avatar: "myimg" },
+ };
+ };
+ let profile = CreateFxAccountsProfile(null, client);
+ profile.PROFILE_FRESHNESS_THRESHOLD = 1000;
+
+ // first fetch should return null as we don't have data.
+ let p = await profile.getProfile();
+ Assert.equal(p, null);
+ // ensure we kicked off a fetch.
+ Assert.notEqual(profile._currentFetchPromise, null);
+ // wait for that fetch to finish
+ await profile._currentFetchPromise;
+ Assert.equal(numFetches, 1);
+ Assert.equal(profile._currentFetchPromise, null);
+
+ await profile.getProfile();
+ Assert.equal(numFetches, 1);
+ Assert.equal(profile._currentFetchPromise, null);
+
+ await new Promise(resolve => {
+ do_timeout(1000, resolve);
+ });
+
+ let origFetchAndCatch = profile._fetchAndCacheProfile;
+ let backgroundFetchDone = Promise.withResolvers();
+ profile._fetchAndCacheProfile = async () => {
+ await origFetchAndCatch.call(profile);
+ backgroundFetchDone.resolve();
+ };
+ await profile.getProfile();
+ await backgroundFetchDone.promise;
+ Assert.equal(numFetches, 2);
+});
+
+add_task(async function test_ensureProfile() {
+ let client = new FxAccountsProfileClient({
+ serverURL: "http://127.0.0.1:1111/v1",
+ fxa: mockFxa(),
+ });
+ let profile = CreateFxAccountsProfile(null, client);
+
+ const testCases = [
+ // profile retrieval when there is no cached profile info
+ {
+ threshold: 1000,
+ expectsCachedProfileReturned: false,
+ cachedProfile: null,
+ fetchedProfile: {
+ uid: ACCOUNT_UID,
+ email: ACCOUNT_EMAIL,
+ avatar: "myimg",
+ },
+ },
+ // profile retrieval when the cached profile is recent
+ {
+ // Note: The threshold for this test case is being set to an arbitrary value that will
+ // be greater than Date.now() so the retrieved cached profile will be deemed recent.
+ threshold: Date.now() + 5000,
+ expectsCachedProfileReturned: true,
+ cachedProfile: {
+ uid: `${ACCOUNT_UID}2`,
+ email: `${ACCOUNT_EMAIL}2`,
+ avatar: "myimg2",
+ },
+ },
+ // profile retrieval when the cached profile is old and a new profile is fetched
+ {
+ threshold: 1000,
+ expectsCachedProfileReturned: false,
+ cachedProfile: {
+ uid: `${ACCOUNT_UID}3`,
+ email: `${ACCOUNT_EMAIL}3`,
+ avatar: "myimg3",
+ },
+ fetchAndCacheProfileResolves: true,
+ fetchedProfile: {
+ uid: `${ACCOUNT_UID}4`,
+ email: `${ACCOUNT_EMAIL}4`,
+ avatar: "myimg4",
+ },
+ },
+ // profile retrieval when the cached profile is old and a null profile is fetched
+ {
+ threshold: 1000,
+ expectsCachedProfileReturned: false,
+ cachedProfile: {
+ uid: `${ACCOUNT_UID}5`,
+ email: `${ACCOUNT_EMAIL}5`,
+ avatar: "myimg5",
+ },
+ fetchAndCacheProfileResolves: true,
+ fetchedProfile: null,
+ },
+ // profile retrieval when the cached profile is old and fetching a new profile errors
+ {
+ threshold: 1000,
+ expectsCachedProfileReturned: false,
+ cachedProfile: {
+ uid: `${ACCOUNT_UID}6`,
+ email: `${ACCOUNT_EMAIL}6`,
+ avatar: "myimg6",
+ },
+ fetchAndCacheProfileResolves: false,
+ },
+ // profile retrieval when we've cached a failure to fetch profile data
+ {
+ // Note: The threshold for this test case is being set to an arbitrary value that will
+ // be greater than Date.now() so the retrieved cached profile will be deemed recent.
+ threshold: Date.now() + 5000,
+ expectsCachedProfileReturned: false,
+ cachedProfile: null,
+ fetchedProfile: {
+ uid: `${ACCOUNT_UID}7`,
+ email: `${ACCOUNT_EMAIL}7`,
+ avatar: "myimg7",
+ },
+ fetchAndCacheProfileResolves: true,
+ },
+ // profile retrieval when the cached profile is old but staleOk is true.
+ {
+ threshold: 1000,
+ expectsCachedProfileReturned: true,
+ cachedProfile: {
+ uid: `${ACCOUNT_UID}8`,
+ email: `${ACCOUNT_EMAIL}8`,
+ avatar: "myimg8",
+ },
+ fetchAndCacheProfileResolves: false,
+ options: { staleOk: true },
+ },
+ // staleOk but no cached profile
+ {
+ threshold: 1000,
+ expectsCachedProfileReturned: false,
+ cachedProfile: null,
+ fetchedProfile: {
+ uid: `${ACCOUNT_UID}9`,
+ email: `${ACCOUNT_EMAIL}9`,
+ avatar: "myimg9",
+ },
+ options: { staleOk: true },
+ },
+ // fresh profile but forceFresh = true
+ {
+ // Note: The threshold for this test case is being set to an arbitrary value that will
+ // be greater than Date.now() so the retrieved cached profile will be deemed recent.
+ threshold: Date.now() + 5000,
+ expectsCachedProfileReturned: false,
+ fetchedProfile: {
+ uid: `${ACCOUNT_UID}10`,
+ email: `${ACCOUNT_EMAIL}10`,
+ avatar: "myimg10",
+ },
+ options: { forceFresh: true },
+ },
+ ];
+
+ for (const tc of testCases) {
+ print(`test case: ${JSON.stringify(tc)}`);
+ let mockProfile = sinon.mock(profile);
+ mockProfile
+ .expects("_getProfileCache")
+ .once()
+ .returns(
+ tc.cachedProfile
+ ? {
+ profile: tc.cachedProfile,
+ }
+ : null
+ );
+ profile.PROFILE_FRESHNESS_THRESHOLD = tc.threshold;
+
+ let options = tc.options || {};
+ if (tc.expectsCachedProfileReturned) {
+ mockProfile.expects("_fetchAndCacheProfile").never();
+ let actualProfile = await profile.ensureProfile(options);
+ mockProfile.verify();
+ Assert.equal(actualProfile, tc.cachedProfile);
+ } else if (tc.fetchAndCacheProfileResolves) {
+ mockProfile
+ .expects("_fetchAndCacheProfile")
+ .once()
+ .resolves(tc.fetchedProfile);
+
+ let actualProfile = await profile.ensureProfile(options);
+ let expectedProfile = tc.fetchedProfile
+ ? tc.fetchedProfile
+ : tc.cachedProfile;
+ mockProfile.verify();
+ Assert.equal(actualProfile, expectedProfile);
+ } else {
+ mockProfile.expects("_fetchAndCacheProfile").once().rejects();
+
+ let actualProfile = await profile.ensureProfile(options);
+ mockProfile.verify();
+ Assert.equal(actualProfile, tc.cachedProfile);
+ }
+ }
+});
+
+// Check that a new profile request within PROFILE_FRESHNESS_THRESHOLD of the
+// last one *does* kick off a new request if ON_PROFILE_CHANGE_NOTIFICATION
+// is sent.
+add_task(async function fetchAndCacheProfileBeforeThresholdOnNotification() {
+ let numFetches = 0;
+ let client = mockClient(mockFxa());
+ client.fetchProfile = async function () {
+ numFetches += 1;
+ return {
+ body: { uid: ACCOUNT_UID, email: ACCOUNT_EMAIL, avatar: "myimg" },
+ };
+ };
+ let profile = CreateFxAccountsProfile(null, client);
+ profile.PROFILE_FRESHNESS_THRESHOLD = 1000;
+
+ // first fetch should return null as we don't have data.
+ let p = await profile.getProfile();
+ Assert.equal(p, null);
+ // ensure we kicked off a fetch.
+ Assert.notEqual(profile._currentFetchPromise, null);
+ // wait for that fetch to finish
+ await profile._currentFetchPromise;
+ Assert.equal(numFetches, 1);
+ Assert.equal(profile._currentFetchPromise, null);
+
+ Services.obs.notifyObservers(null, ON_PROFILE_CHANGE_NOTIFICATION);
+
+ let origFetchAndCatch = profile._fetchAndCacheProfile;
+ let backgroundFetchDone = Promise.withResolvers();
+ profile._fetchAndCacheProfile = async () => {
+ await origFetchAndCatch.call(profile);
+ backgroundFetchDone.resolve();
+ };
+ await profile.getProfile();
+ await backgroundFetchDone.promise;
+ Assert.equal(numFetches, 2);
+});
+
+add_test(function tearDown_ok() {
+ let profile = CreateFxAccountsProfile();
+
+ Assert.ok(!!profile.client);
+ Assert.ok(!!profile.fxai);
+
+ profile.tearDown();
+ Assert.equal(null, profile.fxai);
+ Assert.equal(null, profile.client);
+
+ run_next_test();
+});
+
+add_task(async function getProfile_ok() {
+ let cachedUrl = "myurl";
+ let didFetch = false;
+
+ let fxa = mockFxa();
+ fxa._testProfileCache = { profile: { uid: ACCOUNT_UID, avatar: cachedUrl } };
+ let profile = CreateFxAccountsProfile(fxa);
+
+ profile._fetchAndCacheProfile = function () {
+ didFetch = true;
+ return Promise.resolve();
+ };
+
+ let result = await profile.getProfile();
+
+ Assert.equal(result.avatar, cachedUrl);
+ Assert.ok(didFetch);
+});
+
+add_task(async function getProfile_no_cache() {
+ let fetchedUrl = "newUrl";
+ let fxa = mockFxa();
+ let profile = CreateFxAccountsProfile(fxa);
+
+ profile._fetchAndCacheProfileInternal = function () {
+ return Promise.resolve({ uid: ACCOUNT_UID, avatar: fetchedUrl });
+ };
+
+ await profile.getProfile(); // returns null.
+ let result = await profile._currentFetchPromise;
+ Assert.equal(result.avatar, fetchedUrl);
+});
+
+add_test(function getProfile_has_cached_fetch_deleted() {
+ let cachedUrl = "myurl";
+
+ let fxa = mockFxa();
+ let client = mockClient(fxa);
+ client.fetchProfile = function () {
+ return Promise.resolve({
+ body: { uid: ACCOUNT_UID, email: ACCOUNT_EMAIL, avatar: null },
+ });
+ };
+
+ let profile = CreateFxAccountsProfile(fxa, client);
+ fxa._testProfileCache = {
+ profile: { uid: ACCOUNT_UID, email: ACCOUNT_EMAIL, avatar: cachedUrl },
+ };
+
+ // instead of checking this in a mocked "save" function, just check after the
+ // observer
+ makeObserver(ON_PROFILE_CHANGE_NOTIFICATION, function (subject, topic, data) {
+ profile.getProfile().then(profileData => {
+ Assert.equal(null, profileData.avatar);
+ run_next_test();
+ });
+ });
+
+ return profile.getProfile().then(result => {
+ Assert.equal(result.avatar, "myurl");
+ });
+});
+
+add_test(function getProfile_fetchAndCacheProfile_throws() {
+ let fxa = mockFxa();
+ fxa._testProfileCache = {
+ profile: { uid: ACCOUNT_UID, email: ACCOUNT_EMAIL, avatar: "myimg" },
+ };
+ let profile = CreateFxAccountsProfile(fxa);
+
+ profile._fetchAndCacheProfile = () => Promise.reject(new Error());
+
+ return profile.getProfile().then(result => {
+ Assert.equal(result.avatar, "myimg");
+ run_next_test();
+ });
+});
+
+add_test(function getProfile_email_changed() {
+ let fxa = mockFxa();
+ let client = mockClient(fxa);
+ client.fetchProfile = function () {
+ return Promise.resolve({
+ body: { uid: ACCOUNT_UID, email: "newemail@bar.com" },
+ });
+ };
+ fxa._internal._handleEmailUpdated = email => {
+ Assert.equal(email, "newemail@bar.com");
+ run_next_test();
+ };
+
+ let profile = CreateFxAccountsProfile(fxa, client);
+ return profile._fetchAndCacheProfile();
+});
+
+function makeObserver(aObserveTopic, aObserveFunc) {
+ let callback = function (aSubject, aTopic, aData) {
+ log.debug("observed " + aTopic + " " + aData);
+ if (aTopic == aObserveTopic) {
+ removeMe();
+ aObserveFunc(aSubject, aTopic, aData);
+ }
+ };
+
+ function removeMe() {
+ log.debug("removing observer for " + aObserveTopic);
+ Services.obs.removeObserver(callback, aObserveTopic);
+ }
+
+ Services.obs.addObserver(callback, aObserveTopic);
+ return removeMe;
+}
diff --git a/services/fxaccounts/tests/xpcshell/test_profile_client.js b/services/fxaccounts/tests/xpcshell/test_profile_client.js
new file mode 100644
index 0000000000..22fcc293f8
--- /dev/null
+++ b/services/fxaccounts/tests/xpcshell/test_profile_client.js
@@ -0,0 +1,422 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const {
+ ERRNO_NETWORK,
+ ERRNO_PARSE,
+ ERRNO_UNKNOWN_ERROR,
+ ERROR_CODE_METHOD_NOT_ALLOWED,
+ ERROR_MSG_METHOD_NOT_ALLOWED,
+ ERROR_NETWORK,
+ ERROR_PARSE,
+ ERROR_UNKNOWN,
+} = ChromeUtils.importESModule(
+ "resource://gre/modules/FxAccountsCommon.sys.mjs"
+);
+const { FxAccountsProfileClient, FxAccountsProfileClientError } =
+ ChromeUtils.importESModule(
+ "resource://gre/modules/FxAccountsProfileClient.sys.mjs"
+ );
+
+const STATUS_SUCCESS = 200;
+
+/**
+ * Mock request responder
+ * @param {String} response
+ * Mocked raw response from the server
+ * @returns {Function}
+ */
+let mockResponse = function (response) {
+ let Request = function (requestUri) {
+ // Store the request uri so tests can inspect it
+ Request._requestUri = requestUri;
+ Request.ifNoneMatchSet = false;
+ return {
+ setHeader(header, value) {
+ if (header == "If-None-Match" && value == "bogusETag") {
+ Request.ifNoneMatchSet = true;
+ }
+ },
+ async dispatch(method, payload) {
+ this.response = response;
+ return this.response;
+ },
+ };
+ };
+
+ return Request;
+};
+
+// A simple mock FxA that hands out tokens without checking them and doesn't
+// expect tokens to be revoked. We have specific token tests further down that
+// has more checks here.
+let mockFxaInternal = {
+ getOAuthToken(options) {
+ Assert.equal(options.scope, "profile");
+ return "token";
+ },
+};
+
+const PROFILE_OPTIONS = {
+ serverURL: "http://127.0.0.1:1111/v1",
+ fxai: mockFxaInternal,
+};
+
+/**
+ * Mock request error responder
+ * @param {Error} error
+ * Error object
+ * @returns {Function}
+ */
+let mockResponseError = function (error) {
+ return function () {
+ return {
+ setHeader() {},
+ async dispatch(method, payload) {
+ throw error;
+ },
+ };
+ };
+};
+
+add_test(function successfulResponse() {
+ let client = new FxAccountsProfileClient(PROFILE_OPTIONS);
+ let response = {
+ success: true,
+ status: STATUS_SUCCESS,
+ headers: { etag: "bogusETag" },
+ body: '{"email":"someone@restmail.net","uid":"0d5c1a89b8c54580b8e3e8adadae864a"}',
+ };
+
+ client._Request = new mockResponse(response);
+ client.fetchProfile().then(function (result) {
+ Assert.equal(
+ client._Request._requestUri,
+ "http://127.0.0.1:1111/v1/profile"
+ );
+ Assert.equal(result.body.email, "someone@restmail.net");
+ Assert.equal(result.body.uid, "0d5c1a89b8c54580b8e3e8adadae864a");
+ Assert.equal(result.etag, "bogusETag");
+ run_next_test();
+ });
+});
+
+add_test(function setsIfNoneMatchETagHeader() {
+ let client = new FxAccountsProfileClient(PROFILE_OPTIONS);
+ let response = {
+ success: true,
+ status: STATUS_SUCCESS,
+ headers: {},
+ body: '{"email":"someone@restmail.net","uid":"0d5c1a89b8c54580b8e3e8adadae864a"}',
+ };
+
+ let req = new mockResponse(response);
+ client._Request = req;
+ client.fetchProfile("bogusETag").then(function (result) {
+ Assert.equal(
+ client._Request._requestUri,
+ "http://127.0.0.1:1111/v1/profile"
+ );
+ Assert.equal(result.body.email, "someone@restmail.net");
+ Assert.equal(result.body.uid, "0d5c1a89b8c54580b8e3e8adadae864a");
+ Assert.ok(req.ifNoneMatchSet);
+ run_next_test();
+ });
+});
+
+add_test(function successful304Response() {
+ let client = new FxAccountsProfileClient(PROFILE_OPTIONS);
+ let response = {
+ success: true,
+ headers: { etag: "bogusETag" },
+ status: 304,
+ };
+
+ client._Request = new mockResponse(response);
+ client.fetchProfile().then(function (result) {
+ Assert.equal(result, null);
+ run_next_test();
+ });
+});
+
+add_test(function parseErrorResponse() {
+ let client = new FxAccountsProfileClient(PROFILE_OPTIONS);
+ let response = {
+ success: true,
+ status: STATUS_SUCCESS,
+ body: "unexpected",
+ };
+
+ client._Request = new mockResponse(response);
+ client.fetchProfile().catch(function (e) {
+ Assert.equal(e.name, "FxAccountsProfileClientError");
+ Assert.equal(e.code, STATUS_SUCCESS);
+ Assert.equal(e.errno, ERRNO_PARSE);
+ Assert.equal(e.error, ERROR_PARSE);
+ Assert.equal(e.message, "unexpected");
+ run_next_test();
+ });
+});
+
+add_test(function serverErrorResponse() {
+ let client = new FxAccountsProfileClient(PROFILE_OPTIONS);
+ let response = {
+ status: 500,
+ body: '{ "code": 500, "errno": 100, "error": "Bad Request", "message": "Something went wrong", "reason": "Because the internet" }',
+ };
+
+ client._Request = new mockResponse(response);
+ client.fetchProfile().catch(function (e) {
+ Assert.equal(e.name, "FxAccountsProfileClientError");
+ Assert.equal(e.code, 500);
+ Assert.equal(e.errno, 100);
+ Assert.equal(e.error, "Bad Request");
+ Assert.equal(e.message, "Something went wrong");
+ run_next_test();
+ });
+});
+
+// Test that we get a token, then if we get a 401 we revoke it, get a new one
+// and retry.
+add_test(function server401ResponseThenSuccess() {
+ // The last token we handed out.
+ let lastToken = -1;
+ // The number of times our removeCachedOAuthToken function was called.
+ let numTokensRemoved = 0;
+
+ let mockFxaWithRemove = {
+ getOAuthToken(options) {
+ Assert.equal(options.scope, "profile");
+ return "" + ++lastToken; // tokens are strings.
+ },
+ removeCachedOAuthToken(options) {
+ // This test never has more than 1 token alive at once, so the token
+ // being revoked must always be the last token we handed out.
+ Assert.equal(parseInt(options.token), lastToken);
+ ++numTokensRemoved;
+ },
+ };
+ let profileOptions = {
+ serverURL: "http://127.0.0.1:1111/v1",
+ fxai: mockFxaWithRemove,
+ };
+ let client = new FxAccountsProfileClient(profileOptions);
+
+ // 2 responses - first one implying the token has expired, second works.
+ let responses = [
+ {
+ status: 401,
+ body: '{ "code": 401, "errno": 100, "error": "Token expired", "message": "That token is too old", "reason": "Because security" }',
+ },
+ {
+ success: true,
+ status: STATUS_SUCCESS,
+ headers: {},
+ body: '{"avatar":"http://example.com/image.jpg","id":"0d5c1a89b8c54580b8e3e8adadae864a"}',
+ },
+ ];
+
+ let numRequests = 0;
+ let numAuthHeaders = 0;
+ // Like mockResponse but we want access to headers etc.
+ client._Request = function (requestUri) {
+ return {
+ setHeader(name, value) {
+ if (name == "Authorization") {
+ numAuthHeaders++;
+ Assert.equal(value, "Bearer " + lastToken);
+ }
+ },
+ async dispatch(method, payload) {
+ this.response = responses[numRequests];
+ ++numRequests;
+ return this.response;
+ },
+ };
+ };
+
+ client.fetchProfile().then(result => {
+ Assert.equal(result.body.avatar, "http://example.com/image.jpg");
+ Assert.equal(result.body.id, "0d5c1a89b8c54580b8e3e8adadae864a");
+ // should have been exactly 2 requests and exactly 2 auth headers.
+ Assert.equal(numRequests, 2);
+ Assert.equal(numAuthHeaders, 2);
+ // and we should have seen one token revoked.
+ Assert.equal(numTokensRemoved, 1);
+
+ run_next_test();
+ });
+});
+
+// Test that we get a token, then if we get a 401 we revoke it, get a new one
+// and retry - but we *still* get a 401 on the retry, so the caller sees that.
+add_test(function server401ResponsePersists() {
+ // The last token we handed out.
+ let lastToken = -1;
+ // The number of times our removeCachedOAuthToken function was called.
+ let numTokensRemoved = 0;
+
+ let mockFxaWithRemove = {
+ getOAuthToken(options) {
+ Assert.equal(options.scope, "profile");
+ return "" + ++lastToken; // tokens are strings.
+ },
+ removeCachedOAuthToken(options) {
+ // This test never has more than 1 token alive at once, so the token
+ // being revoked must always be the last token we handed out.
+ Assert.equal(parseInt(options.token), lastToken);
+ ++numTokensRemoved;
+ },
+ };
+ let profileOptions = {
+ serverURL: "http://127.0.0.1:1111/v1",
+ fxai: mockFxaWithRemove,
+ };
+ let client = new FxAccountsProfileClient(profileOptions);
+
+ let response = {
+ status: 401,
+ body: '{ "code": 401, "errno": 100, "error": "It\'s not your token, it\'s you!", "message": "I don\'t like you", "reason": "Because security" }',
+ };
+
+ let numRequests = 0;
+ let numAuthHeaders = 0;
+ client._Request = function (requestUri) {
+ return {
+ setHeader(name, value) {
+ if (name == "Authorization") {
+ numAuthHeaders++;
+ Assert.equal(value, "Bearer " + lastToken);
+ }
+ },
+ async dispatch(method, payload) {
+ this.response = response;
+ ++numRequests;
+ return this.response;
+ },
+ };
+ };
+
+ client.fetchProfile().catch(function (e) {
+ Assert.equal(e.name, "FxAccountsProfileClientError");
+ Assert.equal(e.code, 401);
+ Assert.equal(e.errno, 100);
+ Assert.equal(e.error, "It's not your token, it's you!");
+ // should have been exactly 2 requests and exactly 2 auth headers.
+ Assert.equal(numRequests, 2);
+ Assert.equal(numAuthHeaders, 2);
+ // and we should have seen both tokens revoked.
+ Assert.equal(numTokensRemoved, 2);
+ run_next_test();
+ });
+});
+
+add_test(function networkErrorResponse() {
+ let client = new FxAccountsProfileClient({
+ serverURL: "http://domain.dummy",
+ fxai: mockFxaInternal,
+ });
+ client.fetchProfile().catch(function (e) {
+ Assert.equal(e.name, "FxAccountsProfileClientError");
+ Assert.equal(e.code, null);
+ Assert.equal(e.errno, ERRNO_NETWORK);
+ Assert.equal(e.error, ERROR_NETWORK);
+ run_next_test();
+ });
+});
+
+add_test(function unsupportedMethod() {
+ let client = new FxAccountsProfileClient(PROFILE_OPTIONS);
+
+ return client._createRequest("/profile", "PUT").catch(function (e) {
+ Assert.equal(e.name, "FxAccountsProfileClientError");
+ Assert.equal(e.code, ERROR_CODE_METHOD_NOT_ALLOWED);
+ Assert.equal(e.errno, ERRNO_NETWORK);
+ Assert.equal(e.error, ERROR_NETWORK);
+ Assert.equal(e.message, ERROR_MSG_METHOD_NOT_ALLOWED);
+ run_next_test();
+ });
+});
+
+add_test(function onCompleteRequestError() {
+ let client = new FxAccountsProfileClient(PROFILE_OPTIONS);
+ client._Request = new mockResponseError(new Error("onComplete error"));
+ client.fetchProfile().catch(function (e) {
+ Assert.equal(e.name, "FxAccountsProfileClientError");
+ Assert.equal(e.code, null);
+ Assert.equal(e.errno, ERRNO_NETWORK);
+ Assert.equal(e.error, ERROR_NETWORK);
+ Assert.equal(e.message, "Error: onComplete error");
+ run_next_test();
+ });
+});
+
+add_test(function constructorTests() {
+ validationHelper(
+ undefined,
+ "Error: Missing 'serverURL' configuration option"
+ );
+
+ validationHelper({}, "Error: Missing 'serverURL' configuration option");
+
+ validationHelper({ serverURL: "badUrl" }, "Error: Invalid 'serverURL'");
+
+ run_next_test();
+});
+
+add_test(function errorTests() {
+ let error1 = new FxAccountsProfileClientError();
+ Assert.equal(error1.name, "FxAccountsProfileClientError");
+ Assert.equal(error1.code, null);
+ Assert.equal(error1.errno, ERRNO_UNKNOWN_ERROR);
+ Assert.equal(error1.error, ERROR_UNKNOWN);
+ Assert.equal(error1.message, null);
+
+ let error2 = new FxAccountsProfileClientError({
+ code: STATUS_SUCCESS,
+ errno: 1,
+ error: "Error",
+ message: "Something",
+ });
+ let fields2 = error2._toStringFields();
+ let statusCode = 1;
+
+ Assert.equal(error2.name, "FxAccountsProfileClientError");
+ Assert.equal(error2.code, STATUS_SUCCESS);
+ Assert.equal(error2.errno, statusCode);
+ Assert.equal(error2.error, "Error");
+ Assert.equal(error2.message, "Something");
+
+ Assert.equal(fields2.name, "FxAccountsProfileClientError");
+ Assert.equal(fields2.code, STATUS_SUCCESS);
+ Assert.equal(fields2.errno, statusCode);
+ Assert.equal(fields2.error, "Error");
+ Assert.equal(fields2.message, "Something");
+
+ Assert.ok(error2.toString().includes("Something"));
+ run_next_test();
+});
+
+/**
+ * Quick way to test the "FxAccountsProfileClient" constructor.
+ *
+ * @param {Object} options
+ * FxAccountsProfileClient constructor options
+ * @param {String} expected
+ * Expected error message
+ * @returns {*}
+ */
+function validationHelper(options, expected) {
+ // add fxai to options - that missing isn't what we are testing here.
+ if (options) {
+ options.fxai = mockFxaInternal;
+ }
+ try {
+ new FxAccountsProfileClient(options);
+ } catch (e) {
+ return Assert.equal(e.toString(), expected);
+ }
+ throw new Error("Validation helper error");
+}
diff --git a/services/fxaccounts/tests/xpcshell/test_push_service.js b/services/fxaccounts/tests/xpcshell/test_push_service.js
new file mode 100644
index 0000000000..0441888847
--- /dev/null
+++ b/services/fxaccounts/tests/xpcshell/test_push_service.js
@@ -0,0 +1,522 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Tests for the FxA push service.
+
+/* eslint-disable mozilla/use-chromeutils-generateqi */
+
+const {
+ FXA_PUSH_SCOPE_ACCOUNT_UPDATE,
+ ONLOGOUT_NOTIFICATION,
+ ON_ACCOUNT_DESTROYED_NOTIFICATION,
+ ON_DEVICE_CONNECTED_NOTIFICATION,
+ ON_DEVICE_DISCONNECTED_NOTIFICATION,
+ ON_PASSWORD_CHANGED_NOTIFICATION,
+ ON_PASSWORD_RESET_NOTIFICATION,
+ ON_PROFILE_CHANGE_NOTIFICATION,
+ ON_PROFILE_UPDATED_NOTIFICATION,
+ ON_VERIFY_LOGIN_NOTIFICATION,
+ log,
+} = ChromeUtils.importESModule(
+ "resource://gre/modules/FxAccountsCommon.sys.mjs"
+);
+
+const { FxAccountsPushService } = ChromeUtils.importESModule(
+ "resource://gre/modules/FxAccountsPush.sys.mjs"
+);
+
+XPCOMUtils.defineLazyServiceGetter(
+ this,
+ "PushService",
+ "@mozilla.org/push/Service;1",
+ "nsIPushService"
+);
+
+initTestLogging("Trace");
+log.level = Log.Level.Trace;
+
+const MOCK_ENDPOINT = "http://mochi.test:8888";
+
+// tests do not allow external connections, mock the PushService
+let mockPushService = {
+ pushTopic: PushService.pushTopic,
+ subscriptionChangeTopic: PushService.subscriptionChangeTopic,
+ subscribe(scope, principal, cb) {
+ cb(Cr.NS_OK, {
+ endpoint: MOCK_ENDPOINT,
+ });
+ },
+ unsubscribe(scope, principal, cb) {
+ cb(Cr.NS_OK, true);
+ },
+};
+
+let mockFxAccounts = {
+ checkVerificationStatus() {},
+ updateDeviceRegistration() {},
+};
+
+let mockLog = {
+ trace() {},
+ debug() {},
+ warn() {},
+ error() {},
+};
+
+add_task(async function initialize() {
+ let pushService = new FxAccountsPushService();
+ equal(pushService.initialize(), false);
+});
+
+add_task(async function registerPushEndpointSuccess() {
+ let pushService = new FxAccountsPushService({
+ pushService: mockPushService,
+ fxai: mockFxAccounts,
+ });
+
+ let subscription = await pushService.registerPushEndpoint();
+ equal(subscription.endpoint, MOCK_ENDPOINT);
+});
+
+add_task(async function registerPushEndpointFailure() {
+ let failPushService = Object.assign(mockPushService, {
+ subscribe(scope, principal, cb) {
+ cb(Cr.NS_ERROR_ABORT);
+ },
+ });
+
+ let pushService = new FxAccountsPushService({
+ pushService: failPushService,
+ fxai: mockFxAccounts,
+ });
+
+ let subscription = await pushService.registerPushEndpoint();
+ equal(subscription, null);
+});
+
+add_task(async function unsubscribeSuccess() {
+ let pushService = new FxAccountsPushService({
+ pushService: mockPushService,
+ fxai: mockFxAccounts,
+ });
+
+ let result = await pushService.unsubscribe();
+ equal(result, true);
+});
+
+add_task(async function unsubscribeFailure() {
+ let failPushService = Object.assign(mockPushService, {
+ unsubscribe(scope, principal, cb) {
+ cb(Cr.NS_ERROR_ABORT);
+ },
+ });
+
+ let pushService = new FxAccountsPushService({
+ pushService: failPushService,
+ fxai: mockFxAccounts,
+ });
+
+ let result = await pushService.unsubscribe();
+ equal(result, null);
+});
+
+add_test(function observeLogout() {
+ let customLog = Object.assign(mockLog, {
+ trace(msg) {
+ if (msg === "FxAccountsPushService unsubscribe") {
+ // logout means we unsubscribe
+ run_next_test();
+ }
+ },
+ });
+
+ let pushService = new FxAccountsPushService({
+ pushService: mockPushService,
+ log: customLog,
+ });
+
+ pushService.observe(null, ONLOGOUT_NOTIFICATION);
+});
+
+add_test(function observePushTopicVerify() {
+ let emptyMsg = {
+ QueryInterface() {
+ return this;
+ },
+ };
+ let customAccounts = Object.assign(mockFxAccounts, {
+ checkVerificationStatus() {
+ // checking verification status on push messages without data
+ run_next_test();
+ },
+ });
+
+ let pushService = new FxAccountsPushService({
+ pushService: mockPushService,
+ fxai: customAccounts,
+ });
+
+ pushService.observe(
+ emptyMsg,
+ mockPushService.pushTopic,
+ FXA_PUSH_SCOPE_ACCOUNT_UPDATE
+ );
+});
+
+add_test(function observePushTopicDeviceConnected() {
+ let msg = {
+ data: {
+ json: () => ({
+ command: ON_DEVICE_CONNECTED_NOTIFICATION,
+ data: {
+ deviceName: "My phone",
+ },
+ }),
+ },
+ QueryInterface() {
+ return this;
+ },
+ };
+ let obs = (subject, topic, data) => {
+ Services.obs.removeObserver(obs, topic);
+ run_next_test();
+ };
+ Services.obs.addObserver(obs, ON_DEVICE_CONNECTED_NOTIFICATION);
+
+ let pushService = new FxAccountsPushService({
+ pushService: mockPushService,
+ fxai: mockFxAccounts,
+ });
+
+ pushService.observe(
+ msg,
+ mockPushService.pushTopic,
+ FXA_PUSH_SCOPE_ACCOUNT_UPDATE
+ );
+});
+
+add_task(async function observePushTopicDeviceDisconnected_current_device() {
+ const deviceId = "bogusid";
+ let msg = {
+ data: {
+ json: () => ({
+ command: ON_DEVICE_DISCONNECTED_NOTIFICATION,
+ data: {
+ id: deviceId,
+ },
+ }),
+ },
+ QueryInterface() {
+ return this;
+ },
+ };
+
+ let signoutCalled = false;
+ let { FxAccounts } = ChromeUtils.importESModule(
+ "resource://gre/modules/FxAccounts.sys.mjs"
+ );
+ const fxAccountsMock = new FxAccounts({
+ newAccountState() {
+ return {
+ async getUserAccountData() {
+ return { device: { id: deviceId } };
+ },
+ };
+ },
+ signOut() {
+ signoutCalled = true;
+ },
+ })._internal;
+
+ const deviceDisconnectedNotificationObserved = new Promise(resolve => {
+ Services.obs.addObserver(function obs(subject, topic, data) {
+ Services.obs.removeObserver(obs, topic);
+ equal(data, JSON.stringify({ isLocalDevice: true }));
+ resolve();
+ }, ON_DEVICE_DISCONNECTED_NOTIFICATION);
+ });
+
+ let pushService = new FxAccountsPushService({
+ pushService: mockPushService,
+ fxai: fxAccountsMock,
+ });
+
+ pushService.observe(
+ msg,
+ mockPushService.pushTopic,
+ FXA_PUSH_SCOPE_ACCOUNT_UPDATE
+ );
+
+ await deviceDisconnectedNotificationObserved;
+ ok(signoutCalled);
+});
+
+add_task(async function observePushTopicDeviceDisconnected_another_device() {
+ const deviceId = "bogusid";
+ let msg = {
+ data: {
+ json: () => ({
+ command: ON_DEVICE_DISCONNECTED_NOTIFICATION,
+ data: {
+ id: deviceId,
+ },
+ }),
+ },
+ QueryInterface() {
+ return this;
+ },
+ };
+
+ let signoutCalled = false;
+ let { FxAccounts } = ChromeUtils.importESModule(
+ "resource://gre/modules/FxAccounts.sys.mjs"
+ );
+ const fxAccountsMock = new FxAccounts({
+ newAccountState() {
+ return {
+ async getUserAccountData() {
+ return { device: { id: "thelocaldevice" } };
+ },
+ };
+ },
+ signOut() {
+ signoutCalled = true;
+ },
+ })._internal;
+
+ const deviceDisconnectedNotificationObserved = new Promise(resolve => {
+ Services.obs.addObserver(function obs(subject, topic, data) {
+ Services.obs.removeObserver(obs, topic);
+ equal(data, JSON.stringify({ isLocalDevice: false }));
+ resolve();
+ }, ON_DEVICE_DISCONNECTED_NOTIFICATION);
+ });
+
+ let pushService = new FxAccountsPushService({
+ pushService: mockPushService,
+ fxai: fxAccountsMock,
+ });
+
+ pushService.observe(
+ msg,
+ mockPushService.pushTopic,
+ FXA_PUSH_SCOPE_ACCOUNT_UPDATE
+ );
+
+ await deviceDisconnectedNotificationObserved;
+ ok(!signoutCalled);
+});
+
+add_test(function observePushTopicAccountDestroyed() {
+ const uid = "bogusuid";
+ let msg = {
+ data: {
+ json: () => ({
+ command: ON_ACCOUNT_DESTROYED_NOTIFICATION,
+ data: {
+ uid,
+ },
+ }),
+ },
+ QueryInterface() {
+ return this;
+ },
+ };
+ let customAccounts = Object.assign(mockFxAccounts, {
+ _handleAccountDestroyed() {
+ // checking verification status on push messages without data
+ run_next_test();
+ },
+ });
+
+ let pushService = new FxAccountsPushService({
+ pushService: mockPushService,
+ fxai: customAccounts,
+ });
+
+ pushService.observe(
+ msg,
+ mockPushService.pushTopic,
+ FXA_PUSH_SCOPE_ACCOUNT_UPDATE
+ );
+});
+
+add_test(function observePushTopicVerifyLogin() {
+ let url = "http://localhost/newLogin";
+ let title = "bogustitle";
+ let body = "bogusbody";
+ let msg = {
+ data: {
+ json: () => ({
+ command: ON_VERIFY_LOGIN_NOTIFICATION,
+ data: {
+ body,
+ title,
+ url,
+ },
+ }),
+ },
+ QueryInterface() {
+ return this;
+ },
+ };
+ let obs = (subject, topic, data) => {
+ Services.obs.removeObserver(obs, topic);
+ Assert.equal(data, JSON.stringify(msg.data.json().data));
+ run_next_test();
+ };
+ Services.obs.addObserver(obs, ON_VERIFY_LOGIN_NOTIFICATION);
+
+ let pushService = new FxAccountsPushService({
+ pushService: mockPushService,
+ fxai: mockFxAccounts,
+ });
+
+ pushService.observe(
+ msg,
+ mockPushService.pushTopic,
+ FXA_PUSH_SCOPE_ACCOUNT_UPDATE
+ );
+});
+
+add_test(function observePushTopicProfileUpdated() {
+ let msg = {
+ data: {
+ json: () => ({
+ command: ON_PROFILE_UPDATED_NOTIFICATION,
+ }),
+ },
+ QueryInterface() {
+ return this;
+ },
+ };
+ let obs = (subject, topic, data) => {
+ Services.obs.removeObserver(obs, topic);
+ run_next_test();
+ };
+ Services.obs.addObserver(obs, ON_PROFILE_CHANGE_NOTIFICATION);
+
+ let pushService = new FxAccountsPushService({
+ pushService: mockPushService,
+ fxai: mockFxAccounts,
+ });
+
+ pushService.observe(
+ msg,
+ mockPushService.pushTopic,
+ FXA_PUSH_SCOPE_ACCOUNT_UPDATE
+ );
+});
+
+add_test(function observePushTopicPasswordChanged() {
+ let msg = {
+ data: {
+ json: () => ({
+ command: ON_PASSWORD_CHANGED_NOTIFICATION,
+ }),
+ },
+ QueryInterface() {
+ return this;
+ },
+ };
+
+ let pushService = new FxAccountsPushService({
+ pushService: mockPushService,
+ });
+
+ pushService._onPasswordChanged = function () {
+ run_next_test();
+ };
+
+ pushService.observe(
+ msg,
+ mockPushService.pushTopic,
+ FXA_PUSH_SCOPE_ACCOUNT_UPDATE
+ );
+});
+
+add_test(function observePushTopicPasswordReset() {
+ let msg = {
+ data: {
+ json: () => ({
+ command: ON_PASSWORD_RESET_NOTIFICATION,
+ }),
+ },
+ QueryInterface() {
+ return this;
+ },
+ };
+
+ let pushService = new FxAccountsPushService({
+ pushService: mockPushService,
+ });
+
+ pushService._onPasswordChanged = function () {
+ run_next_test();
+ };
+
+ pushService.observe(
+ msg,
+ mockPushService.pushTopic,
+ FXA_PUSH_SCOPE_ACCOUNT_UPDATE
+ );
+});
+
+add_task(async function commandReceived() {
+ let msg = {
+ data: {
+ json: () => ({
+ command: "fxaccounts:command_received",
+ data: {
+ url: "https://api.accounts.firefox.com/auth/v1/account/device/commands?index=42&limit=1",
+ },
+ }),
+ },
+ QueryInterface() {
+ return this;
+ },
+ };
+
+ let fxAccountsMock = {};
+ const promiseConsumeRemoteMessagesCalled = new Promise(res => {
+ fxAccountsMock.commands = {
+ pollDeviceCommands() {
+ res();
+ },
+ };
+ });
+
+ let pushService = new FxAccountsPushService({
+ pushService: mockPushService,
+ fxai: fxAccountsMock,
+ });
+
+ pushService.observe(
+ msg,
+ mockPushService.pushTopic,
+ FXA_PUSH_SCOPE_ACCOUNT_UPDATE
+ );
+ await promiseConsumeRemoteMessagesCalled;
+});
+
+add_test(function observeSubscriptionChangeTopic() {
+ let customAccounts = Object.assign(mockFxAccounts, {
+ updateDeviceRegistration() {
+ // subscription change means updating the device registration
+ run_next_test();
+ },
+ });
+
+ let pushService = new FxAccountsPushService({
+ pushService: mockPushService,
+ fxai: customAccounts,
+ });
+
+ pushService.observe(
+ null,
+ mockPushService.subscriptionChangeTopic,
+ FXA_PUSH_SCOPE_ACCOUNT_UPDATE
+ );
+});
diff --git a/services/fxaccounts/tests/xpcshell/test_storage_manager.js b/services/fxaccounts/tests/xpcshell/test_storage_manager.js
new file mode 100644
index 0000000000..05c565d2f4
--- /dev/null
+++ b/services/fxaccounts/tests/xpcshell/test_storage_manager.js
@@ -0,0 +1,586 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Tests for the FxA storage manager.
+
+const { FxAccountsStorageManager } = ChromeUtils.importESModule(
+ "resource://gre/modules/FxAccountsStorage.sys.mjs"
+);
+const { DATA_FORMAT_VERSION, log } = ChromeUtils.importESModule(
+ "resource://gre/modules/FxAccountsCommon.sys.mjs"
+);
+
+initTestLogging("Trace");
+log.level = Log.Level.Trace;
+
+const DEVICE_REGISTRATION_VERSION = 42;
+
+// A couple of mocks we can use.
+function MockedPlainStorage(accountData) {
+ let data = null;
+ if (accountData) {
+ data = {
+ version: DATA_FORMAT_VERSION,
+ accountData,
+ };
+ }
+ this.data = data;
+ this.numReads = 0;
+}
+MockedPlainStorage.prototype = {
+ async get() {
+ this.numReads++;
+ Assert.equal(this.numReads, 1, "should only ever be 1 read of acct data");
+ return this.data;
+ },
+
+ async set(data) {
+ this.data = data;
+ },
+};
+
+function MockedSecureStorage(accountData) {
+ let data = null;
+ if (accountData) {
+ data = {
+ version: DATA_FORMAT_VERSION,
+ accountData,
+ };
+ }
+ this.data = data;
+ this.numReads = 0;
+}
+
+MockedSecureStorage.prototype = {
+ fetchCount: 0,
+ locked: false,
+ /* eslint-disable object-shorthand */
+ // This constructor must be declared without
+ // object shorthand or we get an exception of
+ // "TypeError: this.STORAGE_LOCKED is not a constructor"
+ STORAGE_LOCKED: function () {},
+ /* eslint-enable object-shorthand */
+ async get(uid, email) {
+ this.fetchCount++;
+ if (this.locked) {
+ throw new this.STORAGE_LOCKED();
+ }
+ this.numReads++;
+ Assert.equal(
+ this.numReads,
+ 1,
+ "should only ever be 1 read of unlocked data"
+ );
+ return this.data;
+ },
+
+ async set(uid, contents) {
+ this.data = contents;
+ },
+};
+
+function add_storage_task(testFunction) {
+ add_task(async function () {
+ print("Starting test with secure storage manager");
+ await testFunction(new FxAccountsStorageManager());
+ });
+ add_task(async function () {
+ print("Starting test with simple storage manager");
+ await testFunction(new FxAccountsStorageManager({ useSecure: false }));
+ });
+}
+
+// initialized without account data and there's nothing to read. Not logged in.
+add_storage_task(async function checkInitializedEmpty(sm) {
+ if (sm.secureStorage) {
+ sm.secureStorage = new MockedSecureStorage(null);
+ }
+ await sm.initialize();
+ Assert.strictEqual(await sm.getAccountData(), null);
+ await Assert.rejects(
+ sm.updateAccountData({ scopedKeys: { ...MOCK_ACCOUNT_KEYS.scopedKeys } }),
+ /No user is logged in/
+ );
+});
+
+// Initialized with account data (ie, simulating a new user being logged in).
+// Should reflect the initial data and be written to storage.
+add_storage_task(async function checkNewUser(sm) {
+ let initialAccountData = {
+ uid: "uid",
+ email: "someone@somewhere.com",
+ scopedKeys: { ...MOCK_ACCOUNT_KEYS.scopedKeys },
+ device: {
+ id: "device id",
+ },
+ };
+ sm.plainStorage = new MockedPlainStorage();
+ if (sm.secureStorage) {
+ sm.secureStorage = new MockedSecureStorage(null);
+ }
+ await sm.initialize(initialAccountData);
+ let accountData = await sm.getAccountData();
+ Assert.equal(accountData.uid, initialAccountData.uid);
+ Assert.equal(accountData.email, initialAccountData.email);
+ Assert.deepEqual(accountData.scopedKeys, initialAccountData.scopedKeys);
+ Assert.deepEqual(accountData.device, initialAccountData.device);
+
+ // and it should have been written to storage.
+ Assert.equal(sm.plainStorage.data.accountData.uid, initialAccountData.uid);
+ Assert.equal(
+ sm.plainStorage.data.accountData.email,
+ initialAccountData.email
+ );
+ Assert.deepEqual(
+ sm.plainStorage.data.accountData.device,
+ initialAccountData.device
+ );
+ // check secure
+ if (sm.secureStorage) {
+ Assert.deepEqual(
+ sm.secureStorage.data.accountData.scopedKeys,
+ initialAccountData.scopedKeys
+ );
+ } else {
+ Assert.deepEqual(
+ sm.plainStorage.data.accountData.scopedKeys,
+ initialAccountData.scopedKeys
+ );
+ }
+});
+
+// Initialized without account data but storage has it available.
+add_storage_task(async function checkEverythingRead(sm) {
+ sm.plainStorage = new MockedPlainStorage({
+ uid: "uid",
+ email: "someone@somewhere.com",
+ device: {
+ id: "wibble",
+ registrationVersion: null,
+ },
+ });
+ if (sm.secureStorage) {
+ sm.secureStorage = new MockedSecureStorage(null);
+ }
+ await sm.initialize();
+ let accountData = await sm.getAccountData();
+ Assert.ok(accountData, "read account data");
+ Assert.equal(accountData.uid, "uid");
+ Assert.equal(accountData.email, "someone@somewhere.com");
+ Assert.deepEqual(accountData.device, {
+ id: "wibble",
+ registrationVersion: null,
+ });
+ // Update the data - we should be able to fetch it back and it should appear
+ // in our storage.
+ await sm.updateAccountData({
+ verified: true,
+ scopedKeys: { ...MOCK_ACCOUNT_KEYS.scopedKeys },
+ device: {
+ id: "wibble",
+ registrationVersion: DEVICE_REGISTRATION_VERSION,
+ },
+ });
+ accountData = await sm.getAccountData();
+ Assert.deepEqual(accountData.scopedKeys, MOCK_ACCOUNT_KEYS.scopedKeys);
+ Assert.deepEqual(accountData.device, {
+ id: "wibble",
+ registrationVersion: DEVICE_REGISTRATION_VERSION,
+ });
+ // Check the new value was written to storage.
+ await sm._promiseStorageComplete; // storage is written in the background.
+ Assert.equal(sm.plainStorage.data.accountData.verified, true);
+ Assert.deepEqual(sm.plainStorage.data.accountData.device, {
+ id: "wibble",
+ registrationVersion: DEVICE_REGISTRATION_VERSION,
+ });
+ // derive keys are secure
+ if (sm.secureStorage) {
+ Assert.deepEqual(
+ sm.secureStorage.data.accountData.scopedKeys,
+ MOCK_ACCOUNT_KEYS.scopedKeys
+ );
+ } else {
+ Assert.deepEqual(
+ sm.plainStorage.data.accountData.scopedKeys,
+ MOCK_ACCOUNT_KEYS.scopedKeys
+ );
+ }
+});
+
+add_storage_task(async function checkInvalidUpdates(sm) {
+ sm.plainStorage = new MockedPlainStorage({
+ uid: "uid",
+ email: "someone@somewhere.com",
+ });
+ if (sm.secureStorage) {
+ sm.secureStorage = new MockedSecureStorage(null);
+ }
+ await sm.initialize();
+
+ await Assert.rejects(
+ sm.updateAccountData({ uid: "another" }),
+ /Can't change uid/
+ );
+});
+
+add_storage_task(async function checkNullUpdatesRemovedUnlocked(sm) {
+ if (sm.secureStorage) {
+ sm.plainStorage = new MockedPlainStorage({
+ uid: "uid",
+ email: "someone@somewhere.com",
+ });
+ sm.secureStorage = new MockedSecureStorage({
+ scopedKeys: { ...MOCK_ACCOUNT_KEYS.scopedKeys },
+ unwrapBKey: "unwrapBKey",
+ });
+ } else {
+ sm.plainStorage = new MockedPlainStorage({
+ uid: "uid",
+ email: "someone@somewhere.com",
+ scopedKeys: { ...MOCK_ACCOUNT_KEYS.scopedKeys },
+ unwrapBKey: "unwrapBKey",
+ });
+ }
+ await sm.initialize();
+
+ await sm.updateAccountData({ unwrapBKey: null });
+ let accountData = await sm.getAccountData();
+ Assert.ok(!accountData.unwrapBKey);
+ Assert.deepEqual(accountData.scopedKeys, MOCK_ACCOUNT_KEYS.scopedKeys);
+});
+
+add_storage_task(async function checkNullRemovesUnlistedFields(sm) {
+ // kA and kB are not listed in FXA_PWDMGR_*_FIELDS, but we still want to
+ // be able to delete them (migration case).
+ if (sm.secureStorage) {
+ sm.plainStorage = new MockedPlainStorage({
+ uid: "uid",
+ email: "someone@somewhere.com",
+ });
+ sm.secureStorage = new MockedSecureStorage({ kA: "kA", kb: "kB" });
+ } else {
+ sm.plainStorage = new MockedPlainStorage({
+ uid: "uid",
+ email: "someone@somewhere.com",
+ kA: "kA",
+ kb: "kB",
+ });
+ }
+ await sm.initialize();
+
+ await sm.updateAccountData({ kA: null, kB: null });
+ let accountData = await sm.getAccountData();
+ Assert.ok(!accountData.kA);
+ Assert.ok(!accountData.kB);
+});
+
+add_storage_task(async function checkDelete(sm) {
+ if (sm.secureStorage) {
+ sm.plainStorage = new MockedPlainStorage({
+ uid: "uid",
+ email: "someone@somewhere.com",
+ });
+ sm.secureStorage = new MockedSecureStorage({
+ scopedKeys: { ...MOCK_ACCOUNT_KEYS.scopedKeys },
+ });
+ } else {
+ sm.plainStorage = new MockedPlainStorage({
+ uid: "uid",
+ email: "someone@somewhere.com",
+ scopedKeys: { ...MOCK_ACCOUNT_KEYS.scopedKeys },
+ });
+ }
+ await sm.initialize();
+
+ await sm.deleteAccountData();
+ // Storage should have been reset to null.
+ Assert.equal(sm.plainStorage.data, null);
+ if (sm.secureStorage) {
+ Assert.equal(sm.secureStorage.data, null);
+ }
+ // And everything should reflect no user.
+ Assert.equal(await sm.getAccountData(), null);
+});
+
+// Some tests only for the secure storage manager.
+add_task(async function checkNullUpdatesRemovedLocked() {
+ let sm = new FxAccountsStorageManager();
+ sm.plainStorage = new MockedPlainStorage({
+ uid: "uid",
+ email: "someone@somewhere.com",
+ });
+ sm.secureStorage = new MockedSecureStorage({
+ scopedKeys: { ...MOCK_ACCOUNT_KEYS.scopedKeys },
+ unwrapBKey: "unwrapBKey is another secure value",
+ });
+ sm.secureStorage.locked = true;
+ await sm.initialize();
+
+ await sm.updateAccountData({ scopedKeys: null });
+ let accountData = await sm.getAccountData();
+ // No scopedKeys because it was removed.
+ Assert.ok(!accountData.scopedKeys);
+ // No unwrapBKey because we are locked
+ Assert.ok(!accountData.unwrapBKey);
+
+ // now unlock - should still be no scopedKeys but unwrapBKey should appear.
+ sm.secureStorage.locked = false;
+ accountData = await sm.getAccountData();
+ Assert.ok(!accountData.scopedKeys);
+ Assert.equal(accountData.unwrapBKey, "unwrapBKey is another secure value");
+ // And secure storage should have been written with our previously-cached
+ // data.
+ Assert.strictEqual(sm.secureStorage.data.accountData.scopedKeys, undefined);
+ Assert.strictEqual(
+ sm.secureStorage.data.accountData.unwrapBKey,
+ "unwrapBKey is another secure value"
+ );
+});
+
+add_task(async function checkEverythingReadSecure() {
+ let sm = new FxAccountsStorageManager();
+ sm.plainStorage = new MockedPlainStorage({
+ uid: "uid",
+ email: "someone@somewhere.com",
+ });
+ sm.secureStorage = new MockedSecureStorage({
+ scopedKeys: { ...MOCK_ACCOUNT_KEYS.scopedKeys },
+ });
+ await sm.initialize();
+
+ let accountData = await sm.getAccountData();
+ Assert.ok(accountData, "read account data");
+ Assert.equal(accountData.uid, "uid");
+ Assert.equal(accountData.email, "someone@somewhere.com");
+ Assert.deepEqual(accountData.scopedKeys, MOCK_ACCOUNT_KEYS.scopedKeys);
+});
+
+add_task(async function checkExplicitGet() {
+ let sm = new FxAccountsStorageManager();
+ sm.plainStorage = new MockedPlainStorage({
+ uid: "uid",
+ email: "someone@somewhere.com",
+ });
+ sm.secureStorage = new MockedSecureStorage({
+ scopedKeys: { ...MOCK_ACCOUNT_KEYS.scopedKeys },
+ });
+ await sm.initialize();
+
+ let accountData = await sm.getAccountData(["uid", "scopedKeys"]);
+ Assert.ok(accountData, "read account data");
+ Assert.equal(accountData.uid, "uid");
+ Assert.deepEqual(accountData.scopedKeys, MOCK_ACCOUNT_KEYS.scopedKeys);
+ // We didn't ask for email so shouldn't have got it.
+ Assert.strictEqual(accountData.email, undefined);
+});
+
+add_task(async function checkExplicitGetNoSecureRead() {
+ let sm = new FxAccountsStorageManager();
+ sm.plainStorage = new MockedPlainStorage({
+ uid: "uid",
+ email: "someone@somewhere.com",
+ });
+ sm.secureStorage = new MockedSecureStorage({
+ scopedKeys: { ...MOCK_ACCOUNT_KEYS.scopedKeys },
+ });
+ await sm.initialize();
+
+ Assert.equal(sm.secureStorage.fetchCount, 0);
+ // request 2 fields in secure storage - it should have caused a single fetch.
+ let accountData = await sm.getAccountData(["email", "uid"]);
+ Assert.ok(accountData, "read account data");
+ Assert.equal(accountData.uid, "uid");
+ Assert.equal(accountData.email, "someone@somewhere.com");
+ Assert.strictEqual(accountData.scopedKeys, undefined);
+ Assert.equal(sm.secureStorage.fetchCount, 1);
+});
+
+add_task(async function checkLockedUpdates() {
+ let sm = new FxAccountsStorageManager();
+ sm.plainStorage = new MockedPlainStorage({
+ uid: "uid",
+ email: "someone@somewhere.com",
+ });
+ sm.secureStorage = new MockedSecureStorage({
+ scopedKeys: { ...MOCK_ACCOUNT_KEYS.scopedKeys },
+ unwrapBKey: "unwrapBKey",
+ });
+ sm.secureStorage.locked = true;
+ await sm.initialize();
+
+ let accountData = await sm.getAccountData();
+ // requesting scopedKeys will fail as storage is locked.
+ Assert.ok(!accountData.scopedKeys);
+ // While locked we can still update it and see the updated value.
+ sm.updateAccountData({ unwrapBKey: "new-unwrapBKey" });
+ accountData = await sm.getAccountData();
+ Assert.equal(accountData.unwrapBKey, "new-unwrapBKey");
+ // unlock.
+ sm.secureStorage.locked = false;
+ accountData = await sm.getAccountData();
+ // should reflect the value we updated and the one we didn't.
+ Assert.equal(accountData.unwrapBKey, "new-unwrapBKey");
+ Assert.deepEqual(accountData.scopedKeys, MOCK_ACCOUNT_KEYS.scopedKeys);
+ // And storage should also reflect it.
+ Assert.deepEqual(
+ sm.secureStorage.data.accountData.scopedKeys,
+ MOCK_ACCOUNT_KEYS.scopedKeys
+ );
+ Assert.strictEqual(
+ sm.secureStorage.data.accountData.unwrapBKey,
+ "new-unwrapBKey"
+ );
+});
+
+// Some tests for the "storage queue" functionality.
+
+// A helper for our queued tests. It creates a StorageManager and then queues
+// an unresolved promise. The tests then do additional setup and checks, then
+// resolves or rejects the blocked promise.
+async function setupStorageManagerForQueueTest() {
+ let sm = new FxAccountsStorageManager();
+ sm.plainStorage = new MockedPlainStorage({
+ uid: "uid",
+ email: "someone@somewhere.com",
+ });
+ sm.secureStorage = new MockedSecureStorage({
+ scopedKeys: { ...MOCK_ACCOUNT_KEYS.scopedKeys },
+ });
+ sm.secureStorage.locked = true;
+ await sm.initialize();
+
+ let resolveBlocked, rejectBlocked;
+ let blockedPromise = new Promise((resolve, reject) => {
+ resolveBlocked = resolve;
+ rejectBlocked = reject;
+ });
+
+ sm._queueStorageOperation(() => blockedPromise);
+ return { sm, blockedPromise, resolveBlocked, rejectBlocked };
+}
+
+// First the general functionality.
+add_task(async function checkQueueSemantics() {
+ let { sm, resolveBlocked } = await setupStorageManagerForQueueTest();
+
+ // We've one unresolved promise in the queue - add another promise.
+ let resolveSubsequent;
+ let subsequentPromise = new Promise(resolve => {
+ resolveSubsequent = resolve;
+ });
+ let subsequentCalled = false;
+
+ sm._queueStorageOperation(() => {
+ subsequentCalled = true;
+ resolveSubsequent();
+ return subsequentPromise;
+ });
+
+ // Our "subsequent" function should not have been called yet.
+ Assert.ok(!subsequentCalled);
+
+ // Release our blocked promise.
+ resolveBlocked();
+
+ // Our subsequent promise should end up resolved.
+ await subsequentPromise;
+ Assert.ok(subsequentCalled);
+ await sm.finalize();
+});
+
+// Check that a queued promise being rejected works correctly.
+add_task(async function checkQueueSemanticsOnError() {
+ let { sm, blockedPromise, rejectBlocked } =
+ await setupStorageManagerForQueueTest();
+
+ let resolveSubsequent;
+ let subsequentPromise = new Promise(resolve => {
+ resolveSubsequent = resolve;
+ });
+ let subsequentCalled = false;
+
+ sm._queueStorageOperation(() => {
+ subsequentCalled = true;
+ resolveSubsequent();
+ return subsequentPromise;
+ });
+
+ // Our "subsequent" function should not have been called yet.
+ Assert.ok(!subsequentCalled);
+
+ // Reject our blocked promise - the subsequent operations should still work
+ // correctly.
+ rejectBlocked("oh no");
+
+ // Our subsequent promise should end up resolved.
+ await subsequentPromise;
+ Assert.ok(subsequentCalled);
+
+ // But the first promise should reflect the rejection.
+ try {
+ await blockedPromise;
+ Assert.ok(false, "expected this promise to reject");
+ } catch (ex) {
+ Assert.equal(ex, "oh no");
+ }
+ await sm.finalize();
+});
+
+// And some tests for the specific operations that are queued.
+add_task(async function checkQueuedReadAndUpdate() {
+ let { sm, resolveBlocked } = await setupStorageManagerForQueueTest();
+ // Mock the underlying operations
+ // _doReadAndUpdateSecure is queued by _maybeReadAndUpdateSecure
+ let _doReadCalled = false;
+ sm._doReadAndUpdateSecure = () => {
+ _doReadCalled = true;
+ return Promise.resolve();
+ };
+
+ let resultPromise = sm._maybeReadAndUpdateSecure();
+ Assert.ok(!_doReadCalled);
+
+ resolveBlocked();
+ await resultPromise;
+ Assert.ok(_doReadCalled);
+ await sm.finalize();
+});
+
+add_task(async function checkQueuedWrite() {
+ let { sm, resolveBlocked } = await setupStorageManagerForQueueTest();
+ // Mock the underlying operations
+ let __writeCalled = false;
+ sm.__write = () => {
+ __writeCalled = true;
+ return Promise.resolve();
+ };
+
+ let writePromise = sm._write();
+ Assert.ok(!__writeCalled);
+
+ resolveBlocked();
+ await writePromise;
+ Assert.ok(__writeCalled);
+ await sm.finalize();
+});
+
+add_task(async function checkQueuedDelete() {
+ let { sm, resolveBlocked } = await setupStorageManagerForQueueTest();
+ // Mock the underlying operations
+ let _deleteCalled = false;
+ sm._deleteAccountData = () => {
+ _deleteCalled = true;
+ return Promise.resolve();
+ };
+
+ let resultPromise = sm.deleteAccountData();
+ Assert.ok(!_deleteCalled);
+
+ resolveBlocked();
+ await resultPromise;
+ Assert.ok(_deleteCalled);
+ await sm.finalize();
+});
diff --git a/services/fxaccounts/tests/xpcshell/test_telemetry.js b/services/fxaccounts/tests/xpcshell/test_telemetry.js
new file mode 100644
index 0000000000..3b9d318404
--- /dev/null
+++ b/services/fxaccounts/tests/xpcshell/test_telemetry.js
@@ -0,0 +1,52 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { getFxAccountsSingleton } = ChromeUtils.importESModule(
+ "resource://gre/modules/FxAccounts.sys.mjs"
+);
+const fxAccounts = getFxAccountsSingleton();
+
+_("Misc tests for FxAccounts.telemetry");
+
+const MOCK_HASHED_UID = "00112233445566778899aabbccddeeff";
+const MOCK_DEVICE_ID = "ffeeddccbbaa99887766554433221100";
+
+add_task(function test_sanitized_uid() {
+ Services.prefs.deleteBranch(
+ "identity.fxaccounts.account.telemetry.sanitized_uid"
+ );
+
+ // Returns `null` by default.
+ Assert.equal(fxAccounts.telemetry.getSanitizedUID(), null);
+
+ // Returns provided value if set.
+ fxAccounts.telemetry._setHashedUID(MOCK_HASHED_UID);
+ Assert.equal(fxAccounts.telemetry.getSanitizedUID(), MOCK_HASHED_UID);
+
+ // Reverts to unset for falsey values.
+ fxAccounts.telemetry._setHashedUID("");
+ Assert.equal(fxAccounts.telemetry.getSanitizedUID(), null);
+});
+
+add_task(function test_sanitize_device_id() {
+ Services.prefs.deleteBranch(
+ "identity.fxaccounts.account.telemetry.sanitized_uid"
+ );
+
+ // Returns `null` by default.
+ Assert.equal(fxAccounts.telemetry.sanitizeDeviceId(MOCK_DEVICE_ID), null);
+
+ // Hashes with the sanitized UID if set.
+ // (test value here is SHA256(MOCK_DEVICE_ID + MOCK_HASHED_UID))
+ fxAccounts.telemetry._setHashedUID(MOCK_HASHED_UID);
+ Assert.equal(
+ fxAccounts.telemetry.sanitizeDeviceId(MOCK_DEVICE_ID),
+ "dd7c845006df9baa1c6d756926519c8ce12f91230e11b6057bf8ec65f9b55c1a"
+ );
+
+ // Reverts to unset for falsey values.
+ fxAccounts.telemetry._setHashedUID("");
+ Assert.equal(fxAccounts.telemetry.sanitizeDeviceId(MOCK_DEVICE_ID), null);
+});
diff --git a/services/fxaccounts/tests/xpcshell/test_web_channel.js b/services/fxaccounts/tests/xpcshell/test_web_channel.js
new file mode 100644
index 0000000000..48f043d0b9
--- /dev/null
+++ b/services/fxaccounts/tests/xpcshell/test_web_channel.js
@@ -0,0 +1,1380 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { ON_PROFILE_CHANGE_NOTIFICATION, WEBCHANNEL_ID, log } =
+ ChromeUtils.importESModule("resource://gre/modules/FxAccountsCommon.sys.mjs");
+const { CryptoUtils } = ChromeUtils.importESModule(
+ "resource://services-crypto/utils.sys.mjs"
+);
+const { FxAccountsWebChannel, FxAccountsWebChannelHelpers } =
+ ChromeUtils.importESModule(
+ "resource://gre/modules/FxAccountsWebChannel.sys.mjs"
+ );
+
+const URL_STRING = "https://example.com";
+
+const mockSendingContext = {
+ browsingContext: { top: { embedderElement: {} } },
+ principal: {},
+ eventTarget: {},
+};
+
+add_test(function () {
+ validationHelper(undefined, "Error: Missing configuration options");
+
+ validationHelper(
+ {
+ channel_id: WEBCHANNEL_ID,
+ },
+ "Error: Missing 'content_uri' option"
+ );
+
+ validationHelper(
+ {
+ content_uri: "bad uri",
+ channel_id: WEBCHANNEL_ID,
+ },
+ /NS_ERROR_MALFORMED_URI/
+ );
+
+ validationHelper(
+ {
+ content_uri: URL_STRING,
+ },
+ "Error: Missing 'channel_id' option"
+ );
+
+ run_next_test();
+});
+
+add_task(async function test_rejection_reporting() {
+ Services.prefs.setBoolPref(
+ "browser.tabs.remote.separatePrivilegedMozillaWebContentProcess",
+ false
+ );
+
+ let mockMessage = {
+ command: "fxaccounts:login",
+ messageId: "1234",
+ data: { email: "testuser@testuser.com" },
+ };
+
+ let channel = new FxAccountsWebChannel({
+ channel_id: WEBCHANNEL_ID,
+ content_uri: URL_STRING,
+ helpers: {
+ login(accountData) {
+ equal(
+ accountData.email,
+ "testuser@testuser.com",
+ "Should forward incoming message data to the helper"
+ );
+ return Promise.reject(new Error("oops"));
+ },
+ },
+ });
+
+ let promiseSend = new Promise(resolve => {
+ channel._channel.send = (message, context) => {
+ resolve({ message, context });
+ };
+ });
+
+ channel._channelCallback(WEBCHANNEL_ID, mockMessage, mockSendingContext);
+
+ let { message, context } = await promiseSend;
+
+ equal(context, mockSendingContext, "Should forward the original context");
+ equal(
+ message.command,
+ "fxaccounts:login",
+ "Should include the incoming command"
+ );
+ equal(message.messageId, "1234", "Should include the message ID");
+ equal(
+ message.data.error.message,
+ "Error: oops",
+ "Should convert the error message to a string"
+ );
+ notStrictEqual(
+ message.data.error.stack,
+ null,
+ "Should include the stack for JS error rejections"
+ );
+});
+
+add_test(function test_exception_reporting() {
+ let mockMessage = {
+ command: "fxaccounts:sync_preferences",
+ messageId: "5678",
+ data: { entryPoint: "fxa:verification_complete" },
+ };
+
+ let channel = new FxAccountsWebChannel({
+ channel_id: WEBCHANNEL_ID,
+ content_uri: URL_STRING,
+ helpers: {
+ openSyncPreferences(browser, entryPoint) {
+ equal(
+ entryPoint,
+ "fxa:verification_complete",
+ "Should forward incoming message data to the helper"
+ );
+ throw new TypeError("splines not reticulated");
+ },
+ },
+ });
+
+ channel._channel.send = (message, context) => {
+ equal(context, mockSendingContext, "Should forward the original context");
+ equal(
+ message.command,
+ "fxaccounts:sync_preferences",
+ "Should include the incoming command"
+ );
+ equal(message.messageId, "5678", "Should include the message ID");
+ equal(
+ message.data.error.message,
+ "TypeError: splines not reticulated",
+ "Should convert the exception to a string"
+ );
+ notStrictEqual(
+ message.data.error.stack,
+ null,
+ "Should include the stack for JS exceptions"
+ );
+
+ run_next_test();
+ };
+
+ channel._channelCallback(WEBCHANNEL_ID, mockMessage, mockSendingContext);
+});
+
+add_test(function test_error_message_remove_profile_path() {
+ const errors = {
+ windows: {
+ err: new Error(
+ "Win error 183 during operation rename on file C:\\Users\\Some Computer\\AppData\\Roaming\\" +
+ "Mozilla\\Firefox\\Profiles\\dbzjmzxa.default\\signedInUser.json (Cannot create a file)"
+ ),
+ expected:
+ "Error: Win error 183 during operation rename on file C:[REDACTED]signedInUser.json (Cannot create a file)",
+ },
+ unix: {
+ err: new Error(
+ "Unix error 28 during operation write on file /Users/someuser/Library/Application Support/" +
+ "Firefox/Profiles/dbzjmzxa.default-release-7/signedInUser.json (No space left on device)"
+ ),
+ expected:
+ "Error: Unix error 28 during operation write on file [REDACTED]signedInUser.json (No space left on device)",
+ },
+ netpath: {
+ err: new Error(
+ "Win error 32 during operation rename on file \\\\SVC.LOC\\HOMEDIRS$\\USERNAME\\Mozilla\\" +
+ "Firefox\\Profiles\\dbzjmzxa.default-release-7\\signedInUser.json (No space left on device)"
+ ),
+ expected:
+ "Error: Win error 32 during operation rename on file [REDACTED]signedInUser.json (No space left on device)",
+ },
+ mount: {
+ err: new Error(
+ "Win error 649 during operation rename on file C:\\SnapVolumes\\MountPoints\\" +
+ "{9e399ec5-0000-0000-0000-100000000000}\\SVROOT\\Users\\username\\AppData\\Roaming\\Mozilla\\Firefox\\" +
+ "Profiles\\dbzjmzxa.default-release\\signedInUser.json (The create operation failed)"
+ ),
+ expected:
+ "Error: Win error 649 during operation rename on file C:[REDACTED]signedInUser.json " +
+ "(The create operation failed)",
+ },
+ };
+ const mockMessage = {
+ command: "fxaccounts:sync_preferences",
+ messageId: "1234",
+ };
+ const channel = new FxAccountsWebChannel({
+ channel_id: WEBCHANNEL_ID,
+ content_uri: URL_STRING,
+ });
+
+ let testNum = 0;
+ const toTest = Object.keys(errors).length;
+ for (const key in errors) {
+ let error = errors[key];
+ channel._channel.send = (message, context) => {
+ equal(
+ message.data.error.message,
+ error.expected,
+ "Should remove the profile path from the error message"
+ );
+ testNum++;
+ if (testNum === toTest) {
+ run_next_test();
+ }
+ };
+ channel._sendError(error.err, mockMessage, mockSendingContext);
+ }
+});
+
+add_test(function test_profile_image_change_message() {
+ var mockMessage = {
+ command: "profile:change",
+ data: { uid: "foo" },
+ };
+
+ makeObserver(ON_PROFILE_CHANGE_NOTIFICATION, function (subject, topic, data) {
+ Assert.equal(data, "foo");
+ run_next_test();
+ });
+
+ var channel = new FxAccountsWebChannel({
+ channel_id: WEBCHANNEL_ID,
+ content_uri: URL_STRING,
+ });
+
+ channel._channelCallback(WEBCHANNEL_ID, mockMessage, mockSendingContext);
+});
+
+add_test(function test_login_message() {
+ let mockMessage = {
+ command: "fxaccounts:login",
+ data: { email: "testuser@testuser.com" },
+ };
+
+ let channel = new FxAccountsWebChannel({
+ channel_id: WEBCHANNEL_ID,
+ content_uri: URL_STRING,
+ helpers: {
+ login(accountData) {
+ Assert.equal(accountData.email, "testuser@testuser.com");
+ run_next_test();
+ return Promise.resolve();
+ },
+ },
+ });
+
+ channel._channelCallback(WEBCHANNEL_ID, mockMessage, mockSendingContext);
+});
+
+add_test(function test_oauth_login() {
+ const mockData = {
+ code: "oauth code",
+ state: "state parameter",
+ declinedSyncEngines: ["tabs", "creditcards"],
+ offeredSyncEngines: ["tabs", "creditcards", "history"],
+ };
+ const mockMessage = {
+ command: "fxaccounts:oauth_login",
+ data: mockData,
+ };
+ const channel = new FxAccountsWebChannel({
+ channel_id: WEBCHANNEL_ID,
+ content_uri: URL_STRING,
+ helpers: {
+ oauthLogin(data) {
+ Assert.deepEqual(data, mockData);
+ run_next_test();
+ return Promise.resolve();
+ },
+ },
+ });
+ channel._channelCallback(WEBCHANNEL_ID, mockMessage, mockSendingContext);
+});
+
+add_test(function test_logout_message() {
+ let mockMessage = {
+ command: "fxaccounts:logout",
+ data: { uid: "foo" },
+ };
+
+ let channel = new FxAccountsWebChannel({
+ channel_id: WEBCHANNEL_ID,
+ content_uri: URL_STRING,
+ helpers: {
+ logout(uid) {
+ Assert.equal(uid, "foo");
+ run_next_test();
+ return Promise.resolve();
+ },
+ },
+ });
+
+ channel._channelCallback(WEBCHANNEL_ID, mockMessage, mockSendingContext);
+});
+
+add_test(function test_delete_message() {
+ let mockMessage = {
+ command: "fxaccounts:delete",
+ data: { uid: "foo" },
+ };
+
+ let channel = new FxAccountsWebChannel({
+ channel_id: WEBCHANNEL_ID,
+ content_uri: URL_STRING,
+ helpers: {
+ logout(uid) {
+ Assert.equal(uid, "foo");
+ run_next_test();
+ return Promise.resolve();
+ },
+ },
+ });
+
+ channel._channelCallback(WEBCHANNEL_ID, mockMessage, mockSendingContext);
+});
+
+add_test(function test_can_link_account_message() {
+ let mockMessage = {
+ command: "fxaccounts:can_link_account",
+ data: { email: "testuser@testuser.com" },
+ };
+
+ let channel = new FxAccountsWebChannel({
+ channel_id: WEBCHANNEL_ID,
+ content_uri: URL_STRING,
+ helpers: {
+ shouldAllowRelink(email) {
+ Assert.equal(email, "testuser@testuser.com");
+ run_next_test();
+ },
+ },
+ });
+
+ channel._channelCallback(WEBCHANNEL_ID, mockMessage, mockSendingContext);
+});
+
+add_test(function test_sync_preferences_message() {
+ let mockMessage = {
+ command: "fxaccounts:sync_preferences",
+ data: { entryPoint: "fxa:verification_complete" },
+ };
+
+ let channel = new FxAccountsWebChannel({
+ channel_id: WEBCHANNEL_ID,
+ content_uri: URL_STRING,
+ helpers: {
+ openSyncPreferences(browser, entryPoint) {
+ Assert.equal(entryPoint, "fxa:verification_complete");
+ Assert.equal(
+ browser,
+ mockSendingContext.browsingContext.top.embedderElement
+ );
+ run_next_test();
+ },
+ },
+ });
+
+ channel._channelCallback(WEBCHANNEL_ID, mockMessage, mockSendingContext);
+});
+
+add_test(function test_fxa_status_message() {
+ let mockMessage = {
+ command: "fxaccounts:fxa_status",
+ messageId: 123,
+ data: {
+ service: "sync",
+ context: "fx_desktop_v3",
+ },
+ };
+
+ let channel = new FxAccountsWebChannel({
+ channel_id: WEBCHANNEL_ID,
+ content_uri: URL_STRING,
+ helpers: {
+ async getFxaStatus(service, sendingContext, isPairing, context) {
+ Assert.equal(service, "sync");
+ Assert.equal(sendingContext, mockSendingContext);
+ Assert.ok(!isPairing);
+ Assert.equal(context, "fx_desktop_v3");
+ return {
+ signedInUser: {
+ email: "testuser@testuser.com",
+ sessionToken: "session-token",
+ uid: "uid",
+ verified: true,
+ },
+ capabilities: {
+ engines: ["creditcards", "addresses"],
+ },
+ };
+ },
+ },
+ });
+
+ channel._channel = {
+ send(response, sendingContext) {
+ Assert.equal(response.command, "fxaccounts:fxa_status");
+ Assert.equal(response.messageId, 123);
+
+ let signedInUser = response.data.signedInUser;
+ Assert.ok(!!signedInUser);
+ Assert.equal(signedInUser.email, "testuser@testuser.com");
+ Assert.equal(signedInUser.sessionToken, "session-token");
+ Assert.equal(signedInUser.uid, "uid");
+ Assert.equal(signedInUser.verified, true);
+
+ deepEqual(response.data.capabilities.engines, [
+ "creditcards",
+ "addresses",
+ ]);
+
+ run_next_test();
+ },
+ };
+
+ channel._channelCallback(WEBCHANNEL_ID, mockMessage, mockSendingContext);
+});
+
+add_test(function test_unrecognized_message() {
+ let mockMessage = {
+ command: "fxaccounts:unrecognized",
+ data: {},
+ };
+
+ let channel = new FxAccountsWebChannel({
+ channel_id: WEBCHANNEL_ID,
+ content_uri: URL_STRING,
+ });
+
+ // no error is expected.
+ channel._channelCallback(WEBCHANNEL_ID, mockMessage, mockSendingContext);
+ run_next_test();
+});
+
+add_test(function test_helpers_should_allow_relink_same_email() {
+ let helpers = new FxAccountsWebChannelHelpers();
+
+ helpers.setPreviousAccountNameHashPref("testuser@testuser.com");
+ Assert.ok(helpers.shouldAllowRelink("testuser@testuser.com"));
+
+ run_next_test();
+});
+
+add_test(function test_helpers_should_allow_relink_different_email() {
+ let helpers = new FxAccountsWebChannelHelpers();
+
+ helpers.setPreviousAccountNameHashPref("testuser@testuser.com");
+
+ helpers._promptForRelink = acctName => {
+ return acctName === "allowed_to_relink@testuser.com";
+ };
+
+ Assert.ok(helpers.shouldAllowRelink("allowed_to_relink@testuser.com"));
+ Assert.ok(!helpers.shouldAllowRelink("not_allowed_to_relink@testuser.com"));
+
+ run_next_test();
+});
+
+add_task(async function test_helpers_login_without_customize_sync() {
+ let helpers = new FxAccountsWebChannelHelpers({
+ fxAccounts: {
+ _internal: {
+ setSignedInUser(accountData) {
+ return new Promise(resolve => {
+ // ensure fxAccounts is informed of the new user being signed in.
+ Assert.equal(accountData.email, "testuser@testuser.com");
+
+ // verifiedCanLinkAccount should be stripped in the data.
+ Assert.equal(false, "verifiedCanLinkAccount" in accountData);
+
+ resolve();
+ });
+ },
+ },
+ telemetry: {
+ recordConnection: sinon.spy(),
+ },
+ },
+ weaveXPCOM: {
+ whenLoaded() {},
+ Weave: {
+ Service: {
+ configure() {},
+ },
+ },
+ },
+ });
+
+ // ensure the previous account pref is overwritten.
+ helpers.setPreviousAccountNameHashPref("lastuser@testuser.com");
+
+ await helpers.login({
+ email: "testuser@testuser.com",
+ verifiedCanLinkAccount: true,
+ customizeSync: false,
+ });
+ Assert.ok(
+ helpers._fxAccounts.telemetry.recordConnection.calledWith([], "webchannel")
+ );
+});
+
+add_task(async function test_helpers_login_set_previous_account_name_hash() {
+ let helpers = new FxAccountsWebChannelHelpers({
+ fxAccounts: {
+ _internal: {
+ setSignedInUser(accountData) {
+ return new Promise(resolve => {
+ // previously signed in user preference is updated.
+ Assert.equal(
+ helpers.getPreviousAccountNameHashPref(),
+ CryptoUtils.sha256Base64("newuser@testuser.com")
+ );
+ resolve();
+ });
+ },
+ },
+ telemetry: {
+ recordConnection() {},
+ },
+ },
+ weaveXPCOM: {
+ whenLoaded() {},
+ Weave: {
+ Service: {
+ configure() {},
+ },
+ },
+ },
+ });
+
+ // ensure the previous account pref is overwritten.
+ helpers.setPreviousAccountNameHashPref("lastuser@testuser.com");
+
+ await helpers.login({
+ email: "newuser@testuser.com",
+ verifiedCanLinkAccount: true,
+ customizeSync: false,
+ verified: true,
+ });
+});
+
+add_task(
+ async function test_helpers_login_dont_set_previous_account_name_hash_for_unverified_emails() {
+ let helpers = new FxAccountsWebChannelHelpers({
+ fxAccounts: {
+ _internal: {
+ setSignedInUser(accountData) {
+ return new Promise(resolve => {
+ // previously signed in user preference should not be updated.
+ Assert.equal(
+ helpers.getPreviousAccountNameHashPref(),
+ CryptoUtils.sha256Base64("lastuser@testuser.com")
+ );
+ resolve();
+ });
+ },
+ },
+ telemetry: {
+ recordConnection() {},
+ },
+ },
+ weaveXPCOM: {
+ whenLoaded() {},
+ Weave: {
+ Service: {
+ configure() {},
+ },
+ },
+ },
+ });
+
+ // ensure the previous account pref is overwritten.
+ helpers.setPreviousAccountNameHashPref("lastuser@testuser.com");
+
+ await helpers.login({
+ email: "newuser@testuser.com",
+ verifiedCanLinkAccount: true,
+ customizeSync: false,
+ });
+ }
+);
+
+add_task(async function test_helpers_login_with_customize_sync() {
+ let helpers = new FxAccountsWebChannelHelpers({
+ fxAccounts: {
+ _internal: {
+ setSignedInUser(accountData) {
+ return new Promise(resolve => {
+ // ensure fxAccounts is informed of the new user being signed in.
+ Assert.equal(accountData.email, "testuser@testuser.com");
+
+ // customizeSync should be stripped in the data.
+ Assert.equal(false, "customizeSync" in accountData);
+
+ resolve();
+ });
+ },
+ },
+ telemetry: {
+ recordConnection: sinon.spy(),
+ },
+ },
+ weaveXPCOM: {
+ whenLoaded() {},
+ Weave: {
+ Service: {
+ configure() {},
+ },
+ },
+ },
+ });
+
+ await helpers.login({
+ email: "testuser@testuser.com",
+ verifiedCanLinkAccount: true,
+ customizeSync: true,
+ });
+ Assert.ok(
+ helpers._fxAccounts.telemetry.recordConnection.calledWith([], "webchannel")
+ );
+});
+
+add_task(
+ async function test_helpers_login_with_customize_sync_and_declined_engines() {
+ let configured = false;
+ let helpers = new FxAccountsWebChannelHelpers({
+ fxAccounts: {
+ _internal: {
+ setSignedInUser(accountData) {
+ return new Promise(resolve => {
+ // ensure fxAccounts is informed of the new user being signed in.
+ Assert.equal(accountData.email, "testuser@testuser.com");
+
+ // customizeSync should be stripped in the data.
+ Assert.equal(false, "customizeSync" in accountData);
+ Assert.equal(false, "services" in accountData);
+ resolve();
+ });
+ },
+ },
+ telemetry: {
+ recordConnection: sinon.spy(),
+ },
+ },
+ weaveXPCOM: {
+ whenLoaded() {},
+ Weave: {
+ Service: {
+ configure() {
+ configured = true;
+ },
+ },
+ },
+ },
+ });
+
+ Assert.equal(
+ Services.prefs.getBoolPref("services.sync.engine.addons"),
+ true
+ );
+ Assert.equal(
+ Services.prefs.getBoolPref("services.sync.engine.bookmarks"),
+ true
+ );
+ Assert.equal(
+ Services.prefs.getBoolPref("services.sync.engine.history"),
+ true
+ );
+ Assert.equal(
+ Services.prefs.getBoolPref("services.sync.engine.passwords"),
+ true
+ );
+ Assert.equal(
+ Services.prefs.getBoolPref("services.sync.engine.prefs"),
+ true
+ );
+ Assert.equal(Services.prefs.getBoolPref("services.sync.engine.tabs"), true);
+ await helpers.login({
+ email: "testuser@testuser.com",
+ verifiedCanLinkAccount: true,
+ customizeSync: true,
+ services: {
+ sync: {
+ offeredEngines: [
+ "addons",
+ "bookmarks",
+ "history",
+ "passwords",
+ "prefs",
+ ],
+ declinedEngines: ["addons", "prefs"],
+ },
+ },
+ });
+ Assert.equal(
+ Services.prefs.getBoolPref("services.sync.engine.addons"),
+ false
+ );
+ Assert.equal(
+ Services.prefs.getBoolPref("services.sync.engine.bookmarks"),
+ true
+ );
+ Assert.equal(
+ Services.prefs.getBoolPref("services.sync.engine.history"),
+ true
+ );
+ Assert.equal(
+ Services.prefs.getBoolPref("services.sync.engine.passwords"),
+ true
+ );
+ Assert.equal(
+ Services.prefs.getBoolPref("services.sync.engine.prefs"),
+ false
+ );
+ Assert.equal(Services.prefs.getBoolPref("services.sync.engine.tabs"), true);
+ Assert.ok(configured, "sync was configured");
+ Assert.ok(
+ helpers._fxAccounts.telemetry.recordConnection.calledWith(
+ ["sync"],
+ "webchannel"
+ )
+ );
+ }
+);
+
+add_task(async function test_helpers_login_with_offered_sync_engines() {
+ let helpers;
+ let configured = false;
+ const setSignedInUserCalled = new Promise(resolve => {
+ helpers = new FxAccountsWebChannelHelpers({
+ fxAccounts: {
+ _internal: {
+ async setSignedInUser(accountData) {
+ resolve(accountData);
+ },
+ },
+ telemetry: {
+ recordConnection() {},
+ },
+ },
+ weaveXPCOM: {
+ whenLoaded() {},
+ Weave: {
+ Service: {
+ configure() {
+ configured = true;
+ },
+ },
+ },
+ },
+ });
+ });
+
+ Services.prefs.setBoolPref("services.sync.engine.creditcards", false);
+ Services.prefs.setBoolPref("services.sync.engine.addresses", false);
+
+ await helpers.login({
+ email: "testuser@testuser.com",
+ verifiedCanLinkAccount: true,
+ customizeSync: true,
+ services: {
+ sync: {
+ declinedEngines: ["addresses"],
+ offeredEngines: ["creditcards", "addresses"],
+ },
+ },
+ });
+
+ const accountData = await setSignedInUserCalled;
+
+ // ensure fxAccounts is informed of the new user being signed in.
+ equal(accountData.email, "testuser@testuser.com");
+
+ // services should be stripped in the data.
+ ok(!("services" in accountData));
+ // credit cards was offered but not declined.
+ equal(Services.prefs.getBoolPref("services.sync.engine.creditcards"), true);
+ // addresses was offered and explicitely declined.
+ equal(Services.prefs.getBoolPref("services.sync.engine.addresses"), false);
+ ok(configured);
+});
+
+add_task(async function test_helpers_login_nothing_offered() {
+ let helpers;
+ let configured = false;
+ const setSignedInUserCalled = new Promise(resolve => {
+ helpers = new FxAccountsWebChannelHelpers({
+ fxAccounts: {
+ _internal: {
+ async setSignedInUser(accountData) {
+ resolve(accountData);
+ },
+ },
+ telemetry: {
+ recordConnection() {},
+ },
+ },
+ weaveXPCOM: {
+ whenLoaded() {},
+ Weave: {
+ Service: {
+ configure() {
+ configured = true;
+ },
+ },
+ },
+ },
+ });
+ });
+
+ // doesn't really matter if it's *all* engines...
+ const allEngines = [
+ "addons",
+ "addresses",
+ "bookmarks",
+ "creditcards",
+ "history",
+ "passwords",
+ "prefs",
+ ];
+ for (let name of allEngines) {
+ Services.prefs.clearUserPref("services.sync.engine." + name);
+ }
+
+ await helpers.login({
+ email: "testuser@testuser.com",
+ verifiedCanLinkAccount: true,
+ services: {
+ sync: {},
+ },
+ });
+
+ const accountData = await setSignedInUserCalled;
+ // ensure fxAccounts is informed of the new user being signed in.
+ equal(accountData.email, "testuser@testuser.com");
+
+ for (let name of allEngines) {
+ Assert.ok(!Services.prefs.prefHasUserValue("services.sync.engine." + name));
+ }
+ Assert.ok(configured);
+});
+
+add_test(function test_helpers_open_sync_preferences() {
+ let helpers = new FxAccountsWebChannelHelpers({
+ fxAccounts: {},
+ });
+
+ let mockBrowser = {
+ loadURI(uri) {
+ Assert.equal(
+ uri.spec,
+ "about:preferences?entrypoint=fxa%3Averification_complete#sync"
+ );
+ run_next_test();
+ },
+ };
+
+ helpers.openSyncPreferences(mockBrowser, "fxa:verification_complete");
+});
+
+add_task(async function test_helpers_getFxAStatus_extra_engines() {
+ let helpers = new FxAccountsWebChannelHelpers({
+ fxAccounts: {
+ _internal: {
+ getUserAccountData() {
+ return Promise.resolve({
+ email: "testuser@testuser.com",
+ sessionToken: "sessionToken",
+ uid: "uid",
+ verified: true,
+ });
+ },
+ },
+ },
+ privateBrowsingUtils: {
+ isBrowserPrivate: () => true,
+ },
+ });
+
+ Services.prefs.setBoolPref(
+ "services.sync.engine.creditcards.available",
+ true
+ );
+ // Not defining "services.sync.engine.addresses.available" on purpose.
+
+ let fxaStatus = await helpers.getFxaStatus("sync", mockSendingContext);
+ ok(!!fxaStatus);
+ ok(!!fxaStatus.signedInUser);
+ deepEqual(fxaStatus.capabilities.engines, ["creditcards"]);
+});
+
+add_task(async function test_helpers_getFxaStatus_allowed_signedInUser() {
+ let wasCalled = {
+ getUserAccountData: false,
+ shouldAllowFxaStatus: false,
+ };
+
+ let helpers = new FxAccountsWebChannelHelpers({
+ fxAccounts: {
+ _internal: {
+ getUserAccountData() {
+ wasCalled.getUserAccountData = true;
+ return Promise.resolve({
+ email: "testuser@testuser.com",
+ sessionToken: "sessionToken",
+ uid: "uid",
+ verified: true,
+ });
+ },
+ },
+ },
+ });
+
+ helpers.shouldAllowFxaStatus = (service, sendingContext) => {
+ wasCalled.shouldAllowFxaStatus = true;
+ Assert.equal(service, "sync");
+ Assert.equal(sendingContext, mockSendingContext);
+
+ return true;
+ };
+
+ return helpers.getFxaStatus("sync", mockSendingContext).then(fxaStatus => {
+ Assert.ok(!!fxaStatus);
+ Assert.ok(wasCalled.getUserAccountData);
+ Assert.ok(wasCalled.shouldAllowFxaStatus);
+
+ Assert.ok(!!fxaStatus.signedInUser);
+ let { signedInUser } = fxaStatus;
+
+ Assert.equal(signedInUser.email, "testuser@testuser.com");
+ Assert.equal(signedInUser.sessionToken, "sessionToken");
+ Assert.equal(signedInUser.uid, "uid");
+ Assert.ok(signedInUser.verified);
+
+ // These properties are filtered and should not
+ // be returned to the requester.
+ Assert.equal(false, "scopedKeys" in signedInUser);
+ });
+});
+
+add_task(async function test_helpers_getFxaStatus_allowed_no_signedInUser() {
+ let wasCalled = {
+ getUserAccountData: false,
+ shouldAllowFxaStatus: false,
+ };
+
+ let helpers = new FxAccountsWebChannelHelpers({
+ fxAccounts: {
+ _internal: {
+ getUserAccountData() {
+ wasCalled.getUserAccountData = true;
+ return Promise.resolve(null);
+ },
+ },
+ },
+ });
+
+ helpers.shouldAllowFxaStatus = (service, sendingContext) => {
+ wasCalled.shouldAllowFxaStatus = true;
+ Assert.equal(service, "sync");
+ Assert.equal(sendingContext, mockSendingContext);
+
+ return true;
+ };
+
+ return helpers.getFxaStatus("sync", mockSendingContext).then(fxaStatus => {
+ Assert.ok(!!fxaStatus);
+ Assert.ok(wasCalled.getUserAccountData);
+ Assert.ok(wasCalled.shouldAllowFxaStatus);
+
+ Assert.equal(null, fxaStatus.signedInUser);
+ });
+});
+
+add_task(async function test_helpers_getFxaStatus_not_allowed() {
+ let wasCalled = {
+ getUserAccountData: false,
+ shouldAllowFxaStatus: false,
+ };
+
+ let helpers = new FxAccountsWebChannelHelpers({
+ fxAccounts: {
+ _internal: {
+ getUserAccountData() {
+ wasCalled.getUserAccountData = true;
+ return Promise.resolve(null);
+ },
+ },
+ },
+ });
+
+ helpers.shouldAllowFxaStatus = (
+ service,
+ sendingContext,
+ isPairing,
+ context
+ ) => {
+ wasCalled.shouldAllowFxaStatus = true;
+ Assert.equal(service, "sync");
+ Assert.equal(sendingContext, mockSendingContext);
+ Assert.ok(!isPairing);
+ Assert.equal(context, "fx_desktop_v3");
+
+ return false;
+ };
+
+ return helpers
+ .getFxaStatus("sync", mockSendingContext, false, "fx_desktop_v3")
+ .then(fxaStatus => {
+ Assert.ok(!!fxaStatus);
+ Assert.ok(!wasCalled.getUserAccountData);
+ Assert.ok(wasCalled.shouldAllowFxaStatus);
+
+ Assert.equal(null, fxaStatus.signedInUser);
+ });
+});
+
+add_task(
+ async function test_helpers_shouldAllowFxaStatus_sync_service_not_private_browsing() {
+ let wasCalled = {
+ isPrivateBrowsingMode: false,
+ };
+ let helpers = new FxAccountsWebChannelHelpers({});
+
+ helpers.isPrivateBrowsingMode = sendingContext => {
+ wasCalled.isPrivateBrowsingMode = true;
+ Assert.equal(sendingContext, mockSendingContext);
+ return false;
+ };
+
+ let shouldAllowFxaStatus = helpers.shouldAllowFxaStatus(
+ "sync",
+ mockSendingContext,
+ false
+ );
+ Assert.ok(shouldAllowFxaStatus);
+ Assert.ok(wasCalled.isPrivateBrowsingMode);
+ }
+);
+
+add_task(
+ async function test_helpers_shouldAllowFxaStatus_desktop_context_not_private_browsing() {
+ let wasCalled = {
+ isPrivateBrowsingMode: false,
+ };
+ let helpers = new FxAccountsWebChannelHelpers({});
+
+ helpers.isPrivateBrowsingMode = sendingContext => {
+ wasCalled.isPrivateBrowsingMode = true;
+ Assert.equal(sendingContext, mockSendingContext);
+ return false;
+ };
+
+ let shouldAllowFxaStatus = helpers.shouldAllowFxaStatus(
+ "",
+ mockSendingContext,
+ false,
+ "fx_desktop_v3"
+ );
+ Assert.ok(shouldAllowFxaStatus);
+ Assert.ok(wasCalled.isPrivateBrowsingMode);
+ }
+);
+
+add_task(
+ async function test_helpers_shouldAllowFxaStatus_oauth_service_not_private_browsing() {
+ let wasCalled = {
+ isPrivateBrowsingMode: false,
+ };
+ let helpers = new FxAccountsWebChannelHelpers({});
+
+ helpers.isPrivateBrowsingMode = sendingContext => {
+ wasCalled.isPrivateBrowsingMode = true;
+ Assert.equal(sendingContext, mockSendingContext);
+ return false;
+ };
+
+ let shouldAllowFxaStatus = helpers.shouldAllowFxaStatus(
+ "dcdb5ae7add825d2",
+ mockSendingContext,
+ false
+ );
+ Assert.ok(shouldAllowFxaStatus);
+ Assert.ok(wasCalled.isPrivateBrowsingMode);
+ }
+);
+
+add_task(
+ async function test_helpers_shouldAllowFxaStatus_no_service_not_private_browsing() {
+ let wasCalled = {
+ isPrivateBrowsingMode: false,
+ };
+ let helpers = new FxAccountsWebChannelHelpers({});
+
+ helpers.isPrivateBrowsingMode = sendingContext => {
+ wasCalled.isPrivateBrowsingMode = true;
+ Assert.equal(sendingContext, mockSendingContext);
+ return false;
+ };
+
+ let shouldAllowFxaStatus = helpers.shouldAllowFxaStatus(
+ "",
+ mockSendingContext,
+ false
+ );
+ Assert.ok(shouldAllowFxaStatus);
+ Assert.ok(wasCalled.isPrivateBrowsingMode);
+ }
+);
+
+add_task(
+ async function test_helpers_shouldAllowFxaStatus_sync_service_private_browsing() {
+ let wasCalled = {
+ isPrivateBrowsingMode: false,
+ };
+ let helpers = new FxAccountsWebChannelHelpers({});
+
+ helpers.isPrivateBrowsingMode = sendingContext => {
+ wasCalled.isPrivateBrowsingMode = true;
+ Assert.equal(sendingContext, mockSendingContext);
+ return true;
+ };
+
+ let shouldAllowFxaStatus = helpers.shouldAllowFxaStatus(
+ "sync",
+ mockSendingContext,
+ false
+ );
+ Assert.ok(shouldAllowFxaStatus);
+ Assert.ok(wasCalled.isPrivateBrowsingMode);
+ }
+);
+
+add_task(
+ async function test_helpers_shouldAllowFxaStatus_desktop_context_private_browsing() {
+ let wasCalled = {
+ isPrivateBrowsingMode: false,
+ };
+ let helpers = new FxAccountsWebChannelHelpers({});
+
+ helpers.isPrivateBrowsingMode = sendingContext => {
+ wasCalled.isPrivateBrowsingMode = true;
+ Assert.equal(sendingContext, mockSendingContext);
+ return true;
+ };
+
+ let shouldAllowFxaStatus = helpers.shouldAllowFxaStatus(
+ "",
+ mockSendingContext,
+ false,
+ "fx_desktop_v3"
+ );
+ Assert.ok(shouldAllowFxaStatus);
+ Assert.ok(wasCalled.isPrivateBrowsingMode);
+ }
+);
+
+add_task(
+ async function test_helpers_shouldAllowFxaStatus_oauth_service_private_browsing() {
+ let wasCalled = {
+ isPrivateBrowsingMode: false,
+ };
+ let helpers = new FxAccountsWebChannelHelpers({});
+
+ helpers.isPrivateBrowsingMode = sendingContext => {
+ wasCalled.isPrivateBrowsingMode = true;
+ Assert.equal(sendingContext, mockSendingContext);
+ return true;
+ };
+
+ let shouldAllowFxaStatus = helpers.shouldAllowFxaStatus(
+ "dcdb5ae7add825d2",
+ mockSendingContext,
+ false
+ );
+ Assert.ok(!shouldAllowFxaStatus);
+ Assert.ok(wasCalled.isPrivateBrowsingMode);
+ }
+);
+
+add_task(
+ async function test_helpers_shouldAllowFxaStatus_oauth_service_pairing_private_browsing() {
+ let wasCalled = {
+ isPrivateBrowsingMode: false,
+ };
+ let helpers = new FxAccountsWebChannelHelpers({});
+
+ helpers.isPrivateBrowsingMode = sendingContext => {
+ wasCalled.isPrivateBrowsingMode = true;
+ Assert.equal(sendingContext, mockSendingContext);
+ return true;
+ };
+
+ let shouldAllowFxaStatus = helpers.shouldAllowFxaStatus(
+ "dcdb5ae7add825d2",
+ mockSendingContext,
+ true
+ );
+ Assert.ok(shouldAllowFxaStatus);
+ Assert.ok(wasCalled.isPrivateBrowsingMode);
+ }
+);
+
+add_task(
+ async function test_helpers_shouldAllowFxaStatus_no_service_private_browsing() {
+ let wasCalled = {
+ isPrivateBrowsingMode: false,
+ };
+ let helpers = new FxAccountsWebChannelHelpers({});
+
+ helpers.isPrivateBrowsingMode = sendingContext => {
+ wasCalled.isPrivateBrowsingMode = true;
+ Assert.equal(sendingContext, mockSendingContext);
+ return true;
+ };
+
+ let shouldAllowFxaStatus = helpers.shouldAllowFxaStatus(
+ "",
+ mockSendingContext,
+ false
+ );
+ Assert.ok(!shouldAllowFxaStatus);
+ Assert.ok(wasCalled.isPrivateBrowsingMode);
+ }
+);
+
+add_task(async function test_helpers_isPrivateBrowsingMode_private_browsing() {
+ let wasCalled = {
+ isBrowserPrivate: false,
+ };
+ let helpers = new FxAccountsWebChannelHelpers({
+ privateBrowsingUtils: {
+ isBrowserPrivate(browser) {
+ wasCalled.isBrowserPrivate = true;
+ Assert.equal(
+ browser,
+ mockSendingContext.browsingContext.top.embedderElement
+ );
+ return true;
+ },
+ },
+ });
+
+ let isPrivateBrowsingMode = helpers.isPrivateBrowsingMode(mockSendingContext);
+ Assert.ok(isPrivateBrowsingMode);
+ Assert.ok(wasCalled.isBrowserPrivate);
+});
+
+add_task(async function test_helpers_isPrivateBrowsingMode_private_browsing() {
+ let wasCalled = {
+ isBrowserPrivate: false,
+ };
+ let helpers = new FxAccountsWebChannelHelpers({
+ privateBrowsingUtils: {
+ isBrowserPrivate(browser) {
+ wasCalled.isBrowserPrivate = true;
+ Assert.equal(
+ browser,
+ mockSendingContext.browsingContext.top.embedderElement
+ );
+ return false;
+ },
+ },
+ });
+
+ let isPrivateBrowsingMode = helpers.isPrivateBrowsingMode(mockSendingContext);
+ Assert.ok(!isPrivateBrowsingMode);
+ Assert.ok(wasCalled.isBrowserPrivate);
+});
+
+add_task(async function test_helpers_change_password() {
+ let wasCalled = {
+ updateUserAccountData: false,
+ updateDeviceRegistration: false,
+ };
+ let helpers = new FxAccountsWebChannelHelpers({
+ fxAccounts: {
+ _internal: {
+ updateUserAccountData(credentials) {
+ return new Promise(resolve => {
+ Assert.ok(credentials.hasOwnProperty("email"));
+ Assert.ok(credentials.hasOwnProperty("uid"));
+ Assert.ok(credentials.hasOwnProperty("unwrapBKey"));
+ Assert.ok(credentials.hasOwnProperty("device"));
+ Assert.equal(null, credentials.device);
+ Assert.equal(null, credentials.encryptedSendTabKeys);
+ // "foo" isn't a field known by storage, so should be dropped.
+ Assert.ok(!credentials.hasOwnProperty("foo"));
+ wasCalled.updateUserAccountData = true;
+
+ resolve();
+ });
+ },
+
+ updateDeviceRegistration() {
+ Assert.equal(arguments.length, 0);
+ wasCalled.updateDeviceRegistration = true;
+ return Promise.resolve();
+ },
+ },
+ },
+ });
+ await helpers.changePassword({
+ email: "email",
+ uid: "uid",
+ unwrapBKey: "unwrapBKey",
+ foo: "foo",
+ });
+ Assert.ok(wasCalled.updateUserAccountData);
+ Assert.ok(wasCalled.updateDeviceRegistration);
+});
+
+add_task(async function test_helpers_change_password_with_error() {
+ let wasCalled = {
+ updateUserAccountData: false,
+ updateDeviceRegistration: false,
+ };
+ let helpers = new FxAccountsWebChannelHelpers({
+ fxAccounts: {
+ _internal: {
+ updateUserAccountData() {
+ wasCalled.updateUserAccountData = true;
+ return Promise.reject();
+ },
+
+ updateDeviceRegistration() {
+ wasCalled.updateDeviceRegistration = true;
+ return Promise.resolve();
+ },
+ },
+ },
+ });
+ try {
+ await helpers.changePassword({});
+ Assert.equal(false, "changePassword should have rejected");
+ } catch (_) {
+ Assert.ok(wasCalled.updateUserAccountData);
+ Assert.ok(!wasCalled.updateDeviceRegistration);
+ }
+});
+
+function makeObserver(aObserveTopic, aObserveFunc) {
+ let callback = function (aSubject, aTopic, aData) {
+ log.debug("observed " + aTopic + " " + aData);
+ if (aTopic == aObserveTopic) {
+ removeMe();
+ aObserveFunc(aSubject, aTopic, aData);
+ }
+ };
+
+ function removeMe() {
+ log.debug("removing observer for " + aObserveTopic);
+ Services.obs.removeObserver(callback, aObserveTopic);
+ }
+
+ Services.obs.addObserver(callback, aObserveTopic);
+ return removeMe;
+}
+
+function validationHelper(params, expected) {
+ try {
+ new FxAccountsWebChannel(params);
+ } catch (e) {
+ if (typeof expected === "string") {
+ return Assert.equal(e.toString(), expected);
+ }
+ return Assert.ok(e.toString().match(expected));
+ }
+ throw new Error("Validation helper error");
+}
diff --git a/services/fxaccounts/tests/xpcshell/xpcshell.toml b/services/fxaccounts/tests/xpcshell/xpcshell.toml
new file mode 100644
index 0000000000..7fc9c60006
--- /dev/null
+++ b/services/fxaccounts/tests/xpcshell/xpcshell.toml
@@ -0,0 +1,49 @@
+[DEFAULT]
+head = "head.js ../../../common/tests/unit/head_helpers.js ../../../common/tests/unit/head_http.js"
+firefox-appdir = "browser"
+skip-if = [
+ "os == 'android'",
+ "appname == 'thunderbird'",
+]
+support-files = [
+ "!/services/common/tests/unit/head_helpers.js",
+ "!/services/common/tests/unit/head_http.js",
+]
+
+["test_accounts.js"]
+
+["test_accounts_config.js"]
+
+["test_accounts_device_registration.js"]
+
+["test_client.js"]
+
+["test_commands.js"]
+
+["test_credentials.js"]
+
+["test_device.js"]
+
+["test_keys.js"]
+
+["test_loginmgr_storage.js"]
+
+["test_oauth_flow.js"]
+
+["test_oauth_token_storage.js"]
+
+["test_oauth_tokens.js"]
+
+["test_pairing.js"]
+
+["test_profile.js"]
+
+["test_profile_client.js"]
+
+["test_push_service.js"]
+
+["test_storage_manager.js"]
+
+["test_telemetry.js"]
+
+["test_web_channel.js"]