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 42a2a6544ca..b19b01521fc 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 @@ -47,6 +47,7 @@ import org.keycloak.models.RealmModel; import org.keycloak.models.oid4vci.CredentialScopeModel; import org.keycloak.protocol.oid4vc.OID4VCLoginProtocolFactory; import org.keycloak.protocol.oid4vc.issuance.credentialbuilder.CredentialBuilder; +import org.keycloak.protocol.oid4vc.issuance.credentialbuilder.CredentialBuilderFactory; import org.keycloak.protocol.oid4vc.model.CredentialIssuer; import org.keycloak.protocol.oid4vc.model.CredentialRequestEncryptionMetadata; import org.keycloak.protocol.oid4vc.model.CredentialResponseEncryptionMetadata; @@ -459,24 +460,54 @@ public class OID4VCIssuerWellKnownProvider implements WellKnownProvider { keycloakSession.clientScopes() .getClientScopesByProtocol(realm, OID4VCIConstants.OID4VC_PROTOCOL) .map(CredentialScopeModel::new) - .map(clientScope -> { - return SupportedCredentialConfiguration.parse(keycloakSession, - clientScope, + .map(credentialScope -> { + SupportedCredentialConfiguration config = SupportedCredentialConfiguration.parse(keycloakSession, + credentialScope, globalSupportedSigningAlgorithms ); + applyFormatSpecificMetadata(keycloakSession, config, credentialScope); + return config; }) .collect(Collectors.toMap(SupportedCredentialConfiguration::getId, sc -> sc, (sc1, sc2) -> sc1)); return supportedCredentialConfigurations; } + + private static void applyFormatSpecificMetadata(KeycloakSession keycloakSession, + SupportedCredentialConfiguration config, + CredentialScopeModel credentialScope) { + String format = config.getFormat(); + if (format == null) { + return; + } + + // Find the CredentialBuilder for this format using the factory pattern + CredentialBuilder credentialBuilder = keycloakSession.getKeycloakSessionFactory() + .getProviderFactoriesStream(CredentialBuilder.class) + .map(factory -> (CredentialBuilderFactory) factory) + .filter(factory -> format.equals(factory.getSupportedFormat())) + .findFirst() + .map(factory -> factory.create(keycloakSession, null)) + .orElse(null); + + if (credentialBuilder == null) { + LOGGER.debugf("No CredentialBuilder found for format: %s", format); + return; + } + + credentialBuilder.contributeToMetadata(config, credentialScope); + } + public static SupportedCredentialConfiguration toSupportedCredentialConfiguration(KeycloakSession keycloakSession, CredentialScopeModel credentialModel) { List globalSupportedSigningAlgorithms = getSupportedAsymmetricSignatureAlgorithms(keycloakSession); - return SupportedCredentialConfiguration.parse(keycloakSession, + SupportedCredentialConfiguration config = SupportedCredentialConfiguration.parse(keycloakSession, credentialModel, globalSupportedSigningAlgorithms); + applyFormatSpecificMetadata(keycloakSession, config, credentialModel); + return config; } /** diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/credentialbuilder/CredentialBuilder.java b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/credentialbuilder/CredentialBuilder.java index b1e6373d76c..8602e316b3d 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/credentialbuilder/CredentialBuilder.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/credentialbuilder/CredentialBuilder.java @@ -17,7 +17,9 @@ package org.keycloak.protocol.oid4vc.issuance.credentialbuilder; +import org.keycloak.models.oid4vci.CredentialScopeModel; import org.keycloak.protocol.oid4vc.model.CredentialBuildConfig; +import org.keycloak.protocol.oid4vc.model.SupportedCredentialConfiguration; import org.keycloak.protocol.oid4vc.model.VerifiableCredential; import org.keycloak.provider.Provider; @@ -48,4 +50,24 @@ public interface CredentialBuilder extends Provider { VerifiableCredential verifiableCredential, CredentialBuildConfig credentialBuildConfig ) throws CredentialBuilderException; + + /** + * Allows the credential builder to contribute format-specific metadata + * to the OID4VCI well-known credential issuer metadata. + * + *

+ * Implementations should add only the metadata fields required by the + * supported credential format (for example {@code vct} for {@code dc+sd-jwt} + * or {@code credential_definition} for {@code jwt_vc_json}). + *

+ * + *

+ * The default implementation is a no-op to preserve backward compatibility. + *

+ * + * @param credentialConfig the credential configuration to populate with format-specific metadata + * @param credentialScope the credential scope model containing the source data + */ + default void contributeToMetadata(SupportedCredentialConfiguration credentialConfig, CredentialScopeModel credentialScope) { + } } diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/credentialbuilder/JwtCredentialBuilder.java b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/credentialbuilder/JwtCredentialBuilder.java index cc3db5162ea..ea2d7f75313 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/credentialbuilder/JwtCredentialBuilder.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/credentialbuilder/JwtCredentialBuilder.java @@ -24,11 +24,14 @@ import java.util.function.UnaryOperator; import org.keycloak.jose.jws.JWSBuilder; import org.keycloak.models.KeycloakSession; +import org.keycloak.models.oid4vci.CredentialScopeModel; import org.keycloak.protocol.oid4vc.issuance.TimeClaimNormalizer; import org.keycloak.protocol.oid4vc.issuance.TimeProvider; import org.keycloak.protocol.oid4vc.model.CredentialBuildConfig; +import org.keycloak.protocol.oid4vc.model.CredentialDefinition; import org.keycloak.protocol.oid4vc.model.CredentialSubject; import org.keycloak.protocol.oid4vc.model.Format; +import org.keycloak.protocol.oid4vc.model.SupportedCredentialConfiguration; import org.keycloak.protocol.oid4vc.model.VerifiableCredential; import org.keycloak.representations.JsonWebToken; @@ -104,4 +107,10 @@ public class JwtCredentialBuilder implements CredentialBuilder { return new JwtCredentialBody(jwsBuilder); } + + @Override + public void contributeToMetadata(SupportedCredentialConfiguration credentialConfig, CredentialScopeModel credentialScope) { + CredentialDefinition credentialDefinition = CredentialDefinition.parse(credentialScope); + credentialConfig.setCredentialDefinition(credentialDefinition); + } } diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/credentialbuilder/SdJwtCredentialBuilder.java b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/credentialbuilder/SdJwtCredentialBuilder.java index bc812abca16..bb35246df7d 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/credentialbuilder/SdJwtCredentialBuilder.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/credentialbuilder/SdJwtCredentialBuilder.java @@ -26,9 +26,11 @@ import java.util.Optional; import java.util.UUID; import java.util.stream.IntStream; +import org.keycloak.models.oid4vci.CredentialScopeModel; import org.keycloak.protocol.oid4vc.model.CredentialBuildConfig; import org.keycloak.protocol.oid4vc.model.CredentialSubject; import org.keycloak.protocol.oid4vc.model.Format; +import org.keycloak.protocol.oid4vc.model.SupportedCredentialConfiguration; import org.keycloak.protocol.oid4vc.model.VerifiableCredential; import org.keycloak.sdjwt.DisclosureSpec; import org.keycloak.sdjwt.IssuerSignedJWT; @@ -134,4 +136,10 @@ public class SdJwtCredentialBuilder implements CredentialBuilder { return new SdJwtCredentialBody(sdJwtBuilder, issuerSignedJWT); } + + @Override + public void contributeToMetadata(SupportedCredentialConfiguration credentialConfig, CredentialScopeModel credentialScope) { + String vct = Optional.ofNullable(credentialScope.getVct()).orElse(credentialScope.getName()); + credentialConfig.setVct(vct); + } } diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/model/CredentialBuildConfig.java b/services/src/main/java/org/keycloak/protocol/oid4vc/model/CredentialBuildConfig.java index b48d37e1158..5b46b1afbf6 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/model/CredentialBuildConfig.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/model/CredentialBuildConfig.java @@ -97,7 +97,7 @@ public class CredentialBuildConfig { return new CredentialBuildConfig().setCredentialIssuer(credentialIssuer) .setCredentialConfigId(credentialConfiguration.getId()) - .setCredentialType(credentialConfiguration.getVct()) + .setCredentialType(credentialModel.getVct()) .setTokenJwsType(credentialModel.getTokenJwsType()) .setNumberOfDecoys(credentialModel.getSdJwtNumberOfDecoys()) .setSigningKeyId(credentialModel.getSigningKeyId()) diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/model/SupportedCredentialConfiguration.java b/services/src/main/java/org/keycloak/protocol/oid4vc/model/SupportedCredentialConfiguration.java index 4d4e946e027..cf9248a0340 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/model/SupportedCredentialConfiguration.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/model/SupportedCredentialConfiguration.java @@ -109,12 +109,6 @@ public class SupportedCredentialConfiguration { String format = Optional.ofNullable(credentialScope.getFormat()).orElse(Format.SD_JWT_VC); credentialConfiguration.setFormat(format); - String vct = Optional.ofNullable(credentialScope.getVct()).orElse(credentialScope.getName()); - credentialConfiguration.setVct(vct); - - CredentialDefinition credentialDefinition = CredentialDefinition.parse(credentialScope); - credentialConfiguration.setCredentialDefinition(credentialDefinition); - KeyAttestationsRequired keyAttestationsRequired = KeyAttestationsRequired.parse(credentialScope); ProofTypesSupported proofTypesSupported = ProofTypesSupported.parse(keycloakSession, keyAttestationsRequired, 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 06a02cf2b78..df81c408bfe 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 @@ -433,12 +433,9 @@ public class OID4VCIssuerWellKnownProviderTest extends OID4VCIssuerEndpointTest assertNotNull(supportedConfig); assertEquals(Format.SD_JWT_VC, supportedConfig.getFormat()); assertEquals(clientScope.getName(), supportedConfig.getScope()); - assertEquals(1, supportedConfig.getCredentialDefinition().getType().size()); - assertEquals(clientScope.getName(), supportedConfig.getCredentialDefinition().getType().get(0)); - assertEquals(1, supportedConfig.getCredentialDefinition().getContext().size()); - assertEquals(clientScope.getName(), supportedConfig.getCredentialDefinition().getContext().get(0)); + assertEquals(clientScope.getName(), supportedConfig.getVct()); + assertNull("SD-JWT credentials should not have credential_definition", supportedConfig.getCredentialDefinition()); assertNotNull(supportedConfig.getCredentialMetadata()); - assertEquals(clientScope.getName(), supportedConfig.getScope()); compareClaims(supportedConfig.getFormat(), supportedConfig.getCredentialMetadata().getClaims(), clientScope.getProtocolMappers()); } @@ -550,31 +547,35 @@ public class OID4VCIssuerWellKnownProviderTest extends OID4VCIssuerEndpointTest compareDisplay(supportedConfig, clientScope); - String expectedVct = Optional.ofNullable(clientScope.getAttributes().get(CredentialScopeModel.VCT)) - .orElse(clientScope.getName()); - assertEquals(expectedVct, supportedConfig.getVct()); + if (Format.SD_JWT_VC.equals(expectedFormat)) { + String expectedVct = Optional.ofNullable(clientScope.getAttributes().get(CredentialScopeModel.VCT)) + .orElse(clientScope.getName()); + assertEquals(expectedVct, supportedConfig.getVct()); + assertNull("SD-JWT credentials should not have credential_definition", supportedConfig.getCredentialDefinition()); + } else if (Format.JWT_VC.equals(expectedFormat)) { + assertNull("JWT_VC credentials should not have vct", supportedConfig.getVct()); + assertNotNull(supportedConfig.getCredentialDefinition()); + assertNotNull(supportedConfig.getCredentialDefinition().getType()); + List credentialDefinitionTypes = Optional.ofNullable(clientScope.getAttributes() + .get(CredentialScopeModel.TYPES)) + .map(s -> s.split(",")) + .map(Arrays::asList) + .orElseGet(() -> List.of(clientScope.getName())); + assertEquals(credentialDefinitionTypes.size(), + supportedConfig.getCredentialDefinition().getType().size()); - assertNotNull(supportedConfig.getCredentialDefinition()); - assertNotNull(supportedConfig.getCredentialDefinition().getType()); - List credentialDefinitionTypes = Optional.ofNullable(clientScope.getAttributes() - .get(CredentialScopeModel.TYPES)) - .map(s -> s.split(",")) - .map(Arrays::asList) - .orElseGet(() -> List.of(clientScope.getName())); - assertEquals(credentialDefinitionTypes.size(), - supportedConfig.getCredentialDefinition().getType().size()); - - MatcherAssert.assertThat(supportedConfig.getCredentialDefinition().getContext(), - Matchers.containsInAnyOrder(credentialDefinitionTypes.toArray())); - List credentialDefinitionContexts = Optional.ofNullable(clientScope.getAttributes() - .get(CredentialScopeModel.CONTEXTS)) - .map(s -> s.split(",")) - .map(Arrays::asList) - .orElseGet(() -> List.of(clientScope.getName())); - assertEquals(credentialDefinitionContexts.size(), - supportedConfig.getCredentialDefinition().getContext().size()); - MatcherAssert.assertThat(supportedConfig.getCredentialDefinition().getContext(), - Matchers.containsInAnyOrder(credentialDefinitionTypes.toArray())); + MatcherAssert.assertThat(supportedConfig.getCredentialDefinition().getContext(), + Matchers.containsInAnyOrder(credentialDefinitionTypes.toArray())); + List credentialDefinitionContexts = Optional.ofNullable(clientScope.getAttributes() + .get(CredentialScopeModel.CONTEXTS)) + .map(s -> s.split(",")) + .map(Arrays::asList) + .orElseGet(() -> List.of(clientScope.getName())); + assertEquals(credentialDefinitionContexts.size(), + supportedConfig.getCredentialDefinition().getContext().size()); + MatcherAssert.assertThat(supportedConfig.getCredentialDefinition().getContext(), + Matchers.containsInAnyOrder(credentialDefinitionTypes.toArray())); + } List signingAlgsSupported = new ArrayList<>(supportedConfig.getCredentialSigningAlgValuesSupported()); ProofTypesSupported proofTypesSupported = supportedConfig.getProofTypesSupported(); @@ -874,6 +875,7 @@ public class OID4VCIssuerWellKnownProviderTest extends OID4VCIssuerEndpointTest } } + private void testBatchSizeValidation(KeycloakTestingClient testingClient, String batchSize, boolean shouldBePresent, Integer expectedValue) { testingClient .server(TEST_REALM_NAME) diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCJWTIssuerEndpointTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCJWTIssuerEndpointTest.java index 8686f7904bc..76b6db36b61 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCJWTIssuerEndpointTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCJWTIssuerEndpointTest.java @@ -771,9 +771,9 @@ public class OID4VCJWTIssuerEndpointTest extends OID4VCIssuerEndpointTest { assertNull("credentialSubject.scope-name has no display", claim.getDisplay()); } - assertEquals("The jwt_vc-credential should offer vct", - verifiableCredentialType, - jwtVcConfig.getVct()); + assertNotNull("The jwt_vc-credential should offer credential_definition", + jwtVcConfig.getCredentialDefinition()); + assertNull("JWT_VC credentials should not have vct", jwtVcConfig.getVct()); // We are offering key binding only for identity credential assertTrue("The jwt_vc-credential should contain a cryptographic binding method supported named jwk",