Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions common/src/main/java/org/keycloak/common/Profile.java
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
4 changes: 4 additions & 0 deletions core/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,10 @@
<groupId>com.fasterxml.jackson.datatype</groupId>
<artifactId>jackson-datatype-jsr310</artifactId>
</dependency>
<dependency>
<groupId>com.webauthn4j</groupId>
<artifactId>webauthn4j-core</artifactId>
</dependency>
<dependency>
<groupId>org.jboss.logging</groupId>
<artifactId>jboss-logging</artifactId>
Expand Down
2 changes: 2 additions & 0 deletions core/src/main/java/org/keycloak/OID4VCConstants.java
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
7 changes: 7 additions & 0 deletions core/src/main/java/org/keycloak/VCFormat.java
Original file line number Diff line number Diff line change
Expand Up @@ -39,18 +39,25 @@ 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;
}

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;
}
}
224 changes: 224 additions & 0 deletions core/src/main/java/org/keycloak/mdoc/CborUtil.java
Original file line number Diff line number Diff line change
@@ -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 {

Comment thread
dominikschlosser marked this conversation as resolved.
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<Integer, Integer> value) {
return encode(new IntegerMap(value));
}

static Object decode(byte[] encoded) {
return CBOR_CONVERTER.readValue(encoded, Object.class);
}

@SuppressWarnings("unchecked")
static Map<Object, Object> asMap(Object object, String name) {
if (object instanceof Map<?, ?>) {
return (Map<Object, Object>) object;
}
throw new MdocException("Unexpected map structure for " + name);
}

@SuppressWarnings("unchecked")
static Map<String, Object> 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<String, Object>) object;
}
throw new MdocException("Unexpected map structure for " + name);
}

@SuppressWarnings("unchecked")
static List<Object> asList(Object object, String name) {
if (object instanceof List<?>) {
return (List<Object>) 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));
}
Comment thread
dominikschlosser marked this conversation as resolved.

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<Map<?, ?>> {

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<Integer, Integer> value;

IntegerMap(Map<Integer, Integer> 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<Integer, Integer> 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);
}
}
}
77 changes: 77 additions & 0 deletions core/src/main/java/org/keycloak/mdoc/MdocAlgorithm.java
Original file line number Diff line number Diff line change
@@ -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;

Comment thread
dominikschlosser marked this conversation as resolved.
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<String> 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));
}

}
Loading
Loading