1
0
mirror of https://github.com/keycloak/keycloak.git synced 2026-10-23 19:29:23 +03:00

[OID4VCI]: Add issuer_info support to OID4VCI metadata (#53330)

Closes #53293


Signed-off-by: Awambeng Rodrick <awambengrodrick@gmail.com>
This commit is contained in:
Awambeng
2026-09-30 12:52:15 +01:00
committed by GitHub
parent dc1577c1dd
commit 6d2c039b9a
7 changed files with 298 additions and 0 deletions

View File

@@ -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": "<JWT>"}]`. Used for EUDI registration certificate support.
|===
===== Time Claim Correlation Mitigation

View File

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

View File

@@ -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"
/>
<TextAreaControl
name={convertAttributeNameToForm("attributes.oid4vci.issuer_info")}
label={t("issuerInfo")}
labelIcon={t("issuerInfoHelp")}
rules={{
validate: (value: string) => {
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}
/>
<Text
className="kc-override-action-tokens-subtitle"

View File

@@ -405,3 +405,81 @@ test("should save Deflate Compression setting", async ({ page }) => {
"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);
});

View File

@@ -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<CredentialIssuer.IssuerInfo> getIssuerInfo(RealmModel realm) {
String rawValue = realm.getAttribute(ISSUER_INFO_ATTR);
if (rawValue == null || rawValue.isBlank()) {
return null;
}
try {
List<CredentialIssuer.IssuerInfo> issuerInfo = JsonSerialization.readValue(rawValue,
new TypeReference<List<CredentialIssuer.IssuerInfo>>() {
});
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.

View File

@@ -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> issuerInfo;
public String getCredentialIssuer() {
return credentialIssuer;
}
@@ -158,6 +162,46 @@ public class CredentialIssuer {
return this;
}
public List<IssuerInfo> getIssuerInfo() {
return issuerInfo;
}
public CredentialIssuer setIssuerInfo(List<IssuerInfo> 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.
*/

View File

@@ -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<String, Object> claims = JsonSerialization.readValue(jwsInput.getContent(), Map.class);
assertNotNull(claims.get("issuer_info"), "issuer_info should be a top-level claim in signed metadata");
List<Map<String, Object>> issuerInfoList = (List<Map<String, Object>>) 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.
*