diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/model/VerifiableCredential.java b/services/src/main/java/org/keycloak/protocol/oid4vc/model/VerifiableCredential.java index 2e9fd7bf936..2717df7ba6a 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/model/VerifiableCredential.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/model/VerifiableCredential.java @@ -22,13 +22,24 @@ import com.fasterxml.jackson.annotation.JsonAnySetter; import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.JsonDeserializer; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.fasterxml.jackson.databind.node.TextNode; +import org.keycloak.util.JsonSerialization; +import java.io.IOException; import java.net.URI; +import java.net.URISyntaxException; import java.time.Instant; import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Optional; /** * Pojo to represent a VerifiableCredential for internal handling @@ -42,14 +53,23 @@ public class VerifiableCredential { public static final String VC_CONTEXT_V2 = "https://www.w3.org/ns/credentials/v2"; /** - * @context: The value of the @context property MUST be an ordered set where the first item is a URL with the - * value https://www.w3.org/ns/credentials/v2. Subsequent items in the ordered set MUST be composed of any - * combination of URLs and/or objects, where each is processable as a JSON-LD Context. + * @context: The value of the @context property MUST be an ordered set where the first item is a URL with the value + * https://www.w3.org/ns/credentials/v2. Subsequent items in the ordered set MUST be composed of any combination of + * URLs and/or objects, where each is processable as a JSON-LD Context. */ @JsonProperty("@context") private List context = new ArrayList<>(List.of(VC_CONTEXT_V1)); private List type = new ArrayList<>(); - private URI issuer; + + /** + * The value of the issuer property MUST be either a URL, or an object containing an id property whose value is a + * URL; in either case, the issuer selects this URL to identify itself in a globally unambiguous way. It is + * RECOMMENDED that the URL be one which, if dereferenced, results in a controller document, as defined in + * [VC-DATA-INTEGRITY] or [VC-JOSE-COSE], about the issuer that can be used to verify the information expressed in + * the credential. + */ + @JsonDeserialize(using = IssuerDeserializer.class) + private Object issuer; private Instant issuanceDate; private URI id; private Instant expirationDate; @@ -62,6 +82,11 @@ public class VerifiableCredential { return additionalProperties; } + public VerifiableCredential setAdditionalProperties(Map additionalProperties) { + this.additionalProperties = additionalProperties; + return this; + } + @JsonAnySetter public VerifiableCredential setAdditionalProperties(String name, Object property) { additionalProperties.put(name, property); @@ -86,11 +111,37 @@ public class VerifiableCredential { return this; } - public URI getIssuer() { + public Object getIssuer() { return issuer; } - public VerifiableCredential setIssuer(URI issuer) { + public VerifiableCredential setIssuer(Object issuer) { + if (issuer instanceof Map issuerMap) { + + Optional.ofNullable(issuerMap).ifPresent(map -> { + String id = (String) Optional.ofNullable(map.get("id")) + .orElseThrow(() -> new IllegalArgumentException( + "id is a required field for issuer")); + try { + // id must be a URL: https://www.w3.org/TR/vc-data-model-2.0/#issuer + new URI(id); + } catch (URISyntaxException e) { + throw new IllegalStateException("id must be a valid URI", e); + } + }); + this.issuer = issuerMap; + } + else { + try { + this.issuer = new URI(String.valueOf(issuer)); + } catch (URISyntaxException e) { + throw new IllegalStateException("id must be a valid URI", e); + } + } + return this; + } + + public VerifiableCredential setIssuerMap(Map issuer) { this.issuer = issuer; return this; } @@ -131,8 +182,24 @@ public class VerifiableCredential { return this; } - public VerifiableCredential setAdditionalProperties(Map additionalProperties) { - this.additionalProperties = additionalProperties; - return this; + public static class IssuerDeserializer extends JsonDeserializer { + + @Override + public Object deserialize(JsonParser p, DeserializationContext ctxt) throws IOException { + JsonNode node = p.readValueAsTree(); + if (node instanceof TextNode) { + try { + return new URI(node.textValue()); + } catch (URISyntaxException e) { + throw new RuntimeException(e); + } + } + else if (node instanceof ObjectNode objectNode) { + return JsonSerialization.mapper.convertValue(objectNode, Map.class); + } + else { + throw new IllegalArgumentException("Issuer must be a valid URI or a JSON object"); + } + } } } diff --git a/services/src/test/java/org/keycloak/protocol/oid4vc/model/VerifiableCredentialTest.java b/services/src/test/java/org/keycloak/protocol/oid4vc/model/VerifiableCredentialTest.java new file mode 100644 index 00000000000..2c4ba635423 --- /dev/null +++ b/services/src/test/java/org/keycloak/protocol/oid4vc/model/VerifiableCredentialTest.java @@ -0,0 +1,91 @@ +/* + * Copyright 2024 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.junit.Assert; +import org.junit.Test; +import org.keycloak.util.JsonSerialization; + +import java.io.IOException; +import java.net.URI; +import java.util.Map; + +/** + * @author Pascal Knueppel + * @since 02.07.2024 + */ +public class VerifiableCredentialTest { + + @Test + public void testIssuerIsDeserializedAsUri() throws IOException { + final String verifiableCredentialJson = """ + { + "@context": [ + "https://www.w3.org/ns/credentials/v2", + "https://www.w3.org/ns/credentials/examples/v2" + ], + "id": "http://university.example/credentials/3732", + "type": ["VerifiableCredential", "ExampleDegreeCredential"], + "issuer": "https://university.example/issuers/565049", + "validFrom": "2010-01-01T00:00:00Z", + "credentialSubject": { + "id": "did:example:ebfeb1f712ebc6f1c276e12ec21", + "degree": { + "type": "ExampleBachelorDegree", + "name": "Bachelor of Science and Arts" + } + } + } + """; + VerifiableCredential verifiableCredential = JsonSerialization.readValue(verifiableCredentialJson, + VerifiableCredential.class); + Assert.assertEquals(URI.class, verifiableCredential.getIssuer().getClass()); + } + + @Test + public void testDeserializeIssuerAsMap() throws IOException { + final String verifiableCredentialJson = """ + { + "@context": [ + "https://www.w3.org/ns/credentials/v2", + "https://www.w3.org/ns/credentials/examples/v2" + ], + "id": "http://university.example/credentials/3732", + "type": ["VerifiableCredential", "ExampleDegreeCredential"], + "issuer": { + "id": "https://university.example/issuers/565049", + "name": "Example University", + "description": "A public university focusing on teaching examples." + }, + "validFrom": "2015-05-10T12:30:00Z", + "name": "Example University Degree", + "description": "2015 Bachelor of Science and Arts Degree", + "credentialSubject": { + "id": "did:example:ebfeb1f712ebc6f1c276e12ec21", + "degree": { + "type": "ExampleBachelorDegree", + "name": "Bachelor of Science and Arts" + } + } + } + """; + VerifiableCredential verifiableCredential = JsonSerialization.readValue(verifiableCredentialJson, + VerifiableCredential.class); + Assert.assertTrue(Map.class.isAssignableFrom(verifiableCredential.getIssuer().getClass())); + } + +}