Add format-specific credential metadata contribution for OID4VC

Introduce a CredentialBuilder hook that allows credential formats to
contribute format-specific metadata to the OID4VC issuer well-known
configuration. The issuer delegates metadata shaping to the
corresponding CredentialBuilder implementation.

Refactor metadata contribution to work directly with
SupportedCredentialConfiguration and CredentialScopeModel, improving
type-safety and avoiding unnecessary serialization.

Add integration tests to verify that SD-JWT credentials expose `vct`
without `credential_definition`, and JWT_VC credentials expose
`credential_definition` without `vct`.

Closes #45485

Signed-off-by: NAMAN JAIN <naman.049259@tmu.ac.in>
This commit is contained in:
NAMAN JAIN
2026-01-30 22:19:12 +05:30
committed by Marek Posolda
parent c08ed20f78
commit c652adff78
8 changed files with 109 additions and 43 deletions

View File

@@ -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<String> globalSupportedSigningAlgorithms = getSupportedAsymmetricSignatureAlgorithms(keycloakSession);
return SupportedCredentialConfiguration.parse(keycloakSession,
SupportedCredentialConfiguration config = SupportedCredentialConfiguration.parse(keycloakSession,
credentialModel,
globalSupportedSigningAlgorithms);
applyFormatSpecificMetadata(keycloakSession, config, credentialModel);
return config;
}
/**

View File

@@ -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.
*
* <p>
* 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}).
* </p>
*
* <p>
* The default implementation is a no-op to preserve backward compatibility.
* </p>
*
* @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) {
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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())

View File

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

View File

@@ -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<String> 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<String> 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<String> 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<String> 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<String> 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)

View File

@@ -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",