Bug 1490671 - Add FxA device pairing. r=markh,rfkelly,vladikoff,flod

Differential Revision: https://phabricator.services.mozilla.com/D6966

--HG--
extra : moz-landing-system : lando
This commit is contained in:
Edouard Oger 2019-02-21 20:58:04 +00:00
parent 1b8510f881
commit 4177daaaf9
28 changed files with 5040 additions and 64 deletions

View file

@ -293,6 +293,9 @@ security/nss/**
# services/ exclusions # services/ exclusions
# Webpack-bundled library
services/fxaccounts/FxAccountsPairingChannel.js
# Uses `#filter substitution` # Uses `#filter substitution`
services/sync/modules/constants.js services/sync/modules/constants.js
services/sync/services-sync.js services/sync/services-sync.js

View file

@ -1416,6 +1416,12 @@ pref("identity.fxaccounts.remote.profile.uri", "https://profile.accounts.firefox
// The remote URL of the FxA OAuth Server // The remote URL of the FxA OAuth Server
pref("identity.fxaccounts.remote.oauth.uri", "https://oauth.accounts.firefox.com/v1"); 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. // Token server used by the FxA Sync identity.
pref("identity.sync.tokenserver.uri", "https://token.services.mozilla.com/1.0/sync/1.5"); pref("identity.sync.tokenserver.uri", "https://token.services.mozilla.com/1.0/sync/1.5");

View file

@ -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);
}
},
};

View file

@ -0,0 +1,46 @@
<?xml version="1.0"?>
<!-- -*- Mode: Java; tab-width: 4; indent-tabs-mode: nil; c-basic-offset: 4 -*- -->
<!-- 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/. -->
<?xml-stylesheet href="chrome://global/skin/"?>
<?xml-stylesheet href="chrome://browser/skin/preferences/in-content/fxaPairDevice.css" type="text/css"?>
<window id="fxaPairDeviceDialog" class="windowDialog"
xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
xmlns:html="http://www.w3.org/1999/xhtml"
role="dialog"
onload="gFxaPairDeviceDialog.init();"
onunload="gFxaPairDeviceDialog.uninit()"
data-l10n-id="fxa-pair-device-dialog"
data-l10n-attrs="title, style">
<linkset>
<link rel="localization" href="browser/branding/sync-brand.ftl"/>
<link rel="localization" href="browser/preferences/fxaPairDevice.ftl"/>
</linkset>
<script src="chrome://browser/content/preferences/in-content/fxaPairDevice.js"/>
<vbox id="qrCodeDisplay">
<description class="pairHeading" data-l10n-id="fxa-qrcode-heading-phase1">
<html:a
id="connect-another-device-link"
data-l10n-name="connect-another-device"
class="text-link" target="_blank"/>
</description>
<description class="pairHeading" data-l10n-id="fxa-qrcode-heading-phase2"></description>
<vbox>
<vbox align="center" id="qrWrapper" pairing-status="loading">
<box id="qrContainer"></box>
<box id="qrSpinner"></box>
<vbox id="qrError" onclick="gFxaPairDeviceDialog.startPairingFlow();">
<image id="refresh-qr" />
<label class="qr-error-text" data-l10n-id="fxa-qrcode-error-title"></label>
<label class="qr-error-text" data-l10n-id="fxa-qrcode-error-body"></label>
</vbox>
</vbox>
</vbox>
</vbox>
</window>

View file

@ -16,4 +16,6 @@ browser.jar:
content/browser/preferences/in-content/sync.js content/browser/preferences/in-content/sync.js
content/browser/preferences/in-content/syncDisconnect.xul content/browser/preferences/in-content/syncDisconnect.xul
content/browser/preferences/in-content/syncDisconnect.js 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 content/browser/preferences/in-content/findInPage.js

View file

@ -149,11 +149,11 @@ var gSyncPane = {
// Links for mobile devices shown after the user is logged in. // Links for mobile devices shown after the user is logged in.
FxAccounts.config.promiseConnectDeviceURI(this._getEntryPoint()).then(connectURI => { 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 => { 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")); 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 // Notify observers that the UI is now ready
Services.obs.notifyObservers(window, "sync-pane-loaded"); 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) { _toggleComputerNameControls(editMode) {
@ -338,8 +344,8 @@ var gSyncPane = {
let isUnverified = state.status == UIState.STATUS_NOT_VERIFIED; let isUnverified = state.status == UIState.STATUS_NOT_VERIFIED;
// The mobile promo links - which one is shown depends on the number of devices. // The mobile promo links - which one is shown depends on the number of devices.
let isMultiDevice = Weave.Service.clientsEngine.stats.numClients > 1; let isMultiDevice = Weave.Service.clientsEngine.stats.numClients > 1;
document.getElementById("mobilePromo-singledevice").hidden = isUnverified || isMultiDevice; document.getElementById("connect-another-device").hidden = isUnverified;
document.getElementById("mobilePromo-multidevice").hidden = isUnverified || !isMultiDevice; document.getElementById("manage-devices").hidden = isUnverified || !isMultiDevice;
}, },
_getEntryPoint() { _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) { _populateComputerName(value) {
let textbox = document.getElementById("fxaSyncComputerName"); let textbox = document.getElementById("fxaSyncComputerName");
if (!textbox.hasAttribute("placeholder")) { if (!textbox.hasAttribute("placeholder")) {

View file

@ -185,10 +185,10 @@
</hbox> </hbox>
</groupbox> </groupbox>
<vbox align="start"> <vbox align="start">
<label id="mobilePromo-singledevice" is="text-link" <label id="connect-another-device" is="text-link"
class="fxaMobilePromo" data-l10n-id="sync-mobilepromo-single"/> class="fxaMobilePromo" data-l10n-id="sync-connect-another-device"/>
<label id="mobilePromo-multidevice" is="text-link" <label id="manage-devices" is="text-link"
class="fxaMobilePromo" data-l10n-id="sync-mobilepromo-multi"/> class="fxaMobilePromo" data-l10n-id="sync-manage-devices"/>
</vbox> </vbox>
<vbox id="tosPP-small" align="start"> <vbox id="tosPP-small" align="start">
<label id="tosPP-small-ToS" is="text-link" data-l10n-id="sync-tos-link"/> <label id="tosPP-small-ToS" is="text-link" data-l10n-id="sync-tos-link"/>

View file

@ -88,6 +88,7 @@ support-files =
subdialog2.xul subdialog2.xul
[browser_sync_sanitize.js] [browser_sync_sanitize.js]
skip-if = os == 'win' && processor == "x86_64" && bits == 64 # bug 1522821 skip-if = os == 'win' && processor == "x86_64" && bits == 64 # bug 1522821
[browser_sync_pairing.js]
[browser_telemetry.js] [browser_telemetry.js]
# Skip this test on Android as FHR and Telemetry are separate systems there. # Skip this test on Android as FHR and Telemetry are separate systems there.
skip-if = !healthreport || !telemetry || (os == 'linux' && debug) || (os == 'android') skip-if = !healthreport || !telemetry || (os == 'linux' && debug) || (os == 'android')

View file

@ -0,0 +1,130 @@
/* 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;
});
});
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);
}

View file

@ -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 havent already, install <a data-l10n-name="connect-another-device">Firefox on your mobile device</a>.
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.

View file

@ -664,9 +664,11 @@ sync-device-name-save =
.label = Save .label = Save
.accesskey = v .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 sync-tos-link = Terms of Service

View file

@ -0,0 +1,28 @@
<!-- 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/. -->
<svg width="73px" height="73px" viewBox="0 0 73 73" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<!-- Generator: Sketch 46.2 (44496) - http://www.bohemiancoding.com/sketch -->
<defs>
<linearGradient x1="93.0928096%" y1="52.7734375%" x2="68.5133398%" y2="119.326007%" id="linearGradient-1">
<stop stop-color="#0A84FF" stop-opacity="0" offset="0%"></stop>
<stop stop-color="#0A84FF" offset="69.3698182%"></stop>
<stop stop-color="#0A84FF" offset="100%"></stop>
<stop stop-color="#2484C6" stop-opacity="0.00477766951" offset="100%"></stop>
<stop stop-color="#2484C6" stop-opacity="0" offset="100%"></stop>
<stop stop-color="#2484C6" stop-opacity="0" offset="100%"></stop>
</linearGradient>
<rect id="path-2" x="0" y="0" width="48" height="60"></rect>
</defs>
<g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="Shape" transform="translate(-5.000000, -1.000000)">
<path d="M41.8,73.8 C21.9,73.8 5.8,57.7 5.8,37.8 C5.8,18.1 21.6,2.2 41.1,1.8 C41.3,1.8 41.4,1.8 41.4,1.8 C41.5,1.8 41.7,1.8 41.8,1.8 C44.6,2.2 46.8,4.5 46.8,7.3 C46.8,10.1 44.6,12.5 41.8,12.7 C28,12.8 16.8,24 16.8,37.8 C16.8,51.6 28,62.8 41.8,62.8 C55.6,62.8 66.8,51.6 66.8,37.8 L77.8,37.8 C77.8,57.7 61.7,73.8 41.8,73.8 Z" fill="url(#linearGradient-1)"></path>
<mask id="mask-3" fill="white">
<use xlink:href="#path-2"></use>
</mask>
<g id="Mask"></g>
<path d="M41.8,73.8 C21.9,73.8 5.8,57.7 5.8,37.8 C5.8,18.1 21.6,2.2 41.1,1.8 C41.3,1.8 41.4,1.8 41.4,1.8 C41.5,1.8 41.7,1.8 41.8,1.8 C44.6,2.2 46.8,4.5 46.8,7.3 C46.8,10.1 44.6,12.5 41.8,12.7 C28,12.8 16.8,24 16.8,37.8 C16.8,51.6 28,62.8 41.8,62.8 C55.6,62.8 66.8,51.6 66.8,37.8 L77.8,37.8 C77.8,57.7 61.7,73.8 41.8,73.8 Z" fill="#0A84FF" mask="url(#mask-3)"></path>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

View file

@ -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);
}
}

View file

@ -109,12 +109,13 @@
#endif #endif
skin/classic/browser/panel-icon-retry.svg (../shared/panel-icon-retry.svg) 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/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-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/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/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/fxaPairDevice.css (../shared/incontentprefs/fxaPairDevice.css)
skin/classic/browser/preferences/in-content/logo-android.svg (../shared/incontentprefs/logo-android.svg) skin/classic/browser/preferences/in-content/general.svg (../shared/incontentprefs/general.svg)
skin/classic/browser/preferences/in-content/logo-ios.svg (../shared/incontentprefs/logo-ios.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-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/no-search-results.svg (../shared/incontentprefs/no-search-results.svg)
skin/classic/browser/preferences/in-content/privacy-security.svg (../shared/incontentprefs/privacy-security.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/in-content/containers.css (../shared/incontentprefs/containers.css)
* skin/classic/browser/preferences/containers.css (../shared/preferences/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/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.svg (../shared/fxa/sync-illustration.svg)
skin/classic/browser/fxa/sync-illustration-issue.svg (../shared/fxa/sync-illustration-issue.svg) skin/classic/browser/fxa/sync-illustration-issue.svg (../shared/fxa/sync-illustration-issue.svg)

View file

@ -10,7 +10,7 @@ const {Services} = ChromeUtils.import("resource://gre/modules/Services.jsm");
const {XPCOMUtils} = ChromeUtils.import("resource://gre/modules/XPCOMUtils.jsm"); const {XPCOMUtils} = ChromeUtils.import("resource://gre/modules/XPCOMUtils.jsm");
const {clearTimeout, setTimeout} = ChromeUtils.import("resource://gre/modules/Timer.jsm"); const {clearTimeout, setTimeout} = ChromeUtils.import("resource://gre/modules/Timer.jsm");
const {FxAccountsStorageManager} = ChromeUtils.import("resource://gre/modules/FxAccountsStorage.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", ChromeUtils.defineModuleGetter(this, "FxAccountsClient",
"resource://gre/modules/FxAccountsClient.jsm"); "resource://gre/modules/FxAccountsClient.jsm");
@ -48,9 +48,11 @@ var publicProperties = [
"getDeviceId", "getDeviceId",
"getDeviceList", "getDeviceList",
"getKeys", "getKeys",
"authorizeOAuthCode",
"getOAuthToken", "getOAuthToken",
"getProfileCache", "getProfileCache",
"getPushSubscription", "getPushSubscription",
"getScopedKeys",
"getSignedInUser", "getSignedInUser",
"getSignedInUserProfile", "getSignedInUserProfile",
"handleAccountDestroyed", "handleAccountDestroyed",
@ -418,6 +420,18 @@ FxAccountsInternal.prototype = {
return this._commands; 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. // A hook-point for tests who may want a mocked AccountState or mocked storage.
newAccountState(credentials) { newAccountState(credentials) {
let storage = new FxAccountsStorageManager(); 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() { getUserAccountData() {
return this.currentAccountState.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. // We are going to hit the server - this is the string we pass to it.
let scopeString = scope.join(" "); let scopeString = scope.join(" ");
let client = options.client; let client = options.client || this.oauthClient;
if (!client) {
try {
let defaultURL = Services.urlFormatter.formatURLPref("identity.fxaccounts.remote.oauth.uri");
client = new FxAccountsOAuthGrantClient({
serverURL: defaultURL,
client_id: FX_OAUTH_CLIENT_ID,
});
} catch (e) {
throw this._error(ERROR_INVALID_PARAMETER, e);
}
}
let oAuthURL = client.serverURL.href; let oAuthURL = client.serverURL.href;
try { 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<Object>} Object containing "code" and "state" properties.
*/
async authorizeOAuthCode(options) {
await this._getVerifiedAccountOrReject();
const client = this.oauthClient;
const oAuthURL = client.serverURL.href;
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(params.client_id, params.scope, jwk);
delete params.keys_jwk;
}
try {
const assertion = await this.getAssertion(oAuthURL);
return client.authorizeCodeFromAssertion(assertion, params);
} catch (err) {
throw this._errorToErrorClass(err);
}
},
/** /**
* Remove an OAuth token from the token cache. Callers should call this * 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 * after they determine a token is invalid, so a new token will be fetched

View file

@ -200,6 +200,32 @@ this.FxAccountsClient.prototype = {
); );
}, },
/**
* 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 * Destroy the current session with the Firefox Account API server and its
* associated device. * associated device.

View file

@ -78,17 +78,37 @@ exports.ON_NEW_DEVICE_ID = "fxaccounts:new_device_id";
exports.COMMAND_SENDTAB = "https://identity.mozilla.com/cmd/open-uri"; exports.COMMAND_SENDTAB = "https://identity.mozilla.com/cmd/open-uri";
// OAuth
exports.FX_OAUTH_CLIENT_ID = "5882386c6d801776";
exports.SCOPE_PROFILE = "profile";
exports.SCOPE_OLD_SYNC = "https://identity.mozilla.com/apps/oldsync";
// UI Requests. // UI Requests.
exports.UI_REQUEST_SIGN_IN_FLOW = "signInFlow"; exports.UI_REQUEST_SIGN_IN_FLOW = "signInFlow";
exports.UI_REQUEST_REFRESH_AUTH = "refreshAuthentication"; exports.UI_REQUEST_REFRESH_AUTH = "refreshAuthentication";
// The OAuth client ID for Firefox Desktop
exports.FX_OAUTH_CLIENT_ID = "5882386c6d801776";
// Firefox Accounts WebChannel ID // Firefox Accounts WebChannel ID
exports.WEBCHANNEL_ID = "account_updates"; exports.WEBCHANNEL_ID = "account_updates";
// WebChannel commands
exports.COMMAND_PAIR_HEARTBEAT = "fxaccounts:pair_heartbeat";
exports.COMMAND_PAIR_SUPP_METADATA = "fxaccounts:pair_supplicant_metadata";
exports.COMMAND_PAIR_AUTHORIZE = "fxaccounts:pair_authorize";
exports.COMMAND_PAIR_DECLINE = "fxaccounts:pair_decline";
exports.COMMAND_PAIR_COMPLETE = "fxaccounts:pair_complete";
exports.COMMAND_PROFILE_CHANGE = "profile:change";
exports.COMMAND_CAN_LINK_ACCOUNT = "fxaccounts:can_link_account";
exports.COMMAND_LOGIN = "fxaccounts:login";
exports.COMMAND_LOGOUT = "fxaccounts:logout";
exports.COMMAND_DELETE = "fxaccounts:delete";
exports.COMMAND_SYNC_PREFERENCES = "fxaccounts:sync_preferences";
exports.COMMAND_CHANGE_PASSWORD = "fxaccounts:change_password";
exports.COMMAND_FXA_STATUS = "fxaccounts:fxa_status";
exports.COMMAND_PAIR_PREFERENCES = "fxaccounts:pair_preferences";
exports.PREF_LAST_FXA_USER = "identity.fxaccounts.lastSignedInUserHash"; exports.PREF_LAST_FXA_USER = "identity.fxaccounts.lastSignedInUserHash";
exports.PREF_REMOTE_PAIRING_URI = "identity.fxaccounts.remote.pairing.uri";
// Server errno. // Server errno.
// From https://github.com/mozilla/fxa-auth-server/blob/master/docs/api.md#response-format // From https://github.com/mozilla/fxa-auth-server/blob/master/docs/api.md#response-format

View file

@ -29,20 +29,27 @@ const CONFIG_PREFS = [
"identity.fxaccounts.auth.uri", "identity.fxaccounts.auth.uri",
"identity.fxaccounts.remote.oauth.uri", "identity.fxaccounts.remote.oauth.uri",
"identity.fxaccounts.remote.profile.uri", "identity.fxaccounts.remote.profile.uri",
"identity.fxaccounts.remote.pairing.uri",
"identity.sync.tokenserver.uri", "identity.sync.tokenserver.uri",
]; ];
var FxAccountsConfig = { var FxAccountsConfig = {
async promiseSignUpURI(entrypoint) { async promiseSignUpURI(entrypoint) {
return this._buildURL("signup", {entrypoint}); return this._buildURL("signup", {
extraParams: {entrypoint},
});
}, },
async promiseSignInURI(entrypoint) { async promiseSignInURI(entrypoint) {
return this._buildURL("signin", {entrypoint}); return this._buildURL("signin", {
extraParams: {entrypoint},
});
}, },
async promiseEmailURI(email, entrypoint) { async promiseEmailURI(email, entrypoint) {
return this._buildURL("", {entrypoint, email}); return this._buildURL("", {
extraParams: {entrypoint, email},
});
}, },
async promiseEmailFirstURI(entrypoint) { async promiseEmailFirstURI(entrypoint) {
@ -50,23 +57,50 @@ var FxAccountsConfig = {
}, },
async promiseForceSigninURI(entrypoint) { async promiseForceSigninURI(entrypoint) {
return this._buildURL("force_auth", {entrypoint}, true); return this._buildURL("force_auth", {
extraParams: {entrypoint},
addAccountIdentifiers: true,
});
}, },
async promiseManageURI(entrypoint) { async promiseManageURI(entrypoint) {
return this._buildURL("settings", {entrypoint}, true); return this._buildURL("settings", {
extraParams: {entrypoint},
addAccountIdentifiers: true,
});
}, },
async promiseChangeAvatarURI(entrypoint) { async promiseChangeAvatarURI(entrypoint) {
return this._buildURL("settings/avatar/change", {entrypoint}, true); return this._buildURL("settings/avatar/change", {
extraParams: {entrypoint},
addAccountIdentifiers: true,
});
}, },
async promiseManageDevicesURI(entrypoint) { async promiseManageDevicesURI(entrypoint) {
return this._buildURL("settings/clients", {entrypoint}, true); return this._buildURL("settings/clients", {
extraParams: {entrypoint},
addAccountIdentifiers: true,
});
}, },
async promiseConnectDeviceURI(entrypoint) { async promiseConnectDeviceURI(entrypoint) {
return this._buildURL("connect_another_device", {entrypoint}, true); return this._buildURL("connect_another_device", {
extraParams: {entrypoint},
addAccountIdentifiers: true,
});
},
async promisePairingURI() {
return this._buildURL("pair", {
includeDefaultParams: false,
});
},
async promiseOAuthURI() {
return this._buildURL("oauth", {
includeDefaultParams: false,
});
}, },
get defaultParams() { get defaultParams() {
@ -75,21 +109,21 @@ var FxAccountsConfig = {
/** /**
* @param path should be parsable by the URL constructor first parameter. * @param path should be parsable by the URL constructor first parameter.
* @param {Object.<string, string>} [extraParams] Additionnal search params. * @param {bool} [options.includeDefaultParams] If true include the default search params.
* @param {bool} [addCredentials] if true we add the current logged-in user * @param {Object.<string, string>} [options.extraParams] Additionnal search params.
* uid and email to the 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, extraParams, addCredentials = false) { async _buildURL(path, {includeDefaultParams = true, extraParams = {}, addAccountIdentifiers = false}) {
await this.ensureConfigured(); await this.ensureConfigured();
const url = new URL(path, ROOT_URL); const url = new URL(path, ROOT_URL);
if (REQUIRES_HTTPS && url.protocol != "https:") { if (REQUIRES_HTTPS && url.protocol != "https:") {
throw new Error("Firefox Accounts server must use HTTPS"); throw new Error("Firefox Accounts server must use HTTPS");
} }
const params = {...this.defaultParams, ...extraParams}; const params = {...(includeDefaultParams ? this.defaultParams : null), ...extraParams};
for (let [k, v] of Object.entries(params)) { for (let [k, v] of Object.entries(params)) {
url.searchParams.append(k, v); url.searchParams.append(k, v);
} }
if (addCredentials) { if (addAccountIdentifiers) {
const accountData = await this.getSignedInUser(); const accountData = await this.getSignedInUser();
if (!accountData) { if (!accountData) {
return null; return null;
@ -207,6 +241,11 @@ var FxAccountsConfig = {
} }
Services.prefs.setCharPref("identity.fxaccounts.auth.uri", authServerBase); Services.prefs.setCharPref("identity.fxaccounts.auth.uri", authServerBase);
Services.prefs.setCharPref("identity.fxaccounts.remote.oauth.uri", config.oauth_server_base_url + "/v1"); Services.prefs.setCharPref("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.setCharPref("identity.fxaccounts.remote.pairing.uri", config.pairing_server_base_uri);
}
Services.prefs.setCharPref("identity.fxaccounts.remote.profile.uri", config.profile_server_base_url + "/v1"); Services.prefs.setCharPref("identity.fxaccounts.remote.profile.uri", config.profile_server_base_url + "/v1");
Services.prefs.setCharPref("identity.sync.tokenserver.uri", config.sync_tokenserver_base_url + "/1.0/sync/1.5"); Services.prefs.setCharPref("identity.sync.tokenserver.uri", config.sync_tokenserver_base_url + "/1.0/sync/1.5");
Services.prefs.setCharPref("identity.fxaccounts.remote.root", rootURL); Services.prefs.setCharPref("identity.fxaccounts.remote.root", rootURL);

View file

@ -80,6 +80,41 @@ this.FxAccountsOAuthGrantClient.prototype = {
return this._createRequest(AUTH_ENDPOINT, "POST", params); return this._createRequest(AUTH_ENDPOINT, "POST", params);
}, },
/**
* Retrieves an OAuth authorization code using an assertion
*
* @param {Object} assertion BrowserID assertion
* @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.
*/
authorizeCodeFromAssertion(assertion, options) {
if (!assertion) {
throw new Error("Missing 'assertion' parameter");
}
const {client_id, state, scope, access_type, code_challenge, code_challenge_method, keys_jwe} = options;
const params = {
assertion,
client_id,
response_type: "code",
state,
scope,
access_type,
code_challenge,
code_challenge_method,
};
if (keys_jwe) {
params.keys_jwe = keys_jwe;
}
return this._createRequest(AUTH_ENDPOINT, "POST", params);
},
/** /**
* Destroys a previously fetched OAuth access token. * Destroys a previously fetched OAuth access token.
* *

View file

@ -0,0 +1,340 @@
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
"use strict";
const {Services} = ChromeUtils.import("resource://gre/modules/Services.jsm");
const {log, PREF_REMOTE_PAIRING_URI, COMMAND_PAIR_SUPP_METADATA, COMMAND_PAIR_AUTHORIZE, COMMAND_PAIR_DECLINE, COMMAND_PAIR_HEARTBEAT, COMMAND_PAIR_COMPLETE} = ChromeUtils.import("resource://gre/modules/FxAccountsCommon.js");
const {fxAccounts, FxAccounts} = ChromeUtils.import("resource://gre/modules/FxAccounts.jsm");
const {setTimeout, clearTimeout} = ChromeUtils.import("resource://gre/modules/Timer.jsm");
ChromeUtils.import("resource://services-common/utils.js");
ChromeUtils.defineModuleGetter(this, "Weave", "resource://services-sync/main.js");
ChromeUtils.defineModuleGetter(this, "FxAccountsPairingChannel", "resource://gre/modules/FxAccountsPairingChannel.js");
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();
this.FxAccountsPairingFlow = 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 || 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 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._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._fxa.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.getSignedInUserProfile();
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();
}
}
};
const EXPORTED_SYMBOLS = ["FxAccountsPairingFlow"];

File diff suppressed because it is too large Load diff

View file

@ -9,7 +9,7 @@
var EXPORTED_SYMBOLS = ["FxAccountsProfileClient", "FxAccountsProfileClientError"]; var EXPORTED_SYMBOLS = ["FxAccountsProfileClient", "FxAccountsProfileClientError"];
const {ERRNO_NETWORK, ERRNO_PARSE, ERRNO_UNKNOWN_ERROR, ERROR_CODE_METHOD_NOT_ALLOWED, ERROR_MSG_METHOD_NOT_ALLOWED, ERROR_NETWORK, ERROR_PARSE, ERROR_UNKNOWN, log} = ChromeUtils.import("resource://gre/modules/FxAccountsCommon.js"); const {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} = ChromeUtils.import("resource://gre/modules/FxAccountsCommon.js");
const {fxAccounts} = ChromeUtils.import("resource://gre/modules/FxAccounts.jsm"); const {fxAccounts} = ChromeUtils.import("resource://gre/modules/FxAccounts.jsm");
const {RESTRequest} = ChromeUtils.import("resource://services-common/rest.js"); const {RESTRequest} = ChromeUtils.import("resource://services-common/rest.js");
const {XPCOMUtils} = ChromeUtils.import("resource://gre/modules/XPCOMUtils.jsm"); const {XPCOMUtils} = ChromeUtils.import("resource://gre/modules/XPCOMUtils.jsm");
@ -47,7 +47,7 @@ var FxAccountsProfileClient = function(options) {
throw new Error("Invalid 'serverURL'"); throw new Error("Invalid 'serverURL'");
} }
this.oauthOptions = { this.oauthOptions = {
scope: "profile", scope: SCOPE_PROFILE,
}; };
log.debug("FxAccountsProfileClient: Initialized"); log.debug("FxAccountsProfileClient: Initialized");
}; };

View file

@ -13,7 +13,7 @@
var EXPORTED_SYMBOLS = ["EnsureFxAccountsWebChannel"]; var EXPORTED_SYMBOLS = ["EnsureFxAccountsWebChannel"];
const {XPCOMUtils} = ChromeUtils.import("resource://gre/modules/XPCOMUtils.jsm"); const {XPCOMUtils} = ChromeUtils.import("resource://gre/modules/XPCOMUtils.jsm");
const {ON_PROFILE_CHANGE_NOTIFICATION, PREF_LAST_FXA_USER, WEBCHANNEL_ID, log, logPII} = ChromeUtils.import("resource://gre/modules/FxAccountsCommon.js"); const {COMMAND_PROFILE_CHANGE, COMMAND_LOGIN, COMMAND_LOGOUT, 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, ON_PROFILE_CHANGE_NOTIFICATION, PREF_LAST_FXA_USER, WEBCHANNEL_ID, log, logPII} = ChromeUtils.import("resource://gre/modules/FxAccountsCommon.js");
ChromeUtils.defineModuleGetter(this, "Services", ChromeUtils.defineModuleGetter(this, "Services",
"resource://gre/modules/Services.jsm"); "resource://gre/modules/Services.jsm");
@ -29,15 +29,10 @@ ChromeUtils.defineModuleGetter(this, "Weave",
"resource://services-sync/main.js"); "resource://services-sync/main.js");
ChromeUtils.defineModuleGetter(this, "CryptoUtils", ChromeUtils.defineModuleGetter(this, "CryptoUtils",
"resource://services-crypto/utils.js"); "resource://services-crypto/utils.js");
ChromeUtils.defineModuleGetter(this, "FxAccountsPairingFlow",
const COMMAND_PROFILE_CHANGE = "profile:change"; "resource://gre/modules/FxAccountsPairing.jsm");
const COMMAND_CAN_LINK_ACCOUNT = "fxaccounts:can_link_account"; XPCOMUtils.defineLazyPreferenceGetter(this, "pairingEnabled",
const COMMAND_LOGIN = "fxaccounts:login"; "identity.fxaccounts.pairing.enabled");
const COMMAND_LOGOUT = "fxaccounts:logout";
const COMMAND_DELETE = "fxaccounts:delete";
const COMMAND_SYNC_PREFERENCES = "fxaccounts:sync_preferences";
const COMMAND_CHANGE_PASSWORD = "fxaccounts:change_password";
const COMMAND_FXA_STATUS = "fxaccounts:fxa_status";
// These engines were added years after Sync had been introduced, they need // 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 // special handling since they are system add-ons and are un-available on
@ -145,8 +140,7 @@ this.FxAccountsWebChannel.prototype = {
}, },
_receiveMessage(message, sendingContext) { _receiveMessage(message, sendingContext) {
let command = message.command; const {command, data} = message;
let data = message.data;
switch (command) { switch (command) {
case COMMAND_PROFILE_CHANGE: case COMMAND_PROFILE_CHANGE:
@ -176,6 +170,13 @@ this.FxAccountsWebChannel.prototype = {
case COMMAND_SYNC_PREFERENCES: case COMMAND_SYNC_PREFERENCES:
this._helpers.openSyncPreferences(sendingContext.browser, data.entryPoint); this._helpers.openSyncPreferences(sendingContext.browser, data.entryPoint);
break; break;
case COMMAND_PAIR_PREFERENCES:
if (pairingEnabled) {
sendingContext.browser.loadURI("about:preferences?action=pair#sync", {
triggeringPrincipal: Services.scriptSecurityManager.getSystemPrincipal(),
});
}
break;
case COMMAND_CHANGE_PASSWORD: case COMMAND_CHANGE_PASSWORD:
this._helpers.changePassword(data).catch(error => this._helpers.changePassword(data).catch(error =>
this._sendError(error, message, sendingContext)); this._sendError(error, message, sendingContext));
@ -196,8 +197,31 @@ this.FxAccountsWebChannel.prototype = {
this._sendError(error, message, sendingContext) this._sendError(error, message, sendingContext)
); );
break; 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 = 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: default:
log.warn("Unrecognized FxAccountsWebChannel command", command); log.warn("Unrecognized FxAccountsWebChannel command", command);
// As a safety measure we also terminate any pending FxA pairing flow.
FxAccountsPairingFlow.finalizeAll();
break; break;
} }
}, },
@ -400,6 +424,7 @@ this.FxAccountsWebChannelHelpers.prototype = {
return { return {
signedInUser, signedInUser,
capabilities: { capabilities: {
pairing: pairingEnabled,
engines: this._getAvailableExtraEngines(), engines: this._getAvailableExtraEngines(),
}, },
}; };
@ -479,7 +504,9 @@ this.FxAccountsWebChannelHelpers.prototype = {
} }
uri += "#sync"; uri += "#sync";
browser.loadURI(uri); browser.loadURI(uri, {
triggeringPrincipal: Services.scriptSecurityManager.getSystemPrincipal(),
});
}, },
/** /**

View file

@ -23,6 +23,8 @@ EXTRA_JS_MODULES += [
'FxAccountsCommon.js', 'FxAccountsCommon.js',
'FxAccountsConfig.jsm', 'FxAccountsConfig.jsm',
'FxAccountsOAuthGrantClient.jsm', 'FxAccountsOAuthGrantClient.jsm',
'FxAccountsPairing.jsm',
'FxAccountsPairingChannel.js',
'FxAccountsProfile.jsm', 'FxAccountsProfile.jsm',
'FxAccountsProfileClient.jsm', 'FxAccountsProfileClient.jsm',
'FxAccountsPush.jsm', 'FxAccountsPush.jsm',

View file

@ -5,7 +5,7 @@
const {FxAccounts} = ChromeUtils.import("resource://gre/modules/FxAccounts.jsm"); const {FxAccounts} = ChromeUtils.import("resource://gre/modules/FxAccounts.jsm");
const {FxAccountsClient} = ChromeUtils.import("resource://gre/modules/FxAccountsClient.jsm"); const {FxAccountsClient} = ChromeUtils.import("resource://gre/modules/FxAccountsClient.jsm");
const {ASSERTION_LIFETIME, CERT_LIFETIME, ERRNO_INVALID_AUTH_TOKEN, ERRNO_INVALID_FXA_ASSERTION, ERRNO_NETWORK, ERROR_INVALID_FXA_ASSERTION, ERROR_NETWORK, KEY_LIFETIME, ONLOGIN_NOTIFICATION, ONLOGOUT_NOTIFICATION, ONVERIFIED_NOTIFICATION} = ChromeUtils.import("resource://gre/modules/FxAccountsCommon.js"); const {ASSERTION_LIFETIME, CERT_LIFETIME, ERRNO_INVALID_AUTH_TOKEN, ERRNO_INVALID_FXA_ASSERTION, ERRNO_NETWORK, ERROR_INVALID_FXA_ASSERTION, ERROR_NETWORK, KEY_LIFETIME, ONLOGIN_NOTIFICATION, ONLOGOUT_NOTIFICATION, ONVERIFIED_NOTIFICATION, SCOPE_OLD_SYNC} = ChromeUtils.import("resource://gre/modules/FxAccountsCommon.js");
const {FxAccountsOAuthGrantClient, FxAccountsOAuthGrantClientError} = ChromeUtils.import("resource://gre/modules/FxAccountsOAuthGrantClient.jsm"); const {FxAccountsOAuthGrantClient, FxAccountsOAuthGrantClientError} = ChromeUtils.import("resource://gre/modules/FxAccountsOAuthGrantClient.jsm");
const {PromiseUtils} = ChromeUtils.import("resource://gre/modules/PromiseUtils.jsm"); const {PromiseUtils} = ChromeUtils.import("resource://gre/modules/PromiseUtils.jsm");
@ -739,6 +739,69 @@ add_task(async function test_getKeys_invalid_token() {
Assert.equal(user.keyFetchToken, null); Assert.equal(user.keyFetchToken, null);
await fxa.internal.abortExistingFlow(); await fxa.internal.abortExistingFlow();
}); });
// This is the exact same test vectors as
// https://github.com/mozilla/fxa-crypto-relier/blob/f94f441159029a645a474d4b6439c38308da0bb0/test/deriver/ScopedKeys.js#L58
add_task(async function test_getScopedKeys_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,
},
});
let user = {
...getTestUser("eusebius"),
uid: "aeaa1725c7a24ff983c6295725d5fc9b",
verified: true,
kSync: "0d6fe59791b05fa489e463ea25502e3143f6b7a903aa152e95cd9c6eddbac5b4dc68a19097ef65dbd147010ee45222444e66b8b3d7c8a441ebb7dd3dce015a9e",
kXCS: "22a42fe289dced5715135913424cb23b",
kExtSync: "baded53eb3587d7900e604e8a68d860abf9de30b5c955d3c4d5dba63f26fd88265cd85923f6e9dcd16aef3b82bc88039a89c59ecd9e88de09a7418c7d94f90c9",
kExtKbHash: "b776a89db29f22daedd154b44ff88397d0b210223fb956f5a749521dd8de8ddf",
};
await fxa.setSignedInUser(user);
const keys = await fxa.internal.getScopedKeys(`${SCOPE_OLD_SYNC} profile`, "123456789a");
Assert.deepEqual(keys, {
[SCOPE_OLD_SYNC]: {
"scope": SCOPE_OLD_SYNC,
"kid": "1510726317123-IqQv4onc7VcVE1kTQkyyOw",
"k": "DW_ll5GwX6SJ5GPqJVAuMUP2t6kDqhUulc2cbt26xbTcaKGQl-9l29FHAQ7kUiJETma4s9fIpEHrt909zgFang",
"kty": "oct",
},
});
});
add_task(async function test_getScopedKeys_unavailable_key() {
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,
},
"otherkeybearingscope": {
"identifier": "otherkeybearingscope",
"keyRotationSecret": "0000000000000000000000000000000000000000000000000000000000000000",
"keyRotationTimestamp": 1510726331712,
},
});
let user = {
...getTestUser("eusebius"),
uid: "aeaa1725c7a24ff983c6295725d5fc9b",
verified: true,
kSync: "0d6fe59791b05fa489e463ea25502e3143f6b7a903aa152e95cd9c6eddbac5b4dc68a19097ef65dbd147010ee45222444e66b8b3d7c8a441ebb7dd3dce015a9e",
kXCS: "22a42fe289dced5715135913424cb23b",
kExtSync: "baded53eb3587d7900e604e8a68d860abf9de30b5c955d3c4d5dba63f26fd88265cd85923f6e9dcd16aef3b82bc88039a89c59ecd9e88de09a7418c7d94f90c9",
kExtKbHash: "b776a89db29f22daedd154b44ff88397d0b210223fb956f5a749521dd8de8ddf",
};
await fxa.setSignedInUser(user);
await Assert.rejects(fxa.internal.getScopedKeys(`${SCOPE_OLD_SYNC} otherkeybearingscope profile`, "123456789a"),
/Unavailable key material for otherkeybearingscope/);
});
// fetchAndUnwrapKeys with no keyFetchToken should trigger signOut // fetchAndUnwrapKeys with no keyFetchToken should trigger signOut
add_test(function test_fetchAndUnwrapKeys_no_token() { add_test(function test_fetchAndUnwrapKeys_no_token() {
let fxa = new MockFxAccounts(); let fxa = new MockFxAccounts();

View file

@ -0,0 +1,244 @@
/* Any copyright is dedicated to the Public Domain.
* http://creativecommons.org/publicdomain/zero/1.0/ */
"use strict";
const {FxAccountsPairingFlow} = ChromeUtils.import("resource://gre/modules/FxAccountsPairing.jsm", {});
const {EventEmitter} = ChromeUtils.import("resource://gre/modules/EventEmitter.jsm", {});
const {PromiseUtils} = ChromeUtils.import("resource://gre/modules/PromiseUtils.jsm", {});
const {CryptoUtils} = ChromeUtils.import("resource://services-crypto/utils.js", {});
XPCOMUtils.defineLazyModuleGetters(this, {
jwcrypto: "resource://services-crypto/jwcrypto.jsm",
});
XPCOMUtils.defineLazyGlobalGetters(this, ["URL", "crypto"]);
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 fxaConfig = {
promisePairingURI() { return PAIR_URI; },
promiseOAuthURI() { return OAUTH_URI; },
};
const fxAccounts = {
getScopedKeys(scope) {
return {
[scope]: {
kid: "123456",
k: KSYNC,
kty: "oct",
},
};
},
authorizeOAuthCode() {
return {code: "mycode", state: "mystate"};
},
getSignedInUserProfile() {
return {
uid: UID,
email: EMAIL,
avatar: AVATAR,
displayName: DISPLAY_NAME,
};
},
};
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 authorizeOAuthCode = sinon.spy(fxAccounts, "authorizeOAuthCode");
const promiseOAuthParamsMsg = promiseOutgoingMessage(pairingChannel);
await simulateIncomingWebChannel(flow, "fxaccounts:pair_authorize");
Assert.ok(authorizeOAuthCode.calledOnce);
const oauthCodeArgs = authorizeOAuthCode.firstCall.args[0];
Assert.equal(oauthCodeArgs.keys_jwk, ChromeUtils.base64URLEncode(new TextEncoder().encode(JSON.stringify(epk.publicJWK)), {pad: false}));
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);
fxAccounts.authorizeOAuthCode.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,
};
}

View file

@ -16,6 +16,7 @@ support-files =
[test_oauth_grant_client_server.js] [test_oauth_grant_client_server.js]
[test_oauth_tokens.js] [test_oauth_tokens.js]
[test_oauth_token_storage.js] [test_oauth_token_storage.js]
[test_pairing.js]
[test_profile_client.js] [test_profile_client.js]
[test_push_service.js] [test_push_service.js]
[test_web_channel.js] [test_web_channel.js]

View file

@ -86,7 +86,7 @@
"fxaccounts.jsm": ["Authentication"], "fxaccounts.jsm": ["Authentication"],
"FxAccounts.jsm": ["fxAccounts", "FxAccounts"], "FxAccounts.jsm": ["fxAccounts", "FxAccounts"],
"FxAccountsCommands.js": ["SendTab", "FxAccountsCommands"], "FxAccountsCommands.js": ["SendTab", "FxAccountsCommands"],
"FxAccountsCommon.js": ["log", "logPII", "FXACCOUNTS_PERMISSION", "DATA_FORMAT_VERSION", "DEFAULT_STORAGE_FILENAME", "ASSERTION_LIFETIME", "ASSERTION_USE_PERIOD", "CERT_LIFETIME", "KEY_LIFETIME", "POLL_SESSION", "ONLOGIN_NOTIFICATION", "ONVERIFIED_NOTIFICATION", "ONLOGOUT_NOTIFICATION", "ON_COMMAND_RECEIVED_NOTIFICATION", "ON_DEVICE_CONNECTED_NOTIFICATION", "ON_DEVICE_DISCONNECTED_NOTIFICATION", "ON_PROFILE_UPDATED_NOTIFICATION", "ON_PASSWORD_CHANGED_NOTIFICATION", "ON_PASSWORD_RESET_NOTIFICATION", "ON_VERIFY_LOGIN_NOTIFICATION", "ON_ACCOUNT_DESTROYED_NOTIFICATION", "ON_COLLECTION_CHANGED_NOTIFICATION", "FXA_PUSH_SCOPE_ACCOUNT_UPDATE", "ON_PROFILE_CHANGE_NOTIFICATION", "ON_ACCOUNT_STATE_CHANGE_NOTIFICATION", "ON_NEW_DEVICE_ID", "COMMAND_SENDTAB", "UI_REQUEST_SIGN_IN_FLOW", "UI_REQUEST_REFRESH_AUTH", "FX_OAUTH_CLIENT_ID", "WEBCHANNEL_ID", "PREF_LAST_FXA_USER", "ERRNO_ACCOUNT_ALREADY_EXISTS", "ERRNO_ACCOUNT_DOES_NOT_EXIST", "ERRNO_INCORRECT_PASSWORD", "ERRNO_UNVERIFIED_ACCOUNT", "ERRNO_INVALID_VERIFICATION_CODE", "ERRNO_NOT_VALID_JSON_BODY", "ERRNO_INVALID_BODY_PARAMETERS", "ERRNO_MISSING_BODY_PARAMETERS", "ERRNO_INVALID_REQUEST_SIGNATURE", "ERRNO_INVALID_AUTH_TOKEN", "ERRNO_INVALID_AUTH_TIMESTAMP", "ERRNO_MISSING_CONTENT_LENGTH", "ERRNO_REQUEST_BODY_TOO_LARGE", "ERRNO_TOO_MANY_CLIENT_REQUESTS", "ERRNO_INVALID_AUTH_NONCE", "ERRNO_ENDPOINT_NO_LONGER_SUPPORTED", "ERRNO_INCORRECT_LOGIN_METHOD", "ERRNO_INCORRECT_KEY_RETRIEVAL_METHOD", "ERRNO_INCORRECT_API_VERSION", "ERRNO_INCORRECT_EMAIL_CASE", "ERRNO_ACCOUNT_LOCKED", "ERRNO_ACCOUNT_UNLOCKED", "ERRNO_UNKNOWN_DEVICE", "ERRNO_DEVICE_SESSION_CONFLICT", "ERRNO_SERVICE_TEMP_UNAVAILABLE", "ERRNO_PARSE", "ERRNO_NETWORK", "ERRNO_UNKNOWN_ERROR", "OAUTH_SERVER_ERRNO_OFFSET", "ERRNO_UNKNOWN_CLIENT_ID", "ERRNO_INCORRECT_CLIENT_SECRET", "ERRNO_INCORRECT_REDIRECT_URI", "ERRNO_INVALID_FXA_ASSERTION", "ERRNO_UNKNOWN_CODE", "ERRNO_INCORRECT_CODE", "ERRNO_EXPIRED_CODE", "ERRNO_OAUTH_INVALID_TOKEN", "ERRNO_INVALID_REQUEST_PARAM", "ERRNO_INVALID_RESPONSE_TYPE", "ERRNO_UNAUTHORIZED", "ERRNO_FORBIDDEN", "ERRNO_INVALID_CONTENT_TYPE", "ERROR_ACCOUNT_ALREADY_EXISTS", "ERROR_ACCOUNT_DOES_NOT_EXIST", "ERROR_ACCOUNT_LOCKED", "ERROR_ACCOUNT_UNLOCKED", "ERROR_ALREADY_SIGNED_IN_USER", "ERROR_DEVICE_SESSION_CONFLICT", "ERROR_ENDPOINT_NO_LONGER_SUPPORTED", "ERROR_INCORRECT_API_VERSION", "ERROR_INCORRECT_EMAIL_CASE", "ERROR_INCORRECT_KEY_RETRIEVAL_METHOD", "ERROR_INCORRECT_LOGIN_METHOD", "ERROR_INVALID_EMAIL", "ERROR_INVALID_AUDIENCE", "ERROR_INVALID_AUTH_TOKEN", "ERROR_INVALID_AUTH_TIMESTAMP", "ERROR_INVALID_AUTH_NONCE", "ERROR_INVALID_BODY_PARAMETERS", "ERROR_INVALID_PASSWORD", "ERROR_INVALID_VERIFICATION_CODE", "ERROR_INVALID_REFRESH_AUTH_VALUE", "ERROR_INVALID_REQUEST_SIGNATURE", "ERROR_INTERNAL_INVALID_USER", "ERROR_MISSING_BODY_PARAMETERS", "ERROR_MISSING_CONTENT_LENGTH", "ERROR_NO_TOKEN_SESSION", "ERROR_NO_SILENT_REFRESH_AUTH", "ERROR_NOT_VALID_JSON_BODY", "ERROR_OFFLINE", "ERROR_PERMISSION_DENIED", "ERROR_REQUEST_BODY_TOO_LARGE", "ERROR_SERVER_ERROR", "ERROR_SYNC_DISABLED", "ERROR_TOO_MANY_CLIENT_REQUESTS", "ERROR_SERVICE_TEMP_UNAVAILABLE", "ERROR_UI_ERROR", "ERROR_UI_REQUEST", "ERROR_PARSE", "ERROR_NETWORK", "ERROR_UNKNOWN", "ERROR_UNKNOWN_DEVICE", "ERROR_UNVERIFIED_ACCOUNT", "ERROR_UNKNOWN_CLIENT_ID", "ERROR_INCORRECT_CLIENT_SECRET", "ERROR_INCORRECT_REDIRECT_URI", "ERROR_INVALID_FXA_ASSERTION", "ERROR_UNKNOWN_CODE", "ERROR_INCORRECT_CODE", "ERROR_EXPIRED_CODE", "ERROR_OAUTH_INVALID_TOKEN", "ERROR_INVALID_REQUEST_PARAM", "ERROR_INVALID_RESPONSE_TYPE", "ERROR_UNAUTHORIZED", "ERROR_FORBIDDEN", "ERROR_INVALID_CONTENT_TYPE", "ERROR_NO_ACCOUNT", "ERROR_AUTH_ERROR", "ERROR_INVALID_PARAMETER", "ERROR_CODE_METHOD_NOT_ALLOWED", "ERROR_MSG_METHOD_NOT_ALLOWED", "DERIVED_KEYS_NAMES", "FXA_PWDMGR_PLAINTEXT_FIELDS", "FXA_PWDMGR_SECURE_FIELDS", "FXA_PWDMGR_MEMORY_FIELDS", "FXA_PWDMGR_REAUTH_WHITELIST", "FXA_PWDMGR_HOST", "FXA_PWDMGR_REALM", "SERVER_ERRNO_TO_ERROR", "ERROR_TO_GENERAL_ERROR_CLASS"], "FxAccountsCommon.js": ["log", "logPII", "FXACCOUNTS_PERMISSION", "DATA_FORMAT_VERSION", "DEFAULT_STORAGE_FILENAME", "ASSERTION_LIFETIME", "ASSERTION_USE_PERIOD", "CERT_LIFETIME", "KEY_LIFETIME", "POLL_SESSION", "ONLOGIN_NOTIFICATION", "ONVERIFIED_NOTIFICATION", "ONLOGOUT_NOTIFICATION", "ON_COMMAND_RECEIVED_NOTIFICATION", "ON_DEVICE_CONNECTED_NOTIFICATION", "ON_DEVICE_DISCONNECTED_NOTIFICATION", "ON_PROFILE_UPDATED_NOTIFICATION", "ON_PASSWORD_CHANGED_NOTIFICATION", "ON_PASSWORD_RESET_NOTIFICATION", "ON_VERIFY_LOGIN_NOTIFICATION", "ON_ACCOUNT_DESTROYED_NOTIFICATION", "ON_COLLECTION_CHANGED_NOTIFICATION", "FXA_PUSH_SCOPE_ACCOUNT_UPDATE", "ON_PROFILE_CHANGE_NOTIFICATION", "ON_ACCOUNT_STATE_CHANGE_NOTIFICATION", "ON_NEW_DEVICE_ID", "COMMAND_SENDTAB", "SCOPE_PROFILE", "SCOPE_OLD_SYNC", "UI_REQUEST_SIGN_IN_FLOW", "UI_REQUEST_REFRESH_AUTH", "FX_OAUTH_CLIENT_ID", "WEBCHANNEL_ID", "COMMAND_PAIR_HEARTBEAT", "COMMAND_PAIR_SUPP_METADATA", "COMMAND_PAIR_AUTHORIZE", "COMMAND_PAIR_DECLINE", "COMMAND_PAIR_COMPLETE", "COMMAND_PAIR_PREFERENCES", "COMMAND_PROFILE_CHANGE", "COMMAND_CAN_LINK_ACCOUNT", "COMMAND_LOGIN", "COMMAND_LOGOUT", "COMMAND_DELETE", "COMMAND_SYNC_PREFERENCES", "COMMAND_CHANGE_PASSWORD", "COMMAND_FXA_STATUS", "PREF_LAST_FXA_USER", "PREF_REMOTE_PAIRING_URI", "ERRNO_ACCOUNT_ALREADY_EXISTS", "ERRNO_ACCOUNT_DOES_NOT_EXIST", "ERRNO_INCORRECT_PASSWORD", "ERRNO_UNVERIFIED_ACCOUNT", "ERRNO_INVALID_VERIFICATION_CODE", "ERRNO_NOT_VALID_JSON_BODY", "ERRNO_INVALID_BODY_PARAMETERS", "ERRNO_MISSING_BODY_PARAMETERS", "ERRNO_INVALID_REQUEST_SIGNATURE", "ERRNO_INVALID_AUTH_TOKEN", "ERRNO_INVALID_AUTH_TIMESTAMP", "ERRNO_MISSING_CONTENT_LENGTH", "ERRNO_REQUEST_BODY_TOO_LARGE", "ERRNO_TOO_MANY_CLIENT_REQUESTS", "ERRNO_INVALID_AUTH_NONCE", "ERRNO_ENDPOINT_NO_LONGER_SUPPORTED", "ERRNO_INCORRECT_LOGIN_METHOD", "ERRNO_INCORRECT_KEY_RETRIEVAL_METHOD", "ERRNO_INCORRECT_API_VERSION", "ERRNO_INCORRECT_EMAIL_CASE", "ERRNO_ACCOUNT_LOCKED", "ERRNO_ACCOUNT_UNLOCKED", "ERRNO_UNKNOWN_DEVICE", "ERRNO_DEVICE_SESSION_CONFLICT", "ERRNO_SERVICE_TEMP_UNAVAILABLE", "ERRNO_PARSE", "ERRNO_NETWORK", "ERRNO_UNKNOWN_ERROR", "OAUTH_SERVER_ERRNO_OFFSET", "ERRNO_UNKNOWN_CLIENT_ID", "ERRNO_INCORRECT_CLIENT_SECRET", "ERRNO_INCORRECT_REDIRECT_URI", "ERRNO_INVALID_FXA_ASSERTION", "ERRNO_UNKNOWN_CODE", "ERRNO_INCORRECT_CODE", "ERRNO_EXPIRED_CODE", "ERRNO_OAUTH_INVALID_TOKEN", "ERRNO_INVALID_REQUEST_PARAM", "ERRNO_INVALID_RESPONSE_TYPE", "ERRNO_UNAUTHORIZED", "ERRNO_FORBIDDEN", "ERRNO_INVALID_CONTENT_TYPE", "ERROR_ACCOUNT_ALREADY_EXISTS", "ERROR_ACCOUNT_DOES_NOT_EXIST", "ERROR_ACCOUNT_LOCKED", "ERROR_ACCOUNT_UNLOCKED", "ERROR_ALREADY_SIGNED_IN_USER", "ERROR_DEVICE_SESSION_CONFLICT", "ERROR_ENDPOINT_NO_LONGER_SUPPORTED", "ERROR_INCORRECT_API_VERSION", "ERROR_INCORRECT_EMAIL_CASE", "ERROR_INCORRECT_KEY_RETRIEVAL_METHOD", "ERROR_INCORRECT_LOGIN_METHOD", "ERROR_INVALID_EMAIL", "ERROR_INVALID_AUDIENCE", "ERROR_INVALID_AUTH_TOKEN", "ERROR_INVALID_AUTH_TIMESTAMP", "ERROR_INVALID_AUTH_NONCE", "ERROR_INVALID_BODY_PARAMETERS", "ERROR_INVALID_PASSWORD", "ERROR_INVALID_VERIFICATION_CODE", "ERROR_INVALID_REFRESH_AUTH_VALUE", "ERROR_INVALID_REQUEST_SIGNATURE", "ERROR_INTERNAL_INVALID_USER", "ERROR_MISSING_BODY_PARAMETERS", "ERROR_MISSING_CONTENT_LENGTH", "ERROR_NO_TOKEN_SESSION", "ERROR_NO_SILENT_REFRESH_AUTH", "ERROR_NOT_VALID_JSON_BODY", "ERROR_OFFLINE", "ERROR_PERMISSION_DENIED", "ERROR_REQUEST_BODY_TOO_LARGE", "ERROR_SERVER_ERROR", "ERROR_SYNC_DISABLED", "ERROR_TOO_MANY_CLIENT_REQUESTS", "ERROR_SERVICE_TEMP_UNAVAILABLE", "ERROR_UI_ERROR", "ERROR_UI_REQUEST", "ERROR_PARSE", "ERROR_NETWORK", "ERROR_UNKNOWN", "ERROR_UNKNOWN_DEVICE", "ERROR_UNVERIFIED_ACCOUNT", "ERROR_UNKNOWN_CLIENT_ID", "ERROR_INCORRECT_CLIENT_SECRET", "ERROR_INCORRECT_REDIRECT_URI", "ERROR_INVALID_FXA_ASSERTION", "ERROR_UNKNOWN_CODE", "ERROR_INCORRECT_CODE", "ERROR_EXPIRED_CODE", "ERROR_OAUTH_INVALID_TOKEN", "ERROR_INVALID_REQUEST_PARAM", "ERROR_INVALID_RESPONSE_TYPE", "ERROR_UNAUTHORIZED", "ERROR_FORBIDDEN", "ERROR_INVALID_CONTENT_TYPE", "ERROR_NO_ACCOUNT", "ERROR_AUTH_ERROR", "ERROR_INVALID_PARAMETER", "ERROR_CODE_METHOD_NOT_ALLOWED", "ERROR_MSG_METHOD_NOT_ALLOWED", "DERIVED_KEYS_NAMES", "FXA_PWDMGR_PLAINTEXT_FIELDS", "FXA_PWDMGR_SECURE_FIELDS", "FXA_PWDMGR_MEMORY_FIELDS", "FXA_PWDMGR_REAUTH_WHITELIST", "FXA_PWDMGR_HOST", "FXA_PWDMGR_REALM", "SERVER_ERRNO_TO_ERROR", "ERROR_TO_GENERAL_ERROR_CLASS"],
"FxAccountsOAuthGrantClient.jsm": ["FxAccountsOAuthGrantClient", "FxAccountsOAuthGrantClientError"], "FxAccountsOAuthGrantClient.jsm": ["FxAccountsOAuthGrantClient", "FxAccountsOAuthGrantClientError"],
"FxAccountsProfileClient.jsm": ["FxAccountsProfileClient", "FxAccountsProfileClientError"], "FxAccountsProfileClient.jsm": ["FxAccountsProfileClient", "FxAccountsProfileClientError"],
"FxAccountsPush.js": ["FxAccountsPushService"], "FxAccountsPush.js": ["FxAccountsPushService"],