[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:
Ogen Bertrand
2025-12-16 12:54:12 +01:00
committed by GitHub
parent 2f7045d7dd
commit 741c0ad959
3 changed files with 433 additions and 4 deletions

View File

@@ -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.

View File

@@ -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

View File

@@ -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,
);
});