mirror of
https://github.com/keycloak/keycloak.git
synced 2026-01-30 19:39:14 -06:00
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:
committed by
Marek Posolda
parent
c08ed20f78
commit
c652adff78
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user