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 | |
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')
16 files changed, 3348 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); + } + } +} diff --git a/toolkit/components/credentialmanagement/IdentityCredentialStorageService.cpp b/toolkit/components/credentialmanagement/IdentityCredentialStorageService.cpp new file mode 100644 index 0000000000..f3994d1a86 --- /dev/null +++ b/toolkit/components/credentialmanagement/IdentityCredentialStorageService.cpp @@ -0,0 +1,887 @@ +/* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* vim: set ts=8 sts=2 et sw=2 tw=80: */ +/* 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/. */ + +#include "ErrorList.h" +#include "IdentityCredentialStorageService.h" +#include "MainThreadUtils.h" +#include "mozilla/AppShutdown.h" +#include "mozilla/Base64.h" +#include "mozilla/ClearOnShutdown.h" +#include "mozilla/Components.h" +#include "mozilla/OriginAttributes.h" +#include "mozilla/Services.h" +#include "mozilla/StaticPrefs_dom.h" +#include "mozilla/StaticPtr.h" +#include "mozIStorageService.h" +#include "mozIStorageConnection.h" +#include "mozIStorageStatement.h" +#include "mozStorageCID.h" +#include "nsAppDirectoryServiceDefs.h" +#include "nsComponentManagerUtils.h" +#include "nsCRT.h" +#include "nsDebug.h" +#include "nsDirectoryServiceUtils.h" +#include "nsIObserverService.h" +#include "nsIWritablePropertyBag2.h" +#include "nsServiceManagerUtils.h" +#include "nsThreadUtils.h" +#include "nsVariant.h" +#include "prtime.h" + +#define ACCOUNT_STATE_FILENAME "credentialstate.sqlite"_ns +#define SCHEMA_VERSION 1 +#define MODIFIED_NOW PR_Now() + +namespace mozilla { + +StaticRefPtr<IdentityCredentialStorageService> + gIdentityCredentialStorageService; + +NS_IMPL_ISUPPORTS(IdentityCredentialStorageService, + nsIIdentityCredentialStorageService, nsIObserver, + nsIAsyncShutdownBlocker) + +already_AddRefed<IdentityCredentialStorageService> +IdentityCredentialStorageService::GetSingleton() { + AssertIsOnMainThread(); + MOZ_ASSERT(XRE_IsParentProcess()); + if (!gIdentityCredentialStorageService) { + gIdentityCredentialStorageService = new IdentityCredentialStorageService(); + ClearOnShutdown(&gIdentityCredentialStorageService); + nsresult rv = gIdentityCredentialStorageService->Init(); + NS_ENSURE_SUCCESS(rv, nullptr); + } + RefPtr<IdentityCredentialStorageService> service = + gIdentityCredentialStorageService; + return service.forget(); +} + +NS_IMETHODIMP IdentityCredentialStorageService::GetName(nsAString& aName) { + aName = u"IdentityCredentialStorageService: Flushing data"_ns; + return NS_OK; +} + +NS_IMETHODIMP IdentityCredentialStorageService::BlockShutdown( + nsIAsyncShutdownClient* aClient) { + MOZ_ASSERT(NS_IsMainThread()); + nsresult rv = WaitForInitialization(); + NS_ENSURE_SUCCESS(rv, rv); + + MonitorAutoLock lock(mMonitor); + mShuttingDown.Flip(); + + if (mMemoryDatabaseConnection) { + Unused << mMemoryDatabaseConnection->Close(); + mMemoryDatabaseConnection = nullptr; + } + + RefPtr<IdentityCredentialStorageService> self = this; + mBackgroundThread->Dispatch( + NS_NewRunnableFunction( + "IdentityCredentialStorageService::BlockShutdown", + [self]() { + MonitorAutoLock lock(self->mMonitor); + + MOZ_ASSERT(self->mPendingWrites == 0); + + if (self->mDiskDatabaseConnection) { + Unused << self->mDiskDatabaseConnection->Close(); + self->mDiskDatabaseConnection = nullptr; + } + + self->mFinalized.Flip(); + self->mMonitor.NotifyAll(); + NS_DispatchToMainThread(NS_NewRunnableFunction( + "IdentityCredentialStorageService::BlockShutdown " + "- mainthread callback", + [self]() { self->Finalize(); })); + }), + NS_DISPATCH_EVENT_MAY_BLOCK); + + return NS_OK; +} + +NS_IMETHODIMP +IdentityCredentialStorageService::GetState(nsIPropertyBag** aBagOut) { + return NS_OK; +} + +already_AddRefed<nsIAsyncShutdownClient> +IdentityCredentialStorageService::GetAsyncShutdownBarrier() const { + nsresult rv; + nsCOMPtr<nsIAsyncShutdownService> svc = components::AsyncShutdown::Service(); + MOZ_RELEASE_ASSERT(svc); + + nsCOMPtr<nsIAsyncShutdownClient> client; + rv = svc->GetProfileBeforeChange(getter_AddRefs(client)); + MOZ_RELEASE_ASSERT(NS_SUCCEEDED(rv)); + MOZ_RELEASE_ASSERT(client); + return client.forget(); +} + +nsresult IdentityCredentialStorageService::Init() { + AssertIsOnMainThread(); + + if (AppShutdown::IsInOrBeyond(ShutdownPhase::AppShutdownConfirmed)) { + MonitorAutoLock lock(mMonitor); + mShuttingDown.Flip(); + return NS_ERROR_ILLEGAL_DURING_SHUTDOWN; + } + + nsCOMPtr<nsIAsyncShutdownClient> asc = GetAsyncShutdownBarrier(); + if (!asc) { + return NS_ERROR_NOT_AVAILABLE; + } + nsresult rv = asc->AddBlocker(this, NS_LITERAL_STRING_FROM_CSTRING(__FILE__), + __LINE__, u""_ns); + NS_ENSURE_SUCCESS(rv, rv); + + rv = NS_GetSpecialDirectory(NS_APP_USER_PROFILE_50_DIR, + getter_AddRefs(mDatabaseFile)); + NS_ENSURE_SUCCESS(rv, rv); + rv = mDatabaseFile->AppendNative(ACCOUNT_STATE_FILENAME); + NS_ENSURE_SUCCESS(rv, rv); + + // Register the PBMode cleaner (IdentityCredentialStorageService::Observe) as + // an observer. + nsCOMPtr<nsIObserverService> observerService = + mozilla::services::GetObserverService(); + NS_ENSURE_TRUE(observerService, NS_ERROR_FAILURE); + observerService->AddObserver(this, "last-pb-context-exited", false); + + rv = GetMemoryDatabaseConnection(); + if (NS_WARN_IF(NS_FAILED(rv))) { + MonitorAutoLock lock(mMonitor); + mErrored.Flip(); + return rv; + } + + NS_ENSURE_SUCCESS( + NS_CreateBackgroundTaskQueue("IdentityCredentialStorage", + getter_AddRefs(mBackgroundThread)), + NS_ERROR_FAILURE); + + RefPtr<IdentityCredentialStorageService> self = this; + + mBackgroundThread->Dispatch( + NS_NewRunnableFunction("IdentityCredentialStorageService::Init", + [self]() { + MonitorAutoLock lock(self->mMonitor); + nsresult rv = self->GetDiskDatabaseConnection(); + if (NS_WARN_IF(NS_FAILED(rv))) { + self->mErrored.Flip(); + self->mMonitor.Notify(); + return; + } + + rv = self->LoadMemoryTableFromDisk(); + if (NS_WARN_IF(NS_FAILED(rv))) { + self->mErrored.Flip(); + self->mMonitor.Notify(); + return; + } + + self->mInitialized.Flip(); + self->mMonitor.Notify(); + }), + NS_DISPATCH_EVENT_MAY_BLOCK); + + return NS_OK; +} + +nsresult IdentityCredentialStorageService::WaitForInitialization() { + MOZ_ASSERT(NS_IsMainThread(), + "Must only wait for initialization in the main thread."); + MonitorAutoLock lock(mMonitor); + while (!mInitialized && !mErrored && !mShuttingDown) { + mMonitor.Wait(); + } + if (mErrored) { + return NS_ERROR_FAILURE; + } + if (mShuttingDown) { + return NS_ERROR_NOT_AVAILABLE; + } + return NS_OK; +} + +void IdentityCredentialStorageService::Finalize() { + nsCOMPtr<nsIAsyncShutdownClient> asc = GetAsyncShutdownBarrier(); + MOZ_ASSERT(asc); + DebugOnly<nsresult> rv = asc->RemoveBlocker(this); + MOZ_ASSERT(NS_SUCCEEDED(rv)); +} + +// static +nsresult IdentityCredentialStorageService::ValidatePrincipal( + nsIPrincipal* aPrincipal) { + // We add some constraints on the RP principal where it is provided to reduce + // edge cases in implementation. These are reasonable constraints with the + // semantics of the store: it must be a http or https content principal. + NS_ENSURE_ARG_POINTER(aPrincipal); + NS_ENSURE_TRUE(aPrincipal->GetIsContentPrincipal(), NS_ERROR_FAILURE); + nsCString scheme; + nsresult rv = aPrincipal->GetScheme(scheme); + NS_ENSURE_SUCCESS(rv, rv); + NS_ENSURE_TRUE(scheme.Equals("http"_ns) || scheme.Equals("https"_ns), + NS_ERROR_FAILURE); + return NS_OK; +} + +nsresult IdentityCredentialStorageService::GetMemoryDatabaseConnection() { + return IdentityCredentialStorageService::GetDatabaseConnectionInternal( + getter_AddRefs(mMemoryDatabaseConnection), nullptr); +} + +nsresult IdentityCredentialStorageService::GetDiskDatabaseConnection() { + NS_ENSURE_TRUE(mDatabaseFile, NS_ERROR_NULL_POINTER); + return IdentityCredentialStorageService::GetDatabaseConnectionInternal( + getter_AddRefs(mDiskDatabaseConnection), mDatabaseFile); +} + +// static +nsresult IdentityCredentialStorageService::GetDatabaseConnectionInternal( + mozIStorageConnection** aDatabase, nsIFile* aFile) { + NS_ENSURE_TRUE(aDatabase, NS_ERROR_UNEXPECTED); + NS_ENSURE_STATE(!(*aDatabase)); + nsCOMPtr<mozIStorageService> storage = + do_GetService(MOZ_STORAGE_SERVICE_CONTRACTID); + NS_ENSURE_TRUE(storage, NS_ERROR_UNEXPECTED); + nsresult rv; + + if (aFile) { + rv = storage->OpenDatabase(aFile, mozIStorageService::CONNECTION_DEFAULT, + aDatabase); + if (rv == NS_ERROR_FILE_CORRUPTED) { + rv = aFile->Remove(false); + NS_ENSURE_SUCCESS(rv, rv); + rv = storage->OpenDatabase(aFile, mozIStorageService::CONNECTION_DEFAULT, + aDatabase); + } + NS_ENSURE_SUCCESS(rv, rv); + } else { + rv = storage->OpenSpecialDatabase( + kMozStorageMemoryStorageKey, "icsprivatedb"_ns, + mozIStorageService::CONNECTION_DEFAULT, aDatabase); + NS_ENSURE_SUCCESS(rv, rv); + } + + NS_ENSURE_TRUE(*aDatabase, NS_ERROR_UNEXPECTED); + bool ready = false; + (*aDatabase)->GetConnectionReady(&ready); + NS_ENSURE_TRUE(ready, NS_ERROR_UNEXPECTED); + rv = EnsureTable(*aDatabase); + NS_ENSURE_SUCCESS(rv, rv); + return NS_OK; +} + +// static +nsresult IdentityCredentialStorageService::EnsureTable( + mozIStorageConnection* aDatabase) { + NS_ENSURE_ARG_POINTER(aDatabase); + bool tableExists = false; + aDatabase->TableExists("identity"_ns, &tableExists); + if (!tableExists) { + // Currently there is only one schema version, so we just need to create the + // table. The definition uses no explicit rowid column, instead primary + // keying on the tuple defined in the spec. We store two bits and some + // additional data to make integration with the ClearDataService + // easier/possible. + nsresult rv = aDatabase->SetSchemaVersion(SCHEMA_VERSION); + NS_ENSURE_SUCCESS(rv, rv); + rv = aDatabase->ExecuteSimpleSQL( + "CREATE TABLE identity (" + "rpOrigin TEXT NOT NULL" + ",idpOrigin TEXT NOT NULL" + ",credentialId TEXT NOT NULL" + ",registered INTEGER" + ",allowLogout INTEGER" + ",modificationTime INTEGER" + ",rpBaseDomain TEXT" + ",PRIMARY KEY (rpOrigin, idpOrigin, credentialId)" + ")"_ns); + NS_ENSURE_SUCCESS(rv, rv); + } + return NS_OK; +} + +nsresult IdentityCredentialStorageService::LoadMemoryTableFromDisk() { + MOZ_ASSERT(!NS_IsMainThread(), + "Must not load the table from disk in the main thread."); + auto constexpr selectAllQuery = + "SELECT rpOrigin, idpOrigin, credentialId, registered, allowLogout, " + "modificationTime, rpBaseDomain FROM identity;"_ns; + auto constexpr insertQuery = + "INSERT INTO identity(rpOrigin, idpOrigin, credentialId, registered, " + "allowLogout, modificationTime, rpBaseDomain) VALUES (?1, ?2, ?3, ?4, " + "?5, ?6, ?7);"_ns; + + nsCOMPtr<mozIStorageStatement> writeStmt; + nsresult rv = mMemoryDatabaseConnection->CreateStatement( + insertQuery, getter_AddRefs(writeStmt)); + NS_ENSURE_SUCCESS(rv, rv); + + nsCOMPtr<mozIStorageStatement> readStmt; + rv = mDiskDatabaseConnection->CreateStatement(selectAllQuery, + getter_AddRefs(readStmt)); + NS_ENSURE_SUCCESS(rv, rv); + + bool hasResult; + while (NS_SUCCEEDED(readStmt->ExecuteStep(&hasResult)) && hasResult) { + int64_t registered, allowLogout, modificationTime; + nsCString rpOrigin, idpOrigin, credentialID, rpBaseDomain; + + // Read values from disk query + rv = readStmt->GetUTF8String(0, rpOrigin); + NS_ENSURE_SUCCESS(rv, rv); + rv = readStmt->GetUTF8String(1, idpOrigin); + NS_ENSURE_SUCCESS(rv, rv); + rv = readStmt->GetUTF8String(2, credentialID); + NS_ENSURE_SUCCESS(rv, rv); + rv = readStmt->GetInt64(3, ®istered); + NS_ENSURE_SUCCESS(rv, rv); + rv = readStmt->GetInt64(4, &allowLogout); + NS_ENSURE_SUCCESS(rv, rv); + rv = readStmt->GetInt64(5, &modificationTime); + NS_ENSURE_SUCCESS(rv, rv); + rv = readStmt->GetUTF8String(6, rpBaseDomain); + NS_ENSURE_SUCCESS(rv, rv); + + // Write values to memory database + rv = writeStmt->BindUTF8StringByIndex(0, rpOrigin); + NS_ENSURE_SUCCESS(rv, rv); + rv = writeStmt->BindUTF8StringByIndex(1, idpOrigin); + NS_ENSURE_SUCCESS(rv, rv); + rv = writeStmt->BindUTF8StringByIndex(2, credentialID); + NS_ENSURE_SUCCESS(rv, rv); + rv = writeStmt->BindInt64ByIndex(3, registered); + NS_ENSURE_SUCCESS(rv, rv); + rv = writeStmt->BindInt64ByIndex(4, allowLogout); + NS_ENSURE_SUCCESS(rv, rv); + rv = writeStmt->BindInt64ByIndex(5, modificationTime); + NS_ENSURE_SUCCESS(rv, rv); + rv = writeStmt->BindUTF8StringByIndex(6, rpBaseDomain); + NS_ENSURE_SUCCESS(rv, rv); + rv = writeStmt->Execute(); + NS_ENSURE_SUCCESS(rv, rv); + } + return NS_OK; +} + +void IdentityCredentialStorageService::IncrementPendingWrites() { + MonitorAutoLock lock(mMonitor); + MOZ_ASSERT(mPendingWrites < std::numeric_limits<uint32_t>::max()); + mPendingWrites++; +} + +void IdentityCredentialStorageService::DecrementPendingWrites() { + MonitorAutoLock lock(mMonitor); + MOZ_ASSERT(mPendingWrites > 0); + mPendingWrites--; +} + +// static +nsresult IdentityCredentialStorageService::UpsertData( + mozIStorageConnection* aDatabaseConnection, nsIPrincipal* aRPPrincipal, + nsIPrincipal* aIDPPrincipal, nsACString const& aCredentialID, + bool aRegistered, bool aAllowLogout) { + NS_ENSURE_ARG_POINTER(aDatabaseConnection); + NS_ENSURE_ARG_POINTER(aRPPrincipal); + NS_ENSURE_ARG_POINTER(aIDPPrincipal); + nsresult rv; + constexpr auto upsert_query = + "INSERT INTO identity(rpOrigin, idpOrigin, credentialId, " + "registered, allowLogout, modificationTime, rpBaseDomain)" + "VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)" + "ON CONFLICT(rpOrigin, idpOrigin, credentialId)" + "DO UPDATE SET registered=excluded.registered, " + "allowLogout=excluded.allowLogout, " + "modificationTime=excluded.modificationTime"_ns; + + nsCOMPtr<mozIStorageStatement> stmt; + rv = aDatabaseConnection->CreateStatement(upsert_query, getter_AddRefs(stmt)); + NS_ENSURE_SUCCESS(rv, rv); + nsCString rpOrigin; + rv = aRPPrincipal->GetOrigin(rpOrigin); + NS_ENSURE_SUCCESS(rv, rv); + nsCString idpOrigin; + rv = aIDPPrincipal->GetOrigin(idpOrigin); + NS_ENSURE_SUCCESS(rv, rv); + nsCString rpBaseDomain; + rv = aRPPrincipal->GetBaseDomain(rpBaseDomain); + NS_ENSURE_SUCCESS(rv, rv); + rv = stmt->BindUTF8StringByIndex(0, rpOrigin); + NS_ENSURE_SUCCESS(rv, rv); + rv = stmt->BindUTF8StringByIndex(1, idpOrigin); + NS_ENSURE_SUCCESS(rv, rv); + rv = stmt->BindUTF8StringByIndex(2, aCredentialID); + NS_ENSURE_SUCCESS(rv, rv); + rv = stmt->BindInt64ByIndex(3, aRegistered ? 1 : 0); + NS_ENSURE_SUCCESS(rv, rv); + rv = stmt->BindInt64ByIndex(4, aAllowLogout ? 1 : 0); + NS_ENSURE_SUCCESS(rv, rv); + rv = stmt->BindInt64ByIndex(5, MODIFIED_NOW); + NS_ENSURE_SUCCESS(rv, rv); + rv = stmt->BindUTF8StringByIndex(6, rpBaseDomain); + NS_ENSURE_SUCCESS(rv, rv); + rv = stmt->Execute(); + NS_ENSURE_SUCCESS(rv, rv); + return NS_OK; +} + +// static +nsresult IdentityCredentialStorageService::DeleteData( + mozIStorageConnection* aDatabaseConnection, nsIPrincipal* aRPPrincipal, + nsIPrincipal* aIDPPrincipal, nsACString const& aCredentialID) { + NS_ENSURE_ARG_POINTER(aDatabaseConnection); + NS_ENSURE_ARG_POINTER(aRPPrincipal); + NS_ENSURE_ARG_POINTER(aIDPPrincipal); + auto constexpr deleteQuery = + "DELETE FROM identity WHERE rpOrigin=?1 AND idpOrigin=?2 AND " + "credentialId=?3"_ns; + nsCOMPtr<mozIStorageStatement> stmt; + nsresult rv = + aDatabaseConnection->CreateStatement(deleteQuery, getter_AddRefs(stmt)); + NS_ENSURE_SUCCESS(rv, rv); + nsCString rpOrigin; + rv = aRPPrincipal->GetOrigin(rpOrigin); + NS_ENSURE_SUCCESS(rv, rv); + nsCString idpOrigin; + rv = aIDPPrincipal->GetOrigin(idpOrigin); + NS_ENSURE_SUCCESS(rv, rv); + rv = stmt->BindUTF8StringByIndex(0, rpOrigin); + NS_ENSURE_SUCCESS(rv, rv); + rv = stmt->BindUTF8StringByIndex(1, idpOrigin); + NS_ENSURE_SUCCESS(rv, rv); + rv = stmt->BindUTF8StringByIndex(2, aCredentialID); + NS_ENSURE_SUCCESS(rv, rv); + rv = stmt->Execute(); + NS_ENSURE_SUCCESS(rv, rv); + return NS_OK; +} + +// static +nsresult IdentityCredentialStorageService::ClearData( + mozIStorageConnection* aDatabaseConnection) { + NS_ENSURE_ARG_POINTER(aDatabaseConnection); + nsresult rv = + aDatabaseConnection->ExecuteSimpleSQL("DELETE FROM identity;"_ns); + NS_ENSURE_SUCCESS(rv, rv); + return NS_OK; +} + +// static +nsresult +IdentityCredentialStorageService::DeleteDataFromOriginAttributesPattern( + mozIStorageConnection* aDatabaseConnection, + OriginAttributesPattern const& aOriginAttributesPattern) { + NS_ENSURE_ARG_POINTER(aDatabaseConnection); + nsCOMPtr<mozIStorageFunction> patternMatchFunction( + new OriginAttrsPatternMatchOriginSQLFunction(aOriginAttributesPattern)); + + nsresult rv = aDatabaseConnection->CreateFunction( + "ORIGIN_ATTRS_PATTERN_MATCH_ORIGIN"_ns, 1, patternMatchFunction); + NS_ENSURE_SUCCESS(rv, rv); + + rv = aDatabaseConnection->ExecuteSimpleSQL( + "DELETE FROM identity WHERE " + "ORIGIN_ATTRS_PATTERN_MATCH_ORIGIN(rpOrigin);"_ns); + NS_ENSURE_SUCCESS(rv, rv); + + rv = aDatabaseConnection->RemoveFunction( + "ORIGIN_ATTRS_PATTERN_MATCH_ORIGIN"_ns); + NS_ENSURE_SUCCESS(rv, rv); + + return NS_OK; +} + +// static +nsresult IdentityCredentialStorageService::DeleteDataFromTimeRange( + mozIStorageConnection* aDatabaseConnection, int64_t aStart, int64_t aEnd) { + NS_ENSURE_ARG_POINTER(aDatabaseConnection); + auto constexpr deleteTimeQuery = + "DELETE FROM identity WHERE modificationTime > ?1 and modificationTime " + "< ?2"_ns; + nsCOMPtr<mozIStorageStatement> stmt; + nsresult rv = aDatabaseConnection->CreateStatement(deleteTimeQuery, + getter_AddRefs(stmt)); + NS_ENSURE_SUCCESS(rv, rv); + rv = stmt->BindInt64ByIndex(0, aStart); + NS_ENSURE_SUCCESS(rv, rv); + rv = stmt->BindInt64ByIndex(1, aEnd); + NS_ENSURE_SUCCESS(rv, rv); + rv = stmt->Execute(); + NS_ENSURE_SUCCESS(rv, rv); + return NS_OK; +} + +// static +nsresult IdentityCredentialStorageService::DeleteDataFromPrincipal( + mozIStorageConnection* aDatabaseConnection, nsIPrincipal* aPrincipal) { + NS_ENSURE_ARG_POINTER(aDatabaseConnection); + NS_ENSURE_ARG_POINTER(aPrincipal); + + nsCString rpOrigin; + nsresult rv = aPrincipal->GetOrigin(rpOrigin); + NS_ENSURE_SUCCESS(rv, rv); + auto constexpr deletePrincipalQuery = + "DELETE FROM identity WHERE rpOrigin=?1"_ns; + nsCOMPtr<mozIStorageStatement> stmt; + rv = aDatabaseConnection->CreateStatement(deletePrincipalQuery, + getter_AddRefs(stmt)); + NS_ENSURE_SUCCESS(rv, rv); + rv = stmt->BindUTF8StringByIndex(0, rpOrigin); + NS_ENSURE_SUCCESS(rv, rv); + rv = stmt->Execute(); + NS_ENSURE_SUCCESS(rv, rv); + return NS_OK; +} + +// static +nsresult IdentityCredentialStorageService::DeleteDataFromBaseDomain( + mozIStorageConnection* aDatabaseConnection, nsACString const& aBaseDomain) { + NS_ENSURE_ARG_POINTER(aDatabaseConnection); + + auto constexpr deleteBaseDomainQuery = + "DELETE FROM identity WHERE rpBaseDomain=?1"_ns; + nsCOMPtr<mozIStorageStatement> stmt; + nsresult rv = aDatabaseConnection->CreateStatement(deleteBaseDomainQuery, + getter_AddRefs(stmt)); + NS_ENSURE_SUCCESS(rv, rv); + rv = stmt->BindUTF8StringByIndex(0, aBaseDomain); + NS_ENSURE_SUCCESS(rv, rv); + rv = stmt->Execute(); + NS_ENSURE_SUCCESS(rv, rv); + return NS_OK; +} + +NS_IMETHODIMP IdentityCredentialStorageService::SetState( + nsIPrincipal* aRPPrincipal, nsIPrincipal* aIDPPrincipal, + nsACString const& aCredentialID, bool aRegistered, bool aAllowLogout) { + AssertIsOnMainThread(); + NS_ENSURE_ARG_POINTER(aRPPrincipal); + NS_ENSURE_ARG_POINTER(aIDPPrincipal); + + nsresult rv; + rv = WaitForInitialization(); + NS_ENSURE_SUCCESS(rv, rv); + rv = IdentityCredentialStorageService::ValidatePrincipal(aRPPrincipal); + NS_ENSURE_SUCCESS(rv, rv); + + rv = UpsertData(mMemoryDatabaseConnection, aRPPrincipal, aIDPPrincipal, + aCredentialID, aRegistered, aAllowLogout); + NS_ENSURE_SUCCESS(rv, rv); + + IncrementPendingWrites(); + RefPtr<IdentityCredentialStorageService> self = this; + RefPtr<nsIPrincipal> rpPrincipal = aRPPrincipal; + RefPtr<nsIPrincipal> idpPrincipal = aIDPPrincipal; + nsCString credentialID(aCredentialID); + mBackgroundThread->Dispatch( + NS_NewRunnableFunction("IdentityCredentialStorageService::Init", + [self, rpPrincipal, idpPrincipal, credentialID, + aRegistered, aAllowLogout]() { + nsresult rv = UpsertData( + self->mDiskDatabaseConnection, rpPrincipal, + idpPrincipal, credentialID, aRegistered, + aAllowLogout); + NS_ENSURE_SUCCESS_VOID(rv); + self->DecrementPendingWrites(); + }), + NS_DISPATCH_EVENT_MAY_BLOCK); + return NS_OK; +} + +NS_IMETHODIMP IdentityCredentialStorageService::GetState( + nsIPrincipal* aRPPrincipal, nsIPrincipal* aIDPPrincipal, + nsACString const& aCredentialID, bool* aRegistered, bool* aAllowLogout) { + AssertIsOnMainThread(); + NS_ENSURE_ARG_POINTER(aRPPrincipal); + NS_ENSURE_ARG_POINTER(aIDPPrincipal); + + nsresult rv; + rv = WaitForInitialization(); + NS_ENSURE_SUCCESS(rv, rv); + rv = IdentityCredentialStorageService::ValidatePrincipal(aRPPrincipal); + NS_ENSURE_SUCCESS(rv, rv); + + auto constexpr selectQuery = + "SELECT registered, allowLogout FROM identity WHERE rpOrigin=?1 AND " + "idpOrigin=?2 AND credentialId=?3"_ns; + nsCOMPtr<mozIStorageStatement> stmt; + rv = mMemoryDatabaseConnection->CreateStatement(selectQuery, + getter_AddRefs(stmt)); + NS_ENSURE_SUCCESS(rv, rv); + + nsCString rpOrigin; + nsCString idpOrigin; + rv = aRPPrincipal->GetOrigin(rpOrigin); + NS_ENSURE_SUCCESS(rv, rv); + rv = aIDPPrincipal->GetOrigin(idpOrigin); + NS_ENSURE_SUCCESS(rv, rv); + + rv = stmt->BindUTF8StringByIndex(0, rpOrigin); + NS_ENSURE_SUCCESS(rv, rv); + rv = stmt->BindUTF8StringByIndex(1, idpOrigin); + NS_ENSURE_SUCCESS(rv, rv); + rv = stmt->BindUTF8StringByIndex(2, aCredentialID); + NS_ENSURE_SUCCESS(rv, rv); + + bool hasResult; + // If we find a result, return it + if (NS_SUCCEEDED(stmt->ExecuteStep(&hasResult)) && hasResult) { + int64_t registeredInt, allowLogoutInt; + rv = stmt->GetInt64(0, ®isteredInt); + NS_ENSURE_SUCCESS(rv, rv); + rv = stmt->GetInt64(1, &allowLogoutInt); + NS_ENSURE_SUCCESS(rv, rv); + *aRegistered = registeredInt != 0; + *aAllowLogout = allowLogoutInt != 0; + return NS_OK; + } + + // The tuple was not found on disk or in memory, use the defaults. + *aRegistered = false; + *aAllowLogout = false; + return NS_OK; +} + +NS_IMETHODIMP IdentityCredentialStorageService::Delete( + nsIPrincipal* aRPPrincipal, nsIPrincipal* aIDPPrincipal, + nsACString const& aCredentialID) { + AssertIsOnMainThread(); + NS_ENSURE_ARG_POINTER(aRPPrincipal); + NS_ENSURE_ARG_POINTER(aIDPPrincipal); + + nsresult rv; + rv = WaitForInitialization(); + NS_ENSURE_SUCCESS(rv, rv); + rv = IdentityCredentialStorageService::ValidatePrincipal(aRPPrincipal); + NS_ENSURE_SUCCESS(rv, rv); + + rv = DeleteData(mMemoryDatabaseConnection, aRPPrincipal, aIDPPrincipal, + aCredentialID); + NS_ENSURE_SUCCESS(rv, rv); + + IncrementPendingWrites(); + RefPtr<IdentityCredentialStorageService> self = this; + RefPtr<nsIPrincipal> rpPrincipal = aRPPrincipal; + RefPtr<nsIPrincipal> idpPrincipal = aIDPPrincipal; + nsCString credentialID(aCredentialID); + mBackgroundThread->Dispatch( + NS_NewRunnableFunction("IdentityCredentialStorageService::Init", + [self, rpPrincipal, idpPrincipal, credentialID]() { + nsresult rv = DeleteData( + self->mDiskDatabaseConnection, rpPrincipal, + idpPrincipal, credentialID); + NS_ENSURE_SUCCESS_VOID(rv); + self->DecrementPendingWrites(); + }), + NS_DISPATCH_EVENT_MAY_BLOCK); + + return NS_OK; +} + +NS_IMETHODIMP IdentityCredentialStorageService::Clear() { + AssertIsOnMainThread(); + nsresult rv; + rv = WaitForInitialization(); + NS_ENSURE_SUCCESS(rv, rv); + rv = ClearData(mMemoryDatabaseConnection); + NS_ENSURE_SUCCESS(rv, rv); + IncrementPendingWrites(); + RefPtr<IdentityCredentialStorageService> self = this; + mBackgroundThread->Dispatch( + NS_NewRunnableFunction("IdentityCredentialStorageService::Init", + [self]() { + nsresult rv = + ClearData(self->mDiskDatabaseConnection); + NS_ENSURE_SUCCESS_VOID(rv); + self->DecrementPendingWrites(); + }), + NS_DISPATCH_EVENT_MAY_BLOCK); + + return NS_OK; +} + +NS_IMETHODIMP +IdentityCredentialStorageService::DeleteFromOriginAttributesPattern( + nsAString const& aOriginAttributesPattern) { + AssertIsOnMainThread(); + NS_ENSURE_FALSE(aOriginAttributesPattern.IsEmpty(), NS_ERROR_FAILURE); + OriginAttributesPattern oaPattern; + if (!oaPattern.Init(aOriginAttributesPattern)) { + NS_ERROR("Could not parse the argument for OriginAttributes"); + return NS_ERROR_FAILURE; + } + nsresult rv; + rv = WaitForInitialization(); + NS_ENSURE_SUCCESS(rv, rv); + rv = DeleteDataFromOriginAttributesPattern(mMemoryDatabaseConnection, + oaPattern); + NS_ENSURE_SUCCESS(rv, rv); + IncrementPendingWrites(); + RefPtr<IdentityCredentialStorageService> self = this; + mBackgroundThread->Dispatch( + NS_NewRunnableFunction( + "IdentityCredentialStorageService::Init", + [self, oaPattern]() { + nsresult rv = DeleteDataFromOriginAttributesPattern( + self->mDiskDatabaseConnection, oaPattern); + NS_ENSURE_SUCCESS_VOID(rv); + self->DecrementPendingWrites(); + }), + NS_DISPATCH_EVENT_MAY_BLOCK); + return NS_OK; +} + +NS_IMETHODIMP IdentityCredentialStorageService::DeleteFromTimeRange( + int64_t aStart, int64_t aEnd) { + AssertIsOnMainThread(); + nsresult rv; + rv = WaitForInitialization(); + NS_ENSURE_SUCCESS(rv, rv); + rv = DeleteDataFromTimeRange(mMemoryDatabaseConnection, aStart, aEnd); + NS_ENSURE_SUCCESS(rv, rv); + IncrementPendingWrites(); + RefPtr<IdentityCredentialStorageService> self = this; + mBackgroundThread->Dispatch( + NS_NewRunnableFunction("IdentityCredentialStorageService::Init", + [self, aStart, aEnd]() { + nsresult rv = DeleteDataFromTimeRange( + self->mDiskDatabaseConnection, aStart, aEnd); + NS_ENSURE_SUCCESS_VOID(rv); + self->DecrementPendingWrites(); + }), + NS_DISPATCH_EVENT_MAY_BLOCK); + return NS_OK; +} + +NS_IMETHODIMP IdentityCredentialStorageService:: + IdentityCredentialStorageService::DeleteFromPrincipal( + nsIPrincipal* aRPPrincipal) { + AssertIsOnMainThread(); + NS_ENSURE_ARG_POINTER(aRPPrincipal); + nsresult rv = + IdentityCredentialStorageService::ValidatePrincipal(aRPPrincipal); + NS_ENSURE_SUCCESS(rv, rv); + + rv = DeleteDataFromPrincipal(mMemoryDatabaseConnection, aRPPrincipal); + NS_ENSURE_SUCCESS(rv, rv); + IncrementPendingWrites(); + RefPtr<IdentityCredentialStorageService> self = this; + RefPtr<nsIPrincipal> principal = aRPPrincipal; + mBackgroundThread->Dispatch( + NS_NewRunnableFunction("IdentityCredentialStorageService::Init", + [self, principal]() { + nsresult rv = DeleteDataFromPrincipal( + self->mDiskDatabaseConnection, principal); + NS_ENSURE_SUCCESS_VOID(rv); + self->DecrementPendingWrites(); + }), + NS_DISPATCH_EVENT_MAY_BLOCK); + + return NS_OK; +} + +NS_IMETHODIMP IdentityCredentialStorageService::DeleteFromBaseDomain( + nsACString const& aBaseDomain) { + AssertIsOnMainThread(); + nsresult rv; + rv = WaitForInitialization(); + NS_ENSURE_SUCCESS(rv, rv); + rv = DeleteDataFromBaseDomain(mMemoryDatabaseConnection, aBaseDomain); + NS_ENSURE_SUCCESS(rv, rv); + IncrementPendingWrites(); + RefPtr<IdentityCredentialStorageService> self = this; + nsCString baseDomain(aBaseDomain); + mBackgroundThread->Dispatch( + NS_NewRunnableFunction("IdentityCredentialStorageService::Init", + [self, baseDomain]() { + nsresult rv = DeleteDataFromBaseDomain( + self->mDiskDatabaseConnection, baseDomain); + NS_ENSURE_SUCCESS_VOID(rv); + self->DecrementPendingWrites(); + }), + NS_DISPATCH_EVENT_MAY_BLOCK); + return NS_OK; +} + +NS_IMETHODIMP +IdentityCredentialStorageService::Observe(nsISupports* aSubject, + const char* aTopic, + const char16_t* aData) { + AssertIsOnMainThread(); + // Double check that we have the right topic. + if (!nsCRT::strcmp(aTopic, "last-pb-context-exited")) { + MonitorAutoLock lock(mMonitor); + if (mInitialized && mMemoryDatabaseConnection) { + nsCOMPtr<mozIStorageFunction> patternMatchFunction( + new PrivateBrowsingOriginSQLFunction()); + nsresult rv = mMemoryDatabaseConnection->CreateFunction( + "PRIVATE_BROWSING_PATTERN_MATCH_ORIGIN"_ns, 1, patternMatchFunction); + NS_ENSURE_SUCCESS(rv, rv); + + rv = mMemoryDatabaseConnection->ExecuteSimpleSQL( + "DELETE FROM identity WHERE " + "PRIVATE_BROWSING_PATTERN_MATCH_ORIGIN(rpOrigin);"_ns); + NS_ENSURE_SUCCESS(rv, rv); + + rv = mMemoryDatabaseConnection->RemoveFunction( + "PRIVATE_BROWSING_PATTERN_MATCH_ORIGIN"_ns); + NS_ENSURE_SUCCESS(rv, rv); + } + } + return NS_OK; +} + +NS_IMPL_ISUPPORTS(OriginAttrsPatternMatchOriginSQLFunction, mozIStorageFunction) + +NS_IMETHODIMP +OriginAttrsPatternMatchOriginSQLFunction::OnFunctionCall( + mozIStorageValueArray* aFunctionArguments, nsIVariant** aResult) { + nsresult rv; + + nsAutoCString origin; + rv = aFunctionArguments->GetUTF8String(0, origin); + NS_ENSURE_SUCCESS(rv, rv); + + nsCString originNoSuffix; + OriginAttributes oa; + bool parsedSuccessfully = oa.PopulateFromOrigin(origin, originNoSuffix); + NS_ENSURE_TRUE(parsedSuccessfully, NS_ERROR_FAILURE); + bool result = mPattern.Matches(oa); + + RefPtr<nsVariant> outVar(new nsVariant()); + rv = outVar->SetAsBool(result); + NS_ENSURE_SUCCESS(rv, rv); + + outVar.forget(aResult); + return NS_OK; +} + +NS_IMPL_ISUPPORTS(PrivateBrowsingOriginSQLFunction, mozIStorageFunction) + +NS_IMETHODIMP +PrivateBrowsingOriginSQLFunction::OnFunctionCall( + mozIStorageValueArray* aFunctionArguments, nsIVariant** aResult) { + nsresult rv; + + nsAutoCString origin; + rv = aFunctionArguments->GetUTF8String(0, origin); + NS_ENSURE_SUCCESS(rv, rv); + + bool result = OriginAttributes::IsPrivateBrowsing(origin); + + RefPtr<nsVariant> outVar(new nsVariant()); + rv = outVar->SetAsBool(result); + NS_ENSURE_SUCCESS(rv, rv); + + outVar.forget(aResult); + return NS_OK; +} + +} // namespace mozilla diff --git a/toolkit/components/credentialmanagement/IdentityCredentialStorageService.h b/toolkit/components/credentialmanagement/IdentityCredentialStorageService.h new file mode 100644 index 0000000000..ec69c6e0e1 --- /dev/null +++ b/toolkit/components/credentialmanagement/IdentityCredentialStorageService.h @@ -0,0 +1,185 @@ +/* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* vim: set ts=8 sts=2 et sw=2 tw=80: */ +/* 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/. */ + +#ifndef MOZILLA_IDENTITYCREDENTIALSTORAGESERVICE_H_ +#define MOZILLA_IDENTITYCREDENTIALSTORAGESERVICE_H_ + +#include "ErrorList.h" +#include "mozilla/AlreadyAddRefed.h" +#include "mozilla/dom/FlippedOnce.h" +#include "mozilla/Monitor.h" +#include "mozilla/OriginAttributes.h" +#include "mozIStorageConnection.h" +#include "mozIStorageFunction.h" +#include "nsIAsyncShutdown.h" +#include "nsIIdentityCredentialStorageService.h" +#include "nsIObserver.h" +#include "nsISupports.h" +#include "nsThreadUtils.h" + +namespace mozilla { + +class IdentityCredentialStorageService final + : public nsIIdentityCredentialStorageService, + public nsIObserver, + public nsIAsyncShutdownBlocker { + public: + NS_DECL_THREADSAFE_ISUPPORTS + NS_DECL_NSIIDENTITYCREDENTIALSTORAGESERVICE + NS_DECL_NSIOBSERVER + NS_DECL_NSIASYNCSHUTDOWNBLOCKER + + // Returns the singleton instance. Used by the component manager + static already_AddRefed<IdentityCredentialStorageService> GetSingleton(); + + // Singletons shouldn't have copy constructors or assignment operators + IdentityCredentialStorageService(const IdentityCredentialStorageService&) = + delete; + IdentityCredentialStorageService& operator=( + const IdentityCredentialStorageService&) = delete; + + private: + IdentityCredentialStorageService() + : mMonitor("mozilla::IdentityCredentialStorageService::mMonitor"), + mPendingWrites(0){}; + ~IdentityCredentialStorageService() = default; + + // Spins up the service. This includes firing off async work in a worker + // thread. This should always be called before other use of the service to + // prevent deadlock. + nsresult Init(); + + // Wait (non-blocking) until the service is fully initialized. We may be + // waiting for that async work started by Init(). + nsresult WaitForInitialization(); + + // Utility function to grab the correct barrier this service needs to shut + // down by + already_AddRefed<nsIAsyncShutdownClient> GetAsyncShutdownBarrier() const; + + // Called to indicate to the async shutdown service that we are all wrapped + // up. This also spins down the worker thread, since it is called after all + // disk database connections are closed. + void Finalize(); + + // Utility function to make sure a principal is an acceptable primary (RP) + // principal + static nsresult ValidatePrincipal(nsIPrincipal* aPrincipal); + + // Helper functions to initialize the database connections. Also makes sure + // the tables are present and have up to date schemas. + nsresult GetMemoryDatabaseConnection(); + nsresult GetDiskDatabaseConnection(); + static nsresult GetDatabaseConnectionInternal( + mozIStorageConnection** aDatabase, nsIFile* aFile); + + // Helper function for the Get*DatabaseConnection functions to ensure the + // tables are present and have up to date schemas. + static nsresult EnsureTable(mozIStorageConnection* aDatabase); + + // Grab all data from the disk database and insert it into the memory + // database/ This is used at start up + nsresult LoadMemoryTableFromDisk(); + + // Used to (thread-safely) track how many operations have been launched to the + // worker thread so that we can wait for it to hit zero before close the disk + // database connection + void IncrementPendingWrites(); + void DecrementPendingWrites(); + + // Helper functions for database writes. + // We have helper functions here for all operations we perform on the + // databases that invlolve writes. These are split out and take a connection + // as an argument because we have to write to both the memory database and + // the disk database, where we only read from the memory database after + // initialization. See nsIIdentityCredentialStorageService.idl for more info + // on these functions' semantics + + // Upsert == Update or Insert! Uses some nice SQLite syntax to make this easy. + static nsresult UpsertData(mozIStorageConnection* aDatabaseConnection, + nsIPrincipal* aRPPrincipal, + nsIPrincipal* aIDPPrincipal, + nsACString const& aCredentialID, bool aRegistered, + bool aAllowLogout); + + static nsresult DeleteData(mozIStorageConnection* aDatabaseConnection, + nsIPrincipal* aRPPrincipal, + nsIPrincipal* aIDPPrincipal, + nsACString const& aCredentialID); + + static nsresult ClearData(mozIStorageConnection* aDatabaseConnection); + + static nsresult DeleteDataFromOriginAttributesPattern( + mozIStorageConnection* aDatabaseConnection, + OriginAttributesPattern const& aOriginAttributesPattern); + + static nsresult DeleteDataFromTimeRange( + mozIStorageConnection* aDatabaseConnection, int64_t aStart, int64_t aEnd); + + static nsresult DeleteDataFromPrincipal( + mozIStorageConnection* aDatabaseConnection, nsIPrincipal* aPrincipal); + + static nsresult DeleteDataFromBaseDomain( + mozIStorageConnection* aDatabaseConnection, + nsACString const& aBaseDomain); + + // Database connections. Guaranteed to be non-null and working once + // initialized and not-yet finalized + RefPtr<mozIStorageConnection> mDiskDatabaseConnection; // Worker thread only + RefPtr<mozIStorageConnection> + mMemoryDatabaseConnection; // Main thread only after initialization, + // worker thread only before initialization. + + // Worker thread. This should be a valid thread after Init() returns and be + // destroyed when we finalize + nsCOMPtr<nsISerialEventTarget> mBackgroundThread; // main thread only + + // The database file handle. We can only create this in the main thread and + // need it in the worker to perform blocking disk IO. So we put it on this, + // since we pass this to the worker anyway + nsCOMPtr<nsIFile> mDatabaseFile; // initialized in the main thread, read-only + // in worker thread + + // Service state management. We protect these variables with a monitor. This + // monitor is also used to signal the completion of initialization and + // finalization performed in the worker thread. + Monitor mMonitor; + FlippedOnce<false> mInitialized MOZ_GUARDED_BY(mMonitor); + FlippedOnce<false> mErrored MOZ_GUARDED_BY(mMonitor); + FlippedOnce<false> mShuttingDown MOZ_GUARDED_BY(mMonitor); + FlippedOnce<false> mFinalized MOZ_GUARDED_BY(mMonitor); + uint32_t mPendingWrites MOZ_GUARDED_BY(mMonitor); +}; + +class OriginAttrsPatternMatchOriginSQLFunction final + : public mozIStorageFunction { + NS_DECL_ISUPPORTS + NS_DECL_MOZISTORAGEFUNCTION + + explicit OriginAttrsPatternMatchOriginSQLFunction( + OriginAttributesPattern const& aPattern) + : mPattern(aPattern) {} + OriginAttrsPatternMatchOriginSQLFunction() = delete; + + private: + ~OriginAttrsPatternMatchOriginSQLFunction() = default; + + OriginAttributesPattern mPattern; +}; + +class PrivateBrowsingOriginSQLFunction final : public mozIStorageFunction { + NS_DECL_ISUPPORTS + NS_DECL_MOZISTORAGEFUNCTION + + PrivateBrowsingOriginSQLFunction() = default; + + private: + ~PrivateBrowsingOriginSQLFunction() = default; +}; + +} // namespace mozilla + +#endif /* MOZILLA_IDENTITYCREDENTIALSTORAGESERVICE_H_ */ diff --git a/toolkit/components/credentialmanagement/components.conf b/toolkit/components/credentialmanagement/components.conf new file mode 100644 index 0000000000..eb991b86b2 --- /dev/null +++ b/toolkit/components/credentialmanagement/components.conf @@ -0,0 +1,27 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# 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/. + +Classes = [ + { + 'cid': '{936007db-a957-4f1d-a23d-f7d9403223e6}', + 'contract_ids': ['@mozilla.org/browser/identitycredentialpromptservice;1'], + 'esModule': 'resource://gre/modules/IdentityCredentialPromptService.sys.mjs', + 'processes': ProcessSelector.MAIN_PROCESS_ONLY, + 'constructor': 'IdentityCredentialPromptService', + 'name': 'IdentityCredentialPromptService', + }, + { + 'cid': '{029823d0-0448-46c5-af1f-25cd4501d0d7}', + 'contract_ids': ['@mozilla.org/browser/identity-credential-storage-service;1'], + 'processes': ProcessSelector.MAIN_PROCESS_ONLY, + 'singleton' : True, + 'type': 'mozilla::IdentityCredentialStorageService', + 'headers': ['mozilla/IdentityCredentialStorageService.h'], + 'interfaces': ['nsIIdentityCredentialStorageService'], + 'name': 'IdentityCredentialStorageService', + 'constructor': 'mozilla::IdentityCredentialStorageService::GetSingleton', + }, +] diff --git a/toolkit/components/credentialmanagement/moz.build b/toolkit/components/credentialmanagement/moz.build new file mode 100644 index 0000000000..b3216a33a9 --- /dev/null +++ b/toolkit/components/credentialmanagement/moz.build @@ -0,0 +1,34 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# 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/. + +with Files("**"): + BUG_COMPONENT = ("Core", "DOM: Credential Management") + +EXPORTS.mozilla += [ + "IdentityCredentialStorageService.h", +] + +UNIFIED_SOURCES += ["IdentityCredentialStorageService.cpp"] + +FINAL_LIBRARY = "xul" + +XPIDL_SOURCES += [ + "nsIIdentityCredentialPromptService.idl", + "nsIIdentityCredentialStorageService.idl", +] + +XPIDL_MODULE = "dom_identitycredential" + +EXTRA_JS_MODULES += [ + "IdentityCredentialPromptService.sys.mjs", +] + +BROWSER_CHROME_MANIFESTS += ["tests/browser/browser.toml"] +XPCSHELL_TESTS_MANIFESTS += ["tests/xpcshell/xpcshell.toml"] + +XPCOM_MANIFESTS += [ + "components.conf", +] diff --git a/toolkit/components/credentialmanagement/nsIIdentityCredentialPromptService.idl b/toolkit/components/credentialmanagement/nsIIdentityCredentialPromptService.idl new file mode 100644 index 0000000000..dd0a61500b --- /dev/null +++ b/toolkit/components/credentialmanagement/nsIIdentityCredentialPromptService.idl @@ -0,0 +1,22 @@ +/* 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/. */ + +#include "nsISupports.idl" + +webidl BrowsingContext; + +[scriptable, uuid(936007db-a957-4f1d-a23d-f7d9403223e6)] +interface nsIIdentityCredentialPromptService : nsISupports { + // Display to the user an interface to choose from among the identity providers listed + // Resolves with an index referring to one pair of the elements of the lists. + Promise showProviderPrompt(in BrowsingContext browsingContext, in jsval identityProviders, in jsval identityManifests); + // Display to the user an interface to approve (or disapprove) of the terms of service for + // the identity provider when used on the current site. + Promise showPolicyPrompt(in BrowsingContext browsingContext, in jsval identityProvider, in jsval identityManifest, in jsval identityClientMetadata); + // Display to the user an interface to choose from among the accounts listed with the information of the provider. + // Resolves with an index referring to one of the elements of the list. + Promise showAccountListPrompt(in BrowsingContext browsingContext, in jsval accountList, in jsval identityProvider, in jsval identityManifest); + // Close all UI from the other methods of this module + void close(in BrowsingContext browsingContext); +}; diff --git a/toolkit/components/credentialmanagement/nsIIdentityCredentialStorageService.idl b/toolkit/components/credentialmanagement/nsIIdentityCredentialStorageService.idl new file mode 100644 index 0000000000..0d993549e3 --- /dev/null +++ b/toolkit/components/credentialmanagement/nsIIdentityCredentialStorageService.idl @@ -0,0 +1,38 @@ +/* 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/. */ + +#include "nsISupports.idl" +#include "nsIPrincipal.idl" + +webidl IdentityCredential; + +[scriptable, builtinclass, uuid(029823d0-0448-46c5-af1f-25cd4501d0d7)] +interface nsIIdentityCredentialStorageService : nsISupports { + // Store the registered and allowLogout bit for the tuple (rpPrincipal, idpPrincipal, credentialID). + // This goes straight to disk if rpPrincipal is not in Private Browsing mode and stays in memory otherwise. + // Additionally, if rpPrincipal is private, it will be cleared when the user closes the last private browsing window. + void setState(in nsIPrincipal rpPrincipal, in nsIPrincipal idpPrincipal, in ACString credentialID, in boolean registered, in boolean allowLogout); + + // Retrieve the registered and allowLogout bits for the tuple (rpPrincipal, idpPrincipal, credentialID). + // This will always return defaults, even if there was never a value stored or it was deleted. + void getState(in nsIPrincipal rpPrincipal, in nsIPrincipal idpPrincipal, in ACString credentialID, out boolean registered, out boolean allowLogout); + + // Delete the entry for the tuple (rpPrincipal, idpPrincipal, credentialID). + void delete(in nsIPrincipal rpPrincipal, in nsIPrincipal idpPrincipal, in ACString credentialID); + + // Delete all data in this service. + void clear(); + + // Delete all data stored under a tuple with rpPrincipal that has the given base domain + void deleteFromBaseDomain(in ACString baseDomain); + + // Delete all data stored under a tuple with a given rpPrincipal + void deleteFromPrincipal(in nsIPrincipal rpPrincipal); + + // Delete all data stored in the given time range (microseconds since epoch) + void deleteFromTimeRange(in PRTime aFrom, in PRTime aTo); + + // Delete all data matching the given Origin Attributes pattern + void deleteFromOriginAttributesPattern(in AString aPattern); +}; diff --git a/toolkit/components/credentialmanagement/tests/browser/browser.toml b/toolkit/components/credentialmanagement/tests/browser/browser.toml new file mode 100644 index 0000000000..a7202c9190 --- /dev/null +++ b/toolkit/components/credentialmanagement/tests/browser/browser.toml @@ -0,0 +1,18 @@ +[DEFAULT] +head = "head.js" +prefs = [ + "dom.security.credentialmanagement.identity.enabled=true", + "dom.security.credentialmanagement.identity.ignore_well_known=true", + "privacy.antitracking.enableWebcompat=false", +] # disables opener heuristic +scheme = "https" + +support-files = ["custom.svg"] + +["browser_account_dialog.js"] +fail-if = ["a11y_checks"] # Bug 1854509 clicked identity-credential-list-item may not be focusable + +["browser_policy_dialog.js"] + +["browser_provider_dialog.js"] +fail-if = ["a11y_checks"] # Bug 1854509 clicked identity-credential-list-item may not be focusable diff --git a/toolkit/components/credentialmanagement/tests/browser/browser_account_dialog.js b/toolkit/components/credentialmanagement/tests/browser/browser_account_dialog.js new file mode 100644 index 0000000000..f6e74d2bad --- /dev/null +++ b/toolkit/components/credentialmanagement/tests/browser/browser_account_dialog.js @@ -0,0 +1,462 @@ +/* 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/. */ + +"use strict"; + +XPCOMUtils.defineLazyServiceGetter( + this, + "IdentityCredentialPromptService", + "@mozilla.org/browser/identitycredentialpromptservice;1", + "nsIIdentityCredentialPromptService" +); + +const TEST_URL = "https://example.com/"; + +// Test that a single account shows up in a dialog and is chosen when "continue" is clicked +add_task(async function test_single_acccount_dialog() { + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, TEST_URL); + + let popupShown = BrowserTestUtils.waitForEvent( + PopupNotifications.panel, + "popupshown" + ); + + // Show the single account + let prompt = IdentityCredentialPromptService.showAccountListPrompt( + tab.linkedBrowser.browsingContext, + { + accounts: [ + { + id: "00000000-0000-0000-0000-000000000000", + name: "Test Account", + email: "test@idp.example", + }, + ], + }, + { + configURL: "https://idp.example/", + clientId: "123", + }, + { + accounts_endpoint: "", + client_metadata_endpoint: "", + id_assertion_endpoint: "", + } + ); + + // Wait for the popup to appear + await popupShown; + + let popupHiding = BrowserTestUtils.waitForEvent( + PopupNotifications.panel, + "popuphiding" + ); + + let document = tab.linkedBrowser.browsingContext.topChromeWindow.document; + + // Validate the popup contents + let inputs = document + .getElementById("identity-credential-account") + .getElementsByClassName("identity-credential-list-item"); + is(inputs.length, 1, "One account expected"); + let label = inputs[0].getElementsByClassName( + "identity-credential-list-item-label-stack" + )[0]; + ok( + label.textContent.includes("Test Account"), + "Label includes the account name" + ); + ok( + label.textContent.includes("test@idp.example"), + "Label includes the account email" + ); + + let title = document.getElementById("identity-credential-header-text"); + ok( + title.textContent.includes("idp.example"), + "Popup title includes the IDP Site" + ); + + // Click Continue + document + .getElementsByClassName("popup-notification-primary-button")[0] + .click(); + + // Make sure that the prompt resolves with the index of the only argument element + let value = await prompt; + is(value, 0); + + await popupHiding; + + // Close tabs. + await BrowserTestUtils.removeTab(tab); +}); + +// Test that no account is chosen when "cancel" is clicked +add_task(async function test_single_acccount_deny() { + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, TEST_URL); + + let popupShown = BrowserTestUtils.waitForEvent( + PopupNotifications.panel, + "popupshown" + ); + + // Show a prompt with one account + let prompt = IdentityCredentialPromptService.showAccountListPrompt( + tab.linkedBrowser.browsingContext, + { + accounts: [ + { + id: "00000000-0000-0000-0000-000000000000", + name: "Test Account", + email: "test@idp.example", + }, + ], + }, + { + configURL: "https://idp.example/", + clientId: "123", + }, + { + accounts_endpoint: "", + client_metadata_endpoint: "", + id_assertion_endpoint: "", + } + ); + + // Wait for that popup + await popupShown; + + let popupHiding = BrowserTestUtils.waitForEvent( + PopupNotifications.panel, + "popuphiding" + ); + + let document = tab.linkedBrowser.browsingContext.topChromeWindow.document; + + // Validate the popup contents + let inputs = document + .getElementById("identity-credential-account") + .getElementsByClassName("identity-credential-list-item"); + is(inputs.length, 1, "One account expected"); + let label = inputs[0].getElementsByClassName( + "identity-credential-list-item-label-stack" + )[0]; + ok( + label.textContent.includes("Test Account"), + "Label includes the account name" + ); + ok( + label.textContent.includes("test@idp.example"), + "Label includes the account email" + ); + + // Click cancel + document + .getElementsByClassName("popup-notification-secondary-button")[0] + .click(); + + // Make sure we reject + try { + await prompt; + ok(false, "Prompt should not resolve when denied."); + } catch (e) { + ok(true, "Prompt should reject when denied."); + } + + await popupHiding; + + // Close tabs. + await BrowserTestUtils.removeTab(tab); +}); + +// Show multiple accounts and select the first one +add_task(async function test_multiple_acccount_dialog() { + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, TEST_URL); + + let popupShown = BrowserTestUtils.waitForEvent( + PopupNotifications.panel, + "popupshown" + ); + + // Show a prompt with multiple accounts + let prompt = IdentityCredentialPromptService.showAccountListPrompt( + tab.linkedBrowser.browsingContext, + { + accounts: [ + { + id: "00000000-0000-0000-0000-000000000000", + name: "Test Account", + email: "test@idp.example", + }, + { + id: "00000000-0000-0000-0000-000000000000", + name: "Test Account 2", + email: "test2@idp.example", + }, + ], + }, + { + configURL: "https://idp.example/", + clientId: "123", + }, + { + accounts_endpoint: "", + client_metadata_endpoint: "", + id_assertion_endpoint: "", + } + ); + + // Wait for that popup to appear + await popupShown; + + let popupHiding = BrowserTestUtils.waitForEvent( + PopupNotifications.panel, + "popuphiding" + ); + + let document = tab.linkedBrowser.browsingContext.topChromeWindow.document; + + // Validate the account list contents and ordering + let inputs = document + .getElementById("identity-credential-account") + .getElementsByClassName("identity-credential-list-item"); + is(inputs.length, 2, "Two accounts expected"); + let label0 = inputs[0].getElementsByClassName( + "identity-credential-list-item-label-stack" + )[0]; + ok( + label0.textContent.includes("Test Account"), + "The first account name should be in the label" + ); + ok( + label0.textContent.includes("test@idp.example"), + "The first account email should be in the label" + ); + let label1 = inputs[1].getElementsByClassName( + "identity-credential-list-item-label-stack" + )[0]; + ok( + label1.textContent.includes("Test Account 2"), + "The second account name should be in the label" + ); + ok( + label1.textContent.includes("test2@idp.example"), + "The second account email should be in the label" + ); + + // Click continue + document + .getElementsByClassName("popup-notification-primary-button")[0] + .click(); + + // Validate that the caller gets a resolving promise with index of the first element + let value = await prompt; + is(value, 0, "The first account is chosen by default"); + + await popupHiding; + + // Close tabs. + await BrowserTestUtils.removeTab(tab); +}); + +// Show multiple accounts and select the second one +add_task(async function test_multiple_acccount_choose_second() { + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, TEST_URL); + + let popupShown = BrowserTestUtils.waitForEvent( + PopupNotifications.panel, + "popupshown" + ); + + // Show a prompt with multiple accounts + let prompt = IdentityCredentialPromptService.showAccountListPrompt( + tab.linkedBrowser.browsingContext, + { + accounts: [ + { + id: "00000000-0000-0000-0000-000000000000", + name: "Test Account", + email: "test@idp.example", + }, + { + id: "00000000-0000-0000-0000-000000000000", + name: "Test Account 2", + email: "test2@idp.example", + }, + ], + }, + { + configURL: "https://idp.example/", + clientId: "123", + }, + { + accounts_endpoint: "", + client_metadata_endpoint: "", + id_assertion_endpoint: "", + } + ); + + // Wait for that popup + await popupShown; + + let popupHiding = BrowserTestUtils.waitForEvent( + PopupNotifications.panel, + "popuphiding" + ); + + let document = tab.linkedBrowser.browsingContext.topChromeWindow.document; + + // Validate the account list contents and ordering + let inputs = document + .getElementById("identity-credential-account") + .getElementsByClassName("identity-credential-list-item"); + is(inputs.length, 2, "Two accounts expected"); + let label0 = inputs[0].getElementsByClassName( + "identity-credential-list-item-label-stack" + )[0]; + ok( + label0.textContent.includes("Test Account"), + "The first account name should be in the label" + ); + ok( + label0.textContent.includes("test@idp.example"), + "The first account email should be in the label" + ); + let label1 = inputs[1].getElementsByClassName( + "identity-credential-list-item-label-stack" + )[0]; + ok( + label1.textContent.includes("Test Account 2"), + "The second account name should be in the label" + ); + ok( + label1.textContent.includes("test2@idp.example"), + "The second account email should be in the label" + ); + + // Click the second account + inputs[1].click(); + + // Click continue + document + .getElementsByClassName("popup-notification-primary-button")[0] + .click(); + + // Make sure we selected the second account + let value = await prompt; + is(value, 1, "The prompt should resolve to 1, indicating the second account"); + + await popupHiding; + + // Close tabs. + await BrowserTestUtils.removeTab(tab); +}); + +// Test that account pictures are rendered for the user +add_task(async function test_multiple_acccount_show_picture() { + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, TEST_URL); + + let popupShown = BrowserTestUtils.waitForEvent( + PopupNotifications.panel, + "popupshown" + ); + + // Show a prompt with two accounts, but only the first has a custom picture + let prompt = IdentityCredentialPromptService.showAccountListPrompt( + tab.linkedBrowser.browsingContext, + { + accounts: [ + { + id: "00000000-0000-0000-0000-000000000000", + name: "Test Account", + email: "test@idp.example", + picture: + "https://example.net/browser/toolkit/components/credentialmanagement/tests/browser/custom.svg", + }, + { + id: "00000000-0000-0000-0000-000000000000", + name: "Test Account 2", + email: "test2@idp.example", + }, + ], + }, + { + configURL: "https://idp.example/", + clientId: "123", + }, + { + accounts_endpoint: "", + client_metadata_endpoint: "", + id_assertion_endpoint: "", + privacy_policy_url: "https://idp.example/privacy-policy.html", + terms_of_service_url: "https://idp.example/terms-of-service.html", + branding: { + background_color: "0x6200ee", + color: "0xffffff", + icons: [ + { + size: 256, + url: "https://example.net/browser/toolkit/components/credentialmanagement/tests/browser/custom.svg", + }, + ], + name: "demo ip", + }, + } + ); + + // Wait for that popup + await popupShown; + + let popupHiding = BrowserTestUtils.waitForEvent( + PopupNotifications.panel, + "popuphiding" + ); + + let document = tab.linkedBrowser.browsingContext.topChromeWindow.document; + + let icons = document + .getElementById("identity-credential-account") + .getElementsByClassName("identity-credential-list-item-icon"); + is(icons.length, 2, "Two accounts expected"); + Assert.notEqual(icons[0].src, icons[1].src, "The icons are different"); + ok( + icons[0].src.startsWith( + "data:image/svg+xml;base64,PCEtLSBUaGlzIFNvdXJjZSBDb2RlIEZvcm0gaXMgc3ViamVjdCB0byB0aGUgdGVybXMgb2YgdGhlIE1vemlsbGEgUHVibGljCiAgIC0gTGljZW5zZSwgdi4gMi4wLiBJZiBhIGNvcHkgb2YgdGhlIE1QTCB3YXMgbm90IGRpc3RyaWJ1dGVkIHdpdGggdGhpcwogICAtIGZpbGUsIFlvdSBjYW4gb2J0YWluIG9uZSBhdCBodHRwOi8vbW96aWxsYS5vcmcvTVBMLzIuMC8uIC0tPgo8c3ZnIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgdmlld0JveD0iMCAwIDE2IDE2IiB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIGZpbGw9ImNvbnRleHQtZmlsbCIgZmlsbC1vcGFjaXR5PSJjb250ZXh0LWZpbGwtb3BhY2l0eSI+CiAgPHBhdGggZD0iTS42MjUgMTNhLjYyNS42MjUgMCAwIDEgMC0xLjI1bDMuMjUgMEE0Ljg4IDQuODggMCAwIDAgOC43NSA2Ljg3NWwwLS4yNWEuNjI1LjYyNSAwIDAgMSAxLjI1IDBsMCAuMjVBNi4xMzIgNi4xMzIgMCAwIDEgMy44NzUgMTNsLTMuMjUgMHoiLz" + ), + "The first icon matches the custom.svg" + ); + + const headerIcon = document.getElementsByClassName( + "identity-credential-header-icon" + )[0]; + + let title = document.getElementById("identity-credential-header-text"); + ok( + title.textContent.includes("demo ip"), + "Popup title appears as business short name" + ); + + ok(BrowserTestUtils.isVisible(headerIcon), "Header Icon is showing"); + ok( + headerIcon.src.startsWith( + "data:image/svg+xml;base64,PCEtLSBUaGlzIFNvdXJjZSBDb2RlIEZvcm0gaXMgc3ViamVjdCB0byB0aGUgdGVybXMgb2YgdGhlIE1vemlsbGEgUHVibGljCiAgIC0gTGljZW5zZSwgdi4gMi4wLiBJZiBhIGNvcHkgb2YgdGhlIE1QTCB3YXMgbm90IGRpc3RyaWJ1dGVkIHdpdGggdGhpcwogICAtIGZpbGUsIFlvdSBjYW4gb2J0YWluIG9uZSBhdCBodHRwOi8vbW96aWxsYS5vcmcvTVBMLzIuMC8uIC0tPgo8c3ZnIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgdmlld0JveD0iMCAwIDE2IDE2IiB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIGZpbGw9ImNvbnRleHQtZmlsbCIgZmlsbC1vcGFjaXR5PSJjb250ZXh0LWZpbGwtb3BhY2l0eSI+CiAgPHBhdGggZD0iTS42MjUgMTNhLjYyNS42MjUgMCAwIDEgMC0xLjI1bDMuMjUgMEE0Ljg4IDQuODggMCAwIDAgOC43NSA2Ljg3NWwwLS4yNWEuNjI1LjYyNSAwIDAgMSAxLjI1IDBsMCAuMjVBNi4xMzIgNi4xMzIgMCAwIDEgMy44NzUgMTNsLTMuMjUgMHoiLz" + ), + "The header icon matches the icon resource from manifest" + ); + + // Click Continue + document + .getElementsByClassName("popup-notification-primary-button")[0] + .click(); + + // Make sure that the prompt resolves with the index of the only argument element + let value = await prompt; + is(value, 0); + + await popupHiding; + + // Close tabs. + await BrowserTestUtils.removeTab(tab); +}); diff --git a/toolkit/components/credentialmanagement/tests/browser/browser_policy_dialog.js b/toolkit/components/credentialmanagement/tests/browser/browser_policy_dialog.js new file mode 100644 index 0000000000..eb1026934c --- /dev/null +++ b/toolkit/components/credentialmanagement/tests/browser/browser_policy_dialog.js @@ -0,0 +1,204 @@ +/* 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/. */ + +"use strict"; + +XPCOMUtils.defineLazyServiceGetter( + this, + "IdentityCredentialPromptService", + "@mozilla.org/browser/identitycredentialpromptservice;1", + "nsIIdentityCredentialPromptService" +); + +const TEST_URL = "https://example.com/"; + +// Test that a policy dialog does not appear when no policies are given +add_task(async function test_policy_dialog_empty() { + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, TEST_URL); + + let prompt = IdentityCredentialPromptService.showPolicyPrompt( + tab.linkedBrowser.browsingContext, + { + configURL: "https://idp.example/", + clientId: "123", + }, + { + accounts_endpoint: "", + client_metadata_endpoint: "", + id_assertion_endpoint: "", + }, + {} // No policies! + ); + + // Make sure we resolve with true without interaction + let value = await prompt; + is(value, true, "Automatically accept the missing policies"); + + // Close tab + await BrowserTestUtils.removeTab(tab); +}); + +// Make sure that a policy dialog shows up when we have policies to show. +// Also test the accept path. +add_task(async function test_policy_dialog() { + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, TEST_URL); + + let popupShown = BrowserTestUtils.waitForEvent( + PopupNotifications.panel, + "popupshown" + ); + + // Show a prompt- the operative argument is the last one + let prompt = IdentityCredentialPromptService.showPolicyPrompt( + tab.linkedBrowser.browsingContext, + { + configURL: "https://idp.example/", + clientId: "123", + }, + { + accounts_endpoint: "", + client_metadata_endpoint: "", + id_assertion_endpoint: "", + branding: { + background_color: "0x6200ee", + color: "0xffffff", + icons: [ + { + size: 256, + url: "https://example.net/browser/toolkit/components/credentialmanagement/tests/browser/custom.svg", + }, + ], + name: "demo ip", + }, + }, + { + privacy_policy_url: "https://idp.example/privacy-policy.html", + terms_of_service_url: "https://idp.example/terms-of-service.html", + } + ); + + // Make sure the popup shows up + await popupShown; + + let popupHiding = BrowserTestUtils.waitForEvent( + PopupNotifications.panel, + "popuphiding" + ); + + // Validate the contents of the popup + let document = tab.linkedBrowser.browsingContext.topChromeWindow.document; + + let description = document.getElementById( + "identity-credential-policy-explanation" + ); + + ok( + description.textContent.includes("idp.example"), + "IDP domain in the policy prompt text" + ); + ok( + description.textContent.includes("example.com"), + "RP domain in the policy prompt text" + ); + ok( + description.textContent.includes("Privacy Policy"), + "Link to the privacy policy in the policy prompt text" + ); + ok( + description.textContent.includes("Terms of Service"), + "Link to the ToS in the policy prompt text" + ); + + let title = document.getElementById("identity-credential-header-text"); + ok( + title.textContent.includes("demo ip"), + "IDP domain in the policy prompt header as business short name" + ); + + const headerIcon = document.getElementsByClassName( + "identity-credential-header-icon" + )[0]; + + ok(BrowserTestUtils.isVisible(headerIcon), "Header Icon is showing"); + ok( + headerIcon.src.startsWith( + "data:image/svg+xml;base64,PCEtLSBUaGlzIFNvdXJjZSBDb2RlIEZvcm0gaXMgc3ViamVjdCB0byB0aGUgdGVybXMgb2YgdGhlIE1vemlsbGEgUHVibGljCiAgIC0gTGljZW5zZSwgdi4gMi4wLiBJZiBhIGNvcHkgb2YgdGhlIE1QTCB3YXMgbm90IGRpc3RyaWJ1dGVkIHdpdGggdGhpcwogICAtIGZpbGUsIFlvdSBjYW4gb2J0YWluIG9uZSBhdCBodHRwOi8vbW96aWxsYS5vcmcvTVBMLzIuMC8uIC0tPgo8c3ZnIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgdmlld0JveD0iMCAwIDE2IDE2IiB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIGZpbGw9ImNvbnRleHQtZmlsbCIgZmlsbC1vcGFjaXR5PSJjb250ZXh0LWZpbGwtb3BhY2l0eSI+CiAgPHBhdGggZD0iTS42MjUgMTNhLjYyNS42MjUgMCAwIDEgMC0xLjI1bDMuMjUgMEE0Ljg4IDQuODggMCAwIDAgOC43NSA2Ljg3NWwwLS4yNWEuNjI1LjYyNSAwIDAgMSAxLjI1IDBsMCAuMjVBNi4xMzIgNi4xMzIgMCAwIDEgMy44NzUgMTNsLTMuMjUgMHoiLz" + ), + "The header icon matches the icon resource from manifest" + ); + + // Accept the policies + document + .getElementsByClassName("popup-notification-primary-button")[0] + .click(); + + // Make sure the call to the propmt resolves with true + let value = await prompt; + is(value, true, "User clicking accept resolves with true"); + + // Wait for the prompt to go away + await popupHiding; + + // Close tab + await BrowserTestUtils.removeTab(tab); +}); + +// Test that rejecting the policies works +add_task(async function test_policy_reject() { + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, TEST_URL); + + let popupShown = BrowserTestUtils.waitForEvent( + PopupNotifications.panel, + "popupshown" + ); + + // Show the same prompt with policies + let prompt = IdentityCredentialPromptService.showPolicyPrompt( + tab.linkedBrowser.browsingContext, + { + configURL: "https://idp.example/", + clientId: "123", + }, + { + accounts_endpoint: "", + client_metadata_endpoint: "", + id_assertion_endpoint: "", + }, + { + privacy_policy_url: "https://idp.example/privacy-policy.html", + terms_of_service_url: "https://idp.example/terms-of-service.html", + } + ); + + // Wait for the prompt to show up + await popupShown; + + let popupHiding = BrowserTestUtils.waitForEvent( + PopupNotifications.panel, + "popuphiding" + ); + + let document = tab.linkedBrowser.browsingContext.topChromeWindow.document; + + let title = document.getElementById("identity-credential-header-text"); + ok( + title.textContent.includes("idp.example"), + "IDP domain in the policy prompt header as domain" + ); + + // Click reject. + document + .getElementsByClassName("popup-notification-secondary-button")[0] + .click(); + + // Make sure the prompt call accepts with an indication of the user's reject choice. + let value = await prompt; + is(value, false, "User clicking reject causes the promise to resolve(false)"); + + // Wait for the popup to go away. + await popupHiding; + + // Close tab. + await BrowserTestUtils.removeTab(tab); +}); diff --git a/toolkit/components/credentialmanagement/tests/browser/browser_provider_dialog.js b/toolkit/components/credentialmanagement/tests/browser/browser_provider_dialog.js new file mode 100644 index 0000000000..ca81675bb2 --- /dev/null +++ b/toolkit/components/credentialmanagement/tests/browser/browser_provider_dialog.js @@ -0,0 +1,396 @@ +/* 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/. */ + +"use strict"; + +XPCOMUtils.defineLazyServiceGetter( + this, + "IdentityCredentialPromptService", + "@mozilla.org/browser/identitycredentialpromptservice;1", + "nsIIdentityCredentialPromptService" +); + +const TEST_URL = "https://example.com/"; + +// Test that a single provider shows up in a dialog and is chosen when "continue" is clicked +add_task(async function test_single_provider_dialog() { + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, TEST_URL); + + let popupShown = BrowserTestUtils.waitForEvent( + PopupNotifications.panel, + "popupshown" + ); + + // Show one IDP + let prompt = IdentityCredentialPromptService.showProviderPrompt( + tab.linkedBrowser.browsingContext, + [ + { + configURL: "https://idp.example/", + clientId: "123", + }, + ], + [ + { + accounts_endpoint: "", + client_metadata_endpoint: "", + id_assertion_endpoint: "", + }, + ] + ); + + // Make sure a popup shows up. + await popupShown; + + let popupHiding = BrowserTestUtils.waitForEvent( + PopupNotifications.panel, + "popuphiding" + ); + + let document = tab.linkedBrowser.browsingContext.topChromeWindow.document; + + // Make sure there is only one option + let inputs = document + .getElementById("identity-credential-provider") + .getElementsByClassName("identity-credential-list-item"); + is(inputs.length, 1, "One IDP"); + + // Make sure the IDP Site is in the label + let label = inputs[0].getElementsByClassName( + "identity-credential-list-item-label-primary" + )[0]; + ok(label.textContent.includes("idp.example"), "IDP site in label"); + + // Validate the title of the popup + let title = document.querySelector( + 'description[popupid="identity-credential"]' + ); + ok( + title.textContent.includes("Sign in with a login provider"), + "Popup title correct" + ); + + // Click "Continue" + document + .getElementsByClassName("popup-notification-primary-button")[0] + .click(); + + // Make sure the prompt promise resolves + let value = await prompt; + is(value, 0, "Selected the only IDP"); + + await popupHiding; + + // Close tabs. + await BrowserTestUtils.removeTab(tab); +}); + +// Test that a single provider shows up in a dialog and is not chosen when "cancel" is clicked +add_task(async function test_single_provider_deny() { + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, TEST_URL); + + let popupShown = BrowserTestUtils.waitForEvent( + PopupNotifications.panel, + "popupshown" + ); + + // Show one IDP + let prompt = IdentityCredentialPromptService.showProviderPrompt( + tab.linkedBrowser.browsingContext, + [ + { + configURL: "https://idp.example/", + clientId: "123", + }, + ], + [ + { + accounts_endpoint: "", + client_metadata_endpoint: "", + id_assertion_endpoint: "", + }, + ] + ); + + // Make sure a popup shows up. + await popupShown; + + let popupHiding = BrowserTestUtils.waitForEvent( + PopupNotifications.panel, + "popuphiding" + ); + + // Click cancel + let document = tab.linkedBrowser.browsingContext.topChromeWindow.document; + document + .getElementsByClassName("popup-notification-secondary-button")[0] + .click(); + + try { + await prompt; + ok(false, "Prompt should not resolve when denied."); + } catch (e) { + ok(true, "Prompt should reject when denied."); + } + + await popupHiding; + + // Close tabs. + await BrowserTestUtils.removeTab(tab); +}); + +// Show multiple IDPs and select the first one +add_task(async function test_multiple_provider_dialog() { + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, TEST_URL); + + let popupShown = BrowserTestUtils.waitForEvent( + PopupNotifications.panel, + "popupshown" + ); + + // Show two providers, in order. (we don't need the metadata because we aren't testing branding) + let prompt = IdentityCredentialPromptService.showProviderPrompt( + tab.linkedBrowser.browsingContext, + [ + { + configURL: "https://idp1.example/", + clientId: "123", + }, + { + configURL: "https://idp2.example/", + clientId: "123", + }, + ], + [ + { + accounts_endpoint: "", + client_metadata_endpoint: "", + id_assertion_endpoint: "", + }, + { + accounts_endpoint: "", + client_metadata_endpoint: "", + id_assertion_endpoint: "", + }, + ] + ); + + // Make sure the popup shows up + await popupShown; + + let popupHiding = BrowserTestUtils.waitForEvent( + PopupNotifications.panel, + "popuphiding" + ); + + let document = tab.linkedBrowser.browsingContext.topChromeWindow.document; + + let inputs = document + .getElementById("identity-credential-provider") + .getElementsByClassName("identity-credential-list-item"); + is(inputs.length, 2, "Two IDPs visible"); + + let label1 = inputs[0].getElementsByClassName( + "identity-credential-list-item-label-primary" + )[0]; + ok( + label1.textContent.includes("idp1.example"), + "First IDP label includes its site" + ); + let label2 = inputs[1].getElementsByClassName( + "identity-credential-list-item-label-primary" + )[0]; + ok( + label2.textContent.includes("idp2.example"), + "Second IDP label includes its site" + ); + + // Click continue + document + .getElementsByClassName("popup-notification-primary-button")[0] + .click(); + + let value = await prompt; + is(value, 0, "The default is the first option in the list"); + + await popupHiding; + + // Close tabs. + await BrowserTestUtils.removeTab(tab); +}); + +// Show multiple IDPs and select the second one +add_task(async function test_multiple_provider_choose_second() { + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, TEST_URL); + + let popupShown = BrowserTestUtils.waitForEvent( + PopupNotifications.panel, + "popupshown" + ); + + // Show two providers, in order. (we don't need the metadata because we aren't testing branding) + let prompt = IdentityCredentialPromptService.showProviderPrompt( + tab.linkedBrowser.browsingContext, + [ + { + configURL: "https://idp1.example/", + clientId: "123", + }, + { + configURL: "https://idp2.example/", + clientId: "123", + }, + ], + [ + { + accounts_endpoint: "", + client_metadata_endpoint: "", + id_assertion_endpoint: "", + }, + { + accounts_endpoint: "", + client_metadata_endpoint: "", + id_assertion_endpoint: "", + }, + ] + ); + + // Wait for the popup + await popupShown; + + let popupHiding = BrowserTestUtils.waitForEvent( + PopupNotifications.panel, + "popuphiding" + ); + + let document = tab.linkedBrowser.browsingContext.topChromeWindow.document; + + let inputs = document + .getElementById("identity-credential-provider") + .getElementsByClassName("identity-credential-list-item"); + is(inputs.length, 2, "Two IDPs visible"); + + let label1 = inputs[0].getElementsByClassName( + "identity-credential-list-item-label-primary" + )[0]; + ok( + label1.textContent.includes("idp1.example"), + "First IDP label includes its site" + ); + let label2 = inputs[1].getElementsByClassName( + "identity-credential-list-item-label-primary" + )[0]; + ok( + label2.textContent.includes("idp2.example"), + "Second IDP label includes its site" + ); + + // Click the second list item + inputs[1].click(); + + // Click continue + document + .getElementsByClassName("popup-notification-primary-button")[0] + .click(); + + // Make sure the caller gets the second IDP + let value = await prompt; + is(value, 1, "Choosing a different option makes a change"); + + await popupHiding; + + // Close tabs. + await BrowserTestUtils.removeTab(tab); +}); + +// Validate that the branding information is rendered correctly +add_task(async function test_multiple_provider_show_branding() { + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, TEST_URL); + + let popupShown = BrowserTestUtils.waitForEvent( + PopupNotifications.panel, + "popupshown" + ); + + // Show the prompt, but include an icon for the second IDP + let prompt = IdentityCredentialPromptService.showProviderPrompt( + tab.linkedBrowser.browsingContext, + [ + { + configURL: "https://idp1.example/", + clientId: "123", + }, + { + configURL: "https://idp2.example/", + clientId: "123", + }, + ], + [ + { + accounts_endpoint: "", + client_metadata_endpoint: "", + id_assertion_endpoint: "", + }, + { + accounts_endpoint: "", + client_metadata_endpoint: "", + id_assertion_endpoint: "", + branding: { + icons: [ + { + url: "https://example.net/browser/toolkit/components/credentialmanagement/tests/browser/custom.svg", + }, + ], + name: "demo ip", + }, + }, + ] + ); + + // Wait for that popup + await popupShown; + + let popupHiding = BrowserTestUtils.waitForEvent( + PopupNotifications.panel, + "popuphiding" + ); + + let document = tab.linkedBrowser.browsingContext.topChromeWindow.document; + + // Validate the icons + let icons = document + .getElementById("identity-credential-provider") + .getElementsByClassName("identity-credential-list-item-icon"); + is(icons.length, 2, "Two icons in the popup"); + Assert.notEqual(icons[0].src, icons[1].src, "Icons are different"); + ok( + icons[1].src.startsWith( + "data:image/svg+xml;base64,PCEtLSBUaGlzIFNvdXJjZSBDb2RlIEZvcm0gaXMgc3ViamVjdCB0byB0aGUgdGVybXMgb2YgdGhlIE1vemlsbGEgUHVibGljCiAgIC0gTGljZW5zZSwgdi4gMi4wLiBJZiBhIGNvcHkgb2YgdGhlIE1QTCB3YXMgbm90IGRpc3RyaWJ1dGVkIHdpdGggdGhpcwogICAtIGZpbGUsIFlvdSBjYW4gb2J0YWluIG9uZSBhdCBodHRwOi8vbW96aWxsYS5vcmcvTVBMLzIuMC8uIC0tPgo8c3ZnIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgdmlld0JveD0iMCAwIDE2IDE2IiB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIGZpbGw9ImNvbnRleHQtZmlsbCIgZmlsbC1vcGFjaXR5PSJjb250ZXh0LWZpbGwtb3BhY2l0eSI+CiAgPHBhdGggZD0iTS42MjUgMTNhLjYyNS42MjUgMCAwIDEgMC0xLjI1bDMuMjUgMEE0Ljg4IDQuODggMCAwIDAgOC43NSA2Ljg3NWwwLS4yNWEuNjI1LjYyNSAwIDAgMSAxLjI1IDBsMCAuMjVBNi4xMzIgNi4xMzIgMCAwIDEgMy44NzUgMTNsLTMuMjUgMHoiLz" + ), + "The second icon matches the custom.svg" + ); + + let inputs = document + .getElementById("identity-credential-provider") + .getElementsByClassName("identity-credential-list-item"); + is(inputs.length, 2, "One IDP"); + let label = inputs[1].getElementsByClassName( + "identity-credential-list-item-label-primary" + )[0]; + ok(label.textContent.includes("demo ip"), "should show business short time"); + + // Click continue + document + .getElementsByClassName("popup-notification-primary-button")[0] + .click(); + + // Make sure the caller gets the first provider still + let value = await prompt; + is(value, 0, "The default is the first option in the list"); + + await popupHiding; + + // Close tabs. + await BrowserTestUtils.removeTab(tab); +}); diff --git a/toolkit/components/credentialmanagement/tests/browser/custom.svg b/toolkit/components/credentialmanagement/tests/browser/custom.svg new file mode 100644 index 0000000000..320d516b07 --- /dev/null +++ b/toolkit/components/credentialmanagement/tests/browser/custom.svg @@ -0,0 +1,8 @@ +<!-- 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/. --> +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16" fill="context-fill" fill-opacity="context-fill-opacity"> + <path d="M.625 13a.625.625 0 0 1 0-1.25l3.25 0A4.88 4.88 0 0 0 8.75 6.875l0-.25a.625.625 0 0 1 1.25 0l0 .25A6.132 6.132 0 0 1 3.875 13l-3.25 0z"/> + <path d="M12.096 15.248a.625.625 0 0 1-.491-1.012c4.281-5.446 3.261-8.813 2.271-10.337-1.346-2.07-4.169-3.106-6.71-2.464-2.428.615-3.951 2.583-4.178 5.397-.027.345-.364.585-.673.574a.626.626 0 0 1-.573-.673C2.011 3.4 3.924.967 6.859.224c3.044-.768 6.437.489 8.064 2.995 1.172 1.803 2.426 5.732-2.337 11.791a.622.622 0 0 1-.49.238z"/> + <path d="M5.734 16a.624.624 0 0 1-.165-1.228c5.912-1.62 6.147-7.768 6.148-7.829a2.113 2.113 0 0 0-.457-1.708 2.713 2.713 0 0 0-1.689-.947c-.683-.109-1.357.021-1.902.371-.521.334-.859.83-.95 1.396l-.217 1.209a3.621 3.621 0 0 1-3.567 2.986l-2.31 0a.625.625 0 0 1 0-1.25l2.31 0a2.373 2.373 0 0 0 2.337-1.957l.215-1.196c.146-.912.683-1.711 1.509-2.24a3.95 3.95 0 0 1 2.774-.553 3.964 3.964 0 0 1 2.461 1.394c.618.762.878 1.688.729 2.611.005 0-.27 7.059-7.061 8.919a.619.619 0 0 1-.165.022z"/> +</svg> diff --git a/toolkit/components/credentialmanagement/tests/browser/head.js b/toolkit/components/credentialmanagement/tests/browser/head.js new file mode 100644 index 0000000000..d646d2b39d --- /dev/null +++ b/toolkit/components/credentialmanagement/tests/browser/head.js @@ -0,0 +1,2 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ diff --git a/toolkit/components/credentialmanagement/tests/xpcshell/head.js b/toolkit/components/credentialmanagement/tests/xpcshell/head.js new file mode 100644 index 0000000000..4614e91961 --- /dev/null +++ b/toolkit/components/credentialmanagement/tests/xpcshell/head.js @@ -0,0 +1,9 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +const { XPCOMUtils } = ChromeUtils.importESModule( + "resource://gre/modules/XPCOMUtils.sys.mjs" +); +const { TestUtils } = ChromeUtils.importESModule( + "resource://testing-common/TestUtils.sys.mjs" +); diff --git a/toolkit/components/credentialmanagement/tests/xpcshell/test_identity_credential_storage_service.js b/toolkit/components/credentialmanagement/tests/xpcshell/test_identity_credential_storage_service.js new file mode 100644 index 0000000000..95ee8042cd --- /dev/null +++ b/toolkit/components/credentialmanagement/tests/xpcshell/test_identity_credential_storage_service.js @@ -0,0 +1,300 @@ +/* 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/. */ + +XPCOMUtils.defineLazyServiceGetter( + this, + "IdentityCredentialStorageService", + "@mozilla.org/browser/identity-credential-storage-service;1", + "nsIIdentityCredentialStorageService" +); + +do_get_profile(); + +add_task(async function test_insert_and_delete() { + let rpPrincipal = Services.scriptSecurityManager.createContentPrincipal( + Services.io.newURI("https://rp.com/"), + {} + ); + let idpPrincipal = Services.scriptSecurityManager.createContentPrincipal( + Services.io.newURI("https://idp.com/"), + {} + ); + const credentialID = "ID"; + + // Test initial value + let registered = {}; + let allowLogout = {}; + IdentityCredentialStorageService.getState( + rpPrincipal, + idpPrincipal, + credentialID, + registered, + allowLogout + ); + Assert.ok(!registered.value, "Should not be registered initially."); + Assert.ok(!allowLogout.value, "Should not allow logout initially."); + + // Set and read a value + IdentityCredentialStorageService.setState( + rpPrincipal, + idpPrincipal, + credentialID, + true, + true + ); + IdentityCredentialStorageService.getState( + rpPrincipal, + idpPrincipal, + credentialID, + registered, + allowLogout + ); + Assert.ok(registered.value, "Should be registered by set."); + Assert.ok(allowLogout.value, "Should now allow logout by set."); + + IdentityCredentialStorageService.delete( + rpPrincipal, + idpPrincipal, + credentialID + ); + IdentityCredentialStorageService.getState( + rpPrincipal, + idpPrincipal, + credentialID, + registered, + allowLogout + ); + Assert.ok(!registered.value, "Should not be registered after deletion."); + Assert.ok(!allowLogout.value, "Should not allow logout after deletion."); + IdentityCredentialStorageService.clear(); +}); + +add_task(async function test_basedomain_delete() { + let rpPrincipal = Services.scriptSecurityManager.createContentPrincipal( + Services.io.newURI("https://rp.com/"), + {} + ); + let rpPrincipal2 = Services.scriptSecurityManager.createContentPrincipal( + Services.io.newURI("https://www.rp.com/"), + {} + ); + let rpPrincipal3 = Services.scriptSecurityManager.createContentPrincipal( + Services.io.newURI("https://www.other.com/"), + {} + ); + let idpPrincipal = Services.scriptSecurityManager.createContentPrincipal( + Services.io.newURI("https://idp.com/"), + {} + ); + const credentialID = "ID"; + let registered = {}; + let allowLogout = {}; + + // Set values + IdentityCredentialStorageService.setState( + rpPrincipal, + idpPrincipal, + credentialID, + true, + true + ); + IdentityCredentialStorageService.setState( + rpPrincipal2, + idpPrincipal, + credentialID, + true, + true + ); + IdentityCredentialStorageService.setState( + rpPrincipal3, + idpPrincipal, + credentialID, + true, + true + ); + + IdentityCredentialStorageService.deleteFromBaseDomain( + rpPrincipal2.baseDomain + ); + IdentityCredentialStorageService.getState( + rpPrincipal, + idpPrincipal, + credentialID, + registered, + allowLogout + ); + Assert.ok(!registered.value, "Should not be registered after deletion."); + Assert.ok(!allowLogout.value, "Should not allow logout after deletion."); + IdentityCredentialStorageService.getState( + rpPrincipal2, + idpPrincipal, + credentialID, + registered, + allowLogout + ); + Assert.ok(!registered.value, "Should not be registered after deletion."); + Assert.ok(!allowLogout.value, "Should not allow logout after deletion."); + IdentityCredentialStorageService.getState( + rpPrincipal3, + idpPrincipal, + credentialID, + registered, + allowLogout + ); + Assert.ok(registered.value, "Should be registered by set."); + Assert.ok(allowLogout.value, "Should now allow logout by set."); + IdentityCredentialStorageService.clear(); +}); + +add_task(async function test_principal_delete() { + let rpPrincipal = Services.scriptSecurityManager.createContentPrincipal( + Services.io.newURI("https://rp.com/"), + {} + ); + let rpPrincipal2 = Services.scriptSecurityManager.createContentPrincipal( + Services.io.newURI("https://www.rp.com/"), + {} + ); + let rpPrincipal3 = Services.scriptSecurityManager.createContentPrincipal( + Services.io.newURI("https://www.other.com/"), + {} + ); + let idpPrincipal = Services.scriptSecurityManager.createContentPrincipal( + Services.io.newURI("https://idp.com/"), + {} + ); + const credentialID = "ID"; + let registered = {}; + let allowLogout = {}; + + // Set values + IdentityCredentialStorageService.setState( + rpPrincipal, + idpPrincipal, + credentialID, + true, + true + ); + IdentityCredentialStorageService.setState( + rpPrincipal2, + idpPrincipal, + credentialID, + true, + true + ); + IdentityCredentialStorageService.setState( + rpPrincipal3, + idpPrincipal, + credentialID, + true, + true + ); + + IdentityCredentialStorageService.deleteFromPrincipal(rpPrincipal2); + IdentityCredentialStorageService.getState( + rpPrincipal, + idpPrincipal, + credentialID, + registered, + allowLogout + ); + Assert.ok(registered.value, "Should be registered by set."); + Assert.ok(allowLogout.value, "Should now allow logout by set."); + IdentityCredentialStorageService.getState( + rpPrincipal2, + idpPrincipal, + credentialID, + registered, + allowLogout + ); + Assert.ok(!registered.value, "Should not be registered after deletion."); + Assert.ok(!allowLogout.value, "Should not allow logout after deletion."); + IdentityCredentialStorageService.getState( + rpPrincipal3, + idpPrincipal, + credentialID, + registered, + allowLogout + ); + Assert.ok(registered.value, "Should be registered by set."); + Assert.ok(allowLogout.value, "Should now allow logout by set."); + IdentityCredentialStorageService.clear(); +}); + +add_task(async function test_principal_delete() { + let rpPrincipal = Services.scriptSecurityManager.createContentPrincipal( + Services.io.newURI("https://rp.com/"), + {} + ); + let rpPrincipal2 = Services.scriptSecurityManager.createContentPrincipal( + Services.io.newURI("https://rp.com/"), + { privateBrowsingId: 1 } + ); + let rpPrincipal3 = Services.scriptSecurityManager.createContentPrincipal( + Services.io.newURI("https://www.other.com/"), + { privateBrowsingId: 1 } + ); + let idpPrincipal = Services.scriptSecurityManager.createContentPrincipal( + Services.io.newURI("https://idp.com/"), + {} + ); + const credentialID = "ID"; + let registered = {}; + let allowLogout = {}; + + // Set values + IdentityCredentialStorageService.setState( + rpPrincipal, + idpPrincipal, + credentialID, + true, + true + ); + IdentityCredentialStorageService.setState( + rpPrincipal2, + idpPrincipal, + credentialID, + true, + true + ); + IdentityCredentialStorageService.setState( + rpPrincipal3, + idpPrincipal, + credentialID, + true, + true + ); + + IdentityCredentialStorageService.deleteFromOriginAttributesPattern( + '{ "privateBrowsingId": 1 }' + ); + IdentityCredentialStorageService.getState( + rpPrincipal, + idpPrincipal, + credentialID, + registered, + allowLogout + ); + Assert.ok(registered.value, "Should be registered by set."); + Assert.ok(allowLogout.value, "Should now allow logout by set."); + IdentityCredentialStorageService.getState( + rpPrincipal2, + idpPrincipal, + credentialID, + registered, + allowLogout + ); + Assert.ok(!registered.value, "Should not be registered after deletion."); + Assert.ok(!allowLogout.value, "Should not allow logout after deletion."); + IdentityCredentialStorageService.getState( + rpPrincipal3, + idpPrincipal, + credentialID, + registered, + allowLogout + ); + Assert.ok(!registered.value, "Should not be registered after deletion."); + Assert.ok(!allowLogout.value, "Should not allow logout after deletion."); + IdentityCredentialStorageService.clear(); +}); diff --git a/toolkit/components/credentialmanagement/tests/xpcshell/xpcshell.toml b/toolkit/components/credentialmanagement/tests/xpcshell/xpcshell.toml new file mode 100644 index 0000000000..b9c1234449 --- /dev/null +++ b/toolkit/components/credentialmanagement/tests/xpcshell/xpcshell.toml @@ -0,0 +1,5 @@ +[DEFAULT] +head = "head.js" +prefs = ["dom.security.credentialmanagement.identity.enabled=true"] + +["test_identity_credential_storage_service.js"] |