summaryrefslogtreecommitdiffstats
path: root/services/fxaccounts
diff options
context:
space:
mode:
Diffstat (limited to 'services/fxaccounts')
-rw-r--r--services/fxaccounts/FxAccountsCommands.sys.mjs336
-rw-r--r--services/fxaccounts/FxAccountsCommon.sys.mjs14
-rw-r--r--services/fxaccounts/FxAccountsKeys.sys.mjs4
-rw-r--r--services/fxaccounts/FxAccountsProfileClient.sys.mjs1
-rw-r--r--services/fxaccounts/tests/xpcshell/test_commands_closetab.js263
-rw-r--r--services/fxaccounts/tests/xpcshell/test_keys.js12
-rw-r--r--services/fxaccounts/tests/xpcshell/xpcshell.toml2
7 files changed, 622 insertions, 10 deletions
diff --git a/services/fxaccounts/FxAccountsCommands.sys.mjs b/services/fxaccounts/FxAccountsCommands.sys.mjs
index 40fcc7f925..0851906061 100644
--- a/services/fxaccounts/FxAccountsCommands.sys.mjs
+++ b/services/fxaccounts/FxAccountsCommands.sys.mjs
@@ -5,10 +5,14 @@
import {
COMMAND_SENDTAB,
COMMAND_SENDTAB_TAIL,
+ COMMAND_CLOSETAB,
+ COMMAND_CLOSETAB_TAIL,
SCOPE_OLD_SYNC,
log,
} from "resource://gre/modules/FxAccountsCommon.sys.mjs";
+import { clearTimeout, setTimeout } from "resource://gre/modules/Timer.sys.mjs";
+
import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
import { Observers } from "resource://services-common/observers.sys.mjs";
@@ -36,18 +40,32 @@ export class FxAccountsCommands {
constructor(fxAccountsInternal) {
this._fxai = fxAccountsInternal;
this.sendTab = new SendTab(this, fxAccountsInternal);
+ this.closeTab = new CloseRemoteTab(this, fxAccountsInternal);
this._invokeRateLimitExpiry = 0;
}
async availableCommands() {
+ // Invalid keys usually means the account is not verified yet.
const encryptedSendTabKeys = await this.sendTab.getEncryptedSendTabKeys();
- if (!encryptedSendTabKeys) {
- // This will happen if the account is not verified yet.
- return {};
+ let commands = {};
+
+ if (encryptedSendTabKeys) {
+ commands[COMMAND_SENDTAB] = encryptedSendTabKeys;
}
- return {
- [COMMAND_SENDTAB]: encryptedSendTabKeys,
- };
+
+ // Close Tab is still a worked-on feature, so we should not broadcast it widely yet
+ let closeTabEnabled = Services.prefs.getBoolPref(
+ "identity.fxaccounts.commands.remoteTabManagement.enabled",
+ false
+ );
+ if (closeTabEnabled) {
+ const encryptedCloseTabKeys =
+ await this.closeTab.getEncryptedCloseTabKeys();
+ if (encryptedCloseTabKeys) {
+ commands[COMMAND_CLOSETAB] = encryptedCloseTabKeys;
+ }
+ }
+ return commands;
}
async invoke(command, device, payload) {
@@ -166,6 +184,7 @@ export class FxAccountsCommands {
}
// We debounce multiple incoming tabs so we show a single notification.
const tabsReceived = [];
+ const tabsToClose = [];
for (const { index, data } of messages) {
const { command, payload, sender: senderId } = data;
const reason = this._getReason(notifiedIndex, index);
@@ -179,6 +198,24 @@ export class FxAccountsCommands {
);
}
switch (command) {
+ case COMMAND_CLOSETAB:
+ try {
+ const { urls } = await this.closeTab.handleTabClose(
+ senderId,
+ payload,
+ reason
+ );
+ log.info(
+ `Close Tab received with FxA commands: "${urls.length} tabs"
+ from ${sender ? sender.name : "Unknown device"}.`
+ );
+ // URLs are PII, so only logged at trace.
+ log.trace(`Close Remote Tabs received URLs: ${urls}`);
+ tabsToClose.push({ urls, sender });
+ } catch (e) {
+ log.error(`Error while handling incoming Close Tab payload.`, e);
+ }
+ break;
case COMMAND_SENDTAB:
try {
const { title, uri } = await this.sendTab.handle(
@@ -212,11 +249,18 @@ export class FxAccountsCommands {
if (tabsReceived.length) {
this._notifyFxATabsReceived(tabsReceived);
}
+ if (tabsToClose.length) {
+ this._notifyFxATabsClosed(tabsToClose);
+ }
}
_notifyFxATabsReceived(tabsReceived) {
Observers.notify("fxaccounts:commands:open-uri", tabsReceived);
}
+
+ _notifyFxATabsClosed(tabsToClose) {
+ Observers.notify("fxaccounts:commands:close-uri", tabsToClose);
+ }
}
/**
@@ -458,6 +502,286 @@ export class SendTab {
}
}
+/**
+ * Close Tabs is built on-top of device commands and handles
+ * actions a client wants to perform on tabs found on other devices
+ * This class is very similar to the Send Tab component in FxAccountsCommands
+ *
+ * Devices exchange keys wrapped in the oldsync key between themselves (getEncryptedCloseTabKeys)
+ * during the device registration flow. The FxA server can theoretically never
+ * retrieve the close tab keys since it doesn't know the oldsync key.
+ *
+ * Note: Close Tabs does things slightly different from SendTab
+ * The sender encrypts the close-tab command using the receiver's public key,
+ * and the FxA server stores it (without re-encrypting).
+ * A web-push notifies the receiver that a new command is available.
+ * The receiver decrypts the payload using its private key.
+ */
+export class CloseRemoteTab {
+ constructor(commands, fxAccountsInternal) {
+ this._commands = commands;
+ this._fxai = fxAccountsInternal;
+ this.pendingClosedTabs = new Map();
+ // pushes happen per device, making a timer per device makes sending
+ // the pushes a little more sane
+ this.pushTimers = new Map();
+ }
+
+ /**
+ * Sending a push everytime the user wants to close a tab on a remote device
+ * could lead to excessive notifications to the users device, push throttling, etc
+ * so we add the tabs to a queue and have a timer that sends the push after a certain
+ * amount of "inactivity"
+ */
+ /**
+ * @param {Device} targetDevice - Device object (typically returned by fxAccounts.getDevicesList()).
+ * @param {String} tab - url for the tab to close
+ * @param {Integer} how far to delay, in miliseconds, the push for this timer
+ */
+ enqueueTabToClose(targetDevice, tab, pushDelay = 6000) {
+ if (this.pendingClosedTabs.has(targetDevice.id)) {
+ this.pendingClosedTabs.get(targetDevice.id).tabs.push(tab);
+ } else {
+ this.pendingClosedTabs.set(targetDevice.id, {
+ device: targetDevice,
+ tabs: [tab],
+ });
+ }
+
+ // extend the timer
+ this._refreshPushTimer(targetDevice.id, pushDelay);
+ }
+
+ async _refreshPushTimer(deviceId, pushDelay) {
+ // If the user is still performing "actions" for this device
+ // reset the timer to send the push
+ if (this.pushTimers.has(deviceId)) {
+ clearTimeout(this.pushTimers.get(deviceId));
+ }
+
+ // There is a possibility that the browser closes before this actually executes
+ // we should catch the browser as it's closing and immediately fire these
+ // See https://bugzilla.mozilla.org/show_bug.cgi?id=1888299
+ const timerId = setTimeout(async () => {
+ let { device, tabs } = this.pendingClosedTabs.get(deviceId);
+ // send a push notification for this specific device
+ await this._sendCloseTabPush(device, tabs);
+
+ // Clear the timer
+ this.pushTimers.delete(deviceId);
+ // We also need to locally store the tabs we sent so the user doesn't
+ // see these anymore
+ this.pendingClosedTabs.delete(deviceId);
+
+ // This is used for tests only, to get around timer woes
+ Observers.notify("test:fxaccounts:commands:close-uri:sent");
+ }, pushDelay);
+
+ // Store the new timer with the device
+ this.pushTimers.set(deviceId, timerId);
+ }
+
+ /**
+ * @param {Device} target - Device object (typically returned by fxAccounts.getDevicesList()).
+ * @param {String[]} urls - array of urls that should be closed on the remote device
+ */
+ async _sendCloseTabPush(target, urls) {
+ log.info(`Sending tab closures to ${target.id} device.`);
+ const flowID = this._fxai.telemetry.generateFlowID();
+ const encoder = new TextEncoder();
+ try {
+ const streamID = this._fxai.telemetry.generateFlowID();
+ const targetData = { flowID, streamID, urls };
+ const bytes = encoder.encode(JSON.stringify(targetData));
+ const encrypted = await this._encrypt(bytes, target);
+ // 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_CLOSETAB, target, payload);
+ this._fxai.telemetry.recordEvent(
+ "command-sent",
+ COMMAND_CLOSETAB_TAIL,
+ this._fxai.telemetry.sanitizeDeviceId(target.id),
+ { flowID, streamID }
+ );
+ } catch (error) {
+ // We should also show the user there was some kind've error
+ log.error("Error while invoking a send tab command.", error);
+ }
+ }
+
+ // Returns true if the target device is compatible with FxA Commands Send tab.
+ isDeviceCompatible(device) {
+ let pref = Services.prefs.getBoolPref(
+ "identity.fxaccounts.commands.remoteTabManagement.enabled",
+ false
+ );
+ return (
+ pref &&
+ device.availableCommands &&
+ device.availableCommands[COMMAND_CLOSETAB]
+ );
+ }
+
+ // Handle incoming remote tab payload, called by FxAccountsCommands.
+ async handleTabClose(senderID, { encrypted }, reason) {
+ const bytes = await this._decrypt(encrypted);
+ const decoder = new TextDecoder("utf8");
+ const data = JSON.parse(decoder.decode(bytes));
+ // urls is an array of strings
+ const { flowID, streamID, urls } = data;
+ this._fxai.telemetry.recordEvent(
+ "command-received",
+ COMMAND_CLOSETAB_TAIL,
+ this._fxai.telemetry.sanitizeDeviceId(senderID),
+ { flowID, streamID, reason }
+ );
+
+ return {
+ urls,
+ };
+ }
+
+ async _encrypt(bytes, device) {
+ let bundle = device.availableCommands[COMMAND_CLOSETAB];
+ if (!bundle) {
+ throw new Error(`Device ${device.id} does not have close tab keys.`);
+ }
+ const oldsyncKey = await this._fxai.keys.getKeyForScope(SCOPE_OLD_SYNC);
+ 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 _getPersistedCloseTabKeys() {
+ const { device } = await this._fxai.getUserAccountData(["device"]);
+ return device && device.closeTabKeys;
+ }
+
+ async _decrypt(ciphertext) {
+ let { privateKey, publicKey, authSecret } =
+ await this._getPersistedCloseTabKeys();
+ 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 _generateAndPersistCloseTabKeys() {
+ let [publicKey, privateKey] = await lazy.PushCrypto.generateKeys();
+ publicKey = urlsafeBase64Encode(publicKey);
+ let authSecret = lazy.PushCrypto.generateAuthenticationSecret();
+ authSecret = urlsafeBase64Encode(authSecret);
+ const closeTabKeys = {
+ publicKey,
+ privateKey,
+ authSecret,
+ };
+ await this._fxai.withCurrentAccountState(async state => {
+ const { device } = await state.getUserAccountData(["device"]);
+ await state.updateUserAccountData({
+ device: {
+ ...device,
+ closeTabKeys,
+ },
+ });
+ });
+ return closeTabKeys;
+ }
+
+ async _getPersistedEncryptedCloseTabKey() {
+ const { encryptedCloseTabKeys } = await this._fxai.getUserAccountData([
+ "encryptedCloseTabKeys",
+ ]);
+ return encryptedCloseTabKeys;
+ }
+
+ async _generateAndPersistEncryptedCloseTabKeys() {
+ let closeTabKeys = await this._getPersistedCloseTabKeys();
+ if (!closeTabKeys) {
+ log.info("Could not find closeTab keys, generating them");
+ closeTabKeys = await this._generateAndPersistCloseTabKeys();
+ }
+ // Strip the private key from the bundle to encrypt.
+ const keyToEncrypt = {
+ publicKey: closeTabKeys.publicKey,
+ authSecret: closeTabKeys.authSecret,
+ };
+ if (!(await this._fxai.keys.canGetKeyForScope(SCOPE_OLD_SYNC))) {
+ log.info("Can't fetch keys, so unable to determine closeTab 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 closeTab keys",
+ ex
+ );
+ return null;
+ }
+ const wrapper = new lazy.CryptoWrapper();
+ wrapper.cleartext = keyToEncrypt;
+ const keyBundle = lazy.BulkKeyBundle.fromJWK(oldsyncKey);
+ await wrapper.encrypt(keyBundle);
+ const encryptedCloseTabKeys = 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({
+ encryptedCloseTabKeys,
+ });
+ });
+ return encryptedCloseTabKeys;
+ }
+
+ async getEncryptedCloseTabKeys() {
+ let encryptedCloseTabKeys = await this._getPersistedEncryptedCloseTabKey();
+ const closeTabKeys = await this._getPersistedCloseTabKeys();
+ if (!encryptedCloseTabKeys || !closeTabKeys) {
+ log.info("Generating and persisting encrypted closeTab keys");
+ // `_generateAndPersistEncryptedCloseTabKeys` 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)
+ encryptedCloseTabKeys =
+ await this._generateAndPersistEncryptedCloseTabKeys();
+ }
+ return encryptedCloseTabKeys;
+ }
+}
+
function urlsafeBase64Encode(buffer) {
return ChromeUtils.base64URLEncode(new Uint8Array(buffer), { pad: false });
}
diff --git a/services/fxaccounts/FxAccountsCommon.sys.mjs b/services/fxaccounts/FxAccountsCommon.sys.mjs
index 2688fc3c0a..18f129af38 100644
--- a/services/fxaccounts/FxAccountsCommon.sys.mjs
+++ b/services/fxaccounts/FxAccountsCommon.sys.mjs
@@ -3,7 +3,7 @@
* 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";
+import { LogManager } from "resource://gre/modules/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
@@ -29,7 +29,13 @@ let logs = [
];
// For legacy reasons, the log manager still thinks it's part of sync.
-export let logManager = new LogManager("services.sync.", logs, "sync");
+export let logManager = new LogManager({
+ prefRoot: "services.sync.",
+ logNames: logs,
+ logFilePrefix: "sync",
+ logFileSubDirectoryEntries: ["weave", "logs"],
+ testTopicPrefix: "services-tests:common:log-manager:",
+});
// A boolean to indicate if personally identifiable information (or anything
// else sensitive, such as credentials) should be logged.
@@ -77,6 +83,9 @@ 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;
+// A command to close a tab on this device
+export let COMMAND_CLOSETAB_TAIL = "close-uri/v1";
+export let COMMAND_CLOSETAB = COMMAND_PREFIX + COMMAND_CLOSETAB_TAIL;
// OAuth
export let FX_OAUTH_CLIENT_ID = "5882386c6d801776";
@@ -266,6 +275,7 @@ export let FXA_PWDMGR_PLAINTEXT_FIELDS = new Set([
"device",
"profileCache",
"encryptedSendTabKeys",
+ "encryptedCloseTabKeys",
]);
// Fields we store in secure storage if it exists.
diff --git a/services/fxaccounts/FxAccountsKeys.sys.mjs b/services/fxaccounts/FxAccountsKeys.sys.mjs
index 9717f010c7..1258bdcd05 100644
--- a/services/fxaccounts/FxAccountsKeys.sys.mjs
+++ b/services/fxaccounts/FxAccountsKeys.sys.mjs
@@ -157,7 +157,9 @@ export class FxAccountsKeys {
if (!kid.includes("-")) {
return false;
}
- const [keyRotationTimestamp, fingerprint] = kid.split("-");
+ const dashIndex = kid.indexOf("-");
+ const keyRotationTimestamp = kid.substring(0, dashIndex);
+ const fingerprint = kid.substring(dashIndex + 1);
// We then verify that the timestamp is a valid timestamp
const keyRotationTimestampNum = Number(keyRotationTimestamp);
// If the value we got back is falsy it's not a valid timestamp
diff --git a/services/fxaccounts/FxAccountsProfileClient.sys.mjs b/services/fxaccounts/FxAccountsProfileClient.sys.mjs
index 7ae1bd95db..14a773c999 100644
--- a/services/fxaccounts/FxAccountsProfileClient.sys.mjs
+++ b/services/fxaccounts/FxAccountsProfileClient.sys.mjs
@@ -5,7 +5,6 @@
/**
* A client to fetch profile information for a Firefox Account.
*/
-"use strict;";
import {
ERRNO_NETWORK,
diff --git a/services/fxaccounts/tests/xpcshell/test_commands_closetab.js b/services/fxaccounts/tests/xpcshell/test_commands_closetab.js
new file mode 100644
index 0000000000..447b80be94
--- /dev/null
+++ b/services/fxaccounts/tests/xpcshell/test_commands_closetab.js
@@ -0,0 +1,263 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { CloseRemoteTab } = ChromeUtils.importESModule(
+ "resource://gre/modules/FxAccountsCommands.sys.mjs"
+);
+
+const { COMMAND_CLOSETAB, COMMAND_CLOSETAB_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 promiseObserver(topic) {
+ return new Promise(resolve => {
+ let obs = (aSubject, aTopic) => {
+ Services.obs.removeObserver(obs, aTopic);
+ resolve(aSubject);
+ };
+ Services.obs.addObserver(obs, topic);
+ });
+}
+
+add_task(async function test_closetab_isDeviceCompatible() {
+ const closeTab = new CloseRemoteTab(null, null);
+ let device = { name: "My device" };
+ Assert.ok(!closeTab.isDeviceCompatible(device));
+ device = { name: "My device", availableCommands: {} };
+ Assert.ok(!closeTab.isDeviceCompatible(device));
+ device = {
+ name: "My device",
+ availableCommands: {
+ "https://identity.mozilla.com/cmd/close-uri/v1": "payload",
+ },
+ };
+ // Even though the command is available, we're keeping this feature behind a feature
+ // flag for now, so it should still show up as "not available"
+ Assert.ok(!closeTab.isDeviceCompatible(device));
+
+ // Enable the feature
+ Services.prefs.setBoolPref(
+ "identity.fxaccounts.commands.remoteTabManagement.enabled",
+ true
+ );
+ Assert.ok(closeTab.isDeviceCompatible(device));
+
+ // clear it for the next test
+ Services.prefs.clearUserPref(
+ "identity.fxaccounts.commands.remoteTabManagement.enabled"
+ );
+});
+
+add_task(async function test_closetab_send() {
+ const commands = {
+ invoke: sinon.spy((cmd, device, payload) => {
+ Assert.equal(payload.encrypted, "encryptedpayload");
+ }),
+ };
+ const fxai = FxaInternalMock();
+ const closeTab = new CloseRemoteTab(commands, fxai);
+ closeTab._encrypt = async () => {
+ return "encryptedpayload";
+ };
+ const targetDevice = { id: "dev1", name: "Device 1" };
+ const tab = { url: "https://foo.bar/" };
+
+ // We add a 0 delay so we can "send" the push immediately
+ closeTab.enqueueTabToClose(targetDevice, tab, 0);
+
+ // We have a tab queued
+ Assert.equal(closeTab.pendingClosedTabs.get(targetDevice.id).tabs.length, 1);
+
+ // Wait on the notification to ensure the push sent
+ await promiseObserver("test:fxaccounts:commands:close-uri:sent");
+
+ // The push has been sent, we should not have the tabs anymore
+ Assert.equal(
+ closeTab.pendingClosedTabs.has(targetDevice.id),
+ false,
+ "The device should be removed from the queue after sending."
+ );
+
+ // Telemetry shows we sent one successfully
+ Assert.deepEqual(fxai.telemetry._events, [
+ {
+ object: "command-sent",
+ method: COMMAND_CLOSETAB_TAIL,
+ value: "dev1-san",
+ // streamID uses the same generator as flowId, so it will be 2
+ extra: { flowID: "1", streamID: "2" },
+ },
+ ]);
+});
+
+add_task(async function test_multiple_tabs_one_device() {
+ const commands = sinon.stub({
+ invoke: async () => {},
+ });
+ const fxai = FxaInternalMock();
+ const closeTab = new CloseRemoteTab(commands, fxai);
+ closeTab._encrypt = async () => "encryptedpayload";
+
+ const targetDevice = {
+ id: "dev1",
+ name: "Device 1",
+ availableCommands: { [COMMAND_CLOSETAB]: "payload" },
+ };
+ const tab1 = { url: "https://foo.bar/" };
+ const tab2 = { url: "https://example.com/" };
+
+ closeTab.enqueueTabToClose(targetDevice, tab1, 1000);
+ closeTab.enqueueTabToClose(targetDevice, tab2, 0);
+
+ // We have two tabs queued
+ Assert.equal(closeTab.pendingClosedTabs.get("dev1").tabs.length, 2);
+
+ // Wait on the notification to ensure the push sent
+ await promiseObserver("test:fxaccounts:commands:close-uri:sent");
+
+ Assert.equal(
+ closeTab.pendingClosedTabs.has(targetDevice.id),
+ false,
+ "The device should be removed from the queue after sending."
+ );
+
+ // Telemetry shows we sent one successfully
+ Assert.deepEqual(fxai.telemetry._events, [
+ {
+ object: "command-sent",
+ method: COMMAND_CLOSETAB_TAIL,
+ value: "dev1-san",
+ extra: { flowID: "1", streamID: "2" },
+ },
+ ]);
+});
+
+add_task(async function test_timer_reset_on_new_tab() {
+ const commands = sinon.stub({
+ invoke: async () => {},
+ });
+ const fxai = FxaInternalMock();
+ const closeTab = new CloseRemoteTab(commands, fxai);
+ closeTab._encrypt = async () => "encryptedpayload";
+
+ const targetDevice = {
+ id: "dev1",
+ name: "Device 1",
+ availableCommands: { [COMMAND_CLOSETAB]: "payload" },
+ };
+ const tab1 = { url: "https://foo.bar/" };
+ const tab2 = { url: "https://example.com/" };
+
+ // default wait is 6s
+ closeTab.enqueueTabToClose(targetDevice, tab1);
+
+ Assert.equal(closeTab.pendingClosedTabs.get(targetDevice.id).tabs.length, 1);
+
+ // Adds a new tab and should reset timer
+ closeTab.enqueueTabToClose(targetDevice, tab2, 100);
+
+ // We have two tabs queued
+ Assert.equal(closeTab.pendingClosedTabs.get(targetDevice.id).tabs.length, 2);
+
+ // Wait on the notification to ensure the push sent
+ await promiseObserver("test:fxaccounts:commands:close-uri:sent");
+
+ // We only sent one push
+ sinon.assert.calledOnce(commands.invoke);
+ Assert.equal(closeTab.pendingClosedTabs.has(targetDevice.id), false);
+
+ // Telemetry shows we sent only one
+ Assert.deepEqual(fxai.telemetry._events, [
+ {
+ object: "command-sent",
+ method: COMMAND_CLOSETAB_TAIL,
+ value: "dev1-san",
+ extra: { flowID: "1", streamID: "2" },
+ },
+ ]);
+});
+
+add_task(async function test_multiple_devices() {
+ const commands = sinon.stub({
+ invoke: async () => {},
+ });
+ const fxai = FxaInternalMock();
+ const closeTab = new CloseRemoteTab(commands, fxai);
+ closeTab._encrypt = async () => "encryptedpayload";
+
+ const device1 = {
+ id: "dev1",
+ name: "Device 1",
+ availableCommands: { [COMMAND_CLOSETAB]: "payload" },
+ };
+ const device2 = {
+ id: "dev2",
+ name: "Device 2",
+ availableCommands: { [COMMAND_CLOSETAB]: "payload" },
+ };
+ const tab1 = { url: "https://foo.bar/" };
+ const tab2 = { url: "https://example.com/" };
+
+ closeTab.enqueueTabToClose(device1, tab1, 100);
+ closeTab.enqueueTabToClose(device2, tab2, 200);
+
+ Assert.equal(closeTab.pendingClosedTabs.get(device1.id).tabs.length, 1);
+ Assert.equal(closeTab.pendingClosedTabs.get(device2.id).tabs.length, 1);
+
+ // observe the notification to ensure the push sent
+ await promiseObserver("test:fxaccounts:commands:close-uri:sent");
+
+ // We should have only sent the first device
+ sinon.assert.calledOnce(commands.invoke);
+ Assert.equal(closeTab.pendingClosedTabs.has(device1.id), false);
+
+ // Wait on the notification to ensure the push sent
+ await promiseObserver("test:fxaccounts:commands:close-uri:sent");
+
+ // Now we've sent both pushes
+ sinon.assert.calledTwice(commands.invoke);
+
+ // Two telemetry events to two different devices
+ Assert.deepEqual(fxai.telemetry._events, [
+ {
+ object: "command-sent",
+ method: COMMAND_CLOSETAB_TAIL,
+ value: "dev1-san",
+ extra: { flowID: "1", streamID: "2" },
+ },
+ {
+ object: "command-sent",
+ method: COMMAND_CLOSETAB_TAIL,
+ value: "dev2-san",
+ extra: { flowID: "3", streamID: "4" },
+ },
+ ]);
+});
diff --git a/services/fxaccounts/tests/xpcshell/test_keys.js b/services/fxaccounts/tests/xpcshell/test_keys.js
index 9a25ca90f3..5caa4679c6 100644
--- a/services/fxaccounts/tests/xpcshell/test_keys.js
+++ b/services/fxaccounts/tests/xpcshell/test_keys.js
@@ -219,6 +219,18 @@ add_task(function test_check_valid_scoped_keys() {
};
Assert.equal(keys.validScopedKeys(scopedKeys), true);
});
+ add_task(function test_valid_kid_with_dash() {
+ const scopedKeys = {
+ "https://identity.mozilla.com/apps/oldsync": {
+ kty: "oct",
+ // kid contains another dash. The fingerprint must not be truncated.
+ kid: "1510726318123-I-Qv4onc7VcVE1kTQkyyOw",
+ k: "DW_ll5GwX6SJ5GPqJVAuMUP2t6kDqhUulc2cbt26xbTcaKGQl-9l29FHAQ7kUiJETma4s9fIpEHrt909zgFang",
+ scope: "https://identity.mozilla.com/apps/oldsync",
+ },
+ };
+ Assert.equal(keys.validScopedKeys(scopedKeys), true);
+ });
});
add_task(async function test_rejects_bad_scoped_key_data() {
diff --git a/services/fxaccounts/tests/xpcshell/xpcshell.toml b/services/fxaccounts/tests/xpcshell/xpcshell.toml
index 7fc9c60006..09469fc0b4 100644
--- a/services/fxaccounts/tests/xpcshell/xpcshell.toml
+++ b/services/fxaccounts/tests/xpcshell/xpcshell.toml
@@ -20,6 +20,8 @@ support-files = [
["test_commands.js"]
+["test_commands_closetab.js"]
+
["test_credentials.js"]
["test_device.js"]