diff --git a/.eslintignore b/.eslintignore
index dd0755fe7b68..23d2863f0b04 100644
--- a/.eslintignore
+++ b/.eslintignore
@@ -294,6 +294,9 @@ security/nss/**
# services/ exclusions
+# Webpack-bundled library
+services/fxaccounts/FxAccountsPairingChannel.js
+
# Uses `#filter substitution`
services/sync/modules/constants.js
services/sync/services-sync.js
diff --git a/browser/app/profile/firefox.js b/browser/app/profile/firefox.js
index 69030f7c2388..976393e43074 100644
--- a/browser/app/profile/firefox.js
+++ b/browser/app/profile/firefox.js
@@ -1416,6 +1416,12 @@ pref("identity.fxaccounts.remote.profile.uri", "https://profile.accounts.firefox
// The remote URL of the FxA OAuth Server
pref("identity.fxaccounts.remote.oauth.uri", "https://oauth.accounts.firefox.com/v1");
+// Whether FxA pairing using QR codes is enabled.
+pref("identity.fxaccounts.pairing.enabled", false);
+
+// The remote URI of the FxA pairing server
+pref("identity.fxaccounts.remote.pairing.uri", "wss://channelserver.services.mozilla.com");
+
// Token server used by the FxA Sync identity.
pref("identity.sync.tokenserver.uri", "https://token.services.mozilla.com/1.0/sync/1.5");
diff --git a/browser/components/preferences/in-content/fxaPairDevice.js b/browser/components/preferences/in-content/fxaPairDevice.js
new file mode 100644
index 000000000000..0046b165ef95
--- /dev/null
+++ b/browser/components/preferences/in-content/fxaPairDevice.js
@@ -0,0 +1,98 @@
+// 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 {XPCOMUtils} = ChromeUtils.import("resource://gre/modules/XPCOMUtils.jsm");
+const {Services} = ChromeUtils.import("resource://gre/modules/Services.jsm");
+const {FxAccounts} = ChromeUtils.import("resource://gre/modules/FxAccounts.jsm");
+const {Weave} = ChromeUtils.import("resource://services-sync/main.js");
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ EventEmitter: "resource://gre/modules/EventEmitter.jsm",
+ FxAccountsPairingFlow: "resource://gre/modules/FxAccountsPairing.jsm",
+});
+const { require } = ChromeUtils.import("resource://devtools/shared/Loader.jsm", {});
+const QR = require("devtools/shared/qrcode/index");
+
+// This is only for "labor illusion", see
+// https://www.fastcompany.com/3061519/the-ux-secret-that-will-ruin-apps-for-you
+const MIN_PAIRING_LOADING_TIME_MS = 1000;
+
+/**
+ * Communication between FxAccountsPairingFlow and gFxaPairDeviceDialog
+ * is done using an emitter via the following messages:
+ * <- [view:SwitchToWebContent] - Notifies the view to navigate to a specific URL.
+ * <- [view:Error] - Notifies the view something went wrong during the pairing process.
+ * -> [view:Closed] - Notifies the pairing module the view was closed.
+ */
+var gFxaPairDeviceDialog = {
+ init() {
+ this._resetBackgroundQR();
+ FxAccounts.config.promiseConnectDeviceURI("pairing-modal").then(connectURI => {
+ document.getElementById("connect-another-device-link").setAttribute("href", connectURI);
+ });
+ // We let the modal show itself before eventually showing a master-password dialog later.
+ Services.tm.dispatchToMainThread(() => this.startPairingFlow());
+ },
+
+ uninit() {
+ this.teardownListeners();
+ this._emitter.emit("view:Closed");
+ },
+
+ async startPairingFlow() {
+ this._resetBackgroundQR();
+ document.getElementById("qrWrapper").setAttribute("pairing-status", "loading");
+ this._emitter = new EventEmitter();
+ this.setupListeners();
+ try {
+ if (!Weave.Utils.ensureMPUnlocked()) {
+ throw new Error("Master-password locked.");
+ }
+ const [, uri] = await Promise.all([
+ new Promise(res => setTimeout(res, MIN_PAIRING_LOADING_TIME_MS)),
+ FxAccountsPairingFlow.start({emitter: this._emitter}),
+ ]);
+ const imgData = QR.encodeToDataURI(uri, "L");
+ document.getElementById("qrContainer").style.backgroundImage = `url("${imgData.src}")`;
+ document.getElementById("qrWrapper").setAttribute("pairing-status", "ready");
+ } catch (e) {
+ this.onError(e);
+ }
+ },
+
+ _resetBackgroundQR() {
+ // The text we encode doesn't really matter as it is un-scannable (blurry and very transparent).
+ const imgData = QR.encodeToDataURI("https://accounts.firefox.com/pair", "L");
+ document.getElementById("qrContainer").style.backgroundImage = `url("${imgData.src}")`;
+ },
+
+ onError(err) {
+ Cu.reportError(err);
+ this.teardownListeners();
+ document.getElementById("qrWrapper").setAttribute("pairing-status", "error");
+ },
+
+ _switchToUrl(url) {
+ const browser = window.docShell.chromeEventHandler;
+ browser.loadURI(url, {
+ triggeringPrincipal: Services.scriptSecurityManager.createNullPrincipal({}),
+ });
+ },
+
+ setupListeners() {
+ this._switchToWebContent = (_, url) => this._switchToUrl(url);
+ this._onError = (_, error) => this.onError(error);
+ this._emitter.once("view:SwitchToWebContent", this._switchToWebContent);
+ this._emitter.on("view:Error", this._onError);
+ },
+
+ teardownListeners() {
+ try {
+ this._emitter.off("view:SwitchToWebContent", this._switchToWebContent);
+ this._emitter.off("view:Error", this._onError);
+ } catch (e) {
+ console.warn("Error while tearing down listeners.", e);
+ }
+ },
+};
diff --git a/browser/components/preferences/in-content/fxaPairDevice.xul b/browser/components/preferences/in-content/fxaPairDevice.xul
new file mode 100644
index 000000000000..e8be4a73b629
--- /dev/null
+++ b/browser/components/preferences/in-content/fxaPairDevice.xul
@@ -0,0 +1,46 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/browser/components/preferences/in-content/jar.mn b/browser/components/preferences/in-content/jar.mn
index 326bc4670a09..7d365e2babad 100644
--- a/browser/components/preferences/in-content/jar.mn
+++ b/browser/components/preferences/in-content/jar.mn
@@ -16,4 +16,6 @@ browser.jar:
content/browser/preferences/in-content/sync.js
content/browser/preferences/in-content/syncDisconnect.xul
content/browser/preferences/in-content/syncDisconnect.js
+ content/browser/preferences/in-content/fxaPairDevice.xul
+ content/browser/preferences/in-content/fxaPairDevice.js
content/browser/preferences/in-content/findInPage.js
diff --git a/browser/components/preferences/in-content/sync.js b/browser/components/preferences/in-content/sync.js
index ee01258890c2..835ccd22db86 100644
--- a/browser/components/preferences/in-content/sync.js
+++ b/browser/components/preferences/in-content/sync.js
@@ -149,11 +149,11 @@ var gSyncPane = {
// Links for mobile devices shown after the user is logged in.
FxAccounts.config.promiseConnectDeviceURI(this._getEntryPoint()).then(connectURI => {
- document.getElementById("mobilePromo-singledevice").setAttribute("href", connectURI);
+ document.getElementById("connect-another-device").setAttribute("href", connectURI);
});
FxAccounts.config.promiseManageDevicesURI(this._getEntryPoint()).then(manageURI => {
- document.getElementById("mobilePromo-multidevice").setAttribute("href", manageURI);
+ document.getElementById("manage-devices").setAttribute("href", manageURI);
});
document.getElementById("tosPP-small-ToS").setAttribute("href", Weave.Svc.Prefs.get("fxa.termsURL"));
@@ -167,6 +167,12 @@ var gSyncPane = {
// Notify observers that the UI is now ready
Services.obs.notifyObservers(window, "sync-pane-loaded");
+
+ // document.location.search is empty, so we simply match on `action=pair`.
+ if (location.href.includes("action=pair") && location.hash == "#sync" &&
+ UIState.get().status == UIState.STATUS_SIGNED_IN) {
+ gSyncPane.pairAnotherDevice();
+ }
},
_toggleComputerNameControls(editMode) {
@@ -338,8 +344,8 @@ var gSyncPane = {
let isUnverified = state.status == UIState.STATUS_NOT_VERIFIED;
// The mobile promo links - which one is shown depends on the number of devices.
let isMultiDevice = Weave.Service.clientsEngine.stats.numClients > 1;
- document.getElementById("mobilePromo-singledevice").hidden = isUnverified || isMultiDevice;
- document.getElementById("mobilePromo-multidevice").hidden = isUnverified || !isMultiDevice;
+ document.getElementById("connect-another-device").hidden = isUnverified;
+ document.getElementById("manage-devices").hidden = isUnverified || !isMultiDevice;
},
_getEntryPoint() {
@@ -468,6 +474,14 @@ var gSyncPane = {
}
},
+ pairAnotherDevice() {
+ gSubDialog.open("chrome://browser/content/preferences/in-content/fxaPairDevice.xul",
+ "resizable=no", /* aFeatures */
+ null, /* aParams */
+ null /* aClosingCallback */
+ );
+ },
+
_populateComputerName(value) {
let textbox = document.getElementById("fxaSyncComputerName");
if (!textbox.hasAttribute("placeholder")) {
diff --git a/browser/components/preferences/in-content/sync.xul b/browser/components/preferences/in-content/sync.xul
index 4556aad88e28..54a82e1dcdd8 100755
--- a/browser/components/preferences/in-content/sync.xul
+++ b/browser/components/preferences/in-content/sync.xul
@@ -185,10 +185,10 @@
-
-
+
+
diff --git a/browser/components/preferences/in-content/tests/browser.ini b/browser/components/preferences/in-content/tests/browser.ini
index c38f8e8e9a7f..1402fd458d23 100644
--- a/browser/components/preferences/in-content/tests/browser.ini
+++ b/browser/components/preferences/in-content/tests/browser.ini
@@ -88,6 +88,7 @@ support-files =
subdialog2.xul
[browser_sync_sanitize.js]
skip-if = os == 'win' && processor == "x86_64" && bits == 64 # bug 1522821
+[browser_sync_pairing.js]
[browser_telemetry.js]
# Skip this test on Android as FHR and Telemetry are separate systems there.
skip-if = !healthreport || !telemetry || (os == 'linux' && debug) || (os == 'android')
diff --git a/browser/components/preferences/in-content/tests/browser_sync_pairing.js b/browser/components/preferences/in-content/tests/browser_sync_pairing.js
new file mode 100644
index 000000000000..80132be6e2c6
--- /dev/null
+++ b/browser/components/preferences/in-content/tests/browser_sync_pairing.js
@@ -0,0 +1,154 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+/* global sinon */
+
+"use strict";
+
+const {UIState} = ChromeUtils.import("resource://services-sync/UIState.jsm", {});
+const {FxAccountsPairingFlow} = ChromeUtils.import("resource://gre/modules/FxAccountsPairing.jsm", {});
+
+// Use sinon for mocking.
+Services.scriptloader.loadSubScript("resource://testing-common/sinon-2.3.2.js");
+registerCleanupFunction(() => {
+ delete window.sinon; // test fails with this reference left behind.
+});
+
+let flowCounter = 0;
+
+add_task(async function setup() {
+ Services.prefs.setBoolPref("identity.fxaccounts.pairing.enabled", true);
+ // Sync start-up might interfere with our tests, don't let UIState send UI updates.
+ const origNotifyStateUpdated = UIState._internal.notifyStateUpdated;
+ UIState._internal.notifyStateUpdated = () => {};
+
+ const origGet = UIState.get;
+ UIState.get = () => { return { status: UIState.STATUS_SIGNED_IN, email: "foo@bar.com" }; };
+
+ const origStart = FxAccountsPairingFlow.start;
+ FxAccountsPairingFlow.start = ({emitter: e}) => {
+ return `https://foo.bar/${flowCounter++}`;
+ };
+
+ registerCleanupFunction(() => {
+ UIState._internal.notifyStateUpdated = origNotifyStateUpdated;
+ UIState.get = origGet;
+ FxAccountsPairingFlow.start = origStart;
+ });
+});
+
+add_task(async function testShowsQRCode() {
+ await runWithPairingDialog(async (win, sinon) => {
+ let doc = win.document;
+ let qrContainer = doc.getElementById("qrContainer");
+ let qrWrapper = doc.getElementById("qrWrapper");
+
+ await TestUtils.waitForCondition(() => qrWrapper.getAttribute("pairing-status") == "ready");
+
+ // Verify that a QRcode is being shown.
+ Assert.ok(qrContainer.style.backgroundImage.startsWith(`url("data:image/gif;base64,R0lGODdhOgA6AIAAAAAAAP///ywAAAAAOgA6AAAC/4yPqcvtD6OctNqLs968+w+G4gKU5nkiJYO2JuW6KsDGKEw3a7AbPZ+r4Ry7nzFIQkKKN6Avlzowo78`));
+
+ // Close the dialog.
+ let promiseUnloaded = BrowserTestUtils.waitForEvent(win, "unload");
+ gBrowser.contentDocument.querySelector(".dialogClose").click();
+
+ info("waiting for dialog to unload");
+ await promiseUnloaded;
+ });
+});
+
+add_task(async function testCantShowQrCode() {
+ const origStart = FxAccountsPairingFlow.start;
+ FxAccountsPairingFlow.start = async () => { throw new Error("boom"); };
+ await runWithPairingDialog(async (win, sinon) => {
+ let doc = win.document;
+ let qrWrapper = doc.getElementById("qrWrapper");
+
+ await TestUtils.waitForCondition(() => qrWrapper.getAttribute("pairing-status") == "error");
+
+ // Close the dialog.
+ let promiseUnloaded = BrowserTestUtils.waitForEvent(win, "unload");
+ gBrowser.contentDocument.querySelector(".dialogClose").click();
+
+ info("waiting for dialog to unload");
+ await promiseUnloaded;
+ });
+ FxAccountsPairingFlow.start = origStart;
+});
+
+add_task(async function testSwitchToWebContent() {
+ await runWithPairingDialog(async (win, sinon) => {
+ let doc = win.document;
+ let qrWrapper = doc.getElementById("qrWrapper");
+
+ await TestUtils.waitForCondition(() => qrWrapper.getAttribute("pairing-status") == "ready");
+
+ const spySwitchURL = sinon.spy(win.gFxaPairDeviceDialog, "_switchToUrl");
+ const emitter = win.gFxaPairDeviceDialog._emitter;
+ emitter.emit("view:SwitchToWebContent", "about:robots");
+
+ Assert.equal(spySwitchURL.callCount, 1);
+ });
+});
+
+add_task(async function testError() {
+ await runWithPairingDialog(async (win, sinon) => {
+ let doc = win.document;
+ let qrWrapper = doc.getElementById("qrWrapper");
+
+ await TestUtils.waitForCondition(() => qrWrapper.getAttribute("pairing-status") == "ready");
+
+ const emitter = win.gFxaPairDeviceDialog._emitter;
+ emitter.emit("view:Error");
+
+ await TestUtils.waitForCondition(() => qrWrapper.getAttribute("pairing-status") == "error");
+
+ // Close the dialog.
+ let promiseUnloaded = BrowserTestUtils.waitForEvent(win, "unload");
+ gBrowser.contentDocument.querySelector(".dialogClose").click();
+
+ info("waiting for dialog to unload");
+ await promiseUnloaded;
+ });
+});
+
+add_task(async function testMPLocked() {
+ Weave.Utils.ensureMPUnlocked = () => false;
+ await runWithPairingDialog(async (win, sinon) => {
+ let doc = win.document;
+ let qrWrapper = doc.getElementById("qrWrapper");
+
+ await TestUtils.waitForCondition(() => qrWrapper.getAttribute("pairing-status") == "error");
+
+ // Simulate unlock.
+ Weave.Utils.ensureMPUnlocked = () => true;
+
+ await doc.getElementById("qrError").click();
+
+ await TestUtils.waitForCondition(() => qrWrapper.getAttribute("pairing-status") == "ready");
+
+ // Close the dialog.
+ let promiseUnloaded = BrowserTestUtils.waitForEvent(win, "unload");
+ gBrowser.contentDocument.querySelector(".dialogClose").click();
+
+ info("waiting for dialog to unload");
+ await promiseUnloaded;
+ });
+});
+
+async function runWithPairingDialog(test) {
+ await openPreferencesViaOpenPreferencesAPI("paneSync", {leaveOpen: true});
+
+ let promiseSubDialogLoaded =
+ promiseLoadSubDialog("chrome://browser/content/preferences/in-content/fxaPairDevice.xul");
+ gBrowser.contentWindow.gSyncPane.pairAnotherDevice();
+
+ let win = await promiseSubDialogLoaded;
+
+ let ss = sinon.sandbox.create();
+
+ await test(win, ss);
+
+ ss.restore();
+
+ BrowserTestUtils.removeTab(gBrowser.selectedTab);
+}
diff --git a/browser/locales/en-US/browser/preferences/fxaPairDevice.ftl b/browser/locales/en-US/browser/preferences/fxaPairDevice.ftl
new file mode 100644
index 000000000000..258ecf293813
--- /dev/null
+++ b/browser/locales/en-US/browser/preferences/fxaPairDevice.ftl
@@ -0,0 +1,15 @@
+# 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-pair-device-dialog =
+ .title = Connect Another Device
+ .style = width: 26em; min-height: 35em;
+
+fxa-qrcode-heading-phase1 = 1. If you haven’t already, install Firefox on your mobile device.
+
+fxa-qrcode-heading-phase2 = 2. Then sign in to { -sync-brand-short-name }, or on Android scan the pairing code from inside the { -sync-brand-short-name } settings.
+
+fxa-qrcode-error-title = Pairing unsuccessful.
+
+fxa-qrcode-error-body = Try again.
diff --git a/browser/locales/en-US/browser/preferences/preferences.ftl b/browser/locales/en-US/browser/preferences/preferences.ftl
index 5b2a028e913b..48ca56f64204 100644
--- a/browser/locales/en-US/browser/preferences/preferences.ftl
+++ b/browser/locales/en-US/browser/preferences/preferences.ftl
@@ -664,9 +664,11 @@ sync-device-name-save =
.label = Save
.accesskey = v
-sync-mobilepromo-single = Connect another device
+sync-connect-another-device = Connect another device
-sync-mobilepromo-multi = Manage devices
+sync-manage-devices = Manage devices
+
+sync-fxa-begin-pairing = Pair a device
sync-tos-link = Terms of Service
diff --git a/browser/themes/shared/fxa/fxa-spinner.svg b/browser/themes/shared/fxa/fxa-spinner.svg
new file mode 100644
index 000000000000..6265328cf1b4
--- /dev/null
+++ b/browser/themes/shared/fxa/fxa-spinner.svg
@@ -0,0 +1,28 @@
+
+
+
diff --git a/browser/themes/shared/incontentprefs/fxaPairDevice.css b/browser/themes/shared/incontentprefs/fxaPairDevice.css
new file mode 100644
index 000000000000..d4ed14cbc0e2
--- /dev/null
+++ b/browser/themes/shared/incontentprefs/fxaPairDevice.css
@@ -0,0 +1,94 @@
+/* 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/. */
+
+#fxaPairDeviceDialog {
+ padding: 0.5em;
+}
+
+.pairHeading {
+ padding-bottom: 1em;
+ text-align: center;
+}
+
+#qrWrapper {
+ position: relative;
+}
+
+#qrContainer {
+ height: 300px;
+ width: 300px;
+ background-size: contain;
+ image-rendering: -moz-crisp-edges;
+ transition: filter 250ms cubic-bezier(.07,.95,0,1);
+}
+
+#qrWrapper:not([pairing-status="ready"]) #qrContainer {
+ opacity: 0.05;
+ filter: blur(3px);
+}
+
+#qrWrapper:not([pairing-status="loading"]) #qrSpinner {
+ opacity: 0;
+}
+
+#qrSpinner {
+ background-image: url("chrome://browser/skin/fxa/fxa-spinner.svg");
+ animation: 0.9s spin infinite linear;
+ background-size: 36px;
+ background-repeat: no-repeat;
+ background-position: center;
+ width: 100%;
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ left: 0;
+ transition: opacity 250ms cubic-bezier(.07,.95,0,1);
+}
+
+#qrWrapper:not([pairing-status="error"]) #qrError {
+ display: none;
+}
+
+#qrError {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ flex-direction: column;
+ width: 300px; /* Same as #qrContainer */
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ margin-left: auto;
+ margin-right: auto;
+ transition: opacity 250ms cubic-bezier(.07,.95,0,1);
+ cursor: pointer;
+}
+
+.qr-error-text {
+ text-align: center;
+ -moz-user-select: none;
+ display: block;
+ color: #2484C6;
+ cursor: pointer;
+}
+
+#refresh-qr {
+ width: 36px;
+ height: 36px;
+ background-image: url("chrome://browser/skin/reload.svg");
+ background-size: contain;
+ -moz-context-properties: fill;
+ fill: #2484C6;
+}
+
+@keyframes spin {
+ 0% {
+ transform: rotate(0deg);
+ }
+ 100% {
+ transform: rotate(360deg);
+ }
+}
diff --git a/browser/themes/shared/jar.inc.mn b/browser/themes/shared/jar.inc.mn
index 04f1d7dfc961..b775a7c84303 100644
--- a/browser/themes/shared/jar.inc.mn
+++ b/browser/themes/shared/jar.inc.mn
@@ -109,12 +109,13 @@
#endif
skin/classic/browser/panel-icon-retry.svg (../shared/panel-icon-retry.svg)
skin/classic/browser/preferences/in-content/critters-postcard.jpg (../shared/incontentprefs/critters-postcard.jpg)
- skin/classic/browser/preferences/in-content/face-sad.svg (../shared/incontentprefs/face-sad.svg)
- skin/classic/browser/preferences/in-content/face-smile.svg (../shared/incontentprefs/face-smile.svg)
- skin/classic/browser/preferences/in-content/fxa-avatar.svg (../shared/incontentprefs/fxa-avatar.svg)
- skin/classic/browser/preferences/in-content/general.svg (../shared/incontentprefs/general.svg)
- skin/classic/browser/preferences/in-content/logo-android.svg (../shared/incontentprefs/logo-android.svg)
- skin/classic/browser/preferences/in-content/logo-ios.svg (../shared/incontentprefs/logo-ios.svg)
+ skin/classic/browser/preferences/in-content/face-sad.svg (../shared/incontentprefs/face-sad.svg)
+ skin/classic/browser/preferences/in-content/face-smile.svg (../shared/incontentprefs/face-smile.svg)
+ skin/classic/browser/preferences/in-content/fxa-avatar.svg (../shared/incontentprefs/fxa-avatar.svg)
+ skin/classic/browser/preferences/in-content/fxaPairDevice.css (../shared/incontentprefs/fxaPairDevice.css)
+ skin/classic/browser/preferences/in-content/general.svg (../shared/incontentprefs/general.svg)
+ skin/classic/browser/preferences/in-content/logo-android.svg (../shared/incontentprefs/logo-android.svg)
+ skin/classic/browser/preferences/in-content/logo-ios.svg (../shared/incontentprefs/logo-ios.svg)
skin/classic/browser/preferences/in-content/no-search-bar.svg (../shared/incontentprefs/no-search-bar.svg)
skin/classic/browser/preferences/in-content/no-search-results.svg (../shared/incontentprefs/no-search-results.svg)
skin/classic/browser/preferences/in-content/privacy-security.svg (../shared/incontentprefs/privacy-security.svg)
@@ -130,6 +131,7 @@
* skin/classic/browser/preferences/in-content/containers.css (../shared/incontentprefs/containers.css)
* skin/classic/browser/preferences/containers.css (../shared/preferences/containers.css)
skin/classic/browser/fxa/default-avatar.svg (../shared/fxa/default-avatar.svg)
+ skin/classic/browser/fxa/fxa-spinner.svg (../shared/fxa/fxa-spinner.svg)
skin/classic/browser/fxa/sync-illustration.svg (../shared/fxa/sync-illustration.svg)
skin/classic/browser/fxa/sync-illustration-issue.svg (../shared/fxa/sync-illustration-issue.svg)
diff --git a/services/fxaccounts/FxAccounts.jsm b/services/fxaccounts/FxAccounts.jsm
index fa2bffecd400..777b9d2f6ea3 100644
--- a/services/fxaccounts/FxAccounts.jsm
+++ b/services/fxaccounts/FxAccounts.jsm
@@ -10,7 +10,7 @@ const {Services} = ChromeUtils.import("resource://gre/modules/Services.jsm");
const {XPCOMUtils} = ChromeUtils.import("resource://gre/modules/XPCOMUtils.jsm");
const {clearTimeout, setTimeout} = ChromeUtils.import("resource://gre/modules/Timer.jsm");
const {FxAccountsStorageManager} = ChromeUtils.import("resource://gre/modules/FxAccountsStorage.jsm");
-const {ASSERTION_LIFETIME, ASSERTION_USE_PERIOD, CERT_LIFETIME, COMMAND_SENDTAB, DERIVED_KEYS_NAMES, ERRNO_DEVICE_SESSION_CONFLICT, ERRNO_INVALID_AUTH_TOKEN, ERRNO_UNKNOWN_DEVICE, ERROR_AUTH_ERROR, ERROR_INVALID_PARAMETER, ERROR_NO_ACCOUNT, ERROR_OFFLINE, ERROR_TO_GENERAL_ERROR_CLASS, ERROR_UNKNOWN, ERROR_UNVERIFIED_ACCOUNT, FXA_PWDMGR_MEMORY_FIELDS, FXA_PWDMGR_PLAINTEXT_FIELDS, FXA_PWDMGR_REAUTH_WHITELIST, FXA_PWDMGR_SECURE_FIELDS, FX_OAUTH_CLIENT_ID, KEY_LIFETIME, ONLOGIN_NOTIFICATION, ONLOGOUT_NOTIFICATION, ONVERIFIED_NOTIFICATION, ON_DEVICE_DISCONNECTED_NOTIFICATION, ON_NEW_DEVICE_ID, POLL_SESSION, PREF_LAST_FXA_USER, SERVER_ERRNO_TO_ERROR, log, logPII} = ChromeUtils.import("resource://gre/modules/FxAccountsCommon.js");
+const {ASSERTION_LIFETIME, ASSERTION_USE_PERIOD, CERT_LIFETIME, COMMAND_SENDTAB, DERIVED_KEYS_NAMES, ERRNO_DEVICE_SESSION_CONFLICT, ERRNO_INVALID_AUTH_TOKEN, ERRNO_UNKNOWN_DEVICE, ERROR_AUTH_ERROR, ERROR_INVALID_PARAMETER, ERROR_NO_ACCOUNT, ERROR_OFFLINE, ERROR_TO_GENERAL_ERROR_CLASS, ERROR_UNKNOWN, ERROR_UNVERIFIED_ACCOUNT, FXA_PWDMGR_MEMORY_FIELDS, FXA_PWDMGR_PLAINTEXT_FIELDS, FXA_PWDMGR_REAUTH_WHITELIST, FXA_PWDMGR_SECURE_FIELDS, FX_OAUTH_CLIENT_ID, KEY_LIFETIME, ONLOGIN_NOTIFICATION, ONLOGOUT_NOTIFICATION, ONVERIFIED_NOTIFICATION, ON_DEVICE_DISCONNECTED_NOTIFICATION, ON_NEW_DEVICE_ID, POLL_SESSION, PREF_LAST_FXA_USER, SERVER_ERRNO_TO_ERROR, SCOPE_OLD_SYNC, log, logPII} = ChromeUtils.import("resource://gre/modules/FxAccountsCommon.js");
ChromeUtils.defineModuleGetter(this, "FxAccountsClient",
"resource://gre/modules/FxAccountsClient.jsm");
@@ -48,9 +48,11 @@ var publicProperties = [
"getDeviceId",
"getDeviceList",
"getKeys",
+ "authorizeOAuthCode",
"getOAuthToken",
"getProfileCache",
"getPushSubscription",
+ "getScopedKeys",
"getSignedInUser",
"getSignedInUserProfile",
"handleAccountDestroyed",
@@ -418,6 +420,18 @@ FxAccountsInternal.prototype = {
return this._commands;
},
+ _oauthClient: null,
+ get oauthClient() {
+ if (!this._oauthClient) {
+ const serverURL = Services.urlFormatter.formatURLPref("identity.fxaccounts.remote.oauth.uri");
+ this._oauthClient = new FxAccountsOAuthGrantClient({
+ serverURL,
+ client_id: FX_OAUTH_CLIENT_ID,
+ });
+ }
+ return this._oauthClient;
+ },
+
// A hook-point for tests who may want a mocked AccountState or mocked storage.
newAccountState(credentials) {
let storage = new FxAccountsStorageManager();
@@ -1239,6 +1253,41 @@ FxAccountsInternal.prototype = {
};
},
+ /**
+ * @param {String} scope Single key bearing scope
+ */
+ async getKeyForScope(scope, {keyRotationTimestamp}) {
+ if (scope !== SCOPE_OLD_SYNC) {
+ throw new Error(`Unavailable key material for ${scope}`);
+ }
+ let {kSync, kXCS} = await this.getKeys();
+ if (!kSync || !kXCS) {
+ throw new Error("Could not find requested key.");
+ }
+ kXCS = ChromeUtils.base64URLEncode(CommonUtils.hexToArrayBuffer(kXCS), {pad: false});
+ kSync = ChromeUtils.base64URLEncode(CommonUtils.hexToArrayBuffer(kSync), {pad: false});
+ const kid = `${keyRotationTimestamp}-${kXCS}`;
+ return {
+ scope,
+ kid,
+ k: kSync,
+ kty: "oct",
+ };
+ },
+
+ /**
+ * @param {String} scopes Space separated requested scopes
+ */
+ async getScopedKeys(scopes, clientId) {
+ const {sessionToken} = await this._getVerifiedAccountOrReject();
+ const keyData = await this.fxAccountsClient.getScopedKeyData(sessionToken, clientId, scopes);
+ const scopedKeys = {};
+ for (const [scope, data] of Object.entries(keyData)) {
+ scopedKeys[scope] = await this.getKeyForScope(scope, data);
+ }
+ return scopedKeys;
+ },
+
getUserAccountData() {
return this.currentAccountState.getUserAccountData();
},
@@ -1455,19 +1504,7 @@ FxAccountsInternal.prototype = {
// We are going to hit the server - this is the string we pass to it.
let scopeString = scope.join(" ");
- let client = options.client;
-
- if (!client) {
- try {
- let defaultURL = Services.urlFormatter.formatURLPref("identity.fxaccounts.remote.oauth.uri");
- client = new FxAccountsOAuthGrantClient({
- serverURL: defaultURL,
- client_id: FX_OAUTH_CLIENT_ID,
- });
- } catch (e) {
- throw this._error(ERROR_INVALID_PARAMETER, e);
- }
- }
+ let client = options.client || this.oauthClient;
let oAuthURL = client.serverURL.href;
try {
@@ -1496,6 +1533,49 @@ FxAccountsInternal.prototype = {
}
},
+ /**
+ *
+ * @param {String} clientId
+ * @param {String} scope Space separated requested scopes
+ * @param {Object} jwk
+ */
+ async createKeysJWE(clientId, scope, jwk) {
+ let scopedKeys = await this.getScopedKeys(scope, clientId);
+ scopedKeys = new TextEncoder().encode(JSON.stringify(scopedKeys));
+ return jwcrypto.generateJWE(jwk, scopedKeys);
+ },
+
+ /**
+ * Retrieves an OAuth authorization code
+ *
+ * @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