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 532374609c9..a2fc50ec080 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 @@ -18,6 +18,8 @@ package org.keycloak.protocol.oid4vc.issuance.credentialbuilder; import java.time.Instant; +import java.util.ArrayList; +import java.util.List; import java.util.Objects; import java.util.Optional; import java.util.function.UnaryOperator; @@ -71,6 +73,8 @@ public class JwtCredentialBuilder implements CredentialBuilder { VerifiableCredential verifiableCredential, CredentialBuildConfig credentialBuildConfig ) throws CredentialBuilderException { + verifiableCredential.setType(getCredentialTypes(verifiableCredential.getType())); + // Populate the issuer field of the VC verifiableCredential.setIssuer(credentialBuildConfig.getCredentialIssuer()); @@ -108,6 +112,14 @@ public class JwtCredentialBuilder implements CredentialBuilder { return new JwtCredentialBody(jwsBuilder); } + private static List getCredentialTypes(List credentialTypes) { + List types = new ArrayList<>(Optional.ofNullable(credentialTypes).orElseGet(List::of)); + if (!types.contains(CredentialDefinition.VERIFIABLE_CREDENTIAL_TYPE)) { + types.add(0, CredentialDefinition.VERIFIABLE_CREDENTIAL_TYPE); + } + return types; + } + @Override public void contributeToMetadata(SupportedCredentialConfiguration credentialConfig, CredentialScopeModel credentialScope) { CredentialDefinition credentialDefinition = CredentialDefinition.parse(credentialScope); diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/model/CredentialDefinition.java b/services/src/main/java/org/keycloak/protocol/oid4vc/model/CredentialDefinition.java index f74db49839d..045f3b395e7 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/model/CredentialDefinition.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/model/CredentialDefinition.java @@ -37,14 +37,23 @@ public class CredentialDefinition { private List context; private List type = new ArrayList<>(); + public static final String VERIFIABLE_CREDENTIAL_TYPE = "VerifiableCredential"; + public static CredentialDefinition parse(CredentialScopeModel credentialModel) { List contexts = Optional.of(credentialModel.getVcContexts()) .filter(list -> !list.isEmpty()) .orElseGet(() -> new ArrayList<>(List.of(credentialModel.getName()))); List types = Optional.ofNullable(credentialModel.getSupportedCredentialTypes()) .filter(list -> !list.isEmpty()) + .map(ArrayList::new) .orElseGet(() -> new ArrayList<>(List.of(credentialModel.getName()))); + // Ensure VerifiableCredential is always included as a base type + // per the W3C Verifiable Credentials Data Model specification. + if (!types.contains(VERIFIABLE_CREDENTIAL_TYPE)) { + types.add(0, VERIFIABLE_CREDENTIAL_TYPE); + } + return new CredentialDefinition().setContext(contexts) .setType(types); } diff --git a/tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCINaturalPersonTest.java b/tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCINaturalPersonTest.java index c4cf8205b0c..e5ee9ad8937 100644 --- a/tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCINaturalPersonTest.java +++ b/tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCINaturalPersonTest.java @@ -1,6 +1,7 @@ package org.keycloak.tests.oid4vc; import java.net.URI; +import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.stream.Collectors; @@ -8,6 +9,7 @@ import java.util.stream.Collectors; import org.keycloak.TokenVerifier; import org.keycloak.jose.jwk.JWK; import org.keycloak.jose.jwk.JWKBuilder; +import org.keycloak.protocol.oid4vc.model.CredentialDefinition; import org.keycloak.protocol.oid4vc.model.CredentialResponse; import org.keycloak.protocol.oid4vc.model.CredentialScopeRepresentation; import org.keycloak.protocol.oid4vc.model.Proofs; @@ -141,7 +143,10 @@ public class OID4VCINaturalPersonTest extends OID4VCIssuerTestBase { assertEquals(issuer, vcJwt.getIssuer()); Object vc = vcJwt.getOtherClaims().get("vc"); VerifiableCredential credential = JsonSerialization.mapper.convertValue(vc, VerifiableCredential.class); - assertEquals(credScope.getSupportedCredentialTypes(), credential.getType()); + List expectedCredentialTypes = new ArrayList<>(); + expectedCredentialTypes.add(CredentialDefinition.VERIFIABLE_CREDENTIAL_TYPE); + expectedCredentialTypes.addAll(credScope.getSupportedCredentialTypes()); + assertEquals(expectedCredentialTypes, credential.getType()); assertEquals(URI.create(issuer), credential.getIssuer()); assertEquals(expUser + "@email.cz", credential.getCredentialSubject().getClaims().get("email")); } diff --git a/tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCIssuerWellKnownProviderTest.java b/tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCIssuerWellKnownProviderTest.java index 365e73485be..bf605ca95e9 100644 --- a/tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCIssuerWellKnownProviderTest.java +++ b/tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCIssuerWellKnownProviderTest.java @@ -51,6 +51,7 @@ import org.keycloak.protocol.oid4vc.issuance.mappers.OID4VCMapper; import org.keycloak.protocol.oid4vc.model.Claim; import org.keycloak.protocol.oid4vc.model.ClaimDisplay; import org.keycloak.protocol.oid4vc.model.Claims; +import org.keycloak.protocol.oid4vc.model.CredentialDefinition; import org.keycloak.protocol.oid4vc.model.CredentialIssuer; import org.keycloak.protocol.oid4vc.model.CredentialRequestEncryptionMetadata; import org.keycloak.protocol.oid4vc.model.CredentialResponseEncryptionMetadata; @@ -667,9 +668,13 @@ public class OID4VCIssuerWellKnownProviderTest extends OID4VCIssuerTestBase { assertNull(supportedConfig.getVct(), "JWT_VC credentials should not have vct"); assertNotNull(supportedConfig.getCredentialDefinition()); assertNotNull(supportedConfig.getCredentialDefinition().getType()); + // VerifiableCredential must always be present as a base type per W3C VC Data Model + MatcherAssert.assertThat(supportedConfig.getCredentialDefinition().getType(), + Matchers.hasItem(CredentialDefinition.VERIFIABLE_CREDENTIAL_TYPE)); List credentialDefinitionTypes = credScope.getSupportedCredentialTypes(); if (!credentialDefinitionTypes.isEmpty()) { - assertEquals(credentialDefinitionTypes.size(), supportedConfig.getCredentialDefinition().getType().size()); + MatcherAssert.assertThat(supportedConfig.getCredentialDefinition().getType(), + Matchers.hasItems(credentialDefinitionTypes.toArray(new String[0]))); } // @context must not be present for jwt_vc_json format per OID4VCI spec diff --git a/tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCPublicClientTest.java b/tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCPublicClientTest.java index 9eaa6d32d73..2db539411a4 100644 --- a/tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCPublicClientTest.java +++ b/tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCPublicClientTest.java @@ -21,6 +21,7 @@ import java.net.URI; import java.util.List; import org.keycloak.TokenVerifier; +import org.keycloak.protocol.oid4vc.model.CredentialDefinition; import org.keycloak.protocol.oid4vc.model.CredentialIssuer; import org.keycloak.protocol.oid4vc.model.CredentialResponse; import org.keycloak.protocol.oid4vc.model.OID4VCAuthorizationDetail; @@ -152,7 +153,7 @@ public class OID4VCPublicClientTest extends OID4VCIssuerTestBase { assertEquals("did:web:test.org", jsonWebToken.getIssuer()); Object vc = jsonWebToken.getOtherClaims().get("vc"); VerifiableCredential credential = JsonSerialization.mapper.convertValue(vc, VerifiableCredential.class); - assertEquals(List.of(scope), credential.getType()); + assertEquals(List.of(CredentialDefinition.VERIFIABLE_CREDENTIAL_TYPE, scope), credential.getType()); assertEquals(URI.create("did:web:test.org"), credential.getIssuer()); assertEquals(expUser + "@email.cz", credential.getCredentialSubject().getClaims().get("email")); } diff --git a/tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCredentialByScopeTest.java b/tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCredentialByScopeTest.java index 24254d9aa40..ae8244fec3e 100644 --- a/tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCredentialByScopeTest.java +++ b/tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCredentialByScopeTest.java @@ -8,6 +8,7 @@ import org.keycloak.TokenVerifier; import org.keycloak.admin.client.resource.ClientPoliciesPoliciesResource; import org.keycloak.admin.client.resource.RealmResource; import org.keycloak.protocol.oid4vc.clientpolicy.PredicateCredentialClientPolicy; +import org.keycloak.protocol.oid4vc.model.CredentialDefinition; import org.keycloak.protocol.oid4vc.model.CredentialIssuer; import org.keycloak.protocol.oid4vc.model.CredentialResponse; import org.keycloak.protocol.oid4vc.model.CredentialsOffer; @@ -213,7 +214,7 @@ public class OID4VCredentialByScopeTest extends OID4VCIssuerTestBase { assertEquals("did:web:test.org", jsonWebToken.getIssuer()); Object vc = jsonWebToken.getOtherClaims().get("vc"); VerifiableCredential credential = JsonSerialization.mapper.convertValue(vc, VerifiableCredential.class); - assertEquals(List.of(scope), credential.getType()); + assertEquals(List.of(CredentialDefinition.VERIFIABLE_CREDENTIAL_TYPE, scope), credential.getType()); assertEquals(URI.create("did:web:test.org"), credential.getIssuer()); assertEquals(expUser + "@email.cz", credential.getCredentialSubject().getClaims().get("email")); } diff --git a/tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCredentialOfferAuthCodeTest.java b/tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCredentialOfferAuthCodeTest.java index bf66d30be8b..e58a4137ba5 100644 --- a/tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCredentialOfferAuthCodeTest.java +++ b/tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCredentialOfferAuthCodeTest.java @@ -4,6 +4,7 @@ import java.net.URI; import java.util.List; import org.keycloak.TokenVerifier; +import org.keycloak.protocol.oid4vc.model.CredentialDefinition; import org.keycloak.protocol.oid4vc.model.CredentialOfferURI; import org.keycloak.protocol.oid4vc.model.CredentialResponse; import org.keycloak.protocol.oid4vc.model.CredentialsOffer; @@ -317,7 +318,7 @@ public class OID4VCredentialOfferAuthCodeTest extends OID4VCIssuerTestBase { assertEquals("did:web:test.org", jsonWebToken.getIssuer()); Object vc = jsonWebToken.getOtherClaims().get("vc"); VerifiableCredential credential = JsonSerialization.mapper.convertValue(vc, VerifiableCredential.class); - assertEquals(List.of(scope), credential.getType()); + assertEquals(List.of(CredentialDefinition.VERIFIABLE_CREDENTIAL_TYPE, scope), credential.getType()); assertEquals(URI.create("did:web:test.org"), credential.getIssuer()); assertEquals(expUser + "@email.cz", credential.getCredentialSubject().getClaims().get("email")); } diff --git a/tests/base/src/test/java/org/keycloak/tests/oid4vc/issuance/credentialbuilder/JwtCredentialBuilderTest.java b/tests/base/src/test/java/org/keycloak/tests/oid4vc/issuance/credentialbuilder/JwtCredentialBuilderTest.java index 31b3ca61ca5..63b21b2d252 100644 --- a/tests/base/src/test/java/org/keycloak/tests/oid4vc/issuance/credentialbuilder/JwtCredentialBuilderTest.java +++ b/tests/base/src/test/java/org/keycloak/tests/oid4vc/issuance/credentialbuilder/JwtCredentialBuilderTest.java @@ -30,6 +30,7 @@ import org.keycloak.protocol.oid4vc.issuance.TimeProvider; import org.keycloak.protocol.oid4vc.issuance.credentialbuilder.JwtCredentialBody; import org.keycloak.protocol.oid4vc.issuance.credentialbuilder.JwtCredentialBuilder; import org.keycloak.protocol.oid4vc.model.CredentialBuildConfig; +import org.keycloak.protocol.oid4vc.model.CredentialDefinition; import org.keycloak.protocol.oid4vc.model.VerifiableCredential; import org.keycloak.testframework.annotations.KeycloakIntegrationTest; import org.keycloak.tests.oid4vc.OID4VCIssuerTestBase; @@ -93,11 +94,33 @@ public class JwtCredentialBuilderTest extends CredentialBuilderTest { assertEquals(expectedNormalizedNbf, payload.get("nbf").asLong()); } + @Test + public void buildJwtCredential_AddsVerifiableCredentialType() throws Exception { + VerifiableCredential verifiableCredential = getTestCredential(exampleCredentialClaims()); + verifiableCredential.setType(List.of("oid4vc_natural_person")); + CredentialBuildConfig credentialBuildConfig = new CredentialBuildConfig().setTokenJwsType("JWT"); + + JwtCredentialBody jwtCredentialBody = builder + .buildCredentialBody(verifiableCredential, credentialBuildConfig); + + String jws = jwtCredentialBody.sign(exampleSigner()); + JWSInput jwsInput = new JWSInput(jws); + JsonNode credentialTypes = parseCredentialTypes(jwsInput); + + assertEquals(CredentialDefinition.VERIFIABLE_CREDENTIAL_TYPE, credentialTypes.get(0).asText()); + assertEquals("oid4vc_natural_person", credentialTypes.get(1).asText()); + } + private JsonNode parseCredentialSubject(JWSInput jwsInput) throws JWSInputException { JsonNode payload = jwsInput.readJsonContent(JsonNode.class); return payload.get("vc").get(CREDENTIAL_SUBJECT); } + private JsonNode parseCredentialTypes(JWSInput jwsInput) throws JWSInputException { + JsonNode payload = jwsInput.readJsonContent(JsonNode.class); + return payload.get("vc").get("type"); + } + private Map exampleCredentialClaims() { return new HashMap<>(Map.of( "id", String.format("uri:uuid:%s", UUID.randomUUID()), diff --git a/tests/base/src/test/java/org/keycloak/tests/oid4vc/preauth/OID4VCPublicClientPreAuthTest.java b/tests/base/src/test/java/org/keycloak/tests/oid4vc/preauth/OID4VCPublicClientPreAuthTest.java index 5b5d685e165..d9ad4c5f3c8 100644 --- a/tests/base/src/test/java/org/keycloak/tests/oid4vc/preauth/OID4VCPublicClientPreAuthTest.java +++ b/tests/base/src/test/java/org/keycloak/tests/oid4vc/preauth/OID4VCPublicClientPreAuthTest.java @@ -21,6 +21,7 @@ import java.net.URI; import java.util.List; import org.keycloak.TokenVerifier; +import org.keycloak.protocol.oid4vc.model.CredentialDefinition; import org.keycloak.protocol.oid4vc.model.CredentialResponse; import org.keycloak.protocol.oid4vc.model.CredentialsOffer; import org.keycloak.protocol.oid4vc.model.VerifiableCredential; @@ -98,7 +99,7 @@ public class OID4VCPublicClientPreAuthTest extends OID4VCIssuerTestBase { assertEquals("did:web:test.org", jsonWebToken.getIssuer()); Object vc = jsonWebToken.getOtherClaims().get("vc"); VerifiableCredential credential = JsonSerialization.mapper.convertValue(vc, VerifiableCredential.class); - assertEquals(List.of(scope), credential.getType()); + assertEquals(List.of(CredentialDefinition.VERIFIABLE_CREDENTIAL_TYPE, scope), credential.getType()); assertEquals(URI.create("did:web:test.org"), credential.getIssuer()); assertEquals(expUser + "@email.cz", credential.getCredentialSubject().getClaims().get("email")); } diff --git a/tests/base/src/test/java/org/keycloak/tests/oid4vc/preauth/OID4VCredentialOfferPreAuthTest.java b/tests/base/src/test/java/org/keycloak/tests/oid4vc/preauth/OID4VCredentialOfferPreAuthTest.java index 51ea165fdf4..72341325a3b 100644 --- a/tests/base/src/test/java/org/keycloak/tests/oid4vc/preauth/OID4VCredentialOfferPreAuthTest.java +++ b/tests/base/src/test/java/org/keycloak/tests/oid4vc/preauth/OID4VCredentialOfferPreAuthTest.java @@ -5,6 +5,7 @@ import java.util.List; import org.keycloak.TokenVerifier; import org.keycloak.admin.client.resource.UserResource; +import org.keycloak.protocol.oid4vc.model.CredentialDefinition; import org.keycloak.protocol.oid4vc.model.CredentialOfferURI; import org.keycloak.protocol.oid4vc.model.CredentialResponse; import org.keycloak.protocol.oid4vc.model.CredentialsOffer; @@ -161,7 +162,7 @@ public class OID4VCredentialOfferPreAuthTest extends OID4VCIssuerTestBase { assertEquals("did:web:test.org", jsonWebToken.getIssuer()); Object vc = jsonWebToken.getOtherClaims().get("vc"); VerifiableCredential credential = JsonSerialization.mapper.convertValue(vc, VerifiableCredential.class); - assertEquals(List.of(scope), credential.getType()); + assertEquals(List.of(CredentialDefinition.VERIFIABLE_CREDENTIAL_TYPE, scope), credential.getType()); assertEquals(URI.create("did:web:test.org"), credential.getIssuer()); assertEquals(expUser + "@email.cz", credential.getCredentialSubject().getClaims().get("email")); }