diff --git a/docs/documentation/server_admin/topics/oid4vci/vc-issuer-configuration.adoc b/docs/documentation/server_admin/topics/oid4vci/vc-issuer-configuration.adoc index dbd47b30470..f1d116c1a3f 100644 --- a/docs/documentation/server_admin/topics/oid4vci/vc-issuer-configuration.adoc +++ b/docs/documentation/server_admin/topics/oid4vci/vc-issuer-configuration.adoc @@ -281,6 +281,11 @@ The following table describes the available OID4VCI realm attributes: | `oid4vci.batch_credential_issuance.batch_size` | `2` | Maximum number of credentials that can be issued in a single batch request. Must be 2 or greater. Disabled if not set or invalid. + +| Issuer Info +| `oid4vci.issuer_info` +| Not set +| JSON array of `issuer_info` elements published in the Credential Issuer Metadata as defined in ETSI TS 119 472-3, Section 4.2.3. Each element has a `format` and `data` member. Example: `[{"format": "registration_cert", "data": ""}]`. Used for EUDI registration certificate support. |=== ===== Time Claim Correlation Mitigation diff --git a/js/apps/admin-ui/maven-resources/theme/keycloak.v2/admin/messages/messages_en.properties b/js/apps/admin-ui/maven-resources/theme/keycloak.v2/admin/messages/messages_en.properties index 2a09ee3361a..c7ea3354c90 100644 --- a/js/apps/admin-ui/maven-resources/theme/keycloak.v2/admin/messages/messages_en.properties +++ b/js/apps/admin-ui/maven-resources/theme/keycloak.v2/admin/messages/messages_en.properties @@ -4081,6 +4081,9 @@ 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. +issuerInfo=Issuer Info +issuerInfoHelp=JSON array of issuer_info elements as defined in ETSI TS 119 472-3, Section 4.2.3. Each element contains a format and data member. Example: [{"format": "registration_cert", "data": "eyJhbGciOiJFUzI1NiJ9..."}]. +issuerInfoInvalid=Must be a valid JSON array of issuer_info elements. timeClaimCorrelationMitigation=Time-claim correlation mitigation timeClaimsStrategy=Strategy to apply to time claims timeClaimsStrategyHelp=Strategy to apply to time claims. Supported values: off, randomize, round. diff --git a/js/apps/admin-ui/src/realm-settings/TokensTab.tsx b/js/apps/admin-ui/src/realm-settings/TokensTab.tsx index 0a50c1f730b..bedda5cbd61 100644 --- a/js/apps/admin-ui/src/realm-settings/TokensTab.tsx +++ b/js/apps/admin-ui/src/realm-settings/TokensTab.tsx @@ -7,6 +7,7 @@ import { useAlerts, SelectControl, NumberControl, + TextAreaControl, } from "@keycloak/keycloak-ui-shared"; import { AlertVariant, @@ -768,6 +769,47 @@ export const RealmSettingsTokensTab = ({ }} data-testid="batch-issuance-size" /> + { + if (!value) { + return true; + } + try { + const parsed: unknown = JSON.parse(value); + const isIssuerInfoElement = (element: unknown) => { + if ( + typeof element !== "object" || + element === null || + Array.isArray(element) + ) { + return false; + } + if (!("format" in element) || !("data" in element)) { + return false; + } + const { format, data } = element; + return ( + typeof format === "string" && + format.trim().length > 0 && + data !== null + ); + }; + return ( + (Array.isArray(parsed) && + parsed.every(isIssuerInfoElement)) || + t("issuerInfoInvalid") + ); + } catch { + return t("issuerInfoInvalid"); + } + }, + }} + rows={6} + /> { "false", ); }); + +test("should save issuer info setting", async ({ page }) => { + await using testBed = await createTestBed({ + verifiableCredentialsEnabled: true, + }); + 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 issuerInfoField = page.getByTestId("attributes.oid4vci🍺issuer_info"); + await expect(issuerInfoField).toBeVisible(); + + const issuerInfoJson = + '[{"format":"registration_cert","data":"eyJhbGciOiJFUzI1NiJ9.eyJzdWIiOiJEQSJ9.sig"}]'; + await issuerInfoField.fill(issuerInfoJson); + + 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.issuer_info"]).toBe(issuerInfoJson); +}); + +test("should reject invalid issuer info JSON", async ({ page }) => { + await using testBed = await createTestBed({ + verifiableCredentialsEnabled: true, + }); + 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 issuerInfoField = page.getByTestId("attributes.oid4vci🍺issuer_info"); + await issuerInfoField.fill("not-valid-json"); + + await page.getByTestId("tokens-tab-save").click(); + + await expect( + page + .getByText("Must be a valid JSON array of issuer_info elements.") + .first(), + ).toBeVisible(); + await expect(page.getByText("Realm successfully updated")).toHaveCount(0); +}); + +test("should reject issuer info with malformed elements", async ({ page }) => { + await using testBed = await createTestBed({ + verifiableCredentialsEnabled: true, + }); + 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 issuerInfoField = page.getByTestId("attributes.oid4vci🍺issuer_info"); + await issuerInfoField.fill('[{"format":null,"data":null}]'); + + await page.getByTestId("tokens-tab-save").click(); + + await expect( + page + .getByText("Must be a valid JSON array of issuer_info elements.") + .first(), + ).toBeVisible(); + await expect(page.getByText("Realm successfully updated")).toHaveCount(0); +}); diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/OID4VCIssuerWellKnownProvider.java b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/OID4VCIssuerWellKnownProvider.java index 04df21330ea..2d43dbab77b 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/OID4VCIssuerWellKnownProvider.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/OID4VCIssuerWellKnownProvider.java @@ -17,6 +17,7 @@ package org.keycloak.protocol.oid4vc.issuance; +import java.io.IOException; import java.net.URI; import java.time.ZoneOffset; import java.time.ZonedDateTime; @@ -66,6 +67,7 @@ import org.keycloak.util.JsonSerialization; import org.keycloak.utils.MediaType; import org.keycloak.wellknown.WellKnownProvider; +import com.fasterxml.jackson.core.type.TypeReference; import org.apache.http.HttpHeaders; import org.jboss.logging.Logger; @@ -94,6 +96,7 @@ public class OID4VCIssuerWellKnownProvider implements WellKnownProvider { public static final String VC_KEY = "vc"; public static final String ATTR_RESPONSE_ENCRYPTION_REQUIRED = "oid4vci.response.encryption.required"; public static final String ATTR_REQUEST_ENCRYPTION_REQUIRED = "oid4vci.request.encryption.required"; + public static final String ISSUER_INFO_ATTR = "oid4vci.issuer_info"; public static final String DEFLATE_COMPRESSION = "DEF"; public static final String ATTR_REQUEST_ZIP_ALGS = "oid4vci.request.zip.algorithms"; @@ -147,6 +150,7 @@ public class OID4VCIssuerWellKnownProvider implements WellKnownProvider { .setAuthorizationServers(List.of(getIssuer(context))) .setCredentialResponseEncryption(responseEnc) .setCredentialRequestEncryption(requestEnc) + .setIssuerInfo(getIssuerInfo(context.getRealm())) .setBatchCredentialIssuance(getBatchCredentialIssuance(keycloakSession)); } @@ -175,6 +179,31 @@ public class OID4VCIssuerWellKnownProvider implements WellKnownProvider { return getBatchCredentialIssuance(session.getContext().getRealm()); } + /** + * Returns the parsed issuer_info elements from the realm attribute, or null if not configured or invalid. + */ + public static List getIssuerInfo(RealmModel realm) { + String rawValue = realm.getAttribute(ISSUER_INFO_ATTR); + if (rawValue == null || rawValue.isBlank()) { + return null; + } + try { + List issuerInfo = JsonSerialization.readValue(rawValue, + new TypeReference>() { + }); + if (issuerInfo == null || issuerInfo.stream().anyMatch(info -> info == null + || info.getFormat() == null || info.getFormat().isBlank() + || info.getData() == null || info.getData().isNull())) { + LOGGER.warnf("Invalid %s realm attribute. Skipping issuer_info.", ISSUER_INFO_ATTR); + return null; + } + return issuerInfo; + } catch (IOException e) { + LOGGER.warnf(e, "Failed to parse %s from realm attributes. Skipping issuer_info.", ISSUER_INFO_ATTR); + return null; + } + } + /** * Returns the batch credential issuance configuration for the given realm. * This method is public and static to facilitate testing without requiring session state management. diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/model/CredentialIssuer.java b/services/src/main/java/org/keycloak/protocol/oid4vc/model/CredentialIssuer.java index 0baad9fa87b..c6633fa3460 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/model/CredentialIssuer.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/model/CredentialIssuer.java @@ -23,6 +23,7 @@ import java.util.Map; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.JsonNode; /** * Represents a credentials issuer according to the OID4VCI Credentials Issuer Metadata @@ -64,6 +65,9 @@ public class CredentialIssuer { @JsonProperty("credential_request_encryption") private CredentialRequestEncryptionMetadata credentialRequestEncryption; + @JsonProperty("issuer_info") + private List issuerInfo; + public String getCredentialIssuer() { return credentialIssuer; } @@ -158,6 +162,46 @@ public class CredentialIssuer { return this; } + public List getIssuerInfo() { + return issuerInfo; + } + + public CredentialIssuer setIssuerInfo(List issuerInfo) { + this.issuerInfo = issuerInfo; + return this; + } + + /** + * Represents an element of the issuer_info metadata parameter as defined in ETSI TS 119 472-3, Section 4.2.3. + */ + @JsonInclude(JsonInclude.Include.NON_NULL) + @JsonIgnoreProperties(ignoreUnknown = true) + public static class IssuerInfo { + @JsonProperty("format") + private String format; + + @JsonProperty("data") + private JsonNode data; + + public String getFormat() { + return format; + } + + public IssuerInfo setFormat(String format) { + this.format = format; + return this; + } + + public JsonNode getData() { + return data; + } + + public IssuerInfo setData(JsonNode data) { + this.data = data; + return this; + } + } + /** * Represents the batch_credential_issuance metadata parameter. */ diff --git a/tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCIssuerWellKnownProviderTest.java b/tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCIssuerWellKnownProviderTest.java index 25cdc46ae4e..2d2f3363711 100644 --- a/tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCIssuerWellKnownProviderTest.java +++ b/tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCIssuerWellKnownProviderTest.java @@ -97,6 +97,7 @@ import static org.keycloak.protocol.oid4vc.issuance.OID4VCIssuerWellKnownProvide import static org.keycloak.protocol.oid4vc.issuance.OID4VCIssuerWellKnownProvider.ATTR_REQUEST_ZIP_ALGS; import static org.keycloak.protocol.oid4vc.issuance.OID4VCIssuerWellKnownProvider.ATTR_RESPONSE_ENCRYPTION_REQUIRED; import static org.keycloak.protocol.oid4vc.issuance.OID4VCIssuerWellKnownProvider.DEFLATE_COMPRESSION; +import static org.keycloak.protocol.oid4vc.issuance.OID4VCIssuerWellKnownProvider.ISSUER_INFO_ATTR; import static org.keycloak.protocol.oid4vc.issuance.OID4VCIssuerWellKnownProvider.SIGNED_METADATA_ALG_ATTR; import static org.keycloak.protocol.oid4vc.issuance.OID4VCIssuerWellKnownProvider.SIGNED_METADATA_LIFESPAN_ATTR; @@ -573,6 +574,102 @@ public class OID4VCIssuerWellKnownProviderTest extends OID4VCIssuerTestBase { testBatchSizeValidation("invalid", false, null); } + @Test + public void testIssuerInfoInUnsignedMetadata() throws IOException { + String issuerInfoJson = "[{\"format\":\"registration_cert\",\"data\":\"eyJhbGciOiJFUzI1NiJ9.eyJzdWIiOiJEQSJ9.sig\"}]"; + setRealmAttributes(Map.of(ISSUER_INFO_ATTR, issuerInfoJson)); + + try { + CredentialIssuer issuer = oauth.oid4vc() + .doIssuerMetadataRequest() + .getMetadata(); + + assertNotNull(issuer.getIssuerInfo(), "issuer_info should be present"); + assertEquals(1, issuer.getIssuerInfo().size(), "issuer_info should have one element"); + assertEquals("registration_cert", issuer.getIssuerInfo().get(0).getFormat()); + assertEquals("eyJhbGciOiJFUzI1NiJ9.eyJzdWIiOiJEQSJ9.sig", issuer.getIssuerInfo().get(0).getData().asText()); + } finally { + setRealmAttributes(Map.of(ISSUER_INFO_ATTR, "")); + } + } + + @Test + public void testIssuerInfoInSignedMetadata() throws IOException { + String issuerInfoJson = "[{\"format\":\"registration_cert\",\"data\":\"eyJhbGciOiJFUzI1NiJ9.eyJzdWIiOiJEQSJ9.sig\"}]"; + setRealmAttributes(Map.of( + SIGNED_METADATA_ALG_ATTR, "RS256", + SIGNED_METADATA_LIFESPAN_ATTR, "3600", + ISSUER_INFO_ATTR, issuerInfoJson + )); + + try { + CredentialIssuerMetadataResponse response = oauth.oid4vc() + .issuerMetadataRequest() + .header(HttpHeaders.ACCEPT, MediaType.APPLICATION_JWT) + .send(); + + assertEquals(HttpStatus.SC_OK, response.getStatusCode()); + assertEquals(MediaType.APPLICATION_JWT, response.getHeader(HttpHeaders.CONTENT_TYPE)); + + JWSInput jwsInput = (JWSInput) response.getContent(); + assertNotNull(jwsInput, "Response should be signed metadata JWS"); + + Map claims = JsonSerialization.readValue(jwsInput.getContent(), Map.class); + assertNotNull(claims.get("issuer_info"), "issuer_info should be a top-level claim in signed metadata"); + + List> issuerInfoList = (List>) claims.get("issuer_info"); + assertEquals(1, issuerInfoList.size()); + assertEquals("registration_cert", issuerInfoList.get(0).get("format")); + assertEquals("eyJhbGciOiJFUzI1NiJ9.eyJzdWIiOiJEQSJ9.sig", issuerInfoList.get(0).get("data")); + } finally { + setRealmAttributes(Map.of( + SIGNED_METADATA_ALG_ATTR, "RS256", + SIGNED_METADATA_LIFESPAN_ATTR, "3600", + ISSUER_INFO_ATTR, "" + )); + } + } + + @Test + public void testIssuerInfoInvalidJsonFallsBackToOmitted() throws IOException { + setRealmAttributes(Map.of(ISSUER_INFO_ATTR, "not-valid-json")); + + try { + CredentialIssuer issuer = oauth.oid4vc() + .doIssuerMetadataRequest() + .getMetadata(); + + assertNull(issuer.getIssuerInfo(), "issuer_info should be omitted when configuration is invalid"); + } finally { + setRealmAttributes(Map.of(ISSUER_INFO_ATTR, "")); + } + } + + @Test + public void testIssuerInfoMalformedElementFallsBackToOmitted() throws IOException { + setRealmAttributes(Map.of(ISSUER_INFO_ATTR, "[{}]")); + + try { + CredentialIssuer issuer = oauth.oid4vc() + .doIssuerMetadataRequest() + .getMetadata(); + + assertNull(issuer.getIssuerInfo(), + "issuer_info should be omitted when an element misses format or data"); + } finally { + setRealmAttributes(Map.of(ISSUER_INFO_ATTR, "")); + } + } + + @Test + public void testIssuerInfoOmittedWhenNotConfigured() throws IOException { + CredentialIssuer issuer = oauth.oid4vc() + .doIssuerMetadataRequest() + .getMetadata(); + + assertNull(issuer.getIssuerInfo(), "issuer_info should be omitted when not configured"); + } + /** * Configured key attestation resistance levels must reach the metadata. *