mirror of
https://github.com/keycloak/keycloak.git
synced 2025-12-16 12:05:49 -06:00
[OID4VCI] Expose advanced realm-level OID4VCI settings in the Admin UI (#44615)
closes #43900 Signed-off-by: Ogenbertrand <ogenbertrand@gmail.com>
This commit is contained in:
@@ -3602,10 +3602,38 @@ request_uri_not_supported=Request uri not supported
|
||||
registration_not_supported=Registration not supported
|
||||
oid4vciAttributes=OID4VCI attributes
|
||||
oid4vciNonceLifetime=OID4VCI Nonce Lifetime
|
||||
oid4vciNonceLifetimeHelp=The lifetime of the OID4VCI nonce in seconds.
|
||||
oid4vciNonceLifetimeHelp=The lifetime of the OID4VCI nonce.
|
||||
preAuthorizedCodeLifespan=Pre-Authorized Code Lifespan
|
||||
preAuthorizedCodeLifespanHelp=The lifespan of the pre-authorized code in seconds.
|
||||
preAuthorizedCodeLifespanHelp=The lifespan of the pre-authorized code.
|
||||
oid4vciFormValidationError=Please ensure the OID4VCI attribute fields are filled with values 30 seconds or greater.
|
||||
signedIssuerMetadata=Signed Issuer Metadata
|
||||
signedIssuerMetadataHelp=Enable signing of the issuer metadata. When enabled, the issuer metadata will be signed using the configured signing algorithm.
|
||||
signedMetadataLifespan=Signed Metadata Lifespan
|
||||
signedMetadataLifespanHelp=The lifetime of the signed metadata. After this time, the signed metadata will expire.
|
||||
signedMetadataSigningAlgorithm=Signed Metadata Signing Algorithm
|
||||
signedMetadataSigningAlgorithmHelp=The algorithm used to sign the issuer metadata. This ensures the integrity and authenticity of the metadata.
|
||||
requireEncryption=Require Encryption
|
||||
requireEncryptionHelp=If enabled, encryption is required for credential requests. Clients must encrypt their requests using the supported encryption algorithms.
|
||||
enableDeflateCompression=Enable DEF compression
|
||||
enableDeflateCompressionHelp=If enabled, the DEF compression algorithm is supported for credential requests. This allows clients to compress their requests to reduce payload size.
|
||||
batchIssuanceSize=Batch Issuance Size
|
||||
batchIssuanceSizeHelp=The maximum number of credentials that can be issued in a single batch request. This helps manage server load and response times.
|
||||
timeClaimCorrelationMitigation=Time-claim correlation mitigation
|
||||
timeClaimsStrategy=Strategy to apply to time claims
|
||||
timeClaimsStrategyHelp=Strategy to apply to time claims. Supported values: off, randomize, round.
|
||||
randomizeWindow=Randomize Window
|
||||
randomizeWindowHelp=When strategy is randomize, subtract a random number of seconds between 0 and the value of this attribute from the original timestamp to mitigate correlation attacks.
|
||||
roundUnit=Round Unit
|
||||
roundUnitHelp=When strategy is round, truncate timestamps to the selected unit boundary (UTC). Supported values: SECOND, MINUTE, HOUR, DAY.
|
||||
randomize=Randomize
|
||||
round=Round
|
||||
second=Second
|
||||
day=Day
|
||||
attestationTrust=Attestation Trust
|
||||
trustedKeyIds=Trusted Key IDs
|
||||
trustedKeyIdsHelp=Comma-separated list of Key IDs (kid) from the realm keystore. These keys are trusted for validating wallet attestations.
|
||||
trustedKeys=Trusted Keys (JSON)
|
||||
trustedKeysHelp=A JSON array of JWK objects. These keys are trusted for validating wallet attestations in addition to realm keys.
|
||||
# OID4VCI Credential Configuration
|
||||
credentialConfigurationId=Credential Configuration ID
|
||||
credentialConfigurationIdHelp=The unique identifier for this credential configuration. This ID is used in the credential issuer metadata and credential requests.
|
||||
|
||||
@@ -5,6 +5,8 @@ import {
|
||||
SelectVariant,
|
||||
ScrollForm,
|
||||
useAlerts,
|
||||
SelectControl,
|
||||
NumberControl,
|
||||
} from "@keycloak/keycloak-ui-shared";
|
||||
import {
|
||||
AlertVariant,
|
||||
@@ -17,6 +19,7 @@ import {
|
||||
Switch,
|
||||
Text,
|
||||
TextInput,
|
||||
TextArea,
|
||||
TextVariants,
|
||||
} from "@patternfly/react-core";
|
||||
import { useState } from "react";
|
||||
@@ -24,6 +27,7 @@ import { Controller, useFormContext, useWatch } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FormAccess } from "../components/form/FormAccess";
|
||||
import { FixedButtonsGroup } from "../components/form/FixedButtonGroup";
|
||||
import { DefaultSwitchControl } from "../components/SwitchControl";
|
||||
import { convertAttributeNameToForm } from "../util";
|
||||
import {
|
||||
TimeSelector,
|
||||
@@ -37,7 +41,7 @@ import useIsFeatureEnabled, { Feature } from "../utils/useIsFeatureEnabled";
|
||||
|
||||
import "./realm-settings-section.css";
|
||||
|
||||
type RealmSettingsSessionsTabProps = {
|
||||
type RealmSettingsTokensTabProps = {
|
||||
realm: RealmRepresentation;
|
||||
save: (realm: RealmRepresentation) => void;
|
||||
};
|
||||
@@ -45,7 +49,7 @@ type RealmSettingsSessionsTabProps = {
|
||||
export const RealmSettingsTokensTab = ({
|
||||
realm,
|
||||
save,
|
||||
}: RealmSettingsSessionsTabProps) => {
|
||||
}: RealmSettingsTokensTabProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { addAlert } = useAlerts();
|
||||
const serverInfo = useServerInfo();
|
||||
@@ -59,6 +63,9 @@ export const RealmSettingsTokensTab = ({
|
||||
serverInfo.providers!["signature"].providers,
|
||||
);
|
||||
|
||||
const asymmetricSigAlgOptions =
|
||||
serverInfo.cryptoInfo?.clientSignatureAsymmetricAlgorithms ?? [];
|
||||
|
||||
const { control, register, reset, formState, handleSubmit } =
|
||||
useFormContext<RealmRepresentation>();
|
||||
|
||||
@@ -85,6 +92,26 @@ export const RealmSettingsTokensTab = ({
|
||||
defaultValue: false,
|
||||
});
|
||||
|
||||
const signedMetadataEnabled = useWatch({
|
||||
control,
|
||||
name: convertAttributeNameToForm(
|
||||
"attributes.oid4vci.signed_metadata.enabled",
|
||||
),
|
||||
defaultValue: realm.attributes?.["oid4vci.signed_metadata.enabled"],
|
||||
});
|
||||
|
||||
const encryptionRequired = useWatch({
|
||||
control,
|
||||
name: convertAttributeNameToForm("attributes.oid4vci.encryption.required"),
|
||||
defaultValue: realm.attributes?.["oid4vci.encryption.required"],
|
||||
});
|
||||
|
||||
const strategy = useWatch({
|
||||
control,
|
||||
name: convertAttributeNameToForm("attributes.oid4vci.time.claims.strategy"),
|
||||
defaultValue: realm.attributes?.["oid4vci.time.claims.strategy"] ?? "off",
|
||||
});
|
||||
|
||||
const sections = [
|
||||
{
|
||||
title: t("general"),
|
||||
@@ -674,6 +701,193 @@ export const RealmSettingsTokensTab = ({
|
||||
min={30}
|
||||
units={["second", "minute", "hour"]}
|
||||
/>
|
||||
<DefaultSwitchControl
|
||||
name={convertAttributeNameToForm(
|
||||
"attributes.oid4vci.signed_metadata.enabled",
|
||||
)}
|
||||
label={t("signedIssuerMetadata")}
|
||||
labelIcon={t("signedIssuerMetadataHelp")}
|
||||
stringify
|
||||
data-testid="signed-metadata-switch"
|
||||
/>
|
||||
{signedMetadataEnabled === "true" && (
|
||||
<>
|
||||
<TimeSelectorControl
|
||||
name={convertAttributeNameToForm(
|
||||
"attributes.oid4vci.signed_metadata.lifespan",
|
||||
)}
|
||||
label={t("signedMetadataLifespan")}
|
||||
labelIcon={t("signedMetadataLifespanHelp")}
|
||||
controller={{
|
||||
defaultValue: 60,
|
||||
}}
|
||||
units={["second", "minute", "hour"]}
|
||||
data-testid="signed-metadata-lifespan"
|
||||
/>
|
||||
<SelectControl
|
||||
name={convertAttributeNameToForm(
|
||||
"attributes.oid4vci.signed_metadata.alg",
|
||||
)}
|
||||
label={t("signedMetadataSigningAlgorithm")}
|
||||
labelIcon={t("signedMetadataSigningAlgorithmHelp")}
|
||||
controller={{
|
||||
defaultValue: "RS256",
|
||||
}}
|
||||
options={asymmetricSigAlgOptions.map((p) => ({
|
||||
key: p,
|
||||
value: p,
|
||||
}))}
|
||||
data-testid="signed-metadata-signing-algorithm"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<DefaultSwitchControl
|
||||
name={convertAttributeNameToForm(
|
||||
"attributes.oid4vci.encryption.required",
|
||||
)}
|
||||
label={t("requireEncryption")}
|
||||
labelIcon={t("requireEncryptionHelp")}
|
||||
stringify
|
||||
data-testid="require-encryption-switch"
|
||||
/>
|
||||
{encryptionRequired === "true" && (
|
||||
<DefaultSwitchControl
|
||||
name={convertAttributeNameToForm(
|
||||
"attributes.oid4vci.request.zip.algorithms",
|
||||
)}
|
||||
label={t("enableDeflateCompression")}
|
||||
labelIcon={t("enableDeflateCompressionHelp")}
|
||||
data-testid="deflate-compression-switch"
|
||||
stringify
|
||||
/>
|
||||
)}
|
||||
<NumberControl
|
||||
name={convertAttributeNameToForm(
|
||||
"attributes.oid4vci.batch_credential_issuance.batch_size",
|
||||
)}
|
||||
label={t("batchIssuanceSize")}
|
||||
labelIcon={t("batchIssuanceSizeHelp")}
|
||||
min={2}
|
||||
controller={{
|
||||
defaultValue: 2,
|
||||
rules: { min: 2 },
|
||||
}}
|
||||
data-testid="batch-issuance-size"
|
||||
/>
|
||||
|
||||
<Text
|
||||
className="kc-override-action-tokens-subtitle"
|
||||
component={TextVariants.h1}
|
||||
>
|
||||
{t("attestationTrust")}
|
||||
</Text>
|
||||
<FormGroup
|
||||
label={t("trustedKeyIds")}
|
||||
fieldId="trustedKeyIds"
|
||||
labelIcon={
|
||||
<HelpItem
|
||||
helpText={t("trustedKeyIdsHelp")}
|
||||
fieldLabelId="trustedKeyIds"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<TextInput
|
||||
id="trustedKeyIds"
|
||||
data-testid="trusted-key-ids"
|
||||
{...register(
|
||||
convertAttributeNameToForm(
|
||||
"attributes.oid4vc.attestation.trusted_key_ids",
|
||||
),
|
||||
)}
|
||||
/>
|
||||
</FormGroup>
|
||||
<FormGroup
|
||||
label={t("trustedKeys")}
|
||||
fieldId="trustedKeys"
|
||||
labelIcon={
|
||||
<HelpItem
|
||||
helpText={t("trustedKeysHelp")}
|
||||
fieldLabelId="trustedKeys"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Controller
|
||||
name={convertAttributeNameToForm(
|
||||
"attributes.oid4vc.attestation.trusted_keys",
|
||||
)}
|
||||
control={control}
|
||||
defaultValue={
|
||||
realm.attributes?.["oid4vc.attestation.trusted_keys"]
|
||||
}
|
||||
render={({ field }) => (
|
||||
<TextArea
|
||||
id="trustedKeys"
|
||||
data-testid="trusted-keys"
|
||||
value={field.value}
|
||||
onChange={(_event, value) => field.onChange(value)}
|
||||
resizeOrientation="vertical"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<Text
|
||||
className="kc-override-action-tokens-subtitle"
|
||||
component={TextVariants.h1}
|
||||
>
|
||||
{t("timeClaimCorrelationMitigation")}
|
||||
</Text>
|
||||
<SelectControl
|
||||
name={convertAttributeNameToForm(
|
||||
"attributes.oid4vci.time.claims.strategy",
|
||||
)}
|
||||
label={t("timeClaimsStrategy")}
|
||||
labelIcon={t("timeClaimsStrategyHelp")}
|
||||
controller={{
|
||||
defaultValue: "off",
|
||||
}}
|
||||
options={[
|
||||
{ key: "off", value: t("off") },
|
||||
{ key: "randomize", value: t("randomize") },
|
||||
{ key: "round", value: t("round") },
|
||||
]}
|
||||
data-testid="time-claims-strategy"
|
||||
/>
|
||||
{strategy === "randomize" && (
|
||||
<NumberControl
|
||||
name={convertAttributeNameToForm(
|
||||
"attributes.oid4vci.time.randomize.window.seconds",
|
||||
)}
|
||||
label={t("randomizeWindow")}
|
||||
labelIcon={t("randomizeWindowHelp")}
|
||||
min={1}
|
||||
controller={{
|
||||
defaultValue: 86400,
|
||||
rules: { min: 1 },
|
||||
}}
|
||||
data-testid="randomize-window"
|
||||
widthChars={6}
|
||||
/>
|
||||
)}
|
||||
{strategy === "round" && (
|
||||
<SelectControl
|
||||
name={convertAttributeNameToForm(
|
||||
"attributes.oid4vci.time.round.unit",
|
||||
)}
|
||||
label={t("roundUnit")}
|
||||
labelIcon={t("roundUnitHelp")}
|
||||
controller={{
|
||||
defaultValue: "SECOND",
|
||||
}}
|
||||
options={[
|
||||
{ key: "SECOND", value: t("times.seconds") },
|
||||
{ key: "MINUTE", value: t("times.minutes") },
|
||||
{ key: "HOUR", value: t("times.hours") },
|
||||
{ key: "DAY", value: t("times.days") },
|
||||
]}
|
||||
data-testid="round-unit"
|
||||
/>
|
||||
)}
|
||||
<FixedButtonsGroup
|
||||
name="tokens-tab"
|
||||
isSubmit
|
||||
|
||||
@@ -5,6 +5,7 @@ import { createTestBed } from "../support/testbed.ts";
|
||||
import adminClient from "../utils/AdminClient.js";
|
||||
import { SERVER_URL, ROOT_PATH } from "../utils/constants.ts";
|
||||
import { login } from "../utils/login.js";
|
||||
import { selectItem } from "../utils/form.ts";
|
||||
|
||||
test("OID4VCI section visibility and jump link in Tokens tab", async ({
|
||||
page,
|
||||
@@ -200,3 +201,189 @@ test("should show validation error for values below minimum threshold", async ({
|
||||
"Please ensure the OID4VCI attribute fields are filled with values 30 seconds or greater.";
|
||||
await expect(page.getByText(validationErrorText).first()).toBeVisible();
|
||||
});
|
||||
|
||||
test("should save signed metadata, encryption, and batch issuance settings", async ({
|
||||
page,
|
||||
}) => {
|
||||
await using testBed = await createTestBed();
|
||||
await login(page, { to: toRealmSettings({ realm: testBed.realm }) });
|
||||
|
||||
const tokensTab = page.getByTestId("rs-tokens-tab");
|
||||
await tokensTab.click();
|
||||
|
||||
const oid4vciJumpLink = page.getByTestId("jump-link-oid4vci-attributes");
|
||||
await oid4vciJumpLink.click();
|
||||
|
||||
const signedMetadataSwitch = page.getByTestId(
|
||||
"attributes.oid4vci.signed_metadata.enabled",
|
||||
);
|
||||
await signedMetadataSwitch.click({ force: true });
|
||||
|
||||
const signedMetadataLifespan = page.getByTestId(
|
||||
"attributes.oid4vci🍺signed_metadata🍺lifespan",
|
||||
);
|
||||
await signedMetadataLifespan.fill("120");
|
||||
|
||||
const signedMetadataAlgField = page.locator(
|
||||
'[id="attributes.oid4vci🍺signed_metadata🍺alg"]',
|
||||
);
|
||||
await selectItem(page, signedMetadataAlgField, "ES256");
|
||||
|
||||
const requireEncryptionSwitch = page.getByTestId(
|
||||
"attributes.oid4vci.encryption.required",
|
||||
);
|
||||
await requireEncryptionSwitch.click({ force: true });
|
||||
|
||||
const batchIssuanceField = page.locator(
|
||||
'[id="attributes.oid4vci🍺batch_credential_issuance🍺batch_size"]',
|
||||
);
|
||||
const batchIssuanceInput = batchIssuanceField.locator("input");
|
||||
await batchIssuanceInput.fill("5");
|
||||
|
||||
await page.getByTestId("tokens-tab-save").click();
|
||||
await expect(
|
||||
page.getByText("Realm successfully updated").first(),
|
||||
).toBeVisible();
|
||||
|
||||
const realmData = await adminClient.getRealm(testBed.realm);
|
||||
expect(realmData?.attributes?.["oid4vci.signed_metadata.enabled"]).toBe(
|
||||
"true",
|
||||
);
|
||||
expect(realmData?.attributes?.["oid4vci.signed_metadata.lifespan"]).toBe(
|
||||
"7200",
|
||||
);
|
||||
expect(realmData?.attributes?.["oid4vci.signed_metadata.alg"]).toBe("ES256");
|
||||
expect(realmData?.attributes?.["oid4vci.encryption.required"]).toBe("true");
|
||||
expect(
|
||||
realmData?.attributes?.["oid4vci.batch_credential_issuance.batch_size"],
|
||||
).toBe("5");
|
||||
});
|
||||
|
||||
test("should save time-based correlation mitigation settings", async ({
|
||||
page,
|
||||
}) => {
|
||||
await using testBed = await createTestBed();
|
||||
await login(page, { to: toRealmSettings({ realm: testBed.realm }) });
|
||||
|
||||
const tokensTab = page.getByTestId("rs-tokens-tab");
|
||||
await tokensTab.click();
|
||||
|
||||
const oid4vciJumpLink = page.getByTestId("jump-link-oid4vci-attributes");
|
||||
await oid4vciJumpLink.click();
|
||||
|
||||
const strategyField = page.locator(
|
||||
'[id="attributes.oid4vci🍺time🍺claims🍺strategy"]',
|
||||
);
|
||||
await selectItem(page, strategyField, "Randomize");
|
||||
|
||||
const randomizationWindowField = page.locator(
|
||||
'[id="attributes.oid4vci🍺time🍺randomize🍺window🍺seconds"]',
|
||||
);
|
||||
const randomizationWindowInput = randomizationWindowField.locator("input");
|
||||
await randomizationWindowInput.fill("3600");
|
||||
|
||||
await page.getByTestId("tokens-tab-save").click();
|
||||
await expect(
|
||||
page.getByText("Realm successfully updated").first(),
|
||||
).toBeVisible();
|
||||
|
||||
await selectItem(page, strategyField, "Round");
|
||||
|
||||
const roundingUnitField = page.locator(
|
||||
'[id="attributes.oid4vci🍺time🍺round🍺unit"]',
|
||||
);
|
||||
await selectItem(page, roundingUnitField, "Minutes");
|
||||
|
||||
await page.getByTestId("tokens-tab-save").click();
|
||||
await expect(
|
||||
page.getByText("Realm successfully updated").first(),
|
||||
).toBeVisible();
|
||||
|
||||
const realmData = await adminClient.getRealm(testBed.realm);
|
||||
expect(realmData?.attributes?.["oid4vci.time.claims.strategy"]).toBe("round");
|
||||
expect(realmData?.attributes?.["oid4vci.time.randomize.window.seconds"]).toBe(
|
||||
"3600",
|
||||
);
|
||||
expect(realmData?.attributes?.["oid4vci.time.round.unit"]).toBe("MINUTE");
|
||||
});
|
||||
|
||||
test("should save Deflate Compression setting", async ({ page }) => {
|
||||
await using testBed = await createTestBed();
|
||||
await login(page, { to: toRealmSettings({ realm: testBed.realm }) });
|
||||
|
||||
const tokensTab = page.getByTestId("rs-tokens-tab");
|
||||
await tokensTab.click();
|
||||
|
||||
const oid4vciJumpLink = page.getByTestId("jump-link-oid4vci-attributes");
|
||||
await oid4vciJumpLink.click();
|
||||
|
||||
const encryptionSwitch = page.getByTestId(
|
||||
"attributes.oid4vci.encryption.required",
|
||||
);
|
||||
await encryptionSwitch.click({ force: true });
|
||||
|
||||
const deflateSwitch = page.getByTestId(
|
||||
"attributes.oid4vci.request.zip.algorithms",
|
||||
);
|
||||
await deflateSwitch.click({ force: true });
|
||||
|
||||
await page.getByTestId("tokens-tab-save").click();
|
||||
await expect(
|
||||
page.getByText("Realm successfully updated").first(),
|
||||
).toBeVisible();
|
||||
|
||||
let realmData = await adminClient.getRealm(testBed.realm);
|
||||
expect(realmData?.attributes?.["oid4vci.request.zip.algorithms"]).toBe(
|
||||
"true",
|
||||
);
|
||||
|
||||
await deflateSwitch.click({ force: true });
|
||||
await page.getByTestId("tokens-tab-save").click();
|
||||
await expect(
|
||||
page.getByText("Realm successfully updated").first(),
|
||||
).toBeVisible();
|
||||
|
||||
realmData = await adminClient.getRealm(testBed.realm);
|
||||
expect(realmData?.attributes?.["oid4vci.request.zip.algorithms"]).toBe(
|
||||
"false",
|
||||
);
|
||||
});
|
||||
|
||||
test("should save Attestation Trust settings (Trusted Keys and IDs)", async ({
|
||||
page,
|
||||
}) => {
|
||||
await using testBed = await createTestBed();
|
||||
await login(page, { to: toRealmSettings({ realm: testBed.realm }) });
|
||||
|
||||
const tokensTab = page.getByTestId("rs-tokens-tab");
|
||||
await tokensTab.click();
|
||||
|
||||
const oid4vciJumpLink = page.getByTestId("jump-link-oid4vci-attributes");
|
||||
await oid4vciJumpLink.click();
|
||||
|
||||
const trustedKeyIdsInput = page.getByTestId("trusted-key-ids");
|
||||
const trustedKeysInput = page.getByTestId("trusted-keys");
|
||||
|
||||
const testKeyIds = "kid-1, kid-2, kid-3";
|
||||
const testTrustedKeysJson =
|
||||
'[{"kty":"RSA","kid":"external-key","n":"...","e":"AQAB"}]';
|
||||
|
||||
await expect(trustedKeyIdsInput).toBeVisible();
|
||||
await expect(trustedKeysInput).toBeVisible();
|
||||
|
||||
await trustedKeyIdsInput.fill(testKeyIds);
|
||||
await trustedKeysInput.fill(testTrustedKeysJson);
|
||||
|
||||
await page.getByTestId("tokens-tab-save").click();
|
||||
await expect(
|
||||
page.getByText("Realm successfully updated").first(),
|
||||
).toBeVisible();
|
||||
|
||||
const realmData = await adminClient.getRealm(testBed.realm);
|
||||
expect(realmData?.attributes?.["oid4vc.attestation.trusted_key_ids"]).toBe(
|
||||
testKeyIds,
|
||||
);
|
||||
expect(realmData?.attributes?.["oid4vc.attestation.trusted_keys"]).toBe(
|
||||
testTrustedKeysJson,
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user