From 9a62c3c03bf69d47704e208af07b6892b8405285 Mon Sep 17 00:00:00 2001 From: forkimenjeckayang Date: Fri, 27 Jun 2025 08:31:31 +0100 Subject: [PATCH 1/3] Update Credential Issuer Metadata Model for OID4VCI Draft-15 Closes #39290 Signed-off-by: forkimenjeckayang --- .../oid4vc/model/CredentialIssuer.java | 144 ++++++++++++++++-- 1 file changed, 128 insertions(+), 16 deletions(-) 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 07b1e05064a1..77ed8a3ff919 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 @@ -21,12 +21,13 @@ import com.fasterxml.jackson.annotation.JsonProperty; import java.util.Collections; +import java.util.HashMap; import java.util.List; import java.util.Map; /** * Represents a credentials issuer according to the OID4VCI Credentials Issuer Metadata - * {@see https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#name-credential-issuer-metadata} + * {@see https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0-15.html#name-credential-issuer-metadata} * * @author Stefan Wiedemann */ @@ -42,16 +43,29 @@ public class CredentialIssuer { @JsonProperty("nonce_endpoint") private String nonceEndpoint; + @JsonProperty("deferred_credential_endpoint") + private String deferredCredentialEndpoint; + @JsonProperty("authorization_servers") private List authorizationServers; @JsonProperty("notification_endpoint") private String notificationEndpoint; + @JsonProperty("credential_response_encryption") + private CredentialResponseEncryption credentialResponseEncryption; + + @JsonProperty("batch_credential_issuance") + private BatchCredentialIssuance batchCredentialIssuance; + + @JsonProperty("signed_metadata") + private String signedMetadata; + @JsonProperty("credential_configurations_supported") private Map credentialsSupported; - private DisplayObject display; + @JsonProperty("display") + private List display; public String getCredentialIssuer() { return credentialIssuer; @@ -80,21 +94,12 @@ public CredentialIssuer setNonceEndpoint(String nonceEndpoint) { return this; } - public Map getCredentialsSupported() { - return credentialsSupported; + public String getDeferredCredentialEndpoint() { + return deferredCredentialEndpoint; } - public CredentialIssuer setCredentialsSupported(Map credentialsSupported) { - this.credentialsSupported = Collections.unmodifiableMap(credentialsSupported); - return this; - } - - public DisplayObject getDisplay() { - return display; - } - - public CredentialIssuer setDisplay(DisplayObject display) { - this.display = display; + public CredentialIssuer setDeferredCredentialEndpoint(String deferredCredentialEndpoint) { + this.deferredCredentialEndpoint = deferredCredentialEndpoint; return this; } @@ -115,5 +120,112 @@ public CredentialIssuer setNotificationEndpoint(String notificationEndpoint) { this.notificationEndpoint = notificationEndpoint; return this; } -} + public CredentialResponseEncryption getCredentialResponseEncryption() { + return credentialResponseEncryption; + } + + public CredentialIssuer setCredentialResponseEncryption(CredentialResponseEncryption credentialResponseEncryption) { + this.credentialResponseEncryption = credentialResponseEncryption; + return this; + } + + public BatchCredentialIssuance getBatchCredentialIssuance() { + return batchCredentialIssuance; + } + + public CredentialIssuer setBatchCredentialIssuance(BatchCredentialIssuance batchCredentialIssuance) { + this.batchCredentialIssuance = batchCredentialIssuance; + return this; + } + + public String getSignedMetadata() { + return signedMetadata; + } + + public CredentialIssuer setSignedMetadata(String signedMetadata) { + this.signedMetadata = signedMetadata; + return this; + } + + public Map getCredentialsSupported() { + return credentialsSupported; + } + + public CredentialIssuer setCredentialsSupported(Map credentialsSupported) { + if (credentialsSupported == null) { + throw new IllegalArgumentException("credentialsSupported cannot be null"); + } + this.credentialsSupported = Collections.unmodifiableMap(new HashMap<>(credentialsSupported)); + return this; + } + + public List getDisplay() { + return display; + } + + public CredentialIssuer setDisplay(List display) { + this.display = display; + return this; + } + + /** + * Represents the credential_response_encryption metadata parameter. + */ + @JsonInclude(JsonInclude.Include.NON_NULL) + public static class CredentialResponseEncryption { + @JsonProperty("alg_values_supported") + private List algValuesSupported; + + @JsonProperty("enc_values_supported") + private List encValuesSupported; + + @JsonProperty("encryption_required") + private Boolean encryptionRequired; + + public List getAlgValuesSupported() { + return algValuesSupported; + } + + public CredentialResponseEncryption setAlgValuesSupported(List algValuesSupported) { + this.algValuesSupported = algValuesSupported; + return this; + } + + public List getEncValuesSupported() { + return encValuesSupported; + } + + public CredentialResponseEncryption setEncValuesSupported(List encValuesSupported) { + this.encValuesSupported = encValuesSupported; + return this; + } + + public Boolean getEncryptionRequired() { + return encryptionRequired; + } + + public CredentialResponseEncryption setEncryptionRequired(Boolean encryptionRequired) { + this.encryptionRequired = encryptionRequired; + return this; + } + } + + /** + * Represents the batch_credential_issuance metadata parameter. + */ + @JsonInclude(JsonInclude.Include.NON_NULL) + public static class BatchCredentialIssuance { + @JsonProperty("batch_size") + private Integer batchSize; + + public Integer getBatchSize() { + return batchSize; + } + + public BatchCredentialIssuance setBatchSize(Integer batchSize) { + this.batchSize = batchSize; + return this; + } + } +} From 64b3db49674537d8badaf958017ca3b5955c35fd Mon Sep 17 00:00:00 2001 From: forkimenjeckayang Date: Tue, 8 Jul 2025 14:46:01 +0100 Subject: [PATCH 2/3] update: OID4VCIssuerWellKnownProvider and test Signed-off-by: forkimenjeckayang --- .../OID4VCIssuerWellKnownProvider.java | 64 +++++++++++++++++-- .../OID4VCIssuerWellKnownProviderTest.java | 19 ++++++ 2 files changed, 78 insertions(+), 5 deletions(-) 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 982680196ea6..fb86f823428b 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 @@ -37,6 +37,9 @@ import org.keycloak.services.Urls; import org.keycloak.urls.UrlType; import org.keycloak.wellknown.WellKnownProvider; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.jboss.logging.Logger; import java.util.ArrayList; import java.util.HashMap; @@ -55,6 +58,7 @@ */ public class OID4VCIssuerWellKnownProvider implements WellKnownProvider { + private static final Logger LOGGER = Logger.getLogger(OID4VCIssuerWellKnownProvider.class); private final KeycloakSession keycloakSession; public OID4VCIssuerWellKnownProvider(KeycloakSession keycloakSession) { @@ -68,12 +72,62 @@ public void close() { @Override public Object getConfig() { - return new CredentialIssuer() - .setCredentialIssuer(getIssuer(keycloakSession.getContext())) - .setCredentialEndpoint(getCredentialsEndpoint(keycloakSession.getContext())) - .setNonceEndpoint(getNonceEndpoint(keycloakSession.getContext())) + KeycloakContext context = keycloakSession.getContext(); + CredentialIssuer issuer = new CredentialIssuer() + .setCredentialIssuer(getIssuer(context)) + .setCredentialEndpoint(getCredentialsEndpoint(context)) + .setNonceEndpoint(getNonceEndpoint(context)) + .setDeferredCredentialEndpoint(getDeferredCredentialEndpoint(context)) .setCredentialsSupported(getSupportedCredentials(keycloakSession)) - .setAuthorizationServers(List.of(getIssuer(keycloakSession.getContext()))); + .setAuthorizationServers(List.of(getIssuer(context))) + .setCredentialResponseEncryption(getCredentialResponseEncryption(keycloakSession)) + .setBatchCredentialIssuance(getBatchCredentialIssuance(keycloakSession)) + .setSignedMetadata(getSignedMetadata(keycloakSession)); + return issuer; + } + + private static String getDeferredCredentialEndpoint(KeycloakContext context) { + return getIssuer(context) + "/protocol/" + OID4VCLoginProtocolFactory.PROTOCOL_ID + "/deferred_credential"; + } + + private CredentialIssuer.CredentialResponseEncryption getCredentialResponseEncryption(KeycloakSession session) { + RealmModel realm = session.getContext().getRealm(); + String algs = realm.getAttribute("credential_response_encryption.alg_values_supported"); + String encs = realm.getAttribute("credential_response_encryption.enc_values_supported"); + String required = realm.getAttribute("credential_response_encryption.encryption_required"); + if (algs != null && encs != null && required != null) { + try { + ObjectMapper mapper = new ObjectMapper(); + return new CredentialIssuer.CredentialResponseEncryption() + .setAlgValuesSupported(mapper.readValue(algs, new TypeReference>() { + })) + .setEncValuesSupported(mapper.readValue(encs, new TypeReference>() { + })) + .setEncryptionRequired(Boolean.parseBoolean(required)); + } catch (Exception e) { + LOGGER.warnf(e, "Failed to parse credential_response_encryption fields from realm attributes."); + } + } + return null; + } + + private CredentialIssuer.BatchCredentialIssuance getBatchCredentialIssuance(KeycloakSession session) { + RealmModel realm = session.getContext().getRealm(); + String batchSize = realm.getAttribute("batch_credential_issuance.batch_size"); + if (batchSize != null) { + try { + return new CredentialIssuer.BatchCredentialIssuance() + .setBatchSize(Integer.parseInt(batchSize)); + } catch (Exception e) { + LOGGER.warnf(e, "Failed to parse batch_credential_issuance.batch_size from realm attributes."); + } + } + return null; + } + + private String getSignedMetadata(KeycloakSession session) { + RealmModel realm = session.getContext().getRealm(); + return realm.getAttribute("signed_metadata"); } /** diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCIssuerWellKnownProviderTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCIssuerWellKnownProviderTest.java index 12484fcdabb1..27dc6010f15c 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCIssuerWellKnownProviderTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCIssuerWellKnownProviderTest.java @@ -118,6 +118,11 @@ public static void configureTestRealm( Map clientAttributes, Map realmAttributes ) { + realmAttributes.put("credential_response_encryption.alg_values_supported", "[\"RSA-OAEP\"]"); + realmAttributes.put("credential_response_encryption.enc_values_supported", "[\"A256GCM\"]"); + realmAttributes.put("credential_response_encryption.encryption_required", "true"); + realmAttributes.put("batch_credential_issuance.batch_size", "10"); + realmAttributes.put("signed_metadata", "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJmb28iOiJiYXIifQ.XYZ123abc"); // example JWT testClient.setAttributes(new HashMap<>(clientAttributes)); testRealm.setAttributes(new HashMap<>(realmAttributes)); extendConfigureTestRealm(testRealm, testClient); @@ -126,6 +131,7 @@ public static void configureTestRealm( public static void testCredentialConfig(SuiteContext suiteContext, KeycloakTestingClient testingClient) { String expectedIssuer = suiteContext.getAuthServerInfo().getContextRoot().toString() + "/auth/realms/" + TEST_REALM_NAME; String expectedCredentialsEndpoint = expectedIssuer + "/protocol/oid4vc/credential"; + String expectedDeferredEndpoint = expectedIssuer + "/protocol/oid4vc/deferred_credential"; final String expectedAuthorizationServer = expectedIssuer; testingClient .server(TEST_REALM_NAME) @@ -136,6 +142,7 @@ public static void testCredentialConfig(SuiteContext suiteContext, KeycloakTesti CredentialIssuer credentialIssuer = (CredentialIssuer) issuerConfig; assertEquals("The correct issuer should be included.", expectedIssuer, credentialIssuer.getCredentialIssuer()); assertEquals("The correct credentials endpoint should be included.", expectedCredentialsEndpoint, credentialIssuer.getCredentialEndpoint()); + assertEquals("The correct deferred_credential_endpoint should be included.", expectedDeferredEndpoint, credentialIssuer.getDeferredCredentialEndpoint()); assertEquals("Since the authorization server is equal to the issuer, just 1 should be returned.", 1, credentialIssuer.getAuthorizationServers().size()); assertEquals("The expected server should have been returned.", expectedAuthorizationServer, credentialIssuer.getAuthorizationServers().get(0)); assertTrue("The test-credential should be supported.", credentialIssuer.getCredentialsSupported().containsKey("test-credential")); @@ -146,6 +153,18 @@ public static void testCredentialConfig(SuiteContext suiteContext, KeycloakTesti assertFalse("The test-credential claim firstName is not mandatory.", credentialIssuer.getCredentialsSupported().get("test-credential").getClaims().get("firstName").getMandatory()); assertEquals("The test-credential claim firstName shall be displayed as First Name", "First Name", credentialIssuer.getCredentialsSupported().get("test-credential").getClaims().get("firstName").getDisplay().get(0).getName()); // moved sd-jwt specific config to org.keycloak.testsuite.oid4vc.issuance.signing.OID4VCSdJwtIssuingEndpointTest.getConfig + + CredentialIssuer.CredentialResponseEncryption encryption = credentialIssuer.getCredentialResponseEncryption(); + assertNotNull("credential_response_encryption should be present", encryption); + assertNotNull("alg_values_supported should be present", encryption.getAlgValuesSupported()); + assertNotNull("enc_values_supported should be present", encryption.getEncValuesSupported()); + assertNotNull("encryption_required should be present", encryption.getEncryptionRequired()); + + CredentialIssuer.BatchCredentialIssuance batch = credentialIssuer.getBatchCredentialIssuance(); + assertNotNull("batch_credential_issuance should be present", batch); + assertNotNull("batch_size should be present", batch.getBatchSize()); + + assertNotNull("signed_metadata should be present", credentialIssuer.getSignedMetadata()); })); } From 2e6457e90359bb5f2002b63f04681cfde6f4a76f Mon Sep 17 00:00:00 2001 From: forkimenjeckayang Date: Tue, 8 Jul 2025 15:22:16 +0100 Subject: [PATCH 3/3] update: OID4VCIssuerWellKnownProviderTest Signed-off-by: forkimenjeckayang --- .../OID4VCIssuerWellKnownProviderTest.java | 45 ++++++++++++++----- 1 file changed, 33 insertions(+), 12 deletions(-) diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCIssuerWellKnownProviderTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCIssuerWellKnownProviderTest.java index 27dc6010f15c..0831bcef729c 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCIssuerWellKnownProviderTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCIssuerWellKnownProviderTest.java @@ -77,6 +77,39 @@ public void testCredentialConfig() { .testCredentialConfig(suiteContext, testingClient); } + @Test + public void testCredentialIssuerMetadataFields() { + String expectedIssuer = suiteContext.getAuthServerInfo().getContextRoot().toString() + "/auth/realms/" + TEST_REALM_NAME; + KeycloakTestingClient testingClient = this.testingClient; + + testingClient + .server(TEST_REALM_NAME) + .run(session -> { + OID4VCIssuerWellKnownProvider provider = new OID4VCIssuerWellKnownProvider(session); + Object config = provider.getConfig(); + assertTrue("Should return CredentialIssuer", config instanceof CredentialIssuer); + CredentialIssuer issuer = (CredentialIssuer) config; + + // Check credential_response_encryption + CredentialIssuer.CredentialResponseEncryption encryption = issuer.getCredentialResponseEncryption(); + assertNotNull("credential_response_encryption should be present", encryption); + assertEquals(List.of("RSA-OAEP"), encryption.getAlgValuesSupported()); + assertEquals(List.of("A256GCM"), encryption.getEncValuesSupported()); + assertTrue("encryption_required should be true", encryption.getEncryptionRequired()); + + // Check batch_credential_issuance + CredentialIssuer.BatchCredentialIssuance batch = issuer.getBatchCredentialIssuance(); + assertNotNull("batch_credential_issuance should be present", batch); + assertEquals(Integer.valueOf(10), batch.getBatchSize()); + + // Check signed_metadata + assertEquals( + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJmb28iOiJiYXIifQ.XYZ123abc", + issuer.getSignedMetadata() + ); + }); + } + @Override public void configureTestRealm(RealmRepresentation testRealm) { Map clientAttributes = new HashMap<>(getTestCredentialDefinitionAttributes()); @@ -153,18 +186,6 @@ public static void testCredentialConfig(SuiteContext suiteContext, KeycloakTesti assertFalse("The test-credential claim firstName is not mandatory.", credentialIssuer.getCredentialsSupported().get("test-credential").getClaims().get("firstName").getMandatory()); assertEquals("The test-credential claim firstName shall be displayed as First Name", "First Name", credentialIssuer.getCredentialsSupported().get("test-credential").getClaims().get("firstName").getDisplay().get(0).getName()); // moved sd-jwt specific config to org.keycloak.testsuite.oid4vc.issuance.signing.OID4VCSdJwtIssuingEndpointTest.getConfig - - CredentialIssuer.CredentialResponseEncryption encryption = credentialIssuer.getCredentialResponseEncryption(); - assertNotNull("credential_response_encryption should be present", encryption); - assertNotNull("alg_values_supported should be present", encryption.getAlgValuesSupported()); - assertNotNull("enc_values_supported should be present", encryption.getEncValuesSupported()); - assertNotNull("encryption_required should be present", encryption.getEncryptionRequired()); - - CredentialIssuer.BatchCredentialIssuance batch = credentialIssuer.getBatchCredentialIssuance(); - assertNotNull("batch_credential_issuance should be present", batch); - assertNotNull("batch_size should be present", batch.getBatchSize()); - - assertNotNull("signed_metadata should be present", credentialIssuer.getSignedMetadata()); })); }