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:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user