summaryrefslogtreecommitdiffstats
path: root/toolkit/components/passwordmgr/FirefoxRelay.jsm
diff options
context:
space:
mode:
Diffstat (limited to 'toolkit/components/passwordmgr/FirefoxRelay.jsm')
-rw-r--r--toolkit/components/passwordmgr/FirefoxRelay.jsm531
1 files changed, 531 insertions, 0 deletions
diff --git a/toolkit/components/passwordmgr/FirefoxRelay.jsm b/toolkit/components/passwordmgr/FirefoxRelay.jsm
new file mode 100644
index 0000000000..a0beded4a0
--- /dev/null
+++ b/toolkit/components/passwordmgr/FirefoxRelay.jsm
@@ -0,0 +1,531 @@
+/* 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 EXPORTED_SYMBOLS = ["FirefoxRelay"];
+
+const { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+const {
+ LoginHelper,
+ OptInFeature,
+ ParentAutocompleteOption,
+} = ChromeUtils.import("resource://gre/modules/LoginHelper.jsm");
+
+const lazy = {};
+
+// Static configuration
+const config = (function() {
+ const baseUrl = Services.prefs.getStringPref(
+ "signon.firefoxRelay.base_url",
+ undefined
+ );
+ return {
+ scope: ["https://identity.mozilla.com/apps/relay"],
+ addressesUrl: baseUrl + `relayaddresses/`,
+ profilesUrl: baseUrl + `profiles/`,
+ learnMoreURL: Services.prefs.getStringPref(
+ "signon.firefoxRelay.learn_more_url",
+ undefined
+ ),
+ relayFeaturePref: "signon.firefoxRelay.feature",
+ };
+})();
+
+XPCOMUtils.defineLazyGetter(lazy, "log", () =>
+ LoginHelper.createLogger("FirefoxRelay")
+);
+XPCOMUtils.defineLazyGetter(lazy, "fxAccounts", () =>
+ ChromeUtils.import(
+ "resource://gre/modules/FxAccounts.jsm"
+ ).getFxAccountsSingleton()
+);
+XPCOMUtils.defineLazyGetter(lazy, "strings", function() {
+ return new Localization([
+ "branding/brand.ftl",
+ "browser/branding/brandings.ftl",
+ "browser/firefoxRelay.ftl",
+ ]);
+});
+
+if (Services.appinfo.processType !== Services.appinfo.PROCESS_TYPE_DEFAULT) {
+ throw new Error("FirefoxRelay.jsm should only run in the parent process");
+}
+
+async function getRelayTokenAsync() {
+ try {
+ return await lazy.fxAccounts.getOAuthToken({ scope: config.scope });
+ } catch (e) {
+ Cu.reportError(`There was an error getting the user's token: ${e.message}`);
+ return undefined;
+ }
+}
+
+async function createHeadersAsync(browser) {
+ const relayToken = await getRelayTokenAsync();
+ if (!relayToken) {
+ await showErrorAsync(browser, "firefox-relay-must-login-to-fxa");
+ return undefined;
+ }
+
+ return new Headers({
+ Authorization: `Bearer ${relayToken}`,
+ Accept: "application/json",
+ "Accept-Language": Services.locale.requestedLocales,
+ "Content-Type": "application/json",
+ });
+}
+
+async function hasFirefoxAccountAsync() {
+ if (!lazy.fxAccounts.constructor.config.isProductionConfig()) {
+ return false;
+ }
+
+ return lazy.fxAccounts.hasLocalSession();
+}
+
+async function isRelayUserAsync() {
+ if (!(await hasFirefoxAccountAsync())) {
+ return false;
+ }
+
+ const headers = await createHeadersAsync();
+ if (!headers) {
+ return false;
+ }
+
+ const request = new Request(config.profilesUrl, { headers });
+ const res = await fetch(request);
+
+ if (!res.ok) {
+ lazy.log.error(
+ `failed to check if user is a Relay user: ${res.status}:${
+ res.statusText
+ }:${await res.text()}`
+ );
+ }
+
+ return res.ok;
+}
+
+async function getReusableMasksAsync(browser, _origin) {
+ const headers = await createHeadersAsync(browser);
+ if (!headers) {
+ return undefined;
+ }
+
+ const request = new Request(config.addressesUrl, {
+ method: "GET",
+ headers,
+ });
+ const res = await fetch(request);
+ if (res.ok) {
+ return res.json();
+ }
+
+ lazy.log.error(
+ `failed to find reusable Relay masks: ${res.status}:${res.statusText}`
+ );
+ await showErrorAsync(browser, "firefox-relay-get-reusable-masks-failed", {
+ status: res.status,
+ });
+ // Services.telemetry.recordEvent("pwmgr", "make_relay_fail", "relay");
+
+ return undefined;
+}
+
+/**
+ * Show confirmation tooltip
+ * @param browser
+ * @param messageId message ID from browser/browser.properties
+ */
+function showConfirmation(browser, messageId) {
+ const anchor = browser.ownerDocument.getElementById("identity-icon");
+ anchor.ownerGlobal.ConfirmationHint.show(anchor, messageId, {});
+}
+
+/**
+ * Show localized notification.
+ * @param browser
+ * @param messageId messageId from browser/firefoxRelay.ftl
+ * @param messageArgs
+ */
+async function showErrorAsync(browser, messageId, messageArgs) {
+ const { PopupNotifications } = browser.ownerGlobal.wrappedJSObject;
+ const [message] = await lazy.strings.formatValues([
+ { id: messageId, args: messageArgs },
+ ]);
+ PopupNotifications.show(
+ browser,
+ "relay-integration-error",
+ message,
+ "password-notification-icon",
+ null,
+ null,
+ {
+ autofocus: true,
+ removeOnDismissal: true,
+ popupIconURL: "page-icon:https://relay.firefox.com",
+ learnMoreURL: config.learnMoreURL,
+ }
+ );
+}
+
+function customizeNotificationHeader(notification) {
+ const document = notification.owner.panel.ownerDocument;
+ const description = document.querySelector(
+ `description[popupid=${notification.id}]`
+ );
+ const headerTemplate = document.getElementById("firefox-relay-header");
+ description.replaceChildren(headerTemplate.firstChild.cloneNode(true));
+}
+
+async function formatMessages(...ids) {
+ for (let i in ids) {
+ if (typeof ids[i] == "string") {
+ ids[i] = { id: ids[i] };
+ }
+ }
+
+ const messages = await lazy.strings.formatMessages(ids);
+ return messages.map(message => {
+ if (message.attributes) {
+ return message.attributes.reduce(
+ (result, { name, value }) => ({ ...result, [name]: value }),
+ {}
+ );
+ }
+ return message.value;
+ });
+}
+
+async function showReusableMasksAsync(browser, origin, errorMessage) {
+ const reusableMasks = await getReusableMasksAsync(browser, origin);
+ if (!reusableMasks) {
+ return null;
+ }
+
+ let fillUsername;
+ const fillUsernamePromise = new Promise(resolve => (fillUsername = resolve));
+ const [getUnlimitedMasksStrings] = await formatMessages(
+ "firefox-relay-get-unlimited-masks"
+ );
+ const getUnlimitedMasks = {
+ label: getUnlimitedMasksStrings.label,
+ accessKey: getUnlimitedMasksStrings.accesskey,
+ dismiss: true,
+ async callback() {
+ browser.ownerGlobal.openWebLinkIn(config.learnMoreURL, "tab");
+ },
+ };
+
+ let notification;
+
+ function getReusableMasksList() {
+ return notification.owner.panel.getElementsByClassName(
+ "reusable-relay-masks"
+ )[0];
+ }
+
+ function notificationShown() {
+ customizeNotificationHeader(notification);
+
+ notification.owner.panel.getElementsByClassName(
+ "error-message"
+ )[0].textContent = errorMessage;
+
+ // rebuild "reuse mask" buttons list
+ const list = getReusableMasksList();
+ list.innerHTML = "";
+
+ const document = list.ownerDocument;
+ const fragment = document.createDocumentFragment();
+ reusableMasks
+ .filter(mask => mask.enabled)
+ .forEach(mask => {
+ const button = document.createElement("button");
+
+ const maskFullAddress = document.createElement("span");
+ maskFullAddress.textContent = mask.full_address;
+ button.appendChild(maskFullAddress);
+
+ const maskDescription = document.createElement("span");
+ maskDescription.textContent =
+ mask.description || mask.generated_for || mask.used_on;
+ button.appendChild(maskDescription);
+
+ button.addEventListener("click", () => {
+ notification.remove();
+ lazy.log.info("Reusing Relay mask");
+ fillUsername(mask.full_address);
+ });
+ fragment.appendChild(button);
+ });
+ list.appendChild(fragment);
+ }
+
+ function notificationRemoved() {
+ const list = getReusableMasksList();
+ list.innerHTML = "";
+ }
+
+ function onNotificationEvent(event) {
+ switch (event) {
+ case "removed":
+ notificationRemoved();
+ break;
+ case "shown":
+ notificationShown();
+ break;
+ }
+ }
+
+ const { PopupNotifications } = browser.ownerGlobal.wrappedJSObject;
+ notification = PopupNotifications.show(
+ browser,
+ "relay-integration-reuse-masks",
+ "", // content is provided after popup shown
+ "password-notification-icon",
+ getUnlimitedMasks,
+ [],
+ {
+ autofocus: true,
+ removeOnDismissal: true,
+ eventCallback: onNotificationEvent,
+ }
+ );
+
+ return fillUsernamePromise;
+}
+
+async function generateUsernameAsync(browser, origin) {
+ const headers = await createHeadersAsync(browser);
+ if (!headers) {
+ return undefined;
+ }
+ const body = JSON.stringify({
+ enabled: true,
+ description: origin.substr(0, 64),
+ generated_for: origin.substr(0, 255),
+ used_on: origin,
+ });
+ const request = new Request(config.addressesUrl, {
+ method: "POST",
+ headers,
+ body,
+ });
+
+ const res = await fetch(request);
+ if (res.ok) {
+ lazy.log.info(`generated Relay mask`);
+ const result = await res.json();
+ showConfirmation(browser, "confirmation-hint-firefox-relay-mask-generated");
+ // Services.telemetry.recordEvent("pwmgr", "make_relay", "relay");
+ return result.full_address;
+ }
+
+ if (res.status == 403) {
+ const error = await res.json();
+ if (error?.error_code == "free_tier_limit") {
+ return showReusableMasksAsync(browser, origin, error.detail);
+ }
+ }
+
+ lazy.log.error(
+ `failed to generate Relay mask: ${res.status}:${res.statusText}`
+ );
+
+ await showErrorAsync(browser, "firefox-relay-mask-generation-failed", {
+ status: res.status,
+ });
+ // Services.telemetry.recordEvent("pwmgr", "make_relay_fail", "relay");
+
+ return undefined;
+}
+
+function isSignup(scenarioName) {
+ return scenarioName == "SignUpFormScenario";
+}
+
+class RelayOffered {
+ #isRelayUser;
+
+ async *autocompleteItemsAsync(_origin, scenarioName, hasInput) {
+ if (!hasInput && isSignup(scenarioName)) {
+ if (this.#isRelayUser === undefined) {
+ this.#isRelayUser = await isRelayUserAsync();
+ }
+
+ if (this.#isRelayUser) {
+ const [title, subtitle] = await formatMessages(
+ "firefox-relay-opt-in-title",
+ "firefox-relay-opt-in-subtitle"
+ );
+ yield new ParentAutocompleteOption(
+ "page-icon:https://relay.firefox.com",
+ title,
+ subtitle,
+ "PasswordManager:offerRelayIntegration",
+ null
+ );
+ }
+ }
+ }
+
+ async offerRelayIntegration(feature, browser, origin) {
+ const fxaUser = await lazy.fxAccounts.getSignedInUser();
+ if (!fxaUser) {
+ return null;
+ }
+ const { PopupNotifications } = browser.ownerGlobal.wrappedJSObject;
+ let fillUsername;
+ const fillUsernamePromise = new Promise(
+ resolve => (fillUsername = resolve)
+ );
+ const [
+ enableStrings,
+ disableStrings,
+ postponeStrings,
+ ] = await formatMessages(
+ "firefox-relay-opt-in-confirmation-enable",
+ "firefox-relay-opt-in-confirmation-disable",
+ "firefox-relay-opt-in-confirmation-postpone"
+ );
+ const enableIntegration = {
+ label: enableStrings.label,
+ accessKey: enableStrings.accesskey,
+ dismiss: true,
+ async callback() {
+ lazy.log.info("user opted in to Firefox Relay integration");
+ feature.markAsEnabled();
+ fillUsername(await generateUsernameAsync(browser, origin));
+ },
+ };
+ const postpone = {
+ label: postponeStrings.label,
+ accessKey: postponeStrings.accesskey,
+ dismiss: true,
+ callback() {
+ lazy.log.info(
+ "user decided not to decide about Firefox Relay integration"
+ );
+ feature.markAsOffered();
+ },
+ };
+ const disableIntegration = {
+ label: disableStrings.label,
+ accessKey: disableStrings.accesskey,
+ dismiss: true,
+ callback() {
+ lazy.log.info("user opted out from Firefox Relay integration");
+ feature.markAsDisabled();
+ },
+ };
+ let notification;
+ feature.markAsOffered();
+ notification = PopupNotifications.show(
+ browser,
+ "relay-integration-offer",
+ "", // content is provided after popup shown
+ "password-notification-icon",
+ enableIntegration,
+ [postpone, disableIntegration],
+ {
+ autofocus: true,
+ removeOnDismissal: true,
+ learnMoreURL: config.learnMoreURL,
+ eventCallback: event => {
+ switch (event) {
+ case "shown":
+ customizeNotificationHeader(notification);
+ const document = notification.owner.panel.ownerDocument;
+ const content = document.querySelector(
+ `popupnotification[id=${notification.id}-notification] popupnotificationcontent`
+ );
+ const line3 = content.querySelector(
+ "[id=firefox-relay-offer-what-relay-does]"
+ );
+ document.l10n.setAttributes(
+ line3,
+ "firefox-relay-offer-what-relay-does",
+ {
+ sitename: origin,
+ useremail: fxaUser.email,
+ }
+ );
+ break;
+ }
+ },
+ }
+ );
+
+ return fillUsernamePromise;
+ }
+}
+
+class RelayEnabled {
+ async *autocompleteItemsAsync(origin, scenarioName, hasInput) {
+ if (
+ !hasInput &&
+ isSignup(scenarioName) &&
+ (await hasFirefoxAccountAsync())
+ ) {
+ const [title, subtitle] = await formatMessages(
+ "firefox-relay-generate-mask-title",
+ "firefox-relay-generate-mask-subtitle"
+ );
+ yield new ParentAutocompleteOption(
+ "page-icon:https://relay.firefox.com",
+ title,
+ subtitle,
+ "PasswordManager:generateRelayUsername",
+ origin
+ );
+ }
+ }
+
+ async generateUsername(browser, origin) {
+ return generateUsernameAsync(browser, origin);
+ }
+}
+
+class RelayDisabled {}
+
+class RelayFeature extends OptInFeature {
+ constructor() {
+ super(RelayOffered, RelayEnabled, RelayDisabled, config.relayFeaturePref);
+ }
+
+ get learnMoreUrl() {
+ return config.learnMoreURL;
+ }
+
+ async autocompleteItemsAsync({ origin, scenarioName, hasInput }) {
+ const result = [];
+
+ if (this.implementation.autocompleteItemsAsync) {
+ for await (const item of this.implementation.autocompleteItemsAsync(
+ origin,
+ scenarioName,
+ hasInput
+ )) {
+ result.push(item);
+ }
+ }
+
+ return result;
+ }
+
+ async generateUsername(browser, origin) {
+ return this.implementation.generateUsername?.(browser, origin);
+ }
+
+ async offerRelayIntegration(browser, origin) {
+ return this.implementation.offerRelayIntegration?.(this, browser, origin);
+ }
+}
+
+const FirefoxRelay = new RelayFeature();