From d6653d20dd2b37858cd96155bfe924ed4092c3a9 Mon Sep 17 00:00:00 2001 From: Dominik Schlosser Date: Tue, 28 Apr 2026 16:46:53 +0200 Subject: [PATCH] [OpenID4VCI] Add support for mDoc (#48095) Signed-off-by: Dominik Schlosser --- .../java/org/keycloak/common/Profile.java | 1 + core/pom.xml | 4 + .../java/org/keycloak/OID4VCConstants.java | 2 + core/src/main/java/org/keycloak/VCFormat.java | 7 + .../main/java/org/keycloak/mdoc/CborUtil.java | 224 ++++++++++ .../java/org/keycloak/mdoc/MdocAlgorithm.java | 77 ++++ .../main/java/org/keycloak/mdoc/MdocCose.java | 167 ++++++++ .../org/keycloak/mdoc/MdocCredential.java | 395 ++++++++++++++++++ .../java/org/keycloak/mdoc/MdocDeviceKey.java | 128 ++++++ .../java/org/keycloak/mdoc/MdocException.java | 28 ++ .../mdoc/MdocIssuerSignedDocument.java | 159 +++++++ .../keycloak/mdoc/MdocNamespacedClaims.java | 59 +++ .../org/keycloak/mdoc/MdocValidityInfo.java | 74 ++++ .../java/org/keycloak/mdoc/CborUtilTest.java | 83 ++++ .../MdocCredentialCreationAndSigningTest.java | 177 ++++++++ .../org/keycloak/mdoc/MdocCredentialTest.java | 103 +++++ .../mdoc/MdocIssuerSignedDocumentTest.java | 109 +++++ .../org/keycloak/mdoc/MdocKeyBindingTest.java | 178 ++++++++ ...oMdocCredentialCreationAndSigningTest.java | 31 ++ .../mdoc/DefaultCryptoMdocKeyBindingTest.java | 31 ++ ...oMdocCredentialCreationAndSigningTest.java | 22 + .../mdoc/ElytronCryptoMdocKeyBindingTest.java | 22 + ...2MdocCredentialCreationAndSigningTest.java | 31 ++ .../test/mdoc/FIPS1402MdocKeyBindingTest.java | 31 ++ .../admin/messages/messages_en.properties | 4 +- .../src/client-scopes/details/ScopeForm.tsx | 70 ++-- .../admin-ui/src/utils/useIsFeatureEnabled.ts | 1 + pom.xml | 1 - .../models/oid4vci/CredentialScopeModel.java | 3 +- .../oid4vc/OID4VCLoginProtocolFactory.java | 67 ++- .../OID4VCMdocEnvironmentProviderFactory.java | 33 ++ .../oid4vc/issuance/OID4VCIssuerEndpoint.java | 54 ++- .../OID4VCIssuerWellKnownProvider.java | 4 + .../credentialbuilder/MdocCredentialBody.java | 76 ++++ .../MdocCredentialBuilder.java | 69 +++ .../MdocCredentialBuilderFactory.java | 52 +++ .../keybinding/JwtProofValidator.java | 28 +- .../issuance/mappers/OID4VCContextMapper.java | 8 + .../mappers/OID4VCGeneratedIdMapper.java | 24 +- .../OID4VCIssuedAtTimeClaimMapper.java | 11 +- .../oid4vc/issuance/mappers/OID4VCMapper.java | 171 +++++++- .../mappers/OID4VCStaticClaimMapper.java | 5 +- .../mappers/OID4VCSubjectIdMapper.java | 23 +- .../mappers/OID4VCTargetRoleMapper.java | 17 +- .../issuance/mappers/OID4VCTypeMapper.java | 8 + .../mappers/OID4VCUserAttributeMapper.java | 17 +- .../signing/MdocCredentialSigner.java | 43 ++ .../signing/MdocCredentialSignerFactory.java | 35 ++ .../keycloak/protocol/oid4vc/model/Claim.java | 4 + .../oid4vc/model/CredentialBuildConfig.java | 31 +- .../CredentialSigningAlgorithmResolver.java | 168 ++++++++ .../SupportedCredentialConfiguration.java | 83 +++- ...credentialbuilder.CredentialBuilderFactory | 3 +- ...c.issuance.signing.CredentialSignerFactory | 3 +- .../OID4VCLoginProtocolFactoryTest.java | 84 ++++ ...D4VCAuthorizationDetailsProcessorTest.java | 28 ++ .../CredentialBuilderFactoryTest.java | 40 +- .../MdocCredentialBuilderTest.java | 294 +++++++++++++ .../issuance/mappers/OID4VCMapperTest.java | 181 ++++++++ .../SupportedCredentialConfigurationTest.java | 61 +++ ...redentialSigningAlgorithmResolverTest.java | 92 ++++ .../tests/oid4vc/OID4VCExportImportTest.java | 4 +- .../oid4vc/OID4VCINaturalPersonTest.java | 45 +- .../tests/oid4vc/OID4VCIssuerTestBase.java | 105 ++++- .../OID4VCIssuerWellKnownProviderTest.java | 14 +- ...cFeatureDisabledWellKnownProviderTest.java | 47 +++ .../oid4vc/OID4VCMdocIssuerEndpointTest.java | 225 ++++++++++ ...OID4VCMdocIssuerWellKnownProviderTest.java | 207 +++++++++ .../tests/oid4vc/OID4VCMdocMapperTest.java | 245 +++++++++++ .../tests/oid4vc/OID4VCMdocTestBase.java | 271 ++++++++++++ .../tests/oid4vc/OID4VCProofTestUtils.java | 1 + .../OID4VCAuthorizationCodeFlowTestBase.java | 16 +- .../OID4VCJwtAuthorizationCodeFlowTest.java | 7 +- .../OID4VCMdocAuthorizationCodeFlowTest.java | 56 +++ .../OID4VCSdJwtAuthorizationCodeFlowTest.java | 7 +- ...ID4VCAuthorizationCodeFlowWithPARTest.java | 9 +- ...ID4VCMdocAuthorizationDetailsFlowTest.java | 38 ++ ...thorizationDetailsFlowPreAuthTestBase.java | 60 ++- ...wtAuthorizationDetailsFlowPreAuthTest.java | 6 +- ...ocAuthorizationDetailsFlowPreAuthTest.java | 56 +++ .../OID4VCMdocIssuerEndpointPreAuthTest.java | 113 +++++ ...wtAuthorizationDetailsFlowPreAuthTest.java | 6 +- .../vci/AbstractVciConformanceTest.java | 1 + .../vci/IssuerMdocHappyFlowTest.java | 52 +++ .../vci/VciConformanceRealmConfig.java | 62 ++- .../tests/conformance/vci/VciSuiteConfig.java | 6 +- 86 files changed, 5476 insertions(+), 222 deletions(-) create mode 100644 core/src/main/java/org/keycloak/mdoc/CborUtil.java create mode 100644 core/src/main/java/org/keycloak/mdoc/MdocAlgorithm.java create mode 100644 core/src/main/java/org/keycloak/mdoc/MdocCose.java create mode 100644 core/src/main/java/org/keycloak/mdoc/MdocCredential.java create mode 100644 core/src/main/java/org/keycloak/mdoc/MdocDeviceKey.java create mode 100644 core/src/main/java/org/keycloak/mdoc/MdocException.java create mode 100644 core/src/main/java/org/keycloak/mdoc/MdocIssuerSignedDocument.java create mode 100644 core/src/main/java/org/keycloak/mdoc/MdocNamespacedClaims.java create mode 100644 core/src/main/java/org/keycloak/mdoc/MdocValidityInfo.java create mode 100644 core/src/test/java/org/keycloak/mdoc/CborUtilTest.java create mode 100644 core/src/test/java/org/keycloak/mdoc/MdocCredentialCreationAndSigningTest.java create mode 100644 core/src/test/java/org/keycloak/mdoc/MdocCredentialTest.java create mode 100644 core/src/test/java/org/keycloak/mdoc/MdocIssuerSignedDocumentTest.java create mode 100644 core/src/test/java/org/keycloak/mdoc/MdocKeyBindingTest.java create mode 100644 crypto/default/src/test/java/org/keycloak/crypto/def/test/mdoc/DefaultCryptoMdocCredentialCreationAndSigningTest.java create mode 100644 crypto/default/src/test/java/org/keycloak/crypto/def/test/mdoc/DefaultCryptoMdocKeyBindingTest.java create mode 100644 crypto/elytron/src/test/java/org/keycloak/crypto/elytron/test/mdoc/ElytronCryptoMdocCredentialCreationAndSigningTest.java create mode 100644 crypto/elytron/src/test/java/org/keycloak/crypto/elytron/test/mdoc/ElytronCryptoMdocKeyBindingTest.java create mode 100644 crypto/fips1402/src/test/java/org/keycloak/crypto/fips/test/mdoc/FIPS1402MdocCredentialCreationAndSigningTest.java create mode 100644 crypto/fips1402/src/test/java/org/keycloak/crypto/fips/test/mdoc/FIPS1402MdocKeyBindingTest.java create mode 100644 services/src/main/java/org/keycloak/protocol/oid4vc/OID4VCMdocEnvironmentProviderFactory.java create mode 100644 services/src/main/java/org/keycloak/protocol/oid4vc/issuance/credentialbuilder/MdocCredentialBody.java create mode 100644 services/src/main/java/org/keycloak/protocol/oid4vc/issuance/credentialbuilder/MdocCredentialBuilder.java create mode 100644 services/src/main/java/org/keycloak/protocol/oid4vc/issuance/credentialbuilder/MdocCredentialBuilderFactory.java create mode 100644 services/src/main/java/org/keycloak/protocol/oid4vc/issuance/signing/MdocCredentialSigner.java create mode 100644 services/src/main/java/org/keycloak/protocol/oid4vc/issuance/signing/MdocCredentialSignerFactory.java create mode 100644 services/src/main/java/org/keycloak/protocol/oid4vc/model/CredentialSigningAlgorithmResolver.java create mode 100644 services/src/test/java/org/keycloak/protocol/oid4vc/OID4VCLoginProtocolFactoryTest.java create mode 100644 services/src/test/java/org/keycloak/protocol/oid4vc/issuance/credentialbuilder/MdocCredentialBuilderTest.java create mode 100644 services/src/test/java/org/keycloak/protocol/oid4vc/issuance/mappers/OID4VCMapperTest.java create mode 100644 services/src/test/java/org/keycloak/protocol/oid4vc/model/SupportedCredentialConfigurationTest.java create mode 100644 tests/base/src/test/java/org/keycloak/tests/oid4vc/CredentialSigningAlgorithmResolverTest.java create mode 100644 tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCMdocFeatureDisabledWellKnownProviderTest.java create mode 100644 tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCMdocIssuerEndpointTest.java create mode 100644 tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCMdocIssuerWellKnownProviderTest.java create mode 100644 tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCMdocMapperTest.java create mode 100644 tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCMdocTestBase.java create mode 100644 tests/base/src/test/java/org/keycloak/tests/oid4vc/issuance/OID4VCMdocAuthorizationCodeFlowTest.java create mode 100644 tests/base/src/test/java/org/keycloak/tests/oid4vc/issuance/signing/OID4VCMdocAuthorizationDetailsFlowTest.java create mode 100644 tests/base/src/test/java/org/keycloak/tests/oid4vc/preauth/OID4VCMdocAuthorizationDetailsFlowPreAuthTest.java create mode 100644 tests/base/src/test/java/org/keycloak/tests/oid4vc/preauth/OID4VCMdocIssuerEndpointPreAuthTest.java create mode 100644 tests/conformance/src/test/java/org/keycloak/tests/conformance/vci/IssuerMdocHappyFlowTest.java diff --git a/common/src/main/java/org/keycloak/common/Profile.java b/common/src/main/java/org/keycloak/common/Profile.java index 907b17feaadd..da6dd4f38cc8 100755 --- a/common/src/main/java/org/keycloak/common/Profile.java +++ b/common/src/main/java/org/keycloak/common/Profile.java @@ -145,6 +145,7 @@ public enum Feature { OID4VC_VCI("Support for the OID4VCI protocol as part of OID4VC.", Type.EXPERIMENTAL), OID4VC_VCI_PREAUTH_CODE("Support for credential offers with `pre-authorized_code` grant.", Type.EXPERIMENTAL, OID4VC_VCI), OID4VC_VCI_REST_CREDENTIAL_OFFER("Support for the REST endpoint to create credential offers.", Type.EXPERIMENTAL, OID4VC_VCI), + OID4VC_MDOC("Support for OID4VC `mso_mdoc` credential type.", Type.EXPERIMENTAL), // Dependent on either VCI or VP, does nothing if neither is active OID4VC_VP("Support for the OID4VP protocol as part of OID4VC.", Type.EXPERIMENTAL), OPENTELEMETRY("OpenTelemetry support", Type.DEFAULT), diff --git a/core/pom.xml b/core/pom.xml index 665c5267bc3f..0017c67d6305 100755 --- a/core/pom.xml +++ b/core/pom.xml @@ -59,6 +59,10 @@ com.fasterxml.jackson.datatype jackson-datatype-jsr310 + + com.webauthn4j + webauthn4j-core + org.jboss.logging jboss-logging diff --git a/core/src/main/java/org/keycloak/OID4VCConstants.java b/core/src/main/java/org/keycloak/OID4VCConstants.java index 25e6ca36e22e..166b09029ba0 100644 --- a/core/src/main/java/org/keycloak/OID4VCConstants.java +++ b/core/src/main/java/org/keycloak/OID4VCConstants.java @@ -69,6 +69,8 @@ public class OID4VCConstants { public static final String OPENID_CREDENTIAL = "openid_credential"; public static final String CREDENTIAL_IDENTIFIERS = "credential_identifiers"; public static final String CREDENTIAL_CONFIGURATION_ID = "credential_configuration_id"; + public static final String CRYPTOGRAPHIC_BINDING_METHOD_JWK = "jwk"; + public static final String CRYPTOGRAPHIC_BINDING_METHOD_COSE_KEY = "cose_key"; // OID4VP - https://openid.net/specs/openid-4-verifiable-presentations-1_0.html public static final String VP_TOKEN = "vp_token"; diff --git a/core/src/main/java/org/keycloak/VCFormat.java b/core/src/main/java/org/keycloak/VCFormat.java index cb51061b143d..0584e414734f 100644 --- a/core/src/main/java/org/keycloak/VCFormat.java +++ b/core/src/main/java/org/keycloak/VCFormat.java @@ -39,11 +39,17 @@ public interface VCFormat { */ String SD_JWT_VC = "dc+sd-jwt"; + /** + * ISO/IEC 18013-5 mdoc credentials. + */ + String MSO_MDOC = "mso_mdoc"; + String[] SUPPORTED_FORMATS = new String[]{JWT_VC, SD_JWT_VC}; static String getFromScope(String scope) { String format = SD_JWT_VC; // default format if (scope.toLowerCase().endsWith("_jwt")) format = JWT_VC; + else if (scope.toLowerCase().endsWith("_mdoc")) format = MSO_MDOC; return format; } @@ -51,6 +57,7 @@ static String getScopeSuffix(String value) { String suffix = ""; if (JWT_VC.equals(value)) suffix = "_jwt"; else if (SD_JWT_VC.equals(value)) suffix = "_sd"; + else if (MSO_MDOC.equals(value)) suffix = "_mdoc"; return suffix; } } diff --git a/core/src/main/java/org/keycloak/mdoc/CborUtil.java b/core/src/main/java/org/keycloak/mdoc/CborUtil.java new file mode 100644 index 000000000000..d50819e92eeb --- /dev/null +++ b/core/src/main/java/org/keycloak/mdoc/CborUtil.java @@ -0,0 +1,224 @@ +/* + * Copyright 2026 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.keycloak.mdoc; + +import java.io.IOException; +import java.time.Instant; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; +import java.time.temporal.ChronoUnit; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.databind.JsonSerializable; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializerProvider; +import com.fasterxml.jackson.databind.jsontype.TypeSerializer; +import com.fasterxml.jackson.databind.module.SimpleModule; +import com.fasterxml.jackson.databind.ser.std.StdSerializer; +import com.fasterxml.jackson.dataformat.cbor.CBORFactory; +import com.fasterxml.jackson.dataformat.cbor.CBORGenerator; +import com.webauthn4j.converter.util.CborConverter; +import com.webauthn4j.converter.util.ObjectConverter; + +final class CborUtil { + + static final int TAG_TDATE = 0; + static final int TAG_ENCODED_CBOR = 24; + + private static final ObjectConverter OBJECT_CONVERTER = createObjectConverter(); + private static final CborConverter CBOR_CONVERTER = OBJECT_CONVERTER.getCborConverter(); + private static final DateTimeFormatter TDATE_FORMATTER = DateTimeFormatter.ISO_OFFSET_DATE_TIME; + + // ISO mdoc requires definite length CBOR encoding, while Jackson's default Map serializer emits indefinite + // length maps. Register a serializer that writes the map header with its size for every encoded map. + private static ObjectConverter createObjectConverter() { + SimpleModule definiteLengthMaps = new SimpleModule(); + definiteLengthMaps.addSerializer(new DefiniteLengthMapSerializer()); + ObjectMapper cborMapper = new ObjectMapper(new CBORFactory()); + cborMapper.registerModule(definiteLengthMaps); + return new ObjectConverter(new ObjectMapper(), cborMapper); + } + + private CborUtil() { + } + + static byte[] encode(Object value) { + return CBOR_CONVERTER.writeValueAsBytes(value); + } + + // COSE signatures cover the protected-header byte string exactly. Jackson's default Map serializer emits an + // indefinite-length map, so integer-only COSE headers use a sized CBOR object for deterministic minimal bytes. + static byte[] encodeIntegerMap(Map value) { + return encode(new IntegerMap(value)); + } + + static Object decode(byte[] encoded) { + return CBOR_CONVERTER.readValue(encoded, Object.class); + } + + @SuppressWarnings("unchecked") + static Map asMap(Object object, String name) { + if (object instanceof Map) { + return (Map) object; + } + throw new MdocException("Unexpected map structure for " + name); + } + + @SuppressWarnings("unchecked") + static Map asStringKeyMap(Object object, String name) { + if (object instanceof Map) { + Map map = (Map) object; + for (Object key : map.keySet()) { + if (!(key instanceof String)) { + throw new MdocException("Unexpected non-string map key for " + name); + } + } + return (Map) object; + } + throw new MdocException("Unexpected map structure for " + name); + } + + @SuppressWarnings("unchecked") + static List asList(Object object, String name) { + if (object instanceof List) { + return (List) object; + } + throw new MdocException("Unexpected array structure for " + name); + } + + static String asString(Object object, String name) { + if (object instanceof String) { + return (String) object; + } + throw new MdocException("Unexpected string structure for " + name); + } + + static byte[] asByteArray(Object object, String name) { + if (object instanceof byte[]) { + return (byte[]) object; + } + throw new MdocException("Unexpected byte string structure for " + name); + } + + static Object unwrapEncodedCbor(Object item) { + // CBOR tag 24 means the byte string contains an encoded CBOR data item. ISO mdoc wraps + // IssuerSignedItemBytes and MSO bytes this way, so parser callers need to decode the nested item. + if (item instanceof Tagged) { + Tagged taggedItem = (Tagged) item; + if (taggedItem.tag() == TAG_ENCODED_CBOR && taggedItem.value() instanceof byte[]) { + return decode((byte[]) taggedItem.value()); + } + } + if (item instanceof byte[]) { + return decode((byte[]) item); + } + return item; + } + + static Tagged tdate(Instant instant) { + // ISO mdoc restricts tdate values to RFC 3339 timestamps without fractional seconds + Instant truncated = instant.truncatedTo(ChronoUnit.SECONDS); + return new Tagged(TAG_TDATE, TDATE_FORMATTER.format(truncated.atOffset(ZoneOffset.UTC))); + } + + static Tagged encodedCbor(Object value) { + return new Tagged(TAG_ENCODED_CBOR, encode(value)); + } + + static final class Tagged implements JsonSerializable { + + private final int tag; + private final Object value; + + Tagged(int tag, Object value) { + this.tag = tag; + this.value = value; + } + + int tag() { + return tag; + } + + Object value() { + return value; + } + + @Override + public void serialize(JsonGenerator generator, SerializerProvider provider) throws IOException { + ((CBORGenerator) generator).writeTag(tag); + generator.writeObject(value); + } + + @Override + public void serializeWithType(JsonGenerator generator, SerializerProvider provider, TypeSerializer typeSerializer) + throws IOException { + serialize(generator, provider); + } + } + + static final class DefiniteLengthMapSerializer extends StdSerializer> { + + DefiniteLengthMapSerializer() { + super(Map.class, false); + } + + @Override + public void serialize(Map value, JsonGenerator generator, SerializerProvider provider) throws IOException { + CBORGenerator cborGenerator = (CBORGenerator) generator; + cborGenerator.writeStartObject(value.size()); + for (Map.Entry entry : value.entrySet()) { + Object key = entry.getKey(); + if (key instanceof Number) { + cborGenerator.writeFieldId(((Number) key).longValue()); + } else { + generator.writeFieldName(String.valueOf(key)); + } + generator.writeObject(entry.getValue()); + } + generator.writeEndObject(); + } + } + + static final class IntegerMap implements JsonSerializable { + + private final Map value; + + IntegerMap(Map value) { + this.value = value; + } + + @Override + public void serialize(JsonGenerator generator, SerializerProvider provider) throws IOException { + CBORGenerator cborGenerator = (CBORGenerator) generator; + cborGenerator.writeStartObject(value.size()); + for (Map.Entry entry : value.entrySet().stream().sorted(Map.Entry.comparingByKey()).collect(Collectors.toList())) { + cborGenerator.writeFieldId(entry.getKey()); + generator.writeNumber(entry.getValue()); + } + generator.writeEndObject(); + } + + @Override + public void serializeWithType(JsonGenerator generator, SerializerProvider provider, TypeSerializer typeSerializer) + throws IOException { + serialize(generator, provider); + } + } +} diff --git a/core/src/main/java/org/keycloak/mdoc/MdocAlgorithm.java b/core/src/main/java/org/keycloak/mdoc/MdocAlgorithm.java new file mode 100644 index 000000000000..59ef213b9039 --- /dev/null +++ b/core/src/main/java/org/keycloak/mdoc/MdocAlgorithm.java @@ -0,0 +1,77 @@ +/* + * Copyright 2026 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.keycloak.mdoc; + +import java.util.Arrays; +import java.util.List; +import java.util.stream.Collectors; + +import org.keycloak.crypto.Algorithm; + +import com.webauthn4j.data.attestation.statement.COSEAlgorithmIdentifier; + +/** + * Mapping between Keycloak's JOSE algorithm names and the COSE algorithm identifiers used by ISO mdoc IssuerAuth. + * OID4VCI 1.0 Appendix A.2.2 advertises mDoc credential signing algorithms as numeric COSE identifiers, while + * Keycloak signing keys and proof validation use JOSE names. + */ +public enum MdocAlgorithm { + RS256(Algorithm.RS256, COSEAlgorithmIdentifier.RS256), + RS384(Algorithm.RS384, COSEAlgorithmIdentifier.RS384), + RS512(Algorithm.RS512, COSEAlgorithmIdentifier.RS512), + PS256(Algorithm.PS256, COSEAlgorithmIdentifier.PS256), + PS384(Algorithm.PS384, COSEAlgorithmIdentifier.PS384), + PS512(Algorithm.PS512, COSEAlgorithmIdentifier.PS512), + ES256(Algorithm.ES256, COSEAlgorithmIdentifier.ES256), + ES384(Algorithm.ES384, COSEAlgorithmIdentifier.ES384), + ES512(Algorithm.ES512, COSEAlgorithmIdentifier.ES512), + EDDSA(Algorithm.EdDSA, COSEAlgorithmIdentifier.EdDSA); + + private final String joseAlgorithm; + private final COSEAlgorithmIdentifier coseAlgorithmIdentifier; + + MdocAlgorithm(String joseAlgorithm, COSEAlgorithmIdentifier coseAlgorithmIdentifier) { + this.joseAlgorithm = joseAlgorithm; + this.coseAlgorithmIdentifier = coseAlgorithmIdentifier; + } + + public String getJoseAlgorithm() { + return joseAlgorithm; + } + + public int getCoseAlgorithmIdentifier() { + return (int) coseAlgorithmIdentifier.getValue(); + } + + public COSEAlgorithmIdentifier toCoseAlgorithmIdentifier() { + return coseAlgorithmIdentifier; + } + + public static List getSupportedJoseAlgorithms() { + return Arrays.stream(values()) + .map(MdocAlgorithm::getJoseAlgorithm) + .collect(Collectors.toList()); + } + + public static MdocAlgorithm fromJoseAlgorithm(String algorithm) { + return Arrays.stream(values()) + .filter(value -> value.getJoseAlgorithm().equals(algorithm)) + .findFirst() + .orElseThrow(() -> new MdocException("Unsupported JOSE algorithm for mDoc: " + algorithm)); + } + +} diff --git a/core/src/main/java/org/keycloak/mdoc/MdocCose.java b/core/src/main/java/org/keycloak/mdoc/MdocCose.java new file mode 100644 index 000000000000..1a4887270040 --- /dev/null +++ b/core/src/main/java/org/keycloak/mdoc/MdocCose.java @@ -0,0 +1,167 @@ +/* + * Copyright 2026 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.keycloak.mdoc; + +import java.security.cert.CertificateEncodingException; +import java.security.cert.X509Certificate; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import org.keycloak.crypto.SignatureSignerContext; + +import com.fasterxml.jackson.annotation.JsonValue; + +final class MdocCose { + + private static final int HEADER_ALGORITHM = 1; + private static final int HEADER_X5CHAIN = 33; + private static final int SIGN1_PROTECTED_HEADER = 0; + private static final int SIGN1_UNPROTECTED_HEADER = 1; + private static final int SIGN1_PAYLOAD = 2; + private static final int SIGN1_SIGNATURE = 3; + private static final int SIGN1_SIZE = SIGN1_SIGNATURE + 1; + + private MdocCose() { + } + + static Sign1 sign1(byte[] payload, + MdocAlgorithm algorithm, + SignatureSignerContext signerContext, + List certificateChain) { + // COSE headers use integer labels: 1 = alg, 33 = x5chain. + byte[] protectedHeader = CborUtil.encodeIntegerMap(Collections.singletonMap( + HEADER_ALGORITHM, algorithm.getCoseAlgorithmIdentifier() + )); + Map> unprotectedHeader = Collections.singletonMap( + HEADER_X5CHAIN, certificateChain.stream().map(MdocCose::encodeCertificate).collect(Collectors.toList()) + ); + + // The COSE signature covers Sig_structure, not the serialized COSE_Sign1 message. + byte[] sigStructure = CborUtil.encode(new SigStructure( + "Signature1", + protectedHeader, + new byte[0], + payload + )); + byte[] signature = signerContext.sign(sigStructure); + + return new Sign1(protectedHeader, unprotectedHeader, payload, signature); + } + + private static byte[] encodeCertificate(X509Certificate certificate) { + try { + return certificate.getEncoded(); + } catch (CertificateEncodingException e) { + throw new MdocException("Could not encode mDoc issuer certificate", e); + } + } + + static final class Sign1 { + + private final byte[] protectedHeader; + private final Map> unprotectedHeader; + private final byte[] payload; + private final byte[] signature; + + Sign1(byte[] protectedHeader, Map> unprotectedHeader, byte[] payload, byte[] signature) { + this.protectedHeader = protectedHeader; + this.unprotectedHeader = unprotectedHeader; + this.payload = payload; + this.signature = signature; + } + + static ParsedSign1 fromCbor(Object item, String name) { + List coseSign1 = CborUtil.asList(item, name); + if (coseSign1.size() != SIGN1_SIZE) { + throw new MdocException("Unexpected COSE_Sign1 structure for " + name); + } + + return new ParsedSign1( + CborUtil.asByteArray(coseSign1.get(SIGN1_PROTECTED_HEADER), name + " protected header"), + CborUtil.asMap(coseSign1.get(SIGN1_UNPROTECTED_HEADER), name + " unprotected header"), + CborUtil.asByteArray(coseSign1.get(SIGN1_PAYLOAD), name + " payload"), + CborUtil.asByteArray(coseSign1.get(SIGN1_SIGNATURE), name + " signature") + ); + } + + @JsonValue + public List toCborArray() { + return Arrays.asList(protectedHeader, unprotectedHeader, payload, signature); + } + } + + static final class ParsedSign1 { + + private final byte[] protectedHeader; + private final Map unprotectedHeader; + private final byte[] payload; + private final byte[] signature; + + ParsedSign1(byte[] protectedHeader, Map unprotectedHeader, byte[] payload, byte[] signature) { + this.protectedHeader = protectedHeader; + this.unprotectedHeader = unprotectedHeader; + this.payload = payload; + this.signature = signature; + } + + byte[] protectedHeader() { + return protectedHeader; + } + + byte[] payload() { + return payload; + } + + byte[] signature() { + return signature; + } + + Object header(int key) { + Object value = unprotectedHeader.get(key); + if (value == null) { + value = unprotectedHeader.get((long) key); + } + if (value == null) { + value = unprotectedHeader.get(String.valueOf(key)); + } + return value; + } + } + + static final class SigStructure { + + private final String context; + private final byte[] bodyProtected; + private final byte[] externalAad; + private final byte[] payload; + + SigStructure(String context, byte[] bodyProtected, byte[] externalAad, byte[] payload) { + this.context = context; + this.bodyProtected = bodyProtected; + this.externalAad = externalAad; + this.payload = payload; + } + + @JsonValue + public List toCborArray() { + return Arrays.asList(context, bodyProtected, externalAad, payload); + } + } +} diff --git a/core/src/main/java/org/keycloak/mdoc/MdocCredential.java b/core/src/main/java/org/keycloak/mdoc/MdocCredential.java new file mode 100644 index 000000000000..5987cd1df9a8 --- /dev/null +++ b/core/src/main/java/org/keycloak/mdoc/MdocCredential.java @@ -0,0 +1,395 @@ +/* + * Copyright 2026 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.keycloak.mdoc; + +import java.security.SecureRandom; +import java.security.cert.X509Certificate; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.stream.Collectors; + +import org.keycloak.crypto.JavaAlgorithm; +import org.keycloak.crypto.SignatureSignerContext; +import org.keycloak.jose.jwk.JWK; +import org.keycloak.jose.jws.crypto.HashUtils; + +import com.fasterxml.jackson.annotation.JsonValue; + +/** + * Representation of an ISO mdoc credential before it is serialized as IssuerSigned. + * + * The input claims are already arranged as mdoc namespaces. Signing converts each data element into + * IssuerSignedItemBytes, hashes those bytes into the Mobile Security Object, optionally embeds the holder DeviceKey, + * and signs the MSO as the IssuerAuth COSE_Sign1 structure. + */ +public class MdocCredential { + private static final SecureRandom RANDOM = new SecureRandom(); + + private final String docType; + private final List nameSpaces; + private final MdocValidityInfo validityInfo; + private MdocDeviceKey deviceKey; + + public MdocCredential(String docType, Map claims, MdocValidityInfo validityInfo) { + this(docType, toNamespacedClaims(claims), validityInfo); + } + + public MdocCredential(String docType, List nameSpaces, MdocValidityInfo validityInfo) { + this.docType = Objects.requireNonNull(docType, "docType"); + this.nameSpaces = new ArrayList<>(Objects.requireNonNull(nameSpaces, "nameSpaces")); + this.validityInfo = Objects.requireNonNull(validityInfo, "validityInfo"); + } + + /** + * Adds holder binding by converting the proof JWK into the COSE_Key stored in DeviceKeyInfo. + * + * @param jwk the holder public key from the JWT proof + */ + public void addKeyBinding(JWK jwk) { + deviceKey = MdocDeviceKey.fromProofJwk(jwk); + } + + /** + * Signs the credential and returns a decoded view of the IssuerSigned structure. + * + * @param signerContext signer for the IssuerAuth COSE_Sign1 structure + * @return decoded view of the signed IssuerSigned structure + */ + public MdocIssuerSignedDocument signAsIssuerSignedDocument(SignatureSignerContext signerContext) { + try { + List issuerNameSpaces = buildIssuerNameSpaces(); + MdocAlgorithm algorithm = resolveSigningAlgorithm(signerContext); + // OID4VCI 1.0 Appendix A.2.4 returns a base64url-encoded ISO mdoc IssuerSigned structure. + // IssuerAuth is the COSE_Sign1 over the Mobile Security Object inside that structure. + List certificateChain = requireCertificateChain(signerContext.getCertificateChain()); + byte[] payload = buildMobileSecurityObjectBytes(issuerNameSpaces); + MdocCose.Sign1 issuerAuth = MdocCose.sign1(payload, algorithm, signerContext, certificateChain); + byte[] issuerSigned = CborUtil.encode(buildIssuerSigned(issuerNameSpaces, issuerAuth)); + + return MdocIssuerSignedDocument.fromIssuerSigned(issuerSigned); + } catch (MdocException e) { + throw e; + } catch (Exception e) { + throw new MdocException("Could not sign mDoc credential", e); + } + } + + public String getDocType() { + return docType; + } + + public Map getClaims() { + Map claims = new LinkedHashMap<>(); + for (MdocNamespacedClaims nameSpace : nameSpaces) { + claims.put(nameSpace.getNameSpace(), nameSpace.getClaims()); + } + return claims; + } + + public List getNamespacedClaims() { + return Collections.unmodifiableList(nameSpaces); + } + + public MdocValidityInfo getValidityInfo() { + return validityInfo; + } + + private List buildIssuerNameSpaces() { + List entries = new ArrayList<>(); + int digestId = 0; + + // OID4VCI mDoc paths are namespace -> data element -> optional nested value; ISO mdoc signs each top-level + // data element separately and references it from the MSO by digest ID. + for (MdocNamespacedClaims nameSpace : nameSpaces) { + List itemBytesList = buildItemBytesList( + nameSpace, + digestId + ); + entries.add(new IssuerNameSpace(nameSpace.getNameSpace(), itemBytesList)); + digestId += itemBytesList.size(); + } + + return entries; + } + + private List buildItemBytesList(MdocNamespacedClaims nameSpace, int startingDigestId) { + List itemBytes = new ArrayList<>(); + int digestId = startingDigestId; + for (Map.Entry claimEntry : nameSpace.getClaims().entrySet()) { + String elementIdentifier = claimEntry.getKey(); + itemBytes.add(buildIssuerSignedItemBytes(digestId++, elementIdentifier, claimEntry.getValue())); + } + return itemBytes; + } + + private IssuerSignedItemBytes buildIssuerSignedItemBytes(int digestId, String elementIdentifier, Object elementValue) { + // IssuerSignedItemBytes is tag 24 around an encoded IssuerSignedItem; the digest covers those tagged bytes. + IssuerSignedItem issuerSignedItem = new IssuerSignedItem(digestId, generateRandom(), elementIdentifier, elementValue); + CborUtil.Tagged taggedItem = CborUtil.encodedCbor(issuerSignedItem); + return new IssuerSignedItemBytes(digestId, taggedItem, CborUtil.encode(taggedItem)); + } + + private byte[] buildMobileSecurityObjectBytes(List issuerNameSpaces) { + // MobileSecurityObjectBytes is tag 24 around the encoded MobileSecurityObject. + return CborUtil.encode(CborUtil.encodedCbor(buildMobileSecurityObject(issuerNameSpaces))); + } + + private MobileSecurityObject buildMobileSecurityObject(List issuerNameSpaces) { + // MobileSecurityObject binds value digests, document type, validity, and optional holder key authorization. + return new MobileSecurityObject( + buildValueDigests(issuerNameSpaces), + deviceKey == null ? null : buildDeviceKeyInfo(issuerNameSpaces), + docType, + validityInfo + ); + } + + private Map> buildValueDigests(List issuerNameSpaces) { + // valueDigests maps each namespace to digest IDs over IssuerSignedItemBytes. + Map> valueDigests = new LinkedHashMap<>(); + for (IssuerNameSpace namespaceEntry : issuerNameSpaces) { + valueDigests.put(namespaceEntry.nameSpace(), buildDigestIds(namespaceEntry.itemBytesList())); + } + return valueDigests; + } + + private Map buildDigestIds(List itemBytesList) { + // DigestIDs uses integer CBOR map keys, not text keys. + Map digestIds = new LinkedHashMap<>(); + for (IssuerSignedItemBytes itemBytes : itemBytesList) { + digestIds.put(itemBytes.digestId(), computeDigest(itemBytes.encoded())); + } + return digestIds; + } + + private DeviceKeyInfo buildDeviceKeyInfo(List issuerNameSpaces) { + // OID4VCI 1.0 Section 12.2.4 advertises "cose_key" for credentials bound to COSE_Key. The JWT proof + // from Appendix F.1 can still carry the wallet key as JOSE; mDoc stores it here as DeviceKeyInfo.COSE_Key. + return new DeviceKeyInfo( + deviceKey, + new KeyAuthorizations(issuerNameSpaces.stream() + .map(IssuerNameSpace::nameSpace) + .collect(Collectors.toList())) + ); + } + + private static IssuerSigned buildIssuerSigned(List issuerNameSpaces, MdocCose.Sign1 issuerAuth) { + // IssuerSigned combines the namespace item arrays with IssuerAuth COSE_Sign1. + Map nameSpaces = new LinkedHashMap<>(); + for (IssuerNameSpace issuerNameSpace : issuerNameSpaces) { + nameSpaces.put( + issuerNameSpace.nameSpace(), + issuerNameSpace.itemBytesList().stream() + .map(IssuerSignedItemBytes::tagged) + .collect(Collectors.toList()) + ); + } + return new IssuerSigned(nameSpaces, issuerAuth); + } + + private static MdocAlgorithm resolveSigningAlgorithm(SignatureSignerContext signerContext) { + return Optional.ofNullable(signerContext.getAlgorithm()) + .map(MdocAlgorithm::fromJoseAlgorithm) + .orElseThrow(() -> new MdocException("mDoc signing key is missing alg")); + } + + private static List requireCertificateChain(List certificateChain) { + if (certificateChain == null || certificateChain.isEmpty()) { + throw new MdocException("mDoc signing key is missing certificate chain"); + } + return certificateChain; + } + + private static byte[] generateRandom() { + byte[] output = new byte[16]; + RANDOM.nextBytes(output); + return output; + } + + private static byte[] computeDigest(byte[] value) { + return HashUtils.hash(JavaAlgorithm.SHA256, value); + } + + private static List toNamespacedClaims(Map claims) { + Objects.requireNonNull(claims, "claims"); + List nameSpaces = new ArrayList<>(); + for (Map.Entry nameSpaceEntry : claims.entrySet()) { + nameSpaces.add(MdocNamespacedClaims.fromEntry(nameSpaceEntry.getKey(), nameSpaceEntry.getValue())); + } + return nameSpaces; + } + + private static final class IssuerSigned { + + private final Map nameSpaces; + private final MdocCose.Sign1 issuerAuth; + + IssuerSigned(Map nameSpaces, MdocCose.Sign1 issuerAuth) { + this.nameSpaces = nameSpaces; + this.issuerAuth = issuerAuth; + } + + @JsonValue + Map toCborMap() { + Map issuerSigned = new LinkedHashMap<>(); + issuerSigned.put("nameSpaces", nameSpaces); + issuerSigned.put("issuerAuth", issuerAuth); + return issuerSigned; + } + } + + private static final class IssuerSignedItem { + + private final int digestId; + private final byte[] random; + private final String elementIdentifier; + private final Object elementValue; + + IssuerSignedItem(int digestId, byte[] random, String elementIdentifier, Object elementValue) { + this.digestId = digestId; + this.random = random; + this.elementIdentifier = elementIdentifier; + this.elementValue = elementValue; + } + + @JsonValue + Map toCborMap() { + Map issuerSignedItem = new LinkedHashMap<>(); + issuerSignedItem.put("digestID", digestId); + issuerSignedItem.put("random", random); + issuerSignedItem.put("elementIdentifier", elementIdentifier); + issuerSignedItem.put("elementValue", elementValue); + return issuerSignedItem; + } + } + + private static final class MobileSecurityObject { + + private final Map> valueDigests; + private final DeviceKeyInfo deviceKeyInfo; + private final String docType; + private final MdocValidityInfo validityInfo; + + MobileSecurityObject(Map> valueDigests, + DeviceKeyInfo deviceKeyInfo, + String docType, + MdocValidityInfo validityInfo) { + this.valueDigests = valueDigests; + this.deviceKeyInfo = deviceKeyInfo; + this.docType = docType; + this.validityInfo = validityInfo; + } + + @JsonValue + Map toCborMap() { + Map mobileSecurityObject = new LinkedHashMap<>(); + mobileSecurityObject.put("version", "1.0"); + mobileSecurityObject.put("digestAlgorithm", "SHA-256"); + mobileSecurityObject.put("valueDigests", valueDigests); + if (deviceKeyInfo != null) { + mobileSecurityObject.put("deviceKeyInfo", deviceKeyInfo); + } + mobileSecurityObject.put("docType", docType); + mobileSecurityObject.put("validityInfo", validityInfo.toCborMap()); + return mobileSecurityObject; + } + } + + private static final class DeviceKeyInfo { + + private final MdocDeviceKey deviceKey; + private final KeyAuthorizations keyAuthorizations; + + DeviceKeyInfo(MdocDeviceKey deviceKey, KeyAuthorizations keyAuthorizations) { + this.deviceKey = deviceKey; + this.keyAuthorizations = keyAuthorizations; + } + + @JsonValue + Map toCborMap() { + Map deviceKeyInfo = new LinkedHashMap<>(); + deviceKeyInfo.put("deviceKey", deviceKey.toCoseKey()); + deviceKeyInfo.put("keyAuthorizations", keyAuthorizations); + return deviceKeyInfo; + } + } + + private static final class KeyAuthorizations { + + private final List nameSpaces; + + KeyAuthorizations(List nameSpaces) { + this.nameSpaces = nameSpaces; + } + + @JsonValue + Map toCborMap() { + Map keyAuthorizations = new LinkedHashMap<>(); + keyAuthorizations.put("nameSpaces", nameSpaces); + return keyAuthorizations; + } + } + + private static final class IssuerNameSpace { + + private final String nameSpace; + private final List itemBytesList; + + IssuerNameSpace(String nameSpace, List itemBytesList) { + this.nameSpace = nameSpace; + this.itemBytesList = itemBytesList; + } + + String nameSpace() { + return nameSpace; + } + + List itemBytesList() { + return itemBytesList; + } + } + + private static final class IssuerSignedItemBytes { + + private final int digestId; + private final CborUtil.Tagged tagged; + private final byte[] encoded; + + IssuerSignedItemBytes(int digestId, CborUtil.Tagged tagged, byte[] encoded) { + this.digestId = digestId; + this.tagged = tagged; + this.encoded = encoded; + } + + int digestId() { + return digestId; + } + + CborUtil.Tagged tagged() { + return tagged; + } + + byte[] encoded() { + return encoded; + } + } +} diff --git a/core/src/main/java/org/keycloak/mdoc/MdocDeviceKey.java b/core/src/main/java/org/keycloak/mdoc/MdocDeviceKey.java new file mode 100644 index 000000000000..6a7498df3f14 --- /dev/null +++ b/core/src/main/java/org/keycloak/mdoc/MdocDeviceKey.java @@ -0,0 +1,128 @@ +/* + * Copyright 2026 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.keycloak.mdoc; + +import java.nio.charset.StandardCharsets; +import java.security.PublicKey; +import java.security.interfaces.ECPublicKey; +import java.security.interfaces.RSAPublicKey; +import java.util.Objects; + +import org.keycloak.common.util.Base64Url; +import org.keycloak.crypto.Algorithm; +import org.keycloak.crypto.KeyType; +import org.keycloak.jose.jwk.JWK; +import org.keycloak.jose.jwk.JWKParser; +import org.keycloak.jose.jwk.OKPPublicJWK; + +import com.webauthn4j.data.attestation.authenticator.COSEKey; +import com.webauthn4j.data.attestation.authenticator.Curve; +import com.webauthn4j.data.attestation.authenticator.EC2COSEKey; +import com.webauthn4j.data.attestation.authenticator.EdDSACOSEKey; +import com.webauthn4j.data.attestation.authenticator.RSACOSEKey; +import com.webauthn4j.data.attestation.statement.COSEAlgorithmIdentifier; + +/** + * Holder binding key for an ISO mdoc Mobile Security Object. + * + * Wallet proofs arrive as JWT proofs containing a JWK, but mDoc DeviceKeyInfo stores the holder public key as a + * COSE_Key. + */ +final class MdocDeviceKey { + + private final COSEKey coseKey; + + private MdocDeviceKey(COSEKey coseKey) { + this.coseKey = Objects.requireNonNull(coseKey, "coseKey"); + } + + static MdocDeviceKey fromProofJwk(JWK jwk) { + Objects.requireNonNull(jwk, "jwk"); + + COSEAlgorithmIdentifier algorithm = coseAlgorithm(jwk); + byte[] keyId = jwk.getKeyId() == null ? null : jwk.getKeyId().getBytes(StandardCharsets.UTF_8); + + switch (jwk.getKeyType()) { + case KeyType.EC: + PublicKey ecPublicKey = JWKParser.create(jwk).toPublicKey(); + return new MdocDeviceKey(ecKey(keyId, algorithm, asEcPublicKey(ecPublicKey))); + case KeyType.RSA: + PublicKey rsaPublicKey = JWKParser.create(jwk).toPublicKey(); + return new MdocDeviceKey(rsaKey(keyId, algorithm, asRsaPublicKey(rsaPublicKey))); + case KeyType.OKP: + return new MdocDeviceKey(okpKey(keyId, algorithm, jwk)); + default: + throw new MdocException("Unsupported proof key type for mDoc COSE_Key: " + jwk.getKeyType()); + } + } + + COSEKey toCoseKey() { + return coseKey; + } + + private static COSEAlgorithmIdentifier coseAlgorithm(JWK jwk) { + if (jwk.getAlgorithm() == null) { + return null; + } + return MdocAlgorithm.fromJoseAlgorithm(jwk.getAlgorithm()).toCoseAlgorithmIdentifier(); + } + + private static ECPublicKey asEcPublicKey(PublicKey publicKey) { + if (publicKey instanceof ECPublicKey) { + return (ECPublicKey) publicKey; + } + throw new MdocException("Unsupported EC proof key for mDoc COSE_Key"); + } + + private static RSAPublicKey asRsaPublicKey(PublicKey publicKey) { + if (publicKey instanceof RSAPublicKey) { + return (RSAPublicKey) publicKey; + } + throw new MdocException("Unsupported RSA proof key for mDoc COSE_Key"); + } + + private static EC2COSEKey ecKey(byte[] keyId, COSEAlgorithmIdentifier algorithm, ECPublicKey publicKey) { + try { + EC2COSEKey key = algorithm == null ? EC2COSEKey.create(publicKey) : EC2COSEKey.create(publicKey, algorithm); + return new EC2COSEKey(keyId, algorithm, null, key.getCurve(), key.getX(), key.getY()); + } catch (IllegalArgumentException e) { + // webauthn4j only maps P256, P384 and P521, so any other EC proof curve is not representable as a COSE_Key. + throw new MdocException("Unsupported EC proof key curve for mDoc COSE_Key", e); + } + } + + private static RSACOSEKey rsaKey(byte[] keyId, COSEAlgorithmIdentifier algorithm, RSAPublicKey publicKey) { + try { + RSACOSEKey key = algorithm == null ? RSACOSEKey.create(publicKey) : RSACOSEKey.create(publicKey, algorithm); + return new RSACOSEKey(keyId, algorithm, null, key.getN(), key.getE()); + } catch (IllegalArgumentException e) { + throw new MdocException("Unsupported RSA proof key for mDoc COSE_Key", e); + } + } + + private static EdDSACOSEKey okpKey(byte[] keyId, COSEAlgorithmIdentifier algorithm, JWK jwk) { + if (!(jwk instanceof OKPPublicJWK)) { + throw new MdocException("Unsupported OKP proof key for mDoc COSE_Key"); + } + + OKPPublicJWK okp = (OKPPublicJWK) jwk; + if (!Algorithm.Ed25519.equals(okp.getCrv())) { + throw new MdocException("Unsupported OKP proof key curve for mDoc COSE_Key: " + okp.getCrv()); + } + return new EdDSACOSEKey(keyId, algorithm, null, Curve.ED25519, Base64Url.decode(okp.getX()), null); + } +} diff --git a/core/src/main/java/org/keycloak/mdoc/MdocException.java b/core/src/main/java/org/keycloak/mdoc/MdocException.java new file mode 100644 index 000000000000..3877004b401c --- /dev/null +++ b/core/src/main/java/org/keycloak/mdoc/MdocException.java @@ -0,0 +1,28 @@ +/* + * Copyright 2026 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.keycloak.mdoc; + +public class MdocException extends RuntimeException { + + public MdocException(String message) { + super(message); + } + + public MdocException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/core/src/main/java/org/keycloak/mdoc/MdocIssuerSignedDocument.java b/core/src/main/java/org/keycloak/mdoc/MdocIssuerSignedDocument.java new file mode 100644 index 000000000000..8437756e4278 --- /dev/null +++ b/core/src/main/java/org/keycloak/mdoc/MdocIssuerSignedDocument.java @@ -0,0 +1,159 @@ +/* + * Copyright 2026 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.keycloak.mdoc; + +import java.io.ByteArrayInputStream; +import java.security.cert.CertificateException; +import java.security.cert.CertificateFactory; +import java.security.cert.X509Certificate; +import java.util.ArrayList; +import java.util.Base64; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +/** + * Parsed view of an ISO mdoc IssuerSigned structure. + * + * OID4VCI returns mso_mdoc credentials as base64url-encoded IssuerSigned CBOR. This class keeps that encoded value for + * the credential response and exposes decoded namespaces and Mobile Security Object fields. + */ +public final class MdocIssuerSignedDocument { + + private static final int COSE_X5CHAIN_HEADER = 33; + + private final String encodedIssuerSigned; + private final Map namespaces; + private final Map mobileSecurityObject; + private final List issuerAuthCertificateChain; + + private MdocIssuerSignedDocument(String encodedIssuerSigned, + Map namespaces, + Map mobileSecurityObject, + List issuerAuthCertificateChain) { + this.encodedIssuerSigned = Objects.requireNonNull(encodedIssuerSigned, "encodedIssuerSigned"); + this.namespaces = Objects.requireNonNull(namespaces, "namespaces"); + this.mobileSecurityObject = Objects.requireNonNull(mobileSecurityObject, "mobileSecurityObject"); + this.issuerAuthCertificateChain = issuerAuthCertificateChain == null ? Collections.emptyList() : issuerAuthCertificateChain; + } + + /** + * Decodes a base64url IssuerSigned payload and exposes its namespaces and Mobile Security Object. + * + * @param encodedIssuerSigned base64url-encoded IssuerSigned CBOR + * @return decoded IssuerSigned view + */ + public static MdocIssuerSignedDocument parse(String encodedIssuerSigned) { + byte[] issuerSignedBytes = Base64.getUrlDecoder().decode(encodedIssuerSigned); + return parse(encodedIssuerSigned, issuerSignedBytes); + } + + static MdocIssuerSignedDocument fromIssuerSigned(byte[] issuerSigned) { + String encodedIssuerSigned = Base64.getUrlEncoder().withoutPadding().encodeToString(issuerSigned); + return parse(encodedIssuerSigned, issuerSigned); + } + + private static MdocIssuerSignedDocument parse(String encodedIssuerSigned, byte[] issuerSignedBytes) { + try { + Map issuerSigned = CborUtil.asMap(CborUtil.decode(issuerSignedBytes), "issuerSigned"); + Map namespaces = readNamespaces(issuerSigned); + Map mobileSecurityObject = readMobileSecurityObject(issuerSigned); + List issuerAuthCertificateChain = readIssuerAuthCertificateChain(issuerSigned); + return new MdocIssuerSignedDocument(encodedIssuerSigned, namespaces, mobileSecurityObject, issuerAuthCertificateChain); + } catch (MdocException e) { + throw e; + } catch (Exception e) { + throw new MdocException("Could not decode mDoc credential", e); + } + } + + public String getEncodedIssuerSigned() { + return encodedIssuerSigned; + } + + public Map getNamespaces() { + return namespaces; + } + + public Map getMobileSecurityObject() { + return mobileSecurityObject; + } + + public List getIssuerAuthCertificateChain() { + return issuerAuthCertificateChain; + } + + public String getDocType() { + Object docType = mobileSecurityObject.get("docType"); + return docType instanceof String ? (String) docType : null; + } + + private static Map readNamespaces(Map issuerSigned) { + Map namespaces = new LinkedHashMap<>(); + Map namespacePairs = CborUtil.asMap(issuerSigned.get("nameSpaces"), "nameSpaces"); + for (Map.Entry namespacePair : namespacePairs.entrySet()) { + String namespace = CborUtil.asString(namespacePair.getKey(), "namespace"); + List namespaceElements = CborUtil.asList(namespacePair.getValue(), "namespace items"); + + Map namespaceClaims = new LinkedHashMap<>(); + for (Object namespaceElement : namespaceElements) { + Map issuerSignedItem = CborUtil.asMap(CborUtil.unwrapEncodedCbor(namespaceElement), "issuerSignedItem"); + String elementIdentifier = CborUtil.asString(issuerSignedItem.get("elementIdentifier"), "elementIdentifier"); + namespaceClaims.put(elementIdentifier, issuerSignedItem.get("elementValue")); + } + namespaces.put(namespace, namespaceClaims); + } + return namespaces; + } + + private static Map readMobileSecurityObject(Map issuerSigned) { + MdocCose.ParsedSign1 issuerAuth = readIssuerAuth(issuerSigned); + return CborUtil.asStringKeyMap(CborUtil.unwrapEncodedCbor(CborUtil.decode(issuerAuth.payload())), "mobileSecurityObject"); + } + + private static List readIssuerAuthCertificateChain(Map issuerSigned) { + MdocCose.ParsedSign1 issuerAuth = readIssuerAuth(issuerSigned); + Object x5chain = issuerAuth.header(COSE_X5CHAIN_HEADER); + if (x5chain == null) { + return Collections.emptyList(); + } + if (x5chain instanceof byte[]) { + return Collections.singletonList(decodeCertificate((byte[]) x5chain)); + } + List certificates = new ArrayList<>(); + for (Object certificate : CborUtil.asList(x5chain, "issuerAuth x5chain")) { + certificates.add(decodeCertificate(CborUtil.asByteArray(certificate, "issuerAuth x5chain certificate"))); + } + return certificates; + } + + private static MdocCose.ParsedSign1 readIssuerAuth(Map issuerSigned) { + return MdocCose.Sign1.fromCbor(issuerSigned.get("issuerAuth"), "issuerAuth"); + } + + private static X509Certificate decodeCertificate(byte[] encoded) { + try { + return (X509Certificate) CertificateFactory.getInstance("X.509") + .generateCertificate(new ByteArrayInputStream(encoded)); + } catch (CertificateException e) { + throw new MdocException("Could not decode mDoc issuer certificate", e); + } + } + +} diff --git a/core/src/main/java/org/keycloak/mdoc/MdocNamespacedClaims.java b/core/src/main/java/org/keycloak/mdoc/MdocNamespacedClaims.java new file mode 100644 index 000000000000..e095dcb77834 --- /dev/null +++ b/core/src/main/java/org/keycloak/mdoc/MdocNamespacedClaims.java @@ -0,0 +1,59 @@ +/* + * Copyright 2026 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.keycloak.mdoc; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; + +/** + * Data elements belonging to one ISO mdoc namespace. + */ +public final class MdocNamespacedClaims { + + private final String nameSpace; + private final Map claims; + + public MdocNamespacedClaims(String nameSpace, Map claims) { + this.nameSpace = Objects.requireNonNull(nameSpace, "nameSpace"); + this.claims = new LinkedHashMap<>(Objects.requireNonNull(claims, "claims")); + } + + public String getNameSpace() { + return nameSpace; + } + + public Map getClaims() { + return Collections.unmodifiableMap(claims); + } + + static MdocNamespacedClaims fromEntry(String nameSpace, Object value) { + if (!(value instanceof Map)) { + throw new MdocException("The value for the name space '" + nameSpace + "' is not a JSON object."); + } + + Map claims = new LinkedHashMap<>(); + for (Map.Entry claimEntry : ((Map) value).entrySet()) { + if (!(claimEntry.getKey() instanceof String)) { + throw new MdocException("The element identifier for the name space '" + nameSpace + "' is not a string."); + } + claims.put((String) claimEntry.getKey(), claimEntry.getValue()); + } + return new MdocNamespacedClaims(nameSpace, claims); + } +} diff --git a/core/src/main/java/org/keycloak/mdoc/MdocValidityInfo.java b/core/src/main/java/org/keycloak/mdoc/MdocValidityInfo.java new file mode 100644 index 000000000000..74c3dc6399f5 --- /dev/null +++ b/core/src/main/java/org/keycloak/mdoc/MdocValidityInfo.java @@ -0,0 +1,74 @@ +/* + * Copyright 2026 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.keycloak.mdoc; + +import java.time.Instant; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; + +/** + * Validity information for the ISO mdoc Mobile Security Object. + * + * The MSO records when the issuer signed the credential, when it becomes valid, and when it expires. + */ +public class MdocValidityInfo { + + private final Instant signed; + private final Instant validFrom; + private final Instant validUntil; + + public MdocValidityInfo(Instant signed, Instant validFrom, Instant validUntil) { + this.signed = Objects.requireNonNull(signed, "signed"); + this.validFrom = Objects.requireNonNull(validFrom, "validFrom"); + this.validUntil = Objects.requireNonNull(validUntil, "validUntil"); + } + + /** + * Creates validity information where signed and validFrom both equal the issuance time. + * + * @param issuanceInstant issuance time used for signed and validFrom + * @param expirationInstant instant after which the mDoc is no longer valid + * @return MSO validity information + */ + public static MdocValidityInfo issuedAt(Instant issuanceInstant, Instant expirationInstant) { + Instant issuance = Objects.requireNonNull(issuanceInstant, "issuanceInstant"); + Instant expiration = Objects.requireNonNull(expirationInstant, "expirationInstant"); + return new MdocValidityInfo(issuance, issuance, expiration); + } + + public Instant getSigned() { + return signed; + } + + public Instant getValidFrom() { + return validFrom; + } + + public Instant getValidUntil() { + return validUntil; + } + + Map toCborMap() { + // ISO mdoc validity timestamps are CBOR tag 0 date/time strings. + Map validityInfo = new LinkedHashMap<>(); + validityInfo.put("signed", CborUtil.tdate(signed)); + validityInfo.put("validFrom", CborUtil.tdate(validFrom)); + validityInfo.put("validUntil", CborUtil.tdate(validUntil)); + return validityInfo; + } +} diff --git a/core/src/test/java/org/keycloak/mdoc/CborUtilTest.java b/core/src/test/java/org/keycloak/mdoc/CborUtilTest.java new file mode 100644 index 000000000000..dfd669358829 --- /dev/null +++ b/core/src/test/java/org/keycloak/mdoc/CborUtilTest.java @@ -0,0 +1,83 @@ +/* + * Copyright 2026 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.keycloak.mdoc; + +import java.time.Instant; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.Map; + +import org.junit.Test; + +import static org.junit.Assert.assertEquals; + +public class CborUtilTest { + + @Test + public void shouldEncodeIntegerMapAsDefiniteMinimalCbor() { + Map value = new HashMap<>(); + value.put(1, -7); + + // a1 opens a map with exactly one entry, 01 is the integer key 1, 26 the value negative 7 + assertCborHex("a1 01 26", CborUtil.encodeIntegerMap(value)); + } + + @Test + public void shouldSortIntegerMapKeysBeforeEncoding() { + Map value = new HashMap<>(); + value.put(33, -257); + value.put(1, -7); + + // a2 opens a map with exactly two entries, key 1 with value negative 7 comes before + // key 33 (18 21) with value negative 257 (39 0100) + assertCborHex("a2 01 26 1821 390100", CborUtil.encodeIntegerMap(value)); + } + + @Test + public void shouldEncodeMapsWithDefiniteLength() { + Map value = new LinkedHashMap<>(); + value.put("a", 1); + + // a1 opens a map with exactly one entry, 61 61 is the text key "a", 01 the value 1. + // An indefinite length map would start with bf instead, which ISO mdoc forbids. + assertCborHex("a1 6161 01", CborUtil.encode(value)); + } + + @Test + public void shouldEncodeIntegerMapKeysAsCborIntegers() { + Map value = new LinkedHashMap<>(); + value.put(1, 2); + + // a1 opens a map with exactly one entry, 01 is the integer key 1, 02 the value 2 + assertCborHex("a1 01 02", CborUtil.encode(value)); + } + + @Test + public void shouldTruncateTdateToWholeSeconds() { + CborUtil.Tagged tdate = CborUtil.tdate(Instant.parse("2025-07-01T20:00:00.123Z")); + + assertEquals("2025-07-01T20:00:00Z", tdate.value()); + } + + private static void assertCborHex(String expectedHex, byte[] actual) { + StringBuilder actualHex = new StringBuilder(); + for (byte b : actual) { + actualHex.append(String.format("%02x", b)); + } + assertEquals(expectedHex.replace(" ", ""), actualHex.toString()); + } +} diff --git a/core/src/test/java/org/keycloak/mdoc/MdocCredentialCreationAndSigningTest.java b/core/src/test/java/org/keycloak/mdoc/MdocCredentialCreationAndSigningTest.java new file mode 100644 index 000000000000..3bd23e0391e8 --- /dev/null +++ b/core/src/test/java/org/keycloak/mdoc/MdocCredentialCreationAndSigningTest.java @@ -0,0 +1,177 @@ +/* + * Copyright 2026 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.keycloak.mdoc; + +import java.security.KeyPair; +import java.security.PublicKey; +import java.security.Signature; +import java.security.cert.X509Certificate; +import java.time.Instant; +import java.util.Base64; +import java.util.LinkedHashMap; +import java.util.Map; + +import org.keycloak.common.util.CertificateUtils; +import org.keycloak.common.util.KeyUtils; +import org.keycloak.crypto.Algorithm; +import org.keycloak.crypto.ECDSAAlgorithm; +import org.keycloak.crypto.ECDSASignatureSignerContext; +import org.keycloak.crypto.JavaAlgorithm; +import org.keycloak.crypto.KeyUse; +import org.keycloak.crypto.KeyWrapper; +import org.keycloak.jose.jwk.JWK; +import org.keycloak.jose.jwk.JWKBuilder; +import org.keycloak.rule.CryptoInitRule; + +import org.junit.ClassRule; +import org.junit.Test; + +import static org.keycloak.common.crypto.CryptoConstants.EC_KEY_SECP256R1; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +public abstract class MdocCredentialCreationAndSigningTest { + + private static final String DOC_TYPE = "org.iso.18013.5.1.mDL"; + private static final String NAMESPACE = "org.iso.18013.5.1"; + private static final String GIVEN_NAME = "given_name"; + private static final String FAMILY_NAME = "family_name"; + + @ClassRule + public static CryptoInitRule cryptoInitRule = new CryptoInitRule(); + + @Test + public void shouldCreateSignedMdocCredentialWithHolderBinding() throws Exception { + KeyPair issuerKeyPair = KeyUtils.generateEcKeyPair(EC_KEY_SECP256R1); + X509Certificate issuerCertificate = selfSignedCertificate(issuerKeyPair); + MdocCredential credential = mdocCredential(); + credential.addKeyBinding(proofJwk()); + + MdocIssuerSignedDocument document = credential.signAsIssuerSignedDocument( + new ECDSASignatureSignerContext(issuerKey(issuerKeyPair, issuerCertificate)) + ); + + assertIssuedCredential(document, issuerCertificate); + assertNotNull(document.getMobileSecurityObject().get("deviceKeyInfo")); + assertIssuerAuthSignature(document, issuerKeyPair.getPublic()); + } + + @Test + public void shouldCreateSignedMdocCredentialWithoutHolderBinding() throws Exception { + KeyPair issuerKeyPair = KeyUtils.generateEcKeyPair(EC_KEY_SECP256R1); + X509Certificate issuerCertificate = selfSignedCertificate(issuerKeyPair); + + MdocIssuerSignedDocument document = mdocCredential().signAsIssuerSignedDocument( + new ECDSASignatureSignerContext(issuerKey(issuerKeyPair, issuerCertificate)) + ); + + assertIssuedCredential(document, issuerCertificate); + assertNull(document.getMobileSecurityObject().get("deviceKeyInfo")); + assertIssuerAuthSignature(document, issuerKeyPair.getPublic()); + } + + private static MdocCredential mdocCredential() { + Map nameSpaceClaims = new LinkedHashMap<>(); + nameSpaceClaims.put(GIVEN_NAME, "Erika"); + nameSpaceClaims.put(FAMILY_NAME, "Mustermann"); + + Map claims = new LinkedHashMap<>(); + claims.put(NAMESPACE, nameSpaceClaims); + + return new MdocCredential( + DOC_TYPE, + claims, + MdocValidityInfo.issuedAt( + Instant.parse("2026-04-24T10:15:30Z"), + Instant.parse("2027-04-24T10:15:30Z") + ) + ); + } + + private static JWK proofJwk() throws Exception { + KeyPair proofKeyPair = KeyUtils.generateEcKeyPair(EC_KEY_SECP256R1); + return JWKBuilder.create() + .kid("proof-key") + .algorithm(Algorithm.ES256) + .ec(proofKeyPair.getPublic(), KeyUse.SIG); + } + + private static KeyWrapper issuerKey(KeyPair issuerKeyPair, X509Certificate issuerCertificate) { + KeyWrapper issuerKey = new KeyWrapper(); + issuerKey.setAlgorithm(Algorithm.ES256); + issuerKey.setUse(KeyUse.SIG); + issuerKey.setType("EC"); + issuerKey.setKid("issuer-key"); + issuerKey.setPrivateKey(issuerKeyPair.getPrivate()); + issuerKey.setPublicKey(issuerKeyPair.getPublic()); + issuerKey.setCertificate(issuerCertificate); + return issuerKey; + } + + private static X509Certificate selfSignedCertificate(KeyPair keyPair) throws Exception { + return (X509Certificate) CertificateUtils.generateV1SelfSignedCertificate(keyPair, "issuer-key"); + } + + private static void assertIssuedCredential(MdocIssuerSignedDocument document, X509Certificate issuerCertificate) + throws Exception { + assertNotNull(document.getEncodedIssuerSigned()); + assertEquals(DOC_TYPE, document.getDocType()); + assertEquals(1, document.getIssuerAuthCertificateChain().size()); + assertArrayEquals(issuerCertificate.getEncoded(), document.getIssuerAuthCertificateChain().get(0).getEncoded()); + + assertTrue(document.getNamespaces().get(NAMESPACE) instanceof Map); + Map namespaceClaims = (Map) document.getNamespaces().get(NAMESPACE); + assertEquals("Erika", namespaceClaims.get(GIVEN_NAME)); + assertEquals("Mustermann", namespaceClaims.get(FAMILY_NAME)); + + Map mobileSecurityObject = document.getMobileSecurityObject(); + assertEquals("1.0", mobileSecurityObject.get("version")); + assertEquals("SHA-256", mobileSecurityObject.get("digestAlgorithm")); + assertEquals(DOC_TYPE, mobileSecurityObject.get("docType")); + assertTrue(mobileSecurityObject.get("valueDigests") instanceof Map); + assertNotNull(mobileSecurityObject.get("validityInfo")); + } + + private static void assertIssuerAuthSignature(MdocIssuerSignedDocument document, PublicKey publicKey) + throws Exception { + Map issuerSigned = CborUtil.asMap( + CborUtil.decode(Base64.getUrlDecoder().decode(document.getEncodedIssuerSigned())), + "issuerSigned" + ); + MdocCose.ParsedSign1 issuerAuth = MdocCose.Sign1.fromCbor(issuerSigned.get("issuerAuth"), "issuerAuth"); + byte[] sigStructure = CborUtil.encode(new MdocCose.SigStructure( + "Signature1", + issuerAuth.protectedHeader(), + new byte[0], + issuerAuth.payload() + )); + + Signature signature = Signature.getInstance(JavaAlgorithm.getJavaAlgorithm(Algorithm.ES256)); + signature.initVerify(publicKey); + signature.update(sigStructure); + + byte[] derSignature = ECDSAAlgorithm.concatenatedRSToASN1DER( + issuerAuth.signature(), + ECDSAAlgorithm.getSignatureLength(Algorithm.ES256) + ); + assertTrue(signature.verify(derSignature)); + } +} diff --git a/core/src/test/java/org/keycloak/mdoc/MdocCredentialTest.java b/core/src/test/java/org/keycloak/mdoc/MdocCredentialTest.java new file mode 100644 index 000000000000..38823283fead --- /dev/null +++ b/core/src/test/java/org/keycloak/mdoc/MdocCredentialTest.java @@ -0,0 +1,103 @@ +/* + * Copyright 2026 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.keycloak.mdoc; + +import java.time.Instant; +import java.util.LinkedHashMap; +import java.util.Map; + +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; + +public class MdocCredentialTest { + + private static final String DOC_TYPE = "org.iso.18013.5.1.mDL"; + private static final String NAMESPACE = "org.iso.18013.5.1"; + + @Test + public void shouldExposeNamespacedClaimsFromMapInput() { + Map namespaceClaims = namespaceClaims(); + Map claims = claims(namespaceClaims); + MdocValidityInfo validityInfo = validityInfo(); + + MdocCredential credential = new MdocCredential(DOC_TYPE, claims, validityInfo); + + assertEquals(DOC_TYPE, credential.getDocType()); + assertEquals(validityInfo, credential.getValidityInfo()); + assertEquals(claims, credential.getClaims()); + assertEquals(1, credential.getNamespacedClaims().size()); + assertEquals(NAMESPACE, credential.getNamespacedClaims().get(0).getNameSpace()); + assertEquals(namespaceClaims, credential.getNamespacedClaims().get(0).getClaims()); + } + + @Test + public void shouldRejectNamespaceValueThatIsNotObject() { + Map claims = new LinkedHashMap<>(); + claims.put(NAMESPACE, "Erika"); + + MdocException exception = assertThrows(MdocException.class, + () -> new MdocCredential(DOC_TYPE, claims, validityInfo())); + + assertEquals("The value for the name space '" + NAMESPACE + "' is not a JSON object.", exception.getMessage()); + } + + @Test + public void shouldRejectNonStringElementIdentifier() { + Map namespaceClaims = new LinkedHashMap<>(); + namespaceClaims.put(1, "Erika"); + + Map claims = new LinkedHashMap<>(); + claims.put(NAMESPACE, namespaceClaims); + + MdocException exception = assertThrows(MdocException.class, + () -> new MdocCredential(DOC_TYPE, claims, validityInfo())); + + assertEquals("The element identifier for the name space '" + NAMESPACE + "' is not a string.", exception.getMessage()); + } + + @Test + public void shouldExposeImmutableNamespacedClaims() { + MdocCredential credential = new MdocCredential(DOC_TYPE, claims(namespaceClaims()), validityInfo()); + + assertThrows(UnsupportedOperationException.class, () -> credential.getNamespacedClaims().clear()); + assertThrows(UnsupportedOperationException.class, + () -> credential.getNamespacedClaims().get(0).getClaims().put("age_over_21", true)); + } + + private static Map claims(Map namespaceClaims) { + Map claims = new LinkedHashMap<>(); + claims.put(NAMESPACE, namespaceClaims); + return claims; + } + + private static Map namespaceClaims() { + Map namespaceClaims = new LinkedHashMap<>(); + namespaceClaims.put("given_name", "Erika"); + namespaceClaims.put("family_name", "Mustermann"); + namespaceClaims.put("age_over_18", true); + return namespaceClaims; + } + + private static MdocValidityInfo validityInfo() { + return MdocValidityInfo.issuedAt( + Instant.parse("2026-04-24T10:15:30Z"), + Instant.parse("2027-04-24T10:15:30Z") + ); + } +} diff --git a/core/src/test/java/org/keycloak/mdoc/MdocIssuerSignedDocumentTest.java b/core/src/test/java/org/keycloak/mdoc/MdocIssuerSignedDocumentTest.java new file mode 100644 index 000000000000..ca028bd84809 --- /dev/null +++ b/core/src/test/java/org/keycloak/mdoc/MdocIssuerSignedDocumentTest.java @@ -0,0 +1,109 @@ +/* + * Copyright 2026 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.keycloak.mdoc; + +import java.util.Arrays; +import java.util.Base64; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; + +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +public class MdocIssuerSignedDocumentTest { + + private static final String DOC_TYPE = "org.iso.18013.5.1.mDL"; + private static final String NAMESPACE = "org.iso.18013.5.1"; + + @Test + public void shouldParseIssuerSignedRepresentation() { + String encodedIssuerSigned = encodedIssuerSigned(); + + MdocIssuerSignedDocument document = MdocIssuerSignedDocument.parse(encodedIssuerSigned); + + assertEquals(encodedIssuerSigned, document.getEncodedIssuerSigned()); + assertEquals(DOC_TYPE, document.getDocType()); + assertEquals(Collections.emptyList(), document.getIssuerAuthCertificateChain()); + + assertTrue(document.getNamespaces().get(NAMESPACE) instanceof Map); + Map namespaceClaims = (Map) document.getNamespaces().get(NAMESPACE); + assertEquals("Erika", namespaceClaims.get("given_name")); + assertTrue((Boolean) namespaceClaims.get("age_over_18")); + + assertEquals("1.0", document.getMobileSecurityObject().get("version")); + assertEquals("SHA-256", document.getMobileSecurityObject().get("digestAlgorithm")); + assertEquals(DOC_TYPE, document.getMobileSecurityObject().get("docType")); + } + + @Test + public void shouldRejectMalformedIssuerSignedRepresentation() { + String encodedIssuerSigned = Base64.getUrlEncoder().withoutPadding() + .encodeToString(CborUtil.encode(Collections.singletonMap("nameSpaces", "not-a-map"))); + + MdocException exception = assertThrows(MdocException.class, + () -> MdocIssuerSignedDocument.parse(encodedIssuerSigned)); + + assertEquals("Unexpected map structure for nameSpaces", exception.getMessage()); + } + + private static String encodedIssuerSigned() { + Map issuerSigned = new LinkedHashMap<>(); + issuerSigned.put("nameSpaces", namespaces()); + issuerSigned.put("issuerAuth", issuerAuth()); + return Base64.getUrlEncoder().withoutPadding().encodeToString(CborUtil.encode(issuerSigned)); + } + + private static Map namespaces() { + Map namespaceClaims = new LinkedHashMap<>(); + namespaceClaims.put(NAMESPACE, Arrays.asList( + CborUtil.encodedCbor(issuerSignedItem(0, "given_name", "Erika")), + CborUtil.encodedCbor(issuerSignedItem(1, "age_over_18", true)) + )); + return namespaceClaims; + } + + private static Map issuerSignedItem(int digestId, String elementIdentifier, Object elementValue) { + Map issuerSignedItem = new LinkedHashMap<>(); + issuerSignedItem.put("digestID", digestId); + issuerSignedItem.put("random", new byte[] { (byte) digestId }); + issuerSignedItem.put("elementIdentifier", elementIdentifier); + issuerSignedItem.put("elementValue", elementValue); + return issuerSignedItem; + } + + private static MdocCose.Sign1 issuerAuth() { + return new MdocCose.Sign1( + new byte[0], + Collections.emptyMap(), + CborUtil.encode(CborUtil.encodedCbor(mobileSecurityObject())), + new byte[] { 1, 2, 3 } + ); + } + + private static Map mobileSecurityObject() { + Map mobileSecurityObject = new LinkedHashMap<>(); + mobileSecurityObject.put("version", "1.0"); + mobileSecurityObject.put("digestAlgorithm", "SHA-256"); + mobileSecurityObject.put("valueDigests", Collections.singletonMap(NAMESPACE, Collections.emptyMap())); + mobileSecurityObject.put("docType", DOC_TYPE); + return mobileSecurityObject; + } +} diff --git a/core/src/test/java/org/keycloak/mdoc/MdocKeyBindingTest.java b/core/src/test/java/org/keycloak/mdoc/MdocKeyBindingTest.java new file mode 100644 index 000000000000..205a93118011 --- /dev/null +++ b/core/src/test/java/org/keycloak/mdoc/MdocKeyBindingTest.java @@ -0,0 +1,178 @@ +/* + * Copyright 2026 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.keycloak.mdoc; + +import java.math.BigInteger; +import java.nio.charset.StandardCharsets; +import java.security.KeyPair; +import java.util.function.Consumer; +import java.util.function.Function; +import java.util.function.Supplier; + +import org.keycloak.common.util.KeyUtils; +import org.keycloak.crypto.Algorithm; +import org.keycloak.crypto.KeyUse; +import org.keycloak.jose.jwk.JWK; +import org.keycloak.jose.jwk.JWKBuilder; +import org.keycloak.rule.CryptoInitRule; + +import com.webauthn4j.data.attestation.authenticator.COSEKey; +import com.webauthn4j.data.attestation.authenticator.Curve; +import com.webauthn4j.data.attestation.authenticator.EC2COSEKey; +import com.webauthn4j.data.attestation.authenticator.EdDSACOSEKey; +import com.webauthn4j.data.attestation.authenticator.RSACOSEKey; +import com.webauthn4j.data.attestation.statement.COSEAlgorithmIdentifier; +import com.webauthn4j.data.attestation.statement.COSEKeyType; +import org.junit.ClassRule; +import org.junit.Test; + +import static org.keycloak.common.crypto.CryptoConstants.EC_KEY_SECP256R1; +import static org.keycloak.common.crypto.CryptoConstants.EC_KEY_SECP384R1; +import static org.keycloak.common.crypto.CryptoConstants.EC_KEY_SECP521R1; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +/** + * Test of proof key conversion for mDoc holder binding. + */ +public abstract class MdocKeyBindingTest { + + private static final String KEY_ID = "proof-key"; + + @ClassRule + public static CryptoInitRule cryptoInitRule = new CryptoInitRule(); + + @Test + public void testEdDSAKeyBindingWithEd25519() { + testKeyBinding(() -> KeyUtils.generateEddsaKeyPair(Algorithm.Ed25519), + keyPair -> JWKBuilder.create().kid(KEY_ID).algorithm(Algorithm.EdDSA) + .okp(keyPair.getPublic(), KeyUse.SIG), + coseKey -> assertEdDSAKey(coseKey, Curve.ED25519)); + } + + @Test + public void testEdDSAKeyBindingWithEd448() { + MdocException exception = assertThrows(MdocException.class, + () -> testKeyBinding(() -> KeyUtils.generateEddsaKeyPair(Algorithm.Ed448), + keyPair -> JWKBuilder.create().kid(KEY_ID).algorithm(Algorithm.EdDSA) + .okp(keyPair.getPublic(), KeyUse.SIG), + coseKey -> {})); + assertEquals("Unsupported OKP proof key curve for mDoc COSE_Key: Ed448", exception.getMessage()); + } + + @Test + public void testEc256KeyBinding() { + testKeyBinding(() -> KeyUtils.generateEcKeyPair(EC_KEY_SECP256R1), + keyPair -> JWKBuilder.create().kid(KEY_ID).algorithm(Algorithm.ES256) + .ec(keyPair.getPublic(), KeyUse.SIG), + coseKey -> assertEcKey(coseKey, Curve.SECP256R1, COSEAlgorithmIdentifier.ES256)); + } + + @Test + public void testEc384KeyBinding() { + testKeyBinding(() -> KeyUtils.generateEcKeyPair(EC_KEY_SECP384R1), + keyPair -> JWKBuilder.create().kid(KEY_ID).algorithm(Algorithm.ES384) + .ec(keyPair.getPublic(), KeyUse.SIG), + coseKey -> assertEcKey(coseKey, Curve.SECP384R1, COSEAlgorithmIdentifier.ES384)); + } + + @Test + public void testEc521KeyBinding() { + testKeyBinding(() -> KeyUtils.generateEcKeyPair(EC_KEY_SECP521R1), + keyPair -> JWKBuilder.create().kid(KEY_ID).algorithm(Algorithm.ES512) + .ec(keyPair.getPublic(), KeyUse.SIG), + coseKey -> assertEcKey(coseKey, Curve.SECP521R1, COSEAlgorithmIdentifier.ES512)); + } + + @Test + public void testEc384KeyBindingWithoutAlgorithm() { + testKeyBinding(() -> KeyUtils.generateEcKeyPair(EC_KEY_SECP384R1), + keyPair -> JWKBuilder.create().kid(KEY_ID).ec(keyPair.getPublic(), KeyUse.SIG), + coseKey -> assertEcKey(coseKey, Curve.SECP384R1, null)); + } + + @Test + public void testRSA2048KeyBinding() { + testKeyBinding(() -> KeyUtils.generateRsaKeyPair(2048), + keyPair -> JWKBuilder.create().kid(KEY_ID).algorithm(Algorithm.PS256) + .rsa(keyPair.getPublic(), KeyUse.SIG), + coseKey -> assertRsaKey(coseKey, COSEAlgorithmIdentifier.PS256, 2048)); + } + + @Test + public void testRSA4096KeyBinding() { + testKeyBinding(() -> KeyUtils.generateRsaKeyPair(4096), + keyPair -> JWKBuilder.create().kid(KEY_ID).algorithm(Algorithm.PS512) + .rsa(keyPair.getPublic(), KeyUse.SIG), + coseKey -> assertRsaKey(coseKey, COSEAlgorithmIdentifier.PS512, 4096)); + } + + private void testKeyBinding(Supplier keyPairSupplier, Function jwkProvider, + Consumer keyFormatValidator) { + KeyPair keyPair = keyPairSupplier.get(); + JWK proofJwk = jwkProvider.apply(keyPair); + + COSEKey coseKey = MdocDeviceKey.fromProofJwk(proofJwk).toCoseKey(); + + assertArrayEquals(KEY_ID.getBytes(StandardCharsets.UTF_8), coseKey.getKeyId()); + assertTrue(coseKey.hasPublicKey()); + assertFalse(coseKey.hasPrivateKey()); + assertNotNull(coseKey.getPublicKey()); + + keyFormatValidator.accept(coseKey); + } + + private void assertEdDSAKey(COSEKey coseKey, Curve expectedCurve) { + assertTrue(coseKey instanceof EdDSACOSEKey); + assertEquals(COSEKeyType.OKP, coseKey.getKeyType()); + assertEquals(COSEAlgorithmIdentifier.EdDSA, coseKey.getAlgorithm()); + + EdDSACOSEKey edDsaKey = (EdDSACOSEKey) coseKey; + assertEquals(expectedCurve, edDsaKey.getCurve()); + assertEquals(expectedCurve.getSize(), edDsaKey.getX().length); + assertNull(edDsaKey.getD()); + } + + private void assertEcKey(COSEKey coseKey, Curve expectedCurve, COSEAlgorithmIdentifier expectedAlgorithm) { + assertTrue(coseKey instanceof EC2COSEKey); + assertEquals(COSEKeyType.EC2, coseKey.getKeyType()); + assertEquals(expectedAlgorithm, coseKey.getAlgorithm()); + + EC2COSEKey ecKey = (EC2COSEKey) coseKey; + assertEquals(expectedCurve, ecKey.getCurve()); + assertEquals(expectedCurve.getSize(), ecKey.getX().length); + assertEquals(expectedCurve.getSize(), ecKey.getY().length); + assertNull(ecKey.getD()); + } + + private void assertRsaKey(COSEKey coseKey, COSEAlgorithmIdentifier expectedAlgorithm, int expectedKeySize) { + assertTrue(coseKey instanceof RSACOSEKey); + assertEquals(COSEKeyType.RSA, coseKey.getKeyType()); + assertEquals(expectedAlgorithm, coseKey.getAlgorithm()); + + RSACOSEKey rsaKey = (RSACOSEKey) coseKey; + assertEquals(expectedKeySize, new BigInteger(1, rsaKey.getN()).bitLength()); + assertNotNull(rsaKey.getE()); + assertNull(rsaKey.getD()); + } +} diff --git a/crypto/default/src/test/java/org/keycloak/crypto/def/test/mdoc/DefaultCryptoMdocCredentialCreationAndSigningTest.java b/crypto/default/src/test/java/org/keycloak/crypto/def/test/mdoc/DefaultCryptoMdocCredentialCreationAndSigningTest.java new file mode 100644 index 000000000000..d4584cd135a7 --- /dev/null +++ b/crypto/default/src/test/java/org/keycloak/crypto/def/test/mdoc/DefaultCryptoMdocCredentialCreationAndSigningTest.java @@ -0,0 +1,31 @@ +/* + * Copyright 2026 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.keycloak.crypto.def.test.mdoc; + +import org.keycloak.common.util.Environment; +import org.keycloak.mdoc.MdocCredentialCreationAndSigningTest; + +import org.junit.Assume; +import org.junit.Before; + +public class DefaultCryptoMdocCredentialCreationAndSigningTest extends MdocCredentialCreationAndSigningTest { + + @Before + public void before() { + Assume.assumeFalse("Java is in FIPS mode. Skipping the test.", Environment.isJavaInFipsMode()); + } +} diff --git a/crypto/default/src/test/java/org/keycloak/crypto/def/test/mdoc/DefaultCryptoMdocKeyBindingTest.java b/crypto/default/src/test/java/org/keycloak/crypto/def/test/mdoc/DefaultCryptoMdocKeyBindingTest.java new file mode 100644 index 000000000000..3cd5303b7d4c --- /dev/null +++ b/crypto/default/src/test/java/org/keycloak/crypto/def/test/mdoc/DefaultCryptoMdocKeyBindingTest.java @@ -0,0 +1,31 @@ +/* + * Copyright 2026 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.keycloak.crypto.def.test.mdoc; + +import org.keycloak.common.util.Environment; +import org.keycloak.mdoc.MdocKeyBindingTest; + +import org.junit.Assume; +import org.junit.Before; + +public class DefaultCryptoMdocKeyBindingTest extends MdocKeyBindingTest { + + @Before + public void before() { + Assume.assumeFalse("Java is in FIPS mode. Skipping the test.", Environment.isJavaInFipsMode()); + } +} diff --git a/crypto/elytron/src/test/java/org/keycloak/crypto/elytron/test/mdoc/ElytronCryptoMdocCredentialCreationAndSigningTest.java b/crypto/elytron/src/test/java/org/keycloak/crypto/elytron/test/mdoc/ElytronCryptoMdocCredentialCreationAndSigningTest.java new file mode 100644 index 000000000000..1c4562347bf4 --- /dev/null +++ b/crypto/elytron/src/test/java/org/keycloak/crypto/elytron/test/mdoc/ElytronCryptoMdocCredentialCreationAndSigningTest.java @@ -0,0 +1,22 @@ +/* + * Copyright 2026 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.keycloak.crypto.elytron.test.mdoc; + +import org.keycloak.mdoc.MdocCredentialCreationAndSigningTest; + +public class ElytronCryptoMdocCredentialCreationAndSigningTest extends MdocCredentialCreationAndSigningTest { +} diff --git a/crypto/elytron/src/test/java/org/keycloak/crypto/elytron/test/mdoc/ElytronCryptoMdocKeyBindingTest.java b/crypto/elytron/src/test/java/org/keycloak/crypto/elytron/test/mdoc/ElytronCryptoMdocKeyBindingTest.java new file mode 100644 index 000000000000..b66123249e0a --- /dev/null +++ b/crypto/elytron/src/test/java/org/keycloak/crypto/elytron/test/mdoc/ElytronCryptoMdocKeyBindingTest.java @@ -0,0 +1,22 @@ +/* + * Copyright 2026 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.keycloak.crypto.elytron.test.mdoc; + +import org.keycloak.mdoc.MdocKeyBindingTest; + +public class ElytronCryptoMdocKeyBindingTest extends MdocKeyBindingTest { +} diff --git a/crypto/fips1402/src/test/java/org/keycloak/crypto/fips/test/mdoc/FIPS1402MdocCredentialCreationAndSigningTest.java b/crypto/fips1402/src/test/java/org/keycloak/crypto/fips/test/mdoc/FIPS1402MdocCredentialCreationAndSigningTest.java new file mode 100644 index 000000000000..548e290186ae --- /dev/null +++ b/crypto/fips1402/src/test/java/org/keycloak/crypto/fips/test/mdoc/FIPS1402MdocCredentialCreationAndSigningTest.java @@ -0,0 +1,31 @@ +/* + * Copyright 2026 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.keycloak.crypto.fips.test.mdoc; + +import org.keycloak.common.util.Environment; +import org.keycloak.mdoc.MdocCredentialCreationAndSigningTest; + +import org.junit.Assume; +import org.junit.Before; + +public class FIPS1402MdocCredentialCreationAndSigningTest extends MdocCredentialCreationAndSigningTest { + + @Before + public void before() { + Assume.assumeFalse("Java is in FIPS mode. Skipping the test.", Environment.isJavaInFipsMode()); + } +} diff --git a/crypto/fips1402/src/test/java/org/keycloak/crypto/fips/test/mdoc/FIPS1402MdocKeyBindingTest.java b/crypto/fips1402/src/test/java/org/keycloak/crypto/fips/test/mdoc/FIPS1402MdocKeyBindingTest.java new file mode 100644 index 000000000000..e168af633454 --- /dev/null +++ b/crypto/fips1402/src/test/java/org/keycloak/crypto/fips/test/mdoc/FIPS1402MdocKeyBindingTest.java @@ -0,0 +1,31 @@ +/* + * Copyright 2026 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.keycloak.crypto.fips.test.mdoc; + +import org.keycloak.common.util.Environment; +import org.keycloak.mdoc.MdocKeyBindingTest; + +import org.junit.Assume; +import org.junit.Before; + +public class FIPS1402MdocKeyBindingTest extends MdocKeyBindingTest { + + @Before + public void before() { + Assume.assumeFalse("Java is in FIPS mode. Skipping the test.", Environment.isJavaInFipsMode()); + } +} diff --git a/js/apps/admin-ui/maven-resources/theme/keycloak.v2/admin/messages/messages_en.properties b/js/apps/admin-ui/maven-resources/theme/keycloak.v2/admin/messages/messages_en.properties index d6388ca85611..de25f5988784 100644 --- a/js/apps/admin-ui/maven-resources/theme/keycloak.v2/admin/messages/messages_en.properties +++ b/js/apps/admin-ui/maven-resources/theme/keycloak.v2/admin/messages/messages_en.properties @@ -4085,8 +4085,8 @@ hashAlgorithm=Hash Algorithm hashAlgorithmHelp=Hash algorithm used for SD-JWT credentials (for example "SHA-256"). Defaults to "SHA-256" if not specified. supportedCredentialTypes=Supported Credential Types supportedCredentialTypesHelp=Comma-separated list of credential types (e.g., "VerifiableCredential,UniversityDegreeCredential"). Used in the credential definition for JWT VC and SD-JWT formats. When this option is not configured, the name of the client scope will be used as a 'type' claim of the corresponding credential. -verifiableCredentialType=Verifiable Credential Type (VCT) -verifiableCredentialTypeHelp=The credential type identifier for SD-JWT format credentials. This value is used in the vct claim of the issued credential. Required for SD-JWT format. +verifiableCredentialType=Verifiable Credential Type (VCT / DocType) +verifiableCredentialTypeHelp=The credential type identifier for SD-JWT and ISO mDoc format credentials. This value is used in the vct claim of an issued SD-JWT credential and as the doctype of an issued ISO mDoc credential. Required for SD-JWT and ISO mDoc formats. tokenJwsType=Token JWS Type tokenJwsTypeHelp=The type value written into the typ header of the JWT. If empty, a format-specific default is used ("dc+sd-jwt" for SD-JWT VC and "vc+jwt" for JWT VC). Can be set to custom values if required by the wallet or system. tokenJwsTypeFormatWarning=The configured Token JWS Type does not match the recommended value for the selected credential format. Recommended value: {{recommended}}. diff --git a/js/apps/admin-ui/src/client-scopes/details/ScopeForm.tsx b/js/apps/admin-ui/src/client-scopes/details/ScopeForm.tsx index 5fde8dd22ae9..31660ff513b3 100644 --- a/js/apps/admin-ui/src/client-scopes/details/ScopeForm.tsx +++ b/js/apps/admin-ui/src/client-scopes/details/ScopeForm.tsx @@ -46,6 +46,7 @@ import { removeEmptyOid4vcAttributes } from "./oid4vciAttributes"; const OID4VC_PROTOCOL = "oid4vc"; const VC_FORMAT_JWT_VC = "jwt_vc_json"; const VC_FORMAT_SD_JWT = "dc+sd-jwt"; +const VC_FORMAT_MSO_MDOC = "mso_mdoc"; const VC_FORMAT_JWT_VC_TYP = "vc+jwt"; const VC_FORMAT_SD_JWT_TYP = "dc+sd-jwt"; const VC_EXPIRY_DEFAULT_SECONDS = 31536000; // 1 year (matches VC_EXPIRY_IN_SECONDS_DEFAULT) @@ -53,7 +54,8 @@ const VC_REFRESH_INTERVAL_DEFAULT_SECONDS = 604800; // 7 days (matches VC_REFRES // Allowed values for OID4VCI cryptographic binding methods and proof types. // Keep these in sync with server-side support in CredentialScopeModel / ProofType. -const ALLOWED_CRYPTO_BINDING_METHODS = ["jwk"] as const; +const ALLOWED_CRYPTO_BINDING_METHODS_JSON = ["jwk"] as const; +const ALLOWED_CRYPTO_BINDING_METHODS_MDOC = ["cose_key"] as const; const ALLOWED_PROOF_TYPES = ["jwt", "attestation"] as const; // Validation function for comma-separated lists @@ -212,6 +214,11 @@ export const ScopeForm = ({ clientScope, save }: ScopeFormProps) => { const isOid4vcEnabled = isFeatureEnabled(Feature.OpenId4VCI) && realmRepresentation.verifiableCredentialsEnabled; + const isMdocEnabled = isFeatureEnabled(Feature.OpenId4VCIMdoc); + const allowedCryptoBindingMethods: readonly string[] = + selectedFormat === VC_FORMAT_MSO_MDOC + ? ALLOWED_CRYPTO_BINDING_METHODS_MDOC + : ALLOWED_CRYPTO_BINDING_METHODS_JSON; const isNotSaml = selectedProtocol != "saml"; const computeRefreshIntervalDefault = () => { @@ -658,6 +665,14 @@ export const ScopeForm = ({ clientScope, save }: ScopeFormProps) => { key: VC_FORMAT_JWT_VC, value: `JWT VC (${VC_FORMAT_JWT_VC})`, }, + ...(isMdocEnabled + ? [ + { + key: VC_FORMAT_MSO_MDOC, + value: `ISO mDoc (${VC_FORMAT_MSO_MDOC})`, + }, + ] + : []), ]} /> { const entries = value.split(","); const invalid = entries.filter( (entry) => - !ALLOWED_CRYPTO_BINDING_METHODS.includes( - entry.trim() as (typeof ALLOWED_CRYPTO_BINDING_METHODS)[number], - ), + !allowedCryptoBindingMethods.includes(entry.trim()), ); if (invalid.length > 0) { return t( @@ -886,31 +899,32 @@ export const ScopeForm = ({ clientScope, save }: ScopeFormProps) => { }} /> )} + {(selectedFormat === VC_FORMAT_SD_JWT || + selectedFormat === VC_FORMAT_MSO_MDOC) && ( + ( + "attributes.vc.verifiable_credential_type", + )} + label={t("verifiableCredentialType")} + labelIcon={t("verifiableCredentialTypeHelp")} + /> + )} {selectedFormat === VC_FORMAT_SD_JWT && ( - <> - ( - "attributes.vc.verifiable_credential_type", - )} - label={t("verifiableCredentialType")} - labelIcon={t("verifiableCredentialTypeHelp")} - /> - ( - "attributes.vc.credential_build_config.sd_jwt.visible_claims", - )} - label={t("visibleClaims")} - labelIcon={t("visibleClaimsHelp")} - defaultValue={ - clientScope?.attributes?.[ - "vc.credential_build_config.sd_jwt.visible_claims" - ] ?? "id,iat,nbf,exp,jti" - } - rules={{ - validate: validateCommaSeparatedList, - }} - /> - + ( + "attributes.vc.credential_build_config.sd_jwt.visible_claims", + )} + label={t("visibleClaims")} + labelIcon={t("visibleClaimsHelp")} + defaultValue={ + clientScope?.attributes?.[ + "vc.credential_build_config.sd_jwt.visible_claims" + ] ?? "id,iat,nbf,exp,jti" + } + rules={{ + validate: validateCommaSeparatedList, + }} + /> )} )} diff --git a/js/apps/admin-ui/src/utils/useIsFeatureEnabled.ts b/js/apps/admin-ui/src/utils/useIsFeatureEnabled.ts index 9e8c20969f8f..f1e348d94905 100644 --- a/js/apps/admin-ui/src/utils/useIsFeatureEnabled.ts +++ b/js/apps/admin-ui/src/utils/useIsFeatureEnabled.ts @@ -15,6 +15,7 @@ export enum Feature { DeclarativeUI = "DECLARATIVE_UI", Organizations = "ORGANIZATION", OpenId4VCI = "OID4VC_VCI", + OpenId4VCIMdoc = "OID4VC_VCI_MDOC", QuickTheme = "QUICK_THEME", StandardTokenExchangeV2 = "TOKEN_EXCHANGE_STANDARD_V2", JWTAuthorizationGrant = "JWT_AUTHORIZATION_GRANT", diff --git a/pom.xml b/pom.xml index f3f8492a5613..0cef653e2bd7 100644 --- a/pom.xml +++ b/pom.xml @@ -1318,7 +1318,6 @@ rdf-urdna ${io.setl.rdf-urdna.version} - org.infinispan infinispan-core diff --git a/server-spi-private/src/main/java/org/keycloak/models/oid4vci/CredentialScopeModel.java b/server-spi-private/src/main/java/org/keycloak/models/oid4vci/CredentialScopeModel.java index ab45f954e8c5..b55f10803231 100644 --- a/server-spi-private/src/main/java/org/keycloak/models/oid4vci/CredentialScopeModel.java +++ b/server-spi-private/src/main/java/org/keycloak/models/oid4vci/CredentialScopeModel.java @@ -25,6 +25,7 @@ import java.util.Optional; import java.util.stream.Stream; +import org.keycloak.OID4VCConstants; import org.keycloak.VCFormat; import org.keycloak.models.ClientScopeModel; import org.keycloak.models.ProtocolMapperModel; @@ -42,7 +43,7 @@ */ public class CredentialScopeModel implements ClientScopeModel { - public static final String CRYPTOGRAPHIC_BINDING_METHODS_DEFAULT = "jwk"; + public static final String CRYPTOGRAPHIC_BINDING_METHODS_DEFAULT = OID4VCConstants.CRYPTOGRAPHIC_BINDING_METHOD_JWK; public static final String VC_BUILD_CONFIG_HASH_ALGORITHM_DEFAULT = "SHA-256"; public static final String VC_BUILD_CONFIG_SD_JWT_VISIBLE_CLAIMS_DEFAULT = "id,iat,nbf,exp,jti"; diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/OID4VCLoginProtocolFactory.java b/services/src/main/java/org/keycloak/protocol/oid4vc/OID4VCLoginProtocolFactory.java index 5cb6e9071112..26313055b77f 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/OID4VCLoginProtocolFactory.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/OID4VCLoginProtocolFactory.java @@ -18,6 +18,8 @@ package org.keycloak.protocol.oid4vc; import java.io.IOException; +import java.util.ArrayList; +import java.util.Arrays; import java.util.HashMap; import java.util.List; import java.util.Locale; @@ -28,6 +30,7 @@ import org.keycloak.Config; import org.keycloak.VCFormat; +import org.keycloak.common.Profile; import org.keycloak.constants.OID4VCIConstants; import org.keycloak.events.EventBuilder; import org.keycloak.models.ClientModel; @@ -45,6 +48,7 @@ import org.keycloak.protocol.LoginProtocol; import org.keycloak.protocol.LoginProtocolFactory; import org.keycloak.protocol.oid4vc.issuance.OID4VCIssuerEndpoint; +import org.keycloak.protocol.oid4vc.issuance.mappers.OID4VCMapper; import org.keycloak.protocol.oid4vc.issuance.mappers.OID4VCSubjectIdMapper; import org.keycloak.protocol.oid4vc.issuance.mappers.OID4VCUserAttributeMapper; import org.keycloak.protocol.oid4vc.model.DisplayObject; @@ -61,7 +65,9 @@ import org.jboss.logging.Logger; import static org.keycloak.OID4VCConstants.CLAIM_NAME_SUBJECT_ID; +import static org.keycloak.OID4VCConstants.CRYPTOGRAPHIC_BINDING_METHOD_COSE_KEY; import static org.keycloak.OID4VCConstants.OID4VCI_ENABLED_ATTRIBUTE_KEY; +import static org.keycloak.VCFormat.MSO_MDOC; import static org.keycloak.VCFormat.SD_JWT_VC; import static org.keycloak.constants.OID4VCIConstants.OID4VC_PROTOCOL; import static org.keycloak.models.ClientScopeModel.INCLUDE_IN_TOKEN_SCOPE; @@ -106,6 +112,12 @@ public class OID4VCLoginProtocolFactory implements LoginProtocolFactory, OID4VCE public static final String CREDENTIAL_TYPE_NATURAL_PERSON = "natural_person"; public static final String NATURAL_PERSON_SCOPE_CONSENT_TEXT = "${naturalPersonScopeConsentText}"; + /** + * mDoc namespace of the example natural person scope. Following the EUDI PID convention, the same value is used + * as the doctype of the issued credential. + */ + public static final String NATURAL_PERSON_MDOC_NAMESPACE = "com.example.natural_person"; + private final Map builtins = new HashMap<>(); @Override @@ -142,7 +154,13 @@ public Object createProtocolEndpoint(KeycloakSession keycloakSession, EventBuild public void createDefaultClientScopes(RealmModel newRealm, boolean addScopesToExistingClients) { LOGGER.debugf("Create default scopes for realm %s", newRealm.getName()); - for (String format : VCFormat.SUPPORTED_FORMATS) { + List formats = new ArrayList<>(Arrays.asList(VCFormat.SUPPORTED_FORMATS)); + if (Profile.isFeatureEnabled(Profile.Feature.OID4VC_MDOC)) { + formats.add(MSO_MDOC); + } + + for (String format : formats) { + boolean isMdoc = MSO_MDOC.equals(format); String scopeName = CREDENTIAL_TYPE_NATURAL_PERSON + VCFormat.getScopeSuffix(format); ClientScopeModel clientScope = KeycloakModelUtils.getClientScopeByName(newRealm, scopeName); if (clientScope == null) { @@ -152,16 +170,19 @@ public void createDefaultClientScopes(RealmModel newRealm, boolean addScopesToEx clientScope.setDisplayOnConsentScreen(true); clientScope.setConsentScreenText(NATURAL_PERSON_SCOPE_CONSENT_TEXT); clientScope.setProtocol(OID4VC_PROTOCOL); - clientScope.addProtocolMapper(builtins.get(SUBJECT_ID_MAPPER)); - clientScope.addProtocolMapper(builtins.get(EMAIL_MAPPER)); - clientScope.addProtocolMapper(builtins.get(FIRST_NAME_MAPPER)); - clientScope.addProtocolMapper(builtins.get(LAST_NAME_MAPPER)); + for (String mapperName : List.of(SUBJECT_ID_MAPPER, EMAIL_MAPPER, FIRST_NAME_MAPPER, LAST_NAME_MAPPER)) { + ProtocolMapperModel mapper = builtins.get(mapperName); + clientScope.addProtocolMapper(isMdoc ? withMdocNamespace(mapper) : mapper); + } ClientScopeRepresentation clientScopeRep = ModelToRepresentation.toRepresentation(clientScope); clientScopeRep.setAttributes(new HashMap<>(Map.of( VC_BINDING_REQUIRED, "true", VC_BINDING_REQUIRED_PROOF_TYPES, ProofType.JWT + "," + ProofType.ATTESTATION ))); + if (isMdoc) { + clientScopeRep.getAttributes().put(VCT, NATURAL_PERSON_MDOC_NAMESPACE); + } try { DisplayObject display = new DisplayObject(); @@ -179,6 +200,21 @@ public void createDefaultClientScopes(RealmModel newRealm, boolean addScopesToEx } } + /** + * The builtin mapper models are shared between all default scopes, so the mdoc variant gets a copy with the + * example namespace instead of a mutated shared instance. + */ + private static ProtocolMapperModel withMdocNamespace(ProtocolMapperModel mapper) { + ProtocolMapperModel copy = new ProtocolMapperModel(); + copy.setName(mapper.getName()); + copy.setProtocol(mapper.getProtocol()); + copy.setProtocolMapper(mapper.getProtocolMapper()); + Map config = new HashMap<>(mapper.getConfig()); + config.put(OID4VCMapper.MDOC_NAMESPACE, NATURAL_PERSON_MDOC_NAMESPACE); + copy.setConfig(config); + return copy; + } + @Override public void setupClientDefaults(ClientRepresentation rep, ClientModel newClient) { //no-op @@ -192,10 +228,15 @@ public void addClientScopeDefaults(ClientScopeRepresentation clientScope) { clientScope.setAttributes(new HashMap<>()); } - clientScope.getAttributes().computeIfAbsent(VC_FORMAT, k -> VCFormat.getFromScope(scopeName)); + clientScope.getAttributes().computeIfAbsent(VC_FORMAT, k -> getFormatFromScope(scopeName)); String format = clientScope.getAttributes().get(VC_FORMAT); int idx = scopeName.lastIndexOf(VCFormat.getScopeSuffix(format)); + if (idx < 0 && MSO_MDOC.equals(VCFormat.getFromScope(scopeName))) { + // the mdoc format of this scope was downgraded because the feature is disabled, still strip the mdoc + // suffix from the name when deriving the credential type + idx = scopeName.lastIndexOf(VCFormat.getScopeSuffix(MSO_MDOC)); + } String credentialType = idx > 0 ? scopeName.substring(0, idx) : scopeName; // Note, there is no sensible default for the Issuer's DID unless we generate a did:key:* from the signing key @@ -208,7 +249,7 @@ public void addClientScopeDefaults(ClientScopeRepresentation clientScope) { clientScope.getAttributes().putIfAbsent(VC_SUPPORTED_TYPES, credentialType); clientScope.getAttributes().putIfAbsent(VC_CONTEXTS, credentialType); clientScope.getAttributes().putIfAbsent(VCT, credentialType); - clientScope.getAttributes().putIfAbsent(VC_CRYPTOGRAPHIC_BINDING_METHODS, CRYPTOGRAPHIC_BINDING_METHODS_DEFAULT); + clientScope.getAttributes().putIfAbsent(VC_CRYPTOGRAPHIC_BINDING_METHODS, getDefaultCryptographicBindingMethod(format)); clientScope.getAttributes().putIfAbsent(VC_BUILD_CONFIG_HASH_ALGORITHM, VC_BUILD_CONFIG_HASH_ALGORITHM_DEFAULT); clientScope.getAttributes().putIfAbsent(VC_BUILD_CONFIG_TOKEN_JWS_TYPE, getDefaultTokenJwsTypeForFormat(format)); clientScope.getAttributes().putIfAbsent(VC_EXPIRY_IN_SECONDS, String.valueOf(VC_EXPIRY_IN_SECONDS_DEFAULT)); @@ -219,6 +260,18 @@ public void addClientScopeDefaults(ClientScopeRepresentation clientScope) { } } + private static String getFormatFromScope(String scopeName) { + String format = VCFormat.getFromScope(scopeName); + if (MSO_MDOC.equals(format) && !Profile.isFeatureEnabled(Profile.Feature.OID4VC_MDOC)) { + return SD_JWT_VC; + } + return format; + } + + private static String getDefaultCryptographicBindingMethod(String format) { + return MSO_MDOC.equals(format) ? CRYPTOGRAPHIC_BINDING_METHOD_COSE_KEY : CRYPTOGRAPHIC_BINDING_METHODS_DEFAULT; + } + @Override public void validateClientScope(KeycloakSession session, ClientScopeRepresentation clientScope) throws ErrorResponseException { LoginProtocolFactory.super.validateClientScope(session, clientScope); diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/OID4VCMdocEnvironmentProviderFactory.java b/services/src/main/java/org/keycloak/protocol/oid4vc/OID4VCMdocEnvironmentProviderFactory.java new file mode 100644 index 000000000000..a0a0ebabe22a --- /dev/null +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/OID4VCMdocEnvironmentProviderFactory.java @@ -0,0 +1,33 @@ +/* + * Copyright 2026 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.keycloak.protocol.oid4vc; + +import org.keycloak.Config; +import org.keycloak.common.Profile; + +/** + * Interface for OID4VC mDoc provider factories that must be guarded by the dedicated feature. + */ +public interface OID4VCMdocEnvironmentProviderFactory extends OID4VCEnvironmentProviderFactory { + + @Override + default boolean isSupported(Config.Scope config) { + return Profile.isFeatureEnabled(Profile.Feature.OID4VC_VCI) + && Profile.isFeatureEnabled(Profile.Feature.OID4VC_MDOC); + } +} diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/OID4VCIssuerEndpoint.java b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/OID4VCIssuerEndpoint.java index 4036f5e306a4..35526f14ce82 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/OID4VCIssuerEndpoint.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/OID4VCIssuerEndpoint.java @@ -77,8 +77,10 @@ import org.keycloak.models.UserSessionModel; import org.keycloak.models.oid4vci.CredentialScopeModel; import org.keycloak.protocol.ProtocolMapper; +import org.keycloak.protocol.ProtocolMapperConfigException; import org.keycloak.protocol.oid4vc.issuance.credentialbuilder.CredentialBody; import org.keycloak.protocol.oid4vc.issuance.credentialbuilder.CredentialBuilder; +import org.keycloak.protocol.oid4vc.issuance.credentialbuilder.CredentialBuilderException; import org.keycloak.protocol.oid4vc.issuance.credentialbuilder.CredentialBuilderFactory; import org.keycloak.protocol.oid4vc.issuance.credentialoffer.CredentialOfferProvider; import org.keycloak.protocol.oid4vc.issuance.credentialoffer.CredentialOfferState; @@ -1764,6 +1766,7 @@ private VCIssuanceContext prepareCredential(AuthenticationManager.AuthResult aut return null; }) .filter(Objects::nonNull) + .filter(mapper -> mapper.supportsCredentialFormat(credentialScopeModel.getFormat())) .toList(); VCIssuanceContext vcIssuanceContext = getVCToSign(protocolMappers, credentialConfig, authResult, authDetail, credentialRequestVO, credentialScopeModel, eventBuilder); @@ -1862,26 +1865,59 @@ private VCIssuanceContext getVCToSign(List protocolMappers, Suppor .setType(credentialScopeModel.getSupportedCredentialTypes()); Map subjectClaims = new HashMap<>(); - protocolMappers.forEach(mapper -> mapper.setClaim(subjectClaims, authResult.session())); - Map subjectClaimsWithMetadataPrefix = new HashMap<>(); - protocolMappers - .forEach(mapper -> mapper.setClaimWithMetadataPrefix(subjectClaims, subjectClaimsWithMetadataPrefix)); + + if (VCFormat.MSO_MDOC.equals(credentialConfig.getFormat())) { + // A scope switched to mso_mdoc after its claim mappers were created is not revalidated by the admin API, + // so guard here against a claim mapper without a namespace that would otherwise emit a flat claim path. + for (OID4VCMapper mapper : protocolMappers) { + try { + mapper.validateMdocNamespace(credentialConfig.getFormat()); + } catch (ProtocolMapperConfigException e) { + throw badRequestException(ErrorType.INVALID_CREDENTIAL_REQUEST, e.getMessage(), eventBuilder); + } + } + + // mDoc data element identifiers may repeat across namespaces while sharing one raw claim key, so each + // mapper writes into its own scratch map. A shared map would let a mapper without a value pick up the + // claim of a previous mapper with the same name and copy it into the wrong namespace. + protocolMappers.forEach(mapper -> { + Map mapperClaims = new HashMap<>(); + mapper.setClaim(mapperClaims, authResult.session()); + mapper.setClaimWithMetadataPrefix(mapperClaims, subjectClaimsWithMetadataPrefix); + }); + } else { + protocolMappers.forEach(mapper -> mapper.setClaim(subjectClaims, authResult.session())); + protocolMappers + .forEach(mapper -> mapper.setClaimWithMetadataPrefix(subjectClaims, subjectClaimsWithMetadataPrefix)); + } // Validate that requested claims from authorization_details are present String credentialConfigId = credentialConfig.getId(); validateRequestedClaimsArePresent(subjectClaimsWithMetadataPrefix, credentialConfig, authResult.user(), authDetail, credentialConfigId, eventBuilder); + // OID4VCI 1.0 Appendix C.2 gives ISO mdoc paths namespace/data-element semantics. The metadata-prefixed + // claim map already has that namespace -> data element layout; JSON-based formats keep credentialSubject. + Map credentialSubjectClaims = VCFormat.MSO_MDOC.equals(credentialConfig.getFormat()) + ? subjectClaimsWithMetadataPrefix + : subjectClaims; + // Include all available claims - subjectClaims.forEach((key, value) -> vc.getCredentialSubject().setClaims(key, value)); + credentialSubjectClaims.forEach((key, value) -> vc.getCredentialSubject().setClaims(key, value)); protocolMappers.forEach(mapper -> mapper.setClaim(vc, authResult.session())); LOGGER.debugf("The credential to sign is: %s", vc); // Build format-specific credential - CredentialBody credentialBody = this.findCredentialBuilder(credentialConfig) - .buildCredentialBody(vc, credentialConfig.getCredentialBuildConfig()); + CredentialBody credentialBody; + try { + credentialBody = this.findCredentialBuilder(credentialConfig) + .buildCredentialBody(vc, credentialConfig.getCredentialBuildConfig()); + } catch (CredentialBuilderException e) { + throw badRequestException(ErrorType.INVALID_CREDENTIAL_REQUEST, + "Could not build credential: " + e.getMessage(), eventBuilder); + } return new VCIssuanceContext() .setAuthResult(authResult) @@ -1928,6 +1964,10 @@ private void validateProofs(VCIssuanceContext vcIssuanceContext, String proofTyp default: throw new BadRequestException("Could not validate provided proof", e); } + } catch (CredentialBuilderException e) { + // A proof key that cannot be bound to the credential, such as an mDoc COSE_Key conversion that rejects + // the key curve, is an invalid proof rather than a server error. + throw new ErrorResponseException(INVALID_PROOF.getValue(), e.getMessage(), Response.Status.BAD_REQUEST); } } 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 97aeb42ac3b7..30167b179c42 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 @@ -32,6 +32,8 @@ import jakarta.ws.rs.core.UriBuilder; import jakarta.ws.rs.core.UriInfo; +import org.keycloak.VCFormat; +import org.keycloak.common.Profile; import org.keycloak.common.util.Time; import org.keycloak.constants.OID4VCIConstants; import org.keycloak.crypto.CryptoUtils; @@ -450,6 +452,8 @@ public static Map getSupportedCredenti keycloakSession.clientScopes() .getClientScopesByProtocol(realm, OID4VCIConstants.OID4VC_PROTOCOL) .map(CredentialScopeModel::new) + .filter(credentialScope -> Profile.isFeatureEnabled(Profile.Feature.OID4VC_MDOC) + || !VCFormat.MSO_MDOC.equals(credentialScope.getFormat())) .map(credentialScope -> { SupportedCredentialConfiguration config = SupportedCredentialConfiguration.parse(keycloakSession, credentialScope, diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/credentialbuilder/MdocCredentialBody.java b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/credentialbuilder/MdocCredentialBody.java new file mode 100644 index 000000000000..ca7b65450f8a --- /dev/null +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/credentialbuilder/MdocCredentialBody.java @@ -0,0 +1,76 @@ +/* + * Copyright 2026 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.keycloak.protocol.oid4vc.issuance.credentialbuilder; + +import java.util.List; +import java.util.Map; + +import org.keycloak.crypto.SignatureSignerContext; +import org.keycloak.jose.jwk.JWK; +import org.keycloak.mdoc.MdocCredential; +import org.keycloak.mdoc.MdocException; +import org.keycloak.mdoc.MdocIssuerSignedDocument; +import org.keycloak.mdoc.MdocNamespacedClaims; +import org.keycloak.mdoc.MdocValidityInfo; +import org.keycloak.protocol.oid4vc.issuance.signing.CredentialSignerException; + +public class MdocCredentialBody implements CredentialBody { + + private final MdocCredential credential; + + public MdocCredentialBody(String docType, Map claims, MdocValidityInfo validityInfo) { + try { + this.credential = new MdocCredential(docType, claims, validityInfo); + } catch (MdocException e) { + throw new CredentialBuilderException(e.getMessage(), e); + } + } + + @Override + public void addKeyBinding(JWK jwk) throws CredentialBuilderException { + try { + credential.addKeyBinding(jwk); + } catch (MdocException e) { + throw new CredentialBuilderException(e.getMessage(), e); + } + } + + public String getDocType() { + return credential.getDocType(); + } + + public Map getClaims() { + return credential.getClaims(); + } + + public List getNamespacedClaims() { + return credential.getNamespacedClaims(); + } + + public MdocValidityInfo getValidityInfo() { + return credential.getValidityInfo(); + } + + public MdocIssuerSignedDocument signAsIssuerSignedDocument(SignatureSignerContext signerContext) { + try { + return credential.signAsIssuerSignedDocument(signerContext); + } catch (MdocException e) { + throw new CredentialSignerException("Could not sign mDoc credential", e); + } + } +} diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/credentialbuilder/MdocCredentialBuilder.java b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/credentialbuilder/MdocCredentialBuilder.java new file mode 100644 index 000000000000..1ab84f26b588 --- /dev/null +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/credentialbuilder/MdocCredentialBuilder.java @@ -0,0 +1,69 @@ +/* + * Copyright 2026 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.keycloak.protocol.oid4vc.issuance.credentialbuilder; + +import java.time.Instant; +import java.util.LinkedHashMap; +import java.util.Map; + +import org.keycloak.VCFormat; +import org.keycloak.mdoc.MdocValidityInfo; +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.utils.StringUtil; + +public class MdocCredentialBuilder implements CredentialBuilder { + + @Override + public String getSupportedFormat() { + return VCFormat.MSO_MDOC; + } + + @Override + public MdocCredentialBody buildCredentialBody(VerifiableCredential verifiableCredential, + CredentialBuildConfig credentialBuildConfig) throws CredentialBuilderException { + // OID4VCI 1.0 Appendix A.2.2 calls the mDoc credential type identifier "doctype". Internally it shares + // CredentialBuildConfig.credentialType with SD-JWT VC's "vct" because both fields identify the credential type. + String docType = credentialBuildConfig.getCredentialType(); + if (StringUtil.isBlank(docType)) { + throw new CredentialBuilderException("mDoc issuance requires vc.verifiable_credential_type"); + } + + Instant issuanceInstant = verifiableCredential.getIssuanceDate(); + if (issuanceInstant == null) { + throw new CredentialBuilderException("mDoc issuance requires issuanceDate"); + } + + Instant expirationInstant = verifiableCredential.getExpirationDate(); + if (expirationInstant == null) { + throw new CredentialBuilderException("mDoc issuance requires expirationDate"); + } + + MdocValidityInfo validityInfo = MdocValidityInfo.issuedAt(issuanceInstant, expirationInstant); + + Map claims = new LinkedHashMap<>(verifiableCredential.getCredentialSubject().getClaims()); + return new MdocCredentialBody(docType, claims, validityInfo); + } + + @Override + public void contributeToMetadata(SupportedCredentialConfiguration credentialConfig, CredentialScopeModel credentialScope) { + credentialConfig.setDocType(CredentialBuildConfig.resolveCredentialType(credentialScope)); + } +} diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/credentialbuilder/MdocCredentialBuilderFactory.java b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/credentialbuilder/MdocCredentialBuilderFactory.java new file mode 100644 index 000000000000..8375b622bf86 --- /dev/null +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/credentialbuilder/MdocCredentialBuilderFactory.java @@ -0,0 +1,52 @@ +/* + * Copyright 2026 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.keycloak.protocol.oid4vc.issuance.credentialbuilder; + +import java.util.ArrayList; +import java.util.List; + +import org.keycloak.VCFormat; +import org.keycloak.component.ComponentModel; +import org.keycloak.models.KeycloakSession; +import org.keycloak.protocol.oid4vc.OID4VCMdocEnvironmentProviderFactory; +import org.keycloak.provider.ProviderConfigProperty; + +public class MdocCredentialBuilderFactory implements CredentialBuilderFactory, OID4VCMdocEnvironmentProviderFactory { + + protected static final List configProperties = new ArrayList<>(); + + @Override + public String getSupportedFormat() { + return VCFormat.MSO_MDOC; + } + + @Override + public String getHelpText() { + return "Builds verifiable credentials on the mso_mdoc format defined by OpenID4VCI Appendix A.2."; + } + + @Override + public List getConfigProperties() { + return configProperties; + } + + @Override + public CredentialBuilder create(KeycloakSession session, ComponentModel model) { + return new MdocCredentialBuilder(); + } +} diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/keybinding/JwtProofValidator.java b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/keybinding/JwtProofValidator.java index 34c1b8e240db..51e46a7d239e 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/keybinding/JwtProofValidator.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/keybinding/JwtProofValidator.java @@ -28,6 +28,7 @@ import java.util.Optional; import java.util.Set; +import org.keycloak.VCFormat; import org.keycloak.common.VerificationException; import org.keycloak.common.util.Time; import org.keycloak.crypto.CryptoUtils; @@ -59,6 +60,9 @@ import com.fasterxml.jackson.core.type.TypeReference; import org.jboss.logging.Logger; +import static org.keycloak.OID4VCConstants.CRYPTOGRAPHIC_BINDING_METHOD_COSE_KEY; +import static org.keycloak.OID4VCConstants.CRYPTOGRAPHIC_BINDING_METHOD_JWK; + /** * Validates the conformance and authenticity of presented JWT proofs. * @@ -69,7 +73,6 @@ public class JwtProofValidator extends AbstractProofValidator { private static final Logger LOGGER = Logger.getLogger(JwtProofValidator.class); public static final String PROOF_JWT_TYP = "openid4vci-proof+jwt"; - private static final String CRYPTOGRAPHIC_BINDING_METHOD_JWK = "jwk"; private static final String KEY_ATTESTATION_CLAIM = "key_attestation"; private static final int PROOF_MAX_AGE_SECONDS = 30; @@ -216,11 +219,21 @@ private void checkCryptographicKeyBinding(VCIssuanceContext vcIssuanceContext) { return; } - // If binding is required, this implementation currently only supports the "jwk" method. - if (!vcIssuanceContext.getCredentialConfig().getCryptographicBindingMethodsSupported() - .contains(CRYPTOGRAPHIC_BINDING_METHOD_JWK)) { - throw new IllegalStateException("This SD-JWT implementation only supports jwk as cryptographic binding method"); + String expectedBindingMethod = getExpectedCryptographicBindingMethod(vcIssuanceContext.getCredentialConfig()); + if (!vcIssuanceContext.getCredentialConfig().getCryptographicBindingMethodsSupported().contains(expectedBindingMethod)) { + throw new IllegalStateException("JWT proof validation only supports " + + expectedBindingMethod + " cryptographic binding for credential format " + + vcIssuanceContext.getCredentialConfig().getFormat()); + } + } + + private static String getExpectedCryptographicBindingMethod(SupportedCredentialConfiguration credentialConfiguration) { + // OID4VCI 1.0 Appendix F.1 defines JWT proof key transport as JOSE (jwk, kid, or x5c). This metadata value + // is different: Section 12.2.4 describes the representation used inside the issued credential. + if (VCFormat.MSO_MDOC.equals(credentialConfiguration.getFormat())) { + return CRYPTOGRAPHIC_BINDING_METHOD_COSE_KEY; } + return CRYPTOGRAPHIC_BINDING_METHOD_JWK; } private Optional> getProofFromContext(VCIssuanceContext vcIssuanceContext) throws VCIssuerException { @@ -250,7 +263,10 @@ private Optional> getProofFromContext(VCIssuanceContext vcIssuanceC Map supportedProofTypes = proofTypesSupported.getSupportedProofTypes(); Optional.ofNullable(supportedProofTypes.get(ProofType.JWT)) - .orElseThrow(() -> new VCIssuerException(ErrorType.INVALID_PROOF, "SD-JWT supports only jwt proof type.")); + .orElseThrow(() -> new VCIssuerException(ErrorType.INVALID_PROOF, String.format( + "%s credentials do not support jwt proof type.", + config.getFormat() + ))); // At this point, JWT is an explicitly supported proof type and must be enforced. if (proofs == null || proofs.getJwt() == null || proofs.getJwt().isEmpty()) { diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/mappers/OID4VCContextMapper.java b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/mappers/OID4VCContextMapper.java index 8ae215be6758..444a341e9b38 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/mappers/OID4VCContextMapper.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/mappers/OID4VCContextMapper.java @@ -31,6 +31,8 @@ import org.keycloak.protocol.oid4vc.model.VerifiableCredential; import org.keycloak.provider.ProviderConfigProperty; +import static org.keycloak.VCFormat.MSO_MDOC; + /** * Allows to add the context to the credential subject * @@ -69,6 +71,12 @@ public boolean includeInMetadata() { .orElse(false); } + @Override + public boolean supportsCredentialFormat(String credentialFormat) { + // mDoc is not JSON-LD and has no VC-level @context member. + return !MSO_MDOC.equals(credentialFormat); + } + @Override public List getMetadataAttributePath() { return List.of(TYPE_KEY); diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/mappers/OID4VCGeneratedIdMapper.java b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/mappers/OID4VCGeneratedIdMapper.java index cc86aed1c393..90962faefba5 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/mappers/OID4VCGeneratedIdMapper.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/mappers/OID4VCGeneratedIdMapper.java @@ -30,8 +30,6 @@ import org.keycloak.protocol.oid4vc.model.VerifiableCredential; import org.keycloak.provider.ProviderConfigProperty; -import org.apache.commons.collections4.ListUtils; - /** * Adds a generated ID to the credential (as a configurable property). * @@ -71,10 +69,12 @@ public boolean includeInMetadata() { @Override public List getMetadataAttributePath() { - String property = Optional.ofNullable(mapperModel.getConfig()) - .map(config -> config.get(CLAIM_NAME)) - .orElse(SUBJECT_PROPERTY_CONFIG_KEY_DEFAULT); - return ListUtils.union(getAttributePrefix(), List.of(property)); + return getMetadataAttributePath(getGeneratedIdClaimName()); + } + + @Override + protected List getClaimLookupPath() { + return getClaimLookupPath(getGeneratedIdClaimName()); } public void setClaim(VerifiableCredential verifiableCredential, @@ -85,14 +85,16 @@ public void setClaim(VerifiableCredential verifiableCredential, @Override public void setClaim(Map claims, UserSessionModel userSessionModel) { // Assign a generated ID - List attributePath = getMetadataAttributePath(); - if (attributePath.isEmpty()) { - return; - } - String propertyName = attributePath.get(attributePath.size() - 1); + String propertyName = getGeneratedIdClaimName(); claims.put(propertyName, String.format("urn:uuid:%s", UUID.randomUUID())); } + private String getGeneratedIdClaimName() { + return Optional.ofNullable(mapperModel.getConfig()) + .map(config -> config.get(CLAIM_NAME)) + .orElse(SUBJECT_PROPERTY_CONFIG_KEY_DEFAULT); + } + @Override public String getDisplayType() { return "Generated ID Mapper"; diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/mappers/OID4VCIssuedAtTimeClaimMapper.java b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/mappers/OID4VCIssuedAtTimeClaimMapper.java index 60b6cfafd839..ae9bffe0ec34 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/mappers/OID4VCIssuedAtTimeClaimMapper.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/mappers/OID4VCIssuedAtTimeClaimMapper.java @@ -36,6 +36,8 @@ import org.jboss.logging.Logger; +import static org.keycloak.VCFormat.MSO_MDOC; + /** * Map issuance date to the credential, under the default claim name "iat" *

@@ -105,11 +107,16 @@ public boolean includeInMetadata() { .orElse(false); } + @Override + public boolean supportsCredentialFormat(String credentialFormat) { + // mDoc carries issuance timing in the MSO validityInfo instead of a VC-level iat claim. + return !MSO_MDOC.equals(credentialFormat); + } + public void setClaim(VerifiableCredential verifiableCredential, UserSessionModel userSessionModel) { // Set the value - List attributePath = getMetadataAttributePath(); - String propertyName = attributePath.get(attributePath.size() - 1); + String propertyName = getClaimName("iat"); if (propertyName == null) { log.errorf("Invalid configuration: missing config-property '%s' for mapper '%s' of type '%s'. Mapper is ignored.", CLAIM_NAME, diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/mappers/OID4VCMapper.java b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/mappers/OID4VCMapper.java index 85c574c323ac..83a34cfa7ba7 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/mappers/OID4VCMapper.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/mappers/OID4VCMapper.java @@ -26,20 +26,27 @@ import java.util.stream.Stream; import org.keycloak.Config; +import org.keycloak.models.ClientScopeModel; +import org.keycloak.models.KeycloakSession; import org.keycloak.models.KeycloakSessionFactory; +import org.keycloak.models.ProtocolMapperContainerModel; import org.keycloak.models.ProtocolMapperModel; +import org.keycloak.models.RealmModel; import org.keycloak.models.UserSessionModel; import org.keycloak.models.oid4vci.CredentialScopeModel; import org.keycloak.models.oid4vci.Oid4vcProtocolMapperModel; import org.keycloak.protocol.ProtocolMapper; +import org.keycloak.protocol.ProtocolMapperConfigException; import org.keycloak.protocol.oid4vc.OID4VCEnvironmentProviderFactory; import org.keycloak.protocol.oid4vc.OID4VCLoginProtocolFactory; import org.keycloak.protocol.oid4vc.model.VerifiableCredential; import org.keycloak.provider.ProviderConfigProperty; +import org.keycloak.utils.JsonUtils; import org.apache.commons.collections4.ListUtils; import static org.keycloak.OID4VCConstants.CREDENTIAL_SUBJECT; +import static org.keycloak.VCFormat.MSO_MDOC; import static org.keycloak.VCFormat.SD_JWT_VC; /** @@ -50,8 +57,10 @@ public abstract class OID4VCMapper implements ProtocolMapper, OID4VCEnvironmentProviderFactory { public static final String CLAIM_NAME = "claim.name"; + public static final String MDOC_NAMESPACE = "mdoc.namespace"; public static final String USER_ATTRIBUTE_KEY = "userAttribute"; private static final List OID4VC_CONFIG_PROPERTIES = new ArrayList<>(); + private static final List MDOC_CONFIG_PROPERTIES = new ArrayList<>(); static { ProviderConfigProperty property; @@ -75,6 +84,14 @@ public abstract class OID4VCMapper implements ProtocolMapper, OID4VCEnvironmentP property.setType(ProviderConfigProperty.CLAIM_DISPLAY_TYPE); property.setDefaultValue(null); OID4VC_CONFIG_PROPERTIES.add(property); + + property = new ProviderConfigProperty(); + property.setName(MDOC_NAMESPACE); + property.setLabel("mDoc Namespace"); + property.setHelpText("Namespace for mso_mdoc claims. Used only when issuing mso_mdoc credentials."); + property.setType(ProviderConfigProperty.STRING_TYPE); + property.setDefaultValue(null); + MDOC_CONFIG_PROPERTIES.add(property); } protected ProtocolMapperModel mapperModel; @@ -84,7 +101,9 @@ public abstract class OID4VCMapper implements ProtocolMapper, OID4VCEnvironmentP @Override public List getConfigProperties() { - return Stream.concat(OID4VC_CONFIG_PROPERTIES.stream(), getIndividualConfigProperties().stream()).toList(); + Stream configProperties = OID4VC_CONFIG_PROPERTIES.stream(); + configProperties = Stream.concat(configProperties, MDOC_CONFIG_PROPERTIES.stream()); + return Stream.concat(configProperties, getIndividualConfigProperties().stream()).toList(); } public OID4VCMapper setMapperModel(ProtocolMapperModel mapperModel, String format) { @@ -93,6 +112,41 @@ public OID4VCMapper setMapperModel(ProtocolMapperModel mapperModel, String forma return this; } + @Override + public void validateConfig(KeycloakSession session, RealmModel realm, ProtocolMapperContainerModel client, + ProtocolMapperModel mapperModel) throws ProtocolMapperConfigException { + // OID4VC mappers are configured on the credential client scope, which carries the credential format. + if (client instanceof ClientScopeModel clientScope) { + validateMdocNamespace(new CredentialScopeModel(clientScope).getFormat(), mapperModel); + } + } + + /** + * Revalidates this mapper's namespace against the given format. Switching a client scope to mso_mdoc through the + * format selector does not run {@link #validateConfig}, so issuance uses this to reject an already stored claim + * mapper that is missing a namespace before it produces broken claims. + */ + public void validateMdocNamespace(String credentialFormat) throws ProtocolMapperConfigException { + validateMdocNamespace(credentialFormat, mapperModel); + } + + /** + * mDoc claim paths are addressed by namespace, so a namespaced claim mapper without a configured namespace would + * produce a non-namespaced path that fails at signing time. Reject such configuration up front. + */ + void validateMdocNamespace(String credentialFormat, ProtocolMapperModel mapperModel) throws ProtocolMapperConfigException { + if (!MSO_MDOC.equals(credentialFormat) || !supportsCredentialFormat(credentialFormat)) { + return; + } + + Map config = mapperModel.getConfig(); + String namespace = config == null ? null : config.get(MDOC_NAMESPACE); + if (namespace == null || namespace.isBlank()) { + throw new ProtocolMapperConfigException( + String.format("mso_mdoc credential mappers require a non-empty '%s' configuration.", MDOC_NAMESPACE)); + } + } + /** * some specific claims should not be added into the metadata. Examples are jti, sub, iss etc. Since we have the * possibility to add these credentials with specific claims we should also be able to exclude these specific @@ -105,27 +159,75 @@ public boolean includeInMetadata() { } /** - * must return ordered list of attribute-names as they are added into the credential. This is required for the - * metadata endpoint to add the appropriate path-attributes into the claim's description. - * - * @return the attribute path that is being mapped into the credential + * Some mappers target format-specific container fields instead of subject/data-element claims. Callers use this + * hook for both metadata and issuance so unsupported mappers are not advertised or applied for a credential format. + */ + public boolean supportsCredentialFormat(String credentialFormat) { + return true; + } + + /** + * Returns the externally visible claim path used in credential metadata and authorization_details validation. + * JSON credentials use their normal credentialSubject/top-level paths; mDoc prepends the configured namespace + * because OID4VCI mDoc paths address namespace -> data element -> optional nested value. */ public List getMetadataAttributePath() { - final String claimName = mapperModel.getConfig().get(CLAIM_NAME); - final String userAttributeName = mapperModel.getConfig().get(USER_ATTRIBUTE_KEY); - String attributeName = Optional.ofNullable(claimName) - .orElse(userAttributeName); + final String attributeName = getClaimName(); + return getMetadataAttributePath(attributeName); + } + protected List getMetadataAttributePath(String attributeName) { if (attributeName == null) { return Collections.emptyList(); } - - return ListUtils.union(getAttributePrefix(), List.of(attributeName)); + + List attributePath = MSO_MDOC.equals(format) ? JsonUtils.splitClaimPath(attributeName) : List.of(attributeName); + return prefixMetadataAttributePath(attributePath); + } + + protected List prefixMetadataAttributePath(List attributePath) { + if (attributePath.isEmpty()) { + return Collections.emptyList(); + } + + return ListUtils.union(getAttributePrefix(), attributePath); + } + + /** + * Returns the raw claim lookup path in the intermediate map populated by {@link #setClaim(Map, UserSessionModel)}. + * This is intentionally separate from {@link #getMetadataAttributePath()}: mDoc metadata paths add a namespace + * that is not present in the raw mapper output, and simple mappers may write a dotted claim name as one literal key. + */ + protected List getClaimLookupPath() { + return getClaimLookupPath(getClaimName()); + } + + protected List getClaimLookupPath(String claimName) { + if (claimName == null) { + return Collections.emptyList(); + } + return List.of(claimName); + } + + protected String getClaimName() { + return mapperModel.getConfig().get(CLAIM_NAME); + } + + protected String getClaimName(String defaultClaimName) { + return Optional.ofNullable(getClaimName()).orElse(defaultClaimName); } protected List getAttributePrefix() { if (SD_JWT_VC.equals(format)) { return Collections.emptyList(); + } else if (MSO_MDOC.equals(format)) { + String namespace = mapperModel.getConfig().get(MDOC_NAMESPACE); + if (namespace == null || namespace.isBlank()) { + return Collections.emptyList(); + } + // OID4VCI 1.0 Appendix C.2: mDoc claim paths start with namespace and data element identifier, + // followed by optional path components inside the selected data element value. + return List.of(namespace); } else { return List.of(CREDENTIAL_SUBJECT); } @@ -167,7 +269,42 @@ public abstract void setClaim(Map claims, UserSessionModel userSessionModel); /** - * Creates new map "claimsWithPrefix" with the resolved claims including path prefix + * Reads a mapper-produced claim from the intermediate, un-prefixed claim map. + * + * Some mappers write {@code address.street} as one literal key, while the user-attribute mapper writes it as + * {@code {"address": {"street": "Main Street"}}}. This method follows the mapper-specific raw lookup path and + * returns only the claim value; the caller then writes that value to the externally visible path, for example + * {@code ["credentialSubject", "address", "street"]} for JWT VC or {@code ["namespace", "address", "street"]} + * for mDoc. + */ + private Object getNestedClaimValue(Map claims, List claimPath) { + if (claimPath.isEmpty()) { + // No raw lookup path means the mapper produces no subject claim (e.g. type/context mappers), so there + // is no value to copy into the prefixed map. Returning the whole claims map here would be incorrect. + return null; + } + + Object current = claims; + for (String pathElement : claimPath) { + if (!(current instanceof Map currentMap)) { + return null; + } + + @SuppressWarnings("unchecked") + Map typedMap = (Map) currentMap; + current = typedMap.get(pathElement); + if (current == null) { + return null; + } + } + + return current; + } + + /** + * Copies the mapper claim value into {@code claimsWithPrefix} using the externally visible credential path. This + * is used for authorization_details validation and for mDoc issuance, where the credential subject is + * namespace-shaped even though individual mappers write un-namespaced raw claims. * * @param claimsOrig Map with the original claims, which were returned by {@link #setClaim(Map, UserSessionModel)} . This method usually just reads from this map * @param claimsWithPrefix Map with the claims including path prefix. This method might write to this map @@ -177,9 +314,9 @@ public void setClaimWithMetadataPrefix(Map claimsOrig, Map current = claimsWithPrefix; for (int i = 0; i < attributePath.size(); i++) { @@ -187,8 +324,8 @@ public void setClaimWithMetadataPrefix(Map claimsOrig, Map obj = (Map) current.get(currentSnippetName); if (obj == null) { - obj = new HashMap<>(); - current.put(currentSnippetName, obj); + obj = new HashMap<>(); + current.put(currentSnippetName, obj); } current = obj; } else { diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/mappers/OID4VCStaticClaimMapper.java b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/mappers/OID4VCStaticClaimMapper.java index 6fcf5c1479f8..75abb43a6073 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/mappers/OID4VCStaticClaimMapper.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/mappers/OID4VCStaticClaimMapper.java @@ -68,11 +68,10 @@ public void setClaim(VerifiableCredential verifiableCredential, @Override public void setClaim(Map claims, UserSessionModel userSessionModel) { - List attributePath = getMetadataAttributePath(); - if (attributePath.isEmpty()) { + String propertyName = getClaimName(); + if (propertyName == null) { return; } - String propertyName = attributePath.get(attributePath.size() - 1); String staticValue = mapperModel.getConfig().get(STATIC_CLAIM_KEY); claims.put(propertyName, staticValue); } diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/mappers/OID4VCSubjectIdMapper.java b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/mappers/OID4VCSubjectIdMapper.java index 668c8b60cd13..f39a7def5b08 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/mappers/OID4VCSubjectIdMapper.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/mappers/OID4VCSubjectIdMapper.java @@ -92,12 +92,11 @@ public void setClaim(VerifiableCredential verifiableCredential, UserSessionModel @Override public void setClaim(Map claims, UserSessionModel userSessionModel) { UserModel userModel = userSessionModel.getUser(); - List attributePath = getMetadataAttributePath(); - if (attributePath.isEmpty()) { + String userAttributeName = mapperModel.getConfig().get(OID4VCMapper.USER_ATTRIBUTE_KEY); + String propertyName = getClaimName(userAttributeName); + if (propertyName == null) { return; } - String propertyName = attributePath.get(attributePath.size() - 1); - String userAttributeName = mapperModel.getConfig().get(OID4VCMapper.USER_ATTRIBUTE_KEY); Consumer userIdConsumer = (val) -> claims.put(propertyName, val); if (UserModel.ID.equals(userAttributeName)) { userIdConsumer.accept(userModel.getId()); @@ -109,6 +108,22 @@ public void setClaim(Map claims, UserSessionModel userSessionMod } } + // the configured user attribute serves as fallback claim name to stay compatible with mappers that were + // created without an explicit claim name + private String resolveClaimName() { + return getClaimName(mapperModel.getConfig().get(OID4VCMapper.USER_ATTRIBUTE_KEY)); + } + + @Override + public List getMetadataAttributePath() { + return getMetadataAttributePath(resolveClaimName()); + } + + @Override + protected List getClaimLookupPath() { + return getClaimLookupPath(resolveClaimName()); + } + @Override public String getDisplayType() { return "CredentialSubject ID Mapper"; diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/mappers/OID4VCTargetRoleMapper.java b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/mappers/OID4VCTargetRoleMapper.java index 566998b311e0..dfee963f3e05 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/mappers/OID4VCTargetRoleMapper.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/mappers/OID4VCTargetRoleMapper.java @@ -22,7 +22,6 @@ import java.util.HashSet; import java.util.List; import java.util.Map; -import java.util.Optional; import java.util.Set; import java.util.stream.Collectors; @@ -42,7 +41,6 @@ import org.keycloak.util.JsonSerialization; import com.fasterxml.jackson.core.type.TypeReference; -import org.apache.commons.collections4.ListUtils; import org.jboss.logging.Logger; /** @@ -94,9 +92,12 @@ protected List getIndividualConfigProperties() { @Override public List getMetadataAttributePath() { - return ListUtils.union(getAttributePrefix(), - List.of(Optional.ofNullable(mapperModel.getConfig().get(CLAIM_NAME)) - .orElse(DEFAULT_CLAIM_NAME))); + return getMetadataAttributePath(getClaimName(DEFAULT_CLAIM_NAME)); + } + + @Override + protected List getClaimLookupPath() { + return getClaimLookupPath(getClaimName(DEFAULT_CLAIM_NAME)); } @Override @@ -151,11 +152,7 @@ public void setClaim(VerifiableCredential verifiableCredential, @Override public void setClaim(Map claims, UserSessionModel userSessionModel) { - List attributePath = getMetadataAttributePath(); - if (attributePath.isEmpty()) { - return; - } - String propertyName = attributePath.get(attributePath.size() - 1); + String propertyName = getClaimName(DEFAULT_CLAIM_NAME); String client = mapperModel.getConfig().get(CLIENT_CONFIG_KEY); ClientModel clientModel = userSessionModel.getRealm().getClientByClientId(client); if (clientModel == null) { diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/mappers/OID4VCTypeMapper.java b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/mappers/OID4VCTypeMapper.java index af8cd1773b91..28ff8c086719 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/mappers/OID4VCTypeMapper.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/mappers/OID4VCTypeMapper.java @@ -31,6 +31,8 @@ import org.keycloak.protocol.oid4vc.model.VerifiableCredential; import org.keycloak.provider.ProviderConfigProperty; +import static org.keycloak.VCFormat.MSO_MDOC; + /** * Allows to add types to the credential subject * @@ -68,6 +70,12 @@ public boolean includeInMetadata() { .orElse(false); } + @Override + public boolean supportsCredentialFormat(String credentialFormat) { + // mDoc uses the configured document type, not a VC-level JSON type member. + return !MSO_MDOC.equals(credentialFormat); + } + @Override public List getMetadataAttributePath() { return List.of("type"); diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/mappers/OID4VCUserAttributeMapper.java b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/mappers/OID4VCUserAttributeMapper.java index 9cb1c8967fa6..526191a087ba 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/mappers/OID4VCUserAttributeMapper.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/mappers/OID4VCUserAttributeMapper.java @@ -37,8 +37,6 @@ import org.keycloak.provider.ProviderConfigProperty; import org.keycloak.utils.JsonUtils; -import org.apache.commons.collections4.ListUtils; - /** * Allows adding user properties to the credential subject * @@ -144,7 +142,8 @@ public String getId() { @Override public List getMetadataAttributePath() { String claimName = mapperModel.getConfig().get(CLAIM_NAME); - final String userAttributeName = mapperModel.getConfig().get(USER_ATTRIBUTE_KEY); + String userAttributeName = mapperModel.getConfig().get(USER_ATTRIBUTE_KEY); + // Split claim name into path segments for metadata endpoint. final List claimPath = Optional.ofNullable(claimName) .map(JsonUtils::splitClaimPath) @@ -154,6 +153,16 @@ public List getMetadataAttributePath() { if (claimPath.isEmpty()) { return Collections.emptyList(); } - return ListUtils.union(getAttributePrefix(), claimPath); + return prefixMetadataAttributePath(claimPath); + } + + @Override + protected List getClaimLookupPath() { + String claimName = Optional.ofNullable(mapperModel.getConfig().get(CLAIM_NAME)) + .orElse(mapperModel.getConfig().get(USER_ATTRIBUTE_KEY)); + if (claimName == null) { + return Collections.emptyList(); + } + return JsonUtils.splitClaimPath(claimName); } } diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/signing/MdocCredentialSigner.java b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/signing/MdocCredentialSigner.java new file mode 100644 index 000000000000..c314557bdddc --- /dev/null +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/signing/MdocCredentialSigner.java @@ -0,0 +1,43 @@ +/* + * Copyright 2026 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.keycloak.protocol.oid4vc.issuance.signing; + +import org.keycloak.crypto.SignatureSignerContext; +import org.keycloak.models.KeycloakSession; +import org.keycloak.protocol.oid4vc.issuance.credentialbuilder.CredentialBody; +import org.keycloak.protocol.oid4vc.issuance.credentialbuilder.MdocCredentialBody; +import org.keycloak.protocol.oid4vc.model.CredentialBuildConfig; + +public class MdocCredentialSigner extends AbstractCredentialSigner { + + public MdocCredentialSigner(KeycloakSession keycloakSession) { + super(keycloakSession); + } + + @Override + public String signCredential(CredentialBody credentialBody, CredentialBuildConfig credentialBuildConfig) + throws CredentialSignerException { + if (!(credentialBody instanceof MdocCredentialBody mdocCredentialBody)) { + throw new CredentialSignerException("Credential body unexpectedly not of type MdocCredentialBody"); + } + + SignatureSignerContext signer = getSigner(credentialBuildConfig); + + return mdocCredentialBody.signAsIssuerSignedDocument(signer).getEncodedIssuerSigned(); + } +} diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/signing/MdocCredentialSignerFactory.java b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/signing/MdocCredentialSignerFactory.java new file mode 100644 index 000000000000..5f1b738279e9 --- /dev/null +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/signing/MdocCredentialSignerFactory.java @@ -0,0 +1,35 @@ +/* + * Copyright 2026 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.keycloak.protocol.oid4vc.issuance.signing; + +import org.keycloak.VCFormat; +import org.keycloak.models.KeycloakSession; +import org.keycloak.protocol.oid4vc.OID4VCMdocEnvironmentProviderFactory; + +public class MdocCredentialSignerFactory implements CredentialSignerFactory, OID4VCMdocEnvironmentProviderFactory { + + @Override + public String getSupportedFormat() { + return VCFormat.MSO_MDOC; + } + + @Override + public CredentialSigner create(KeycloakSession session) { + return new MdocCredentialSigner(session); + } +} diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/model/Claim.java b/services/src/main/java/org/keycloak/protocol/oid4vc/model/Claim.java index 39dc3edddc98..e021218c99c5 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/model/Claim.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/model/Claim.java @@ -73,6 +73,10 @@ public static Optional parse(KeycloakSession keycloakSession, OID4VCMapper mapper = (OID4VCMapper) protocolMapperImpl; mapper.setMapperModel(protocolMapper, credentialFormat); + if (!mapper.supportsCredentialFormat(credentialFormat)) { + return Optional.empty(); + } + if (!mapper.includeInMetadata()) { return Optional.empty(); } 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 06ba5101b261..ba94791cce67 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 @@ -21,12 +21,10 @@ import java.util.Objects; import java.util.Optional; -import org.keycloak.crypto.KeyWrapper; import org.keycloak.models.Constants; import org.keycloak.models.KeycloakSession; import org.keycloak.models.oid4vci.CredentialScopeModel; import org.keycloak.protocol.oid4vc.issuance.OID4VCIssuerWellKnownProvider; -import org.keycloak.utils.StringUtil; /** * Define credential-specific configurations for its builder. @@ -53,7 +51,8 @@ public class CredentialBuildConfig { //-- Proper building configuration fields --// - // The vct field to be used for the SD-JWT. + // OID4VCI 1.0 defines format-specific credential type identifiers: vct for SD-JWT VC + // (Appendix A.3.2) and doctype for mDoc (Appendix A.2.2). private String credentialType; // The type of the token to be created. @@ -94,23 +93,17 @@ public static CredentialBuildConfig parse(KeycloakSession keycloakSession, final String credentialIssuer = Optional.ofNullable(credentialModel.getIssuerDid()).orElse( OID4VCIssuerWellKnownProvider.getIssuer(keycloakSession.getContext())); - - String signingAlg = Constants.DEFAULT_SIGNATURE_ALGORITHM; - if (StringUtil.isNotBlank(credentialModel.getSigningKeyId())) { - signingAlg = keycloakSession.keys() - .getKeysStream(keycloakSession.getContext().getRealm()) - .filter(key-> key.getKid().equals(credentialModel.getSigningKeyId())) - .findAny() - .map(KeyWrapper::getAlgorithm) - .orElse(Constants.DEFAULT_SIGNATURE_ALGORITHM); - } - else if (StringUtil.isNotBlank(credentialModel.getSigningAlg())) { - signingAlg = credentialModel.getSigningAlg(); - } + String format = Optional.ofNullable(credentialModel.getFormat()).orElse(credentialConfiguration.getFormat()); + String signingAlg = CredentialSigningAlgorithmResolver.resolveSigningAlgorithm( + keycloakSession, + credentialModel, + format, + Constants.DEFAULT_SIGNATURE_ALGORITHM + ); return new CredentialBuildConfig().setCredentialIssuer(credentialIssuer) .setCredentialConfigId(credentialConfiguration.getId()) - .setCredentialType(credentialModel.getVct()) + .setCredentialType(resolveCredentialType(credentialModel)) .setTokenJwsType(credentialModel.getBuildConfigTokenJwsType()) .setNumberOfDecoys(credentialModel.getSdJwtNumberOfDecoys()) .setSigningKeyId(credentialModel.getSigningKeyId()) @@ -119,6 +112,10 @@ else if (StringUtil.isNotBlank(credentialModel.getSigningAlg())) { .setSdJwtVisibleClaims(credentialModel.getBuildConfigSdJwtVisibleClaims()); } + public static String resolveCredentialType(CredentialScopeModel credentialModel) { + return Optional.ofNullable(credentialModel.getVct()).orElse(credentialModel.getName()); + } + public String getCredentialIssuer() { return credentialIssuer; } diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/model/CredentialSigningAlgorithmResolver.java b/services/src/main/java/org/keycloak/protocol/oid4vc/model/CredentialSigningAlgorithmResolver.java new file mode 100644 index 000000000000..4df7d5aee195 --- /dev/null +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/model/CredentialSigningAlgorithmResolver.java @@ -0,0 +1,168 @@ +/* + * Copyright 2026 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.keycloak.protocol.oid4vc.model; + +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; + +import org.keycloak.VCFormat; +import org.keycloak.crypto.KeyUse; +import org.keycloak.crypto.KeyWrapper; +import org.keycloak.mdoc.MdocAlgorithm; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.RealmModel; +import org.keycloak.models.oid4vci.CredentialScopeModel; +import org.keycloak.utils.StringUtil; + +import org.jboss.logging.Logger; + +final class CredentialSigningAlgorithmResolver { + + private static final Logger LOGGER = Logger.getLogger(CredentialSigningAlgorithmResolver.class); + + private CredentialSigningAlgorithmResolver() { + } + + static List resolveMetadataSigningAlgorithms(KeycloakSession keycloakSession, + CredentialScopeModel credentialModel, + String format, + List globalSupportedSigningAlgorithms) { + if (!VCFormat.MSO_MDOC.equals(format)) { + String signingAlgorithm = resolveConfiguredSigningAlgorithm(keycloakSession, credentialModel, null); + return StringUtil.isBlank(signingAlgorithm) + ? List.copyOf(globalSupportedSigningAlgorithms) + : List.of(signingAlgorithm); + } + + // OID4VCI 1.0 Appendix A.2.2 advertises mDoc signing algorithms as COSE values for IssuerAuth. + // Only include realm keys that we can map from Keycloak's JOSE algorithm names to COSE identifiers and that + // carry an issuer certificate chain, since mDoc IssuerAuth requires one. + List availableMdocAlgorithms = resolveAvailableMdocSigningAlgorithms(keycloakSession); + + // resolved with the same precedence as the signing path so that the advertised algorithm matches the + // algorithm actually used to sign the credential + Optional signingAlgorithm = resolveSupportedConfiguredSigningAlgorithm( + format, + resolveConfiguredSigningAlgorithm(keycloakSession, credentialModel, null), + availableMdocAlgorithms + ); + if (signingAlgorithm.isPresent()) { + return List.of(signingAlgorithm.get()); + } + + return availableMdocAlgorithms; + } + + static String resolveSigningAlgorithm(KeycloakSession keycloakSession, + CredentialScopeModel credentialModel, + String format, + String defaultSigningAlgorithm) { + if (!VCFormat.MSO_MDOC.equals(format)) { + return resolveJsonSigningAlgorithm(keycloakSession, credentialModel, defaultSigningAlgorithm); + } + + List availableMdocAlgorithms = resolveAvailableMdocSigningAlgorithms(keycloakSession); + + Optional signingAlgorithm = resolveSupportedConfiguredSigningAlgorithm( + format, + resolveConfiguredSigningAlgorithm(keycloakSession, credentialModel, null), + availableMdocAlgorithms + ); + if (signingAlgorithm.isPresent()) { + return signingAlgorithm.get(); + } + + return availableMdocAlgorithms + .stream() + .findFirst() + .orElse(null); + } + + private static String resolveJsonSigningAlgorithm(KeycloakSession keycloakSession, + CredentialScopeModel credentialModel, + String defaultSigningAlgorithm) { + return resolveConfiguredSigningAlgorithm(keycloakSession, credentialModel, defaultSigningAlgorithm); + } + + private static String resolveConfiguredSigningAlgorithm(KeycloakSession keycloakSession, + CredentialScopeModel credentialModel, + String fallbackSigningAlgorithm) { + if (StringUtil.isNotBlank(credentialModel.getSigningKeyId())) { + return resolveKeyAlgorithm(keycloakSession, credentialModel.getSigningKeyId()) + .orElse(fallbackSigningAlgorithm); + } + + if (StringUtil.isNotBlank(credentialModel.getSigningAlg())) { + return credentialModel.getSigningAlg(); + } + + return fallbackSigningAlgorithm; + } + + private static Optional resolveSupportedConfiguredSigningAlgorithm(String format, String signingAlgorithm, + List availableMdocAlgorithms) { + if (StringUtil.isBlank(signingAlgorithm)) { + return Optional.empty(); + } + + if (!isSupportedForFormat(format, signingAlgorithm)) { + LOGGER.warnf("Configured signing algorithm '%s' is unsupported for credential format '%s'.", + signingAlgorithm, format); + return Optional.empty(); + } + + // An mDoc scope may be configured with an algorithm whose active signing key has no certificate chain. Such a + // credential would advertise successfully and then fail every issuance, so treat it as unavailable here. + if (VCFormat.MSO_MDOC.equals(format) && !availableMdocAlgorithms.contains(signingAlgorithm)) { + LOGGER.warnf("Configured signing algorithm '%s' has no active signing key with a certificate chain for " + + "credential format '%s'.", signingAlgorithm, format); + return Optional.empty(); + } + return Optional.of(signingAlgorithm); + } + + private static Optional resolveKeyAlgorithm(KeycloakSession keycloakSession, String signingKeyId) { + return keycloakSession.keys() + .getKeysStream(keycloakSession.getContext().getRealm()) + .filter(key -> signingKeyId.equals(key.getKid())) + .findAny() + .map(KeyWrapper::getAlgorithm); + } + + private static List resolveAvailableMdocSigningAlgorithms(KeycloakSession keycloakSession) { + RealmModel realm = keycloakSession.getContext().getRealm(); + return keycloakSession.keys().getKeysStream(realm) + .filter(key -> key.getStatus().isActive()) + .filter(key -> KeyUse.SIG.equals(key.getUse())) + .filter(CredentialSigningAlgorithmResolver::hasCertificateChain) + .map(KeyWrapper::getAlgorithm) + .filter(MdocAlgorithm.getSupportedJoseAlgorithms()::contains) + .distinct() + .collect(Collectors.toList()); + } + + private static boolean hasCertificateChain(KeyWrapper key) { + // Mirror what AsymmetricSignatureSignerContext.getCertificateChain accepts, a populated chain or a single certificate. + return (key.getCertificateChain() != null && !key.getCertificateChain().isEmpty()) + || key.getCertificate() != null; + } + + private static boolean isSupportedForFormat(String format, String signingAlgorithm) { + return !VCFormat.MSO_MDOC.equals(format) || MdocAlgorithm.getSupportedJoseAlgorithms().contains(signingAlgorithm); + } +} 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 93a15f4aa859..8973a103e0a0 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 @@ -23,15 +23,18 @@ import java.util.stream.Collectors; import org.keycloak.VCFormat; +import org.keycloak.mdoc.MdocAlgorithm; import org.keycloak.models.KeycloakSession; import org.keycloak.models.oid4vci.CredentialScopeModel; -import org.keycloak.utils.StringUtil; import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; import org.jboss.logging.Logger; +import static org.keycloak.OID4VCConstants.CRYPTOGRAPHIC_BINDING_METHOD_COSE_KEY; +import static org.keycloak.OID4VCConstants.CRYPTOGRAPHIC_BINDING_METHOD_JWK; + /** * A supported credential, as used in the Credentials Issuer Metadata in OID4VCI * {@see https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#name-credential-issuer-metadata} @@ -54,6 +57,8 @@ public class SupportedCredentialConfiguration { @JsonIgnore public static final String VERIFIABLE_CREDENTIAL_TYPE_KEY = "vct"; @JsonIgnore + public static final String DOC_TYPE_KEY = "doctype"; + @JsonIgnore private static final String CREDENTIAL_DEFINITION_KEY = "credential_definition"; @JsonIgnore public static final String CREDENTIAL_BUILD_CONFIG_KEY = "credential_build_config"; @@ -73,11 +78,14 @@ public class SupportedCredentialConfiguration { private List cryptographicBindingMethodsSupported; @JsonProperty(CREDENTIAL_SIGNING_ALG_VALUES_SUPPORTED_KEY) - private List credentialSigningAlgValuesSupported; + private List credentialSigningAlgValuesSupported; @JsonProperty(VERIFIABLE_CREDENTIAL_TYPE_KEY) private String vct; + @JsonProperty(DOC_TYPE_KEY) + private String docType; + @JsonProperty(CREDENTIAL_DEFINITION_KEY) private CredentialDefinition credentialDefinition; @@ -118,9 +126,12 @@ public static SupportedCredentialConfiguration parse(KeycloakSession keycloakSes List requiredProofTypes = credentialScope.getRequiredProofTypes(); List configuredBindingMethods = credentialScope.getCryptographicBindingMethods(); - // Normalize and validate binding methods and proof types against what the server actually supports. - // This prevents unknown values configured via the admin UI or API from leaking into issuer metadata. - List allowedBindingMethods = List.of(CredentialScopeModel.CRYPTOGRAPHIC_BINDING_METHODS_DEFAULT); + // OID4VCI 1.0 Section 12.2.4 defines binding methods as the key-material representation in the issued + // credential: "jwk" for JWK and "cose_key" for COSE_Key, which is the representation used by ISO mdoc. + // Normalize configured values so unsupported admin/API configuration does not leak into issuer metadata. + List allowedBindingMethods = VCFormat.MSO_MDOC.equals(format) + ? List.of(CRYPTOGRAPHIC_BINDING_METHOD_COSE_KEY) + : List.of(CRYPTOGRAPHIC_BINDING_METHOD_JWK); List effectiveBindingMethods = Optional.ofNullable(configuredBindingMethods) .orElse(Collections.emptyList()) .stream() @@ -173,11 +184,20 @@ public static SupportedCredentialConfiguration parse(KeycloakSession keycloakSes credentialConfiguration.setCryptographicBindingMethodsSupported(effectiveBindingMethods); } - // Return single configured value for the signature algorithm if any - String signingAlgSupported = credentialScope.getSigningAlg(); - List signingAlgsSupported = StringUtil.isBlank(signingAlgSupported) ? globalSupportedSigningAlgorithms : - Collections.singletonList(signingAlgSupported); - credentialConfiguration.setCredentialSigningAlgValuesSupported(signingAlgsSupported); + // Return single configured value for the signature algorithm if any. + List signingAlgsSupported = CredentialSigningAlgorithmResolver.resolveMetadataSigningAlgorithms( + keycloakSession, + credentialScope, + format, + globalSupportedSigningAlgorithms + ); + if (signingAlgsSupported.isEmpty()) { + LOGGER.warnf("No compatible signing algorithm is available for credential configuration '%s' with format '%s'.", + credentialConfigurationId, format); + } + credentialConfiguration.setCredentialSigningAlgValuesSupported( + toCredentialSigningAlgValuesSupported(format, signingAlgsSupported) + ); // Parse credential metadata (includes display and claims) CredentialMetadata credentialMetadata = CredentialMetadata.parse(keycloakSession, credentialScope); @@ -192,11 +212,8 @@ public static SupportedCredentialConfiguration parse(KeycloakSession keycloakSes } /** - * Return the verifiable credential type. Sort of confusing in the specification. For sdjwt, we have a "vct" claim. - * See: https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#name-credential-request-6 - *

- * For iso mdl (not yet supported) we have a "doctype" See: - * https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#name-credential-request-5 + * Return the format-specific credential type identifier. For SD-JWT VC it is emitted as "vct"; for mDoc it is + * emitted as "doctype". *

* For jwt_vc and ldp_vc, we will be inferring from the "credential_definition" See: * https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#name-credential-request-3 @@ -207,6 +224,9 @@ public VerifiableCredentialType deriveType() { if (Objects.equals(format, VCFormat.SD_JWT_VC)) { return VerifiableCredentialType.from(vct); } + if (Objects.equals(format, VCFormat.MSO_MDOC)) { + return VerifiableCredentialType.from(docType); + } return null; } @@ -223,6 +243,15 @@ public SupportedCredentialConfiguration setFormat(String format) { return this; } + public String getDocType() { + return docType; + } + + public SupportedCredentialConfiguration setDocType(String docType) { + this.docType = docType; + return this; + } + public String getScope() { return scope; } @@ -250,15 +279,31 @@ public SupportedCredentialConfiguration setId(String id) { return this; } - public List getCredentialSigningAlgValuesSupported() { + public List getCredentialSigningAlgValuesSupported() { return credentialSigningAlgValuesSupported; } - public SupportedCredentialConfiguration setCredentialSigningAlgValuesSupported(List credentialSigningAlgValuesSupported) { + public SupportedCredentialConfiguration setCredentialSigningAlgValuesSupported(List credentialSigningAlgValuesSupported) { this.credentialSigningAlgValuesSupported = Collections.unmodifiableList(credentialSigningAlgValuesSupported); return this; } + private static List toCredentialSigningAlgValuesSupported(String format, List signingAlgorithmsSupported) { + if (!VCFormat.MSO_MDOC.equals(format)) { + return List.copyOf(signingAlgorithmsSupported); + } + + // OID4VCI 1.0 Appendix A.2.2 uses numeric COSE algorithm identifiers for mDoc issuer metadata, while + // Keycloak key providers are configured with JOSE names. Convert only the mDoc metadata representation here. + return signingAlgorithmsSupported.stream() + .map(SupportedCredentialConfiguration::toMdocCredentialSigningAlgorithm) + .collect(Collectors.toList()); + } + + private static Integer toMdocCredentialSigningAlgorithm(String algorithm) { + return MdocAlgorithm.fromJoseAlgorithm(algorithm).getCoseAlgorithmIdentifier(); + } + public String getVct() { return vct; } @@ -309,11 +354,11 @@ public boolean equals(Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; SupportedCredentialConfiguration that = (SupportedCredentialConfiguration) o; - return Objects.equals(id, that.id) && Objects.equals(format, that.format) && Objects.equals(scope, that.scope) && Objects.equals(cryptographicBindingMethodsSupported, that.cryptographicBindingMethodsSupported) && Objects.equals(credentialSigningAlgValuesSupported, that.credentialSigningAlgValuesSupported) && Objects.equals(vct, that.vct) && Objects.equals(credentialDefinition, that.credentialDefinition) && Objects.equals(proofTypesSupported, that.proofTypesSupported) && Objects.equals(credentialMetadata, that.credentialMetadata) && Objects.equals(credentialBuildConfig, that.credentialBuildConfig); + return Objects.equals(id, that.id) && Objects.equals(format, that.format) && Objects.equals(scope, that.scope) && Objects.equals(cryptographicBindingMethodsSupported, that.cryptographicBindingMethodsSupported) && Objects.equals(credentialSigningAlgValuesSupported, that.credentialSigningAlgValuesSupported) && Objects.equals(vct, that.vct) && Objects.equals(docType, that.docType) && Objects.equals(credentialDefinition, that.credentialDefinition) && Objects.equals(proofTypesSupported, that.proofTypesSupported) && Objects.equals(credentialMetadata, that.credentialMetadata) && Objects.equals(credentialBuildConfig, that.credentialBuildConfig); } @Override public int hashCode() { - return Objects.hash(id, format, scope, cryptographicBindingMethodsSupported, credentialSigningAlgValuesSupported, vct, credentialDefinition, proofTypesSupported, credentialMetadata, credentialBuildConfig); + return Objects.hash(id, format, scope, cryptographicBindingMethodsSupported, credentialSigningAlgValuesSupported, vct, docType, credentialDefinition, proofTypesSupported, credentialMetadata, credentialBuildConfig); } } diff --git a/services/src/main/resources/META-INF/services/org.keycloak.protocol.oid4vc.issuance.credentialbuilder.CredentialBuilderFactory b/services/src/main/resources/META-INF/services/org.keycloak.protocol.oid4vc.issuance.credentialbuilder.CredentialBuilderFactory index 35e89c3f90a3..b8b6bb28581c 100644 --- a/services/src/main/resources/META-INF/services/org.keycloak.protocol.oid4vc.issuance.credentialbuilder.CredentialBuilderFactory +++ b/services/src/main/resources/META-INF/services/org.keycloak.protocol.oid4vc.issuance.credentialbuilder.CredentialBuilderFactory @@ -15,7 +15,8 @@ # limitations under the License. # -# Disabled to keep OID4VCI scope limited to supported formats (JWT VC and SD-JWT VC). +# Disabled to keep OID4VCI scope limited to supported formats (JWT VC, SD-JWT VC, and mDoc). # org.keycloak.protocol.oid4vc.issuance.credentialbuilder.LDCredentialBuilderFactory org.keycloak.protocol.oid4vc.issuance.credentialbuilder.JwtCredentialBuilderFactory +org.keycloak.protocol.oid4vc.issuance.credentialbuilder.MdocCredentialBuilderFactory org.keycloak.protocol.oid4vc.issuance.credentialbuilder.SdJwtCredentialBuilderFactory diff --git a/services/src/main/resources/META-INF/services/org.keycloak.protocol.oid4vc.issuance.signing.CredentialSignerFactory b/services/src/main/resources/META-INF/services/org.keycloak.protocol.oid4vc.issuance.signing.CredentialSignerFactory index f0fd3ef10e8c..e1a9f2d8706e 100644 --- a/services/src/main/resources/META-INF/services/org.keycloak.protocol.oid4vc.issuance.signing.CredentialSignerFactory +++ b/services/src/main/resources/META-INF/services/org.keycloak.protocol.oid4vc.issuance.signing.CredentialSignerFactory @@ -15,7 +15,8 @@ # limitations under the License. # -# Disabled to keep OID4VCI scope limited to supported formats (JWT VC and SD-JWT VC). +# Disabled to keep OID4VCI scope limited to supported formats (JWT VC, SD-JWT VC, and mDoc). # org.keycloak.protocol.oid4vc.issuance.signing.LDCredentialSignerFactory org.keycloak.protocol.oid4vc.issuance.signing.JwtCredentialSignerFactory +org.keycloak.protocol.oid4vc.issuance.signing.MdocCredentialSignerFactory org.keycloak.protocol.oid4vc.issuance.signing.SdJwtCredentialSignerFactory diff --git a/services/src/test/java/org/keycloak/protocol/oid4vc/OID4VCLoginProtocolFactoryTest.java b/services/src/test/java/org/keycloak/protocol/oid4vc/OID4VCLoginProtocolFactoryTest.java new file mode 100644 index 000000000000..f6c27cbde076 --- /dev/null +++ b/services/src/test/java/org/keycloak/protocol/oid4vc/OID4VCLoginProtocolFactoryTest.java @@ -0,0 +1,84 @@ +/* + * Copyright 2026 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.keycloak.protocol.oid4vc; + +import org.keycloak.VCFormat; +import org.keycloak.common.Profile; +import org.keycloak.common.Profile.Feature; +import org.keycloak.common.profile.CommaSeparatedListProfileConfigResolver; +import org.keycloak.representations.idm.ClientScopeRepresentation; + +import org.junit.After; +import org.junit.Test; + +import static org.keycloak.OID4VCConstants.CRYPTOGRAPHIC_BINDING_METHOD_COSE_KEY; +import static org.keycloak.OID4VCConstants.CRYPTOGRAPHIC_BINDING_METHOD_JWK; +import static org.keycloak.models.oid4vci.CredentialScopeModel.VC_CRYPTOGRAPHIC_BINDING_METHODS; +import static org.keycloak.models.oid4vci.CredentialScopeModel.VC_FORMAT; + +import static org.junit.Assert.assertEquals; + +public class OID4VCLoginProtocolFactoryTest { + + private final OID4VCLoginProtocolFactory factory = new OID4VCLoginProtocolFactory(); + + @After + public void resetProfile() { + Profile.reset(); + } + + @Test + public void addClientScopeDefaultsKeepsMdocSuffixAsSdJwtWhenMdocFeatureDisabled() { + Profile.configure(new CommaSeparatedListProfileConfigResolver(Feature.OID4VC_VCI.getVersionedKey(), "")); + + ClientScopeRepresentation clientScope = new ClientScopeRepresentation(); + clientScope.setName("natural-person_mdoc"); + + factory.addClientScopeDefaults(clientScope); + + assertEquals(VCFormat.SD_JWT_VC, clientScope.getAttributes().get(VC_FORMAT)); + assertEquals(CRYPTOGRAPHIC_BINDING_METHOD_JWK, clientScope.getAttributes().get(VC_CRYPTOGRAPHIC_BINDING_METHODS)); + } + + @Test + public void addClientScopeDefaultsInfersMdocSuffixWhenMdocFeatureEnabled() { + Profile.configure(new CommaSeparatedListProfileConfigResolver( + Feature.OID4VC_VCI.getVersionedKey() + "," + Feature.OID4VC_MDOC.getVersionedKey(), "")); + + ClientScopeRepresentation clientScope = new ClientScopeRepresentation(); + clientScope.setName("natural-person_mdoc"); + + factory.addClientScopeDefaults(clientScope); + + assertEquals(VCFormat.MSO_MDOC, clientScope.getAttributes().get(VC_FORMAT)); + assertEquals(CRYPTOGRAPHIC_BINDING_METHOD_COSE_KEY, clientScope.getAttributes().get(VC_CRYPTOGRAPHIC_BINDING_METHODS)); + } + + @Test + public void addClientScopeDefaultsKeepsJwtSuffixWhenMdocFeatureDisabled() { + Profile.configure(new CommaSeparatedListProfileConfigResolver(Feature.OID4VC_VCI.getVersionedKey(), "")); + + ClientScopeRepresentation clientScope = new ClientScopeRepresentation(); + clientScope.setName("natural-person_jwt"); + + factory.addClientScopeDefaults(clientScope); + + assertEquals(VCFormat.JWT_VC, clientScope.getAttributes().get(VC_FORMAT)); + assertEquals(CRYPTOGRAPHIC_BINDING_METHOD_JWK, clientScope.getAttributes().get(VC_CRYPTOGRAPHIC_BINDING_METHODS)); + } +} diff --git a/services/src/test/java/org/keycloak/protocol/oid4vc/issuance/OID4VCAuthorizationDetailsProcessorTest.java b/services/src/test/java/org/keycloak/protocol/oid4vc/issuance/OID4VCAuthorizationDetailsProcessorTest.java index e108c557e375..599d779b713b 100644 --- a/services/src/test/java/org/keycloak/protocol/oid4vc/issuance/OID4VCAuthorizationDetailsProcessorTest.java +++ b/services/src/test/java/org/keycloak/protocol/oid4vc/issuance/OID4VCAuthorizationDetailsProcessorTest.java @@ -243,6 +243,34 @@ public void testAuthorizationDetailWithComplexClaims() { assertFalse("Second claim should not be mandatory", secondClaim.isMandatory()); } + @Test + public void testAuthorizationDetailsParserPreservesMdocClaimPaths() throws IOException { + OID4VCAuthorizationDetail authDetail = createValidAuthorizationDetail(); + + ClaimsDescription claim1 = new ClaimsDescription(); + claim1.setPath(Arrays.asList("org.iso.18013.5.1", "given_name")); + claim1.setMandatory(true); + + ClaimsDescription claim2 = new ClaimsDescription(); + claim2.setPath(Arrays.asList("org.iso.18013.5.1", "address", "street")); + claim2.setMandatory(false); + + authDetail.setClaims(Arrays.asList(claim1, claim2)); + + AuthorizationDetailsJSONRepresentation genericDetail = convertToResponseType(authDetail); + OID4VCAuthorizationDetail parsedAuthDetail = genericDetail.asSubtype(OID4VCAuthorizationDetail.class); + + assertEquals("Should have exactly two claims", 2, parsedAuthDetail.getClaims().size()); + + ClaimsDescription firstClaim = parsedAuthDetail.getClaims().get(0); + assertEquals(Arrays.asList("org.iso.18013.5.1", "given_name"), firstClaim.getPath()); + assertTrue(firstClaim.isMandatory()); + + ClaimsDescription secondClaim = parsedAuthDetail.getClaims().get(1); + assertEquals(Arrays.asList("org.iso.18013.5.1", "address", "street"), secondClaim.getPath()); + assertFalse(secondClaim.isMandatory()); + } + @Test public void testAuthorizationDetailWithNullClaims() { // Test null claims handling diff --git a/services/src/test/java/org/keycloak/protocol/oid4vc/issuance/credentialbuilder/CredentialBuilderFactoryTest.java b/services/src/test/java/org/keycloak/protocol/oid4vc/issuance/credentialbuilder/CredentialBuilderFactoryTest.java index 5a6482c3f23a..311b0f9b56b5 100644 --- a/services/src/test/java/org/keycloak/protocol/oid4vc/issuance/credentialbuilder/CredentialBuilderFactoryTest.java +++ b/services/src/test/java/org/keycloak/protocol/oid4vc/issuance/credentialbuilder/CredentialBuilderFactoryTest.java @@ -2,6 +2,7 @@ import java.util.List; +import org.keycloak.VCFormat; import org.keycloak.common.Profile; import org.keycloak.common.Profile.Feature; import org.keycloak.common.crypto.CryptoIntegration; @@ -12,15 +13,12 @@ import org.keycloak.services.resteasy.ResteasyKeycloakSession; import org.keycloak.services.resteasy.ResteasyKeycloakSessionFactory; +import org.junit.AfterClass; import org.junit.BeforeClass; import org.junit.Test; -import static org.hamcrest.MatcherAssert.assertThat; -import static org.hamcrest.Matchers.empty; -import static org.hamcrest.Matchers.hasItem; -import static org.hamcrest.Matchers.is; -import static org.hamcrest.Matchers.not; -import static org.hamcrest.Matchers.notNullValue; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; public class CredentialBuilderFactoryTest { @@ -35,6 +33,11 @@ public static void beforeClass() { session = new ResteasyKeycloakSession(factory); } + @AfterClass + public static void afterClass() { + Profile.reset(); + } + @Test public void testVerifyNonNullConfigProperties() { List credentialBuilderFactories = session @@ -44,10 +47,10 @@ public void testVerifyNonNullConfigProperties() { .map(CredentialBuilderFactory.class::cast) .toList(); - assertThat(credentialBuilderFactories, is(not(empty()))); + assertFalse(credentialBuilderFactories.isEmpty()); for (CredentialBuilderFactory credentialBuilderFactory : credentialBuilderFactories) { - assertThat(credentialBuilderFactory.getConfigProperties(), notNullValue()); + assertNotNull(credentialBuilderFactory.getConfigProperties()); } } @@ -59,13 +62,30 @@ public void testLdpFactoriesDisabled() { .map(f -> f.getId()) .toList(); - assertThat(builderIds, not(hasItem("ldp_vc"))); + assertFalse(builderIds.contains("ldp_vc")); + + List signerIds = session.getKeycloakSessionFactory() + .getProviderFactoriesStream(CredentialSigner.class) + .map(f -> f.getId()) + .toList(); + + assertFalse(signerIds.contains("ldp_vc")); + } + + @Test + public void testMdocFactoriesDisabledWithoutMdocFeature() { + List builderIds = session.getKeycloakSessionFactory() + .getProviderFactoriesStream(CredentialBuilder.class) + .map(f -> f.getId()) + .toList(); + + assertFalse(builderIds.contains(VCFormat.MSO_MDOC)); List signerIds = session.getKeycloakSessionFactory() .getProviderFactoriesStream(CredentialSigner.class) .map(f -> f.getId()) .toList(); - assertThat(signerIds, not(hasItem("ldp_vc"))); + assertFalse(signerIds.contains(VCFormat.MSO_MDOC)); } } diff --git a/services/src/test/java/org/keycloak/protocol/oid4vc/issuance/credentialbuilder/MdocCredentialBuilderTest.java b/services/src/test/java/org/keycloak/protocol/oid4vc/issuance/credentialbuilder/MdocCredentialBuilderTest.java new file mode 100644 index 000000000000..7b5ff5a96040 --- /dev/null +++ b/services/src/test/java/org/keycloak/protocol/oid4vc/issuance/credentialbuilder/MdocCredentialBuilderTest.java @@ -0,0 +1,294 @@ +package org.keycloak.protocol.oid4vc.issuance.credentialbuilder; + +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.spec.ECGenParameterSpec; +import java.time.Instant; +import java.util.LinkedHashMap; +import java.util.Map; + +import org.keycloak.common.crypto.CryptoIntegration; +import org.keycloak.common.util.CertificateUtils; +import org.keycloak.common.util.KeyUtils; +import org.keycloak.crypto.Algorithm; +import org.keycloak.crypto.AsymmetricSignatureSignerContext; +import org.keycloak.crypto.ECDSASignatureSignerContext; +import org.keycloak.crypto.KeyUse; +import org.keycloak.crypto.KeyWrapper; +import org.keycloak.jose.jwk.JWK; +import org.keycloak.jose.jwk.JWKBuilder; +import org.keycloak.mdoc.MdocIssuerSignedDocument; +import org.keycloak.mdoc.MdocNamespacedClaims; +import org.keycloak.mdoc.MdocValidityInfo; +import org.keycloak.protocol.oid4vc.issuance.signing.CredentialSignerException; +import org.keycloak.protocol.oid4vc.model.CredentialBuildConfig; +import org.keycloak.protocol.oid4vc.model.VerifiableCredential; + +import org.junit.BeforeClass; +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +public class MdocCredentialBuilderTest { + + private static final String DOC_TYPE = "org.iso.18013.5.1.mDL"; + private static final String NAMESPACE = "org.iso.18013.5.1"; + + @BeforeClass + public static void initCrypto() { + CryptoIntegration.init(MdocCredentialBuilderTest.class.getClassLoader()); + } + + @Test + public void shouldBuildMdocBodyWithNamespaceClaims() throws Exception { + MdocCredentialBuilder builder = new MdocCredentialBuilder(); + + VerifiableCredential credential = new VerifiableCredential() + .setIssuanceDate(Instant.parse("2026-04-24T10:15:30Z")) + .setExpirationDate(Instant.parse("2027-04-24T10:15:30Z")); + + Map namespaceClaims = new LinkedHashMap<>(); + namespaceClaims.put("given_name", "John"); + namespaceClaims.put("family_name", "Doe"); + namespaceClaims.put("document_number", "did:key:1234"); + credential.getCredentialSubject().setClaims(NAMESPACE, namespaceClaims); + + MdocCredentialBody body = builder.buildCredentialBody( + credential, + new CredentialBuildConfig().setCredentialType(DOC_TYPE) + ); + + assertEquals(DOC_TYPE, body.getDocType()); + assertNotNull(body.getValidityInfo()); + assertTrue(body.getClaims().containsKey(NAMESPACE)); + assertTrue(body.getClaims().get(NAMESPACE) instanceof Map); + assertEquals(namespaceClaims, body.getClaims().get(NAMESPACE)); + assertEquals(1, body.getNamespacedClaims().size()); + MdocNamespacedClaims namespacedClaims = body.getNamespacedClaims().get(0); + assertEquals(NAMESPACE, namespacedClaims.getNameSpace()); + assertEquals(namespaceClaims, namespacedClaims.getClaims()); + } + + @Test + public void shouldRequireDocType() throws Exception { + MdocCredentialBuilder builder = new MdocCredentialBuilder(); + + CredentialBuilderException exception = assertThrows(CredentialBuilderException.class, + () -> builder.buildCredentialBody(new VerifiableCredential(), new CredentialBuildConfig())); + assertEquals("mDoc issuance requires vc.verifiable_credential_type", exception.getMessage()); + } + + @Test + public void shouldRequireExpirationDate() throws Exception { + MdocCredentialBuilder builder = new MdocCredentialBuilder(); + + VerifiableCredential credential = new VerifiableCredential() + .setIssuanceDate(Instant.parse("2026-04-24T10:15:30Z")); + + CredentialBuilderException exception = assertThrows(CredentialBuilderException.class, + () -> builder.buildCredentialBody(credential, new CredentialBuildConfig().setCredentialType(DOC_TYPE))); + assertEquals("mDoc issuance requires expirationDate", exception.getMessage()); + } + + @Test + public void shouldAcceptProofKeyWithoutAlgorithm() throws Exception { + KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("EC"); + keyPairGenerator.initialize(new ECGenParameterSpec("secp384r1")); + KeyPair issuerKeyPair = keyPairGenerator.generateKeyPair(); + KeyPair proofKeyPair = keyPairGenerator.generateKeyPair(); + JWK proofJwk = JWKBuilder.create().kid("proof-key").ec(proofKeyPair.getPublic(), KeyUse.SIG); + proofJwk.setAlgorithm(null); + + MdocCredentialBody body = new MdocCredentialBody( + DOC_TYPE, + Map.of(NAMESPACE, Map.of("given_name", "John")), + builderValidityInfo() + ); + + body.addKeyBinding(proofJwk); + + KeyWrapper issuerKey = createIssuerKey(issuerKeyPair, Algorithm.ES384, "EC"); + MdocIssuerSignedDocument issuerSignedDocument = body.signAsIssuerSignedDocument( + new ECDSASignatureSignerContext(issuerKey) + ); + assertSignedMdoc(issuerSignedDocument); + } + + @Test + public void shouldSignMdocCredentialWithEcIssuerKey() throws Exception { + KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("EC"); + keyPairGenerator.initialize(new ECGenParameterSpec("secp256r1")); + KeyPair issuerKeyPair = keyPairGenerator.generateKeyPair(); + KeyPair proofKeyPair = keyPairGenerator.generateKeyPair(); + + JWK proofJwk = JWKBuilder.create() + .kid("proof-key") + .algorithm(Algorithm.ES256) + .ec(proofKeyPair.getPublic(), KeyUse.SIG); + + MdocCredentialBody body = new MdocCredentialBody( + DOC_TYPE, + Map.of(NAMESPACE, Map.of("given_name", "John")), + builderValidityInfo() + ); + body.addKeyBinding(proofJwk); + + KeyWrapper issuerKey = createIssuerKey(issuerKeyPair, Algorithm.ES256, "EC"); + MdocIssuerSignedDocument issuerSignedDocument = body.signAsIssuerSignedDocument( + new ECDSASignatureSignerContext(issuerKey) + ); + assertSignedMdoc(issuerSignedDocument); + } + + @Test + public void shouldProduceParseableIssuerSignedStructure() throws Exception { + KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("EC"); + keyPairGenerator.initialize(new ECGenParameterSpec("secp256r1")); + KeyPair issuerKeyPair = keyPairGenerator.generateKeyPair(); + KeyPair proofKeyPair = keyPairGenerator.generateKeyPair(); + + JWK proofJwk = JWKBuilder.create() + .kid("proof-key") + .algorithm(Algorithm.ES256) + .ec(proofKeyPair.getPublic(), KeyUse.SIG); + + MdocCredentialBody body = new MdocCredentialBody( + DOC_TYPE, + Map.of(NAMESPACE, Map.of("given_name", "John")), + builderValidityInfo() + ); + body.addKeyBinding(proofJwk); + + KeyWrapper issuerKey = createIssuerKey(issuerKeyPair, Algorithm.ES256, "EC"); + MdocIssuerSignedDocument issuerSignedDocument = body.signAsIssuerSignedDocument( + new ECDSASignatureSignerContext(issuerKey) + ); + + assertFalse(issuerSignedDocument.getIssuerAuthCertificateChain().isEmpty()); + assertTrue(issuerSignedDocument.getNamespaces().containsKey(NAMESPACE)); + + Map mobileSecurityObject = issuerSignedDocument.getMobileSecurityObject(); + assertEquals("1.0", mobileSecurityObject.get("version")); + assertEquals("SHA-256", mobileSecurityObject.get("digestAlgorithm")); + assertEquals(DOC_TYPE, mobileSecurityObject.get("docType")); + assertNotNull(mobileSecurityObject.get("valueDigests")); + assertNotNull(mobileSecurityObject.get("validityInfo")); + assertNotNull(mobileSecurityObject.get("deviceKeyInfo")); + } + + @Test + public void shouldRejectUnsupportedOkpProofKeyCurve() throws Exception { + KeyPair proofKeyPair = KeyUtils.generateEddsaKeyPair(Algorithm.Ed448); + JWK proofJwk = JWKBuilder.create() + .kid("proof-key") + .algorithm(Algorithm.EdDSA) + .okp(proofKeyPair.getPublic(), KeyUse.SIG); + + MdocCredentialBody body = new MdocCredentialBody( + DOC_TYPE, + Map.of(NAMESPACE, Map.of("given_name", "John")), + builderValidityInfo() + ); + + assertThrows(CredentialBuilderException.class, () -> body.addKeyBinding(proofJwk)); + } + + @Test + public void shouldSignMdocCredentialWithRsaIssuerKey() throws Exception { + KeyPair issuerKeyPair = KeyUtils.generateRsaKeyPair(2048); + KeyPair proofKeyPair = KeyUtils.generateRsaKeyPair(2048); + + JWK proofJwk = JWKBuilder.create() + .kid("proof-key") + .algorithm(Algorithm.PS256) + .rsa(proofKeyPair.getPublic(), KeyUse.SIG); + + MdocCredentialBody body = new MdocCredentialBody( + DOC_TYPE, + Map.of(NAMESPACE, Map.of("given_name", "John")), + builderValidityInfo() + ); + body.addKeyBinding(proofJwk); + + KeyWrapper issuerKey = createIssuerKey(issuerKeyPair, Algorithm.RS256, "RSA"); + MdocIssuerSignedDocument issuerSignedDocument = body.signAsIssuerSignedDocument( + new AsymmetricSignatureSignerContext(issuerKey) + ); + assertSignedMdoc(issuerSignedDocument); + } + + @Test + public void shouldRequireCertificateChainForEdDsaIssuerKey() throws Exception { + KeyPair issuerKeyPair = KeyUtils.generateEddsaKeyPair(Algorithm.Ed25519); + KeyPair proofKeyPair = KeyUtils.generateEddsaKeyPair(Algorithm.Ed25519); + + JWK proofJwk = JWKBuilder.create() + .kid("proof-key") + .algorithm(Algorithm.EdDSA) + .okp(proofKeyPair.getPublic(), KeyUse.SIG); + + MdocCredentialBody body = new MdocCredentialBody( + DOC_TYPE, + Map.of(NAMESPACE, Map.of("given_name", "John")), + builderValidityInfo() + ); + body.addKeyBinding(proofJwk); + + KeyWrapper issuerKey = createIssuerKey(issuerKeyPair, Algorithm.EdDSA, "OKP", false); + assertThrows(CredentialSignerException.class, + () -> body.signAsIssuerSignedDocument(new AsymmetricSignatureSignerContext(issuerKey))); + } + + @Test + public void shouldRequireIssuerCertificateChain() throws Exception { + KeyPair issuerKeyPair = KeyUtils.generateRsaKeyPair(2048); + + MdocCredentialBody body = new MdocCredentialBody( + DOC_TYPE, + Map.of(NAMESPACE, Map.of("given_name", "John")), + builderValidityInfo() + ); + + KeyWrapper issuerKey = createIssuerKey(issuerKeyPair, Algorithm.RS256, "RSA", false); + assertThrows(CredentialSignerException.class, + () -> body.signAsIssuerSignedDocument(new AsymmetricSignatureSignerContext(issuerKey))); + } + + private static KeyWrapper createIssuerKey(KeyPair issuerKeyPair, String algorithm, String type) throws Exception { + return createIssuerKey(issuerKeyPair, algorithm, type, true); + } + + private static KeyWrapper createIssuerKey(KeyPair issuerKeyPair, String algorithm, String type, boolean includeCertificate) throws Exception { + KeyWrapper issuerKey = new KeyWrapper(); + issuerKey.setAlgorithm(algorithm); + issuerKey.setUse(KeyUse.SIG); + issuerKey.setType(type); + issuerKey.setKid("issuer-key"); + issuerKey.setPrivateKey(issuerKeyPair.getPrivate()); + issuerKey.setPublicKey(issuerKeyPair.getPublic()); + if (includeCertificate) { + issuerKey.setCertificate(CertificateUtils.generateV1SelfSignedCertificate(issuerKeyPair, "issuer-key")); + } + return issuerKey; + } + + private void assertSignedMdoc(MdocIssuerSignedDocument issuerSignedDocument) { + assertNotNull(issuerSignedDocument.getEncodedIssuerSigned()); + assertTrue(issuerSignedDocument.getNamespaces().containsKey(NAMESPACE)); + assertFalse(issuerSignedDocument.getIssuerAuthCertificateChain().isEmpty()); + assertEquals(DOC_TYPE, issuerSignedDocument.getDocType()); + } + + private static MdocValidityInfo builderValidityInfo() { + return new MdocValidityInfo( + Instant.parse("2026-04-24T10:15:30Z"), + Instant.parse("2026-04-24T10:15:30Z"), + Instant.parse("2027-04-24T10:15:30Z") + ); + } + +} diff --git a/services/src/test/java/org/keycloak/protocol/oid4vc/issuance/mappers/OID4VCMapperTest.java b/services/src/test/java/org/keycloak/protocol/oid4vc/issuance/mappers/OID4VCMapperTest.java new file mode 100644 index 000000000000..e46a89f29b85 --- /dev/null +++ b/services/src/test/java/org/keycloak/protocol/oid4vc/issuance/mappers/OID4VCMapperTest.java @@ -0,0 +1,181 @@ +package org.keycloak.protocol.oid4vc.issuance.mappers; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.keycloak.VCFormat; +import org.keycloak.models.ProtocolMapperModel; +import org.keycloak.protocol.ProtocolMapperConfigException; + +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; + +public class OID4VCMapperTest { + + @Test + public void shouldUseNamespaceClaimPathForMdoc() { + OID4VCStaticClaimMapper mapper = new OID4VCStaticClaimMapper(); + mapper.setMapperModel(createStaticClaimMapperModel("given_name", "John", "org.iso.18013.5.1"), VCFormat.MSO_MDOC); + + Map claims = new HashMap<>(); + mapper.setClaim(claims, null); + + assertEquals(List.of("org.iso.18013.5.1", "given_name"), mapper.getMetadataAttributePath()); + assertEquals(Map.of("given_name", "John"), claims); + + Map prefixedClaims = new HashMap<>(); + mapper.setClaimWithMetadataPrefix(claims, prefixedClaims); + assertEquals(Map.of("org.iso.18013.5.1", Map.of("given_name", "John")), prefixedClaims); + } + + @Test + public void shouldCreateNestedMdocClaimPaths() { + OID4VCStaticClaimMapper mapper = new OID4VCStaticClaimMapper(); + mapper.setMapperModel(createStaticClaimMapperModel("address.street", "Main Street", "org.iso.18013.5.1"), VCFormat.MSO_MDOC); + + Map claims = new HashMap<>(); + mapper.setClaim(claims, null); + + assertEquals(List.of("org.iso.18013.5.1", "address", "street"), mapper.getMetadataAttributePath()); + assertEquals(Map.of("address.street", "Main Street"), claims); + + Map prefixedClaims = new HashMap<>(); + mapper.setClaimWithMetadataPrefix(claims, prefixedClaims); + assertEquals(Map.of("org.iso.18013.5.1", Map.of("address", Map.of("street", "Main Street"))), prefixedClaims); + } + + @Test + public void shouldKeepMdocClaimPathsSeparateWhenLeafNamesMatch() { + OID4VCStaticClaimMapper addressMapper = new OID4VCStaticClaimMapper(); + addressMapper.setMapperModel(createStaticClaimMapperModel("address.street", "Main Street", "org.iso.18013.5.1"), VCFormat.MSO_MDOC); + + OID4VCStaticClaimMapper employerMapper = new OID4VCStaticClaimMapper(); + employerMapper.setMapperModel(createStaticClaimMapperModel("employer.street", "Work Street", "org.iso.18013.5.1"), VCFormat.MSO_MDOC); + + Map claims = new HashMap<>(); + addressMapper.setClaim(claims, null); + employerMapper.setClaim(claims, null); + + Map prefixedClaims = new HashMap<>(); + addressMapper.setClaimWithMetadataPrefix(claims, prefixedClaims); + employerMapper.setClaimWithMetadataPrefix(claims, prefixedClaims); + + assertEquals(Map.of("org.iso.18013.5.1", Map.of( + "address", Map.of("street", "Main Street"), + "employer", Map.of("street", "Work Street") + )), prefixedClaims); + } + + @Test + public void shouldKeepNonMdocStaticClaimNamesLiteral() { + OID4VCStaticClaimMapper mapper = new OID4VCStaticClaimMapper(); + mapper.setMapperModel(createStaticClaimMapperModel("address.street", "Main Street", null), VCFormat.JWT_VC); + + Map claims = new HashMap<>(); + mapper.setClaim(claims, null); + + assertEquals(Map.of("address.street", "Main Street"), claims); + + Map prefixedClaims = new HashMap<>(); + mapper.setClaimWithMetadataPrefix(claims, prefixedClaims); + assertEquals(Map.of("credentialSubject", Map.of("address.street", "Main Street")), prefixedClaims); + } + + @Test + public void shouldNotPrefixClaimsForMapperWithoutClaimLookupPath() { + // The type mapper has a fixed metadata path but writes no subject claim, so it must not copy the whole + // claims map into the prefixed map under its metadata key. + OID4VCTypeMapper mapper = new OID4VCTypeMapper(); + ProtocolMapperModel mapperModel = new ProtocolMapperModel(); + mapperModel.setConfig(new HashMap<>()); + mapper.setMapperModel(mapperModel, VCFormat.JWT_VC); + + Map claims = new HashMap<>(); + claims.put("given_name", "John"); + + Map prefixedClaims = new HashMap<>(); + mapper.setClaimWithMetadataPrefix(claims, prefixedClaims); + + assertEquals(Map.of(), prefixedClaims); + } + + @Test + public void shouldPreserveNestedUserAttributeClaimPath() { + OID4VCUserAttributeMapper mapper = new OID4VCUserAttributeMapper(); + mapper.setMapperModel(createUserAttributeMapperModel("address.street", "address"), VCFormat.JWT_VC); + + assertEquals(List.of("credentialSubject", "address", "street"), mapper.getMetadataAttributePath()); + } + + @Test + public void shouldRejectMdocClaimMapperWithoutNamespace() { + OID4VCStaticClaimMapper mapper = new OID4VCStaticClaimMapper(); + + ProtocolMapperConfigException exception = assertThrows(ProtocolMapperConfigException.class, + () -> mapper.validateMdocNamespace(VCFormat.MSO_MDOC, createStaticClaimMapperModel("given_name", "John", null))); + assertEquals("mso_mdoc credential mappers require a non-empty 'mdoc.namespace' configuration.", + exception.getMessage()); + } + + @Test + public void shouldRejectMdocClaimMapperWithNullConfig() { + OID4VCStaticClaimMapper mapper = new OID4VCStaticClaimMapper(); + + assertThrows(ProtocolMapperConfigException.class, + () -> mapper.validateMdocNamespace(VCFormat.MSO_MDOC, new ProtocolMapperModel())); + } + + @Test + public void shouldRejectMdocClaimMapperWithBlankNamespace() { + OID4VCStaticClaimMapper mapper = new OID4VCStaticClaimMapper(); + + assertThrows(ProtocolMapperConfigException.class, + () -> mapper.validateMdocNamespace(VCFormat.MSO_MDOC, createStaticClaimMapperModel("given_name", "John", " "))); + } + + @Test + public void shouldAcceptMdocClaimMapperWithNamespace() throws ProtocolMapperConfigException { + OID4VCStaticClaimMapper mapper = new OID4VCStaticClaimMapper(); + + mapper.validateMdocNamespace(VCFormat.MSO_MDOC, createStaticClaimMapperModel("given_name", "John", "org.iso.18013.5.1")); + } + + @Test + public void shouldNotRequireNamespaceForNonMdocFormat() throws ProtocolMapperConfigException { + OID4VCStaticClaimMapper mapper = new OID4VCStaticClaimMapper(); + + mapper.validateMdocNamespace(VCFormat.JWT_VC, createStaticClaimMapperModel("given_name", "John", null)); + } + + @Test + public void shouldNotRequireNamespaceForMapperNotSupportingMdoc() throws ProtocolMapperConfigException { + // The type mapper does not contribute a namespaced data element to mdoc, so it must not require a namespace. + OID4VCTypeMapper mapper = new OID4VCTypeMapper(); + + mapper.validateMdocNamespace(VCFormat.MSO_MDOC, createStaticClaimMapperModel("given_name", "John", null)); + } + + private static ProtocolMapperModel createStaticClaimMapperModel(String claimName, String claimValue, String mdocNamespace) { + ProtocolMapperModel mapperModel = new ProtocolMapperModel(); + Map config = new HashMap<>(); + config.put(OID4VCMapper.CLAIM_NAME, claimName); + config.put(OID4VCStaticClaimMapper.STATIC_CLAIM_KEY, claimValue); + if (mdocNamespace != null) { + config.put(OID4VCMapper.MDOC_NAMESPACE, mdocNamespace); + } + mapperModel.setConfig(config); + return mapperModel; + } + + private static ProtocolMapperModel createUserAttributeMapperModel(String claimName, String userAttribute) { + ProtocolMapperModel mapperModel = new ProtocolMapperModel(); + Map config = new HashMap<>(); + config.put(OID4VCMapper.CLAIM_NAME, claimName); + config.put(OID4VCMapper.USER_ATTRIBUTE_KEY, userAttribute); + mapperModel.setConfig(config); + return mapperModel; + } +} diff --git a/services/src/test/java/org/keycloak/protocol/oid4vc/model/SupportedCredentialConfigurationTest.java b/services/src/test/java/org/keycloak/protocol/oid4vc/model/SupportedCredentialConfigurationTest.java new file mode 100644 index 000000000000..a3e8bf3e599b --- /dev/null +++ b/services/src/test/java/org/keycloak/protocol/oid4vc/model/SupportedCredentialConfigurationTest.java @@ -0,0 +1,61 @@ +/* + * Copyright 2026 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.keycloak.protocol.oid4vc.model; + +import org.keycloak.VCFormat; + +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; + +public class SupportedCredentialConfigurationTest { + + @Test + public void shouldDeriveVctForSdJwt() { + SupportedCredentialConfiguration config = new SupportedCredentialConfiguration() + .setFormat(VCFormat.SD_JWT_VC) + .setVct("https://credentials.example.com/identity"); + + assertEquals("https://credentials.example.com/identity", config.deriveType().getValue()); + } + + @Test + public void shouldDeriveDocTypeForMdoc() { + SupportedCredentialConfiguration config = new SupportedCredentialConfiguration() + .setFormat(VCFormat.MSO_MDOC) + .setDocType("org.iso.18013.5.1.mDL"); + + assertEquals("org.iso.18013.5.1.mDL", config.deriveType().getValue()); + } + + @Test + public void shouldDeriveNullForMdocWithoutDocType() { + SupportedCredentialConfiguration config = new SupportedCredentialConfiguration() + .setFormat(VCFormat.MSO_MDOC); + + assertNull(config.deriveType()); + } + + @Test + public void shouldDeriveNullForOtherFormats() { + SupportedCredentialConfiguration config = new SupportedCredentialConfiguration() + .setFormat(VCFormat.JWT_VC); + + assertNull(config.deriveType()); + } +} diff --git a/tests/base/src/test/java/org/keycloak/tests/oid4vc/CredentialSigningAlgorithmResolverTest.java b/tests/base/src/test/java/org/keycloak/tests/oid4vc/CredentialSigningAlgorithmResolverTest.java new file mode 100644 index 000000000000..8f682519dc76 --- /dev/null +++ b/tests/base/src/test/java/org/keycloak/tests/oid4vc/CredentialSigningAlgorithmResolverTest.java @@ -0,0 +1,92 @@ +/* + * Copyright 2026 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.keycloak.tests.oid4vc; + +import java.util.List; + +import org.keycloak.admin.client.resource.ComponentsResource; +import org.keycloak.crypto.KeyWrapper; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.RealmModel; +import org.keycloak.models.oid4vci.CredentialScopeModel; +import org.keycloak.protocol.oid4vc.model.SupportedCredentialConfiguration; +import org.keycloak.testframework.annotations.KeycloakIntegrationTest; +import org.keycloak.testframework.annotations.TestSetup; +import org.keycloak.testframework.remote.runonserver.InjectRunOnServer; +import org.keycloak.testframework.remote.runonserver.RunOnServerClient; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +@KeycloakIntegrationTest(config = OID4VCIssuerTestBase.VCTestServerConfig.class) +public class CredentialSigningAlgorithmResolverTest extends OID4VCIssuerTestBase { + + @InjectRunOnServer + RunOnServerClient runOnServer; + + @TestSetup + public void configureTestRealm() { + super.configureTestRealm(); + ComponentsResource components = testRealm.admin().components(); + components.add(getRsaKeyProvider(getRsaKey_Default())).close(); + } + + @Test + public void metadataSigningAlgorithmsFallBackToGlobalListWithoutConfiguredKey() { + runOnServer.run(session -> { + KeyWrapper key = getKeyFromSession(session); + List globalSupportedSigningAlgorithms = List.of("PS256", "ES256", key.getAlgorithm()); + + CredentialScopeModel credentialScope = jwtCredentialScope(session); + credentialScope.setSigningKeyId(null); + credentialScope.setSigningAlg(null); + + SupportedCredentialConfiguration config = SupportedCredentialConfiguration.parse( + session, credentialScope, globalSupportedSigningAlgorithms); + + assertEquals(globalSupportedSigningAlgorithms, config.getCredentialSigningAlgValuesSupported()); + }); + } + + @Test + public void metadataSigningAlgorithmFollowsConfiguredSigningKey() { + runOnServer.run(session -> { + KeyWrapper key = getKeyFromSession(session); + List globalSupportedSigningAlgorithms = List.of("PS256", "ES256", key.getAlgorithm()); + + CredentialScopeModel credentialScope = jwtCredentialScope(session); + credentialScope.setSigningKeyId(key.getKid()); + credentialScope.setSigningAlg(null); + + SupportedCredentialConfiguration config = SupportedCredentialConfiguration.parse( + session, credentialScope, globalSupportedSigningAlgorithms); + + assertEquals(List.of(key.getAlgorithm()), config.getCredentialSigningAlgValuesSupported()); + }); + } + + private static CredentialScopeModel jwtCredentialScope(KeycloakSession session) { + RealmModel realm = session.getContext().getRealm(); + return new CredentialScopeModel( + realm.getClientScopesStream() + .filter(cs -> jwtTypeCredentialScopeName.equals(cs.getName())) + .findFirst() + .orElseThrow()); + } +} diff --git a/tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCExportImportTest.java b/tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCExportImportTest.java index 17c06c28e6c6..8937161088cd 100644 --- a/tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCExportImportTest.java +++ b/tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCExportImportTest.java @@ -68,7 +68,7 @@ public void testExportImportUserVerifiableCredentials() { List verifiableCreds = testRealm.admin().users().get(john.getId()).verifiableCredentials().getCredentials(); Map> userAttributes = john.getRawAttributes(); assertNotNull(userAttributes); - assertUserCredentials(verifiableCreds, userAttributes, jwtTypeCredentialScopeName, sdJwtTypeCredentialScopeName, minimalJwtTypeCredentialScopeName, jwtTypeNaturalPersonScopeName, sdJwtTypeNaturalPersonScopeName); + assertUserCredentials(verifiableCreds, userAttributes, jwtTypeCredentialScopeName, sdJwtTypeCredentialScopeName, mdocTypeCredentialScopeName, minimalJwtTypeCredentialScopeName, jwtTypeNaturalPersonScopeName, sdJwtTypeNaturalPersonScopeName); // Export realm exportRealm("oid4vc-test-realm.json"); @@ -88,7 +88,7 @@ public void testExportImportUserVerifiableCredentials() { assertEquals(verifiableCreds, importedVerifiableCreds); // Verify each credential's userAttributes are preserved - assertUserCredentials(importedVerifiableCreds, userAttributes, jwtTypeCredentialScopeName, sdJwtTypeCredentialScopeName, minimalJwtTypeCredentialScopeName, jwtTypeNaturalPersonScopeName, sdJwtTypeNaturalPersonScopeName); + assertUserCredentials(importedVerifiableCreds, userAttributes, jwtTypeCredentialScopeName, sdJwtTypeCredentialScopeName, mdocTypeCredentialScopeName, minimalJwtTypeCredentialScopeName, jwtTypeNaturalPersonScopeName, sdJwtTypeNaturalPersonScopeName); } 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 bc3e5e77dbd0..58c87ddc12ab 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 @@ -11,6 +11,8 @@ import org.keycloak.broker.trust.DefaultTrustIdentityProviderFactory; import org.keycloak.crypto.KeyWrapper; import org.keycloak.jose.jwk.JSONWebKeySet; +import org.keycloak.mdoc.MdocIssuerSignedDocument; +import org.keycloak.protocol.oid4vc.OID4VCLoginProtocolFactory; import org.keycloak.protocol.oid4vc.model.CredentialDefinition; import org.keycloak.protocol.oid4vc.model.CredentialResponse; import org.keycloak.protocol.oid4vc.model.CredentialScopeRepresentation; @@ -34,13 +36,15 @@ import static org.keycloak.OID4VCConstants.CLAIM_NAME_VCT; import static org.keycloak.VCFormat.JWT_VC; +import static org.keycloak.VCFormat.MSO_MDOC; import static org.keycloak.VCFormat.SD_JWT_VC; import static org.keycloak.tests.oid4vc.OID4VCProofTestUtils.createEcKeyPair; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; import static org.junit.jupiter.api.Assertions.assertNotNull; -@KeycloakIntegrationTest(config = OID4VCIssuerTestBase.VCTestServerConfig.class) +@KeycloakIntegrationTest(config = OID4VCMdocTestBase.VCTestServerWithMdocEnabled.class) public class OID4VCINaturalPersonTest extends OID4VCIssuerTestBase { private static KeyWrapper attestationKey; @@ -121,6 +125,31 @@ public void testNaturalPersonSdJwt_JwtProof() throws Exception { verifyCredentialResponse(ctx, ctx.getHolder(), credResponse); } + @Test + public void testNaturalPersonMdoc_AttestationProof() throws Exception { + + ensureEcSigningKeyProvider("mdoc-natural-person-issuer-key", "P-256", "ES256", 200); + var ctx = new OID4VCTestContext(client, mdocNaturalPersonCredentialScope); + + Proofs proofs = wallet.generateAttestationProof(ctx, attestationKey); + + String accessToken = getAccessToken(ctx); + CredentialResponse credResponse = getCredentialResponse(ctx, accessToken, proofs); + verifyCredentialResponse(ctx, ctx.getHolder(), credResponse); + } + + @Test + public void testNaturalPersonMdoc_JwtProof() throws Exception { + + ensureEcSigningKeyProvider("mdoc-natural-person-issuer-key", "P-256", "ES256", 200); + var ctx = new OID4VCTestContext(client, mdocNaturalPersonCredentialScope); + + String accessToken = getAccessToken(ctx); + Proofs proofs = wallet.generateJwtProof(ctx); + CredentialResponse credResponse = getCredentialResponse(ctx, accessToken, proofs); + verifyCredentialResponse(ctx, ctx.getHolder(), credResponse); + } + // Private --------------------------------------------------------------------------------------------------------- private String getAccessToken(OID4VCTestContext ctx) { @@ -193,6 +222,20 @@ private void verifyCredentialResponse(OID4VCTestContext ctx, String expUser, Cre "sub", "alice" ), claims); } + case MSO_MDOC -> { + MdocIssuerSignedDocument mdoc = MdocIssuerSignedDocument.parse((String) credentialObj.getCredential()); + assertEquals(credScope.getVct(), mdoc.getMobileSecurityObject().get("docType")); + + Map nameSpaces = mdoc.getNamespaces(); + Map namespaceClaims = assertInstanceOf(Map.class, + nameSpaces.get(OID4VCLoginProtocolFactory.NATURAL_PERSON_MDOC_NAMESPACE)); + assertEquals(Map.of( + "id", "alice", + "email", "alice@email.cz", + "firstName", "Alice", + "familyName", "Wonderland" + ), namespaceClaims); + } } } } diff --git a/tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCIssuerTestBase.java b/tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCIssuerTestBase.java index 506969b849a2..67472a3c2bd3 100644 --- a/tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCIssuerTestBase.java +++ b/tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCIssuerTestBase.java @@ -57,6 +57,7 @@ import org.keycloak.protocol.oid4vc.issuance.TimeProvider; import org.keycloak.protocol.oid4vc.issuance.mappers.OID4VCGeneratedIdMapper; import org.keycloak.protocol.oid4vc.issuance.mappers.OID4VCIssuedAtTimeClaimMapper; +import org.keycloak.protocol.oid4vc.issuance.mappers.OID4VCMapper; import org.keycloak.protocol.oid4vc.model.CredentialScopeRepresentation; import org.keycloak.protocol.oid4vc.model.CredentialSubject; import org.keycloak.protocol.oid4vc.model.DisplayObject; @@ -75,6 +76,7 @@ import org.keycloak.representations.idm.RealmRepresentation; import org.keycloak.representations.idm.RoleRepresentation; import org.keycloak.representations.idm.UserRepresentation; +import org.keycloak.representations.idm.oid4vc.UserVerifiableCredentialRepresentation; import org.keycloak.representations.userprofile.config.UPConfig; import org.keycloak.services.clientpolicy.executor.ConfidentialClientAcceptExecutorFactory; import org.keycloak.services.clientpolicy.executor.DPoPBindEnforcerExecutorFactory; @@ -125,6 +127,7 @@ import org.junit.jupiter.api.BeforeEach; import static org.keycloak.OID4VCConstants.CLAIM_NAME_SUBJECT_ID; +import static org.keycloak.OID4VCConstants.CRYPTOGRAPHIC_BINDING_METHOD_COSE_KEY; import static org.keycloak.OID4VCConstants.KeyAttestationResistanceLevels.MODERATE; import static org.keycloak.OID4VCConstants.OID4VCI_ENABLED_ATTRIBUTE_KEY; import static org.keycloak.OID4VCConstants.OPENID_CREDENTIAL; @@ -160,6 +163,7 @@ public abstract class OID4VCIssuerTestBase { public static final String jwtTypeNaturalPersonScopeName = "oid4vc_natural_person_jwt"; public static final String sdJwtTypeNaturalPersonScopeName = "oid4vc_natural_person_sd"; + public static final String mdocTypeNaturalPersonScopeName = "oid4vc_natural_person_mdoc"; public static final String sdJwtTypeCredentialScopeName = "sd-jwt-credential"; public static final String sdJwtTypeCredentialConfigurationIdName = "sd-jwt-credential-config-id"; @@ -172,6 +176,10 @@ public abstract class OID4VCIssuerTestBase { public static final String minimalJwtTypeCredentialScopeName = "vc-with-minimal-config"; public static final String minimalJwtTypeCredentialConfigurationIdName = "vc-with-minimal-config-id"; + public static final String mdocTypeCredentialScopeName = "mdoc-credential"; + public static final String mdocTypeCredentialConfigurationIdName = "mdoc-credential-config-id"; + public static final String mdocTypeCredentialDocType = "org.iso.18013.5.1.mDL"; + public static final String CONTEXT_URL = "https://www.w3.org/2018/credentials/v1"; public static final List TEST_TYPES = List.of("VerifiableCredential"); public static final Instant TEST_EXPIRATION_DATE = Instant.ofEpochMilli(Time.currentTimeMillis()) @@ -223,9 +231,11 @@ public abstract class OID4VCIssuerTestBase { protected CredentialScopeRepresentation jwtTypeCredentialScope; protected CredentialScopeRepresentation sdJwtTypeCredentialScope; protected CredentialScopeRepresentation keyAttestationCredentialScope; + protected CredentialScopeRepresentation mdocTypeCredentialScope; protected CredentialScopeRepresentation minimalJwtTypeCredentialScope; protected CredentialScopeRepresentation jwtNaturalPersonCredentialScope; protected CredentialScopeRepresentation sdJwtNaturalPersonCredentialScope; + protected CredentialScopeRepresentation mdocNaturalPersonCredentialScope; protected ClientRepresentation client; protected ClientRepresentation abcaClient; @@ -249,6 +259,27 @@ public void configureTestRealm() { }); }); + // The natural person mdoc scope is created automatically on servers with the mdoc feature enabled. Since the + // realm representation cannot reference it upfront, attach it to the wallet clients and test users here. + boolean isMdocEnabled = runOnServer.fetch(session -> Profile.isFeatureEnabled(Profile.Feature.OID4VC_MDOC), Boolean.class); + if (isMdocEnabled) { + CredentialScopeRepresentation mdocNaturalPerson = requireExistingCredentialScope(mdocTypeNaturalPersonScopeName); + for (String clientId : List.of(OID4VCI_CLIENT_ID, OID4VCI_ABCA_CLIENT_ID, OID4VCI_PUBLIC_CLIENT_ID)) { + ClientRepresentation clientRep = realmResource.clients().findByClientId(clientId).get(0); + realmResource.clients().get(clientRep.getId()).addOptionalClientScope(mdocNaturalPerson.getId()); + } + for (String username : List.of("john", "alice")) { + var credentialsResource = realmResource.users().get(requireExistingUser(username).getId()).verifiableCredentials(); + boolean alreadyPresent = credentialsResource.getCredentials().stream() + .anyMatch(cred -> mdocTypeNaturalPersonScopeName.equals(cred.getCredentialScopeName())); + if (!alreadyPresent) { + UserVerifiableCredentialRepresentation cred = new UserVerifiableCredentialRepresentation(); + cred.setCredentialScopeName(mdocTypeNaturalPersonScopeName); + credentialsResource.createCredential(cred); + } + } + } + AuthorizationDetailsParser.registerParser(OPENID_CREDENTIAL, new OID4VCAuthorizationDetailsParser()); boolean isRestCredentialEnabled = runOnServer.fetch(session -> Profile.isFeatureEnabled(Profile.Feature.OID4VC_VCI_REST_CREDENTIAL_OFFER), Boolean.class); @@ -271,9 +302,12 @@ void beforeEachBase() { jwtTypeCredentialScope = requireExistingCredentialScope(jwtTypeCredentialScopeName); sdJwtTypeCredentialScope = requireExistingCredentialScope(sdJwtTypeCredentialScopeName); keyAttestationCredentialScope = requireExistingCredentialScope(keyAttestationCredentialScopeName); + mdocTypeCredentialScope = requireExistingCredentialScope(mdocTypeCredentialScopeName); minimalJwtTypeCredentialScope = requireExistingCredentialScope(minimalJwtTypeCredentialScopeName); jwtNaturalPersonCredentialScope = requireExistingCredentialScope(jwtTypeNaturalPersonScopeName); sdJwtNaturalPersonCredentialScope = requireExistingCredentialScope(sdJwtTypeNaturalPersonScopeName); + // created automatically by the server, so it exists only when the mdoc feature is enabled + mdocNaturalPersonCredentialScope = getCredentialScope(mdocTypeNaturalPersonScopeName); oauth.client(client.getClientId(), client.getSecret()); enableVerifiableCredentialEvents(); @@ -421,6 +455,14 @@ protected ComponentRepresentation getAesKeyProvider(String algorithm, String key return component; } + protected void ensureEcSigningKeyProvider(String keyName, String ellipticCurve, String algorithm, int priority) { + boolean alreadyPresent = testRealm.admin().components().query(null, KeyProvider.class.getName()).stream() + .anyMatch(component -> keyName.equals(component.getName())); + if (!alreadyPresent) { + testRealm.admin().components().add(getEcKeyProvider(keyName, ellipticCurve, algorithm, priority)).close(); + } + } + protected String getBearerToken(OAuthClient oauthClient) { return getBearerToken(oauthClient, null); } @@ -631,6 +673,24 @@ private ComponentRepresentation createRsaKeyProviderComponentWithNonSelfSignedLe } } + private static ComponentRepresentation getEcKeyProvider(String keyName, String ellipticCurve, String algorithm, int priority) { + ComponentRepresentation component = new ComponentRepresentation(); + component.setProviderType(KeyProvider.class.getName()); + component.setName(keyName); + component.setId(UUID.randomUUID().toString()); + component.setProviderId("ecdsa-generated"); + component.setConfig(new MultivaluedHashMap<>(Map.of( + "active", List.of("true"), + "priority", List.of(String.valueOf(priority)), + "enabled", List.of("true"), + "algorithm", List.of(algorithm), + "keyUse", List.of(KeyUse.SIG.name()), + "ecdsaEllipticCurveKey", List.of(ellipticCurve), + "ecGenerateCertificate", List.of("true") + ))); + return component; + } + private void enableVerifiableCredentialEvents() { RealmEventsConfigRepresentation realmEventsConfig = testRealm.admin().getRealmEventsConfig(); List enabledEventTypes = realmEventsConfig.getEnabledEventTypes(); @@ -736,6 +796,8 @@ public RealmBuilder configure(RealmBuilder realm) { null )); + realm.clientScopes(createMdocCredentialScope()); + realm.users(createUser("John Doe", Map.of(), List.of(), Collections.emptyMap())); realm.users(createUser("Alice Wonderland", Map.of(), List.of(), Map.of())); @@ -972,6 +1034,27 @@ private CredentialScopeRepresentation configureBinding(CredentialScopeRepresenta return scope; } + private CredentialScopeRepresentation createMdocCredentialScope() { + CredentialScopeRepresentation cs = new CredentialScopeRepresentation(mdocTypeCredentialScopeName) + .setIncludeInTokenScope(true) + .setExpiryInSeconds(CREDENTIALS_EXPIRATION_IN_SECONDS) + .setCredentialConfigurationId(mdocTypeCredentialConfigurationIdName) + .setCredentialIdentifier(mdocTypeCredentialScopeName) + .setFormat(VCFormat.MSO_MDOC) + .setVct(mdocTypeCredentialDocType) + .setSigningAlg("ES256") + .setBindingRequired(true) + .setCryptographicBindingMethods(List.of(CRYPTOGRAPHIC_BINDING_METHOD_COSE_KEY)); + cs.setProtocolMappers(List.of( + ProtocolMapperUtils.getUserAttributeMapper("given_name", "firstName", "org.iso.18013.5.1"), + ProtocolMapperUtils.getUserAttributeMapper("family_name", "lastName", "org.iso.18013.5.1"), + ProtocolMapperUtils.getSubjectIdMapper("id", UserModel.USERNAME, "org.iso.18013.5.1") + )); + cs.getAttributes().put(VC_BINDING_REQUIRED_PROOF_TYPES, "jwt"); + + return cs; + } + private UserRepresentation createUser( String fullName, Map attributes, @@ -999,7 +1082,8 @@ private UserRepresentation createUser( .verifiableCredential(sdJwtTypeCredentialScopeName) .verifiableCredential(minimalJwtTypeCredentialScopeName) .verifiableCredential(jwtTypeNaturalPersonScopeName) - .verifiableCredential(sdJwtTypeNaturalPersonScopeName); + .verifiableCredential(sdJwtTypeNaturalPersonScopeName) + .verifiableCredential(mdocTypeCredentialScopeName); attributes.forEach(userBuilder::attribute); @@ -1034,6 +1118,7 @@ public ClientBuilder configure(ClientBuilder client) { minimalJwtTypeCredentialScopeName, jwtTypeNaturalPersonScopeName, sdJwtTypeNaturalPersonScopeName, + mdocTypeCredentialScopeName, "email" }; client.clientId(OID4VCI_ABCA_CLIENT_ID) @@ -1060,6 +1145,7 @@ public ClientBuilder configure(ClientBuilder client) { minimalJwtTypeCredentialScopeName, jwtTypeNaturalPersonScopeName, sdJwtTypeNaturalPersonScopeName, + mdocTypeCredentialScopeName, "email" }; client.clientId(OID4VCI_CLIENT_ID) @@ -1084,6 +1170,7 @@ public ClientBuilder configure(ClientBuilder client) { minimalJwtTypeCredentialScopeName, jwtTypeNaturalPersonScopeName, sdJwtTypeNaturalPersonScopeName, + mdocTypeCredentialScopeName, "email" }; client.clientId(OID4VCI_PUBLIC_CLIENT_ID) @@ -1168,6 +1255,14 @@ static ProtocolMapperRepresentation getSubjectIdMapper(String subjectProperty, S return protocolMapperRepresentation; } + static ProtocolMapperRepresentation getSubjectIdMapper(String subjectProperty, String attributeName, String mdocNamespace) { + ProtocolMapperRepresentation protocolMapperRepresentation = getSubjectIdMapper(subjectProperty, attributeName); + Map config = new HashMap<>(protocolMapperRepresentation.getConfig()); + config.put(OID4VCMapper.MDOC_NAMESPACE, mdocNamespace); + protocolMapperRepresentation.setConfig(config); + return protocolMapperRepresentation; + } + static ProtocolMapperRepresentation getUserAttributeMapper(String subjectProperty, String attributeName) { ProtocolMapperRepresentation protocolMapperRepresentation = new ProtocolMapperRepresentation(); protocolMapperRepresentation.setName(attributeName + "-mapper"); @@ -1182,6 +1277,14 @@ static ProtocolMapperRepresentation getUserAttributeMapper(String subjectPropert return protocolMapperRepresentation; } + static ProtocolMapperRepresentation getUserAttributeMapper(String subjectProperty, String attributeName, String mdocNamespace) { + ProtocolMapperRepresentation protocolMapperRepresentation = getUserAttributeMapper(subjectProperty, attributeName); + Map config = new HashMap<>(protocolMapperRepresentation.getConfig()); + config.put(OID4VCMapper.MDOC_NAMESPACE, mdocNamespace); + protocolMapperRepresentation.setConfig(config); + return protocolMapperRepresentation; + } + static ProtocolMapperRepresentation getRoleMapper(String clientId) { ProtocolMapperRepresentation protocolMapperRepresentation = new ProtocolMapperRepresentation(); protocolMapperRepresentation.setName("role-mapper"); 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 89ea881373d4..0f196a6672e5 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 @@ -684,7 +684,7 @@ private void compareMetadataToClientScope(CredentialIssuer credentialIssuer, Cre "jwt_vc_json credentials should not have @context in credential_definition"); } - List signingAlgsSupported = supportedConfig.getCredentialSigningAlgValuesSupported(); + List signingAlgsSupported = supportedConfig.getCredentialSigningAlgValuesSupported(); ProofTypesSupported proofTypesSupported = supportedConfig.getProofTypesSupported(); if (!bindingRequired) { assertNull(proofTypesSupported, "proof_types_supported should be omitted when binding is optional"); @@ -763,7 +763,7 @@ private void compareMetadataToClientScope(CredentialIssuer credentialIssuer, Cre compareClaims(expectedFormat, supportedConfig.getCredentialMetadata().getClaims(), credScope.getProtocolMappers()); } - private static List getAllAsymmetricAlgorithms() { + public static List getAllAsymmetricAlgorithms() { return List.of( Algorithm.PS256, Algorithm.PS384, Algorithm.PS512, Algorithm.RS256, Algorithm.RS384, Algorithm.RS512, @@ -843,6 +843,16 @@ private void compareClaims(String credentialFormat, }); } + public static void assertHasClaimPath(SupportedCredentialConfiguration supportedConfig, List expectedPath) { + assertNotNull(supportedConfig.getCredentialMetadata()); + assertNotNull(supportedConfig.getCredentialMetadata().getClaims()); + List> actualPaths = supportedConfig.getCredentialMetadata().getClaims().stream() + .map(Claim::getPath) + .toList(); + assertTrue(actualPaths.stream().anyMatch(expectedPath::equals), + "Missing claim path " + expectedPath + " in " + actualPaths); + } + private void testBatchSizeValidation(String batchSize, boolean shouldBePresent, Integer expectedValue) { runOnServer.run(session -> { // Create a new isolated realm for testing diff --git a/tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCMdocFeatureDisabledWellKnownProviderTest.java b/tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCMdocFeatureDisabledWellKnownProviderTest.java new file mode 100644 index 000000000000..bd3c5ed1602d --- /dev/null +++ b/tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCMdocFeatureDisabledWellKnownProviderTest.java @@ -0,0 +1,47 @@ +/* + * Copyright 2026 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.keycloak.tests.oid4vc; + +import org.keycloak.protocol.oid4vc.model.CredentialIssuer; +import org.keycloak.protocol.oid4vc.model.CredentialScopeRepresentation; +import org.keycloak.testframework.annotations.KeycloakIntegrationTest; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; + +@KeycloakIntegrationTest(config = OID4VCIssuerTestBase.VCTestServerConfig.class) +public class OID4VCMdocFeatureDisabledWellKnownProviderTest extends OID4VCMdocTestBase { + + @Test + void testMdocCredentialConfigurationHiddenWhenFeatureDisabled() { + CredentialScopeRepresentation mdocScope = createMdocCredentialScope("mdoc-feature-off-scope", "mdoc-feature-off-config"); + + CredentialIssuer credentialIssuer = oauth.oid4vc().doIssuerMetadataRequest().getMetadata(); + assertNotNull(credentialIssuer); + assertFalse(credentialIssuer.getCredentialsSupported().containsKey(mdocScope.getCredentialConfigurationId()), + "mso_mdoc configuration must not be advertised when the mDoc feature is disabled"); + } + + @Test + void testNaturalPersonMdocScopeNotAutoCreatedWhenFeatureDisabled() { + assertNull(getCredentialScope(mdocTypeNaturalPersonScopeName), + "the natural person mdoc scope must only be auto created when the mDoc feature is enabled"); + } +} diff --git a/tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCMdocIssuerEndpointTest.java b/tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCMdocIssuerEndpointTest.java new file mode 100644 index 000000000000..a64546b2e4d6 --- /dev/null +++ b/tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCMdocIssuerEndpointTest.java @@ -0,0 +1,225 @@ +/* + * Copyright 2026 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.keycloak.tests.oid4vc; + +import java.util.List; + +import org.keycloak.crypto.Algorithm; +import org.keycloak.models.UserModel; +import org.keycloak.protocol.oid4vc.model.CredentialIssuer; +import org.keycloak.protocol.oid4vc.model.CredentialScopeRepresentation; +import org.keycloak.protocol.oid4vc.model.OID4VCAuthorizationDetail; +import org.keycloak.testframework.annotations.KeycloakIntegrationTest; +import org.keycloak.testsuite.util.oauth.AccessTokenResponse; +import org.keycloak.testsuite.util.oauth.AuthorizationEndpointResponse; +import org.keycloak.testsuite.util.oauth.oid4vc.Oid4vcCredentialResponse; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.keycloak.OID4VCConstants.OPENID_CREDENTIAL; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@KeycloakIntegrationTest(config = OID4VCMdocTestBase.VCTestServerWithMdocEnabled.class) +public class OID4VCMdocIssuerEndpointTest extends OID4VCMdocTestBase { + + private CredentialScopeRepresentation mdocScope; + + @BeforeEach + void setUpMdocScope() { + ensureEcSigningKeyProvider("mdoc-endpoint-issuer-key", "P-256", "ES256", 200); + mdocScope = createMdocCredentialScope("mdoc-endpoint-scope", "mdoc-endpoint-config"); + } + + @Test + void testCredentialRequestByIdentifierReturnsMdocCredential() { + OID4VCTestContext ctx = new OID4VCTestContext(client, mdocScope); + CredentialIssuer issuerMetadata = wallet.getIssuerMetadata(ctx); + OID4VCAuthorizationDetail authDetail = createAuthorizationDetail(ctx, issuerMetadata); + + AuthorizationEndpointResponse authResponse = wallet.authorizationRequest() + .scope(ctx.getScope()) + .authorizationDetails(authDetail) + .send(TEST_USER, TEST_PASSWORD); + String authCode = authResponse.getCode(); + assertNotNull(authCode, "Authorization code should not be null"); + + AccessTokenResponse tokenResponse = oauth.accessTokenRequest(authCode) + .authorizationDetails(List.of(authDetail)) + .send(); + assertEquals(200, tokenResponse.getStatusCode()); + + String credentialIdentifier = tokenResponse.getOID4VCAuthorizationDetails().get(0).getCredentialIdentifiers().get(0); + Oid4vcCredentialResponse credentialResponse = oauth.oid4vc().credentialRequest() + .credentialIdentifier(credentialIdentifier) + .proofs(wallet.generateJwtProof(ctx)) + .bearerToken(tokenResponse.getAccessToken()) + .send(); + + assertEquals(200, credentialResponse.getStatusCode()); + assertValidMdocCredential(credentialResponse.getCredentialResponse(), true); + } + + @Test + void testCredentialRequestRejectsUnknownMdocIdentifier() { + OID4VCTestContext ctx = new OID4VCTestContext(client, mdocScope); + CredentialIssuer issuerMetadata = wallet.getIssuerMetadata(ctx); + OID4VCAuthorizationDetail authDetail = createAuthorizationDetail(ctx, issuerMetadata); + + AuthorizationEndpointResponse authResponse = wallet.authorizationRequest() + .scope(ctx.getScope()) + .authorizationDetails(authDetail) + .send(TEST_USER, TEST_PASSWORD); + String authCode = authResponse.getCode(); + assertNotNull(authCode, "Authorization code should not be null"); + + AccessTokenResponse tokenResponse = oauth.accessTokenRequest(authCode) + .authorizationDetails(List.of(authDetail)) + .send(); + assertEquals(200, tokenResponse.getStatusCode()); + + Oid4vcCredentialResponse credentialResponse = oauth.oid4vc().credentialRequest() + .credentialIdentifier("invalid-credential-identifier") + .proofs(wallet.generateJwtProof(ctx)) + .bearerToken(tokenResponse.getAccessToken()) + .send(); + + assertEquals(400, credentialResponse.getStatusCode()); + assertEquals("unknown_credential_identifier", credentialResponse.getError()); + assertTrue(credentialResponse.getErrorDescription().contains("credential_identifier"), + credentialResponse.getErrorDescription()); + } + + @Test + void testCredentialRequestWithoutProofSucceedsWhenMdocBindingIsOptional() { + CredentialScopeRepresentation optionalBindingScope = createCustomMdocCredentialScope( + "mdoc-optional-binding-endpoint-scope", + "mdoc-optional-binding-endpoint-config", + List.of( + ProtocolMapperUtils.getUserAttributeMapper("given_name", "firstName", "org.iso.18013.5.1"), + ProtocolMapperUtils.getSubjectIdMapper("id", UserModel.USERNAME, "org.iso.18013.5.1") + ), + null, + false + ); + + OID4VCTestContext ctx = new OID4VCTestContext(client, optionalBindingScope); + CredentialIssuer issuerMetadata = wallet.getIssuerMetadata(ctx); + OID4VCAuthorizationDetail authDetail = createAuthorizationDetail(ctx, issuerMetadata); + + AuthorizationEndpointResponse authResponse = wallet.authorizationRequest() + .scope(ctx.getScope()) + .authorizationDetails(authDetail) + .send(TEST_USER, TEST_PASSWORD); + String authCode = authResponse.getCode(); + assertNotNull(authCode, "Authorization code should not be null"); + + AccessTokenResponse tokenResponse = oauth.accessTokenRequest(authCode) + .authorizationDetails(List.of(authDetail)) + .send(); + assertEquals(200, tokenResponse.getStatusCode()); + + String credentialIdentifier = tokenResponse.getOID4VCAuthorizationDetails().get(0).getCredentialIdentifiers().get(0); + Oid4vcCredentialResponse credentialResponse = oauth.oid4vc().credentialRequest() + .credentialIdentifier(credentialIdentifier) + .bearerToken(tokenResponse.getAccessToken()) + .send(); + + assertEquals(200, credentialResponse.getStatusCode()); + assertValidMdocCredential(credentialResponse.getCredentialResponse(), false); + } + + @Test + void testCredentialRequestWithoutProofFailsWhenMdocBindingIsRequired() { + OID4VCTestContext ctx = new OID4VCTestContext(client, mdocScope); + CredentialIssuer issuerMetadata = wallet.getIssuerMetadata(ctx); + OID4VCAuthorizationDetail authDetail = createAuthorizationDetail(ctx, issuerMetadata); + + AuthorizationEndpointResponse authResponse = wallet.authorizationRequest() + .scope(ctx.getScope()) + .authorizationDetails(authDetail) + .send(TEST_USER, TEST_PASSWORD); + String authCode = authResponse.getCode(); + assertNotNull(authCode, "Authorization code should not be null"); + + AccessTokenResponse tokenResponse = oauth.accessTokenRequest(authCode) + .authorizationDetails(List.of(authDetail)) + .send(); + assertEquals(200, tokenResponse.getStatusCode()); + + String credentialIdentifier = tokenResponse.getOID4VCAuthorizationDetails().get(0).getCredentialIdentifiers().get(0); + Oid4vcCredentialResponse credentialResponse = oauth.oid4vc().credentialRequest() + .credentialIdentifier(credentialIdentifier) + .bearerToken(tokenResponse.getAccessToken()) + .send(); + + assertEquals(400, credentialResponse.getStatusCode()); + assertEquals("invalid_proof", credentialResponse.getError()); + } + + @Test + void testCredentialRequestSupportsRsaIssuerSigning() { + CredentialScopeRepresentation rsaScope = createCustomMdocCredentialScope( + "mdoc-rsa-endpoint-scope", + "mdoc-rsa-endpoint-config", + List.of( + ProtocolMapperUtils.getUserAttributeMapper("given_name", "firstName", "org.iso.18013.5.1"), + ProtocolMapperUtils.getSubjectIdMapper("id", UserModel.USERNAME, "org.iso.18013.5.1") + ), + Algorithm.RS256, + true + ); + + OID4VCTestContext ctx = new OID4VCTestContext(client, rsaScope); + CredentialIssuer issuerMetadata = wallet.getIssuerMetadata(ctx); + OID4VCAuthorizationDetail authDetail = createAuthorizationDetail(ctx, issuerMetadata); + + AuthorizationEndpointResponse authResponse = wallet.authorizationRequest() + .scope(ctx.getScope()) + .authorizationDetails(authDetail) + .send(TEST_USER, TEST_PASSWORD); + String authCode = authResponse.getCode(); + assertNotNull(authCode, "Authorization code should not be null"); + + AccessTokenResponse tokenResponse = oauth.accessTokenRequest(authCode) + .authorizationDetails(List.of(authDetail)) + .send(); + assertEquals(200, tokenResponse.getStatusCode()); + + String credentialIdentifier = tokenResponse.getOID4VCAuthorizationDetails().get(0).getCredentialIdentifiers().get(0); + Oid4vcCredentialResponse credentialResponse = oauth.oid4vc().credentialRequest() + .credentialIdentifier(credentialIdentifier) + .proofs(wallet.generateJwtProof(ctx)) + .bearerToken(tokenResponse.getAccessToken()) + .send(); + + assertEquals(200, credentialResponse.getStatusCode()); + assertValidMdocCredential(credentialResponse.getCredentialResponse(), true); + } + + private OID4VCAuthorizationDetail createAuthorizationDetail(OID4VCTestContext ctx, CredentialIssuer issuerMetadata) { + OID4VCAuthorizationDetail authDetail = new OID4VCAuthorizationDetail(); + authDetail.setType(OPENID_CREDENTIAL); + authDetail.setCredentialConfigurationId(ctx.getCredentialConfigurationId()); + authDetail.setLocations(List.of(issuerMetadata.getCredentialIssuer())); + return authDetail; + } + +} diff --git a/tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCMdocIssuerWellKnownProviderTest.java b/tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCMdocIssuerWellKnownProviderTest.java new file mode 100644 index 000000000000..e3b7de865add --- /dev/null +++ b/tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCMdocIssuerWellKnownProviderTest.java @@ -0,0 +1,207 @@ +/* + * Copyright 2026 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.keycloak.tests.oid4vc; + +import java.util.List; +import java.util.Set; + +import org.keycloak.VCFormat; +import org.keycloak.crypto.Algorithm; +import org.keycloak.crypto.KeyStatus; +import org.keycloak.crypto.KeyUse; +import org.keycloak.mdoc.MdocAlgorithm; +import org.keycloak.models.oid4vci.CredentialScopeModel; +import org.keycloak.protocol.oid4vc.issuance.mappers.OID4VCMapper; +import org.keycloak.protocol.oid4vc.model.CredentialIssuer; +import org.keycloak.protocol.oid4vc.model.CredentialScopeRepresentation; +import org.keycloak.protocol.oid4vc.model.ProofType; +import org.keycloak.protocol.oid4vc.model.SupportedCredentialConfiguration; +import org.keycloak.protocol.oid4vc.model.SupportedProofTypeData; +import org.keycloak.representations.idm.ProtocolMapperRepresentation; +import org.keycloak.testframework.annotations.KeycloakIntegrationTest; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.keycloak.OID4VCConstants.CRYPTOGRAPHIC_BINDING_METHOD_COSE_KEY; +import static org.keycloak.protocol.oid4vc.OID4VCLoginProtocolFactory.NATURAL_PERSON_MDOC_NAMESPACE; +import static org.keycloak.tests.oid4vc.OID4VCIssuerWellKnownProviderTest.assertHasClaimPath; +import static org.keycloak.tests.oid4vc.OID4VCIssuerWellKnownProviderTest.getAllAsymmetricAlgorithms; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@KeycloakIntegrationTest(config = OID4VCMdocTestBase.VCTestServerWithMdocEnabled.class) +public class OID4VCMdocIssuerWellKnownProviderTest extends OID4VCMdocTestBase { + + private CredentialScopeRepresentation mdocScope; + + @BeforeEach + void setUpMdocScope() { + ensureEcSigningKeyProvider("mdoc-issuer-key", "P-256", "ES256", 200); + mdocScope = createMdocCredentialScope("mdoc-feature-on-scope", "mdoc-feature-on-config"); + } + + @Test + void testMdocCredentialConfigurationMetadata() { + CredentialIssuer credentialIssuer = oauth.oid4vc().doIssuerMetadataRequest().getMetadata(); + SupportedCredentialConfiguration supportedConfig = + credentialIssuer.getCredentialsSupported().get(mdocScope.getCredentialConfigurationId()); + + assertNotNull(supportedConfig, "mso_mdoc configuration must be advertised when the mDoc feature is enabled"); + assertEquals(VCFormat.MSO_MDOC, supportedConfig.getFormat()); + assertEquals(mdocTypeCredentialDocType, supportedConfig.getDocType()); + assertEquals(List.of(CRYPTOGRAPHIC_BINDING_METHOD_COSE_KEY), supportedConfig.getCryptographicBindingMethodsSupported()); + assertEquals(List.of(-7), supportedConfig.getCredentialSigningAlgValuesSupported()); + assertNotNull(supportedConfig.getProofTypesSupported()); + + SupportedProofTypeData jwtProofSupport = + supportedConfig.getProofTypesSupported().getSupportedProofTypes().get(ProofType.JWT); + assertNotNull(jwtProofSupport, "JWT proof support must be advertised for mDoc binding"); + assertEquals(Set.copyOf(getAllAsymmetricAlgorithms()), Set.copyOf(jwtProofSupport.getSigningAlgorithmsSupported())); + + assertHasClaimPath(supportedConfig, List.of("org.iso.18013.5.1", "given_name")); + assertHasClaimPath(supportedConfig, List.of("org.iso.18013.5.1", "family_name")); + assertHasClaimPath(supportedConfig, List.of("org.iso.18013.5.1", "id")); + } + + @Test + void testNaturalPersonMdocScopeIsAutoCreated() { + CredentialScopeRepresentation scope = requireExistingCredentialScope(mdocTypeNaturalPersonScopeName); + assertEquals(VCFormat.MSO_MDOC, scope.getFormat()); + assertEquals(NATURAL_PERSON_MDOC_NAMESPACE, scope.getVct()); + assertFalse(scope.getProtocolMappers().isEmpty()); + scope.getProtocolMappers().forEach(mapper -> + assertEquals(NATURAL_PERSON_MDOC_NAMESPACE, mapper.getConfig().get(OID4VCMapper.MDOC_NAMESPACE), + "mapper '" + mapper.getName() + "' must carry the example mDoc namespace")); + + CredentialIssuer credentialIssuer = oauth.oid4vc().doIssuerMetadataRequest().getMetadata(); + SupportedCredentialConfiguration supportedConfig = + credentialIssuer.getCredentialsSupported().get(mdocTypeNaturalPersonScopeName); + + assertNotNull(supportedConfig, "the auto created natural person configuration must be advertised"); + assertEquals(VCFormat.MSO_MDOC, supportedConfig.getFormat()); + assertEquals(NATURAL_PERSON_MDOC_NAMESPACE, supportedConfig.getDocType()); + assertEquals(List.of(CRYPTOGRAPHIC_BINDING_METHOD_COSE_KEY), supportedConfig.getCryptographicBindingMethodsSupported()); + + assertHasClaimPath(supportedConfig, List.of(NATURAL_PERSON_MDOC_NAMESPACE, "id")); + assertHasClaimPath(supportedConfig, List.of(NATURAL_PERSON_MDOC_NAMESPACE, "email")); + assertHasClaimPath(supportedConfig, List.of(NATURAL_PERSON_MDOC_NAMESPACE, "firstName")); + assertHasClaimPath(supportedConfig, List.of(NATURAL_PERSON_MDOC_NAMESPACE, "familyName")); + } + + @Test + void testMdocCredentialConfigurationSupportsNestedClaimPaths() { + ProtocolMapperRepresentation nestedAddressMapper = + ProtocolMapperUtils.getUserAttributeMapper("address.street", "address_street_address", "org.iso.18013.5.1"); + nestedAddressMapper.getConfig().put(CredentialScopeModel.VC_INCLUDE_IN_METADATA, "true"); + CredentialScopeRepresentation scope = + createCustomMdocCredentialScope("mdoc-nested-claim-scope", "mdoc-nested-claim-config", List.of(nestedAddressMapper)); + + CredentialIssuer credentialIssuer = oauth.oid4vc().doIssuerMetadataRequest().getMetadata(); + SupportedCredentialConfiguration supportedConfig = + credentialIssuer.getCredentialsSupported().get(scope.getCredentialConfigurationId()); + + assertHasClaimPath(supportedConfig, List.of("org.iso.18013.5.1", "address", "street")); + } + + @Test + void testMdocCredentialConfigurationUsesScopeNameAsDoctypeFallback() { + CredentialScopeRepresentation scope = createMdocCredentialScope("mdoc-doctype-fallback-scope", "mdoc-doctype-fallback-config"); + scope.setVct(null); + updateCredentialScope(scope); + + CredentialIssuer credentialIssuer = oauth.oid4vc().doIssuerMetadataRequest().getMetadata(); + SupportedCredentialConfiguration supportedConfig = + credentialIssuer.getCredentialsSupported().get(scope.getCredentialConfigurationId()); + + assertNotNull(supportedConfig); + assertEquals(scope.getName(), supportedConfig.getDocType()); + } + + @Test + void testMdocCredentialConfigurationUsesConfiguredSigningAlgMetadata() { + ensureEcSigningKeyProvider("mdoc-issuer-key-es384", "P-384", "ES384", 210); + CredentialScopeRepresentation scope = createMdocCredentialScope("mdoc-es384-scope", "mdoc-es384-config"); + scope.setSigningAlg("ES384"); + updateCredentialScope(scope); + + CredentialIssuer credentialIssuer = oauth.oid4vc().doIssuerMetadataRequest().getMetadata(); + SupportedCredentialConfiguration supportedConfig = + credentialIssuer.getCredentialsSupported().get(scope.getCredentialConfigurationId()); + + assertEquals(List.of(MdocAlgorithm.ES384.getCoseAlgorithmIdentifier()), + supportedConfig.getCredentialSigningAlgValuesSupported()); + } + + @Test + void testMdocCredentialConfigurationUsesSupportedRealmSigningAlgsByDefault() { + ensureEcSigningKeyProvider("mdoc-default-signing-key-es384", "P-384", "ES384", 210); + boolean hasEs512KeyBeforeMetadataRequest = hasActiveRealmSigningKey(Algorithm.ES512); + assertFalse(hasEs512KeyBeforeMetadataRequest, + "Test requires no active ES512 signing key before metadata is requested"); + CredentialScopeRepresentation scope = createCustomMdocCredentialScope( + "mdoc-default-signing-scope", + "mdoc-default-signing-config", + List.of(ProtocolMapperUtils.getUserAttributeMapper("given_name", "firstName", "org.iso.18013.5.1")), + null, + true + ); + + CredentialIssuer credentialIssuer = oauth.oid4vc().doIssuerMetadataRequest().getMetadata(); + SupportedCredentialConfiguration supportedConfig = + credentialIssuer.getCredentialsSupported().get(scope.getCredentialConfigurationId()); + + Set actualSigningAlgs = Set.copyOf(supportedConfig.getCredentialSigningAlgValuesSupported()); + assertTrue(actualSigningAlgs.contains(MdocAlgorithm.ES384.getCoseAlgorithmIdentifier())); + assertFalse(actualSigningAlgs.contains(MdocAlgorithm.ES512.getCoseAlgorithmIdentifier())); + assertEquals(hasEs512KeyBeforeMetadataRequest, hasActiveRealmSigningKey(Algorithm.ES512), + "Issuer metadata request should not generate an ES512 key just to advertise it"); + } + + @Test + void testMdocCredentialConfigurationOmitsBindingMetadataWhenBindingIsOptional() { + CredentialScopeRepresentation scope = createCustomMdocCredentialScope( + "mdoc-optional-binding-scope", + "mdoc-optional-binding-config", + List.of(ProtocolMapperUtils.getUserAttributeMapper("given_name", "firstName", "org.iso.18013.5.1")), + "ES256", + false + ); + + CredentialIssuer credentialIssuer = oauth.oid4vc().doIssuerMetadataRequest().getMetadata(); + SupportedCredentialConfiguration supportedConfig = + credentialIssuer.getCredentialsSupported().get(scope.getCredentialConfigurationId()); + + assertNotNull(supportedConfig, "mDoc configuration should be advertised"); + assertEquals(VCFormat.MSO_MDOC, supportedConfig.getFormat()); + assertEquals(List.of(MdocAlgorithm.ES256.getCoseAlgorithmIdentifier()), + supportedConfig.getCredentialSigningAlgValuesSupported()); + assertNull(supportedConfig.getCryptographicBindingMethodsSupported()); + assertNull(supportedConfig.getProofTypesSupported()); + } + + private boolean hasActiveRealmSigningKey(String algorithm) { + return testRealm.admin().keys().getKeyMetadata().getKeys().stream() + .filter(key -> KeyStatus.ACTIVE.name().equals(key.getStatus())) + .filter(key -> KeyUse.SIG.equals(key.getUse())) + .anyMatch(key -> algorithm.equals(key.getAlgorithm())); + } +} diff --git a/tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCMdocMapperTest.java b/tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCMdocMapperTest.java new file mode 100644 index 000000000000..9c1927a9946a --- /dev/null +++ b/tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCMdocMapperTest.java @@ -0,0 +1,245 @@ +/* + * Copyright 2026 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.keycloak.tests.oid4vc; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.keycloak.models.oid4vci.CredentialScopeModel; +import org.keycloak.protocol.oid4vc.issuance.mappers.OID4VCContextMapper; +import org.keycloak.protocol.oid4vc.issuance.mappers.OID4VCMapper; +import org.keycloak.protocol.oid4vc.issuance.mappers.OID4VCTypeMapper; +import org.keycloak.protocol.oid4vc.model.Claim; +import org.keycloak.protocol.oid4vc.model.CredentialIssuer; +import org.keycloak.protocol.oid4vc.model.CredentialScopeRepresentation; +import org.keycloak.protocol.oid4vc.model.OID4VCAuthorizationDetail; +import org.keycloak.protocol.oid4vc.model.SupportedCredentialConfiguration; +import org.keycloak.representations.idm.ProtocolMapperRepresentation; +import org.keycloak.testframework.annotations.KeycloakIntegrationTest; +import org.keycloak.testsuite.util.oauth.AccessTokenResponse; +import org.keycloak.testsuite.util.oauth.AuthorizationEndpointResponse; +import org.keycloak.testsuite.util.oauth.oid4vc.Oid4vcCredentialResponse; + +import org.junit.jupiter.api.Test; + +import static org.keycloak.OID4VCConstants.OPENID_CREDENTIAL; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@KeycloakIntegrationTest(config = OID4VCMdocTestBase.VCTestServerWithMdocEnabled.class) +public class OID4VCMdocMapperTest extends OID4VCMdocTestBase { + + @Test + public void testUserAttributeMapperUsesNamespaceAndNestedPath() { + ensureEcSigningKeyProvider("mdoc-mapper-issuer-key", "P-256", "ES256", 200); + + ProtocolMapperRepresentation mapper = + ProtocolMapperUtils.getUserAttributeMapper("address.street", "address_street_address", "org.iso.18013.5.1"); + mapper.getConfig().put(CredentialScopeModel.VC_INCLUDE_IN_METADATA, "true"); + CredentialScopeRepresentation scope = createCustomMdocCredentialScope("mdoc-mapper-scope", "mdoc-mapper-config", List.of(mapper)); + + CredentialIssuer credentialIssuer = oauth.oid4vc().doIssuerMetadataRequest().getMetadata(); + SupportedCredentialConfiguration supportedConfig = credentialIssuer.getCredentialsSupported().get(scope.getCredentialConfigurationId()); + + assertNotNull(supportedConfig); + assertNotNull(supportedConfig.getCredentialMetadata()); + List> actualPaths = supportedConfig.getCredentialMetadata().getClaims().stream() + .map(Claim::getPath) + .toList(); + assertTrue(actualPaths.stream().anyMatch(List.of("org.iso.18013.5.1", "address", "street")::equals), + "Expected nested mDoc path in " + actualPaths); + + OID4VCTestContext ctx = new OID4VCTestContext(client, scope); + OID4VCAuthorizationDetail authDetail = new OID4VCAuthorizationDetail(); + authDetail.setType(OPENID_CREDENTIAL); + authDetail.setCredentialConfigurationId(ctx.getCredentialConfigurationId()); + authDetail.setLocations(List.of(credentialIssuer.getCredentialIssuer())); + + AuthorizationEndpointResponse authResponse = wallet.authorizationRequest() + .scope(ctx.getScope()) + .authorizationDetails(authDetail) + .send(TEST_USER, TEST_PASSWORD); + assertNotNull(authResponse.getCode()); + + AccessTokenResponse tokenResponse = oauth.accessTokenRequest(authResponse.getCode()) + .authorizationDetails(List.of(authDetail)) + .send(); + assertEquals(200, tokenResponse.getStatusCode()); + + String credentialIdentifier = tokenResponse.getOID4VCAuthorizationDetails().get(0).getCredentialIdentifiers().get(0); + Oid4vcCredentialResponse credentialResponse = oauth.oid4vc().credentialRequest() + .credentialIdentifier(credentialIdentifier) + .proofs(wallet.generateJwtProof(ctx)) + .bearerToken(tokenResponse.getAccessToken()) + .send(); + assertEquals(200, credentialResponse.getStatusCode()); + + Object credential = credentialResponse.getCredentialResponse().getCredentials().get(0).getCredential(); + String encodedIssuerSigned = assertInstanceOf(String.class, credential); + Map nameSpaces = getMdocNamespaces(encodedIssuerSigned); + Map namespaceClaims = assertInstanceOf(Map.class, nameSpaces.get("org.iso.18013.5.1")); + Map addressClaim = assertInstanceOf(Map.class, namespaceClaims.get("address")); + assertEquals("221B Baker Street", addressClaim.get("street")); + } + + @Test + public void testVcLevelMappersAreIgnored() { + ensureEcSigningKeyProvider("mdoc-vc-level-mapper-issuer-key", "P-256", "ES256", 200); + + ProtocolMapperRepresentation nameMapper = + ProtocolMapperUtils.getUserAttributeMapper("given_name", "firstName", "org.iso.18013.5.1"); + nameMapper.getConfig().put(CredentialScopeModel.VC_INCLUDE_IN_METADATA, "true"); + + ProtocolMapperRepresentation issuedAtMapper = + ProtocolMapperUtils.getIssuedAtTimeMapper("iat", null, "COMPUTE"); + issuedAtMapper.setConfig(new HashMap<>(issuedAtMapper.getConfig())); + issuedAtMapper.getConfig().put(CredentialScopeModel.VC_INCLUDE_IN_METADATA, "true"); + issuedAtMapper.getConfig().put(OID4VCMapper.MDOC_NAMESPACE, "org.iso.18013.5.1"); + + ProtocolMapperRepresentation contextMapper = ProtocolMapperUtils.getProtocolMapper( + "context-mapper", "oid4vc-context-mapper", new HashMap<>(Map.of( + OID4VCContextMapper.TYPE_KEY, "https://www.w3.org/2018/credentials/v1", + CredentialScopeModel.VC_INCLUDE_IN_METADATA, "true", + OID4VCMapper.MDOC_NAMESPACE, "org.iso.18013.5.1" + ))); + + ProtocolMapperRepresentation typeMapper = ProtocolMapperUtils.getProtocolMapper( + "type-mapper", "oid4vc-vc-type-mapper", new HashMap<>(Map.of( + OID4VCTypeMapper.TYPE_KEY, "VerifiableCredential", + CredentialScopeModel.VC_INCLUDE_IN_METADATA, "true", + OID4VCMapper.MDOC_NAMESPACE, "org.iso.18013.5.1" + ))); + + CredentialScopeRepresentation scope = createCustomMdocCredentialScope( + "mdoc-vc-level-mapper-scope", + "mdoc-vc-level-mapper-config", + List.of(nameMapper, issuedAtMapper, contextMapper, typeMapper) + ); + + CredentialIssuer credentialIssuer = oauth.oid4vc().doIssuerMetadataRequest().getMetadata(); + SupportedCredentialConfiguration supportedConfig = + credentialIssuer.getCredentialsSupported().get(scope.getCredentialConfigurationId()); + assertNotNull(supportedConfig.getCredentialMetadata()); + List> claimPaths = supportedConfig.getCredentialMetadata().getClaims().stream() + .map(Claim::getPath) + .toList(); + assertTrue(claimPaths.contains(List.of("org.iso.18013.5.1", "given_name"))); + assertFalse(claimPaths.contains(List.of("org.iso.18013.5.1", "iat"))); + assertFalse(claimPaths.contains(List.of("context"))); + assertFalse(claimPaths.contains(List.of("type"))); + + OID4VCTestContext ctx = new OID4VCTestContext(client, scope); + OID4VCAuthorizationDetail authDetail = new OID4VCAuthorizationDetail(); + authDetail.setType(OPENID_CREDENTIAL); + authDetail.setCredentialConfigurationId(ctx.getCredentialConfigurationId()); + authDetail.setLocations(List.of(credentialIssuer.getCredentialIssuer())); + + AuthorizationEndpointResponse authResponse = wallet.authorizationRequest() + .scope(ctx.getScope()) + .authorizationDetails(authDetail) + .send(TEST_USER, TEST_PASSWORD); + assertNotNull(authResponse.getCode()); + + AccessTokenResponse tokenResponse = oauth.accessTokenRequest(authResponse.getCode()) + .authorizationDetails(List.of(authDetail)) + .send(); + assertEquals(200, tokenResponse.getStatusCode()); + + String credentialIdentifier = tokenResponse.getOID4VCAuthorizationDetails().get(0).getCredentialIdentifiers().get(0); + Oid4vcCredentialResponse credentialResponse = oauth.oid4vc().credentialRequest() + .credentialIdentifier(credentialIdentifier) + .proofs(wallet.generateJwtProof(ctx)) + .bearerToken(tokenResponse.getAccessToken()) + .send(); + assertEquals(200, credentialResponse.getStatusCode()); + + Object credential = credentialResponse.getCredentialResponse().getCredentials().get(0).getCredential(); + String encodedIssuerSigned = assertInstanceOf(String.class, credential); + Map nameSpaces = getMdocNamespaces(encodedIssuerSigned); + assertTrue(nameSpaces.containsKey("org.iso.18013.5.1")); + assertFalse(nameSpaces.containsKey("iat"), "VC-level iat mapper must not become an mDoc namespace"); + Map namespaceClaims = assertInstanceOf(Map.class, nameSpaces.get("org.iso.18013.5.1")); + assertFalse(namespaceClaims.containsKey("iat")); + assertFalse(namespaceClaims.containsKey("context")); + assertFalse(namespaceClaims.containsKey("type")); + } + + @Test + public void testSameElementIdentifierInDistinctNamespacesKeepsSeparateValues() { + ensureEcSigningKeyProvider("mdoc-namespace-collision-issuer-key", "P-256", "ES256", 200); + + String namespaceA = "org.iso.18013.5.1"; + String namespaceB = "org.iso.18013.5.1.aamva"; + + // Two mappers emit the same mDoc element identifier into different namespaces from distinct user attributes. + ProtocolMapperRepresentation givenNameInA = + ProtocolMapperUtils.getUserAttributeMapper("given_name", "firstName", namespaceA); + givenNameInA.getConfig().put(CredentialScopeModel.VC_INCLUDE_IN_METADATA, "true"); + ProtocolMapperRepresentation givenNameInB = + ProtocolMapperUtils.getUserAttributeMapper("given_name", "lastName", namespaceB); + givenNameInB.getConfig().put(CredentialScopeModel.VC_INCLUDE_IN_METADATA, "true"); + + CredentialScopeRepresentation scope = createCustomMdocCredentialScope( + "mdoc-namespace-collision-scope", + "mdoc-namespace-collision-config", + List.of(givenNameInA, givenNameInB)); + + CredentialIssuer credentialIssuer = oauth.oid4vc().doIssuerMetadataRequest().getMetadata(); + SupportedCredentialConfiguration supportedConfig = credentialIssuer.getCredentialsSupported().get(scope.getCredentialConfigurationId()); + assertNotNull(supportedConfig); + + OID4VCTestContext ctx = new OID4VCTestContext(client, scope); + OID4VCAuthorizationDetail authDetail = new OID4VCAuthorizationDetail(); + authDetail.setType(OPENID_CREDENTIAL); + authDetail.setCredentialConfigurationId(ctx.getCredentialConfigurationId()); + authDetail.setLocations(List.of(credentialIssuer.getCredentialIssuer())); + + AuthorizationEndpointResponse authResponse = wallet.authorizationRequest() + .scope(ctx.getScope()) + .authorizationDetails(authDetail) + .send(TEST_USER, TEST_PASSWORD); + assertNotNull(authResponse.getCode()); + + AccessTokenResponse tokenResponse = oauth.accessTokenRequest(authResponse.getCode()) + .authorizationDetails(List.of(authDetail)) + .send(); + assertEquals(200, tokenResponse.getStatusCode()); + + String credentialIdentifier = tokenResponse.getOID4VCAuthorizationDetails().get(0).getCredentialIdentifiers().get(0); + Oid4vcCredentialResponse credentialResponse = oauth.oid4vc().credentialRequest() + .credentialIdentifier(credentialIdentifier) + .proofs(wallet.generateJwtProof(ctx)) + .bearerToken(tokenResponse.getAccessToken()) + .send(); + assertEquals(200, credentialResponse.getStatusCode()); + + Object credential = credentialResponse.getCredentialResponse().getCredentials().get(0).getCredential(); + String encodedIssuerSigned = assertInstanceOf(String.class, credential); + Map nameSpaces = getMdocNamespaces(encodedIssuerSigned); + + // Each namespace must retain its own value rather than both collapsing to the last mapper's claim. + Map claimsA = assertInstanceOf(Map.class, nameSpaces.get(namespaceA)); + Map claimsB = assertInstanceOf(Map.class, nameSpaces.get(namespaceB)); + assertEquals("John", claimsA.get("given_name")); + assertEquals("Doe", claimsB.get("given_name")); + } +} diff --git a/tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCMdocTestBase.java b/tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCMdocTestBase.java new file mode 100644 index 000000000000..6439ffb1a874 --- /dev/null +++ b/tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCMdocTestBase.java @@ -0,0 +1,271 @@ +/* + * Copyright 2026 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.keycloak.tests.oid4vc; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +import jakarta.ws.rs.core.Response; + +import org.keycloak.VCFormat; +import org.keycloak.common.Profile; +import org.keycloak.constants.OID4VCIConstants; +import org.keycloak.mdoc.MdocIssuerSignedDocument; +import org.keycloak.models.UserModel; +import org.keycloak.protocol.oid4vc.issuance.mappers.OID4VCMapper; +import org.keycloak.protocol.oid4vc.model.CredentialResponse; +import org.keycloak.protocol.oid4vc.model.CredentialScopeRepresentation; +import org.keycloak.representations.idm.ProtocolMapperRepresentation; +import org.keycloak.representations.idm.oid4vc.UserVerifiableCredentialRepresentation; +import org.keycloak.testframework.server.KeycloakServerConfig; +import org.keycloak.testframework.server.KeycloakServerConfigBuilder; +import org.keycloak.testframework.util.ApiUtil; + +import static org.keycloak.OID4VCConstants.CRYPTOGRAPHIC_BINDING_METHOD_COSE_KEY; +import static org.keycloak.models.oid4vci.CredentialScopeModel.VC_BINDING_REQUIRED; +import static org.keycloak.models.oid4vci.CredentialScopeModel.VC_BINDING_REQUIRED_PROOF_TYPES; +import static org.keycloak.models.oid4vci.CredentialScopeModel.VC_CRYPTOGRAPHIC_BINDING_METHODS; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +public abstract class OID4VCMdocTestBase extends OID4VCIssuerTestBase { + + public static class VCTestServerWithMdocEnabled implements KeycloakServerConfig { + @Override + public KeycloakServerConfigBuilder configure(KeycloakServerConfigBuilder config) { + return config.features(Profile.Feature.OID4VC_VCI, Profile.Feature.OID4VC_MDOC); + } + } + + public static class VCTestServerWithPreAuthCodeAndMdocEnabled implements KeycloakServerConfig { + @Override + public KeycloakServerConfigBuilder configure(KeycloakServerConfigBuilder config) { + return config.features(Profile.Feature.OID4VC_VCI, Profile.Feature.OID4VC_VCI_REST_CREDENTIAL_OFFER, Profile.Feature.OID4VC_VCI_PREAUTH_CODE, Profile.Feature.OID4VC_MDOC); + } + } + + protected CredentialScopeRepresentation createMdocCredentialScope(String scopeName, String credentialConfigurationId) { + return createMdocCredentialScope(this, scopeName, credentialConfigurationId); + } + + public static CredentialScopeRepresentation createMdocCredentialScope(OID4VCIssuerTestBase base, String scopeName, String credentialConfigurationId) { + CredentialScopeRepresentation existingScope = base.getCredentialScope(scopeName); + if (existingScope != null) { + addScopeToOid4vciClients(base, existingScope.getId()); + addCredentialToTestUser(base, scopeName); + return existingScope; + } + + return createCustomMdocCredentialScope( + base, + scopeName, + credentialConfigurationId, + List.of( + ProtocolMapperUtils.getUserAttributeMapper("given_name", "firstName", "org.iso.18013.5.1"), + ProtocolMapperUtils.getUserAttributeMapper("family_name", "lastName", "org.iso.18013.5.1"), + ProtocolMapperUtils.getSubjectIdMapper("id", UserModel.USERNAME, "org.iso.18013.5.1") + ), + "ES256", + true + ); + } + + protected CredentialScopeRepresentation createCustomMdocCredentialScope(String scopeName, + String credentialConfigurationId, + List protocolMappers) { + return createCustomMdocCredentialScope(this, scopeName, credentialConfigurationId, protocolMappers, "ES256", true); + } + + protected CredentialScopeRepresentation createCustomMdocCredentialScope(String scopeName, + String credentialConfigurationId, + List protocolMappers, + String signingAlg, + boolean bindingRequired) { + return createCustomMdocCredentialScope(this, scopeName, credentialConfigurationId, protocolMappers, signingAlg, bindingRequired); + } + + public static CredentialScopeRepresentation createCustomMdocCredentialScope(OID4VCIssuerTestBase base, + String scopeName, + String credentialConfigurationId, + List protocolMappers, + String signingAlg, + boolean bindingRequired) { + CredentialScopeRepresentation existingScope = base.getCredentialScope(scopeName); + if (existingScope != null) { + addScopeToOid4vciClients(base, existingScope.getId()); + addCredentialToTestUser(base, scopeName); + return existingScope; + } + + CredentialScopeRepresentation scope = new CredentialScopeRepresentation(scopeName) + .setIncludeInTokenScope(true) + .setExpiryInSeconds(15) + .setCredentialConfigurationId(credentialConfigurationId) + .setCredentialIdentifier(scopeName) + .setFormat(VCFormat.MSO_MDOC) + .setVct(mdocTypeCredentialDocType); + + if (signingAlg != null) { + scope.setSigningAlg(signingAlg); + } + if (bindingRequired) { + scope.setCryptographicBindingMethods(List.of(CRYPTOGRAPHIC_BINDING_METHOD_COSE_KEY)); + } + scope.setProtocolMappers(protocolMappers); + + Map attrs = scope.getAttributes() != null ? scope.getAttributes() : new HashMap<>(); + attrs.put(VC_BINDING_REQUIRED, String.valueOf(bindingRequired)); + if (bindingRequired) { + attrs.put(VC_BINDING_REQUIRED_PROOF_TYPES, "jwt"); + attrs.put(VC_CRYPTOGRAPHIC_BINDING_METHODS, CRYPTOGRAPHIC_BINDING_METHOD_COSE_KEY); + } else { + attrs.remove(VC_BINDING_REQUIRED_PROOF_TYPES); + attrs.remove(VC_CRYPTOGRAPHIC_BINDING_METHODS); + } + scope.setAttributes(attrs); + + String scopeId; + try (Response response = base.testRealm.admin().clientScopes().create(scope)) { + scopeId = ApiUtil.getCreatedId(response); + } + base.testRealm.cleanup().add(realm -> realm.clientScopes().get(scopeId).remove()); + + addScopeToOid4vciClients(base, scopeId); + addCredentialToTestUser(base, scopeName); + return new CredentialScopeRepresentation(base.testRealm.admin().clientScopes().get(scopeId).toRepresentation()); + } + + protected void addScopeToOid4vciClients(String scopeId) { + addScopeToOid4vciClients(this, scopeId); + } + + private static void addScopeToOid4vciClients(OID4VCIssuerTestBase base, String scopeId) { + base.testRealm.admin().clients().get(base.client.getId()).addOptionalClientScope(scopeId); + base.testRealm.admin().clients().get(base.pubClient.getId()).addOptionalClientScope(scopeId); + } + + private static void addCredentialToTestUser(OID4VCIssuerTestBase base, String scopeName) { + String userId = base.requireExistingUser(TEST_USER).getId(); + boolean alreadyPresent = base.testRealm.admin().users().get(userId).verifiableCredentials().getCredentials().stream() + .anyMatch(credential -> scopeName.equals(credential.getCredentialScopeName())); + if (!alreadyPresent) { + UserVerifiableCredentialRepresentation credential = new UserVerifiableCredentialRepresentation(); + credential.setCredentialScopeName(scopeName); + base.testRealm.admin().users().get(userId).verifiableCredentials().createCredential(credential); + } + } + + protected MdocIssuerSignedDocument parseMdocCredential(String encodedIssuerSigned) { + return parseCredential(encodedIssuerSigned); + } + + protected Map getMdocNamespaces(String encodedIssuerSigned) { + return getMdocNamespacesFromCredential(encodedIssuerSigned); + } + + protected Map getMdocMobileSecurityObject(String encodedIssuerSigned) { + return getMdocMobileSecurityObjectFromCredential(encodedIssuerSigned); + } + + public static Map getMdocNamespacesFromCredential(String encodedIssuerSigned) { + return parseCredential(encodedIssuerSigned).getNamespaces(); + } + + public static Map getMdocMobileSecurityObjectFromCredential(String encodedIssuerSigned) { + return parseCredential(encodedIssuerSigned).getMobileSecurityObject(); + } + + public static void assertMdocCredentialStructure(Object credentialObj) { + assertNotNull(credentialObj, "mDoc credential object should not be null"); + assertInstanceOf(String.class, credentialObj, "mDoc credential should be a string"); + + String encodedIssuerSigned = (String) credentialObj; + assertFalse(encodedIssuerSigned.isBlank(), "mDoc credential response must contain a base64url payload"); + + Map nameSpaces = getMdocNamespacesFromCredential(encodedIssuerSigned); + assertTrue(nameSpaces.containsKey("org.iso.18013.5.1"), "mDoc payload must include the configured namespace"); + Map namespaceClaims = assertInstanceOf(Map.class, nameSpaces.get("org.iso.18013.5.1")); + assertTrue(namespaceClaims.containsKey("given_name"), "mDoc payload must contain the given_name claim"); + assertTrue(namespaceClaims.containsKey("id"), "mDoc payload must contain the id claim"); + + Map mobileSecurityObject = getMdocMobileSecurityObjectFromCredential(encodedIssuerSigned); + assertEquals(mdocTypeCredentialDocType, mobileSecurityObject.get("docType")); + } + + protected static void assertValidMdocCredential(CredentialResponse credentialResponse, boolean expectDeviceKeyInfo) { + assertNotNull(credentialResponse); + assertNotNull(credentialResponse.getCredentials()); + assertEquals(1, credentialResponse.getCredentials().size()); + + Object credential = credentialResponse.getCredentials().get(0).getCredential(); + assertInstanceOf(String.class, credential, "mDoc credential should be a string"); + + Map nameSpaces = getMdocNamespacesFromCredential((String) credential); + assertTrue(nameSpaces.containsKey("org.iso.18013.5.1")); + + Map mobileSecurityObject = getMdocMobileSecurityObjectFromCredential((String) credential); + assertEquals(mdocTypeCredentialDocType, mobileSecurityObject.get("docType")); + if (expectDeviceKeyInfo) { + assertNotNull(mobileSecurityObject.get("deviceKeyInfo")); + } else { + assertNull(mobileSecurityObject.get("deviceKeyInfo")); + } + } + + public static class ProtocolMapperUtils extends OID4VCIssuerTestBase.ProtocolMapperUtils { + + static ProtocolMapperRepresentation getSubjectIdMapper(String subjectProperty, String attributeName, String mdocNamespace) { + ProtocolMapperRepresentation protocolMapperRepresentation = new ProtocolMapperRepresentation(); + protocolMapperRepresentation.setName(attributeName + "-mapper"); + protocolMapperRepresentation.setProtocol(OID4VCIConstants.OID4VC_PROTOCOL); + protocolMapperRepresentation.setId(UUID.randomUUID().toString()); + protocolMapperRepresentation.setProtocolMapper("oid4vc-subject-id-mapper"); + Map config = new HashMap<>(); + config.put(OID4VCMapper.CLAIM_NAME, subjectProperty); + config.put(OID4VCMapper.USER_ATTRIBUTE_KEY, attributeName); + config.put(OID4VCMapper.MDOC_NAMESPACE, mdocNamespace); + protocolMapperRepresentation.setConfig(config); + return protocolMapperRepresentation; + } + + static ProtocolMapperRepresentation getUserAttributeMapper(String subjectProperty, String attributeName, String mdocNamespace) { + ProtocolMapperRepresentation protocolMapperRepresentation = new ProtocolMapperRepresentation(); + protocolMapperRepresentation.setName(attributeName + "-mapper"); + protocolMapperRepresentation.setProtocol(OID4VCIConstants.OID4VC_PROTOCOL); + protocolMapperRepresentation.setId(UUID.randomUUID().toString()); + protocolMapperRepresentation.setProtocolMapper("oid4vc-user-attribute-mapper"); + Map config = new HashMap<>(); + config.put(OID4VCMapper.CLAIM_NAME, subjectProperty); + config.put(OID4VCMapper.USER_ATTRIBUTE_KEY, attributeName); + config.put(OID4VCMapper.MDOC_NAMESPACE, mdocNamespace); + protocolMapperRepresentation.setConfig(config); + return protocolMapperRepresentation; + } + } + + private static MdocIssuerSignedDocument parseCredential(String encodedIssuerSigned) { + return MdocIssuerSignedDocument.parse(encodedIssuerSigned); + } + +} diff --git a/tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCProofTestUtils.java b/tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCProofTestUtils.java index 52b73ae68d37..cd47b7f17779 100644 --- a/tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCProofTestUtils.java +++ b/tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCProofTestUtils.java @@ -78,6 +78,7 @@ private static String generateJwtProofWithClaims( ) { keyWrapper.setKid(null); JWK jwk = JWKBuilder.create().ec(keyWrapper.getPublicKey()); + jwk.setAlgorithm(keyWrapper.getAlgorithm()); AccessToken token = new AccessToken(); List resolvedAudiences = audiences != null ? audiences : List.of(); diff --git a/tests/base/src/test/java/org/keycloak/tests/oid4vc/issuance/OID4VCAuthorizationCodeFlowTestBase.java b/tests/base/src/test/java/org/keycloak/tests/oid4vc/issuance/OID4VCAuthorizationCodeFlowTestBase.java index 594dbd26552f..f88c3019df58 100644 --- a/tests/base/src/test/java/org/keycloak/tests/oid4vc/issuance/OID4VCAuthorizationCodeFlowTestBase.java +++ b/tests/base/src/test/java/org/keycloak/tests/oid4vc/issuance/OID4VCAuthorizationCodeFlowTestBase.java @@ -1,6 +1,5 @@ package org.keycloak.tests.oid4vc.issuance; -import java.util.Arrays; import java.util.Collections; import java.util.HashMap; import java.util.List; @@ -68,11 +67,8 @@ public abstract class OID4VCAuthorizationCodeFlowTestBase extends OID4VCIssuerTe /** Returns the credential scope configured for this test's format. */ protected abstract CredentialScopeRepresentation getCredentialScope(); - /** - * Returns the claim name (last segment of the path) used when building mandatory-claim - * authorization details for this format. - */ - protected abstract String getExpectedClaimPath(); + /** Returns the claim path used when building mandatory-claim authorization details for this format. */ + protected abstract List getExpectedClaimPath(); /** Returns the name of the firstName protocol mapper in the credential scope. */ protected abstract String getFirstNameProtocolMapperName(); @@ -943,13 +939,7 @@ protected AccessTokenResponse authzCodeFlow(CredentialIssuer issuer, */ protected List mandatoryLastNameClaims() { ClaimsDescription claim = new ClaimsDescription(); - List claimPath; - if ("sd_jwt_vc".equals(getCredentialFormat())) { - claimPath = Collections.singletonList(getExpectedClaimPath()); - } else { - claimPath = Arrays.asList("credentialSubject", getExpectedClaimPath()); - } - claim.setPath(claimPath); + claim.setPath(getExpectedClaimPath()); claim.setMandatory(true); return List.of(claim); } diff --git a/tests/base/src/test/java/org/keycloak/tests/oid4vc/issuance/OID4VCJwtAuthorizationCodeFlowTest.java b/tests/base/src/test/java/org/keycloak/tests/oid4vc/issuance/OID4VCJwtAuthorizationCodeFlowTest.java index a12efc8ff39c..5d52df2ef90a 100644 --- a/tests/base/src/test/java/org/keycloak/tests/oid4vc/issuance/OID4VCJwtAuthorizationCodeFlowTest.java +++ b/tests/base/src/test/java/org/keycloak/tests/oid4vc/issuance/OID4VCJwtAuthorizationCodeFlowTest.java @@ -1,5 +1,7 @@ package org.keycloak.tests.oid4vc.issuance; +import java.util.List; + import org.keycloak.protocol.oid4vc.model.CredentialScopeRepresentation; import org.keycloak.testframework.annotations.KeycloakIntegrationTest; import org.keycloak.tests.oid4vc.OID4VCIssuerTestBase; @@ -28,9 +30,8 @@ protected CredentialScopeRepresentation getCredentialScope() { } @Override - protected String getExpectedClaimPath() { - // In jwt_vc the last-name claim sits at vc.credentialSubject.family_name - return "family_name"; + protected List getExpectedClaimPath() { + return List.of("credentialSubject", "family_name"); } @Override diff --git a/tests/base/src/test/java/org/keycloak/tests/oid4vc/issuance/OID4VCMdocAuthorizationCodeFlowTest.java b/tests/base/src/test/java/org/keycloak/tests/oid4vc/issuance/OID4VCMdocAuthorizationCodeFlowTest.java new file mode 100644 index 000000000000..2238a0b1c197 --- /dev/null +++ b/tests/base/src/test/java/org/keycloak/tests/oid4vc/issuance/OID4VCMdocAuthorizationCodeFlowTest.java @@ -0,0 +1,56 @@ +/* + * Copyright 2026 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.keycloak.tests.oid4vc.issuance; + +import java.util.List; + +import org.keycloak.VCFormat; +import org.keycloak.protocol.oid4vc.model.CredentialScopeRepresentation; +import org.keycloak.testframework.annotations.KeycloakIntegrationTest; +import org.keycloak.tests.oid4vc.OID4VCMdocTestBase; + +import static org.keycloak.tests.oid4vc.OID4VCMdocTestBase.assertMdocCredentialStructure; + +@KeycloakIntegrationTest(config = OID4VCMdocTestBase.VCTestServerWithMdocEnabled.class) +public class OID4VCMdocAuthorizationCodeFlowTest extends OID4VCAuthorizationCodeFlowTestBase { + + @Override + protected String getCredentialFormat() { + return VCFormat.MSO_MDOC; + } + + @Override + protected CredentialScopeRepresentation getCredentialScope() { + ensureEcSigningKeyProvider("mdoc-issuer-key", "P-256", "ES256", 200); + return mdocTypeCredentialScope; + } + + @Override + protected List getExpectedClaimPath() { + return List.of("org.iso.18013.5.1", "family_name"); + } + + @Override + protected String getFirstNameProtocolMapperName() { + return "firstName-mapper"; + } + + @Override + protected void verifyCredentialStructure(Object credentialObj) { + assertMdocCredentialStructure(credentialObj); + } +} diff --git a/tests/base/src/test/java/org/keycloak/tests/oid4vc/issuance/OID4VCSdJwtAuthorizationCodeFlowTest.java b/tests/base/src/test/java/org/keycloak/tests/oid4vc/issuance/OID4VCSdJwtAuthorizationCodeFlowTest.java index a4381555dfff..227be588f0c4 100644 --- a/tests/base/src/test/java/org/keycloak/tests/oid4vc/issuance/OID4VCSdJwtAuthorizationCodeFlowTest.java +++ b/tests/base/src/test/java/org/keycloak/tests/oid4vc/issuance/OID4VCSdJwtAuthorizationCodeFlowTest.java @@ -1,5 +1,7 @@ package org.keycloak.tests.oid4vc.issuance; +import java.util.List; + import org.keycloak.protocol.oid4vc.model.CredentialScopeRepresentation; import org.keycloak.testframework.annotations.KeycloakIntegrationTest; import org.keycloak.tests.oid4vc.OID4VCIssuerTestBase; @@ -28,9 +30,8 @@ protected CredentialScopeRepresentation getCredentialScope() { } @Override - protected String getExpectedClaimPath() { - // In sd_jwt_vc the last-name claim sits at the top level as lastName - return "lastName"; + protected List getExpectedClaimPath() { + return List.of("lastName"); } @Override diff --git a/tests/base/src/test/java/org/keycloak/tests/oid4vc/issuance/signing/OID4VCAuthorizationCodeFlowWithPARTest.java b/tests/base/src/test/java/org/keycloak/tests/oid4vc/issuance/signing/OID4VCAuthorizationCodeFlowWithPARTest.java index 949c46f799a1..70a46b22655a 100644 --- a/tests/base/src/test/java/org/keycloak/tests/oid4vc/issuance/signing/OID4VCAuthorizationCodeFlowWithPARTest.java +++ b/tests/base/src/test/java/org/keycloak/tests/oid4vc/issuance/signing/OID4VCAuthorizationCodeFlowWithPARTest.java @@ -1,6 +1,5 @@ package org.keycloak.tests.oid4vc.issuance.signing; -import java.util.Arrays; import java.util.Collections; import java.util.List; @@ -51,8 +50,8 @@ protected CredentialScopeRepresentation getCredentialScope() { } @Override - protected String getExpectedClaimPath() { - return "family_name"; + protected List getExpectedClaimPath() { + return List.of("credentialSubject", "family_name"); } @Override @@ -87,7 +86,7 @@ public void testAuthorizationCodeFlowWithPARAndAuthorizationDetails() throws Exc // Create authorization details with claims ClaimsDescription claim = new ClaimsDescription(); - List claimPath = Arrays.asList("credentialSubject", getExpectedClaimPath()); + List claimPath = getExpectedClaimPath(); claim.setPath(claimPath); claim.setMandatory(true); @@ -212,7 +211,7 @@ public void testAuthorizationCodeFlowWithPARPromptCreateOpensTheRegistrationPage // Create authorization details with claims ClaimsDescription claim = new ClaimsDescription(); - List claimPath = Arrays.asList("credentialSubject", getExpectedClaimPath()); + List claimPath = getExpectedClaimPath(); claim.setPath(claimPath); claim.setMandatory(true); diff --git a/tests/base/src/test/java/org/keycloak/tests/oid4vc/issuance/signing/OID4VCMdocAuthorizationDetailsFlowTest.java b/tests/base/src/test/java/org/keycloak/tests/oid4vc/issuance/signing/OID4VCMdocAuthorizationDetailsFlowTest.java new file mode 100644 index 000000000000..19a26ca2ae6a --- /dev/null +++ b/tests/base/src/test/java/org/keycloak/tests/oid4vc/issuance/signing/OID4VCMdocAuthorizationDetailsFlowTest.java @@ -0,0 +1,38 @@ +/* + * Copyright 2026 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.keycloak.tests.oid4vc.issuance.signing; + +import org.keycloak.protocol.oid4vc.model.CredentialScopeRepresentation; +import org.keycloak.testframework.annotations.KeycloakIntegrationTest; +import org.keycloak.tests.oid4vc.OID4VCMdocTestBase; + +import static org.keycloak.tests.oid4vc.OID4VCMdocTestBase.assertMdocCredentialStructure; + +@KeycloakIntegrationTest(config = OID4VCMdocTestBase.VCTestServerWithMdocEnabled.class) +public class OID4VCMdocAuthorizationDetailsFlowTest extends OID4VCAuthorizationDetailsFlowTestBase { + + @Override + protected CredentialScopeRepresentation getCredentialScope() { + ensureEcSigningKeyProvider("mdoc-auth-details-issuer-key", "P-256", "ES256", 200); + return mdocTypeCredentialScope; + } + + @Override + protected void verifyCredentialStructure(Object credentialObj) { + assertMdocCredentialStructure(credentialObj); + } +} diff --git a/tests/base/src/test/java/org/keycloak/tests/oid4vc/preauth/OID4VCAuthorizationDetailsFlowPreAuthTestBase.java b/tests/base/src/test/java/org/keycloak/tests/oid4vc/preauth/OID4VCAuthorizationDetailsFlowPreAuthTestBase.java index 2b73695f3f37..1fe9c2cca964 100644 --- a/tests/base/src/test/java/org/keycloak/tests/oid4vc/preauth/OID4VCAuthorizationDetailsFlowPreAuthTestBase.java +++ b/tests/base/src/test/java/org/keycloak/tests/oid4vc/preauth/OID4VCAuthorizationDetailsFlowPreAuthTestBase.java @@ -17,6 +17,7 @@ package org.keycloak.tests.oid4vc.preauth; +import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.List; @@ -24,6 +25,7 @@ import java.util.stream.Collectors; import org.keycloak.OAuth2Constants; +import org.keycloak.VCFormat; import org.keycloak.events.Details; import org.keycloak.events.EventType; import org.keycloak.models.oid4vci.CredentialScopeModel; @@ -106,10 +108,12 @@ protected static class Oid4vcTestContext { /** * Get the expected claim path for this test implementation. - * - * @return the claim path as a string */ - protected abstract String getExpectedClaimPath(); + protected abstract List getExpectedClaimPath(); + + protected String getClaimsNamespace() { + return null; + } protected Oid4vcTestContext prepareOid4vcTestContext(String token) throws Exception { Oid4vcTestContext ctx = new Oid4vcTestContext(); @@ -212,16 +216,7 @@ public void testPreAuthorizedCodeWithAuthorizationDetailsAndClaims() throws Exce // Create claims description for a claim that should be supported ClaimsDescription claim = new ClaimsDescription(); - // Construct claim path based on credential format - List claimPath; - if ("sd_jwt_vc".equals(getCredentialFormat())) { - // SD-JWT doesn't use credentialSubject prefix - claimPath = Arrays.asList(getExpectedClaimPath()); - } else { - // JWT and other formats use credentialSubject prefix - claimPath = Arrays.asList("credentialSubject", getExpectedClaimPath()); - } - claim.setPath(claimPath); + claim.setPath(getExpectedClaimPath()); claim.setMandatory(true); OID4VCAuthorizationDetail authDetail = new OID4VCAuthorizationDetail(); @@ -249,13 +244,7 @@ public void testPreAuthorizedCodeWithAuthorizationDetailsAndClaims() throws Exce assertEquals(1, authDetailResponse.getClaims().size()); ClaimsDescription responseClaim = authDetailResponse.getClaims().get(0); - List expectedClaimPath; - if ("sd_jwt_vc".equals(getCredentialFormat())) { - expectedClaimPath = Arrays.asList(getExpectedClaimPath()); - } else { - expectedClaimPath = Arrays.asList("credentialSubject", getExpectedClaimPath()); - } - assertEquals(expectedClaimPath, responseClaim.getPath()); + assertEquals(getExpectedClaimPath(), responseClaim.getPath()); assertTrue(responseClaim.isMandatory()); // Verify that credential identifiers are present @@ -271,7 +260,7 @@ public void testPreAuthorizedCodeWithUnsupportedClaims() throws Exception { // Create claims description for a claim that should NOT be supported ClaimsDescription claim = new ClaimsDescription(); - claim.setPath(Arrays.asList("credentialSubject", "unsupportedClaim")); + claim.setPath(buildClaimPath("unsupportedClaim")); claim.setMandatory(false); OID4VCAuthorizationDetail authDetail = new OID4VCAuthorizationDetail(); @@ -302,7 +291,7 @@ public void testPreAuthorizedCodeWithMandatoryClaimMissing() throws Exception { // Create claims description for a mandatory claim ClaimsDescription claim = new ClaimsDescription(); - claim.setPath(Arrays.asList("credentialSubject", "mandatoryClaim")); + claim.setPath(buildClaimPath("mandatoryClaim")); claim.setMandatory(true); OID4VCAuthorizationDetail authDetail = new OID4VCAuthorizationDetail(); @@ -333,7 +322,7 @@ public void testPreAuthorizedCodeWithComplexClaimsPath() throws Exception { // Create claims description with complex path ClaimsDescription claim = new ClaimsDescription(); - claim.setPath(Arrays.asList("credentialSubject", "address", "street")); + claim.setPath(buildClaimPath("address", "street")); claim.setMandatory(false); OID4VCAuthorizationDetail authDetail = new OID4VCAuthorizationDetail(); @@ -723,14 +712,7 @@ private AccessTokenResponse preAuthzCodeSuccessful() throws Exception { // This tests that requested claims are validated and present in the final credential ClaimsDescription claim = new ClaimsDescription(); - // Construct claim path based on credential format - List claimPath; - if ("sd_jwt_vc".equals(getCredentialFormat())) { - claimPath = Arrays.asList(getExpectedClaimPath()); - } else { - claimPath = Arrays.asList("credentialSubject", getExpectedClaimPath()); - } - claim.setPath(claimPath); + claim.setPath(getExpectedClaimPath()); claim.setMandatory(true); OID4VCAuthorizationDetail authDetail = new OID4VCAuthorizationDetail(); @@ -906,6 +888,22 @@ protected void verifyCredentialStructure(Object credentialObj) { assertNotNull(credentialObj, "Credential object should not be null"); } + private List buildClaimPath(String... relativePath) { + if ("sd_jwt_vc".equals(getCredentialFormat())) { + return Arrays.stream(relativePath).map(path -> (Object) path).toList(); + } + if (VCFormat.MSO_MDOC.equals(getCredentialFormat())) { + List claimPath = new ArrayList<>(); + claimPath.add(getClaimsNamespace()); + claimPath.addAll(Arrays.asList(relativePath)); + return claimPath; + } + List claimPath = new ArrayList<>(); + claimPath.add("credentialSubject"); + claimPath.addAll(Arrays.asList(relativePath)); + return claimPath; + } + private String getCredentialIssuerFromAuthDetails(AccessTokenResponse tokenResponse) { List authDetailsResponse = tokenResponse.getOID4VCAuthorizationDetails(); if (authDetailsResponse != null && !authDetailsResponse.isEmpty()) { diff --git a/tests/base/src/test/java/org/keycloak/tests/oid4vc/preauth/OID4VCJwtAuthorizationDetailsFlowPreAuthTest.java b/tests/base/src/test/java/org/keycloak/tests/oid4vc/preauth/OID4VCJwtAuthorizationDetailsFlowPreAuthTest.java index 2770de81ca81..3141bbb4b4e8 100644 --- a/tests/base/src/test/java/org/keycloak/tests/oid4vc/preauth/OID4VCJwtAuthorizationDetailsFlowPreAuthTest.java +++ b/tests/base/src/test/java/org/keycloak/tests/oid4vc/preauth/OID4VCJwtAuthorizationDetailsFlowPreAuthTest.java @@ -17,6 +17,8 @@ package org.keycloak.tests.oid4vc.preauth; +import java.util.List; + import org.keycloak.representations.idm.ClientScopeRepresentation; import org.keycloak.testframework.annotations.KeycloakIntegrationTest; import org.keycloak.tests.oid4vc.OID4VCIssuerTestBase; @@ -42,8 +44,8 @@ protected ClientScopeRepresentation getCredentialClientScope() { } @Override - protected String getExpectedClaimPath() { - return "given_name"; + protected List getExpectedClaimPath() { + return List.of("credentialSubject", "given_name"); } @Override diff --git a/tests/base/src/test/java/org/keycloak/tests/oid4vc/preauth/OID4VCMdocAuthorizationDetailsFlowPreAuthTest.java b/tests/base/src/test/java/org/keycloak/tests/oid4vc/preauth/OID4VCMdocAuthorizationDetailsFlowPreAuthTest.java new file mode 100644 index 000000000000..41c62c789e0f --- /dev/null +++ b/tests/base/src/test/java/org/keycloak/tests/oid4vc/preauth/OID4VCMdocAuthorizationDetailsFlowPreAuthTest.java @@ -0,0 +1,56 @@ +/* + * Copyright 2026 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.keycloak.tests.oid4vc.preauth; + +import java.util.List; + +import org.keycloak.VCFormat; +import org.keycloak.representations.idm.ClientScopeRepresentation; +import org.keycloak.testframework.annotations.KeycloakIntegrationTest; +import org.keycloak.tests.oid4vc.OID4VCMdocTestBase; + +import static org.keycloak.tests.oid4vc.OID4VCMdocTestBase.assertMdocCredentialStructure; + +@KeycloakIntegrationTest(config = OID4VCMdocTestBase.VCTestServerWithPreAuthCodeAndMdocEnabled.class) +public class OID4VCMdocAuthorizationDetailsFlowPreAuthTest extends OID4VCAuthorizationDetailsFlowPreAuthTestBase { + + @Override + protected String getCredentialFormat() { + return VCFormat.MSO_MDOC; + } + + @Override + protected ClientScopeRepresentation getCredentialClientScope() { + ensureEcSigningKeyProvider("mdoc-preauth-auth-details-issuer-key", "P-256", "ES256", 200); + return mdocTypeCredentialScope; + } + + @Override + protected List getExpectedClaimPath() { + return List.of("org.iso.18013.5.1", "given_name"); + } + + @Override + protected String getClaimsNamespace() { + return "org.iso.18013.5.1"; + } + + @Override + protected void verifyCredentialStructure(Object credentialObj) { + assertMdocCredentialStructure(credentialObj); + } +} diff --git a/tests/base/src/test/java/org/keycloak/tests/oid4vc/preauth/OID4VCMdocIssuerEndpointPreAuthTest.java b/tests/base/src/test/java/org/keycloak/tests/oid4vc/preauth/OID4VCMdocIssuerEndpointPreAuthTest.java new file mode 100644 index 000000000000..6a5f1b49fd9f --- /dev/null +++ b/tests/base/src/test/java/org/keycloak/tests/oid4vc/preauth/OID4VCMdocIssuerEndpointPreAuthTest.java @@ -0,0 +1,113 @@ +/* + * Copyright 2026 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.keycloak.tests.oid4vc.preauth; + +import org.keycloak.models.oid4vci.CredentialScopeModel; +import org.keycloak.protocol.oid4vc.model.CredentialIssuer; +import org.keycloak.protocol.oid4vc.model.CredentialOfferURI; +import org.keycloak.protocol.oid4vc.model.CredentialResponse; +import org.keycloak.protocol.oid4vc.model.CredentialScopeRepresentation; +import org.keycloak.protocol.oid4vc.model.CredentialsOffer; +import org.keycloak.protocol.oid4vc.model.PreAuthorizedCodeGrant; +import org.keycloak.protocol.oidc.representations.OIDCConfigurationRepresentation; +import org.keycloak.testframework.annotations.KeycloakIntegrationTest; +import org.keycloak.tests.oid4vc.OID4VCMdocTestBase; +import org.keycloak.tests.oid4vc.OID4VCTestContext; +import org.keycloak.testsuite.util.AccountHelper; +import org.keycloak.testsuite.util.oauth.AccessTokenResponse; +import org.keycloak.testsuite.util.oauth.oid4vc.CredentialOfferResponse; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@KeycloakIntegrationTest(config = OID4VCMdocTestBase.VCTestServerWithPreAuthCodeAndMdocEnabled.class) +public class OID4VCMdocIssuerEndpointPreAuthTest extends OID4VCMdocTestBase { + + private CredentialScopeRepresentation mdocScope; + + @BeforeEach + void setUpMdocScope() { + ensureEcSigningKeyProvider("mdoc-preauth-endpoint-issuer-key", "P-256", "ES256", 200); + mdocScope = createMdocCredentialScope("mdoc-preauth-endpoint-scope", "mdoc-preauth-endpoint-config"); + } + + @AfterEach + public void logout() { + AccountHelper.logout(testRealm.admin(), "john"); + } + + @Test + public void testCredentialIssuancePreAuth() { + String token = oauth.openid(false) + .scope(mdocScope.getName()) + .doPasswordGrantRequest("john", TEST_PASSWORD) + .getAccessToken(); + assertNotNull(token); + + String credentialConfigurationId = mdocScope.getAttributes().get(CredentialScopeModel.VC_CONFIGURATION_ID); + + CredentialOfferURI credOfferUri = oauth.oid4vc() + .credentialOfferUriRequest(credentialConfigurationId) + .preAuthorized(true) + .targetUser("john") + .bearerToken(token) + .send() + .getCredentialOfferURI(); + assertNotNull(credOfferUri); + + CredentialOfferResponse credentialOfferResponse = oauth.oid4vc().doCredentialOfferRequest(credOfferUri); + CredentialsOffer credentialsOffer = credentialOfferResponse.getCredentialsOffer(); + assertNotNull(credentialsOffer); + + CredentialIssuer credentialIssuer = oauth.oid4vc() + .issuerMetadataRequest() + .endpoint(credentialsOffer.getIssuerMetadataUrl()) + .send() + .getMetadata(); + OIDCConfigurationRepresentation openidConfig = oauth.wellknownRequest() + .url(credentialIssuer.getAuthorizationServers().get(0)) + .send() + .getOidcConfiguration(); + + assertTrue(openidConfig.getGrantTypesSupported().contains(PreAuthorizedCodeGrant.PRE_AUTH_GRANT_TYPE)); + + AccessTokenResponse accessTokenResponse = oauth.oid4vc() + .preAuthorizedCodeGrantRequest(credentialsOffer.getPreAuthorizedCode()) + .endpoint(openidConfig.getTokenEndpoint()) + .send(); + assertEquals(200, accessTokenResponse.getStatusCode()); + assertFalse(accessTokenResponse.getOID4VCAuthorizationDetails().isEmpty()); + + String credentialIdentifier = accessTokenResponse.getOID4VCAuthorizationDetails().get(0).getCredentialIdentifiers().get(0); + OID4VCTestContext ctx = new OID4VCTestContext(client, mdocScope); + CredentialResponse credentialResponse = oauth.oid4vc() + .credentialRequest() + .credentialIdentifier(credentialIdentifier) + .proofs(wallet.generateJwtProof(ctx)) + .bearerToken(accessTokenResponse.getAccessToken()) + .send() + .getCredentialResponse(); + + assertValidMdocCredential(credentialResponse, true); + } +} diff --git a/tests/base/src/test/java/org/keycloak/tests/oid4vc/preauth/OID4VCSdJwtAuthorizationDetailsFlowPreAuthTest.java b/tests/base/src/test/java/org/keycloak/tests/oid4vc/preauth/OID4VCSdJwtAuthorizationDetailsFlowPreAuthTest.java index fb987e827d04..56d39109c99f 100644 --- a/tests/base/src/test/java/org/keycloak/tests/oid4vc/preauth/OID4VCSdJwtAuthorizationDetailsFlowPreAuthTest.java +++ b/tests/base/src/test/java/org/keycloak/tests/oid4vc/preauth/OID4VCSdJwtAuthorizationDetailsFlowPreAuthTest.java @@ -17,6 +17,8 @@ package org.keycloak.tests.oid4vc.preauth; +import java.util.List; + import org.keycloak.representations.idm.ClientScopeRepresentation; import org.keycloak.testframework.annotations.KeycloakIntegrationTest; import org.keycloak.tests.oid4vc.OID4VCIssuerTestBase; @@ -45,8 +47,8 @@ protected ClientScopeRepresentation getCredentialClientScope() { } @Override - protected String getExpectedClaimPath() { - return "lastName"; + protected List getExpectedClaimPath() { + return List.of("lastName"); } @Override diff --git a/tests/conformance/src/test/java/org/keycloak/tests/conformance/vci/AbstractVciConformanceTest.java b/tests/conformance/src/test/java/org/keycloak/tests/conformance/vci/AbstractVciConformanceTest.java index 682af96af6a5..4e9c3d1e59fc 100644 --- a/tests/conformance/src/test/java/org/keycloak/tests/conformance/vci/AbstractVciConformanceTest.java +++ b/tests/conformance/src/test/java/org/keycloak/tests/conformance/vci/AbstractVciConformanceTest.java @@ -33,6 +33,7 @@ abstract class AbstractVciConformanceTest extends AbstractConformanceTest { protected JsonNode suiteConfig(ConformanceModuleVariant module) { return VciSuiteConfig.create( OpenIdConformanceSuite.KEYCLOAK_BASE_URI, + VciConformanceRealmConfig.credentialConfigurationId(module.planVariant().get("credential_format")), VciConformanceRealmConfig.attesterJwks(), VciTestSigningKey.caCertificatePem(), module.browserInteraction()).toJson(); diff --git a/tests/conformance/src/test/java/org/keycloak/tests/conformance/vci/IssuerMdocHappyFlowTest.java b/tests/conformance/src/test/java/org/keycloak/tests/conformance/vci/IssuerMdocHappyFlowTest.java new file mode 100644 index 000000000000..cfde538eeef9 --- /dev/null +++ b/tests/conformance/src/test/java/org/keycloak/tests/conformance/vci/IssuerMdocHappyFlowTest.java @@ -0,0 +1,52 @@ +/* + * Copyright 2026 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.keycloak.tests.conformance.vci; + +import java.util.Map; +import java.util.stream.Stream; + +import org.keycloak.testframework.annotations.InjectRealm; +import org.keycloak.testframework.annotations.KeycloakIntegrationTest; +import org.keycloak.testframework.injection.LifeCycle; +import org.keycloak.testframework.realm.ManagedRealm; +import org.keycloak.tests.conformance.runner.BrowserInteraction; +import org.keycloak.tests.conformance.runner.ConformanceModuleVariant; +import org.keycloak.tests.conformance.runner.ConformanceResult; + +/** + * Runs the issuer happy flow with the ISO mdoc credential format, mirroring {@link IssuerHappyFlowTest} for SD-JWT VC. + */ +@KeycloakIntegrationTest(config = VciConformanceRealmConfig.ServerConfig.class) +public class IssuerMdocHappyFlowTest extends AbstractVciConformanceTest { + + @InjectRealm(config = VciConformanceRealmConfig.class, lifecycle = LifeCycle.METHOD) + ManagedRealm realm; + + @Override + protected Stream moduleVariants() { + // The plan variant pins every dimension but vci_credential_encryption, leaving the plain and encrypted variants + return discoverModuleVariants( + "oid4vci-1_0-issuer-haip-test-plan", + Map.of( + "credential_format", VciConformanceRealmConfig.MDOC_CREDENTIAL_FORMAT_VARIANT, + "vci_authorization_code_flow_variant", "wallet_initiated"), + "oid4vci-1_0-issuer-happy-flow", + ConformanceResult.PASSED, + BrowserInteraction.LOGIN); + } +} diff --git a/tests/conformance/src/test/java/org/keycloak/tests/conformance/vci/VciConformanceRealmConfig.java b/tests/conformance/src/test/java/org/keycloak/tests/conformance/vci/VciConformanceRealmConfig.java index 9e38a981eae8..612a740d231e 100644 --- a/tests/conformance/src/test/java/org/keycloak/tests/conformance/vci/VciConformanceRealmConfig.java +++ b/tests/conformance/src/test/java/org/keycloak/tests/conformance/vci/VciConformanceRealmConfig.java @@ -41,6 +41,7 @@ import org.keycloak.protocol.oid4vc.issuance.OID4VCIssuerWellKnownProvider; import org.keycloak.protocol.oid4vc.issuance.mappers.OID4VCGeneratedIdMapper; import org.keycloak.protocol.oid4vc.issuance.mappers.OID4VCIssuedAtTimeClaimMapper; +import org.keycloak.protocol.oid4vc.issuance.mappers.OID4VCMapper; import org.keycloak.protocol.oid4vc.model.CredentialScopeRepresentation; import org.keycloak.protocol.oid4vc.model.DisplayObject; import org.keycloak.representations.idm.ComponentExportRepresentation; @@ -58,6 +59,7 @@ import com.fasterxml.jackson.databind.JsonNode; +import static org.keycloak.OID4VCConstants.CRYPTOGRAPHIC_BINDING_METHOD_COSE_KEY; import static org.keycloak.OID4VCConstants.OID4VCI_ENABLED_ATTRIBUTE_KEY; import static org.keycloak.models.Constants.CREATE_DEFAULT_CLIENT_SCOPES; import static org.keycloak.models.Constants.DEFAULT_ROLES_ROLE_PREFIX; @@ -71,9 +73,25 @@ public class VciConformanceRealmConfig implements RealmConfig { public static final String CLIENT2 = "oid4vci-client2"; public static final String SD_JWT_SCOPE = "conformance_sd_jwt_vc"; public static final String CREDENTIAL_CONFIGURATION_ID = "conformance_sd_jwt_vc"; + public static final String MDOC_SCOPE = "conformance_mso_mdoc"; + public static final String MDOC_CREDENTIAL_CONFIGURATION_ID = "conformance_mso_mdoc"; + public static final String MDOC_DOC_TYPE = "org.iso.18013.5.1.mDL"; + public static final String MDOC_NAMESPACE = "org.iso.18013.5.1"; + // The credential_format plan variant value the conformance suite uses for ISO mdoc, see VCI1FinalCredentialFormat + public static final String MDOC_CREDENTIAL_FORMAT_VARIANT = "mdoc"; public static final String CONFORMANCE_CALLBACK = OpenIdConformanceSuite.INTERNAL_BASE_URI + "/test/a/keycloak/callback"; public static final String TRUST_IDP_ALIAS = "conformance-client-attester"; + /** + * Maps the conformance suite {@code credential_format} plan variant to the matching credential configuration id + * provisioned on the realm, defaulting to SD-JWT VC. + */ + public static String credentialConfigurationId(String credentialFormatVariant) { + return MDOC_CREDENTIAL_FORMAT_VARIANT.equals(credentialFormatVariant) + ? MDOC_CREDENTIAL_CONFIGURATION_ID + : CREDENTIAL_CONFIGURATION_ID; + } + @Override public RealmBuilder configure(RealmBuilder realm) { realm.name(REALM) @@ -84,7 +102,7 @@ public RealmBuilder configure(RealmBuilder realm) { // The conformance suite wallet requests DEF-compressed encrypted credential responses .attribute(OID4VCIssuerWellKnownProvider.ATTR_REQUEST_ZIP_ALGS, "DEF") .defaultSignatureAlgorithm(Algorithm.ES256) - .clientScopes(createSdJwtCredentialScope()) + .clientScopes(createSdJwtCredentialScope(), createMdocCredentialScope()) .users(UserBuilder.create() .username(HOLDER) .enabled(true) @@ -98,6 +116,7 @@ public RealmBuilder configure(RealmBuilder realm) { .attribute("address_locality", "London") .realmRoles(DEFAULT_ROLES_ROLE_PREFIX + "-" + REALM) .verifiableCredential(SD_JWT_SCOPE) + .verifiableCredential(MDOC_SCOPE) .build()) .clients(conformanceClient(CLIENT, false), conformanceClient(CLIENT2, true)) .update(rep -> { @@ -134,7 +153,7 @@ private ClientBuilder conformanceClient(String clientId, boolean wildcardRedirec .attribute(AttestationBasedClientAuthenticator.OAUTH_CLIENT_ATTESTATION_CONFIG_TRUST_IDPS, TRUST_IDP_ALIAS) .attribute(OID4VCIConstants.OID4VCI_ATTESTER_TRUST_IDPS_ATTR, TRUST_IDP_ALIAS) .defaultClientScopes("basic", "profile", "roles") - .optionalClientScopes(SD_JWT_SCOPE, "email") + .optionalClientScopes(SD_JWT_SCOPE, MDOC_SCOPE, "email") .attribute(OID4VCI_ENABLED_ATTRIBUTE_KEY, "true") .redirectUris(CONFORMANCE_CALLBACK + (wildcardRedirect ? "*" : "")) .webOrigins(OpenIdConformanceSuite.INTERNAL_BASE_URI.toString()); @@ -161,6 +180,28 @@ private CredentialScopeRepresentation createSdJwtCredentialScope() { return scope; } + private CredentialScopeRepresentation createMdocCredentialScope() { + CredentialScopeRepresentation scope = new CredentialScopeRepresentation(MDOC_SCOPE) + .setIncludeInTokenScope(true) + .setExpiryInSeconds(300) + .setCredentialConfigurationId(MDOC_CREDENTIAL_CONFIGURATION_ID) + .setCredentialIdentifier(MDOC_CREDENTIAL_CONFIGURATION_ID) + .setFormat(VCFormat.MSO_MDOC) + .setSigningAlg(Algorithm.ES256) + // mDoc carries the doctype where SD-JWT VC carries the vct + .setVct(MDOC_DOC_TYPE); + + scope.setDisplay(JsonSerialization.valueAsString(List.of(new DisplayObject().setName(MDOC_CREDENTIAL_CONFIGURATION_ID).setLocale("en-EN")))); + scope.setProtocolMappers(mdocProtocolMappers()); + + Map attributes = Optional.ofNullable(scope.getAttributes()).orElseGet(HashMap::new); + attributes.put(CredentialScopeModel.VC_BINDING_REQUIRED, "true"); + attributes.put(CredentialScopeModel.VC_BINDING_REQUIRED_PROOF_TYPES, "jwt"); + attributes.put(CredentialScopeModel.VC_CRYPTOGRAPHIC_BINDING_METHODS, CRYPTOGRAPHIC_BINDING_METHOD_COSE_KEY); + scope.setAttributes(attributes); + return scope; + } + private ComponentExportRepresentation conformanceSigningKeyProvider() { ComponentExportRepresentation keyProvider = new ComponentExportRepresentation(); keyProvider.setName("oid4vci-conformance-signing-key"); @@ -216,6 +257,21 @@ private List protocolMappers(String scopeName) { OID4VCIssuedAtTimeClaimMapper.VALUE_SOURCE, "COMPUTE"))); } + // mDoc claims are organised into namespaces, so every mapper pins the ISO 18013-5 namespace + private List mdocProtocolMappers() { + return List.of( + mdocMapper("did-mapper", "oid4vc-subject-id-mapper", "id", "did"), + mdocMapper("given-name-mapper", "oid4vc-user-attribute-mapper", "given_name", "firstName"), + mdocMapper("family-name-mapper", "oid4vc-user-attribute-mapper", "family_name", "lastName")); + } + + private ProtocolMapperRepresentation mdocMapper(String name, String type, String claimName, String userAttribute) { + return mapper(name, type, Map.of( + OID4VCMapper.CLAIM_NAME, claimName, + OID4VCMapper.USER_ATTRIBUTE_KEY, userAttribute, + OID4VCMapper.MDOC_NAMESPACE, MDOC_NAMESPACE)); + } + private ProtocolMapperRepresentation mapper(String name, String type, Map config) { ProtocolMapperRepresentation mapper = new ProtocolMapperRepresentation(); mapper.setName(name); @@ -230,7 +286,7 @@ public static class ServerConfig implements KeycloakServerConfig { @Override public KeycloakServerConfigBuilder configure(KeycloakServerConfigBuilder config) { - return config.features(Profile.Feature.OID4VC_VCI, Profile.Feature.CLIENT_AUTH_ABCA) + return config.features(Profile.Feature.OID4VC_VCI, Profile.Feature.OID4VC_MDOC, Profile.Feature.CLIENT_AUTH_ABCA) .option("hostname", OpenIdConformanceSuite.KEYCLOAK_BASE_URI.toString()) .spiOption("keys", "java-keystore", "keystores-path", ConformanceSigningKey.keystoresBaseDir()); } diff --git a/tests/conformance/src/test/java/org/keycloak/tests/conformance/vci/VciSuiteConfig.java b/tests/conformance/src/test/java/org/keycloak/tests/conformance/vci/VciSuiteConfig.java index e6d514423b5a..57fb76bc2eff 100644 --- a/tests/conformance/src/test/java/org/keycloak/tests/conformance/vci/VciSuiteConfig.java +++ b/tests/conformance/src/test/java/org/keycloak/tests/conformance/vci/VciSuiteConfig.java @@ -40,12 +40,12 @@ record VciSuiteConfig( Client client2, List browser) { - static VciSuiteConfig create(URI keycloakBaseUri, JsonNode attesterJwks, String trustAnchorPem, - BrowserInteraction browserInteraction) { + static VciSuiteConfig create(URI keycloakBaseUri, String credentialConfigurationId, JsonNode attesterJwks, + String trustAnchorPem, BrowserInteraction browserInteraction) { return new VciSuiteConfig( "keycloak", new Vci(keycloakBaseUri + "/realms/" + VciConformanceRealmConfig.REALM, - VciConformanceRealmConfig.CREDENTIAL_CONFIGURATION_ID, + credentialConfigurationId, "https://example.com/client-attester", attesterJwks, attesterJwks),