summaryrefslogtreecommitdiffstats
path: root/toolkit/components/credentialmanagement/IdentityCredentialPromptService.sys.mjs
diff options
context:
space:
mode:
Diffstat (limited to 'toolkit/components/credentialmanagement/IdentityCredentialPromptService.sys.mjs')
-rw-r--r--toolkit/components/credentialmanagement/IdentityCredentialPromptService.sys.mjs751
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);
+ }
+ }
+}