Source code
Revision control
Copy as Markdown
Other Tools
/**
* 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
*/
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
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
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
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);
}
}
}