diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-19 00:47:55 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-19 00:47:55 +0000 |
commit | 26a029d407be480d791972afb5975cf62c9360a6 (patch) | |
tree | f435a8308119effd964b339f76abb83a57c29483 /toolkit/components/satchel/integrations/FirefoxRelay.sys.mjs | |
parent | Initial commit. (diff) | |
download | firefox-26a029d407be480d791972afb5975cf62c9360a6.tar.xz firefox-26a029d407be480d791972afb5975cf62c9360a6.zip |
Adding upstream version 124.0.1.upstream/124.0.1
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'toolkit/components/satchel/integrations/FirefoxRelay.sys.mjs')
-rw-r--r-- | toolkit/components/satchel/integrations/FirefoxRelay.sys.mjs | 647 |
1 files changed, 647 insertions, 0 deletions
diff --git a/toolkit/components/satchel/integrations/FirefoxRelay.sys.mjs b/toolkit/components/satchel/integrations/FirefoxRelay.sys.mjs new file mode 100644 index 0000000000..8f88373763 --- /dev/null +++ b/toolkit/components/satchel/integrations/FirefoxRelay.sys.mjs @@ -0,0 +1,647 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { FirefoxRelayTelemetry } from "resource://gre/modules/FirefoxRelayTelemetry.mjs"; +import { + LoginHelper, + OptInFeature, + ParentAutocompleteOption, +} from "resource://gre/modules/LoginHelper.sys.mjs"; +import { TelemetryUtils } from "resource://gre/modules/TelemetryUtils.sys.mjs"; +import { showConfirmation } from "resource://gre/modules/FillHelpers.sys.mjs"; + +const lazy = {}; + +// Static configuration +const gConfig = (function () { + const baseUrl = Services.prefs.getStringPref( + "signon.firefoxRelay.base_url", + undefined + ); + return { + scope: ["profile", "https://identity.mozilla.com/apps/relay"], + addressesUrl: baseUrl + `relayaddresses/`, + acceptTermsUrl: baseUrl + `terms-accepted-user/`, + profilesUrl: baseUrl + `profiles/`, + learnMoreURL: Services.urlFormatter.formatURLPref( + "signon.firefoxRelay.learn_more_url" + ), + manageURL: Services.urlFormatter.formatURLPref( + "signon.firefoxRelay.manage_url" + ), + relayFeaturePref: "signon.firefoxRelay.feature", + termsOfServiceUrl: Services.urlFormatter.formatURLPref( + "signon.firefoxRelay.terms_of_service_url" + ), + privacyPolicyUrl: Services.urlFormatter.formatURLPref( + "signon.firefoxRelay.privacy_policy_url" + ), + }; +})(); + +ChromeUtils.defineLazyGetter(lazy, "log", () => + LoginHelper.createLogger("FirefoxRelay") +); +ChromeUtils.defineLazyGetter(lazy, "fxAccounts", () => + ChromeUtils.importESModule( + "resource://gre/modules/FxAccounts.sys.mjs" + ).getFxAccountsSingleton() +); +ChromeUtils.defineLazyGetter(lazy, "strings", function () { + return new Localization([ + "branding/brand.ftl", + "browser/firefoxRelay.ftl", + "toolkit/branding/accounts.ftl", + "toolkit/branding/brandings.ftl", + ]); +}); + +if (Services.appinfo.processType !== Services.appinfo.PROCESS_TYPE_DEFAULT) { + throw new Error("FirefoxRelay.sys.mjs should only run in the parent process"); +} + +// Using 418 to avoid conflict with other standard http error code +const AUTH_TOKEN_ERROR_CODE = 418; + +let gFlowId; + +async function getRelayTokenAsync() { + try { + return await lazy.fxAccounts.getOAuthToken({ scope: gConfig.scope }); + } catch (e) { + console.error(`There was an error getting the user's token: ${e.message}`); + return undefined; + } +} + +async function hasFirefoxAccountAsync() { + if (!lazy.fxAccounts.constructor.config.isProductionConfig()) { + return false; + } + return lazy.fxAccounts.hasLocalSession(); +} + +async function fetchWithReauth( + browser, + createRequest, + canGetFreshOAuthToken = true +) { + const relayToken = await getRelayTokenAsync(); + if (!relayToken) { + if (browser) { + await showErrorAsync(browser, "firefox-relay-must-login-to-account"); + } + return undefined; + } + + const headers = new Headers({ + Authorization: `Bearer ${relayToken}`, + Accept: "application/json", + "Accept-Language": Services.locale.requestedLocales, + "Content-Type": "application/json", + }); + + const request = createRequest(headers); + const response = await fetch(request); + + if (canGetFreshOAuthToken && response.status == 401) { + await lazy.fxAccounts.removeCachedOAuthToken({ token: relayToken }); + return fetchWithReauth(browser, createRequest, false); + } + return response; +} + +async function getReusableMasksAsync(browser, _origin) { + const response = await fetchWithReauth( + browser, + headers => + new Request(gConfig.addressesUrl, { + method: "GET", + headers, + }) + ); + + if (!response) { + // fetchWithReauth only returns undefined if login / obtaining a token failed. + // Otherwise, it will return a response object. + return [undefined, AUTH_TOKEN_ERROR_CODE]; + } + + if (response.ok) { + return [await response.json(), response.status]; + } + + lazy.log.error( + `failed to find reusable Relay masks: ${response.status}:${response.statusText}` + ); + await showErrorAsync(browser, "firefox-relay-get-reusable-masks-failed", { + status: response.status, + }); + + return [undefined, response.status]; +} + +/** + * Show localized notification. + * + * @param {*} browser + * @param {*} messageId from browser/firefoxRelay.ftl + * @param {object} 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: "chrome://browser/content/logos/relay.svg", + learnMoreURL: gConfig.learnMoreURL, + } + ); +} + +function customizeNotificationHeader(notification) { + if (!notification) { + return; + } + 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, error) { + const [reusableMasks, status] = await getReusableMasksAsync(browser, origin); + if (!reusableMasks) { + FirefoxRelayTelemetry.recordRelayReusePanelEvent("shown", gFlowId, status); + 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() { + FirefoxRelayTelemetry.recordRelayReusePanelEvent( + "get_unlimited_masks", + gFlowId + ); + browser.ownerGlobal.openWebLinkIn(gConfig.manageURL, "tab"); + }, + }; + + let notification; + + function getReusableMasksList() { + return notification?.owner.panel.getElementsByClassName( + "reusable-relay-masks" + )[0]; + } + + function notificationShown() { + if (!notification) { + return; + } + + customizeNotificationHeader(notification); + + notification.owner.panel.getElementsByClassName( + "error-message" + )[0].textContent = error.detail || ""; + + // 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); + showConfirmation( + browser, + "confirmation-hint-firefox-relay-mask-reused" + ); + FirefoxRelayTelemetry.recordRelayReusePanelEvent( + "reuse_mask", + gFlowId + ); + }, + { once: true } + ); + 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(); + FirefoxRelayTelemetry.recordRelayReusePanelEvent("shown", gFlowId); + 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 body = JSON.stringify({ + enabled: true, + description: origin.substr(0, 64), + generated_for: origin.substr(0, 255), + used_on: origin, + }); + + const response = await fetchWithReauth( + browser, + headers => + new Request(gConfig.addressesUrl, { + method: "POST", + headers, + body, + }) + ); + + if (!response) { + FirefoxRelayTelemetry.recordRelayUsernameFilledEvent( + "shown", + gFlowId, + AUTH_TOKEN_ERROR_CODE + ); + return undefined; + } + + if (response.ok) { + lazy.log.info(`generated Relay mask`); + const result = await response.json(); + showConfirmation(browser, "confirmation-hint-firefox-relay-mask-created"); + return result.full_address; + } + + if (response.status == 403) { + const error = await response.json(); + if (error?.error_code == "free_tier_limit") { + FirefoxRelayTelemetry.recordRelayUsernameFilledEvent( + "shown", + gFlowId, + error?.error_code + ); + return showReusableMasksAsync(browser, origin, error); + } + } + + lazy.log.error( + `failed to generate Relay mask: ${response.status}:${response.statusText}` + ); + + await showErrorAsync(browser, "firefox-relay-mask-generation-failed", { + status: response.status, + }); + + FirefoxRelayTelemetry.recordRelayReusePanelEvent( + "shown", + gFlowId, + response.status + ); + + return undefined; +} + +function isSignup(scenarioName) { + return scenarioName == "SignUpFormScenario"; +} + +class RelayOffered { + async *autocompleteItemsAsync(_origin, scenarioName, hasInput) { + if ( + !hasInput && + isSignup(scenarioName) && + (await hasFirefoxAccountAsync()) && + !Services.prefs.prefIsLocked("signon.firefoxRelay.feature") + ) { + const [title, subtitle] = await formatMessages( + "firefox-relay-opt-in-title-1", + "firefox-relay-opt-in-subtitle-1" + ); + yield new ParentAutocompleteOption( + "chrome://browser/content/logos/relay.svg", + title, + subtitle, + "PasswordManager:offerRelayIntegration", + { + telemetry: { + flowId: gFlowId, + scenarioName, + }, + } + ); + FirefoxRelayTelemetry.recordRelayOfferedEvent( + "shown", + gFlowId, + scenarioName + ); + } + } + + async notifyServerTermsAcceptedAsync(browser) { + const response = await fetchWithReauth( + browser, + headers => + new Request(gConfig.acceptTermsUrl, { + method: "POST", + headers, + }) + ); + + if (!response?.ok) { + lazy.log.error( + `failed to notify server that terms are accepted : ${response?.status}:${response?.statusText}` + ); + + let error; + try { + error = await response?.json(); + } catch {} + await showErrorAsync(browser, "firefox-relay-mask-generation-failed", { + status: error?.detail || response.status, + }); + return false; + } + + return true; + } + + 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-button", + "firefox-relay-opt-in-confirmation-disable", + "firefox-relay-opt-in-confirmation-postpone" + ); + const enableIntegration = { + label: enableStrings.label, + accessKey: enableStrings.accesskey, + dismiss: true, + callback: async () => { + lazy.log.info("user opted in to Firefox Relay integration"); + // Capture the flowId here since async operations might take some time to resolve + // and by then gFlowId might have another value + const flowId = gFlowId; + if (await this.notifyServerTermsAcceptedAsync(browser)) { + feature.markAsEnabled(); + FirefoxRelayTelemetry.recordRelayOptInPanelEvent("enabled", flowId); + 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(); + FirefoxRelayTelemetry.recordRelayOptInPanelEvent("postponed", gFlowId); + }, + }; + const disableIntegration = { + label: disableStrings.label, + accessKey: disableStrings.accesskey, + dismiss: true, + callback() { + lazy.log.info("user opted out from Firefox Relay integration"); + feature.markAsDisabled(); + FirefoxRelayTelemetry.recordRelayOptInPanelEvent("disabled", gFlowId); + }, + }; + 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: gConfig.learnMoreURL, + eventCallback: event => { + switch (event) { + case "shown": + customizeNotificationHeader(notification); + const document = notification.owner.panel.ownerDocument; + const tosLink = document.getElementById( + "firefox-relay-offer-tos-url" + ); + tosLink.href = gConfig.termsOfServiceUrl; + const privacyLink = document.getElementById( + "firefox-relay-offer-privacy-url" + ); + privacyLink.href = gConfig.privacyPolicyUrl; + const content = document.querySelector( + `popupnotification[id=${notification.id}-notification] popupnotificationcontent` + ); + const line3 = content.querySelector( + "[id=firefox-relay-offer-what-relay-provides]" + ); + document.l10n.setAttributes( + line3, + "firefox-relay-offer-what-relay-provides", + { + useremail: fxaUser.email, + } + ); + FirefoxRelayTelemetry.recordRelayOptInPanelEvent( + "shown", + gFlowId + ); + break; + } + }, + } + ); + getRelayTokenAsync(); + return fillUsernamePromise; + } +} + +class RelayEnabled { + async *autocompleteItemsAsync(origin, scenarioName, hasInput) { + if ( + !hasInput && + isSignup(scenarioName) && + (await hasFirefoxAccountAsync()) + ) { + const [title] = await formatMessages("firefox-relay-use-mask-title"); + yield new ParentAutocompleteOption( + "chrome://browser/content/logos/relay.svg", + title, + "", // when the user has opted-in, there is no subtitle content + "PasswordManager:generateRelayUsername", + { + telemetry: { + flowId: gFlowId, + }, + } + ); + FirefoxRelayTelemetry.recordRelayUsernameFilledEvent("shown", gFlowId); + } + } + + async generateUsername(browser, origin) { + return generateUsernameAsync(browser, origin); + } +} + +class RelayDisabled {} + +class RelayFeature extends OptInFeature { + constructor() { + super(RelayOffered, RelayEnabled, RelayDisabled, gConfig.relayFeaturePref); + Services.telemetry.setEventRecordingEnabled("relay_integration", true); + // Update the config when the signon.firefoxRelay.base_url pref is changed. + // This is added mainly for tests. + Services.prefs.addObserver( + "signon.firefoxRelay.base_url", + this.updateConfig + ); + } + + get learnMoreUrl() { + return gConfig.learnMoreURL; + } + + updateConfig() { + const newBaseUrl = Services.prefs.getStringPref( + "signon.firefoxRelay.base_url" + ); + gConfig.addressesUrl = newBaseUrl + `relayaddresses/`; + gConfig.profilesUrl = newBaseUrl + `profiles/`; + gConfig.acceptTermsUrl = newBaseUrl + `terms-accepted-user/`; + } + + async autocompleteItemsAsync({ origin, scenarioName, hasInput }) { + const result = []; + + // Generate a flowID to unique identify a series of user action. FlowId + // allows us to link users' interaction on different UI component (Ex. autocomplete, notification) + // We can use flowID to build the Funnel Diagram + // This value need to always be regenerated in the entry point of an user + // action so we overwrite the previous one. + gFlowId = TelemetryUtils.generateUUID(); + + 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); + } +} + +export const FirefoxRelay = new RelayFeature(); |