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/credentialmanagement/IdentityCredentialPromptService.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/credentialmanagement/IdentityCredentialPromptService.sys.mjs')
-rw-r--r-- | toolkit/components/credentialmanagement/IdentityCredentialPromptService.sys.mjs | 751 |
1 files changed, 751 insertions, 0 deletions
diff --git a/toolkit/components/credentialmanagement/IdentityCredentialPromptService.sys.mjs b/toolkit/components/credentialmanagement/IdentityCredentialPromptService.sys.mjs new file mode 100644 index 0000000000..3a6b43b5b1 --- /dev/null +++ b/toolkit/components/credentialmanagement/IdentityCredentialPromptService.sys.mjs @@ -0,0 +1,751 @@ +/** + * 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 { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; +import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs"; + +const lazy = {}; + +XPCOMUtils.defineLazyServiceGetter( + lazy, + "IDNService", + "@mozilla.org/network/idn-service;1", + "nsIIDNService" +); + +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "SELECT_FIRST_IN_UI_LISTS", + "dom.security.credentialmanagement.identity.select_first_in_ui_lists", + false +); + +ChromeUtils.defineESModuleGetters(lazy, { + GeckoViewIdentityCredential: + "resource://gre/modules/GeckoViewIdentityCredential.sys.mjs", +}); +const BEST_HEADER_ICON_SIZE = 16; +const BEST_ICON_SIZE = 32; + +// Used in plain mochitests to enable automation +function fulfilledPromiseFromFirstListElement(list) { + if (list.length) { + return Promise.resolve(0); + } + return Promise.reject(); +} + +// Converts a "blob" to a data URL +function blobToDataUrl(blob) { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.addEventListener("loadend", function () { + if (reader.error) { + reject(reader.error); + } + resolve(reader.result); + }); + reader.readAsDataURL(blob); + }); +} + +// Converts a URL into a data:// url, suitable for inclusion in Chrome UI +async function fetchToDataUrl(url) { + let result = await fetch(url); + if (!result.ok) { + throw result.status; + } + let blob = await result.blob(); + let data = blobToDataUrl(blob); + return data; +} + +/** + * Class implementing the nsIIdentityCredentialPromptService + * */ +export class IdentityCredentialPromptService { + classID = Components.ID("{936007db-a957-4f1d-a23d-f7d9403223e6}"); + QueryInterface = ChromeUtils.generateQI([ + "nsIIdentityCredentialPromptService", + ]); + + async loadIconFromManifest( + providerManifest, + bestIconSize = BEST_ICON_SIZE, + defaultIcon = null + ) { + if (providerManifest?.branding?.icons?.length) { + // Prefer a vector icon, then an exactly sized icon, + // the the largest icon available. + let iconsArray = providerManifest.branding.icons; + let vectorIcon = iconsArray.find(icon => !icon.size); + if (vectorIcon) { + return fetchToDataUrl(vectorIcon.url); + } + let exactIcon = iconsArray.find(icon => icon.size == bestIconSize); + if (exactIcon) { + return fetchToDataUrl(exactIcon.url); + } + let biggestIcon = iconsArray.sort( + (iconA, iconB) => iconB.size - iconA.size + )[0]; + if (biggestIcon) { + return fetchToDataUrl(biggestIcon.url); + } + } + + return defaultIcon; + } + + /** + * Ask the user, using a PopupNotification, to select an Identity Provider from a provided list. + * @param {BrowsingContext} browsingContext - The BrowsingContext of the document requesting an identity credential via navigator.credentials.get() + * @param {IdentityProviderConfig[]} identityProviders - The list of identity providers the user selects from + * @param {IdentityProviderAPIConfig[]} identityManifests - The manifests corresponding 1-to-1 with identityProviders + * @returns {Promise<number>} The user-selected identity provider + */ + async showProviderPrompt( + browsingContext, + identityProviders, + identityManifests + ) { + // For testing only. + if (lazy.SELECT_FIRST_IN_UI_LISTS) { + return fulfilledPromiseFromFirstListElement(identityProviders); + } + let browser = browsingContext.top.embedderElement; + if (!browser) { + throw new Error("Null browser provided"); + } + + if (identityProviders.length != identityManifests.length) { + throw new Error("Mismatch argument array length"); + } + + // Map each identity manifest to a promise that would resolve to its icon + let promises = identityManifests.map(async providerManifest => { + // we don't need to set default icon because default icon is already set on popup-notifications.inc + const iconResult = await this.loadIconFromManifest(providerManifest); + // If we didn't have a manifest with an icon, push a rejection. + // This will be replaced with the default icon. + return iconResult ? iconResult : Promise.reject(); + }); + + const providerNames = identityManifests.map( + providerManifest => providerManifest?.branding?.name + ); + + // Sanity check that we made one promise per IDP. + if (promises.length != identityManifests.length) { + throw new Error("Mismatch promise array length"); + } + + let iconResults = await Promise.allSettled(promises); + if (AppConstants.platform === "android") { + const providers = []; + for (const [providerIndex, provider] of identityProviders.entries()) { + let providerURL = new URL(provider.configURL); + let displayDomain = lazy.IDNService.convertToDisplayIDN( + providerURL.host, + {} + ); + + let iconResult = iconResults[providerIndex]; + const data = { + id: providerIndex, + icon: iconResult.value, + name: providerNames[providerIndex], + domain: displayDomain, + }; + providers.push(data); + } + + return new Promise((resolve, reject) => { + lazy.GeckoViewIdentityCredential.onShowProviderPrompt( + browsingContext, + providers, + resolve, + reject + ); + }); + } + + // Localize all strings to be used + // Bug 1797154 - Convert localization calls to use the async formatValues. + let localization = new Localization( + ["browser/identityCredentialNotification.ftl"], + true + ); + let headerMessage = localization.formatValueSync( + "identity-credential-header-providers" + ); + let [accept, cancel] = localization.formatMessagesSync([ + { id: "identity-credential-accept-button" }, + { id: "identity-credential-cancel-button" }, + ]); + + let cancelLabel = cancel.attributes.find(x => x.name == "label").value; + let cancelKey = cancel.attributes.find(x => x.name == "accesskey").value; + let acceptLabel = accept.attributes.find(x => x.name == "label").value; + let acceptKey = accept.attributes.find(x => x.name == "accesskey").value; + + // Build the choices into the panel + let listBox = browser.ownerDocument.getElementById( + "identity-credential-provider-selector-container" + ); + while (listBox.firstChild) { + listBox.removeChild(listBox.lastChild); + } + let itemTemplate = browser.ownerDocument.getElementById( + "template-credential-provider-list-item" + ); + for (const [providerIndex, provider] of identityProviders.entries()) { + let providerURL = new URL(provider.configURL); + let displayDomain = lazy.IDNService.convertToDisplayIDN( + providerURL.host, + {} + ); + let newItem = itemTemplate.content.firstElementChild.cloneNode(true); + + // Create the radio button, + // including the check callback and the initial state + let newRadio = newItem.getElementsByClassName( + "identity-credential-list-item-radio" + )[0]; + newRadio.value = providerIndex; + newRadio.addEventListener("change", function (event) { + for (let item of listBox.children) { + item.classList.remove("checked"); + } + if (event.target.checked) { + event.target.parentElement.classList.add("checked"); + } + }); + if (providerIndex == 0) { + newRadio.checked = true; + newItem.classList.add("checked"); + } + + // Set the icon to the data url if we have one + let iconResult = iconResults[providerIndex]; + if (iconResult.status == "fulfilled") { + let newIcon = newItem.getElementsByClassName( + "identity-credential-list-item-icon" + )[0]; + newIcon.setAttribute("src", iconResult.value); + } + + // Set the words that the user sees in the selection + newItem.getElementsByClassName( + "identity-credential-list-item-label-primary" + )[0].textContent = providerNames[providerIndex] || displayDomain; + newItem.getElementsByClassName( + "identity-credential-list-item-label-secondary" + )[0].hidden = true; + + if (providerNames[providerIndex] && displayDomain) { + newItem.getElementsByClassName( + "identity-credential-list-item-label-secondary" + )[0].hidden = false; + newItem.getElementsByClassName( + "identity-credential-list-item-label-secondary" + )[0].textContent = displayDomain; + } + + // Add the new item to the DOM! + listBox.append(newItem); + } + + // Create a new promise to wrap the callbacks of the popup buttons + return new Promise((resolve, reject) => { + // Construct the necessary arguments for notification behavior + let options = { + hideClose: true, + eventCallback: (topic, nextRemovalReason, isCancel) => { + if (topic == "removed" && isCancel) { + reject(); + } + }, + }; + let mainAction = { + label: acceptLabel, + accessKey: acceptKey, + callback(event) { + let result = listBox.querySelector( + ".identity-credential-list-item-radio:checked" + ).value; + resolve(parseInt(result)); + }, + }; + let secondaryActions = [ + { + label: cancelLabel, + accessKey: cancelKey, + callback(event) { + reject(); + }, + }, + ]; + + // Show the popup + browser.ownerDocument.getElementById( + "identity-credential-provider" + ).hidden = false; + browser.ownerDocument.getElementById( + "identity-credential-policy" + ).hidden = true; + browser.ownerDocument.getElementById( + "identity-credential-account" + ).hidden = true; + browser.ownerDocument.getElementById( + "identity-credential-header" + ).hidden = true; + browser.ownerGlobal.PopupNotifications.show( + browser, + "identity-credential", + headerMessage, + "identity-credential-notification-icon", + mainAction, + secondaryActions, + options + ); + }); + } + + /** + * Ask the user, using a PopupNotification, to approve or disapprove of the policies of the Identity Provider. + * @param {BrowsingContext} browsingContext - The BrowsingContext of the document requesting an identity credential via navigator.credentials.get() + * @param {IdentityProviderConfig} identityProvider - The Identity Provider that the user has selected to use + * @param {IdentityProviderAPIConfig} identityManifest - The Identity Provider that the user has selected to use's manifest + * @param {IdentityCredentialMetadata} identityCredentialMetadata - The metadata displayed to the user + * @returns {Promise<bool>} A boolean representing the user's acceptance of the metadata. + */ + async showPolicyPrompt( + browsingContext, + identityProvider, + identityManifest, + identityCredentialMetadata + ) { + // For testing only. + if (lazy.SELECT_FIRST_IN_UI_LISTS) { + return Promise.resolve(true); + } + if ( + !identityCredentialMetadata || + !identityCredentialMetadata.privacy_policy_url || + !identityCredentialMetadata.terms_of_service_url + ) { + return Promise.resolve(true); + } + + let iconResult = await this.loadIconFromManifest( + identityManifest, + BEST_HEADER_ICON_SIZE, + "chrome://global/skin/icons/defaultFavicon.svg" + ); + + const providerName = identityManifest?.branding?.name; + + return new Promise(function (resolve, reject) { + let browser = browsingContext.top.embedderElement; + if (!browser) { + reject(); + return; + } + + let providerURL = new URL(identityProvider.configURL); + let providerDisplayDomain = lazy.IDNService.convertToDisplayIDN( + providerURL.host, + {} + ); + let currentBaseDomain = + browsingContext.currentWindowContext.documentPrincipal.baseDomain; + + if (AppConstants.platform === "android") { + lazy.GeckoViewIdentityCredential.onShowPolicyPrompt( + browsingContext, + identityCredentialMetadata.privacy_policy_url, + identityCredentialMetadata.terms_of_service_url, + providerDisplayDomain, + currentBaseDomain, + iconResult, + resolve, + reject + ); + } else { + // Localize the description + // Bug 1797154 - Convert localization calls to use the async formatValues. + let localization = new Localization( + ["browser/identityCredentialNotification.ftl"], + true + ); + let [accept, cancel] = localization.formatMessagesSync([ + { id: "identity-credential-accept-button" }, + { id: "identity-credential-cancel-button" }, + ]); + + let cancelLabel = cancel.attributes.find(x => x.name == "label").value; + let cancelKey = cancel.attributes.find( + x => x.name == "accesskey" + ).value; + let acceptLabel = accept.attributes.find(x => x.name == "label").value; + let acceptKey = accept.attributes.find( + x => x.name == "accesskey" + ).value; + + let title = localization.formatValueSync( + "identity-credential-policy-title", + { + provider: providerName || providerDisplayDomain, + } + ); + + if (iconResult) { + let headerIcon = browser.ownerDocument.getElementsByClassName( + "identity-credential-header-icon" + )[0]; + headerIcon.setAttribute("src", iconResult); + } + + const headerText = browser.ownerDocument.getElementById( + "identity-credential-header-text" + ); + headerText.textContent = title; + + let privacyPolicyAnchor = browser.ownerDocument.getElementById( + "identity-credential-privacy-policy" + ); + privacyPolicyAnchor.href = + identityCredentialMetadata.privacy_policy_url; + let termsOfServiceAnchor = browser.ownerDocument.getElementById( + "identity-credential-terms-of-service" + ); + termsOfServiceAnchor.href = + identityCredentialMetadata.terms_of_service_url; + + // Populate the content of the policy panel + let description = browser.ownerDocument.getElementById( + "identity-credential-policy-explanation" + ); + browser.ownerDocument.l10n.setAttributes( + description, + "identity-credential-policy-description", + { + host: currentBaseDomain, + provider: providerDisplayDomain, + } + ); + + // Construct the necessary arguments for notification behavior + let options = { + hideClose: true, + eventCallback: (topic, nextRemovalReason, isCancel) => { + if (topic == "removed" && isCancel) { + reject(); + } + }, + }; + let mainAction = { + label: acceptLabel, + accessKey: acceptKey, + callback(event) { + resolve(true); + }, + }; + let secondaryActions = [ + { + label: cancelLabel, + accessKey: cancelKey, + callback(event) { + resolve(false); + }, + }, + ]; + + // Show the popup + let ownerDocument = browser.ownerDocument; + ownerDocument.getElementById( + "identity-credential-provider" + ).hidden = true; + ownerDocument.getElementById( + "identity-credential-policy" + ).hidden = false; + ownerDocument.getElementById( + "identity-credential-account" + ).hidden = true; + ownerDocument.getElementById( + "identity-credential-header" + ).hidden = false; + browser.ownerGlobal.PopupNotifications.show( + browser, + "identity-credential", + "", + "identity-credential-notification-icon", + mainAction, + secondaryActions, + options + ); + } + }); + } + + /** + * Ask the user, using a PopupNotification, to select an account from a provided list. + * @param {BrowsingContext} browsingContext - The BrowsingContext of the document requesting an identity credential via navigator.credentials.get() + * @param {IdentityProviderAccountList} accountList - The list of accounts the user selects from + * @param {IdentityProviderConfig} provider - The selected identity provider + * @param {IdentityProviderAPIConfig} providerManifest - The manifest of the selected identity provider + * @returns {Promise<IdentityProviderAccount>} The user-selected account + */ + async showAccountListPrompt( + browsingContext, + accountList, + provider, + providerManifest + ) { + // For testing only. + if (lazy.SELECT_FIRST_IN_UI_LISTS) { + return fulfilledPromiseFromFirstListElement(accountList.accounts); + } + + let browser = browsingContext.top.embedderElement; + if (!browser) { + throw new Error("Null browser provided"); + } + + // Map to an array of promises that resolve to a data URL, + // encoding the corresponding account's picture + let promises = accountList.accounts.map(async account => { + if (!account?.picture) { + throw new Error("Missing picture"); + } + return fetchToDataUrl(account.picture); + }); + + // Sanity check that we made one promise per account. + if (promises.length != accountList.accounts.length) { + throw new Error("Incorrect number of promises obtained"); + } + + let pictureResults = await Promise.allSettled(promises); + + // Localize all strings to be used + // Bug 1797154 - Convert localization calls to use the async formatValues. + let localization = new Localization( + ["browser/identityCredentialNotification.ftl"], + true + ); + const providerName = providerManifest?.branding?.name; + let providerURL = new URL(provider.configURL); + let displayDomain = lazy.IDNService.convertToDisplayIDN( + providerURL.host, + {} + ); + + let headerIconResult = await this.loadIconFromManifest( + providerManifest, + BEST_HEADER_ICON_SIZE, + "chrome://global/skin/icons/defaultFavicon.svg" + ); + + if (AppConstants.platform === "android") { + const accounts = []; + + for (const [accountIndex, account] of accountList.accounts.entries()) { + var picture = ""; + let pictureResult = pictureResults[accountIndex]; + if (pictureResult.status == "fulfilled") { + picture = pictureResult.value; + } + account.name; + account.email; + const data = { + id: accountIndex, + icon: picture, + name: account.name, + email: account.email, + }; + accounts.push(data); + console.log(data); + } + + const provider = { + name: providerName || displayDomain, + domain: displayDomain, + icon: headerIconResult, + }; + + const result = { + provider, + accounts, + }; + + return new Promise((resolve, reject) => { + lazy.GeckoViewIdentityCredential.onShowAccountsPrompt( + browsingContext, + result, + resolve, + reject + ); + }); + } + + let headerMessage = localization.formatValueSync( + "identity-credential-header-accounts", + { + provider: providerName || displayDomain, + } + ); + + let [accept, cancel] = localization.formatMessagesSync([ + { id: "identity-credential-sign-in-button" }, + { id: "identity-credential-cancel-button" }, + ]); + + let cancelLabel = cancel.attributes.find(x => x.name == "label").value; + let cancelKey = cancel.attributes.find(x => x.name == "accesskey").value; + let acceptLabel = accept.attributes.find(x => x.name == "label").value; + let acceptKey = accept.attributes.find(x => x.name == "accesskey").value; + + // Build the choices into the panel + let listBox = browser.ownerDocument.getElementById( + "identity-credential-account-selector-container" + ); + while (listBox.firstChild) { + listBox.removeChild(listBox.lastChild); + } + let itemTemplate = browser.ownerDocument.getElementById( + "template-credential-account-list-item" + ); + for (const [accountIndex, account] of accountList.accounts.entries()) { + let newItem = itemTemplate.content.firstElementChild.cloneNode(true); + + // Add the new radio button, including pre-selection and the callback + let newRadio = newItem.getElementsByClassName( + "identity-credential-list-item-radio" + )[0]; + newRadio.value = accountIndex; + newRadio.addEventListener("change", function (event) { + for (let item of listBox.children) { + item.classList.remove("checked"); + } + if (event.target.checked) { + event.target.parentElement.classList.add("checked"); + } + }); + if (accountIndex == 0) { + newRadio.checked = true; + newItem.classList.add("checked"); + } + + // Change the default picture if one exists + let pictureResult = pictureResults[accountIndex]; + if (pictureResult.status == "fulfilled") { + let newPicture = newItem.getElementsByClassName( + "identity-credential-list-item-icon" + )[0]; + newPicture.setAttribute("src", pictureResult.value); + } + + // Add information to the label + newItem.getElementsByClassName( + "identity-credential-list-item-label-primary" + )[0].textContent = account.name; + newItem.getElementsByClassName( + "identity-credential-list-item-label-secondary" + )[0].textContent = account.email; + + // Add the item to the DOM! + listBox.append(newItem); + } + + // Create a new promise to wrap the callbacks of the popup buttons + return new Promise(function (resolve, reject) { + // Construct the necessary arguments for notification behavior + let options = { + hideClose: true, + eventCallback: (topic, nextRemovalReason, isCancel) => { + if (topic == "removed" && isCancel) { + reject(); + } + }, + }; + let mainAction = { + label: acceptLabel, + accessKey: acceptKey, + callback(event) { + let result = listBox.querySelector( + ".identity-credential-list-item-radio:checked" + ).value; + resolve(parseInt(result)); + }, + }; + let secondaryActions = [ + { + label: cancelLabel, + accessKey: cancelKey, + callback(event) { + reject(); + }, + }, + ]; + + if (headerIconResult) { + let headerIcon = browser.ownerDocument.getElementsByClassName( + "identity-credential-header-icon" + )[0]; + headerIcon.setAttribute("src", headerIconResult); + } + + const headerText = browser.ownerDocument.getElementById( + "identity-credential-header-text" + ); + headerText.textContent = headerMessage; + + // Show the popup + browser.ownerDocument.getElementById( + "identity-credential-provider" + ).hidden = true; + browser.ownerDocument.getElementById( + "identity-credential-policy" + ).hidden = true; + browser.ownerDocument.getElementById( + "identity-credential-account" + ).hidden = false; + browser.ownerDocument.getElementById( + "identity-credential-header" + ).hidden = false; + browser.ownerGlobal.PopupNotifications.show( + browser, + "identity-credential", + "", + "identity-credential-notification-icon", + mainAction, + secondaryActions, + options + ); + }); + } + + /** + * Close all UI from the other methods of this module for the provided window. + * @param {BrowsingContext} browsingContext - The BrowsingContext of the document requesting an identity credential via navigator.credentials.get() + * @returns + */ + close(browsingContext) { + let browser = browsingContext.top.embedderElement; + if (!browser || AppConstants.platform === "android") { + return; + } + let notification = browser.ownerGlobal.PopupNotifications.getNotification( + "identity-credential", + browser + ); + if (notification) { + browser.ownerGlobal.PopupNotifications.remove(notification, true); + } + } +} |