Files
tubestation/toolkit/components/credentialmanagement/CredentialChooserService.sys.mjs
Cristian Tuns a9d8f21da6 Backed out 11 changesets (bug 1915762) for causing node failures in gecko.d.ts CLOSED TREE
Backed out changeset 5c7edcb3b105 (bug 1915762)
Backed out changeset 05691232a461 (bug 1915762)
Backed out changeset 964a29c9a07a (bug 1915762)
Backed out changeset 5d74e419e249 (bug 1915762)
Backed out changeset 677c35e884d3 (bug 1915762)
Backed out changeset 8a7b0b87a0ef (bug 1915762)
Backed out changeset 5e38449cd4d9 (bug 1915762)
Backed out changeset ec8a0553d97a (bug 1915762)
Backed out changeset fb2232e5f3b5 (bug 1915762)
Backed out changeset b290501f5832 (bug 1915762)
Backed out changeset 9955e42e749b (bug 1915762)
2025-04-22 22:15:19 -04:00

467 lines
16 KiB
JavaScript

/**
* 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";
const lazy = {};
ChromeUtils.defineESModuleGetters(lazy, {
PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs",
});
ChromeUtils.defineLazyGetter(lazy, "localization", () => {
return new Localization(["preview/credentialChooser.ftl"], true);
});
XPCOMUtils.defineLazyServiceGetter(
lazy,
"IDNService",
"@mozilla.org/network/idn-service;1",
"nsIIDNService"
);
XPCOMUtils.defineLazyPreferenceGetter(
lazy,
"TESTING_MODE",
"dom.security.credentialmanagement.chooser.testing.enabled",
false
);
/**
* Set an image element's src attribute to a data: url of the favicon for a
* given origin, defaulting to the browser default favicon.
*
* @param {HTMLImageElement} icon The image Element that should have source be the icon result.
* @param {string} origin The origin whose favicon should be used.
*/
async function setIconToFavicon(icon, origin) {
try {
let iconData = await lazy.PlacesUtils.promiseFaviconData(origin);
icon.src = iconData.uri.spec;
} catch {
icon.src = "chrome://global/skin/icons/defaultFavicon.svg";
}
}
/**
* Class implementing the nsICredentialChooserService.
*
* This class shows UI to the user for the Credential Chooser for the
* Credential Management API.
*
* @class CredentialChooserService
*/
export class CredentialChooserService {
classID = Components.ID("{673ddc19-03e2-4b30-a868-06297e8fed89}");
QueryInterface = ChromeUtils.generateQI(["nsICredentialChooserService"]);
/**
* @typedef {object} CredentialArgument
* @property {string} id - The unique identifier for the credential.
* @property {string} type - The type of the credential.
* @property {string} [origin] - The origin associated to the credential.
* @property {UIHints} [uiHints] - UI hints for the credential.
*/
/**
* @typedef {object} UIHints
* @property {string} [name] - The display name for the credential.
* @property {string} [iconURL] - The data URL of the icon for the credential.
* @property {number} [expiresAfter] - The expiration time for the UI hint.
*/
/**
* This function displays the credential chooser UI, allowing the user to make an identity choice.
* Once the user makes a choice from the credentials provided, or dismisses the prompt, we will
* call the callback with that credential, or null in the case of a dismiss.
*
* We also support UI-less testing via choices provided by picking any credential with ID 'wpt-pick-me'
* if the preference 'dom.security.credentialmanagement.chooser.testing.enabled' is true.
*
* @param {BrowsingContext} browsingContext The browsing context of the window calling the Credential Management API.
* @param {Array<CredentialArgument>} credentials The credentials the user should choose from.
* @param {nsICredentialChosenCallback} callback A callback to return the user's credential choice to.
* @returns {nsresult}
*/
async showCredentialChooser(browsingContext, credentials, callback) {
if (!callback) {
callback = { notify: () => {} };
}
if (!credentials.length) {
return Cr.NS_ERROR_INVALID_ARG;
}
// If we are not an active BC, return no choice and bail out.
if (!browsingContext?.currentWindowContext?.isActiveInTab) {
callback.notify(null);
return Cr.NS_OK;
}
if (lazy.TESTING_MODE) {
let match = credentials.find(cred => cred.id == "wpt-pick-me");
if (match) {
if (browsingContext.currentWindowGlobal?.documentPrincipal) {
Services.perms.addFromPrincipal(
browsingContext.currentWindowGlobal.documentPrincipal,
"credential-allow-silent-access^" + match.origin,
Ci.nsIPermissionManager.ALLOW_ACTION,
Ci.nsIPermissionManager.EXPIRE_SESSION
);
Services.perms.addFromPrincipal(
browsingContext.currentWindowGlobal.documentPrincipal,
"credential-allow-silent-access",
Ci.nsIPermissionManager.ALLOW_ACTION,
Ci.nsIPermissionManager.EXPIRE_SESSION
);
}
callback.notify("wpt-pick-me");
} else {
callback.notify(null);
}
return Cr.NS_OK;
}
let browser = browsingContext.topFrameElement;
if (browser?.tagName != "browser") {
return Cr.NS_ERROR_INVALID_ARG;
}
let headerTextElement = browser.ownerDocument.getElementById(
"credential-chooser-header-text"
);
let host = browser.ownerGlobal.gIdentityHandler.getHostForDisplay();
browser.ownerDocument.l10n.setAttributes(
headerTextElement,
"credential-chooser-header",
{
host,
}
);
let faviconPromises = [];
let localizationPromise = lazy.localization.formatMessages([
{ id: "credential-chooser-sign-in-button" },
{ id: "credential-chooser-cancel-button" },
]);
let listBox = browser.ownerDocument.getElementById(
"credential-chooser-entry-selector-container"
);
while (listBox.firstChild) {
listBox.removeChild(listBox.lastChild);
}
let itemTemplate = browser.ownerDocument.getElementById(
"template-credential-entry-list-item"
);
for (let [index, credential] of credentials.entries()) {
let newItem = itemTemplate.content.firstElementChild.cloneNode(true);
// Add the new radio button, including pre-selection and the callback
let [radio] = newItem.getElementsByClassName(
"identity-credential-list-item-radio"
);
radio.value = index;
if (index == 0) {
radio.checked = true;
}
let providerURL = new URL(credential.origin);
let displayDomain = lazy.IDNService.convertToDisplayIDN(
providerURL.host,
{}
);
let [primary] = newItem.getElementsByClassName(
"identity-credential-list-item-label-primary"
);
let [secondary] = newItem.getElementsByClassName(
"identity-credential-list-item-label-secondary"
);
let [icon] = newItem.getElementsByClassName(
"identity-credential-list-item-icon"
);
if (
credential.uiHints &&
(credential.uiHints.expiresAfter == null ||
credential.uiHints.expiresAfter > 0)
) {
primary.textContent = credential.uiHints.name;
browser.ownerDocument.l10n.setAttributes(
secondary,
"credential-chooser-host-descriptor",
{
provider: displayDomain,
}
);
secondary.hidden = false;
icon.src = credential.uiHints.iconURL;
} else {
let doneWithFavicon = setIconToFavicon(icon, credential.origin);
faviconPromises.push(doneWithFavicon);
browser.ownerDocument.l10n.setAttributes(
primary,
"credential-chooser-identity",
{
provider: displayDomain,
}
);
}
// Add the item to the DOM!
listBox.append(newItem);
}
// wait for the labels to be localized before showing the panel
let [accept, cancel] = await localizationPromise;
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;
// wait for icons to be set to prevent favicon jank
await Promise.all(faviconPromises);
// Construct the necessary arguments for notification behavior
let options = {
hideClose: true,
removeOnDismissal: true,
eventCallback: (topic, _nextRemovalReason, _isCancel) => {
if (topic == "removed") {
callback.notify(null);
}
},
};
let mainAction = {
label: acceptLabel,
accessKey: acceptKey,
callback() {
let result = listBox.querySelector(
".identity-credential-list-item-radio:checked"
).value;
if (browsingContext.currentWindowGlobal?.documentPrincipal) {
Services.perms.addFromPrincipal(
browsingContext.currentWindowGlobal.documentPrincipal,
"credential-allow-silent-access^" +
credentials[parseInt(result)].origin,
Ci.nsIPermissionManager.ALLOW_ACTION,
Ci.nsIPermissionManager.EXPIRE_SESSION
);
Services.perms.addFromPrincipal(
browsingContext.currentWindowGlobal.documentPrincipal,
"credential-allow-silent-access",
Ci.nsIPermissionManager.ALLOW_ACTION,
Ci.nsIPermissionManager.EXPIRE_SESSION
);
}
callback.notify(credentials[parseInt(result, 10)].id);
},
};
let secondaryActions = [
{
label: cancelLabel,
accessKey: cancelKey,
callback() {
callback.notify(null);
},
},
];
browser.ownerGlobal.PopupNotifications.show(
browser,
"credential-chooser",
"",
"identity-credential-notification-icon",
mainAction,
secondaryActions,
options
);
return Cr.NS_OK;
}
/**
* Dismiss the credential chooser dialog for this browsing context's window.
*
* @param {BrowsingContext} browsingContext - The top browsing context of the window calling the Credential Management API
*/
cancelCredentialChooser(browsingContext) {
let browser = browsingContext.top.embedderElement;
if (browser?.tagName != "browser") {
return;
}
let notification = browser.ownerGlobal.PopupNotifications.getNotification(
"credential-chooser",
browser
);
if (notification) {
browser.ownerGlobal.PopupNotifications.remove(notification, true);
}
}
/**
* A service function to help any UI. Fetches and serializes images to
* data urls, which can be used in chrome UI.
*
* @param {Window} window - Window which should perform the fetch
* @param {nsIURI} uri - Icon location to be fetched from
* @returns {Promise<string, Error>} The data URI encoded as a string representing the icon that was loaded
*/
async fetchImageToDataURI(window, uri) {
if (uri.protocol === "data:") {
return uri.href;
}
let request = new window.Request(uri.spec, { mode: "cors" });
request.overrideContentPolicyType(Ci.nsIContentPolicy.TYPE_IMAGE);
let blob;
let response = await window.fetch(request);
if (!response.ok) {
return Promise.reject(new Error("HTTP failure on Fetch"));
}
blob = await response.blob();
return new Promise((resolve, reject) => {
let reader = new FileReader();
reader.onloadend = () => resolve(reader.result);
reader.onerror = reject;
reader.readAsDataURL(blob);
});
}
/**
* A helper function that performs precisely the right Fetch for the well-known resource for FedCM.
*
* @param {nsIURI} uri - Well known resource location
* @param {nsIPrincipal} triggeringPrincipal - Principal of the IDP triggering this request
* @returns {Promise} A promise that will be the result of fetching the resource and parsing the body as JSON,
* or reject along the way.
*/
async fetchWellKnown(uri, triggeringPrincipal) {
let request = new Request(uri.spec, {
mode: "no-cors",
referrerPolicy: "no-referrer",
// We use a Null Principal here because we don't want to send any
// cookies or an Origin header here before we get user permission.
triggeringPrincipal: Services.scriptSecurityManager.createNullPrincipal(
triggeringPrincipal.originAttributes
),
// and we want to be able to read the response, so we don't let CORS hide it
// because we are no-cors
neverTaint: true,
credentials: "omit",
headers: [["Accept", "application/json"]],
});
// This overrides the Sec-Fetch-Dest to `webidentity` rather than `empty`
request.overrideContentPolicyType(Ci.nsIContentPolicy.TYPE_WEB_IDENTITY);
let response = await fetch(request);
return response.json();
}
/**
* A helper function that performs precisely the right Fetch for the IDP configuration resource for FedCM.
*
* @param {nsIURI} uri - Well known resource location
* @param {nsIPrincipal} triggeringPrincipal - Principal of the IDP triggering this request
* @returns {Promise} A promise that will be the result of fetching the resource and parsing the body as JSON,
* or reject along the way.
*/
async fetchConfig(uri, triggeringPrincipal) {
let request = new Request(uri.spec, {
mode: "no-cors",
referrerPolicy: "no-referrer",
redirect: "error",
// We use a Null Principal here because we don't want to send any
// cookies or an Origin header here before we get user permission.
triggeringPrincipal: Services.scriptSecurityManager.createNullPrincipal(
triggeringPrincipal.originAttributes
),
// and we want to be able to read the response, so we don't let CORS hide it
// because we are no-cors
neverTaint: true,
credentials: "omit",
headers: [["Accept", "application/json"]],
});
// This overrides the Sec-Fetch-Dest to `webidentity` rather than `empty`
request.overrideContentPolicyType(Ci.nsIContentPolicy.TYPE_WEB_IDENTITY);
let response = await fetch(request);
return response.json();
}
/**
* A helper function that performs precisely the right Fetch for the account list for FedCM.
*
* @param {nsIURI} uri - Well known resource location
* @param {nsIPrincipal} triggeringPrincipal - Principal of the IDP triggering this request
* @returns {Promise} A promise that will be the result of fetching the resource and parsing the body as JSON,
* or reject along the way.
*/
async fetchAccounts(uri, triggeringPrincipal) {
let request = new Request(uri.spec, {
mode: "no-cors",
redirect: "error",
referrerPolicy: "no-referrer",
triggeringPrincipal,
// and we want to be able to read the response, so we don't let CORS hide it
// because we are no-cors
neverTaint: true,
credentials: "include",
headers: [["Accept", "application/json"]],
});
// This overrides the Sec-Fetch-Dest to `webidentity` rather than `empty`
request.overrideContentPolicyType(Ci.nsIContentPolicy.TYPE_WEB_IDENTITY);
let response = await fetch(request);
return response.json();
}
/**
* A helper function that performs precisely the right Fetch for the token request for FedCM.
*
* @param {nsIURI} uri - Well known resource location
* @param {string} body - Body to be sent with the fetch, pre-serialized.
* @param {nsIPrincipal} triggeringPrincipal - Principal of the IDP triggering this request
* @returns {Promise} A promise that will be the result of fetching the resource and parsing the body as JSON,
* or reject along the way.
*/
async fetchToken(uri, body, triggeringPrincipal) {
let request = new Request(uri.spec, {
mode: "cors",
method: "POST",
redirect: "error",
triggeringPrincipal,
body,
credentials: "include",
headers: [["Content-type", "application/x-www-form-urlencoded"]],
});
// This overrides the Sec-Fetch-Dest to `webidentity` rather than `empty`
request.overrideContentPolicyType(Ci.nsIContentPolicy.TYPE_WEB_IDENTITY);
let response = await fetch(request);
return response.json();
}
/**
* A helper function that performs precisely the right Fetch for the disconnect request for FedCM.
*
* @param {nsIURI} uri - Well known resource location
* @param {string} body - Body to be sent with the fetch, pre-serialized.
* @param {nsIPrincipal} triggeringPrincipal - Principal of the IDP triggering this request
* @returns {Promise} A promise that will be the result of fetching the resource and parsing the body as JSON,
* or reject along the way.
*/
async fetchDisconnect(uri, body, triggeringPrincipal) {
let request = new Request(uri.spec, {
mode: "cors",
method: "POST",
redirect: "error",
triggeringPrincipal,
body,
credentials: "include",
headers: [["Content-type", "application/x-www-form-urlencoded"]],
});
// This overrides the Sec-Fetch-Dest to `webidentity` rather than `empty`
request.overrideContentPolicyType(Ci.nsIContentPolicy.TYPE_WEB_IDENTITY);
let response = await fetch(request);
return response.json();
}
}