Skip to content

Commit 2ee4199

Browse files
committed
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. Update existing tests to verify that SD-JWT credentials expose `vct` without `credential_definition`, and JWT_VC credentials expose `credential_definition` without `vct`. Closes keycloak#45485 Signed-off-by: NAMAN JAIN <naman.049259@tmu.ac.in>
1 parent cb4c533 commit 2ee4199

File tree

6 files changed

+105
-39
lines changed

6 files changed

+105
-39
lines changed

services/src/main/java/org/keycloak/protocol/oid4vc/issuance/OID4VCIssuerWellKnownProvider.java

Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@
4747
import org.keycloak.models.oid4vci.CredentialScopeModel;
4848
import org.keycloak.protocol.oid4vc.OID4VCLoginProtocolFactory;
4949
import org.keycloak.protocol.oid4vc.issuance.credentialbuilder.CredentialBuilder;
50+
import org.keycloak.protocol.oid4vc.issuance.credentialbuilder.CredentialBuilderFactory;
5051
import org.keycloak.protocol.oid4vc.model.CredentialIssuer;
5152
import org.keycloak.protocol.oid4vc.model.CredentialRequestEncryptionMetadata;
5253
import org.keycloak.protocol.oid4vc.model.CredentialResponseEncryptionMetadata;
@@ -459,24 +460,54 @@ public static Map<String, SupportedCredentialConfiguration> getSupportedCredenti
459460
keycloakSession.clientScopes()
460461
.getClientScopesByProtocol(realm, OID4VCIConstants.OID4VC_PROTOCOL)
461462
.map(CredentialScopeModel::new)
462-
.map(clientScope -> {
463-
return SupportedCredentialConfiguration.parse(keycloakSession,
464-
clientScope,
463+
.map(credentialScope -> {
464+
SupportedCredentialConfiguration config = SupportedCredentialConfiguration.parse(keycloakSession,
465+
credentialScope,
465466
globalSupportedSigningAlgorithms
466467
);
468+
applyFormatSpecificMetadata(keycloakSession, config, credentialScope);
469+
return config;
467470
})
468471
.collect(Collectors.toMap(SupportedCredentialConfiguration::getId, sc -> sc, (sc1, sc2) -> sc1));
469472

470473
return supportedCredentialConfigurations;
471474
}
472475

476+
477+
private static void applyFormatSpecificMetadata(KeycloakSession keycloakSession,
478+
SupportedCredentialConfiguration config,
479+
CredentialScopeModel credentialScope) {
480+
String format = config.getFormat();
481+
if (format == null) {
482+
return;
483+
}
484+
485+
// Find the CredentialBuilder for this format using the factory pattern
486+
CredentialBuilder credentialBuilder = keycloakSession.getKeycloakSessionFactory()
487+
.getProviderFactoriesStream(CredentialBuilder.class)
488+
.map(factory -> (CredentialBuilderFactory) factory)
489+
.filter(factory -> format.equals(factory.getSupportedFormat()))
490+
.findFirst()
491+
.map(factory -> factory.create(keycloakSession, null))
492+
.orElse(null);
493+
494+
if (credentialBuilder == null) {
495+
LOGGER.debugf("No CredentialBuilder found for format: %s", format);
496+
return;
497+
}
498+
499+
credentialBuilder.contributeToMetadata(config, credentialScope);
500+
}
501+
473502
public static SupportedCredentialConfiguration toSupportedCredentialConfiguration(KeycloakSession keycloakSession,
474503
CredentialScopeModel credentialModel) {
475504
List<String> globalSupportedSigningAlgorithms = getSupportedAsymmetricSignatureAlgorithms(keycloakSession);
476505

477-
return SupportedCredentialConfiguration.parse(keycloakSession,
506+
SupportedCredentialConfiguration config = SupportedCredentialConfiguration.parse(keycloakSession,
478507
credentialModel,
479508
globalSupportedSigningAlgorithms);
509+
applyFormatSpecificMetadata(keycloakSession, config, credentialModel);
510+
return config;
480511
}
481512

482513
/**

services/src/main/java/org/keycloak/protocol/oid4vc/issuance/credentialbuilder/CredentialBuilder.java

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,9 @@
1717

1818
package org.keycloak.protocol.oid4vc.issuance.credentialbuilder;
1919

20+
import org.keycloak.models.oid4vci.CredentialScopeModel;
2021
import org.keycloak.protocol.oid4vc.model.CredentialBuildConfig;
22+
import org.keycloak.protocol.oid4vc.model.SupportedCredentialConfiguration;
2123
import org.keycloak.protocol.oid4vc.model.VerifiableCredential;
2224
import org.keycloak.provider.Provider;
2325

@@ -48,4 +50,24 @@ CredentialBody buildCredentialBody(
4850
VerifiableCredential verifiableCredential,
4951
CredentialBuildConfig credentialBuildConfig
5052
) throws CredentialBuilderException;
53+
54+
/**
55+
* Allows the credential builder to contribute format-specific metadata
56+
* to the OID4VCI well-known credential issuer metadata.
57+
*
58+
* <p>
59+
* Implementations should add only the metadata fields required by the
60+
* supported credential format (for example {@code vct} for {@code dc+sd-jwt}
61+
* or {@code credential_definition} for {@code jwt_vc_json}).
62+
* </p>
63+
*
64+
* <p>
65+
* The default implementation is a no-op to preserve backward compatibility.
66+
* </p>
67+
*
68+
* @param credentialConfig the credential configuration to populate with format-specific metadata
69+
* @param credentialScope the credential scope model containing the source data
70+
*/
71+
default void contributeToMetadata(SupportedCredentialConfiguration credentialConfig, CredentialScopeModel credentialScope) {
72+
}
5173
}

services/src/main/java/org/keycloak/protocol/oid4vc/issuance/credentialbuilder/JwtCredentialBuilder.java

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,11 +24,14 @@
2424

2525
import org.keycloak.jose.jws.JWSBuilder;
2626
import org.keycloak.models.KeycloakSession;
27+
import org.keycloak.models.oid4vci.CredentialScopeModel;
2728
import org.keycloak.protocol.oid4vc.issuance.TimeClaimNormalizer;
2829
import org.keycloak.protocol.oid4vc.issuance.TimeProvider;
2930
import org.keycloak.protocol.oid4vc.model.CredentialBuildConfig;
31+
import org.keycloak.protocol.oid4vc.model.CredentialDefinition;
3032
import org.keycloak.protocol.oid4vc.model.CredentialSubject;
3133
import org.keycloak.protocol.oid4vc.model.Format;
34+
import org.keycloak.protocol.oid4vc.model.SupportedCredentialConfiguration;
3235
import org.keycloak.protocol.oid4vc.model.VerifiableCredential;
3336
import org.keycloak.representations.JsonWebToken;
3437

@@ -104,4 +107,10 @@ public JwtCredentialBody buildCredentialBody(
104107

105108
return new JwtCredentialBody(jwsBuilder);
106109
}
110+
111+
@Override
112+
public void contributeToMetadata(SupportedCredentialConfiguration credentialConfig, CredentialScopeModel credentialScope) {
113+
CredentialDefinition credentialDefinition = CredentialDefinition.parse(credentialScope);
114+
credentialConfig.setCredentialDefinition(credentialDefinition);
115+
}
107116
}

services/src/main/java/org/keycloak/protocol/oid4vc/issuance/credentialbuilder/SdJwtCredentialBuilder.java

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,11 @@
2626
import java.util.UUID;
2727
import java.util.stream.IntStream;
2828

29+
import org.keycloak.models.oid4vci.CredentialScopeModel;
2930
import org.keycloak.protocol.oid4vc.model.CredentialBuildConfig;
3031
import org.keycloak.protocol.oid4vc.model.CredentialSubject;
3132
import org.keycloak.protocol.oid4vc.model.Format;
33+
import org.keycloak.protocol.oid4vc.model.SupportedCredentialConfiguration;
3234
import org.keycloak.protocol.oid4vc.model.VerifiableCredential;
3335
import org.keycloak.sdjwt.DisclosureSpec;
3436
import org.keycloak.sdjwt.IssuerSignedJWT;
@@ -134,4 +136,10 @@ public SdJwtCredentialBody buildCredentialBody(
134136

135137
return new SdJwtCredentialBody(sdJwtBuilder, issuerSignedJWT);
136138
}
139+
140+
@Override
141+
public void contributeToMetadata(SupportedCredentialConfiguration credentialConfig, CredentialScopeModel credentialScope) {
142+
String vct = Optional.ofNullable(credentialScope.getVct()).orElse(credentialScope.getName());
143+
credentialConfig.setVct(vct);
144+
}
137145
}

services/src/main/java/org/keycloak/protocol/oid4vc/model/SupportedCredentialConfiguration.java

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -109,12 +109,6 @@ public static SupportedCredentialConfiguration parse(KeycloakSession keycloakSes
109109
String format = Optional.ofNullable(credentialScope.getFormat()).orElse(Format.SD_JWT_VC);
110110
credentialConfiguration.setFormat(format);
111111

112-
String vct = Optional.ofNullable(credentialScope.getVct()).orElse(credentialScope.getName());
113-
credentialConfiguration.setVct(vct);
114-
115-
CredentialDefinition credentialDefinition = CredentialDefinition.parse(credentialScope);
116-
credentialConfiguration.setCredentialDefinition(credentialDefinition);
117-
118112
KeyAttestationsRequired keyAttestationsRequired = KeyAttestationsRequired.parse(credentialScope);
119113
ProofTypesSupported proofTypesSupported = ProofTypesSupported.parse(keycloakSession,
120114
keyAttestationsRequired,

testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCIssuerWellKnownProviderTest.java

Lines changed: 31 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -433,10 +433,8 @@ public void testMinimalJwtCredentialHardcodedTest() {
433433
assertNotNull(supportedConfig);
434434
assertEquals(Format.SD_JWT_VC, supportedConfig.getFormat());
435435
assertEquals(clientScope.getName(), supportedConfig.getScope());
436-
assertEquals(1, supportedConfig.getCredentialDefinition().getType().size());
437-
assertEquals(clientScope.getName(), supportedConfig.getCredentialDefinition().getType().get(0));
438-
assertEquals(1, supportedConfig.getCredentialDefinition().getContext().size());
439-
assertEquals(clientScope.getName(), supportedConfig.getCredentialDefinition().getContext().get(0));
436+
assertEquals(clientScope.getName(), supportedConfig.getVct());
437+
assertNull("SD-JWT credentials should not have credential_definition", supportedConfig.getCredentialDefinition());
440438
assertNotNull(supportedConfig.getCredentialMetadata());
441439
assertEquals(clientScope.getName(), supportedConfig.getScope());
442440

@@ -550,31 +548,35 @@ private void compareMetadataToClientScope(CredentialIssuer credentialIssuer, Cli
550548

551549
compareDisplay(supportedConfig, clientScope);
552550

553-
String expectedVct = Optional.ofNullable(clientScope.getAttributes().get(CredentialScopeModel.VCT))
554-
.orElse(clientScope.getName());
555-
assertEquals(expectedVct, supportedConfig.getVct());
556-
557-
assertNotNull(supportedConfig.getCredentialDefinition());
558-
assertNotNull(supportedConfig.getCredentialDefinition().getType());
559-
List<String> credentialDefinitionTypes = Optional.ofNullable(clientScope.getAttributes()
560-
.get(CredentialScopeModel.TYPES))
561-
.map(s -> s.split(","))
562-
.map(Arrays::asList)
563-
.orElseGet(() -> List.of(clientScope.getName()));
564-
assertEquals(credentialDefinitionTypes.size(),
565-
supportedConfig.getCredentialDefinition().getType().size());
566-
567-
MatcherAssert.assertThat(supportedConfig.getCredentialDefinition().getContext(),
568-
Matchers.containsInAnyOrder(credentialDefinitionTypes.toArray()));
569-
List<String> credentialDefinitionContexts = Optional.ofNullable(clientScope.getAttributes()
570-
.get(CredentialScopeModel.CONTEXTS))
571-
.map(s -> s.split(","))
572-
.map(Arrays::asList)
573-
.orElseGet(() -> List.of(clientScope.getName()));
574-
assertEquals(credentialDefinitionContexts.size(),
575-
supportedConfig.getCredentialDefinition().getContext().size());
576-
MatcherAssert.assertThat(supportedConfig.getCredentialDefinition().getContext(),
577-
Matchers.containsInAnyOrder(credentialDefinitionTypes.toArray()));
551+
if (Format.SD_JWT_VC.equals(expectedFormat)) {
552+
String expectedVct = Optional.ofNullable(clientScope.getAttributes().get(CredentialScopeModel.VCT))
553+
.orElse(clientScope.getName());
554+
assertEquals(expectedVct, supportedConfig.getVct());
555+
assertNull("SD-JWT credentials should not have credential_definition", supportedConfig.getCredentialDefinition());
556+
} else if (Format.JWT_VC.equals(expectedFormat)) {
557+
assertNull("JWT_VC credentials should not have vct", supportedConfig.getVct());
558+
assertNotNull(supportedConfig.getCredentialDefinition());
559+
assertNotNull(supportedConfig.getCredentialDefinition().getType());
560+
List<String> credentialDefinitionTypes = Optional.ofNullable(clientScope.getAttributes()
561+
.get(CredentialScopeModel.TYPES))
562+
.map(s -> s.split(","))
563+
.map(Arrays::asList)
564+
.orElseGet(() -> List.of(clientScope.getName()));
565+
assertEquals(credentialDefinitionTypes.size(),
566+
supportedConfig.getCredentialDefinition().getType().size());
567+
568+
MatcherAssert.assertThat(supportedConfig.getCredentialDefinition().getContext(),
569+
Matchers.containsInAnyOrder(credentialDefinitionTypes.toArray()));
570+
List<String> credentialDefinitionContexts = Optional.ofNullable(clientScope.getAttributes()
571+
.get(CredentialScopeModel.CONTEXTS))
572+
.map(s -> s.split(","))
573+
.map(Arrays::asList)
574+
.orElseGet(() -> List.of(clientScope.getName()));
575+
assertEquals(credentialDefinitionContexts.size(),
576+
supportedConfig.getCredentialDefinition().getContext().size());
577+
MatcherAssert.assertThat(supportedConfig.getCredentialDefinition().getContext(),
578+
Matchers.containsInAnyOrder(credentialDefinitionTypes.toArray()));
579+
}
578580

579581
List<String> signingAlgsSupported = new ArrayList<>(supportedConfig.getCredentialSigningAlgValuesSupported());
580582
ProofTypesSupported proofTypesSupported = supportedConfig.getProofTypesSupported();

0 commit comments

Comments
 (0)