-
Notifications
You must be signed in to change notification settings - Fork 8.8k
[OpenID4VCI] Add support for mDoc (#48095) #48582
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
dominikschlosser
wants to merge
1
commit into
keycloak:main
Choose a base branch
from
dominikschlosser:ghi48095
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 { | ||
|
|
||
| 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)); | ||
| } | ||
|
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); | ||
| } | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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; | ||
|
|
||
|
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)); | ||
| } | ||
|
|
||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.