From a6d93e9efde5d40f52392a3517a543205fa1c9b6 Mon Sep 17 00:00:00 2001 From: Francis Pouatcha Date: Fri, 14 Jun 2024 01:26:20 +0100 Subject: [PATCH 01/19] Enhance SupportedCredentialConfiguration to support optional claims object as defined in OpenID for Verifiable Credential Issuance specification. Signed-off-by: Francis Pouatcha --- .../keycloak/protocol/oid4vc/model/Claim.java | 66 +++++++++++++++++++ .../protocol/oid4vc/model/ClaimDisplay.java | 51 ++++++++++++++ .../protocol/oid4vc/model/Claims.java | 44 +++++++++++++ .../SupportedCredentialConfiguration.java | 26 +++++++- .../protocol/oid4vc/model/ClaimsTest.java | 66 +++++++++++++++++++ .../OID4VCIssuerWellKnownProviderTest.java | 6 ++ .../oid4vc/issuance/signing/OID4VCTest.java | 6 +- 7 files changed, 262 insertions(+), 3 deletions(-) create mode 100644 services/src/main/java/org/keycloak/protocol/oid4vc/model/Claim.java create mode 100644 services/src/main/java/org/keycloak/protocol/oid4vc/model/ClaimDisplay.java create mode 100644 services/src/main/java/org/keycloak/protocol/oid4vc/model/Claims.java create mode 100644 services/src/test/java/org/keycloak/protocol/oid4vc/model/ClaimsTest.java 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 new file mode 100644 index 000000000000..e66c5687298e --- /dev/null +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/model/Claim.java @@ -0,0 +1,66 @@ +/* + * 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 com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.util.List; + +/** + * Holding metadata on a claim of verifiable credential. + *

+ * See: openid-4-verifiable-credential-issuance-1_0.html#appendix-A.2.2 + * + * @author Francis Pouatcha + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +public class Claim { + @JsonProperty("mandatory") + private Boolean mandatory; + @JsonProperty("value_type") + private String valueType; + @JsonProperty("display") + private List display; + + public Boolean getMandatory() { + return mandatory; + } + + public Claim setMandatory(Boolean mandatory) { + this.mandatory = mandatory; + return this; + } + + public String getValueType() { + return valueType; + } + + public Claim setValueType(String valueType) { + this.valueType = valueType; + return this; + } + + public List getDisplay() { + return display; + } + + public Claim setDisplay(List display) { + this.display = display; + return this; + } +} diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/model/ClaimDisplay.java b/services/src/main/java/org/keycloak/protocol/oid4vc/model/ClaimDisplay.java new file mode 100644 index 000000000000..83c9ca5a6226 --- /dev/null +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/model/ClaimDisplay.java @@ -0,0 +1,51 @@ +/* + * 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 com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * + * @author Francis Pouatcha + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +public class ClaimDisplay { + @JsonProperty("name") + private String name; + + @JsonProperty("locale") + private String locale; + + public String getName() { + return name; + } + + public ClaimDisplay setName(String name) { + this.name = name; + return this; + } + + public String getLocale() { + return locale; + } + + public ClaimDisplay setLocale(String locale) { + this.locale = locale; + return this; + } +} diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/model/Claims.java b/services/src/main/java/org/keycloak/protocol/oid4vc/model/Claims.java new file mode 100644 index 000000000000..308295a2dea3 --- /dev/null +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/model/Claims.java @@ -0,0 +1,44 @@ +/* + * 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.keycloak.util.JsonSerialization; + +import java.io.IOException; +import java.util.HashMap; + +/** + * @author Francis Pouatcha + */ +public class Claims extends HashMap { + + public String toJsonString(){ + try { + return JsonSerialization.writeValueAsString(this); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + public static Claims fromJsonString(String jsonString){ + try { + return JsonSerialization.readValue(jsonString, Claims.class); + } catch (IOException e) { + throw new RuntimeException(e); + } + } +} 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 c15b8369cc0a..7fdecbe66f01 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 @@ -49,6 +49,10 @@ public class SupportedCredentialConfiguration { private static final String CREDENTIAL_SIGNING_ALG_VALUES_SUPPORTED_KEY = "credential_signing_alg_values_supported"; @JsonIgnore private static final String DISPLAY_KEY = "display"; + @JsonIgnore + private static final String PROOF_TYPES_SUPPORTED = "proof_types_supported"; + @JsonIgnore + private static final String CLAIMS_KEY = "claims"; private String id; @JsonProperty(FORMAT_KEY) @@ -69,6 +73,12 @@ public class SupportedCredentialConfiguration { @JsonProperty(DISPLAY_KEY) private DisplayObject display; + @JsonProperty(PROOF_TYPES_SUPPORTED) + private List proofTypesSupported; + + @JsonProperty(CLAIMS_KEY) + private Claims claims; + public Format getFormat() { return format; } @@ -135,6 +145,15 @@ public SupportedCredentialConfiguration setCredentialSigningAlgValuesSupported(L return this; } + public Claims getClaims() { + return claims; + } + + public SupportedCredentialConfiguration setClaims(Claims claims) { + this.claims = claims; + return this; + } + public Map toDotNotation() { Map dotNotation = new HashMap<>(); Optional.ofNullable(format).ifPresent(format -> dotNotation.put(id + DOT_SEPARATOR + FORMAT_KEY, format.toString())); @@ -145,6 +164,7 @@ public Map toDotNotation() { dotNotation.put(id + DOT_SEPARATOR + CRYPTOGRAPHIC_SUITES_SUPPORTED_KEY, String.join(",", cryptographicSuitesSupported))); Optional.ofNullable(cryptographicSuitesSupported).ifPresent(types -> dotNotation.put(id + DOT_SEPARATOR + CREDENTIAL_SIGNING_ALG_VALUES_SUPPORTED_KEY, String.join(",", credentialSigningAlgValuesSupported))); + Optional.ofNullable(claims).ifPresent(c -> dotNotation.put(id + DOT_SEPARATOR + CLAIMS_KEY, c.toJsonString())); Map dotNotatedDisplay = Optional.ofNullable(display) .map(DisplayObject::toDotNotation) @@ -172,6 +192,10 @@ public static SupportedCredentialConfiguration fromDotNotation(String credential .map(css -> css.split(",")) .map(Arrays::asList) .ifPresent(supportedCredentialConfiguration::setCredentialSigningAlgValuesSupported); + Optional.ofNullable(dotNotated.get(credentialId + DOT_SEPARATOR + CLAIMS_KEY)) + .map(Claims::fromJsonString) + .ifPresent(supportedCredentialConfiguration::setClaims); + Map displayMap = new HashMap<>(); dotNotated.entrySet().forEach(entry -> { String key = entry.getKey(); @@ -213,4 +237,4 @@ public int hashCode() { result = 31 * result + (getDisplay() != null ? getDisplay().hashCode() : 0); return result; } -} \ No newline at end of file +} diff --git a/services/src/test/java/org/keycloak/protocol/oid4vc/model/ClaimsTest.java b/services/src/test/java/org/keycloak/protocol/oid4vc/model/ClaimsTest.java new file mode 100644 index 000000000000..a9d012eb2a9d --- /dev/null +++ b/services/src/test/java/org/keycloak/protocol/oid4vc/model/ClaimsTest.java @@ -0,0 +1,66 @@ +/* + * 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 com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import org.junit.Test; +import org.keycloak.util.JsonSerialization; + +import static org.junit.Assert.*; + +/** + * @author Francis Pouatcha + */ +public class ClaimsTest { + + @Test + public void toJsonString() throws JsonProcessingException { + Claims claims = new Claims(); + claims.put("firstName", new Claim()); + claims.put("lastName", new Claim()); + claims.put("email", new Claim()); + String jsonString = claims.toJsonString(); + JsonNode jsonNode = JsonSerialization.mapper.readTree(jsonString); + assertNotNull(jsonNode.get("firstName")); + assertNotNull(jsonNode.get("lastName")); + assertNotNull(jsonNode.get("email")); + } + + @Test + public void fromJsonString() { + final String serializeForm = "{ \"firstName\": {}, \"lastName\": {}, \"email\": {} }"; + Claims claims = Claims.fromJsonString(serializeForm); + assertNotNull(claims); + assertNotNull(claims.get("firstName")); + assertNotNull(claims.get("lastName")); + assertNotNull(claims.get("email")); + } + + @Test + public void fromJsonStringDeepClaim() { + final String serializeForm = "{ \"firstName\": {\"mandatory\":false}, \"lastName\": {\"mandatory\":false}, \"email\": {\"mandatory\":true} }"; + Claims claims = Claims.fromJsonString(serializeForm); + assertNotNull(claims); + assertNotNull(claims.get("firstName")); + assertFalse(claims.get("firstName").getMandatory()); + assertNotNull(claims.get("lastName")); + assertFalse(claims.get("lastName").getMandatory()); + assertNotNull(claims.get("email")); + assertTrue(claims.get("email").getMandatory()); + } +} diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCIssuerWellKnownProviderTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCIssuerWellKnownProviderTest.java index 2ee612721619..dc5cb22d5f5d 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCIssuerWellKnownProviderTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCIssuerWellKnownProviderTest.java @@ -29,6 +29,8 @@ import java.util.Map; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertTrue; public class OID4VCIssuerWellKnownProviderTest extends OID4VCTest { @@ -52,6 +54,10 @@ public void getConfig() { assertTrue("The test-credential should be supported.", credentialIssuer.getCredentialsSupported().containsKey("test-credential")); assertEquals("The test-credential should offer type VerifiableCredential", "VerifiableCredential", credentialIssuer.getCredentialsSupported().get("test-credential").getScope()); assertEquals("The test-credential should be offered in the jwt-vc format.", Format.JWT_VC, credentialIssuer.getCredentialsSupported().get("test-credential").getFormat()); + assertNotNull("The test-credential can optionally provide a claims claim.", credentialIssuer.getCredentialsSupported().get("test-credential").getClaims()); + assertNotNull("The test-credential claim firstName is present.", credentialIssuer.getCredentialsSupported().get("test-credential").getClaims().get("firstName")); + assertFalse("The test-credential claim firstName is not mandatory.", credentialIssuer.getCredentialsSupported().get("test-credential").getClaims().get("firstName").getMandatory()); + assertEquals("The test-credential claim firstName shall be displayed as First Name", "First Name", credentialIssuer.getCredentialsSupported().get("test-credential").getClaims().get("firstName").getDisplay().get(0).getName()); })); } diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCTest.java index 3b4500cefb9e..861b6248c53f 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCTest.java @@ -182,7 +182,9 @@ public static ClientRepresentation getTestClient(String clientId) { clientRepresentation.setAttributes(Map.of( "vc.test-credential.expiry_in_s", "100", "vc.test-credential.format", Format.JWT_VC.toString(), - "vc.test-credential.scope", "VerifiableCredential")); + "vc.test-credential.scope", "VerifiableCredential", + "vc.test-credential.claims", "{ \"firstName\": {\"mandatory\": false, \"display\": [{\"name\": \"First Name\", \"locale\": \"en-US\"}, {\"name\": \"名前\", \"locale\": \"ja-JP\"}]}, \"lastName\": {\"mandatory\": false}, \"email\": {\"mandatory\": false} }" + )); clientRepresentation.setProtocolMappers( List.of( getRoleMapper(clientId), @@ -347,4 +349,4 @@ public long currentTimeMillis() { } } -} \ No newline at end of file +} From 8db7d15e4fa3c2d085703a75f1e148084e363fb4 Mon Sep 17 00:00:00 2001 From: Francis Pouatcha Date: Sat, 15 Jun 2024 14:06:23 +0100 Subject: [PATCH 02/19] Modifications from issue comment https://github.com/keycloak/keycloak/issues/30419#issuecomment-2169408107 vct claim is mandatory for sd-jwt. Can be ommited by other formats display shall be an array display object can be configured using an escaped json, as dot notation will be cumbersome with arrays proof_types_supported can be configured using an escaped json, as each proof type has different configuration parameters. Signed-off-by: Francis Pouatcha --- .../protocol/oid4vc/model/DisplayObject.java | 38 +++---- .../protocol/oid4vc/model/ProofTypeCWT.java | 81 ++++++++++++++ .../protocol/oid4vc/model/ProofTypeJWT.java | 56 ++++++++++ .../protocol/oid4vc/model/ProofTypeLdpVp.java | 28 +++++ .../oid4vc/model/ProofTypesSupported.java | 96 ++++++++++++++++ .../SupportedCredentialConfiguration.java | 103 ++++++++++-------- .../OID4VCClientRegistrationProviderTest.java | 25 ++--- .../OID4VCIssuerWellKnownProviderTest.java | 6 + .../oid4vc/issuance/signing/OID4VCTest.java | 7 +- 9 files changed, 360 insertions(+), 80 deletions(-) create mode 100644 services/src/main/java/org/keycloak/protocol/oid4vc/model/ProofTypeCWT.java create mode 100644 services/src/main/java/org/keycloak/protocol/oid4vc/model/ProofTypeJWT.java create mode 100644 services/src/main/java/org/keycloak/protocol/oid4vc/model/ProofTypeLdpVp.java create mode 100644 services/src/main/java/org/keycloak/protocol/oid4vc/model/ProofTypesSupported.java diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/model/DisplayObject.java b/services/src/main/java/org/keycloak/protocol/oid4vc/model/DisplayObject.java index a25d8155d7fb..df51baf4ad6a 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/model/DisplayObject.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/model/DisplayObject.java @@ -14,17 +14,15 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - package org.keycloak.protocol.oid4vc.model; import com.fasterxml.jackson.annotation.JsonAutoDetect; import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; +import org.keycloak.util.JsonSerialization; -import java.util.HashMap; -import java.util.Map; -import java.util.Optional; +import java.io.IOException; /** * Represents a DisplayObject, as used in the OID4VCI Credentials Issuer Metadata @@ -126,26 +124,20 @@ public DisplayObject setTextColor(String textColor) { return this; } - public Map toDotNotation() { - Map dotNotation = new HashMap<>(); - dotNotation.put(NAME_KEY, name); - dotNotation.put(LOCALE_KEY, locale); - dotNotation.put(LOGO_KEY, logo); - dotNotation.put(DESCRIPTION_KEY, description); - dotNotation.put(BG_COLOR_KEY, backgroundColor); - dotNotation.put(TEXT_COLOR_KEY, textColor); - return dotNotation; + public String toJsonString(){ + try { + return JsonSerialization.writeValueAsString(this); + } catch (IOException e) { + throw new RuntimeException(e); + } } - public static DisplayObject fromDotNotation(Map dotNotated) { - DisplayObject displayObject = new DisplayObject(); - Optional.ofNullable(dotNotated.get(NAME_KEY)).ifPresent(displayObject::setName); - Optional.ofNullable(dotNotated.get(LOCALE_KEY)).ifPresent(displayObject::setLocale); - Optional.ofNullable(dotNotated.get(LOGO_KEY)).ifPresent(displayObject::setLogo); - Optional.ofNullable(dotNotated.get(DESCRIPTION_KEY)).ifPresent(displayObject::setDescription); - Optional.ofNullable(dotNotated.get(BG_COLOR_KEY)).ifPresent(displayObject::setBackgroundColor); - Optional.ofNullable(dotNotated.get(TEXT_COLOR_KEY)).ifPresent(displayObject::setTextColor); - return displayObject; + public static DisplayObject fromJsonString(String jsonString){ + try { + return JsonSerialization.readValue(jsonString, DisplayObject.class); + } catch (IOException e) { + throw new RuntimeException(e); + } } @Override @@ -173,4 +165,4 @@ public int hashCode() { result = 31 * result + (getTextColor() != null ? getTextColor().hashCode() : 0); return result; } -} \ No newline at end of file +} diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/model/ProofTypeCWT.java b/services/src/main/java/org/keycloak/protocol/oid4vc/model/ProofTypeCWT.java new file mode 100644 index 000000000000..dd06b1a03ba3 --- /dev/null +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/model/ProofTypeCWT.java @@ -0,0 +1,81 @@ +/* + * 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 com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.util.List; +import java.util.Objects; + +/** + * See: https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#name-cwt-proof-type + * + * @author Francis Pouatcha + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +public class ProofTypeCWT { + + @JsonProperty("proof_signing_alg_values_supported") + private List proofSigningAlgValuesSupported; + + @JsonProperty("proof_alg_values_supported") + private List proofAlgValuesSupported; + + @JsonProperty("proof_crv_values_supported") + private List proofCrvValuesSupported; + + public List getProofSigningAlgValuesSupported() { + return proofSigningAlgValuesSupported; + } + + public ProofTypeCWT setProofSigningAlgValuesSupported(List proofSigningAlgValuesSupported) { + this.proofSigningAlgValuesSupported = proofSigningAlgValuesSupported; + return this; + } + + public List getProofAlgValuesSupported() { + return proofAlgValuesSupported; + } + + public ProofTypeCWT setProofAlgValuesSupported(List proofAlgValuesSupported) { + this.proofAlgValuesSupported = proofAlgValuesSupported; + return this; + } + + public List getProofCrvValuesSupported() { + return proofCrvValuesSupported; + } + + public ProofTypeCWT setProofCrvValuesSupported(List proofCrvValuesSupported) { + this.proofCrvValuesSupported = proofCrvValuesSupported; + return this; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + ProofTypeCWT that = (ProofTypeCWT) o; + return Objects.equals(proofSigningAlgValuesSupported, that.proofSigningAlgValuesSupported) && Objects.equals(proofAlgValuesSupported, that.proofAlgValuesSupported) && Objects.equals(proofCrvValuesSupported, that.proofCrvValuesSupported); + } + + @Override + public int hashCode() { + return Objects.hash(proofSigningAlgValuesSupported, proofAlgValuesSupported, proofCrvValuesSupported); + } +} diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/model/ProofTypeJWT.java b/services/src/main/java/org/keycloak/protocol/oid4vc/model/ProofTypeJWT.java new file mode 100644 index 000000000000..cf923e37fcdd --- /dev/null +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/model/ProofTypeJWT.java @@ -0,0 +1,56 @@ +/* + * 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 com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.util.List; +import java.util.Objects; + +/** + * See: https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#name-jwt-proof-type + * + * @author Francis Pouatcha + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +public class ProofTypeJWT { + @JsonProperty("proof_signing_alg_values_supported") + private List proofSigningAlgValuesSupported; + + public List getProofSigningAlgValuesSupported() { + return proofSigningAlgValuesSupported; + } + + public ProofTypeJWT setProofSigningAlgValuesSupported(List proofSigningAlgValuesSupported) { + this.proofSigningAlgValuesSupported = proofSigningAlgValuesSupported; + return this; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + ProofTypeJWT that = (ProofTypeJWT) o; + return Objects.equals(proofSigningAlgValuesSupported, that.proofSigningAlgValuesSupported); + } + + @Override + public int hashCode() { + return Objects.hash(proofSigningAlgValuesSupported); + } +} diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/model/ProofTypeLdpVp.java b/services/src/main/java/org/keycloak/protocol/oid4vc/model/ProofTypeLdpVp.java new file mode 100644 index 000000000000..7de5c83a9f0f --- /dev/null +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/model/ProofTypeLdpVp.java @@ -0,0 +1,28 @@ +/* + * 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 com.fasterxml.jackson.annotation.JsonInclude; + +/** + * See: https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#name-ldp_vp-proof-type + * + * @author Francis Pouatcha + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +public class ProofTypeLdpVp { +} diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/model/ProofTypesSupported.java b/services/src/main/java/org/keycloak/protocol/oid4vc/model/ProofTypesSupported.java new file mode 100644 index 000000000000..d232ff35ee25 --- /dev/null +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/model/ProofTypesSupported.java @@ -0,0 +1,96 @@ +/* + * 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 com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import org.keycloak.util.JsonSerialization; + +import java.io.IOException; +import java.util.Objects; + +/** + * See: https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#name-proof-types + * + * @author Francis Pouatcha + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +public class ProofTypesSupported { + @JsonProperty("jwt") + private ProofTypeJWT jwt; + @JsonProperty("cwt") + private ProofTypeCWT cwt; + + @JsonProperty("ldp_vp") + private ProofTypeLdpVp ldpVp; + + public ProofTypeJWT getJwt() { + return jwt; + } + + public ProofTypesSupported setJwt(ProofTypeJWT jwt) { + this.jwt = jwt; + return this; + } + + public ProofTypeCWT getCwt() { + return cwt; + } + + public ProofTypesSupported setCwt(ProofTypeCWT cwt) { + this.cwt = cwt; + return this; + } + + public ProofTypeLdpVp getLdpVp() { + return ldpVp; + } + + public ProofTypesSupported setLdpVp(ProofTypeLdpVp ldpVp) { + this.ldpVp = ldpVp; + return this; + } + + public String toJsonString(){ + try { + return JsonSerialization.writeValueAsString(this); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + public static ProofTypesSupported fromJsonString(String jsonString){ + try { + return JsonSerialization.readValue(jsonString, ProofTypesSupported.class); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + ProofTypesSupported that = (ProofTypesSupported) o; + return Objects.equals(jwt, that.jwt) && Objects.equals(cwt, that.cwt) && Objects.equals(ldpVp, that.ldpVp); + } + + @Override + public int hashCode() { + return Objects.hash(jwt, cwt, ldpVp); + } +} 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 7fdecbe66f01..bef34c1b88a4 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 @@ -14,7 +14,6 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - package org.keycloak.protocol.oid4vc.model; import com.fasterxml.jackson.annotation.JsonIgnore; @@ -26,7 +25,9 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Optional; +import java.util.stream.Collectors; /** * A supported credential, as used in the Credentials Issuer Metadata in OID4VCI @@ -42,7 +43,7 @@ public class SupportedCredentialConfiguration { @JsonIgnore private static final String SCOPE_KEY = "scope"; @JsonIgnore - private static final String CRYPTOGRAPHIC_BINDING_METHODS_SUPPORTED_KEY = " credential_signing_alg_values_supported"; + private static final String CRYPTOGRAPHIC_BINDING_METHODS_SUPPORTED_KEY = "cryptographic_binding_methods_supported"; @JsonIgnore private static final String CRYPTOGRAPHIC_SUITES_SUPPORTED_KEY = "cryptographic_suites_supported"; @JsonIgnore @@ -50,9 +51,11 @@ public class SupportedCredentialConfiguration { @JsonIgnore private static final String DISPLAY_KEY = "display"; @JsonIgnore - private static final String PROOF_TYPES_SUPPORTED = "proof_types_supported"; + private static final String PROOF_TYPES_SUPPORTED_KEY = "proof_types_supported"; @JsonIgnore private static final String CLAIMS_KEY = "claims"; + @JsonIgnore + private static final String VERIFIABLE_CREDENTIAL_TYPE_KEY = "vct"; private String id; @JsonProperty(FORMAT_KEY) @@ -71,10 +74,13 @@ public class SupportedCredentialConfiguration { private List credentialSigningAlgValuesSupported; @JsonProperty(DISPLAY_KEY) - private DisplayObject display; + private List display; + + @JsonProperty(VERIFIABLE_CREDENTIAL_TYPE_KEY) + private String vct; - @JsonProperty(PROOF_TYPES_SUPPORTED) - private List proofTypesSupported; + @JsonProperty(PROOF_TYPES_SUPPORTED_KEY) + private ProofTypesSupported proofTypesSupported; @JsonProperty(CLAIMS_KEY) private Claims claims; @@ -115,11 +121,11 @@ public SupportedCredentialConfiguration setCryptographicSuitesSupported(List getDisplay() { return display; } - public SupportedCredentialConfiguration setDisplay(DisplayObject display) { + public SupportedCredentialConfiguration setDisplay(List display) { this.display = display; return this; } @@ -154,9 +160,28 @@ public SupportedCredentialConfiguration setClaims(Claims claims) { return this; } + public String getVct() { + return vct; + } + + public SupportedCredentialConfiguration setVct(String vct) { + this.vct = vct; + return this; + } + + public ProofTypesSupported getProofTypesSupported() { + return proofTypesSupported; + } + + public SupportedCredentialConfiguration setProofTypesSupported(ProofTypesSupported proofTypesSupported) { + this.proofTypesSupported = proofTypesSupported; + return this; + } + public Map toDotNotation() { Map dotNotation = new HashMap<>(); Optional.ofNullable(format).ifPresent(format -> dotNotation.put(id + DOT_SEPARATOR + FORMAT_KEY, format.toString())); + Optional.ofNullable(vct).ifPresent(vct -> dotNotation.put(id + DOT_SEPARATOR + VERIFIABLE_CREDENTIAL_TYPE_KEY, vct)); Optional.ofNullable(scope).ifPresent(scope -> dotNotation.put(id + DOT_SEPARATOR + SCOPE_KEY, scope)); Optional.ofNullable(cryptographicBindingMethodsSupported).ifPresent(types -> dotNotation.put(id + DOT_SEPARATOR + CRYPTOGRAPHIC_BINDING_METHODS_SUPPORTED_KEY, String.join(",", cryptographicBindingMethodsSupported))); @@ -166,12 +191,14 @@ public Map toDotNotation() { dotNotation.put(id + DOT_SEPARATOR + CREDENTIAL_SIGNING_ALG_VALUES_SUPPORTED_KEY, String.join(",", credentialSigningAlgValuesSupported))); Optional.ofNullable(claims).ifPresent(c -> dotNotation.put(id + DOT_SEPARATOR + CLAIMS_KEY, c.toJsonString())); - Map dotNotatedDisplay = Optional.ofNullable(display) - .map(DisplayObject::toDotNotation) - .orElse(Map.of()); - dotNotatedDisplay.entrySet().stream() - .filter(entry -> entry.getValue() != null) - .forEach(entry -> dotNotation.put(id + DOT_SEPARATOR + DISPLAY_KEY + "." + entry.getKey(), entry.getValue())); + Optional.ofNullable(display) + .ifPresent(d -> d.stream() + .filter(Objects::nonNull) + .forEach(o -> dotNotation.put(id + DOT_SEPARATOR + DISPLAY_KEY + DOT_SEPARATOR + d.indexOf(o), o.toJsonString()))); + + Optional.ofNullable(proofTypesSupported) + .ifPresent(p -> dotNotation.put(id + DOT_SEPARATOR + PROOF_TYPES_SUPPORTED_KEY, p.toJsonString())); + return dotNotation; } @@ -179,6 +206,7 @@ public static SupportedCredentialConfiguration fromDotNotation(String credential SupportedCredentialConfiguration supportedCredentialConfiguration = new SupportedCredentialConfiguration().setId(credentialId); Optional.ofNullable(dotNotated.get(credentialId + DOT_SEPARATOR + FORMAT_KEY)).map(Format::fromString).ifPresent(supportedCredentialConfiguration::setFormat); + Optional.ofNullable(dotNotated.get(credentialId + DOT_SEPARATOR + VERIFIABLE_CREDENTIAL_TYPE_KEY)).ifPresent(supportedCredentialConfiguration::setVct); Optional.ofNullable(dotNotated.get(credentialId + DOT_SEPARATOR + SCOPE_KEY)).ifPresent(supportedCredentialConfiguration::setScope); Optional.ofNullable(dotNotated.get(credentialId + DOT_SEPARATOR + CRYPTOGRAPHIC_BINDING_METHODS_SUPPORTED_KEY)) .map(cbms -> cbms.split(",")) @@ -196,45 +224,34 @@ public static SupportedCredentialConfiguration fromDotNotation(String credential .map(Claims::fromJsonString) .ifPresent(supportedCredentialConfiguration::setClaims); - Map displayMap = new HashMap<>(); - dotNotated.entrySet().forEach(entry -> { - String key = entry.getKey(); - if (key.startsWith(credentialId + DOT_SEPARATOR + DISPLAY_KEY)) { - displayMap.put(key.substring((credentialId + DOT_SEPARATOR + DISPLAY_KEY).length() + 1), entry.getValue()); - } - }); - if (!displayMap.isEmpty()) { - supportedCredentialConfiguration.setDisplay(DisplayObject.fromDotNotation(displayMap)); + String displayKeyPrefix = credentialId + DOT_SEPARATOR + DISPLAY_KEY + DOT_SEPARATOR; + List displayList = dotNotated.entrySet().stream() + .filter(entry -> entry.getKey().startsWith(displayKeyPrefix)) + .sorted(Map.Entry.comparingByKey()) + .map(entry -> DisplayObject.fromJsonString(entry.getValue())) + .collect(Collectors.toList()); + + if(!displayList.isEmpty()){ + supportedCredentialConfiguration.setDisplay(displayList); } + + Optional.ofNullable(dotNotated.get(credentialId + DOT_SEPARATOR + PROOF_TYPES_SUPPORTED_KEY)) + .map(ProofTypesSupported::fromJsonString) + .ifPresent(supportedCredentialConfiguration::setProofTypesSupported); + return supportedCredentialConfiguration; } @Override public boolean equals(Object o) { if (this == o) return true; - if (!(o instanceof SupportedCredentialConfiguration that)) return false; - - if (getId() != null ? !getId().equals(that.getId()) : that.getId() != null) return false; - if (getFormat() != that.getFormat()) return false; - if (getScope() != null ? !getScope().equals(that.getScope()) : that.getScope() != null) return false; - if (getCryptographicBindingMethodsSupported() != null ? !getCryptographicBindingMethodsSupported().equals(that.getCryptographicBindingMethodsSupported()) : that.getCryptographicBindingMethodsSupported() != null) - return false; - if (getCryptographicSuitesSupported() != null ? !getCryptographicSuitesSupported().equals(that.getCryptographicSuitesSupported()) : that.getCryptographicSuitesSupported() != null) - return false; - if (getCredentialSigningAlgValuesSupported() != null ? !getCredentialSigningAlgValuesSupported().equals(that.getCredentialSigningAlgValuesSupported()) : that.getCredentialSigningAlgValuesSupported() != null) - return false; - return getDisplay() != null ? getDisplay().equals(that.getDisplay()) : that.getDisplay() == null; + if (o == null || getClass() != o.getClass()) return false; + SupportedCredentialConfiguration that = (SupportedCredentialConfiguration) o; + return Objects.equals(id, that.id) && format == that.format && Objects.equals(scope, that.scope) && Objects.equals(cryptographicBindingMethodsSupported, that.cryptographicBindingMethodsSupported) && Objects.equals(cryptographicSuitesSupported, that.cryptographicSuitesSupported) && Objects.equals(credentialSigningAlgValuesSupported, that.credentialSigningAlgValuesSupported) && Objects.equals(display, that.display) && Objects.equals(vct, that.vct) && Objects.equals(proofTypesSupported, that.proofTypesSupported) && Objects.equals(claims, that.claims); } @Override public int hashCode() { - int result = getId() != null ? getId().hashCode() : 0; - result = 31 * result + (getFormat() != null ? getFormat().hashCode() : 0); - result = 31 * result + (getScope() != null ? getScope().hashCode() : 0); - result = 31 * result + (getCryptographicBindingMethodsSupported() != null ? getCryptographicBindingMethodsSupported().hashCode() : 0); - result = 31 * result + (getCryptographicSuitesSupported() != null ? getCryptographicSuitesSupported().hashCode() : 0); - result = 31 * result + (getCredentialSigningAlgValuesSupported() != null ? getCredentialSigningAlgValuesSupported().hashCode() : 0); - result = 31 * result + (getDisplay() != null ? getDisplay().hashCode() : 0); - return result; + return Objects.hash(id, format, scope, cryptographicBindingMethodsSupported, cryptographicSuitesSupported, credentialSigningAlgValuesSupported, display, vct, proofTypesSupported, claims); } } diff --git a/services/src/test/java/org/keycloak/protocol/oid4vc/OID4VCClientRegistrationProviderTest.java b/services/src/test/java/org/keycloak/protocol/oid4vc/OID4VCClientRegistrationProviderTest.java index 29736ffd1828..b6caeff7398d 100644 --- a/services/src/test/java/org/keycloak/protocol/oid4vc/OID4VCClientRegistrationProviderTest.java +++ b/services/src/test/java/org/keycloak/protocol/oid4vc/OID4VCClientRegistrationProviderTest.java @@ -14,7 +14,6 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - package org.keycloak.protocol.oid4vc; import org.junit.Test; @@ -23,6 +22,8 @@ import org.keycloak.protocol.oid4vc.model.DisplayObject; import org.keycloak.protocol.oid4vc.model.Format; import org.keycloak.protocol.oid4vc.model.OID4VCClient; +import org.keycloak.protocol.oid4vc.model.ProofTypeJWT; +import org.keycloak.protocol.oid4vc.model.ProofTypesSupported; import org.keycloak.protocol.oid4vc.model.SupportedCredentialConfiguration; import java.util.Arrays; @@ -31,7 +32,6 @@ import java.util.Map; import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertTrue; @RunWith(Parameterized.class) public class OID4VCClientRegistrationProviderTest { @@ -68,13 +68,12 @@ public static Collection parameters() { Map.of( "vc.credential-id.format", Format.JWT_VC.toString(), "vc.credential-id.scope", "AnotherCredential", - "vc.credential-id.display.name", "Another", - "vc.credential-id.display.locale", "en"), + "vc.credential-id.display.0", "{\"name\":\"Another\",\"locale\":\"en\"}"), new OID4VCClient(null, "did:web:test.org", List.of(new SupportedCredentialConfiguration() .setId("credential-id") .setFormat(Format.JWT_VC) - .setDisplay(new DisplayObject().setLocale("en").setName("Another")) + .setDisplay(Arrays.asList(new DisplayObject().setLocale("en").setName("Another"))) .setScope("AnotherCredential")), null, null) }, @@ -83,23 +82,23 @@ public static Collection parameters() { Map.of( "vc.first-id.format", Format.JWT_VC.toString(), "vc.first-id.scope", "AnotherCredential", - "vc.first-id.display.name", "First", - "vc.first-id.display.locale", "en", + "vc.first-id.display.0", "{\"name\":\"First\",\"locale\":\"en\"}", "vc.second-id.format", Format.SD_JWT_VC.toString(), "vc.second-id.scope", "MyType", - "vc.second-id.display.name", "Second Credential", - "vc.second-id.display.locale", "de"), + "vc.second-id.display.0", "{\"name\":\"Second Credential\",\"locale\":\"de\"}", + "vc.second-id.proof_types_supported","{\"jwt\":{\"proof_signing_alg_values_supported\":[\"ES256\"]}}"), new OID4VCClient(null, "did:web:test.org", List.of(new SupportedCredentialConfiguration() .setId("first-id") .setFormat(Format.JWT_VC) - .setDisplay(new DisplayObject().setLocale("en").setName("First")) + .setDisplay(Arrays.asList(new DisplayObject().setLocale("en").setName("First"))) .setScope("AnotherCredential"), new SupportedCredentialConfiguration() .setId("second-id") .setFormat(Format.SD_JWT_VC) - .setDisplay(new DisplayObject().setLocale("de").setName("Second Credential")) - .setScope("MyType")), + .setDisplay(Arrays.asList(new DisplayObject().setLocale("de").setName("Second Credential"))) + .setScope("MyType") + .setProofTypesSupported(new ProofTypesSupported().setJwt(new ProofTypeJWT().setProofSigningAlgValuesSupported(Arrays.asList("ES256"))))), null, null) } }); @@ -129,4 +128,4 @@ public void testFromClientAttributes() { OID4VCClientRegistrationProvider.fromClientAttributes("did:web:test.org", clientAttributes)); } -} \ No newline at end of file +} diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCIssuerWellKnownProviderTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCIssuerWellKnownProviderTest.java index dc5cb22d5f5d..73129a0d9900 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCIssuerWellKnownProviderTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCIssuerWellKnownProviderTest.java @@ -58,6 +58,12 @@ public void getConfig() { assertNotNull("The test-credential claim firstName is present.", credentialIssuer.getCredentialsSupported().get("test-credential").getClaims().get("firstName")); assertFalse("The test-credential claim firstName is not mandatory.", credentialIssuer.getCredentialsSupported().get("test-credential").getClaims().get("firstName").getMandatory()); assertEquals("The test-credential claim firstName shall be displayed as First Name", "First Name", credentialIssuer.getCredentialsSupported().get("test-credential").getClaims().get("firstName").getDisplay().get(0).getName()); + assertEquals("The test-credential should offer vct VerifiableCredential", "VerifiableCredential", credentialIssuer.getCredentialsSupported().get("test-credential").getVct()); + assertTrue("The test-credential should contain a cryptographic binding method supported named jwk", credentialIssuer.getCredentialsSupported().get("test-credential").getCryptographicBindingMethodsSupported().contains("jwk")); + assertTrue("The test-credential should contain a credential signing algorithm named ES256", credentialIssuer.getCredentialsSupported().get("test-credential").getCredentialSigningAlgValuesSupported().contains("ES256")); + assertTrue("The test-credential should contain a credential signing algorithm named ES384", credentialIssuer.getCredentialsSupported().get("test-credential").getCredentialSigningAlgValuesSupported().contains("ES384")); + assertEquals("The test-credential should display as Test Credential", "Test Credential", credentialIssuer.getCredentialsSupported().get("test-credential").getDisplay().get(0).getName()); + assertTrue("The test-credential should support a proof of type jwt with signing algorithm ES256", credentialIssuer.getCredentialsSupported().get("test-credential").getProofTypesSupported().getJwt().getProofSigningAlgValuesSupported().contains("ES256")); })); } diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCTest.java index 861b6248c53f..954dda47b4da 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCTest.java @@ -183,7 +183,12 @@ public static ClientRepresentation getTestClient(String clientId) { "vc.test-credential.expiry_in_s", "100", "vc.test-credential.format", Format.JWT_VC.toString(), "vc.test-credential.scope", "VerifiableCredential", - "vc.test-credential.claims", "{ \"firstName\": {\"mandatory\": false, \"display\": [{\"name\": \"First Name\", \"locale\": \"en-US\"}, {\"name\": \"名前\", \"locale\": \"ja-JP\"}]}, \"lastName\": {\"mandatory\": false}, \"email\": {\"mandatory\": false} }" + "vc.test-credential.claims", "{ \"firstName\": {\"mandatory\": false, \"display\": [{\"name\": \"First Name\", \"locale\": \"en-US\"}, {\"name\": \"名前\", \"locale\": \"ja-JP\"}]}, \"lastName\": {\"mandatory\": false}, \"email\": {\"mandatory\": false} }", + "vc.test-credential.vct", "VerifiableCredential", + "vc.test-credential.cryptographic_binding_methods_supported", "jwk", + "vc.test-credential.credential_signing_alg_values_supported", "ES256,ES384", + "vc.test-credential.display.0","{\n \"name\": \"Test Credential\"\n}", + "vc.test-credential.proof_types_supported","{\"jwt\":{\"proof_signing_alg_values_supported\":[\"ES256\"]}}" )); clientRepresentation.setProtocolMappers( List.of( From e06264d960d24c56d954e469cedb0c876ac9b728 Mon Sep 17 00:00:00 2001 From: Francis Pouatcha Date: Wed, 19 Jun 2024 14:22:08 +0100 Subject: [PATCH 03/19] Fix on issue 30525 Signed-off-by: Francis Pouatcha --- .../oid4vc/OID4VCLoginProtocolFactory.java | 9 +- .../oid4vc/issuance/OID4VCIssuerEndpoint.java | 97 +++++---- .../oid4vc/issuance/VCIssuanceContext.java | 74 +++++++ .../oid4vc/issuance/mappers/OID4VCMapper.java | 12 +- .../issuance/signing/JwtSigningService.java | 10 +- .../issuance/signing/LDSigningService.java | 10 +- .../issuance/signing/SdJwtSigningService.java | 194 +++++++++++++++++- .../SdJwtSigningServiceProviderFactory.java | 5 +- .../issuance/signing/SigningProperties.java | 7 +- .../issuance/signing/SigningService.java | 57 ++++- .../VCSigningServiceProviderFactory.java | 9 +- .../VerifiableCredentialsSigningService.java | 20 +- .../keycloak/protocol/oid4vc/model/Proof.java | 37 +++- .../signing/JwtSigningServiceTest.java | 7 +- .../signing/LDSigningServiceTest.java | 3 +- .../signing/OID4VCIssuerEndpointTest.java | 4 +- .../oid4vc/issuance/signing/OID4VCTest.java | 18 +- .../signing/SdJwtSigningServiceTest.java | 12 +- 18 files changed, 491 insertions(+), 94 deletions(-) create mode 100644 services/src/main/java/org/keycloak/protocol/oid4vc/issuance/VCIssuanceContext.java 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 166675f1b705..1b28914360fc 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/OID4VCLoginProtocolFactory.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/OID4VCLoginProtocolFactory.java @@ -99,12 +99,13 @@ public Map getBuiltinMappers() { return builtins; } - private void addServiceFromComponent(Map signingServices, KeycloakSession keycloakSession, ComponentModel componentModel) { + private void addServiceFromComponent(Map signingServices, KeycloakSession keycloakSession, ComponentModel componentModel) { ProviderFactory factory = keycloakSession .getKeycloakSessionFactory() .getProviderFactory(VerifiableCredentialsSigningService.class, componentModel.getProviderId()); if (factory instanceof VCSigningServiceProviderFactory sspf) { - signingServices.put(sspf.supportedFormat(), sspf.create(keycloakSession, componentModel)); + VerifiableCredentialsSigningService verifiableCredentialsSigningService = sspf.create(keycloakSession, componentModel); + signingServices.put(verifiableCredentialsSigningService.locator(), sspf.create(keycloakSession, componentModel)); } else { throw new IllegalArgumentException(String.format("The component %s is not a VerifiableCredentialsSigningServiceProviderFactory", componentModel.getProviderId())); } @@ -114,7 +115,7 @@ private void addServiceFromComponent(Map signingServices = new EnumMap<>(Format.class); + Map signingServices = new HashMap<>(); RealmModel realm = keycloakSession.getContext().getRealm(); realm.getComponentsStream(realm.getId(), VerifiableCredentialsSigningService.class.getName()) .forEach(cm -> addServiceFromComponent(signingServices, keycloakSession, cm)); @@ -170,4 +171,4 @@ public String getId() { return PROTOCOL_ID; } -} \ No newline at end of file +} 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 344a6e3d7578..2deb082eec40 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 @@ -24,8 +24,6 @@ import com.google.zxing.client.j2se.MatrixToImageWriter; import com.google.zxing.common.BitMatrix; import com.google.zxing.qrcode.QRCodeWriter; -import com.google.zxing.qrcode.encoder.QRCode; -import jakarta.annotation.Nullable; import jakarta.ws.rs.BadRequestException; import jakarta.ws.rs.Consumes; import jakarta.ws.rs.DefaultValue; @@ -37,7 +35,6 @@ import jakarta.ws.rs.QueryParam; import jakarta.ws.rs.WebApplicationException; import jakarta.ws.rs.core.Response; -import org.apache.http.HttpStatus; import org.jboss.logging.Logger; import org.keycloak.common.util.SecretGenerator; import org.keycloak.models.AuthenticatedClientSessionModel; @@ -69,14 +66,12 @@ import org.keycloak.services.managers.AuthenticationManager; import org.keycloak.utils.MediaType; -import java.awt.image.BufferedImage; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.net.URI; import java.net.URLEncoder; import java.nio.charset.StandardCharsets; import java.time.Instant; -import java.util.Base64; import java.util.Date; import java.util.HashMap; import java.util.List; @@ -107,11 +102,23 @@ public class OID4VCIssuerEndpoint { // lifespan of the preAuthorizedCodes in seconds private final int preAuthorizedCodeLifeSpan; - private final Map signingServices; + /** + * Key shall be strings, as configured credential of the same format can + * have different configs. Like decoy, visible claims, + * time requirements (iat, exp, nbf, ...). + * + * Credentials with same configs can share a default entry with locator= {@link Format#name()}. + * + * Credentials in need of special configuration can provide another signer with specific + * locator={@link Format#name()}/vc_config_id + * + * The providerId of the signing service factory is still the format. + */ + private final Map signingServices; public OID4VCIssuerEndpoint(KeycloakSession session, String issuerDid, - Map signingServices, + Map signingServices, AppAuthManager.BearerTokenAuthenticator authenticator, ObjectMapper objectMapper, TimeProvider timeProvider, int preAuthorizedCodeLifeSpan) { this.session = session; @@ -143,10 +150,9 @@ public Response getCredentialOfferURI(@QueryParam("credential_configuration_id") throw new BadRequestException(getErrorResponse(ErrorType.INVALID_CREDENTIAL_REQUEST)); } SupportedCredentialConfiguration supportedCredentialConfiguration = credentialsMap.get(vcId); - Format format = supportedCredentialConfiguration.getFormat(); // check that the user is allowed to get such credential - if (getClientsOfType(supportedCredentialConfiguration.getScope(), format).isEmpty()) { + if (getClientsOfScope(supportedCredentialConfiguration.getScope(), supportedCredentialConfiguration.getFormat()).isEmpty()) { LOGGER.debugf("No OID4VP-Client supporting type %s registered.", supportedCredentialConfiguration.getScope()); throw new BadRequestException(getErrorResponse(ErrorType.UNSUPPORTED_CREDENTIAL_TYPE)); } @@ -250,7 +256,7 @@ public Response requestCredential( LOGGER.debugf("Received credentials request %s.", credentialRequestVO); // do first to fail fast on auth - UserSessionModel userSessionModel = getUserSessionModel(); + AuthenticationManager.AuthResult authResult = getAuthResult(); Format requestedFormat = credentialRequestVO.getFormat(); String requestedCredential = credentialRequestVO.getCredentialIdentifier(); @@ -271,7 +277,7 @@ public Response requestCredential( CredentialResponse responseVO = new CredentialResponse(); - Object theCredential = getCredential(userSessionModel, supportedCredentialConfiguration.getScope(), credentialRequestVO.getFormat()); + Object theCredential = getCredential(authResult, supportedCredentialConfiguration, credentialRequestVO); switch (requestedFormat) { case LDP_VC, JWT_VC, SD_JWT_VC -> responseVO.setCredential(theCredential); default -> throw new BadRequestException( @@ -294,12 +300,6 @@ private AuthenticatedClientSessionModel getAuthenticatedClientSession() { return clientSession; } - // return the current UserSessionModel - private UserSessionModel getUserSessionModel() { - return getAuthResult( - new BadRequestException(getErrorResponse(ErrorType.INVALID_TOKEN))).getSession(); - } - private AuthenticationManager.AuthResult getAuthResult() { return getAuthResult(new BadRequestException(getErrorResponse(ErrorType.INVALID_TOKEN))); } @@ -316,14 +316,14 @@ private AuthenticationManager.AuthResult getAuthResult(WebApplicationException e /** * Get a signed credential * - * @param userSessionModel userSession to create the credential for - * @param vcType type of the credential to be created - * @param format format of the credential to be created + * @param authResult authResult containing the userSession to create the credential for + * @param credentialConfig the supported credential configuration + * @param credentialRequestVO * @return the signed credential */ - private Object getCredential(UserSessionModel userSessionModel, String vcType, Format format) { + private Object getCredential(AuthenticationManager.AuthResult authResult, SupportedCredentialConfiguration credentialConfig, CredentialRequest credentialRequestVO) { - List clients = getClientsOfType(vcType, format); + List clients = getClientsOfScope(credentialConfig.getScope(), credentialConfig.getFormat()); List protocolMappers = getProtocolMappers(clients) .stream() @@ -341,11 +341,21 @@ private Object getCredential(UserSessionModel userSessionModel, String vcType, F .filter(Objects::nonNull) .toList(); - VerifiableCredential credentialToSign = getVCToSign(protocolMappers, vcType, userSessionModel); + VCIssuanceContext vcIssuanceContext = getVCToSign(protocolMappers, credentialConfig, authResult, credentialRequestVO); + + String specificConfigKey = VerifiableCredentialsSigningService.locator(credentialConfig.getFormat(), credentialConfig.getId()); + String formatKey = credentialConfig.getFormat().name(); - return Optional.ofNullable(signingServices.get(format)) - .map(verifiableCredentialsSigningService -> verifiableCredentialsSigningService.signCredential(credentialToSign)) - .orElseThrow(() -> new IllegalArgumentException(String.format("Requested format %s is not supported.", format))); + // First retrieve by format/id, then by format only + VerifiableCredentialsSigningService signingService = signingServices.getOrDefault( + specificConfigKey, signingServices.get(formatKey) + ); + + return Optional.ofNullable(signingService) + .map(service -> service.signCredential(vcIssuanceContext)) + .orElseThrow(() -> new IllegalArgumentException( + String.format("No signer found for specific config '%s' or format '%s'.", specificConfigKey, formatKey) + )); } private List getProtocolMappers(List oid4VCClients) { @@ -372,19 +382,20 @@ private Response getErrorResponse(ErrorType errorType) { return Response.status(Response.Status.BAD_REQUEST).entity(errorResponse).build(); } - // Return all {@link OID4VCClient}s that support the given type and format - private List getClientsOfType(String vcType, Format format) { - LOGGER.debugf("Retrieve all clients of type %s, supporting format %s", vcType, format.toString()); + // Return all {@link OID4VCClient}s that support the given scope and format + // Scope might be different from vct. In the case of sd-jwt for eaxample + private List getClientsOfScope(String vcScope, Format format) { + LOGGER.debugf("Retrieve all clients of scope %s, supporting format %s", vcScope, format.toString()); - if (Optional.ofNullable(vcType).filter(type -> !type.isEmpty()).isEmpty()) { - throw new BadRequestException("No VerifiableCredential-Type was provided in the request."); + if (Optional.ofNullable(vcScope).filter(scope -> !scope.isEmpty()).isEmpty()) { + throw new BadRequestException("No VerifiableCredential-Scope was provided in the request."); } return getOID4VCClientsFromSession() .stream() .filter(oid4VCClient -> oid4VCClient.getSupportedVCTypes() .stream() - .anyMatch(supportedCredential -> supportedCredential.getScope().equals(vcType))) + .anyMatch(supportedCredential -> supportedCredential.getScope().equals(vcScope))) .toList(); } @@ -402,28 +413,32 @@ private List getOID4VCClientsFromSession() { } // builds the unsigned credential by applying all protocol mappers. - private VerifiableCredential getVCToSign(List protocolMappers, String vcType, - UserSessionModel userSessionModel) { + private VCIssuanceContext getVCToSign(List protocolMappers, SupportedCredentialConfiguration credentialConfig, + AuthenticationManager.AuthResult authResult, CredentialRequest credentialRequestVO) { // set the required claims VerifiableCredential vc = new VerifiableCredential() .setIssuer(URI.create(issuerDid)) .setIssuanceDate(Date.from(Instant.ofEpochMilli(timeProvider.currentTimeMillis()))) - .setType(List.of(vcType)); + .setType(List.of(credentialConfig.getScope())); Map subjectClaims = new HashMap<>(); protocolMappers .stream() - .filter(mapper -> mapper.isTypeSupported(vcType)) - .forEach(mapper -> mapper.setClaimsForSubject(subjectClaims, userSessionModel)); + .filter(mapper -> mapper.isScopeSupported(credentialConfig.getScope())) + .forEach(mapper -> mapper.setClaimsForSubject(subjectClaims, authResult.getSession())); subjectClaims.forEach((key, value) -> vc.getCredentialSubject().setClaims(key, value)); protocolMappers .stream() - .filter(mapper -> mapper.isTypeSupported(vcType)) - .forEach(mapper -> mapper.setClaimsForCredential(vc, userSessionModel)); + .filter(mapper -> mapper.isScopeSupported(credentialConfig.getScope())) + .forEach(mapper -> mapper.setClaimsForCredential(vc, authResult.getSession())); LOGGER.debugf("The credential to sign is: %s", vc); - return vc; + + return new VCIssuanceContext().setAuthResult(authResult) + .setVerifiableCredential(vc) + .setCredentialConfig(credentialConfig) + .setCredentialRequest(credentialRequestVO); } -} \ No newline at end of file +} diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/VCIssuanceContext.java b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/VCIssuanceContext.java new file mode 100644 index 000000000000..0f470d303174 --- /dev/null +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/VCIssuanceContext.java @@ -0,0 +1,74 @@ +/* + * 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.issuance; + +import org.keycloak.protocol.oid4vc.model.CredentialRequest; +import org.keycloak.protocol.oid4vc.model.SupportedCredentialConfiguration; +import org.keycloak.protocol.oid4vc.model.VerifiableCredential; +import org.keycloak.services.managers.AuthenticationManager; + +/** + * Holds the verifiable credential to sign and additional context information. + * + * Helps keeps the {@link VerifiableCredential} as clean pojo. Without any risk to + * mistakenly serialize unwanted information. + * + * @author Francis Pouatcha + */ +public class VCIssuanceContext { + private VerifiableCredential verifiableCredential; + + private SupportedCredentialConfiguration credentialConfig; + private CredentialRequest credentialRequest; + private AuthenticationManager.AuthResult authResult; + + public VerifiableCredential getVerifiableCredential() { + return verifiableCredential; + } + + public VCIssuanceContext setVerifiableCredential(VerifiableCredential verifiableCredential) { + this.verifiableCredential = verifiableCredential; + return this; + } + + public SupportedCredentialConfiguration getCredentialConfig() { + return credentialConfig; + } + + public VCIssuanceContext setCredentialConfig(SupportedCredentialConfiguration credentialConfig) { + this.credentialConfig = credentialConfig; + return this; + } + + public CredentialRequest getCredentialRequest() { + return credentialRequest; + } + + public VCIssuanceContext setCredentialRequest(CredentialRequest credentialRequest) { + this.credentialRequest = credentialRequest; + return this; + } + + public AuthenticationManager.AuthResult getAuthResult() { + return authResult; + } + + public VCIssuanceContext setAuthResult(AuthenticationManager.AuthResult authResult) { + this.authResult = authResult; + return this; + } +} 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 9cc4f3745a24..67ff39da5eee 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 @@ -97,15 +97,15 @@ public void close() { /** * Checks if the mapper supports the given credential type. Allows to configure them not only per client, but also per VC Type. * - * @param credentialType type of the VerifiableCredential that should be checked + * @param credentialScope type of the VerifiableCredential that should be checked * @return true if it is supported */ - public boolean isTypeSupported(String credentialType) { - var optionalTypes = Optional.ofNullable(mapperModel.getConfig().get(SUPPORTED_CREDENTIALS_KEY)); - if (optionalTypes.isEmpty()) { + public boolean isScopeSupported(String credentialScope) { + var optionalScopes = Optional.ofNullable(mapperModel.getConfig().get(SUPPORTED_CREDENTIALS_KEY)); + if (optionalScopes.isEmpty()) { return false; } - return Arrays.asList(optionalTypes.get().split(",")).contains(credentialType); + return Arrays.asList(optionalScopes.get().split(",")).contains(credentialScope); } /** @@ -120,4 +120,4 @@ public abstract void setClaimsForCredential(VerifiableCredential verifiableCrede public abstract void setClaimsForSubject(Map claims, UserSessionModel userSessionModel); -} \ No newline at end of file +} diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/signing/JwtSigningService.java b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/signing/JwtSigningService.java index fd076417eef2..8ee5f4dc7538 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/signing/JwtSigningService.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/signing/JwtSigningService.java @@ -24,6 +24,8 @@ import org.keycloak.jose.jws.JWSBuilder; import org.keycloak.models.KeycloakSession; import org.keycloak.protocol.oid4vc.issuance.TimeProvider; +import org.keycloak.protocol.oid4vc.issuance.VCIssuanceContext; +import org.keycloak.protocol.oid4vc.model.Format; import org.keycloak.protocol.oid4vc.model.VerifiableCredential; import org.keycloak.representations.JsonWebToken; @@ -53,7 +55,7 @@ public class JwtSigningService extends SigningService { protected final String issuerDid; public JwtSigningService(KeycloakSession keycloakSession, String keyId, String algorithmType, String tokenType, String issuerDid, TimeProvider timeProvider) { - super(keycloakSession, keyId, algorithmType); + super(keycloakSession, keyId, Format.JWT_VC, algorithmType); this.issuerDid = issuerDid; this.timeProvider = timeProvider; this.tokenType = tokenType; @@ -68,9 +70,11 @@ public JwtSigningService(KeycloakSession keycloakSession, String keyId, String a } @Override - public String signCredential(VerifiableCredential verifiableCredential) { + public String signCredential(VCIssuanceContext vcIssuanceContext) { LOGGER.debugf("Sign credentials to jwt-vc format."); + VerifiableCredential verifiableCredential = vcIssuanceContext.getVerifiableCredential(); + // Get the issuance date from the credential. Since nbf is mandatory, we set it to the current time if not // provided long iat = Optional.ofNullable(verifiableCredential.getIssuanceDate()) @@ -110,4 +114,4 @@ static String createCredentialId(VerifiableCredential verifiableCredential) { .orElse(URI.create(String.format(ID_TEMPLATE, UUID.randomUUID()))) .toString(); } -} \ No newline at end of file +} diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/signing/LDSigningService.java b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/signing/LDSigningService.java index 1feb7a6c0bb1..e61e9799543c 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/signing/LDSigningService.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/signing/LDSigningService.java @@ -24,8 +24,10 @@ import org.keycloak.crypto.SignatureProvider; import org.keycloak.models.KeycloakSession; import org.keycloak.protocol.oid4vc.issuance.TimeProvider; +import org.keycloak.protocol.oid4vc.issuance.VCIssuanceContext; import org.keycloak.protocol.oid4vc.issuance.signing.vcdm.Ed255192018Suite; import org.keycloak.protocol.oid4vc.issuance.signing.vcdm.LinkedDataCryptographicSuite; +import org.keycloak.protocol.oid4vc.model.Format; import org.keycloak.protocol.oid4vc.model.VerifiableCredential; import org.keycloak.protocol.oid4vc.model.vcdm.LdProof; @@ -49,7 +51,7 @@ public class LDSigningService extends SigningService { private final String keyId; public LDSigningService(KeycloakSession keycloakSession, String keyId, String algorithmType, String ldpType, ObjectMapper objectMapper, TimeProvider timeProvider, Optional kid) { - super(keycloakSession, keyId, algorithmType); + super(keycloakSession, keyId, Format.LDP_VC, algorithmType); this.timeProvider = timeProvider; this.keyId = kid.orElse(keyId); KeyWrapper signingKey = getKey(keyId, algorithmType); @@ -72,8 +74,8 @@ public LDSigningService(KeycloakSession keycloakSession, String keyId, String al } @Override - public VerifiableCredential signCredential(VerifiableCredential verifiableCredential) { - return addProof(verifiableCredential); + public VerifiableCredential signCredential(VCIssuanceContext vcIssuanceContext) { + return addProof(vcIssuanceContext.getVerifiableCredential()); } // add the signed proof to the credential. @@ -96,4 +98,4 @@ private VerifiableCredential addProof(VerifiableCredential verifiableCredential) throw new SigningServiceException("Was not able to encode the signature.", e); } } -} \ No newline at end of file +} diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/signing/SdJwtSigningService.java b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/signing/SdJwtSigningService.java index e461bc033857..825eea195c09 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/signing/SdJwtSigningService.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/signing/SdJwtSigningService.java @@ -21,18 +21,34 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.ObjectNode; import org.jboss.logging.Logger; +import org.keycloak.common.VerificationException; import org.keycloak.crypto.KeyWrapper; import org.keycloak.crypto.SignatureProvider; import org.keycloak.crypto.SignatureSignerContext; +import org.keycloak.jose.jwk.JWK; +import org.keycloak.jose.jws.JWSHeader; +import org.keycloak.jose.jws.JWSInput; +import org.keycloak.jose.jws.JWSInputException; import org.keycloak.models.KeycloakSession; import org.keycloak.protocol.oid4vc.issuance.TimeProvider; +import org.keycloak.protocol.oid4vc.issuance.VCIssuanceContext; +import org.keycloak.protocol.oid4vc.issuance.VCIssuerException; import org.keycloak.protocol.oid4vc.model.CredentialSubject; +import org.keycloak.protocol.oid4vc.model.Format; +import org.keycloak.protocol.oid4vc.model.Proof; +import org.keycloak.protocol.oid4vc.model.ProofType; +import org.keycloak.protocol.oid4vc.model.ProofTypeJWT; import org.keycloak.protocol.oid4vc.model.VerifiableCredential; +import org.keycloak.representations.AccessToken; import org.keycloak.sdjwt.DisclosureSpec; import org.keycloak.sdjwt.SdJwt; import org.keycloak.sdjwt.SdJwtUtils; +import org.keycloak.util.JsonSerialization; +import java.io.IOException; import java.util.List; +import java.util.Map; +import java.util.Objects; import java.util.Optional; import java.util.stream.IntStream; @@ -53,6 +69,9 @@ public class SdJwtSigningService extends SigningService { private static final String NOT_BEFORE_CLAIM ="nbf"; private static final String VERIFIABLE_CREDENTIAL_TYPE_CLAIM = "vct"; private static final String CREDENTIAL_ID_CLAIM = "jti"; + private static final String CNF_CLAIM = "cnf"; + private static final String JWK_CLAIM = "jwk"; + public static final String PROOF_JWT_TYP="openid4vci-proof+jwt"; private final ObjectMapper objectMapper; private final SignatureSignerContext signatureSignerContext; @@ -63,8 +82,10 @@ public class SdJwtSigningService extends SigningService { private final List visibleClaims; protected final String issuerDid; - public SdJwtSigningService(KeycloakSession keycloakSession, ObjectMapper objectMapper, String keyId, String algorithmType, String tokenType, String hashAlgorithm, String issuerDid, int decoys, List visibleClaims, TimeProvider timeProvider, Optional kid) { - super(keycloakSession, keyId, algorithmType); + private final String vcConfigId; + + public SdJwtSigningService(KeycloakSession keycloakSession, ObjectMapper objectMapper, String keyId, String algorithmType, String tokenType, String hashAlgorithm, String issuerDid, int decoys, List visibleClaims, TimeProvider timeProvider, Optional kid, String vcConfigId) { + super(keycloakSession, keyId, Format.SD_JWT_VC, algorithmType); this.objectMapper = objectMapper; this.issuerDid = issuerDid; this.timeProvider = timeProvider; @@ -72,17 +93,21 @@ public SdJwtSigningService(KeycloakSession keycloakSession, ObjectMapper objectM this.hashAlgorithm = hashAlgorithm; this.decoys = decoys; this.visibleClaims = visibleClaims; + this.vcConfigId = vcConfigId; + // Will return the active key if key id is null. KeyWrapper signingKey = getKey(keyId, algorithmType); if (signingKey == null) { throw new SigningServiceException(String.format("No key for id %s and algorithm %s available.", keyId, algorithmType)); } + // @Francis: keyId header can be confusing if there is any key rotation, as key ids have to be immutable. It can lead + // to different keys being exposed under the same id. // set the configured kid if present. if (kid.isPresent()) { // we need to clone the key first, to not change the kid of the original key so that the next request still can find it. signingKey = signingKey.cloneKey(); signingKey.setKid(keyId); } - kid.ifPresent(signingKey::setKid); + SignatureProvider signatureProvider = keycloakSession.getProvider(SignatureProvider.class, algorithmType); signatureSignerContext = signatureProvider.signer(signingKey); @@ -90,8 +115,17 @@ public SdJwtSigningService(KeycloakSession keycloakSession, ObjectMapper objectM } @Override - public String signCredential(VerifiableCredential verifiableCredential) { + public String signCredential(VCIssuanceContext vcIssuanceContext) throws VCIssuerException { + + JWK jwk = null; + try { + // null returned is a valid result. Means no key binding will be included. + jwk = validateProof(vcIssuanceContext); + } catch (JWSInputException | VerificationException | IOException e) { + throw new VCIssuerException("Can not verify proof", e); + } + VerifiableCredential verifiableCredential = vcIssuanceContext.getVerifiableCredential(); DisclosureSpec.Builder disclosureSpecBuilder = DisclosureSpec.builder(); CredentialSubject credentialSubject = verifiableCredential.getCredentialSubject(); JsonNode claimSet = objectMapper.valueToTree(credentialSubject); @@ -130,6 +164,11 @@ public String signCredential(VerifiableCredential verifiableCredential) { rootNode.put(VERIFIABLE_CREDENTIAL_TYPE_CLAIM, verifiableCredential.getType().get(0)); rootNode.put(CREDENTIAL_ID_CLAIM, JwtSigningService.createCredentialId(verifiableCredential)); + // add the key binding if any + if(jwk!=null){ + rootNode.putPOJO(CNF_CLAIM, Map.of(JWK_CLAIM, jwk)); + } + SdJwt sdJwt = SdJwt.builder() .withDisclosureSpec(disclosureSpecBuilder.build()) .withClaimSet(claimSet) @@ -141,4 +180,149 @@ public String signCredential(VerifiableCredential verifiableCredential) { return sdJwt.toSdJwtString(); } -} \ No newline at end of file + @Override + public String locator() { + return VerifiableCredentialsSigningService.locator(format,vcConfigId); + } + + /* + * Validates a proof provided by the client if any. + * + * Returns null if there is no need to include a key binding in the credential + * + * Return the JWK to be included as key binding in the JWK if the provided proof was correctly validated + * + * @param vcIssuanceContext + * @return + * @throws VCIssuerException + * @throws JWSInputException + * @throws VerificationException + * @throws IllegalStateException: is credential type badly configured + * @throws IOException + */ + private JWK validateProof(VCIssuanceContext vcIssuanceContext) throws VCIssuerException, JWSInputException, VerificationException, IOException { + + Optional optionalProof = getProofFromContext(vcIssuanceContext); + + if (!optionalProof.isPresent()) { + return null; // No proof support + } + + // Check key binding config for jwt. Only type supported. + checkCryptographicKeyBinding(vcIssuanceContext); + + JWSInput jwsInput = getJwsInput(optionalProof.get()); + JWSHeader jwsHeader = jwsInput.getHeader(); + validateJwsHeader(vcIssuanceContext, jwsHeader); + + JWK jwk = Optional.ofNullable(jwsHeader.getKey()) + .orElseThrow(() -> new VCIssuerException("Missing binding key. Make sure provided JWT contains the jwk jwsHeader claim.")); + + // Parsing the Proof as an access token shall work, as a proof is a strict subset of an access token. + AccessToken proofPayload = JsonSerialization.readValue(jwsInput.getContent(), AccessToken.class); + validateProofPayload(vcIssuanceContext, proofPayload); + + if (!getVerifier(jwk, jwsHeader.getAlgorithm().name()).verify(jwsInput.getContent(), jwsInput.getSignature())) { + throw new VCIssuerException("Could not verify provided proof"); + } + + return jwk; + } + + private void checkCryptographicKeyBinding(VCIssuanceContext vcIssuanceContext){ + // Make sure we are dealing with a jwk proof. + if (vcIssuanceContext.getCredentialConfig().getCryptographicBindingMethodsSupported() != null || + !vcIssuanceContext.getCredentialConfig().getCryptographicBindingMethodsSupported().contains("jwk")) { + throw new IllegalStateException("This SD-JWT implementation only supports jwk as cryptographic binding method"); + } + } + + private Optional getProofFromContext(VCIssuanceContext vcIssuanceContext) throws VCIssuerException { + return Optional.ofNullable(vcIssuanceContext.getCredentialConfig()) + .map(config -> config.getProofTypesSupported()) + .flatMap(proofTypesSupported -> { + if (proofTypesSupported == null) { + LOGGER.debugf("No proof support. Will skip proof validation."); + return Optional.empty(); + } + + ProofTypeJWT jwt = Optional.ofNullable(proofTypesSupported.getJwt()) + .orElseThrow(() -> new VCIssuerException("SD-JWT supports only jwt proof type.")); + + Proof proof = Optional.ofNullable(vcIssuanceContext.getCredentialRequest().getProof()) + .orElseThrow(() -> new VCIssuerException("Credential configuration requires a proof of type: " + ProofType.JWT.getValue())); + + if (!Objects.equals(proof.getProofType(), ProofType.JWT)) { + throw new VCIssuerException("Wrong proof type"); + } + + return Optional.of(proof); + }); + } + + private JWSInput getJwsInput(Proof proof) throws JWSInputException { + return new JWSInput(proof.getJwt()); + } + + /** + * As we limit accepted algorithm to the ones listed by the issuer, we can omit checking for "none" + * The Algorithm enum class does not list the none value anyway. + * + * @param vcIssuanceContext + * @param jwsHeader + * @throws VCIssuerException + */ + private void validateJwsHeader(VCIssuanceContext vcIssuanceContext, JWSHeader jwsHeader) throws VCIssuerException { + Optional.ofNullable(jwsHeader.getAlgorithm()) + .orElseThrow(() -> new VCIssuerException("Missing jwsHeader claim alg")); + + // As we limit accepted algorithm to the ones listed by the server, we can omit checking for "none" + // The Algorithm enum class does not list the none value anyway. + Optional.ofNullable(vcIssuanceContext.getCredentialConfig()) + .map(config -> config.getProofTypesSupported()) + .map(proofTypesSupported -> proofTypesSupported.getJwt()) + .map(jwt -> jwt.getProofSigningAlgValuesSupported()) + .filter(supportedAlgs -> supportedAlgs.contains(jwsHeader.getAlgorithm().name())) + .orElseThrow(() -> new VCIssuerException("Proof signature algorithm not supported: " + jwsHeader.getAlgorithm().name())); + + Optional.ofNullable(jwsHeader.getType()) + .filter(type -> Objects.equals(PROOF_JWT_TYP, type)) + .orElseThrow(() -> new VCIssuerException("JWT type must be: " + PROOF_JWT_TYP)); + + // KeyId shall not be present alongside the jwk. + Optional.ofNullable(jwsHeader.getKeyId()) + .ifPresent(keyId -> { + throw new VCIssuerException("KeyId not expected in this JWT. Use the jwk claim instead."); + }); + } + + private void validateProofPayload(VCIssuanceContext vcIssuanceContext, AccessToken proofPayload) throws VCIssuerException { + // azp is the id of the client, as mentioned in the access token used to request the credential. + String azp = vcIssuanceContext.getAuthResult().getToken().getIssuedFor(); + Optional.ofNullable(proofPayload.getIssuer()) + .filter(proofIssuer -> Objects.equals(azp, proofIssuer)) + .orElseThrow(() -> new VCIssuerException("Issuer claim must be null for preauthorized code else the clientId of the client making the request: " + azp)); + + // The issuer is the token / credential is the audience of the proof + String credentialIssuer = vcIssuanceContext.getVerifiableCredential().getIssuer().toString(); + Optional.ofNullable(proofPayload.getAudience()) + .filter(audience -> Objects.equals(credentialIssuer, audience)) + .orElseThrow(() -> new VCIssuerException("Proof not produced for this audience. Audience claim must be: " + credentialIssuer)); + + // Validate mandatory iat. + // I do not understand the rationale behind requiring a issue time if we are not checking expiration. + Optional.ofNullable(proofPayload.getIat()) + .orElseThrow(() -> new VCIssuerException("Missing proof issuing time. iat claim must be provided.")); + + // Check cNonce matches. + // We really dislike having to produce and manage a new nonce. As in this case + // token and credential issuer match, we will consider the nonce in the access token + // the c_nonce. We will also consider the expiration time of the access token the + // expiration time of the c_nonce. This way nonce is automatically validated with token expiry. + String cNonce = vcIssuanceContext.getAuthResult().getToken().getNonce(); + Optional.ofNullable(proofPayload.getNonce()) + .filter(nonce -> Objects.equals(cNonce, nonce)) + .orElseThrow(() -> new VCIssuerException("Missing or wrong nonce value. Please provide nonce returned by the issuer if any.")); + } + +} diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/signing/SdJwtSigningServiceProviderFactory.java b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/signing/SdJwtSigningServiceProviderFactory.java index c1685443df18..6eaf687a7f69 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/signing/SdJwtSigningServiceProviderFactory.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/signing/SdJwtSigningServiceProviderFactory.java @@ -50,6 +50,7 @@ public VerifiableCredentialsSigningService create(KeycloakSession session, Compo String hashAlgorithm = model.get(SigningProperties.HASH_ALGORITHM.getKey()); Optional kid = Optional.ofNullable(model.get(SigningProperties.KID_HEADER.getKey())); int decoys = Integer.parseInt(model.get(SigningProperties.DECOYS.getKey())); + String vcConfigId = model.get(SigningProperties.VC_CONFIG_ID.getKey()); List visibleClaims = Optional.ofNullable(model.get(SigningProperties.VISIBLE_CLAIMS.getKey())) .map(visibileClaims -> visibileClaims.split(",")) @@ -63,7 +64,7 @@ public VerifiableCredentialsSigningService create(KeycloakSession session, Compo .getAttribute(ISSUER_DID_REALM_ATTRIBUTE_KEY)) .orElseThrow(() -> new VCIssuerException("No issuerDid configured.")); - return new SdJwtSigningService(session, new ObjectMapper(), keyId, algorithmType, tokenType, hashAlgorithm, issuerDid, decoys, visibleClaims, new OffsetTimeProvider(), kid); + return new SdJwtSigningService(session, new ObjectMapper(), keyId, algorithmType, tokenType, hashAlgorithm, issuerDid, decoys, visibleClaims, new OffsetTimeProvider(), kid, vcConfigId); } @Override @@ -100,4 +101,4 @@ public void validateSpecificConfiguration(KeycloakSession session, RealmModel re public Format supportedFormat() { return SUPPORTED_FORMAT; } -} \ No newline at end of file +} diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/signing/SigningProperties.java b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/signing/SigningProperties.java index 6a6c863fe670..a83c62d53f48 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/signing/SigningProperties.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/signing/SigningProperties.java @@ -29,13 +29,16 @@ public enum SigningProperties { ISSUER_DID("issuerDid", "Did of the issuer.", "Provide the DID of the issuer. Needs to match the provided key material.", ProviderConfigProperty.STRING_TYPE, null), KEY_ID("keyId", "Id of the signing key.", "The id of the key to be used for signing credentials. The key needs to be provided as a realm key.", ProviderConfigProperty.STRING_TYPE, null), + // keyId header can be confusing if there is any key rotation, as key ids have to be immutable. It can lead + // to different keys being exposed under the same id. KID_HEADER("kidHeader", "Kid to be set for the JWT.", "The kid to be set in the jwt-header. Depending on the did-schema, the pure key-id might not be enough and can be overwritten here.", ProviderConfigProperty.STRING_TYPE, null), PROOF_TYPE("proofType", "Type of the LD-Proof.", "The type of LD-Proofs to be created. Needs to fit the provided signing key.", ProviderConfigProperty.STRING_TYPE, null), ALGORITHM_TYPE("algorithmType", "Type of the signing algorithm.", "The type of the algorithm to be used for signing. Needs to fit the provided signing key.", ProviderConfigProperty.STRING_TYPE, Algorithm.RS256), TOKEN_TYPE("tokenType", "Type of the token.", "The type of the token to be created. Will be used as `typ` claim in the JWT-Header.", ProviderConfigProperty.STRING_TYPE, "JWT"), DECOYS("decoys", "Number of decoys to be added.", "The number of decoys to be added to the SD-JWT.", ProviderConfigProperty.STRING_TYPE, 0), HASH_ALGORITHM("hashAlgorithm", "Hash algorithm for SD-JWTs.", "The hash algorithm to be used for the SD-JWTs.", ProviderConfigProperty.STRING_TYPE, "sha-256"), - VISIBLE_CLAIMS("visibleClaims", "Visible claims of the SD-JWT.", "List of claims to stay disclosed in the SD-JWT.", ProviderConfigProperty.MULTIVALUED_STRING_TYPE, null); + VISIBLE_CLAIMS("visibleClaims", "Visible claims of the SD-JWT.", "List of claims to stay disclosed in the SD-JWT.", ProviderConfigProperty.MULTIVALUED_STRING_TYPE, null), + VC_CONFIG_ID("vcConfigId", "Credential configuration identifier", "The identifier of this credential configuration", ProviderConfigProperty.MULTIVALUED_STRING_TYPE, null); private final String key; private final String label; @@ -59,4 +62,4 @@ public ProviderConfigProperty asConfigProperty() { public String getKey() { return key; } -} \ No newline at end of file +} diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/signing/SigningService.java b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/signing/SigningService.java index 7a1b5da3c1f1..f07391a594f2 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/signing/SigningService.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/signing/SigningService.java @@ -17,9 +17,16 @@ package org.keycloak.protocol.oid4vc.issuance.signing; +import org.keycloak.common.VerificationException; import org.keycloak.crypto.KeyUse; import org.keycloak.crypto.KeyWrapper; +import org.keycloak.crypto.SignatureProvider; +import org.keycloak.crypto.SignatureVerifierContext; +import org.keycloak.jose.jwk.JWK; +import org.keycloak.jose.jwk.JWKParser; +import org.keycloak.jose.jwk.OKPPublicJWK; import org.keycloak.models.KeycloakSession; +import org.keycloak.protocol.oid4vc.model.Format; /** * Abstract base class to provide the Signing Services common functionality @@ -34,18 +41,64 @@ public abstract class SigningService implements VerifiableCredentialsSigningS // values of the type field are defined by the implementing service. Could f.e. the security suite for ldp_vc or the algorithm to be used for jwt_vc protected final String type; - protected SigningService(KeycloakSession keycloakSession, String keyId, String type) { + // As the type is not identical to the format, we use the format as a factory to + // instantiate provider. + protected final Format format; + + protected SigningService(KeycloakSession keycloakSession, String keyId, Format format, String type) { this.keycloakSession = keycloakSession; this.keyId = keyId; + this.format = format; this.type = type; } + @Override + public String locator() { + return format.name(); + } + + /** + * Returns the key stored under kid, or the active key for the given jws algorithm, + * + * @param kid + * @param algorithm + * @return + */ protected KeyWrapper getKey(String kid, String algorithm) { + // Allow the service to work with the active key if keyId is null + // And we still have to figure out how to proceed with key rotation + if(keyId==null){ + return keycloakSession.keys().getActiveKey(keycloakSession.getContext().getRealm(), KeyUse.SIG, algorithm); + } return keycloakSession.keys().getKey(keycloakSession.getContext().getRealm(), kid, KeyUse.SIG, algorithm); } + protected SignatureVerifierContext getVerifier(JWK jwk, String jwsAlgorithm) throws VerificationException { + SignatureProvider signatureProvider = keycloakSession.getProvider(SignatureProvider.class, jwsAlgorithm); + return signatureProvider.verifier(getKeyWraper(jwk, jwsAlgorithm, KeyUse.SIG)); + } + + private KeyWrapper getKeyWraper(JWK jwk, String algorithm, KeyUse keyUse) { + KeyWrapper keyWrapper = new KeyWrapper(); + keyWrapper.setType(jwk.getKeyType()); + + // Use the algorithm provided by the caller, and not the one inside the jwk (if any) + // As jws validation will also check that one against the value "none" + keyWrapper.setAlgorithm(algorithm); + + // Set the curve if any + if (jwk.getOtherClaims().get(OKPPublicJWK.CRV) != null) { + keyWrapper.setCurve((String) jwk.getOtherClaims().get(OKPPublicJWK.CRV)); + } + + keyWrapper.setUse(KeyUse.SIG); + JWKParser parser = JWKParser.create(jwk); + keyWrapper.setPublicKey(parser.toPublicKey()); + return keyWrapper; + } + @Override public void close() { // no-op } -} \ No newline at end of file +} diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/signing/VCSigningServiceProviderFactory.java b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/signing/VCSigningServiceProviderFactory.java index 73b9d5312c9b..12f2a9642e6f 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/signing/VCSigningServiceProviderFactory.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/signing/VCSigningServiceProviderFactory.java @@ -44,13 +44,15 @@ public interface VCSigningServiceProviderFactory extends ComponentFactory extends Provider { * Takes a verifiable credential and signs it according to the implementation. * Depending on the type of the SigningService, it will return a signed representation of the credential * - * @param verifiableCredential the credential to sign + * @param vcIssuanceContext verifiable credential to sign and context info * @return a signed representation */ - T signCredential(VerifiableCredential verifiableCredential); -} \ No newline at end of file + T signCredential(VCIssuanceContext vcIssuanceContext) throws VCIssuerException; + + /** + * Returns the identifier of this service instance, can be either the format alone, + * or the combination between format and credential configuration id. + * @return + */ + String locator(); + + static String locator(Format format, String vcConfigId){ + return vcConfigId==null ? format.name() : format.name() + "/" + vcConfigId; + } +} diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/model/Proof.java b/services/src/main/java/org/keycloak/protocol/oid4vc/model/Proof.java index 984432e45cd5..65ab182fa6a8 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/model/Proof.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/model/Proof.java @@ -32,7 +32,14 @@ public class Proof { @JsonProperty("proof_type") private ProofType proofType; - private Object proofObject; + @JsonProperty("jwt") + private String jwt; + + @JsonProperty("cwt") + private String cwt; + + @JsonProperty("ldp_vp") + private Object ldpVp; public ProofType getProofType() { return proofType; @@ -43,12 +50,30 @@ public Proof setProofType(ProofType proofType) { return this; } - public Object getProofObject() { - return proofObject; + public String getJwt() { + return jwt; + } + + public Proof setJwt(String jwt) { + this.jwt = jwt; + return this; + } + + public String getCwt() { + return cwt; + } + + public Proof setCwt(String cwt) { + this.cwt = cwt; + return this; + } + + public Object getLdpVp() { + return ldpVp; } - public Proof setProofObject(Object proofObject) { - this.proofObject = proofObject; + public Proof setLdpVp(Object ldpVp) { + this.ldpVp = ldpVp; return this; } -} \ No newline at end of file +} diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/JwtSigningServiceTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/JwtSigningServiceTest.java index efa6dce176a9..8d32771db7ce 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/JwtSigningServiceTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/JwtSigningServiceTest.java @@ -31,6 +31,7 @@ import org.keycloak.crypto.ServerECDSASignatureVerifierContext; import org.keycloak.crypto.SignatureVerifierContext; import org.keycloak.models.KeycloakSession; +import org.keycloak.protocol.oid4vc.issuance.VCIssuanceContext; import org.keycloak.protocol.oid4vc.issuance.signing.JwtSigningService; import org.keycloak.protocol.oid4vc.issuance.signing.SigningServiceException; import org.keycloak.protocol.oid4vc.model.CredentialSubject; @@ -154,7 +155,7 @@ public static void testSignJwtCredential(KeycloakSession session, String algorit VerifiableCredential testCredential = getTestCredential(claims); - String jwtCredential = jwtSigningService.signCredential(testCredential); + String jwtCredential = jwtSigningService.signCredential(new VCIssuanceContext().setVerifiableCredential(testCredential)); SignatureVerifierContext verifierContext = null; switch (algorithm) { @@ -213,7 +214,7 @@ public static void testSignJwtCredential(KeycloakSession session, String algorit } } - + @Override public void configureTestRealm(RealmRepresentation testRealm) { if (testRealm.getComponents() != null) { @@ -223,4 +224,4 @@ public void configureTestRealm(RealmRepresentation testRealm) { Map.of("org.keycloak.keys.KeyProvider", List.of(getRsaKeyProvider(rsaKey))))); } } -} \ No newline at end of file +} diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/LDSigningServiceTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/LDSigningServiceTest.java index cb2e8d2fbd2d..750cd12c64f4 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/LDSigningServiceTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/LDSigningServiceTest.java @@ -24,6 +24,7 @@ import org.keycloak.common.util.MultivaluedHashMap; import org.keycloak.crypto.KeyWrapper; import org.keycloak.models.KeycloakSession; +import org.keycloak.protocol.oid4vc.issuance.VCIssuanceContext; import org.keycloak.protocol.oid4vc.issuance.signing.LDSigningService; import org.keycloak.protocol.oid4vc.issuance.signing.SigningServiceException; import org.keycloak.protocol.oid4vc.model.CredentialSubject; @@ -156,7 +157,7 @@ public static void testSignLdCredential(KeycloakSession session, Map( @@ -218,6 +221,15 @@ protected ComponentExportRepresentation getEdDSAKeyProvider() { return componentExportRepresentation; } + protected ComponentExportRepresentation getEcKeyProvider() { + ComponentExportRepresentation componentExportRepresentation = new ComponentExportRepresentation(); + componentExportRepresentation.setName("ecdsa-issuer-key"); + componentExportRepresentation.setId(UUID.randomUUID().toString()); + componentExportRepresentation.setProviderId("ecdsa-generated"); + componentExportRepresentation.setConfig(new MultivaluedHashMap<>(Map.of("ecdsaEllipticCurveKey", List.of("P-256")))); + return componentExportRepresentation; + } + public static ProtocolMapperRepresentation getRoleMapper(String clientId) { ProtocolMapperRepresentation protocolMapperRepresentation = new ProtocolMapperRepresentation(); protocolMapperRepresentation.setName("role-mapper"); diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/SdJwtSigningServiceTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/SdJwtSigningServiceTest.java index c1e7abed656d..8957801b8e46 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/SdJwtSigningServiceTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/SdJwtSigningServiceTest.java @@ -30,9 +30,11 @@ import org.keycloak.crypto.SignatureVerifierContext; import org.keycloak.jose.jws.crypto.HashUtils; import org.keycloak.models.KeycloakSession; +import org.keycloak.protocol.oid4vc.issuance.VCIssuanceContext; import org.keycloak.protocol.oid4vc.issuance.signing.JwtSigningService; import org.keycloak.protocol.oid4vc.issuance.signing.SdJwtSigningService; import org.keycloak.protocol.oid4vc.issuance.signing.SigningServiceException; +import org.keycloak.protocol.oid4vc.model.SupportedCredentialConfiguration; import org.keycloak.protocol.oid4vc.model.VerifiableCredential; import org.keycloak.representations.JsonWebToken; import org.keycloak.representations.idm.RealmRepresentation; @@ -75,7 +77,7 @@ public void testUnsupportedAlgorithm() throws Throwable { 0, List.of(), new StaticTimeProvider(1000), - Optional.empty())); + Optional.empty(), null)); } catch (RunOnServerException ros) { throw ros.getCause(); } @@ -194,11 +196,13 @@ public static void testSignSDJwtCredential(KeycloakSession session, Optional Date: Sun, 23 Jun 2024 16:30:25 +0100 Subject: [PATCH 04/19] Replaced code param with pre-authorized_code as stated by spec Signed-off-by: Francis Pouatcha --- .../protocol/oidc/grants/PreAuthorizedCodeGrantType.java | 8 +++++--- .../oidc/grants/PreAuthorizedCodeGrantTypeFactory.java | 1 + 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/services/src/main/java/org/keycloak/protocol/oidc/grants/PreAuthorizedCodeGrantType.java b/services/src/main/java/org/keycloak/protocol/oidc/grants/PreAuthorizedCodeGrantType.java index 60534a08f1a5..2a602ad154dc 100644 --- a/services/src/main/java/org/keycloak/protocol/oidc/grants/PreAuthorizedCodeGrantType.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/grants/PreAuthorizedCodeGrantType.java @@ -50,10 +50,12 @@ public Response process(Context context) { LOGGER.debug("Process grant request for preauthorized."); setContext(context); - String code = formParams.getFirst(OAuth2Constants.CODE); + // See: https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#name-token-request + String code = formParams.getFirst(PreAuthorizedCodeGrantTypeFactory.CODE_REQUEST_PARAM); if (code == null) { - String errorMessage = "Missing parameter: " + OAuth2Constants.CODE; + // See: https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#name-token-request + String errorMessage = "Missing parameter: " + PreAuthorizedCodeGrantTypeFactory.CODE_REQUEST_PARAM; event.detail(Details.REASON, errorMessage); event.error(Errors.INVALID_CODE); throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_REQUEST, @@ -119,4 +121,4 @@ public static String getPreAuthorizedCode(KeycloakSession session, Authenticated authenticatedClientSession.getUserSession().getId()); return OAuth2CodeParser.persistCode(session, authenticatedClientSession, oAuth2Code); } -} \ No newline at end of file +} diff --git a/services/src/main/java/org/keycloak/protocol/oidc/grants/PreAuthorizedCodeGrantTypeFactory.java b/services/src/main/java/org/keycloak/protocol/oidc/grants/PreAuthorizedCodeGrantTypeFactory.java index 72425fa8f0c5..be13b00bd43b 100644 --- a/services/src/main/java/org/keycloak/protocol/oidc/grants/PreAuthorizedCodeGrantTypeFactory.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/grants/PreAuthorizedCodeGrantTypeFactory.java @@ -31,6 +31,7 @@ public class PreAuthorizedCodeGrantTypeFactory implements OAuth2GrantTypeFactory, EnvironmentDependentProviderFactory { public static final String GRANT_TYPE = "urn:ietf:params:oauth:grant-type:pre-authorized_code"; + public static final String CODE_REQUEST_PARAM = "pre-authorized_code"; @Override public OAuth2GrantType create(KeycloakSession session) { From aa63e2cdeb3862a51d08d3b58cd7d3fc9053467d Mon Sep 17 00:00:00 2001 From: Francis Pouatcha Date: Sun, 23 Jun 2024 16:53:20 +0100 Subject: [PATCH 05/19] Removed mandatory presence of nbf claim as it is optional in the spec Signed-off-by: Francis Pouatcha --- .../oid4vc/issuance/signing/SdJwtSigningService.java | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/signing/SdJwtSigningService.java b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/signing/SdJwtSigningService.java index 825eea195c09..fa708bc8b9a0 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/signing/SdJwtSigningService.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/signing/SdJwtSigningService.java @@ -152,12 +152,8 @@ public String signCredential(VCIssuanceContext vcIssuanceContext) throws VCIssue ObjectNode rootNode = claimSet.withObject(""); rootNode.put(ISSUER_CLAIM, issuerDid); - // Get the issuance date from the credential. Since nbf is mandatory, we set it to the current time if not - // provided - long iat = Optional.ofNullable(verifiableCredential.getIssuanceDate()) - .map(issuanceDate -> issuanceDate.toInstant().getEpochSecond()) - .orElse((long) timeProvider.currentTimeSeconds()); - rootNode.put(NOT_BEFORE_CLAIM, iat); + // nbf, iat and exp are all optional. So need to be set by a protocol mapper if needed + // see: https://www.ietf.org/archive/id/draft-ietf-oauth-sd-jwt-vc-03.html#name-registered-jwt-claims if (verifiableCredential.getType() == null || verifiableCredential.getType().size() != 1) { throw new SigningServiceException("SD-JWT only supports single type credentials."); } From 803ac111cdb631b10703a97e76afcfcd58ee9419 Mon Sep 17 00:00:00 2001 From: Francis Pouatcha Date: Sun, 23 Jun 2024 16:58:31 +0100 Subject: [PATCH 06/19] Serializing a proof type enum Signed-off-by: Francis Pouatcha --- .../keycloak/protocol/oid4vc/model/Proof.java | 4 +++ .../protocol/oid4vc/model/ProofType.java | 2 +- .../oid4vc/model/ProofTypeDeserializer.java | 34 +++++++++++++++++++ .../oid4vc/model/ProofTypeSerializer.java | 18 ++++++++++ .../oid4vc/model/ProofSerializationTest.java | 16 +++++++++ 5 files changed, 73 insertions(+), 1 deletion(-) create mode 100644 services/src/main/java/org/keycloak/protocol/oid4vc/model/ProofTypeDeserializer.java create mode 100644 services/src/main/java/org/keycloak/protocol/oid4vc/model/ProofTypeSerializer.java create mode 100644 services/src/test/java/org/keycloak/protocol/oid4vc/model/ProofSerializationTest.java diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/model/Proof.java b/services/src/main/java/org/keycloak/protocol/oid4vc/model/Proof.java index 65ab182fa6a8..8d7b29975fdf 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/model/Proof.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/model/Proof.java @@ -19,6 +19,8 @@ import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; /** * Proof to be used in the Credential Request(to allow holder binding) according to OID4VCI @@ -29,6 +31,8 @@ @JsonInclude(JsonInclude.Include.NON_NULL) public class Proof { + @JsonSerialize(using = ProofTypeSerializer.class) + @JsonDeserialize(using = ProofTypeDeserializer.class) @JsonProperty("proof_type") private ProofType proofType; diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/model/ProofType.java b/services/src/main/java/org/keycloak/protocol/oid4vc/model/ProofType.java index 5306744a15c9..d00bfef6d34b 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/model/ProofType.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/model/ProofType.java @@ -38,4 +38,4 @@ public enum ProofType { public String getValue() { return value; } -} \ No newline at end of file +} diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/model/ProofTypeDeserializer.java b/services/src/main/java/org/keycloak/protocol/oid4vc/model/ProofTypeDeserializer.java new file mode 100644 index 000000000000..6e178e807a8e --- /dev/null +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/model/ProofTypeDeserializer.java @@ -0,0 +1,34 @@ +package org.keycloak.protocol.oid4vc.model; + +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.deser.std.StdDeserializer; +import com.fasterxml.jackson.databind.exc.InvalidFormatException; + +import java.io.IOException; +import java.util.Map; + +public class ProofTypeDeserializer extends StdDeserializer { + + private static final Map PROOF_TYPE_MAP = Map.of( + "jwt", ProofType.JWT, + "cwt", ProofType.CWT, + "ldp_vp", ProofType.LD_PROOF + ); + + protected ProofTypeDeserializer() { + super(ProofType.class); + } + + @Override + public ProofType deserialize(JsonParser p, DeserializationContext ctxt) throws IOException { + String value = p.getValueAsString(); + + ProofType proofType = PROOF_TYPE_MAP.get(value); + if (proofType != null) { + return proofType; + } + + throw new InvalidFormatException(p, "Invalid ProofType value: " + value, value, ProofType.class); + } +} diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/model/ProofTypeSerializer.java b/services/src/main/java/org/keycloak/protocol/oid4vc/model/ProofTypeSerializer.java new file mode 100644 index 000000000000..c23086205d93 --- /dev/null +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/model/ProofTypeSerializer.java @@ -0,0 +1,18 @@ +package org.keycloak.protocol.oid4vc.model; + +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.databind.SerializerProvider; +import com.fasterxml.jackson.databind.ser.std.StdSerializer; + +import java.io.IOException; + +public class ProofTypeSerializer extends StdSerializer { + protected ProofTypeSerializer() { + super(ProofType.class); + } + + @Override + public void serialize(ProofType value, JsonGenerator gen, SerializerProvider provider) throws IOException { + gen.writeString(value.getValue()); // Serialize as the value + } +} diff --git a/services/src/test/java/org/keycloak/protocol/oid4vc/model/ProofSerializationTest.java b/services/src/test/java/org/keycloak/protocol/oid4vc/model/ProofSerializationTest.java new file mode 100644 index 000000000000..ccb98dbaa056 --- /dev/null +++ b/services/src/test/java/org/keycloak/protocol/oid4vc/model/ProofSerializationTest.java @@ -0,0 +1,16 @@ +package org.keycloak.protocol.oid4vc.model; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.Test; +import static org.junit.Assert.*; + +public class ProofSerializationTest { + @Test + public void testSerializeProof() throws JsonProcessingException { + ObjectMapper objectMapper = new ObjectMapper(); + String proofStr = " { \"proof_type\": \"jwt\", \"jwt\": \"ewogICJhbGciOiAiRVMyNTYiLAogICJ0eXAiOiAib3BlbmlkNHZjaS1wcm9vZitqd3QiLAogICJqd2siOiB7CiAgICAia3R5IjogIkVDIiwKICAgICJjcnYiOiAiUC0yNTYiLAogICAgIngiOiAiWEdkNU9GU1pwc080VkRRTUZrR3Z0TDVHU2FXWWE3SzBrNGhxUUdLbFBjWSIsCiAgICAieSI6ICJiSXFDaGhoVDdfdnYtYmhuRmVuREljVzVSUjRKTS1nME5sUi1qZGlHemNFIgogIH0KfQo.ewogICJpc3MiOiAib2lkNHZjaS1jbGllbnQiLAogICJhdWQiOiAiaHR0cDovL2xvY2FsaG9zdDo4MDgwL3JlYWxtcy9tYXN0ZXIiLAogICJpYXQiOiAxNzE4OTU5MzY5LAogICJub25jZSI6ICJOODAxTEpVam1qQ1FDMUpzTm5lTllXWFpqZHQ2UEZSd01pNkpoTTU1OF9JIgp9Cg.mKKrkRkG1BfOzgsKwcZhop74EHflzHslO_NFOloKPnZ-ms6t0SnsTNDQjM_o4FBQAgtv_fnFEWRgnkNIa34gvQ\" } "; + Proof proof = objectMapper.readValue(proofStr, Proof.class); + assertEquals(ProofType.JWT, proof.getProofType()); + } +} From 2b6f4296dd176364da724f11d3778e4a19b5d7a8 Mon Sep 17 00:00:00 2001 From: Francis Pouatcha Date: Mon, 24 Jun 2024 07:54:37 +0100 Subject: [PATCH 07/19] Fix: verifier wallet provided proof Signed-off-by: Francis Pouatcha --- .../issuance/signing/SdJwtSigningService.java | 51 ++++++++++++------- 1 file changed, 34 insertions(+), 17 deletions(-) diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/signing/SdJwtSigningService.java b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/signing/SdJwtSigningService.java index fa708bc8b9a0..4b417a0a7f0a 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/signing/SdJwtSigningService.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/signing/SdJwtSigningService.java @@ -25,6 +25,7 @@ import org.keycloak.crypto.KeyWrapper; import org.keycloak.crypto.SignatureProvider; import org.keycloak.crypto.SignatureSignerContext; +import org.keycloak.crypto.SignatureVerifierContext; import org.keycloak.jose.jwk.JWK; import org.keycloak.jose.jws.JWSHeader; import org.keycloak.jose.jws.JWSInput; @@ -46,6 +47,7 @@ import org.keycloak.util.JsonSerialization; import java.io.IOException; +import java.util.Arrays; import java.util.List; import java.util.Map; import java.util.Objects; @@ -218,7 +220,11 @@ private JWK validateProof(VCIssuanceContext vcIssuanceContext) throws VCIssuerEx AccessToken proofPayload = JsonSerialization.readValue(jwsInput.getContent(), AccessToken.class); validateProofPayload(vcIssuanceContext, proofPayload); - if (!getVerifier(jwk, jwsHeader.getAlgorithm().name()).verify(jwsInput.getContent(), jwsInput.getSignature())) { + SignatureVerifierContext signatureVerifierContext = getVerifier(jwk, jwsHeader.getAlgorithm().name()); + if(signatureVerifierContext==null){ + throw new VCIssuerException("No verifier configured for " +jwsHeader.getAlgorithm()); + } + if (!signatureVerifierContext.verify(jwsInput.getEncodedSignatureInput().getBytes("UTF-8"), jwsInput.getSignature())) { throw new VCIssuerException("Could not verify provided proof"); } @@ -227,7 +233,7 @@ private JWK validateProof(VCIssuanceContext vcIssuanceContext) throws VCIssuerEx private void checkCryptographicKeyBinding(VCIssuanceContext vcIssuanceContext){ // Make sure we are dealing with a jwk proof. - if (vcIssuanceContext.getCredentialConfig().getCryptographicBindingMethodsSupported() != null || + if (vcIssuanceContext.getCredentialConfig().getCryptographicBindingMethodsSupported() == null || !vcIssuanceContext.getCredentialConfig().getCryptographicBindingMethodsSupported().contains("jwk")) { throw new IllegalStateException("This SD-JWT implementation only supports jwk as cryptographic binding method"); } @@ -294,16 +300,20 @@ private void validateJwsHeader(VCIssuanceContext vcIssuanceContext, JWSHeader jw private void validateProofPayload(VCIssuanceContext vcIssuanceContext, AccessToken proofPayload) throws VCIssuerException { // azp is the id of the client, as mentioned in the access token used to request the credential. - String azp = vcIssuanceContext.getAuthResult().getToken().getIssuedFor(); - Optional.ofNullable(proofPayload.getIssuer()) - .filter(proofIssuer -> Objects.equals(azp, proofIssuer)) - .orElseThrow(() -> new VCIssuerException("Issuer claim must be null for preauthorized code else the clientId of the client making the request: " + azp)); + // Token provided from user is obtained with a clientId that support the oidc login protocol. + // oid4vci client doesn't. But it is the client needed at the credential endpoint. +// String azp = vcIssuanceContext.getAuthResult().getToken().getIssuedFor(); +// Optional.ofNullable(proofPayload.getIssuer()) +// .filter(proofIssuer -> Objects.equals(azp, proofIssuer)) +// .orElseThrow(() -> new VCIssuerException("Issuer claim must be null for preauthorized code else the clientId of the client making the request: " + azp)); // The issuer is the token / credential is the audience of the proof String credentialIssuer = vcIssuanceContext.getVerifiableCredential().getIssuer().toString(); - Optional.ofNullable(proofPayload.getAudience()) - .filter(audience -> Objects.equals(credentialIssuer, audience)) - .orElseThrow(() -> new VCIssuerException("Proof not produced for this audience. Audience claim must be: " + credentialIssuer)); + Optional.ofNullable(proofPayload.getAudience()) // Ensure null-safety with Optional + .map(Arrays::asList) // Convert to List + .filter(audiences -> audiences.contains(credentialIssuer)) // Check if the issuer is in the audience list + .orElseThrow(() -> new VCIssuerException( + "Proof not produced for this audience. Audience claim must be: " + credentialIssuer + " but are " + Arrays.asList(proofPayload.getAudience()))); // Validate mandatory iat. // I do not understand the rationale behind requiring a issue time if we are not checking expiration. @@ -311,14 +321,21 @@ private void validateProofPayload(VCIssuanceContext vcIssuanceContext, AccessTok .orElseThrow(() -> new VCIssuerException("Missing proof issuing time. iat claim must be provided.")); // Check cNonce matches. - // We really dislike having to produce and manage a new nonce. As in this case - // token and credential issuer match, we will consider the nonce in the access token - // the c_nonce. We will also consider the expiration time of the access token the - // expiration time of the c_nonce. This way nonce is automatically validated with token expiry. - String cNonce = vcIssuanceContext.getAuthResult().getToken().getNonce(); - Optional.ofNullable(proofPayload.getNonce()) - .filter(nonce -> Objects.equals(cNonce, nonce)) - .orElseThrow(() -> new VCIssuerException("Missing or wrong nonce value. Please provide nonce returned by the issuer if any.")); + // If the token endpoint provides a c_nonce, we would like this: + // - stored in the access token + // - having the same validity as the access token. + Optional.ofNullable(vcIssuanceContext.getAuthResult().getToken().getNonce()) + .ifPresent( + cNonce -> { + Optional.ofNullable(proofPayload.getNonce()) + .filter(nonce -> Objects.equals(cNonce, nonce)) + .orElseThrow(() -> new VCIssuerException("Missing or wrong nonce value. Please provide nonce returned by the issuer if any.")); + + // We expect the expiration to be identical to the token expiration. We assume token expiration has been checked by AuthManager, + // So no_op + } + ); + } } From b5e52b39cbf592d16ff1cdc0b07437b9fd806bb4 Mon Sep 17 00:00:00 2001 From: Francis Pouatcha Date: Mon, 24 Jun 2024 11:48:53 +0100 Subject: [PATCH 08/19] Fix: removed dupplicate instantiation of service Signed-off-by: Francis Pouatcha --- .../keycloak/protocol/oid4vc/OID4VCLoginProtocolFactory.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 1b28914360fc..2bcd063175a7 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/OID4VCLoginProtocolFactory.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/OID4VCLoginProtocolFactory.java @@ -105,7 +105,7 @@ private void addServiceFromComponent(Map Date: Tue, 25 Jun 2024 07:55:05 +0100 Subject: [PATCH 09/19] Fix(test): set code request param grant ty urn:ietf:params:oauth:grant-type:pre-authorized_code to pre-authorized_code instead of code as per spec. Signed-off-by: Francis Pouatcha --- .../org/keycloak/testsuite/oauth/PreAuthorizedGrantTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/PreAuthorizedGrantTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/PreAuthorizedGrantTest.java index 7ae67c7f96d5..a67b56f3cd20 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/PreAuthorizedGrantTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/PreAuthorizedGrantTest.java @@ -98,7 +98,7 @@ private OAuthClient.AccessTokenResponse postCode(String preAuthorizedCode) throw HttpPost post = new HttpPost(getTokenEndpoint()); List parameters = new LinkedList<>(); parameters.add(new BasicNameValuePair(OAuth2Constants.GRANT_TYPE, PreAuthorizedCodeGrantTypeFactory.GRANT_TYPE)); - parameters.add(new BasicNameValuePair("code", preAuthorizedCode)); + parameters.add(new BasicNameValuePair("pre-authorized_code", preAuthorizedCode)); UrlEncodedFormEntity formEntity = new UrlEncodedFormEntity(parameters, "UTF-8"); post.setEntity(formEntity); From af6abfd0c201d300ec5abd7dc15b3006ce19f114 Mon Sep 17 00:00:00 2001 From: Francis Pouatcha Date: Tue, 25 Jun 2024 09:46:49 +0100 Subject: [PATCH 10/19] Fix(test): set code request param grant ty urn:ietf:params:oauth:grant-type:pre-authorized_code to pre-authorized_code instead of code as per spec. Signed-off-by: Francis Pouatcha --- .../oid4vc/issuance/signing/OID4VCIssuerEndpointTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCIssuerEndpointTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCIssuerEndpointTest.java index 123ecffebd78..83b3288e08cd 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCIssuerEndpointTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCIssuerEndpointTest.java @@ -432,7 +432,7 @@ public void testCredentialIssuance() throws Exception { HttpPost postPreAuthorizedCode = new HttpPost(openidConfig.getTokenEndpoint()); List parameters = new LinkedList<>(); parameters.add(new BasicNameValuePair(OAuth2Constants.GRANT_TYPE, PreAuthorizedCodeGrantTypeFactory.GRANT_TYPE)); - parameters.add(new BasicNameValuePair("code", credentialsOffer.getGrants().getPreAuthorizedCode().getPreAuthorizedCode())); + parameters.add(new BasicNameValuePair("pre-authorized_code", credentialsOffer.getGrants().getPreAuthorizedCode().getPreAuthorizedCode())); UrlEncodedFormEntity formEntity = new UrlEncodedFormEntity(parameters, "UTF-8"); postPreAuthorizedCode.setEntity(formEntity); OAuthClient.AccessTokenResponse accessTokenResponse = new OAuthClient.AccessTokenResponse(httpClient.execute(postPreAuthorizedCode)); From 80dd9c7ea315e9f7cfa334faacc0e99c20b258f1 Mon Sep 17 00:00:00 2001 From: Francis Pouatcha Date: Tue, 25 Jun 2024 09:50:32 +0100 Subject: [PATCH 11/19] Fix(test): remove check of nbf from tests, as nbf claim not mandatory as per spec. Signed-off-by: Francis Pouatcha --- .../oid4vc/issuance/signing/SdJwtSigningServiceTest.java | 2 -- 1 file changed, 2 deletions(-) diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/SdJwtSigningServiceTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/SdJwtSigningServiceTest.java index 8957801b8e46..515b93fee318 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/SdJwtSigningServiceTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/SdJwtSigningServiceTest.java @@ -247,8 +247,6 @@ public static void testSignSDJwtCredential(KeycloakSession session, Optional sds = (List) theToken.getOtherClaims().get("_sd"); if (sds != null && !sds.isEmpty()){ assertEquals("The algorithm should be included", "sha-256", theToken.getOtherClaims().get("_sd_alg")); From b5e326df6080b9065ea1b1547367878b326cb46f Mon Sep 17 00:00:00 2001 From: Francis Pouatcha Date: Mon, 15 Jul 2024 19:54:08 +0100 Subject: [PATCH 12/19] Processing feedbacks from first review comments Signed-off-by: Francis Pouatcha --- .../signing/JwtProofBasedSigningService.java | 204 +++++ .../oid4vc/model/CredentialConfigId.java | 12 + .../oid4vc/model/CredentialDefinition.java | 130 +++ .../model/VerifiableCredentialType.java | 35 + .../signing/OID4VCJWTIssuerEndpointTest.java | 4 + .../OID4VCSdJwtIssuingEndpointTest.java | 814 ++++++++++++++++++ 6 files changed, 1199 insertions(+) create mode 100644 services/src/main/java/org/keycloak/protocol/oid4vc/issuance/signing/JwtProofBasedSigningService.java create mode 100644 services/src/main/java/org/keycloak/protocol/oid4vc/model/CredentialConfigId.java create mode 100644 services/src/main/java/org/keycloak/protocol/oid4vc/model/CredentialDefinition.java create mode 100644 services/src/main/java/org/keycloak/protocol/oid4vc/model/VerifiableCredentialType.java create mode 100644 testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCJWTIssuerEndpointTest.java create mode 100644 testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCSdJwtIssuingEndpointTest.java diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/signing/JwtProofBasedSigningService.java b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/signing/JwtProofBasedSigningService.java new file mode 100644 index 000000000000..fbd9767c44d1 --- /dev/null +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/signing/JwtProofBasedSigningService.java @@ -0,0 +1,204 @@ +/* + * 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.issuance.signing; + +import java.io.IOException; +import java.util.Arrays; +import java.util.Objects; +import java.util.Optional; + +import org.jboss.logging.Logger; +import org.keycloak.common.VerificationException; +import org.keycloak.crypto.SignatureVerifierContext; +import org.keycloak.jose.jwk.JWK; +import org.keycloak.jose.jws.JWSHeader; +import org.keycloak.jose.jws.JWSInput; +import org.keycloak.jose.jws.JWSInputException; +import org.keycloak.models.KeycloakSession; +import org.keycloak.protocol.oid4vc.issuance.VCIssuanceContext; +import org.keycloak.protocol.oid4vc.issuance.VCIssuerException; +import org.keycloak.protocol.oid4vc.model.Format; +import org.keycloak.protocol.oid4vc.model.Proof; +import org.keycloak.protocol.oid4vc.model.ProofType; +import org.keycloak.representations.AccessToken; +import org.keycloak.util.JsonSerialization; + +public abstract class JwtProofBasedSigningService extends SigningService { + + private static final Logger LOGGER = Logger.getLogger(JwtProofBasedSigningService.class); + private static final String CRYPTOGRAPHIC_BINDING_METHOD_JWK = "jwk"; + public static final String PROOF_JWT_TYP="openid4vci-proof+jwt"; + + protected JwtProofBasedSigningService(KeycloakSession keycloakSession, String keyId, Format format, String type) { + super(keycloakSession, keyId, format, type); + } + + /* + * Validates a proof provided by the client if any. + * + * Returns null if there is no need to include a key binding in the credential + * + * Return the JWK to be included as key binding in the JWK if the provided proof was correctly validated + * + * @param vcIssuanceContext + * @return + * @throws VCIssuerException + * @throws JWSInputException + * @throws VerificationException + * @throws IllegalStateException: is credential type badly configured + * @throws IOException + */ + protected JWK validateProof(VCIssuanceContext vcIssuanceContext) throws VCIssuerException, JWSInputException, VerificationException, IOException { + + Optional optionalProof = getProofFromContext(vcIssuanceContext); + + if (!optionalProof.isPresent()) { + return null; // No proof support + } + + // Check key binding config for jwt. Only type supported. + checkCryptographicKeyBinding(vcIssuanceContext); + + JWSInput jwsInput = getJwsInput(optionalProof.get()); + JWSHeader jwsHeader = jwsInput.getHeader(); + validateJwsHeader(vcIssuanceContext, jwsHeader); + + JWK jwk = Optional.ofNullable(jwsHeader.getKey()) + .orElseThrow(() -> new VCIssuerException("Missing binding key. Make sure provided JWT contains the jwk jwsHeader claim.")); + + // Parsing the Proof as an access token shall work, as a proof is a strict subset of an access token. + AccessToken proofPayload = JsonSerialization.readValue(jwsInput.getContent(), AccessToken.class); + validateProofPayload(vcIssuanceContext, proofPayload); + + SignatureVerifierContext signatureVerifierContext = getVerifier(jwk, jwsHeader.getAlgorithm().name()); + if(signatureVerifierContext==null){ + throw new VCIssuerException("No verifier configured for " +jwsHeader.getAlgorithm()); + } + if (!signatureVerifierContext.verify(jwsInput.getEncodedSignatureInput().getBytes("UTF-8"), jwsInput.getSignature())) { + throw new VCIssuerException("Could not verify provided proof"); + } + + return jwk; + } + + private void checkCryptographicKeyBinding(VCIssuanceContext vcIssuanceContext){ + // Make sure we are dealing with a jwk proof. + if (vcIssuanceContext.getCredentialConfig().getCryptographicBindingMethodsSupported() == null || + !vcIssuanceContext.getCredentialConfig().getCryptographicBindingMethodsSupported().contains(CRYPTOGRAPHIC_BINDING_METHOD_JWK)) { + throw new IllegalStateException("This SD-JWT implementation only supports jwk as cryptographic binding method"); + } + } + + private Optional getProofFromContext(VCIssuanceContext vcIssuanceContext) throws VCIssuerException { + return Optional.ofNullable(vcIssuanceContext.getCredentialConfig()) + .map(config -> config.getProofTypesSupported()) + .flatMap(proofTypesSupported -> { + if (proofTypesSupported == null) { + LOGGER.debugf("No proof support. Will skip proof validation."); + return Optional.empty(); + } + + Optional.ofNullable(proofTypesSupported.getJwt()) + .orElseThrow(() -> new VCIssuerException("SD-JWT supports only jwt proof type.")); + + Proof proof = Optional.ofNullable(vcIssuanceContext.getCredentialRequest().getProof()) + .orElseThrow(() -> new VCIssuerException("Credential configuration requires a proof of type: " + ProofType.JWT.getValue())); + + if (!Objects.equals(proof.getProofType(), ProofType.JWT)) { + throw new VCIssuerException("Wrong proof type"); + } + + return Optional.of(proof); + }); + } + + private JWSInput getJwsInput(Proof proof) throws JWSInputException { + return new JWSInput(proof.getJwt()); + } + + /** + * As we limit accepted algorithm to the ones listed by the issuer, we can omit checking for "none" + * The Algorithm enum class does not list the none value anyway. + * + * @param vcIssuanceContext + * @param jwsHeader + * @throws VCIssuerException + */ + private void validateJwsHeader(VCIssuanceContext vcIssuanceContext, JWSHeader jwsHeader) throws VCIssuerException { + Optional.ofNullable(jwsHeader.getAlgorithm()) + .orElseThrow(() -> new VCIssuerException("Missing jwsHeader claim alg")); + + // As we limit accepted algorithm to the ones listed by the server, we can omit checking for "none" + // The Algorithm enum class does not list the none value anyway. + Optional.ofNullable(vcIssuanceContext.getCredentialConfig()) + .map(config -> config.getProofTypesSupported()) + .map(proofTypesSupported -> proofTypesSupported.getJwt()) + .map(jwt -> jwt.getProofSigningAlgValuesSupported()) + .filter(supportedAlgs -> supportedAlgs.contains(jwsHeader.getAlgorithm().name())) + .orElseThrow(() -> new VCIssuerException("Proof signature algorithm not supported: " + jwsHeader.getAlgorithm().name())); + + Optional.ofNullable(jwsHeader.getType()) + .filter(type -> Objects.equals(PROOF_JWT_TYP, type)) + .orElseThrow(() -> new VCIssuerException("JWT type must be: " + PROOF_JWT_TYP)); + + // KeyId shall not be present alongside the jwk. + Optional.ofNullable(jwsHeader.getKeyId()) + .ifPresent(keyId -> { + throw new VCIssuerException("KeyId not expected in this JWT. Use the jwk claim instead."); + }); + } + + private void validateProofPayload(VCIssuanceContext vcIssuanceContext, AccessToken proofPayload) throws VCIssuerException { + // azp is the id of the client, as mentioned in the access token used to request the credential. + // Token provided from user is obtained with a clientId that support the oidc login protocol. + // oid4vci client doesn't. But it is the client needed at the credential endpoint. + // String azp = vcIssuanceContext.getAuthResult().getToken().getIssuedFor(); + // Optional.ofNullable(proofPayload.getIssuer()) + // .filter(proofIssuer -> Objects.equals(azp, proofIssuer)) + // .orElseThrow(() -> new VCIssuerException("Issuer claim must be null for preauthorized code else the clientId of the client making the request: " + azp)); + + // The issuer is the token / credential is the audience of the proof + String credentialIssuer = vcIssuanceContext.getVerifiableCredential().getIssuer().toString(); + Optional.ofNullable(proofPayload.getAudience()) // Ensure null-safety with Optional + .map(Arrays::asList) // Convert to List + .filter(audiences -> audiences.contains(credentialIssuer)) // Check if the issuer is in the audience list + .orElseThrow(() -> new VCIssuerException( + "Proof not produced for this audience. Audience claim must be: " + credentialIssuer + " but are " + Arrays.asList(proofPayload.getAudience()))); + + // Validate mandatory iat. + // I do not understand the rationale behind requiring a issue time if we are not checking expiration. + Optional.ofNullable(proofPayload.getIat()) + .orElseThrow(() -> new VCIssuerException("Missing proof issuing time. iat claim must be provided.")); + + // Check cNonce matches. + // If the token endpoint provides a c_nonce, we would like this: + // - stored in the access token + // - having the same validity as the access token. + Optional.ofNullable(vcIssuanceContext.getAuthResult().getToken().getNonce()) + .ifPresent( + cNonce -> { + Optional.ofNullable(proofPayload.getNonce()) + .filter(nonce -> Objects.equals(cNonce, nonce)) + .orElseThrow(() -> new VCIssuerException("Missing or wrong nonce value. Please provide nonce returned by the issuer if any.")); + + // We expect the expiration to be identical to the token expiration. We assume token expiration has been checked by AuthManager, + // So no_op + } + ); + + } +} diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/model/CredentialConfigId.java b/services/src/main/java/org/keycloak/protocol/oid4vc/model/CredentialConfigId.java new file mode 100644 index 000000000000..68a067121c54 --- /dev/null +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/model/CredentialConfigId.java @@ -0,0 +1,12 @@ +package org.keycloak.protocol.oid4vc.model; + +public class CredentialConfigId { + private String value; + public CredentialConfigId(String value) { + this.value = value; + } + + public String getValue() { + return value; + } +} diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/model/CredentialDefinition.java b/services/src/main/java/org/keycloak/protocol/oid4vc/model/CredentialDefinition.java new file mode 100644 index 000000000000..12c4d9e797a7 --- /dev/null +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/model/CredentialDefinition.java @@ -0,0 +1,130 @@ +/* + * 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 com.fasterxml.jackson.annotation.JsonAnyGetter; +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 java.net.URI; +import java.util.ArrayList; +import java.util.Date; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * Pojo to represent a VerifiableCredential for internal handling + * + * @author Stefan Wiedemann + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +public class CredentialDefinition { + + @JsonProperty("@context") + private List context; + private List type = new ArrayList<>(); + private URI issuer; + private Date issuanceDate; + private URI id; + private Date expirationDate; + private CredentialSubject credentialSubject = new CredentialSubject(); + @JsonIgnore + private Map additionalProperties = new HashMap<>(); + + @JsonAnyGetter + public Map getAdditionalProperties() { + return additionalProperties; + } + + @JsonAnySetter + public CredentialDefinition setAdditionalProperties(String name, Object property) { + additionalProperties.put(name, property); + return this; + } + + public List getContext() { + return context; + } + + public CredentialDefinition setContext(List context) { + this.context = context; + return this; + } + + public List getType() { + return type; + } + + public CredentialDefinition setType(List type) { + this.type = type; + return this; + } + + public URI getIssuer() { + return issuer; + } + + public CredentialDefinition setIssuer(URI issuer) { + this.issuer = issuer; + return this; + } + + public Date getIssuanceDate() { + return issuanceDate; + } + + public CredentialDefinition setIssuanceDate(Date issuanceDate) { + this.issuanceDate = issuanceDate; + return this; + } + + public URI getId() { + return id; + } + + public CredentialDefinition setId(URI id) { + this.id = id; + return this; + } + + public Date getExpirationDate() { + return expirationDate; + } + + public CredentialDefinition setExpirationDate(Date expirationDate) { + this.expirationDate = expirationDate; + return this; + } + + public CredentialSubject getCredentialSubject() { + return credentialSubject; + } + + public CredentialDefinition setCredentialSubject(CredentialSubject credentialSubject) { + this.credentialSubject = credentialSubject; + return this; + } + + public CredentialDefinition setAdditionalProperties(Map additionalProperties) { + this.additionalProperties = additionalProperties; + return this; + } +} diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/model/VerifiableCredentialType.java b/services/src/main/java/org/keycloak/protocol/oid4vc/model/VerifiableCredentialType.java new file mode 100644 index 000000000000..9e9c3d910c8b --- /dev/null +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/model/VerifiableCredentialType.java @@ -0,0 +1,35 @@ +/* + * 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; + +/** + * @author Francis Pouatcha + */ +public class VerifiableCredentialType { + private String value; + + public static VerifiableCredentialType from(String value){ + return new VerifiableCredentialType(value); + } + public VerifiableCredentialType(String value) { + this.value = value; + } + + public String getValue() { + return value; + } +} diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCJWTIssuerEndpointTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCJWTIssuerEndpointTest.java new file mode 100644 index 000000000000..212e36c62278 --- /dev/null +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCJWTIssuerEndpointTest.java @@ -0,0 +1,4 @@ +package org.keycloak.testsuite.oid4vc.issuance.signing; + +public class OID4VCJWTIssuerEndpointTest { +} diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCSdJwtIssuingEndpointTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCSdJwtIssuingEndpointTest.java new file mode 100644 index 000000000000..f76ffa52c0b8 --- /dev/null +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCSdJwtIssuingEndpointTest.java @@ -0,0 +1,814 @@ +/* + * 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.testsuite.oid4vc.issuance.signing; + +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.ws.rs.BadRequestException; +import jakarta.ws.rs.client.Client; +import jakarta.ws.rs.client.Entity; +import jakarta.ws.rs.client.WebTarget; +import jakarta.ws.rs.core.HttpHeaders; +import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.core.UriBuilder; +import org.apache.commons.io.IOUtils; +import org.apache.http.HttpStatus; +import org.apache.http.NameValuePair; +import org.apache.http.client.entity.UrlEncodedFormEntity; +import org.apache.http.client.methods.CloseableHttpResponse; +import org.apache.http.client.methods.HttpGet; +import org.apache.http.client.methods.HttpPost; +import org.apache.http.entity.ContentType; +import org.apache.http.entity.StringEntity; +import org.apache.http.impl.client.CloseableHttpClient; +import org.apache.http.impl.client.HttpClientBuilder; +import org.apache.http.message.BasicNameValuePair; +import org.junit.Before; +import org.junit.Test; +import org.keycloak.OAuth2Constants; +import org.keycloak.TokenVerifier; +import org.keycloak.admin.client.resource.ClientResource; +import org.keycloak.admin.client.resource.RealmResource; +import org.keycloak.admin.client.resource.UserResource; +import org.keycloak.common.VerificationException; +import org.keycloak.common.crypto.CryptoIntegration; +import org.keycloak.common.util.MultivaluedHashMap; +import org.keycloak.common.util.SecretGenerator; +import org.keycloak.crypto.Algorithm; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.UserSessionModel; +import org.keycloak.protocol.oid4vc.issuance.OID4VCIssuerEndpoint; +import org.keycloak.protocol.oid4vc.issuance.OID4VCIssuerWellKnownProviderFactory; +import org.keycloak.protocol.oid4vc.issuance.TimeProvider; +import org.keycloak.protocol.oid4vc.issuance.signing.JwtSigningService; +import org.keycloak.protocol.oid4vc.issuance.signing.SdJwtSigningService; +import org.keycloak.protocol.oid4vc.model.CredentialConfigId; +import org.keycloak.protocol.oid4vc.model.CredentialIssuer; +import org.keycloak.protocol.oid4vc.model.CredentialOfferURI; +import org.keycloak.protocol.oid4vc.model.CredentialRequest; +import org.keycloak.protocol.oid4vc.model.CredentialResponse; +import org.keycloak.protocol.oid4vc.model.CredentialsOffer; +import org.keycloak.protocol.oid4vc.model.Format; +import org.keycloak.protocol.oid4vc.model.OfferUriType; +import org.keycloak.protocol.oid4vc.model.PreAuthorizedGrant; +import org.keycloak.protocol.oid4vc.model.SupportedCredentialConfiguration; +import org.keycloak.protocol.oid4vc.model.VerifiableCredential; +import org.keycloak.protocol.oid4vc.model.VerifiableCredentialType; +import org.keycloak.protocol.oidc.OIDCLoginProtocol; +import org.keycloak.protocol.oidc.grants.PreAuthorizedCodeGrantTypeFactory; +import org.keycloak.protocol.oidc.representations.OIDCConfigurationRepresentation; +import org.keycloak.representations.JsonWebToken; +import org.keycloak.representations.idm.ClientRepresentation; +import org.keycloak.representations.idm.ClientScopeRepresentation; +import org.keycloak.representations.idm.RealmRepresentation; +import org.keycloak.services.managers.AppAuthManager; +import org.keycloak.services.managers.AuthenticationManager; +import org.keycloak.services.resources.RealmsResource; +import org.keycloak.testsuite.Assert; +import org.keycloak.testsuite.admin.ApiUtil; +import org.keycloak.testsuite.runonserver.RunOnServerException; +import org.keycloak.testsuite.util.AdminClientUtil; +import org.keycloak.testsuite.util.OAuthClient; +import org.keycloak.util.JsonSerialization; + +import java.io.IOException; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.function.BiFunction; +import java.util.function.Consumer; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +public class OID4VCSdJwtIssuingEndpointTest extends OID4VCTest { + + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + private static final TimeProvider TIME_PROVIDER = new StaticTimeProvider(1000); + private CloseableHttpClient httpClient; + + + @Before + public void setup() { + CryptoIntegration.init(this.getClass().getClassLoader()); + httpClient = HttpClientBuilder.create().build(); + } + + + // ----- getCredentialOfferUri + + @Test(expected = BadRequestException.class) + public void testGetCredentialOfferUriUnsupportedCredential() throws Throwable { + String token = getBearerToken(oauth); + withCausePropagation(() -> testingClient.server(TEST_REALM_NAME) + .run((session -> { + AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session); + authenticator.setTokenString(token); + + OID4VCIssuerEndpoint oid4VCIssuerEndpoint = prepareIssuerEndpoint(session, authenticator); + oid4VCIssuerEndpoint.getCredentialOfferURI("inexistent-id", OfferUriType.URI, 0, 0); + }))); + + } + + @Test(expected = BadRequestException.class) + public void testGetCredentialOfferUriUnauthorized() throws Throwable { + withCausePropagation(() -> testingClient.server(TEST_REALM_NAME) + .run((session -> { + AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session); + authenticator.setTokenString(null); + OID4VCIssuerEndpoint oid4VCIssuerEndpoint = prepareIssuerEndpoint(session, authenticator); + oid4VCIssuerEndpoint.getCredentialOfferURI("test-credential", OfferUriType.URI, 0, 0); + }))); + } + + @Test(expected = BadRequestException.class) + public void testGetCredentialOfferUriInvalidToken() throws Throwable { + withCausePropagation(() -> testingClient.server(TEST_REALM_NAME) + .run((session -> { + AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session); + authenticator.setTokenString("invalid-token"); + OID4VCIssuerEndpoint oid4VCIssuerEndpoint = prepareIssuerEndpoint(session, authenticator); + oid4VCIssuerEndpoint.getCredentialOfferURI("test-credential", OfferUriType.URI, 0, 0); + }))); + } + + @Test + public void testGetCredentialOfferURI() { + String token = getBearerToken(oauth); + testingClient + .server(TEST_REALM_NAME) + .run((session) -> { + try { + AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session); + authenticator.setTokenString(token); + OID4VCIssuerEndpoint oid4VCIssuerEndpoint = prepareIssuerEndpoint(session, authenticator); + + Response response = oid4VCIssuerEndpoint.getCredentialOfferURI("test-credential", OfferUriType.URI, 0, 0); + + assertEquals("An offer uri should have been returned.", HttpStatus.SC_OK, response.getStatus()); + CredentialOfferURI credentialOfferURI = new ObjectMapper().convertValue(response.getEntity(), CredentialOfferURI.class); + assertNotNull("A nonce should be included.", credentialOfferURI.getNonce()); + assertNotNull("The issuer uri should be provided.", credentialOfferURI.getIssuer()); + } catch (Exception e) { + throw new RuntimeException(e); + } + }); + + } + + private static String getBearerToken(OAuthClient oAuthClient) { + OAuthClient.AuthorizationEndpointResponse authorizationEndpointResponse = oAuthClient.doLogin("john", "password"); + return oAuthClient.doAccessTokenRequest(authorizationEndpointResponse.getCode(), "password").getAccessToken(); + } + + // ----- getCredentialOffer + + @Test(expected = BadRequestException.class) + public void testGetCredentialOfferUnauthorized() throws Throwable { + withCausePropagation(() -> { + testingClient + .server(TEST_REALM_NAME) + .run((session) -> { + AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session); + authenticator.setTokenString(null); + OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator); + issuerEndpoint.getCredentialOffer("nonce"); + }); + }); + } + + @Test(expected = BadRequestException.class) + public void testGetCredentialOfferWithoutNonce() throws Throwable { + String token = getBearerToken(oauth); + withCausePropagation(() -> { + testingClient + .server(TEST_REALM_NAME) + .run((session -> { + AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session); + authenticator.setTokenString(token); + OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator); + issuerEndpoint.getCredentialOffer(null); + })); + }); + } + + @Test(expected = BadRequestException.class) + public void testGetCredentialOfferWithoutAPreparedOffer() throws Throwable { + String token = getBearerToken(oauth); + withCausePropagation(() -> { + testingClient + .server(TEST_REALM_NAME) + .run((session -> { + AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session); + authenticator.setTokenString(token); + OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator); + issuerEndpoint.getCredentialOffer("unpreparedNonce"); + })); + }); + } + + @Test(expected = BadRequestException.class) + public void testGetCredentialOfferWithABrokenNote() throws Throwable { + String token = getBearerToken(oauth); + withCausePropagation(() -> { + testingClient + .server(TEST_REALM_NAME) + .run((session -> { + AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session); + authenticator.setTokenString(token); + String nonce = prepareNonce(authenticator, "invalidNote"); + OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator); + issuerEndpoint.getCredentialOffer(nonce); + })); + }); + } + + @Test + public void testGetCredentialOffer() { + String token = getBearerToken(oauth); + String rootURL = suiteContext.getAuthServerInfo().getContextRoot().toString(); + testingClient + .server(TEST_REALM_NAME) + .run((session) -> { + AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session); + authenticator.setTokenString(token); + + SupportedCredentialConfiguration supportedCredentialConfiguration = new SupportedCredentialConfiguration() + .setId("test-credential") + .setScope("VerifiableCredential") + .setFormat(Format.JWT_VC); + String nonce = prepareNonce(authenticator, OBJECT_MAPPER.writeValueAsString(supportedCredentialConfiguration)); + + OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator); + Response credentialOfferResponse = issuerEndpoint.getCredentialOffer(nonce); + assertEquals("The offer should have been returned.", HttpStatus.SC_OK, credentialOfferResponse.getStatus()); + Object credentialOfferEntity = credentialOfferResponse.getEntity(); + assertNotNull("An actual offer should be in the response.", credentialOfferEntity); + + CredentialsOffer credentialsOffer = OBJECT_MAPPER.convertValue(credentialOfferEntity, CredentialsOffer.class); + assertNotNull("Credentials should have been offered.", credentialsOffer.getCredentialConfigurationIds()); + assertFalse("Credentials should have been offered.", credentialsOffer.getCredentialConfigurationIds().isEmpty()); + List supportedCredentials = credentialsOffer.getCredentialConfigurationIds(); + assertEquals("Exactly one credential should have been returned.", 1, supportedCredentials.size()); + String offeredCredentialId = supportedCredentials.get(0); + assertEquals("The credential should be as defined in the note.", supportedCredentialConfiguration.getId(), offeredCredentialId); + + PreAuthorizedGrant grant = credentialsOffer.getGrants(); + assertNotNull("The grant should be included.", grant); + assertNotNull("The grant should contain the pre-authorized code.", grant.getPreAuthorizedCode()); + assertNotNull("The actual pre-authorized code should be included.", grant + .getPreAuthorizedCode() + .getPreAuthorizedCode()); + + assertEquals("The correct issuer should be included.", rootURL + "/auth/realms/" + TEST_REALM_NAME, credentialsOffer.getCredentialIssuer()); + }); + } + + // ----- requestCredential + + @Test(expected = BadRequestException.class) + public void testRequestCredentialUnauthorized() throws Throwable { + withCausePropagation(() -> { + testingClient + .server(TEST_REALM_NAME) + .run((session -> { + AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session); + authenticator.setTokenString(null); + OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator); + issuerEndpoint.requestCredential(new CredentialRequest() + .setFormat(Format.JWT_VC) + .setCredentialIdentifier("test-credential")); + })); + }); + } + + @Test(expected = BadRequestException.class) + public void testRequestCredentialInvalidToken() throws Throwable { + withCausePropagation(() -> { + testingClient + .server(TEST_REALM_NAME) + .run((session -> { + AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session); + authenticator.setTokenString("token"); + OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator); + issuerEndpoint.requestCredential(new CredentialRequest() + .setFormat(Format.JWT_VC) + .setCredentialIdentifier("test-credential")); + })); + }); + } + + @Test(expected = BadRequestException.class) + public void testRequestCredentialUnsupportedFormat() throws Throwable { + String token = getBearerToken(oauth); + withCausePropagation(() -> { + testingClient + .server(TEST_REALM_NAME) + .run((session -> { + AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session); + authenticator.setTokenString(token); + OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator); + issuerEndpoint.requestCredential(new CredentialRequest() + .setFormat(Format.SD_JWT_VC) + .setCredentialIdentifier("test-credential")); + })); + }); + } + + @Test(expected = BadRequestException.class) + public void testRequestCredentialUnsupportedCredential() throws Throwable { + String token = getBearerToken(oauth); + withCausePropagation(() -> { + testingClient + .server(TEST_REALM_NAME) + .run((session -> { + AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session); + authenticator.setTokenString(token); + OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator); + issuerEndpoint.requestCredential(new CredentialRequest() + .setFormat(Format.JWT_VC) + .setCredentialIdentifier("no-such-credential")); + })); + }); + } + + @Test + public void testRequestCredential() { + String token = getBearerToken(oauth); + ObjectMapper objectMapper = new ObjectMapper(); + testingClient + .server(TEST_REALM_NAME) + .run((session -> { + AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session); + authenticator.setTokenString(token); + OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator); + CredentialRequest credentialRequest = new CredentialRequest() + .setFormat(Format.JWT_VC) + .setCredentialIdentifier("test-credential"); + Response credentialResponse = issuerEndpoint.requestCredential(credentialRequest); + assertEquals("The credential request should be answered successfully.", HttpStatus.SC_OK, credentialResponse.getStatus()); + assertNotNull("A credential should be responded.", credentialResponse.getEntity()); + CredentialResponse credentialResponseVO = OBJECT_MAPPER.convertValue(credentialResponse.getEntity(), CredentialResponse.class); + JsonWebToken jsonWebToken = TokenVerifier.create((String) credentialResponseVO.getCredential(), JsonWebToken.class).getToken(); + + assertNotNull("A valid credential string should have been responded", jsonWebToken); + assertNotNull("The credentials should be included at the vc-claim.", jsonWebToken.getOtherClaims().get("vc")); + VerifiableCredential credential = objectMapper.convertValue(jsonWebToken.getOtherClaims().get("vc"), VerifiableCredential.class); + assertTrue("The static claim should be set.", credential.getCredentialSubject().getClaims().containsKey("VerifiableCredential")); + assertFalse("Only mappers supported for the requested type should have been evaluated.", credential.getCredentialSubject().getClaims().containsKey("AnotherCredentialType")); + })); + } + + @Test + public void testRequestSdJwtCredential() { + String token = getBearerToken(oauth); + ObjectMapper objectMapper = new ObjectMapper(); + testingClient + .server(TEST_REALM_NAME) + .run((session -> { + AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session); + authenticator.setTokenString(token); + OID4VCIssuerEndpoint issuerEndpoint = prepareSdJwtIssuerEndpoint(session, authenticator); + CredentialRequest credentialRequest = new CredentialRequest() + .setFormat(Format.SD_JWT_VC) + .setVct("https://credentials.example.com/test-credential"); + Response credentialResponse = issuerEndpoint.requestCredential(credentialRequest); + assertEquals("The credential request should be answered successfully.", HttpStatus.SC_OK, credentialResponse.getStatus()); + assertNotNull("A credential should be responded.", credentialResponse.getEntity()); + CredentialResponse credentialResponseVO = OBJECT_MAPPER.convertValue(credentialResponse.getEntity(), CredentialResponse.class); + JsonWebToken jsonWebToken = TokenVerifier.create((String) credentialResponseVO.getCredential(), JsonWebToken.class).getToken(); + + assertNotNull("A valid credential string should have been responded", jsonWebToken); + assertNotNull("The credentials should be included at the vc-claim.", jsonWebToken.getOtherClaims().get("vc")); + VerifiableCredential credential = objectMapper.convertValue(jsonWebToken.getOtherClaims().get("vc"), VerifiableCredential.class); + assertTrue("The static claim should be set.", credential.getCredentialSubject().getClaims().containsKey("VerifiableCredential")); + assertFalse("Only mappers supported for the requested type should have been evaluated.", credential.getCredentialSubject().getClaims().containsKey("AnotherCredentialType")); + })); + } + + // Tests the complete flow from + // 1. Retrieving the credential-offer-uri + // 2. Using the uri to get the actual credential offer + // 3. Get the issuer metadata + // 4. Get the openid-configuration + // 5. Get an access token for the pre-authorized code + // 6. Get the credential + @Test + public void testCredentialIssuance() throws Exception { + + String token = getBearerToken(oauth); + + // 1. Retrieving the credential-offer-uri + HttpGet getCredentialOfferURI = new HttpGet(getBasePath(TEST_REALM_NAME) + "credential-offer-uri?credential_configuration_id=test-credential"); + getCredentialOfferURI.addHeader(HttpHeaders.AUTHORIZATION, "Bearer " + token); + CloseableHttpResponse credentialOfferURIResponse = httpClient.execute(getCredentialOfferURI); + + assertEquals("A valid offer uri should be returned", HttpStatus.SC_OK, credentialOfferURIResponse.getStatusLine().getStatusCode()); + String s = IOUtils.toString(credentialOfferURIResponse.getEntity().getContent(), StandardCharsets.UTF_8); + CredentialOfferURI credentialOfferURI = JsonSerialization.readValue(s, CredentialOfferURI.class); + + // 2. Using the uri to get the actual credential offer + HttpGet getCredentialOffer = new HttpGet(credentialOfferURI.getIssuer() + "/" + credentialOfferURI.getNonce()); + getCredentialOffer.addHeader(HttpHeaders.AUTHORIZATION, "Bearer " + token); + CloseableHttpResponse credentialOfferResponse = httpClient.execute(getCredentialOffer); + + assertEquals("A valid offer should be returned", HttpStatus.SC_OK, credentialOfferResponse.getStatusLine().getStatusCode()); + s = IOUtils.toString(credentialOfferResponse.getEntity().getContent(), StandardCharsets.UTF_8); + CredentialsOffer credentialsOffer = JsonSerialization.readValue(s, CredentialsOffer.class); + + // 3. Get the issuer metadata + HttpGet getIssuerMetadata = new HttpGet(credentialsOffer.getCredentialIssuer() + "/.well-known/openid-credential-issuer"); + CloseableHttpResponse issuerMetadataResponse = httpClient.execute(getIssuerMetadata); + assertEquals(HttpStatus.SC_OK, issuerMetadataResponse.getStatusLine().getStatusCode()); + s = IOUtils.toString(issuerMetadataResponse.getEntity().getContent(), StandardCharsets.UTF_8); + CredentialIssuer credentialIssuer = JsonSerialization.readValue(s, CredentialIssuer.class); + + assertEquals("We only expect one authorization server.", 1, credentialIssuer.getAuthorizationServers().size()); + + // 4. Get the openid-configuration + HttpGet getOpenidConfiguration = new HttpGet(credentialIssuer.getAuthorizationServers().get(0) + "/.well-known/openid-configuration"); + CloseableHttpResponse openidConfigResponse = httpClient.execute(getOpenidConfiguration); + assertEquals(HttpStatus.SC_OK, openidConfigResponse.getStatusLine().getStatusCode()); + s = IOUtils.toString(openidConfigResponse.getEntity().getContent(), StandardCharsets.UTF_8); + OIDCConfigurationRepresentation openidConfig = JsonSerialization.readValue(s, OIDCConfigurationRepresentation.class); + + assertNotNull("A token endpoint should be included.", openidConfig.getTokenEndpoint()); + assertTrue("The pre-authorized code should be supported.", openidConfig.getGrantTypesSupported().contains(PreAuthorizedCodeGrantTypeFactory.GRANT_TYPE)); + + // 5. Get an access token for the pre-authorized code + HttpPost postPreAuthorizedCode = new HttpPost(openidConfig.getTokenEndpoint()); + List parameters = new LinkedList<>(); + parameters.add(new BasicNameValuePair(OAuth2Constants.GRANT_TYPE, PreAuthorizedCodeGrantTypeFactory.GRANT_TYPE)); + parameters.add(new BasicNameValuePair("pre-authorized_code", credentialsOffer.getGrants().getPreAuthorizedCode().getPreAuthorizedCode())); + UrlEncodedFormEntity formEntity = new UrlEncodedFormEntity(parameters, "UTF-8"); + postPreAuthorizedCode.setEntity(formEntity); + OAuthClient.AccessTokenResponse accessTokenResponse = new OAuthClient.AccessTokenResponse(httpClient.execute(postPreAuthorizedCode)); + assertEquals(HttpStatus.SC_OK, accessTokenResponse.getStatusCode()); + String theToken = accessTokenResponse.getAccessToken(); + + // 6. Get the credential + credentialsOffer.getCredentialConfigurationIds().stream() + .map(offeredCredentialId -> credentialIssuer.getCredentialsSupported().get(offeredCredentialId)) + .forEach(supportedCredential -> { + try { + requestOffer(theToken, credentialIssuer.getCredentialEndpoint(), supportedCredential); + } catch (IOException e) { + fail("Was not able to get the credential."); + } catch (VerificationException e) { + throw new RuntimeException(e); + } + }); + } + + private ClientResource findClientByClientId(RealmResource realm, String clientId) { + for (ClientRepresentation c : realm.clients().findAll()) { + if (clientId.equals(c.getClientId())) { + return realm.clients().get(c.getId()); + } + } + return null; + } + + private String registerOptionalClientScope(String scopeName) { + ClientScopeRepresentation clientScope = new ClientScopeRepresentation(); + clientScope.setName(scopeName); + clientScope.setProtocol(OIDCLoginProtocol.LOGIN_PROTOCOL); + Response res = testRealm().clientScopes().create(clientScope); + String scopeId = ApiUtil.getCreatedId(res); + getCleanup().addClientScopeId(scopeId); // automatically removed when a test method is finished. + res.close(); + return scopeId; + } + + private void assignOptionalClientScopeToClient(String scopeId, String clientId) { + ClientResource clientResource = findClientByClientId(testRealm(), clientId); + clientResource.addOptionalClientScope(scopeId); + } + + private void addCredentialConfigurationIdToClient(String clientId, String credentialConfigurationId, String format, String scope) { + ClientRepresentation clientRepresentation = adminClient.realm(TEST_REALM_NAME).clients().findByClientId(clientId).get(0); + ClientResource clientResource = adminClient.realm(TEST_REALM_NAME).clients().get(clientRepresentation.getId()); + + clientRepresentation.setAttributes(Map.of( + "vc." + credentialConfigurationId + ".format", format, + "vc." + credentialConfigurationId + ".scope", scope)); + clientRepresentation.setProtocolMappers( + List.of( + getRoleMapper(clientId), + getEmailMapper(), + getIdMapper(), + getStaticClaimMapper(scope), + getStaticClaimMapper("AnotherCredentialType") + ) + ); + + clientResource.update(clientRepresentation); + } + + private void removeCredentialConfigurationIdToClient(String clientId) { + ClientRepresentation clientRepresentation = adminClient.realm(TEST_REALM_NAME).clients().findByClientId(clientId).get(0); + ClientResource clientResource = adminClient.realm(TEST_REALM_NAME).clients().get(clientRepresentation.getId()); + clientRepresentation.setAttributes(Map.of()); + clientResource.update(clientRepresentation); + } + + private void logoutUser(String clientId, String username) { + UserResource user = ApiUtil.findUserByUsernameId(adminClient.realm(TEST_REALM_NAME), username); + user.logout(); + } + + private void testCredentialIssuanceWithAuthZCodeFlow(Consumer> c) throws Exception { + // use pre-registered client for this test class whose clientId is "test-app" defined in testrealm.json + String testClientId = "test-app"; + + // use supported values by Credential Issuer Metadata + String testCredentialConfigurationId = "test-credential"; + String testScope = "VerifiableCredential"; + String testFormat = Format.JWT_VC.toString(); + + // register optional client scope + String scopeId = registerOptionalClientScope(testScope); + + // assign registered optional client scope + assignOptionalClientScopeToClient(scopeId, testClientId); // pre-registered client for this test class + + // add credential configuration id to a client as client attributes + addCredentialConfigurationIdToClient(testClientId, testCredentialConfigurationId, testFormat, testScope); + + c.accept(Map.of( + "clientId", testClientId, + "credentialConfigurationId", testCredentialConfigurationId, + "scope", testScope, + "format", testFormat) + ); + // clean-up + logoutUser(testClientId, "john"); + removeCredentialConfigurationIdToClient(testClientId); + oauth.clientId(null); + } + + // Tests the AuthZCode complete flow without scope from + // 1. Get authorization code without scope specified by wallet + // 2. Using the code to get access token + // 3. Get the credential configuration id from issuer metadata at .wellKnown + // 4. With the access token, get the credential + private void testCredentialIssuanceWithAuthZCodeFlow(BiFunction f, Consumer> c) throws Exception { + testCredentialIssuanceWithAuthZCodeFlow(m->{ + String testClientId = m.get("clientId"); + String testScope = m.get("scope"); + String testFormat = m.get("format"); + String testCredentialConfigurationId = m.get("credentialConfigurationId"); + + try (Client client = AdminClientUtil.createResteasyClient()) { + UriBuilder builder = UriBuilder.fromUri(OAuthClient.AUTH_SERVER_ROOT); + URI oid4vciDiscoveryUri = RealmsResource.wellKnownProviderUrl(builder).build(TEST_REALM_NAME, OID4VCIssuerWellKnownProviderFactory.PROVIDER_ID); + WebTarget oid4vciDiscoveryTarget = client.target(oid4vciDiscoveryUri); + + // 1. Get authoriZation code without scope specified by wallet + // 2. Using the code to get accesstoken + String token = f.apply(testClientId, testScope); + + // 3. Get the credential configuration id from issuer metadata at .wellKnown + try (Response discoveryResponse = oid4vciDiscoveryTarget.request().get()) { + CredentialIssuer oid4vciIssuerConfig = JsonSerialization.readValue(discoveryResponse.readEntity(String.class), CredentialIssuer.class); + assertEquals(200, discoveryResponse.getStatus()); + assertEquals(getRealmPath(TEST_REALM_NAME), oid4vciIssuerConfig.getCredentialIssuer()); + assertEquals(getBasePath(TEST_REALM_NAME) + "credential", oid4vciIssuerConfig.getCredentialEndpoint()); + + // 4. With the access token, get the credential + try (Client clientForCredentialRequest = AdminClientUtil.createResteasyClient()) { + UriBuilder credentialUriBuilder = UriBuilder.fromUri(oid4vciIssuerConfig.getCredentialEndpoint()); + URI credentialUri = credentialUriBuilder.build(); + WebTarget credentialTarget = clientForCredentialRequest.target(credentialUri); + + CredentialRequest request = new CredentialRequest(); + request.setFormat(oid4vciIssuerConfig.getCredentialsSupported().get(testCredentialConfigurationId).getFormat()); + request.setCredentialIdentifier(oid4vciIssuerConfig.getCredentialsSupported().get(testCredentialConfigurationId).getId()); + + assertEquals(testFormat, oid4vciIssuerConfig.getCredentialsSupported().get(testCredentialConfigurationId).getFormat().toString()); + assertEquals(testCredentialConfigurationId, oid4vciIssuerConfig.getCredentialsSupported().get(testCredentialConfigurationId).getId()); + + c.accept(Map.of( + "accessToken", token, + "credentialTarget", credentialTarget, + "credentialRequest", request + )); + } + } + } catch (IOException e) { + Assert.fail(); + } + + }); + } + + @Test + public void testCredentialIssuanceWithAuthZCodeWithScopeMatched() throws Exception { + testCredentialIssuanceWithAuthZCodeFlow((testClientId, testScope) -> getBearerToken(oauth.clientId(testClientId).openid(false).scope(testScope)), + m -> { + String accessToken = (String)m.get("accessToken"); + WebTarget credentialTarget = (WebTarget)m.get("credentialTarget"); + CredentialRequest credentialRequest = (CredentialRequest)m.get("credentialRequest"); + + try (Response response = credentialTarget.request().header(HttpHeaders.AUTHORIZATION, "bearer " + accessToken).post(Entity.json(credentialRequest))) { + CredentialResponse credentialResponse = JsonSerialization.readValue(response.readEntity(String.class),CredentialResponse.class); + + assertEquals(200, response.getStatus()); + JsonWebToken jsonWebToken = TokenVerifier.create((String) credentialResponse.getCredential(), JsonWebToken.class).getToken(); + assertEquals("did:web:test.org", jsonWebToken.getIssuer()); + + VerifiableCredential credential = new ObjectMapper().convertValue(jsonWebToken.getOtherClaims().get("vc"), VerifiableCredential.class); + assertEquals(TEST_TYPES, credential.getType()); + assertEquals(TEST_DID, credential.getIssuer()); + assertEquals("john@email.cz", credential.getCredentialSubject().getClaims().get("email")); + } catch (IOException | VerificationException e) { + Assert.fail(); + } + }); + } + + @Test + public void testCredentialIssuanceWithAuthZCodeWithScopeUnmatched() throws Exception { + testCredentialIssuanceWithAuthZCodeFlow((testClientId, testScope) -> getBearerToken(oauth.clientId(testClientId).openid(false).scope("email")), // set registered different scope + m -> { + String accessToken = (String)m.get("accessToken"); + WebTarget credentialTarget = (WebTarget)m.get("credentialTarget"); + CredentialRequest credentialRequest = (CredentialRequest)m.get("credentialRequest"); + + try (Response response = credentialTarget.request().header(HttpHeaders.AUTHORIZATION, "bearer " + accessToken).post(Entity.json(credentialRequest))) { + assertEquals(400, response.getStatus()); + } + }); + } + + @Test + public void testCredentialIssuanceWithAuthZCodeSWithoutScope() throws Exception { + testCredentialIssuanceWithAuthZCodeFlow((testClientId, testScope) -> getBearerToken(oauth.clientId(testClientId).openid(false).scope(null)), // no scope + m -> { + String accessToken = (String)m.get("accessToken"); + WebTarget credentialTarget = (WebTarget)m.get("credentialTarget"); + CredentialRequest credentialRequest = (CredentialRequest)m.get("credentialRequest"); + + try (Response response = credentialTarget.request().header(HttpHeaders.AUTHORIZATION, "bearer " + accessToken).post(Entity.json(credentialRequest))) { + assertEquals(400, response.getStatus()); + } + }); + } + + private static String prepareNonce(AppAuthManager.BearerTokenAuthenticator authenticator, String note) { + String nonce = SecretGenerator.getInstance().randomString(); + AuthenticationManager.AuthResult authResult = authenticator.authenticate(); + UserSessionModel userSessionModel = authResult.getSession(); + userSessionModel.getAuthenticatedClientSessionByClient(authResult.getClient().getId()).setNote(nonce, note); + return nonce; + } + + private static OID4VCIssuerEndpoint prepareIssuerEndpoint(KeycloakSession session, AppAuthManager.BearerTokenAuthenticator authenticator) { + JwtSigningService jwtSigningService = new JwtSigningService( + session, + getKeyFromSession(session).getKid(), + Algorithm.RS256, + "JWT", + "did:web:issuer.org", + TIME_PROVIDER); + return new OID4VCIssuerEndpoint( + session, + "did:web:issuer.org", + Map.of(jwtSigningService.locator(), jwtSigningService), + authenticator, + new ObjectMapper(), + TIME_PROVIDER, + 30, + true); + } + + private static OID4VCIssuerEndpoint prepareSdJwtIssuerEndpoint(KeycloakSession session, AppAuthManager.BearerTokenAuthenticator authenticator) { + SdJwtSigningService sdJwtSigningService = new SdJwtSigningService( + session, + new ObjectMapper(), + getKeyFromSession(session).getKid(), + Algorithm.RS256, + "vc+sd-jwt", + "sha-256", + "did:web:issuer.org", + 0, + List.of(), + Optional.empty(), + VerifiableCredentialType.from("https://credentials.example.com/test-credential"), + CredentialConfigId.from("test-credential")); + return new OID4VCIssuerEndpoint( + session, + "did:web:issuer.org", + Map.of(sdJwtSigningService.locator(), sdJwtSigningService), + authenticator, + new ObjectMapper(), + TIME_PROVIDER, + 30, + true); + } + + + private String getBasePath(String realm) { + return getRealmPath(realm) + "/protocol/oid4vc/"; + } + + private String getRealmPath(String realm){ + return suiteContext.getAuthServerInfo().getContextRoot().toString() + "/auth/realms/" + realm; + } + + private void requestOffer(String token, String credentialEndpoint, SupportedCredentialConfiguration offeredCredential) throws IOException, VerificationException { + CredentialRequest request = new CredentialRequest(); + request.setFormat(offeredCredential.getFormat()); + request.setCredentialIdentifier(offeredCredential.getId()); + + StringEntity stringEntity = new StringEntity(OBJECT_MAPPER.writeValueAsString(request), ContentType.APPLICATION_JSON); + + HttpPost postCredential = new HttpPost(credentialEndpoint); + postCredential.addHeader(HttpHeaders.AUTHORIZATION, "Bearer " + token); + postCredential.setEntity(stringEntity); + CloseableHttpResponse credentialRequestResponse = httpClient.execute(postCredential); + assertEquals(HttpStatus.SC_OK, credentialRequestResponse.getStatusLine().getStatusCode()); + String s = IOUtils.toString(credentialRequestResponse.getEntity().getContent(), StandardCharsets.UTF_8); + CredentialResponse credentialResponse = JsonSerialization.readValue(s, CredentialResponse.class); + + assertNotNull("The credential should have been responded.", credentialResponse.getCredential()); + JsonWebToken jsonWebToken = TokenVerifier.create((String) credentialResponse.getCredential(), JsonWebToken.class).getToken(); + assertEquals("did:web:test.org", jsonWebToken.getIssuer()); + VerifiableCredential credential = new ObjectMapper().convertValue(jsonWebToken.getOtherClaims().get("vc"), VerifiableCredential.class); + assertEquals(List.of("VerifiableCredential"), credential.getType()); + assertEquals(URI.create("did:web:test.org"), credential.getIssuer()); + assertEquals("john@email.cz", credential.getCredentialSubject().getClaims().get("email")); + assertTrue("The static claim should be set.", credential.getCredentialSubject().getClaims().containsKey("VerifiableCredential")); + assertFalse("Only mappers supported for the requested type should have been evaluated.", credential.getCredentialSubject().getClaims().containsKey("AnotherCredentialType")); + } + + @Override + public void configureTestRealm(RealmRepresentation testRealm) { + if (testRealm.getComponents() != null) { + testRealm.getComponents().add("org.keycloak.keys.KeyProvider", getRsaKeyProvider(RSA_KEY)); + testRealm.getComponents().add("org.keycloak.protocol.oid4vc.issuance.signing.VerifiableCredentialsSigningService", getJwtSigningProvider(RSA_KEY)); + } else { + testRealm.setComponents(new MultivaluedHashMap<>( + Map.of("org.keycloak.keys.KeyProvider", List.of(getRsaKeyProvider(RSA_KEY)), + "org.keycloak.protocol.oid4vc.issuance.signing.VerifiableCredentialsSigningService", List.of(getJwtSigningProvider(RSA_KEY)) + ))); + } + ClientRepresentation clientRepresentation = getTestClient("did:web:test.org"); + if (testRealm.getClients() != null) { + testRealm.getClients().add(clientRepresentation); + } else { + testRealm.setClients(List.of(clientRepresentation)); + } + if (testRealm.getRoles() != null) { + testRealm.getRoles().getClient() + .put(clientRepresentation.getClientId(), List.of(getRoleRepresentation("testRole", clientRepresentation.getClientId()))); + } else { + testRealm.getRoles() + .setClient(Map.of(clientRepresentation.getClientId(), List.of(getRoleRepresentation("testRole", clientRepresentation.getClientId())))); + } + if (testRealm.getUsers() != null) { + testRealm.getUsers().add(getUserRepresentation(Map.of(clientRepresentation.getClientId(), List.of("testRole")))); + } else { + testRealm.setUsers(List.of(getUserRepresentation(Map.of(clientRepresentation.getClientId(), List.of("testRole"))))); + } + if (testRealm.getAttributes() != null) { + testRealm.getAttributes().put("issuerDid", TEST_DID.toString()); + } else { + testRealm.setAttributes(Map.of("issuerDid", TEST_DID.toString())); + } + } + + private void withCausePropagation(Runnable r) throws Throwable { + try { + r.run(); + } catch (Exception e) { + if (e instanceof RunOnServerException) { + throw e.getCause(); + } + throw e; + } + } + +} + From 39100edc1281422399b045012acb3cb27062fbf6 Mon Sep 17 00:00:00 2001 From: Francis Pouatcha Date: Mon, 15 Jul 2024 20:00:44 +0100 Subject: [PATCH 13/19] Processing feedbacks from first review comments Signed-off-by: Francis Pouatcha --- .../oid4vc/issuance/OID4VCIssuerEndpoint.java | 100 ++- .../signing/JwtProofBasedSigningService.java | 5 + .../issuance/signing/SdJwtSigningService.java | 202 +---- .../SdJwtSigningServiceProviderFactory.java | 17 +- .../issuance/signing/SigningProperties.java | 3 +- .../issuance/signing/SigningService.java | 4 +- .../VerifiableCredentialsSigningService.java | 22 +- .../oid4vc/model/CredentialConfigId.java | 25 + .../oid4vc/model/CredentialDefinition.java | 84 +- .../oid4vc/model/CredentialRequest.java | 29 +- .../protocol/oid4vc/model/ErrorType.java | 6 +- .../SupportedCredentialConfiguration.java | 46 +- .../model/VerifiableCredentialType.java | 2 +- .../signing/OID4VCIssuerEndpointTest.java | 499 ++--------- .../OID4VCIssuerWellKnownProviderTest.java | 7 +- .../signing/OID4VCJWTIssuerEndpointTest.java | 441 +++++++++- .../OID4VCSdJwtIssuingEndpointTest.java | 821 +++++------------- .../oid4vc/issuance/signing/OID4VCTest.java | 69 +- .../signing/SdJwtSigningServiceTest.java | 14 +- 19 files changed, 1005 insertions(+), 1391 deletions(-) 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 9dfb77f11f84..f71bb7bc4296 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 @@ -84,6 +84,7 @@ import java.util.Map; import java.util.Objects; import java.util.Optional; +import java.util.stream.Collectors; /** * Provides the (REST-)endpoints required for the OID4VCI protocol. @@ -309,21 +310,44 @@ public Response requestCredential( checkScope(credentialRequestVO); } + // Both Format and identifier are optional. + // If the credential_identifier is present, Format can't be present. But this implementation will + // tolerate the presence of both, waiting for clarity in specifications. + // This implementation will priviledge the presence of the credential config identifier. + String requestedCredentialId = credentialRequestVO.getCredentialIdentifier(); Format requestedFormat = credentialRequestVO.getFormat(); - String requestedCredential = credentialRequestVO.getCredentialIdentifier(); - - SupportedCredentialConfiguration supportedCredentialConfiguration = Optional - .ofNullable(OID4VCIssuerWellKnownProvider.getSupportedCredentials(this.session) - .get(requestedCredential)) - .orElseThrow( - () -> { - LOGGER.debugf("Unsupported credential %s was requested.", requestedCredential); - return new BadRequestException(getErrorResponse(ErrorType.UNSUPPORTED_CREDENTIAL_TYPE)); - }); - - if (!supportedCredentialConfiguration.getFormat().equals(requestedFormat)) { - LOGGER.debugf("Format %s is not supported for credential %s.", requestedFormat, requestedCredential); - throw new BadRequestException(getErrorResponse(ErrorType.UNSUPPORTED_CREDENTIAL_FORMAT)); + + // Check if at least one of both is available. + if(requestedCredentialId==null && requestedFormat==null){ + LOGGER.debugf("Missing both configuration id and requested format. At least one shall be specified."); + throw new BadRequestException(getErrorResponse(ErrorType.MISSING_CREDENTIAL_CONFIG_AND_FORMAT)); + } + + Map supportedCredentials = OID4VCIssuerWellKnownProvider.getSupportedCredentials(this.session); + + // resolve from identifier first + SupportedCredentialConfiguration supportedCredentialConfiguration = null; + if (requestedCredentialId!=null) { + supportedCredentialConfiguration = supportedCredentials.get(requestedCredentialId); + if(supportedCredentialConfiguration==null){ + LOGGER.debugf("Credential with configuration id %s not found.", requestedCredentialId); + throw new BadRequestException(getErrorResponse(ErrorType.UNSUPPORTED_CREDENTIAL_TYPE)); + } + // Then for format. We know spec does not allow both parameter. But we are tolerant if you send both + // Was found by id, check that the format matches. + if (requestedFormat!=null && !requestedFormat.equals(supportedCredentialConfiguration.getFormat())){ + LOGGER.debugf("Credential with configuration id %s does not support requested format %s, but supports %s.", requestedCredentialId, requestedFormat, supportedCredentialConfiguration.getFormat()); + throw new BadRequestException(getErrorResponse(ErrorType.UNSUPPORTED_CREDENTIAL_FORMAT)); + } + } + + if(supportedCredentialConfiguration==null && requestedFormat!=null) { + // Search by format + supportedCredentialConfiguration = getSupportedCredentialConfiguration(credentialRequestVO, supportedCredentials, requestedFormat); + if(supportedCredentialConfiguration==null) { + LOGGER.debugf("Credential with requested format %s, not supported.", requestedFormat); + throw new BadRequestException(getErrorResponse(ErrorType.UNSUPPORTED_CREDENTIAL_FORMAT)); + } } CredentialResponse responseVO = new CredentialResponse(); @@ -334,8 +358,38 @@ public Response requestCredential( default -> throw new BadRequestException( getErrorResponse(ErrorType.UNSUPPORTED_CREDENTIAL_TYPE)); } - return Response.ok().entity(responseVO) - .build(); + return Response.ok().entity(responseVO).build(); + } + + private SupportedCredentialConfiguration getSupportedCredentialConfiguration(CredentialRequest credentialRequestVO, Map supportedCredentials, Format requestedFormat) { + // 1. Format resolver + List configs = supportedCredentials.values().stream() + .filter(supportedCredential -> supportedCredential.getFormat() == requestedFormat) + .collect(Collectors.toList()); + + List matchingConfigs; + + switch (requestedFormat) { + // Will detach this when each format provides logic on how to resolve from definition. + case JWT_VC, LDP_VC -> { + matchingConfigs = configs.stream() + .filter(supportedCredential -> Objects.equals(supportedCredential.getCredentialDefinition(), credentialRequestVO.getCredentialDefinition())) + .collect(Collectors.toList()); + } + // Resolve from vct for sd-jwt + case SD_JWT_VC -> { + matchingConfigs = configs.stream() + .filter(supportedCredential -> Objects.equals(supportedCredential.getVct(), credentialRequestVO.getVct())) + .collect(Collectors.toList()); + } + default -> throw new BadRequestException(getErrorResponse(ErrorType.UNSUPPORTED_CREDENTIAL_FORMAT)); + } + + if (matchingConfigs.isEmpty()) { + throw new BadRequestException(getErrorResponse(ErrorType.MISSING_CREDENTIAL_CONFIG)); + } + + return matchingConfigs.iterator().next(); } private AuthenticatedClientSessionModel getAuthenticatedClientSession() { @@ -394,18 +448,22 @@ private Object getCredential(AuthenticationManager.AuthResult authResult, Suppor VCIssuanceContext vcIssuanceContext = getVCToSign(protocolMappers, credentialConfig, authResult, credentialRequestVO); - String specificConfigKey = VerifiableCredentialsSigningService.locator(credentialConfig.getFormat(), credentialConfig.getId()); - String formatKey = credentialConfig.getFormat().name(); + String fullyQualifiedConfigKey = VerifiableCredentialsSigningService.locator(credentialConfig.getFormat(), credentialConfig.deriveType(), credentialConfig.deriveConfiId()); + String formatAndTypeKey = VerifiableCredentialsSigningService.locator(credentialConfig.getFormat(), credentialConfig.deriveType(), null); + String formatOnlyKey = VerifiableCredentialsSigningService.locator(credentialConfig.getFormat(), null, null); - // First retrieve by format/id, then by format only + // Search from specific to general config. VerifiableCredentialsSigningService signingService = signingServices.getOrDefault( - specificConfigKey, signingServices.get(formatKey) + fullyQualifiedConfigKey, + signingServices.getOrDefault( + formatAndTypeKey, + signingServices.get(formatOnlyKey)) ); return Optional.ofNullable(signingService) .map(service -> service.signCredential(vcIssuanceContext)) .orElseThrow(() -> new IllegalArgumentException( - String.format("No signer found for specific config '%s' or format '%s'.", specificConfigKey, formatKey) + String.format("No signer found for specific config '%s' or '%s' or format '%s'.", fullyQualifiedConfigKey, formatAndTypeKey, formatOnlyKey) )); } diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/signing/JwtProofBasedSigningService.java b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/signing/JwtProofBasedSigningService.java index fbd9767c44d1..d416103fbf20 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/signing/JwtProofBasedSigningService.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/signing/JwtProofBasedSigningService.java @@ -37,6 +37,11 @@ import org.keycloak.representations.AccessToken; import org.keycloak.util.JsonSerialization; +/** + * Common signing service logic to handle proofs. + * + * @author Francis Pouatcha + */ public abstract class JwtProofBasedSigningService extends SigningService { private static final Logger LOGGER = Logger.getLogger(JwtProofBasedSigningService.class); diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/signing/SdJwtSigningService.java b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/signing/SdJwtSigningService.java index 4b417a0a7f0a..14b3595f9670 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/signing/SdJwtSigningService.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/signing/SdJwtSigningService.java @@ -25,32 +25,23 @@ import org.keycloak.crypto.KeyWrapper; import org.keycloak.crypto.SignatureProvider; import org.keycloak.crypto.SignatureSignerContext; -import org.keycloak.crypto.SignatureVerifierContext; import org.keycloak.jose.jwk.JWK; -import org.keycloak.jose.jws.JWSHeader; -import org.keycloak.jose.jws.JWSInput; import org.keycloak.jose.jws.JWSInputException; import org.keycloak.models.KeycloakSession; -import org.keycloak.protocol.oid4vc.issuance.TimeProvider; import org.keycloak.protocol.oid4vc.issuance.VCIssuanceContext; import org.keycloak.protocol.oid4vc.issuance.VCIssuerException; +import org.keycloak.protocol.oid4vc.model.CredentialConfigId; import org.keycloak.protocol.oid4vc.model.CredentialSubject; import org.keycloak.protocol.oid4vc.model.Format; -import org.keycloak.protocol.oid4vc.model.Proof; -import org.keycloak.protocol.oid4vc.model.ProofType; -import org.keycloak.protocol.oid4vc.model.ProofTypeJWT; import org.keycloak.protocol.oid4vc.model.VerifiableCredential; -import org.keycloak.representations.AccessToken; +import org.keycloak.protocol.oid4vc.model.VerifiableCredentialType; import org.keycloak.sdjwt.DisclosureSpec; import org.keycloak.sdjwt.SdJwt; import org.keycloak.sdjwt.SdJwtUtils; -import org.keycloak.util.JsonSerialization; import java.io.IOException; -import java.util.Arrays; import java.util.List; import java.util.Map; -import java.util.Objects; import java.util.Optional; import java.util.stream.IntStream; @@ -63,39 +54,47 @@ * * @author Stefan Wiedemann */ -public class SdJwtSigningService extends SigningService { +public class SdJwtSigningService extends JwtProofBasedSigningService { private static final Logger LOGGER = Logger.getLogger(SdJwtSigningService.class); private static final String ISSUER_CLAIM ="iss"; - private static final String NOT_BEFORE_CLAIM ="nbf"; private static final String VERIFIABLE_CREDENTIAL_TYPE_CLAIM = "vct"; private static final String CREDENTIAL_ID_CLAIM = "jti"; private static final String CNF_CLAIM = "cnf"; private static final String JWK_CLAIM = "jwk"; - public static final String PROOF_JWT_TYP="openid4vci-proof+jwt"; private final ObjectMapper objectMapper; private final SignatureSignerContext signatureSignerContext; - private final TimeProvider timeProvider; private final String tokenType; private final String hashAlgorithm; private final int decoys; private final List visibleClaims; protected final String issuerDid; - private final String vcConfigId; + private final CredentialConfigId vcConfigId; - public SdJwtSigningService(KeycloakSession keycloakSession, ObjectMapper objectMapper, String keyId, String algorithmType, String tokenType, String hashAlgorithm, String issuerDid, int decoys, List visibleClaims, TimeProvider timeProvider, Optional kid, String vcConfigId) { + // See: https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#name-credential-request-6 + // vct sort of additional category for sd-jw. + private final VerifiableCredentialType vct; + + public SdJwtSigningService(KeycloakSession keycloakSession, ObjectMapper objectMapper, String keyId, String algorithmType, String tokenType, String hashAlgorithm, String issuerDid, int decoys, List visibleClaims, Optional kid, VerifiableCredentialType credentialType, CredentialConfigId vcConfigId) { super(keycloakSession, keyId, Format.SD_JWT_VC, algorithmType); this.objectMapper = objectMapper; this.issuerDid = issuerDid; - this.timeProvider = timeProvider; this.tokenType = tokenType; this.hashAlgorithm = hashAlgorithm; this.decoys = decoys; this.visibleClaims = visibleClaims; this.vcConfigId = vcConfigId; + this.vct = credentialType; + + // If a config id is defined, a vct must be defined. + // Also validated in: org.keycloak.protocol.oid4vc.issuance.signing.SdJwtSigningServiceProviderFactory.validateSpecificConfiguration + if(this.vcConfigId!=null && this.vct==null){ + throw new SigningServiceException(String.format("Missing vct for credential config id %s.", vcConfigId)); + } + // Will return the active key if key id is null. KeyWrapper signingKey = getKey(keyId, algorithmType); if (signingKey == null) { @@ -156,14 +155,13 @@ public String signCredential(VCIssuanceContext vcIssuanceContext) throws VCIssue // nbf, iat and exp are all optional. So need to be set by a protocol mapper if needed // see: https://www.ietf.org/archive/id/draft-ietf-oauth-sd-jwt-vc-03.html#name-registered-jwt-claims - if (verifiableCredential.getType() == null || verifiableCredential.getType().size() != 1) { - throw new SigningServiceException("SD-JWT only supports single type credentials."); - } - rootNode.put(VERIFIABLE_CREDENTIAL_TYPE_CLAIM, verifiableCredential.getType().get(0)); + + // Use vct as type for sd-jwt. + rootNode.put(VERIFIABLE_CREDENTIAL_TYPE_CLAIM, vct.getValue()); rootNode.put(CREDENTIAL_ID_CLAIM, JwtSigningService.createCredentialId(verifiableCredential)); // add the key binding if any - if(jwk!=null){ + if (jwk!=null) { rootNode.putPOJO(CNF_CLAIM, Map.of(JWK_CLAIM, jwk)); } @@ -180,162 +178,6 @@ public String signCredential(VCIssuanceContext vcIssuanceContext) throws VCIssue @Override public String locator() { - return VerifiableCredentialsSigningService.locator(format,vcConfigId); - } - - /* - * Validates a proof provided by the client if any. - * - * Returns null if there is no need to include a key binding in the credential - * - * Return the JWK to be included as key binding in the JWK if the provided proof was correctly validated - * - * @param vcIssuanceContext - * @return - * @throws VCIssuerException - * @throws JWSInputException - * @throws VerificationException - * @throws IllegalStateException: is credential type badly configured - * @throws IOException - */ - private JWK validateProof(VCIssuanceContext vcIssuanceContext) throws VCIssuerException, JWSInputException, VerificationException, IOException { - - Optional optionalProof = getProofFromContext(vcIssuanceContext); - - if (!optionalProof.isPresent()) { - return null; // No proof support - } - - // Check key binding config for jwt. Only type supported. - checkCryptographicKeyBinding(vcIssuanceContext); - - JWSInput jwsInput = getJwsInput(optionalProof.get()); - JWSHeader jwsHeader = jwsInput.getHeader(); - validateJwsHeader(vcIssuanceContext, jwsHeader); - - JWK jwk = Optional.ofNullable(jwsHeader.getKey()) - .orElseThrow(() -> new VCIssuerException("Missing binding key. Make sure provided JWT contains the jwk jwsHeader claim.")); - - // Parsing the Proof as an access token shall work, as a proof is a strict subset of an access token. - AccessToken proofPayload = JsonSerialization.readValue(jwsInput.getContent(), AccessToken.class); - validateProofPayload(vcIssuanceContext, proofPayload); - - SignatureVerifierContext signatureVerifierContext = getVerifier(jwk, jwsHeader.getAlgorithm().name()); - if(signatureVerifierContext==null){ - throw new VCIssuerException("No verifier configured for " +jwsHeader.getAlgorithm()); - } - if (!signatureVerifierContext.verify(jwsInput.getEncodedSignatureInput().getBytes("UTF-8"), jwsInput.getSignature())) { - throw new VCIssuerException("Could not verify provided proof"); - } - - return jwk; - } - - private void checkCryptographicKeyBinding(VCIssuanceContext vcIssuanceContext){ - // Make sure we are dealing with a jwk proof. - if (vcIssuanceContext.getCredentialConfig().getCryptographicBindingMethodsSupported() == null || - !vcIssuanceContext.getCredentialConfig().getCryptographicBindingMethodsSupported().contains("jwk")) { - throw new IllegalStateException("This SD-JWT implementation only supports jwk as cryptographic binding method"); - } + return VerifiableCredentialsSigningService.locator(format, vct, vcConfigId); } - - private Optional getProofFromContext(VCIssuanceContext vcIssuanceContext) throws VCIssuerException { - return Optional.ofNullable(vcIssuanceContext.getCredentialConfig()) - .map(config -> config.getProofTypesSupported()) - .flatMap(proofTypesSupported -> { - if (proofTypesSupported == null) { - LOGGER.debugf("No proof support. Will skip proof validation."); - return Optional.empty(); - } - - ProofTypeJWT jwt = Optional.ofNullable(proofTypesSupported.getJwt()) - .orElseThrow(() -> new VCIssuerException("SD-JWT supports only jwt proof type.")); - - Proof proof = Optional.ofNullable(vcIssuanceContext.getCredentialRequest().getProof()) - .orElseThrow(() -> new VCIssuerException("Credential configuration requires a proof of type: " + ProofType.JWT.getValue())); - - if (!Objects.equals(proof.getProofType(), ProofType.JWT)) { - throw new VCIssuerException("Wrong proof type"); - } - - return Optional.of(proof); - }); - } - - private JWSInput getJwsInput(Proof proof) throws JWSInputException { - return new JWSInput(proof.getJwt()); - } - - /** - * As we limit accepted algorithm to the ones listed by the issuer, we can omit checking for "none" - * The Algorithm enum class does not list the none value anyway. - * - * @param vcIssuanceContext - * @param jwsHeader - * @throws VCIssuerException - */ - private void validateJwsHeader(VCIssuanceContext vcIssuanceContext, JWSHeader jwsHeader) throws VCIssuerException { - Optional.ofNullable(jwsHeader.getAlgorithm()) - .orElseThrow(() -> new VCIssuerException("Missing jwsHeader claim alg")); - - // As we limit accepted algorithm to the ones listed by the server, we can omit checking for "none" - // The Algorithm enum class does not list the none value anyway. - Optional.ofNullable(vcIssuanceContext.getCredentialConfig()) - .map(config -> config.getProofTypesSupported()) - .map(proofTypesSupported -> proofTypesSupported.getJwt()) - .map(jwt -> jwt.getProofSigningAlgValuesSupported()) - .filter(supportedAlgs -> supportedAlgs.contains(jwsHeader.getAlgorithm().name())) - .orElseThrow(() -> new VCIssuerException("Proof signature algorithm not supported: " + jwsHeader.getAlgorithm().name())); - - Optional.ofNullable(jwsHeader.getType()) - .filter(type -> Objects.equals(PROOF_JWT_TYP, type)) - .orElseThrow(() -> new VCIssuerException("JWT type must be: " + PROOF_JWT_TYP)); - - // KeyId shall not be present alongside the jwk. - Optional.ofNullable(jwsHeader.getKeyId()) - .ifPresent(keyId -> { - throw new VCIssuerException("KeyId not expected in this JWT. Use the jwk claim instead."); - }); - } - - private void validateProofPayload(VCIssuanceContext vcIssuanceContext, AccessToken proofPayload) throws VCIssuerException { - // azp is the id of the client, as mentioned in the access token used to request the credential. - // Token provided from user is obtained with a clientId that support the oidc login protocol. - // oid4vci client doesn't. But it is the client needed at the credential endpoint. -// String azp = vcIssuanceContext.getAuthResult().getToken().getIssuedFor(); -// Optional.ofNullable(proofPayload.getIssuer()) -// .filter(proofIssuer -> Objects.equals(azp, proofIssuer)) -// .orElseThrow(() -> new VCIssuerException("Issuer claim must be null for preauthorized code else the clientId of the client making the request: " + azp)); - - // The issuer is the token / credential is the audience of the proof - String credentialIssuer = vcIssuanceContext.getVerifiableCredential().getIssuer().toString(); - Optional.ofNullable(proofPayload.getAudience()) // Ensure null-safety with Optional - .map(Arrays::asList) // Convert to List - .filter(audiences -> audiences.contains(credentialIssuer)) // Check if the issuer is in the audience list - .orElseThrow(() -> new VCIssuerException( - "Proof not produced for this audience. Audience claim must be: " + credentialIssuer + " but are " + Arrays.asList(proofPayload.getAudience()))); - - // Validate mandatory iat. - // I do not understand the rationale behind requiring a issue time if we are not checking expiration. - Optional.ofNullable(proofPayload.getIat()) - .orElseThrow(() -> new VCIssuerException("Missing proof issuing time. iat claim must be provided.")); - - // Check cNonce matches. - // If the token endpoint provides a c_nonce, we would like this: - // - stored in the access token - // - having the same validity as the access token. - Optional.ofNullable(vcIssuanceContext.getAuthResult().getToken().getNonce()) - .ifPresent( - cNonce -> { - Optional.ofNullable(proofPayload.getNonce()) - .filter(nonce -> Objects.equals(cNonce, nonce)) - .orElseThrow(() -> new VCIssuerException("Missing or wrong nonce value. Please provide nonce returned by the issuer if any.")); - - // We expect the expiration to be identical to the token expiration. We assume token expiration has been checked by AuthManager, - // So no_op - } - ); - - } - } diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/signing/SdJwtSigningServiceProviderFactory.java b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/signing/SdJwtSigningServiceProviderFactory.java index 6eaf687a7f69..0c62552650e4 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/signing/SdJwtSigningServiceProviderFactory.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/signing/SdJwtSigningServiceProviderFactory.java @@ -22,9 +22,10 @@ import org.keycloak.component.ComponentValidationException; import org.keycloak.models.KeycloakSession; import org.keycloak.models.RealmModel; -import org.keycloak.protocol.oid4vc.issuance.OffsetTimeProvider; import org.keycloak.protocol.oid4vc.issuance.VCIssuerException; +import org.keycloak.protocol.oid4vc.model.CredentialConfigId; import org.keycloak.protocol.oid4vc.model.Format; +import org.keycloak.protocol.oid4vc.model.VerifiableCredentialType; import org.keycloak.provider.ConfigurationValidationHelper; import org.keycloak.provider.ProviderConfigProperty; @@ -50,6 +51,9 @@ public VerifiableCredentialsSigningService create(KeycloakSession session, Compo String hashAlgorithm = model.get(SigningProperties.HASH_ALGORITHM.getKey()); Optional kid = Optional.ofNullable(model.get(SigningProperties.KID_HEADER.getKey())); int decoys = Integer.parseInt(model.get(SigningProperties.DECOYS.getKey())); + // Store vct as a conditional attribute of the signing service. + // But is vcConfigId is provided, vct must be provided as well. + String vct = model.get(SigningProperties.VC_VCT.getKey()); String vcConfigId = model.get(SigningProperties.VC_CONFIG_ID.getKey()); List visibleClaims = Optional.ofNullable(model.get(SigningProperties.VISIBLE_CLAIMS.getKey())) @@ -64,7 +68,8 @@ public VerifiableCredentialsSigningService create(KeycloakSession session, Compo .getAttribute(ISSUER_DID_REALM_ATTRIBUTE_KEY)) .orElseThrow(() -> new VCIssuerException("No issuerDid configured.")); - return new SdJwtSigningService(session, new ObjectMapper(), keyId, algorithmType, tokenType, hashAlgorithm, issuerDid, decoys, visibleClaims, new OffsetTimeProvider(), kid, vcConfigId); + return new SdJwtSigningService(session, new ObjectMapper(), keyId, algorithmType, tokenType, hashAlgorithm, + issuerDid, decoys, visibleClaims, kid, VerifiableCredentialType.from(vct), CredentialConfigId.from(vcConfigId)); } @Override @@ -80,6 +85,8 @@ public List getConfigProperties() { .property(SigningProperties.DECOYS.asConfigProperty()) .property(SigningProperties.KID_HEADER.asConfigProperty()) .property(SigningProperties.HASH_ALGORITHM.asConfigProperty()) + .property(SigningProperties.VC_VCT.asConfigProperty()) + .property(SigningProperties.VC_CONFIG_ID.asConfigProperty()) .build(); } @@ -90,11 +97,15 @@ public String getId() { @Override public void validateSpecificConfiguration(KeycloakSession session, RealmModel realm, ComponentModel model) throws ComponentValidationException { - ConfigurationValidationHelper.check(model) + ConfigurationValidationHelper helper = ConfigurationValidationHelper.check(model) .checkRequired(SigningProperties.HASH_ALGORITHM.asConfigProperty()) .checkRequired(SigningProperties.ALGORITHM_TYPE.asConfigProperty()) .checkRequired(SigningProperties.TOKEN_TYPE.asConfigProperty()) .checkInt(SigningProperties.DECOYS.asConfigProperty(), true); + // Make sure VCT is set if vc config id is set. + if(model.get(SigningProperties.VC_CONFIG_ID.getKey())!=null){ + helper.checkRequired(SigningProperties.VC_VCT.asConfigProperty()); + } } @Override diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/signing/SigningProperties.java b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/signing/SigningProperties.java index a83c62d53f48..49a2793cbdd2 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/signing/SigningProperties.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/signing/SigningProperties.java @@ -38,7 +38,8 @@ public enum SigningProperties { DECOYS("decoys", "Number of decoys to be added.", "The number of decoys to be added to the SD-JWT.", ProviderConfigProperty.STRING_TYPE, 0), HASH_ALGORITHM("hashAlgorithm", "Hash algorithm for SD-JWTs.", "The hash algorithm to be used for the SD-JWTs.", ProviderConfigProperty.STRING_TYPE, "sha-256"), VISIBLE_CLAIMS("visibleClaims", "Visible claims of the SD-JWT.", "List of claims to stay disclosed in the SD-JWT.", ProviderConfigProperty.MULTIVALUED_STRING_TYPE, null), - VC_CONFIG_ID("vcConfigId", "Credential configuration identifier", "The identifier of this credential configuration", ProviderConfigProperty.MULTIVALUED_STRING_TYPE, null); + VC_CONFIG_ID("vcConfigId", "Credential configuration identifier", "The identifier of this credential configuration", ProviderConfigProperty.STRING_TYPE, null), + VC_VCT("vct", "Credential Type", "The type of this credential", ProviderConfigProperty.STRING_TYPE, null); private final String key; private final String label; diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/signing/SigningService.java b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/signing/SigningService.java index f07391a594f2..94481f27626e 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/signing/SigningService.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/signing/SigningService.java @@ -54,7 +54,9 @@ protected SigningService(KeycloakSession keycloakSession, String keyId, Format f @Override public String locator() { - return format.name(); + // Future implementation might consider credential type or even cofiguration specific signers. + // See: org.keycloak.protocol.oid4vc.issuance.signing.SdJwtSigningService.locator + return VerifiableCredentialsSigningService.locator(format, null, null); } /** diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/signing/VerifiableCredentialsSigningService.java b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/signing/VerifiableCredentialsSigningService.java index 45d8ce45edb8..d015395ff7e4 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/signing/VerifiableCredentialsSigningService.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/signing/VerifiableCredentialsSigningService.java @@ -19,8 +19,9 @@ import org.keycloak.protocol.oid4vc.issuance.VCIssuanceContext; import org.keycloak.protocol.oid4vc.issuance.VCIssuerException; +import org.keycloak.protocol.oid4vc.model.CredentialConfigId; +import org.keycloak.protocol.oid4vc.model.VerifiableCredentialType; import org.keycloak.protocol.oid4vc.model.Format; -import org.keycloak.protocol.oid4vc.model.VerifiableCredential; import org.keycloak.provider.Provider; /** @@ -40,12 +41,25 @@ public interface VerifiableCredentialsSigningService extends Provider { /** * Returns the identifier of this service instance, can be either the format alone, - * or the combination between format and credential configuration id. + * or the combination between format, credential type and credential configuration id. * @return */ String locator(); - static String locator(Format format, String vcConfigId){ - return vcConfigId==null ? format.name() : format.name() + "/" + vcConfigId; + static final String LOCATION_SEPARATOR = "::"; + + /** + * We are forcing a structure with 3 components. format::type::configId. We assume format is always set, as + * implementation of this interface always know their format. + * + * @param format + * @param credentialType + * @param vcConfigId + * @return + */ + static String locator(Format format, VerifiableCredentialType credentialType, CredentialConfigId vcConfigId){ + return (format==null? "" : format.name()) + LOCATION_SEPARATOR + + (credentialType==null?"":credentialType.getValue()) + LOCATION_SEPARATOR + + (vcConfigId==null?"":vcConfigId.getValue()); } } diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/model/CredentialConfigId.java b/services/src/main/java/org/keycloak/protocol/oid4vc/model/CredentialConfigId.java index 68a067121c54..d93344b9f978 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/model/CredentialConfigId.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/model/CredentialConfigId.java @@ -1,7 +1,32 @@ +/* + * 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; +/** + * @author Francis Pouatcha + */ public class CredentialConfigId { private String value; + + public static CredentialConfigId from(String value) { + + return value==null ? null : new CredentialConfigId(value); + } + public CredentialConfigId(String value) { this.value = value; } diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/model/CredentialDefinition.java b/services/src/main/java/org/keycloak/protocol/oid4vc/model/CredentialDefinition.java index 12c4d9e797a7..05383eb564a0 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/model/CredentialDefinition.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/model/CredentialDefinition.java @@ -14,26 +14,20 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - package org.keycloak.protocol.oid4vc.model; -import com.fasterxml.jackson.annotation.JsonAnyGetter; -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 org.keycloak.util.JsonSerialization; -import java.net.URI; +import java.io.IOException; import java.util.ArrayList; -import java.util.Date; -import java.util.HashMap; import java.util.List; -import java.util.Map; /** - * Pojo to represent a VerifiableCredential for internal handling + * Pojo to represent a CredentialDefinition for internal handling * - * @author Stefan Wiedemann + * @author Francis Pouatcha */ @JsonInclude(JsonInclude.Include.NON_NULL) public class CredentialDefinition { @@ -41,24 +35,7 @@ public class CredentialDefinition { @JsonProperty("@context") private List context; private List type = new ArrayList<>(); - private URI issuer; - private Date issuanceDate; - private URI id; - private Date expirationDate; private CredentialSubject credentialSubject = new CredentialSubject(); - @JsonIgnore - private Map additionalProperties = new HashMap<>(); - - @JsonAnyGetter - public Map getAdditionalProperties() { - return additionalProperties; - } - - @JsonAnySetter - public CredentialDefinition setAdditionalProperties(String name, Object property) { - additionalProperties.put(name, property); - return this; - } public List getContext() { return context; @@ -78,42 +55,6 @@ public CredentialDefinition setType(List type) { return this; } - public URI getIssuer() { - return issuer; - } - - public CredentialDefinition setIssuer(URI issuer) { - this.issuer = issuer; - return this; - } - - public Date getIssuanceDate() { - return issuanceDate; - } - - public CredentialDefinition setIssuanceDate(Date issuanceDate) { - this.issuanceDate = issuanceDate; - return this; - } - - public URI getId() { - return id; - } - - public CredentialDefinition setId(URI id) { - this.id = id; - return this; - } - - public Date getExpirationDate() { - return expirationDate; - } - - public CredentialDefinition setExpirationDate(Date expirationDate) { - this.expirationDate = expirationDate; - return this; - } - public CredentialSubject getCredentialSubject() { return credentialSubject; } @@ -123,8 +64,19 @@ public CredentialDefinition setCredentialSubject(CredentialSubject credentialSub return this; } - public CredentialDefinition setAdditionalProperties(Map additionalProperties) { - this.additionalProperties = additionalProperties; - return this; + public String toJsonString() { + try { + return JsonSerialization.writeValueAsString(this); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + public static CredentialDefinition fromJsonString(String jsonString) { + try { + return JsonSerialization.readValue(jsonString, CredentialDefinition.class); + } catch (IOException e) { + throw new RuntimeException(e); + } } } diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/model/CredentialRequest.java b/services/src/main/java/org/keycloak/protocol/oid4vc/model/CredentialRequest.java index c0db0c18a366..1a75fbd91b11 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/model/CredentialRequest.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/model/CredentialRequest.java @@ -36,6 +36,15 @@ public class CredentialRequest { private Proof proof; + // I have the choice of either defining format specific fields here, or adding a generic structure, + // opening room for spamming the server. I will prefer having format specific fields. + private String vct; + + // See: https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#name-format-identifier-3 + @JsonProperty("credential_definition") + private CredentialDefinition credentialDefinition; + + public Format getFormat() { return format; } @@ -62,4 +71,22 @@ public CredentialRequest setProof(Proof proof) { this.proof = proof; return this; } -} \ No newline at end of file + + public String getVct() { + return vct; + } + + public CredentialRequest setVct(String vct) { + this.vct = vct; + return this; + } + + public CredentialDefinition getCredentialDefinition() { + return credentialDefinition; + } + + public CredentialRequest setCredentialDefinition(CredentialDefinition credentialDefinition) { + this.credentialDefinition = credentialDefinition; + return this; + } +} diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/model/ErrorType.java b/services/src/main/java/org/keycloak/protocol/oid4vc/model/ErrorType.java index 4bb5cf4b56ef..5cb13c3644a9 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/model/ErrorType.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/model/ErrorType.java @@ -31,7 +31,9 @@ public enum ErrorType { UNSUPPORTED_CREDENTIAL_TYPE("unsupported_credential_type"), UNSUPPORTED_CREDENTIAL_FORMAT("unsupported_credential_format"), INVALID_PROOF("invalid_proof"), - INVALID_ENCRYPTION_PARAMETER("invalid_encryption_parameters"); + INVALID_ENCRYPTION_PARAMETER("invalid_encryption_parameters"), + MISSING_CREDENTIAL_CONFIG("missing_credential_config"), + MISSING_CREDENTIAL_CONFIG_AND_FORMAT("missing_credential_config_format"); private final String value; @@ -42,4 +44,4 @@ public enum ErrorType { public String getValue() { return value; } -} \ No newline at end of file +} 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 bef34c1b88a4..c264ed3cd21d 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 @@ -56,6 +56,8 @@ public class SupportedCredentialConfiguration { private static final String CLAIMS_KEY = "claims"; @JsonIgnore private static final String VERIFIABLE_CREDENTIAL_TYPE_KEY = "vct"; + @JsonIgnore + private static final String CREDENTIAL_DEFINITION_KEY = "credential_definition"; private String id; @JsonProperty(FORMAT_KEY) @@ -79,6 +81,9 @@ public class SupportedCredentialConfiguration { @JsonProperty(VERIFIABLE_CREDENTIAL_TYPE_KEY) private String vct; + @JsonProperty(CREDENTIAL_DEFINITION_KEY) + private CredentialDefinition credentialDefinition; + @JsonProperty(PROOF_TYPES_SUPPORTED_KEY) private ProofTypesSupported proofTypesSupported; @@ -89,6 +94,30 @@ public Format getFormat() { return format; } + /** + * 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 + * + * 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 + * + * @return + */ + public VerifiableCredentialType deriveType() { + if(format==Format.SD_JWT_VC) { + return VerifiableCredentialType.from(vct); + } + return null; + } + + public CredentialConfigId deriveConfiId() { + return CredentialConfigId.from(id); + } + public SupportedCredentialConfiguration setFormat(Format format) { this.format = format; return this; @@ -169,6 +198,15 @@ public SupportedCredentialConfiguration setVct(String vct) { return this; } + public CredentialDefinition getCredentialDefinition() { + return credentialDefinition; + } + + public SupportedCredentialConfiguration setCredentialDefinition(CredentialDefinition credentialDefinition) { + this.credentialDefinition = credentialDefinition; + return this; + } + public ProofTypesSupported getProofTypesSupported() { return proofTypesSupported; } @@ -190,6 +228,7 @@ public Map toDotNotation() { Optional.ofNullable(cryptographicSuitesSupported).ifPresent(types -> dotNotation.put(id + DOT_SEPARATOR + CREDENTIAL_SIGNING_ALG_VALUES_SUPPORTED_KEY, String.join(",", credentialSigningAlgValuesSupported))); Optional.ofNullable(claims).ifPresent(c -> dotNotation.put(id + DOT_SEPARATOR + CLAIMS_KEY, c.toJsonString())); + Optional.ofNullable(credentialDefinition).ifPresent(cdef -> dotNotation.put(id + DOT_SEPARATOR + CREDENTIAL_DEFINITION_KEY, cdef.toJsonString())); Optional.ofNullable(display) .ifPresent(d -> d.stream() @@ -223,6 +262,9 @@ public static SupportedCredentialConfiguration fromDotNotation(String credential Optional.ofNullable(dotNotated.get(credentialId + DOT_SEPARATOR + CLAIMS_KEY)) .map(Claims::fromJsonString) .ifPresent(supportedCredentialConfiguration::setClaims); + Optional.ofNullable(dotNotated.get(credentialId + DOT_SEPARATOR + CREDENTIAL_DEFINITION_KEY)) + .map(CredentialDefinition::fromJsonString) + .ifPresent(supportedCredentialConfiguration::setCredentialDefinition); String displayKeyPrefix = credentialId + DOT_SEPARATOR + DISPLAY_KEY + DOT_SEPARATOR; List displayList = dotNotated.entrySet().stream() @@ -247,11 +289,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) && format == that.format && Objects.equals(scope, that.scope) && Objects.equals(cryptographicBindingMethodsSupported, that.cryptographicBindingMethodsSupported) && Objects.equals(cryptographicSuitesSupported, that.cryptographicSuitesSupported) && Objects.equals(credentialSigningAlgValuesSupported, that.credentialSigningAlgValuesSupported) && Objects.equals(display, that.display) && Objects.equals(vct, that.vct) && Objects.equals(proofTypesSupported, that.proofTypesSupported) && Objects.equals(claims, that.claims); + return Objects.equals(id, that.id) && format == that.format && Objects.equals(scope, that.scope) && Objects.equals(cryptographicBindingMethodsSupported, that.cryptographicBindingMethodsSupported) && Objects.equals(cryptographicSuitesSupported, that.cryptographicSuitesSupported) && Objects.equals(credentialSigningAlgValuesSupported, that.credentialSigningAlgValuesSupported) && Objects.equals(display, that.display) && Objects.equals(vct, that.vct) && Objects.equals(credentialDefinition, that.credentialDefinition) && Objects.equals(proofTypesSupported, that.proofTypesSupported) && Objects.equals(claims, that.claims); } @Override public int hashCode() { - return Objects.hash(id, format, scope, cryptographicBindingMethodsSupported, cryptographicSuitesSupported, credentialSigningAlgValuesSupported, display, vct, proofTypesSupported, claims); + return Objects.hash(id, format, scope, cryptographicBindingMethodsSupported, cryptographicSuitesSupported, credentialSigningAlgValuesSupported, display, vct, credentialDefinition, proofTypesSupported, claims); } } diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/model/VerifiableCredentialType.java b/services/src/main/java/org/keycloak/protocol/oid4vc/model/VerifiableCredentialType.java index 9e9c3d910c8b..53b963606efb 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/model/VerifiableCredentialType.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/model/VerifiableCredentialType.java @@ -23,7 +23,7 @@ public class VerifiableCredentialType { private String value; public static VerifiableCredentialType from(String value){ - return new VerifiableCredentialType(value); + return value==null? null : new VerifiableCredentialType(value); } public VerifiableCredentialType(String value) { this.value = value; diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCIssuerEndpointTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCIssuerEndpointTest.java index 83b3288e08cd..700f642ac65f 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCIssuerEndpointTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCIssuerEndpointTest.java @@ -18,28 +18,20 @@ package org.keycloak.testsuite.oid4vc.issuance.signing; import com.fasterxml.jackson.databind.ObjectMapper; -import jakarta.ws.rs.BadRequestException; -import jakarta.ws.rs.core.Response; -import jakarta.ws.rs.core.HttpHeaders; -import jakarta.ws.rs.core.UriBuilder; import jakarta.ws.rs.client.Client; -import jakarta.ws.rs.client.Entity; import jakarta.ws.rs.client.WebTarget; +import jakarta.ws.rs.core.HttpHeaders; +import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.core.UriBuilder; import org.apache.commons.io.IOUtils; import org.apache.http.HttpStatus; -import org.apache.http.NameValuePair; -import org.apache.http.client.entity.UrlEncodedFormEntity; import org.apache.http.client.methods.CloseableHttpResponse; -import org.apache.http.client.methods.HttpGet; import org.apache.http.client.methods.HttpPost; import org.apache.http.entity.ContentType; import org.apache.http.entity.StringEntity; import org.apache.http.impl.client.CloseableHttpClient; import org.apache.http.impl.client.HttpClientBuilder; -import org.apache.http.message.BasicNameValuePair; import org.junit.Before; -import org.junit.Test; -import org.keycloak.OAuth2Constants; import org.keycloak.TokenVerifier; import org.keycloak.admin.client.resource.ClientResource; import org.keycloak.admin.client.resource.RealmResource; @@ -51,27 +43,21 @@ import org.keycloak.crypto.Algorithm; import org.keycloak.models.KeycloakSession; import org.keycloak.models.UserSessionModel; -import org.keycloak.protocol.oid4vc.OID4VCLoginProtocolFactory; import org.keycloak.protocol.oid4vc.issuance.OID4VCIssuerEndpoint; import org.keycloak.protocol.oid4vc.issuance.OID4VCIssuerWellKnownProviderFactory; import org.keycloak.protocol.oid4vc.issuance.TimeProvider; import org.keycloak.protocol.oid4vc.issuance.signing.JwtSigningService; import org.keycloak.protocol.oid4vc.model.CredentialIssuer; -import org.keycloak.protocol.oid4vc.model.CredentialOfferURI; import org.keycloak.protocol.oid4vc.model.CredentialRequest; import org.keycloak.protocol.oid4vc.model.CredentialResponse; -import org.keycloak.protocol.oid4vc.model.CredentialsOffer; import org.keycloak.protocol.oid4vc.model.Format; -import org.keycloak.protocol.oid4vc.model.OfferUriType; -import org.keycloak.protocol.oid4vc.model.PreAuthorizedGrant; import org.keycloak.protocol.oid4vc.model.SupportedCredentialConfiguration; import org.keycloak.protocol.oid4vc.model.VerifiableCredential; import org.keycloak.protocol.oidc.OIDCLoginProtocol; -import org.keycloak.protocol.oidc.grants.PreAuthorizedCodeGrantTypeFactory; -import org.keycloak.protocol.oidc.representations.OIDCConfigurationRepresentation; import org.keycloak.representations.JsonWebToken; import org.keycloak.representations.idm.ClientRepresentation; import org.keycloak.representations.idm.ClientScopeRepresentation; +import org.keycloak.representations.idm.ComponentExportRepresentation; import org.keycloak.representations.idm.RealmRepresentation; import org.keycloak.services.managers.AppAuthManager; import org.keycloak.services.managers.AuthenticationManager; @@ -86,25 +72,24 @@ import java.io.IOException; import java.net.URI; import java.nio.charset.StandardCharsets; -import java.util.LinkedList; import java.util.List; import java.util.Map; -import java.util.Optional; import java.util.function.BiFunction; import java.util.function.Consumer; -import java.util.function.Function; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertTrue; -import static org.junit.Assert.fail; -public class OID4VCIssuerEndpointTest extends OID4VCTest { +/** + * Moved test to subclass. so we can reuse initialization code. + */ +public abstract class OID4VCIssuerEndpointTest extends OID4VCTest { - private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); - private static final TimeProvider TIME_PROVIDER = new OID4VCTest.StaticTimeProvider(1000); - private CloseableHttpClient httpClient; + protected static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + protected static final TimeProvider TIME_PROVIDER = new OID4VCTest.StaticTimeProvider(1000); + protected CloseableHttpClient httpClient; @Before @@ -114,345 +99,11 @@ public void setup() { } - // ----- getCredentialOfferUri - - @Test(expected = BadRequestException.class) - public void testGetCredentialOfferUriUnsupportedCredential() throws Throwable { - String token = getBearerToken(oauth); - withCausePropagation(() -> testingClient.server(TEST_REALM_NAME) - .run((session -> { - AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session); - authenticator.setTokenString(token); - - OID4VCIssuerEndpoint oid4VCIssuerEndpoint = prepareIssuerEndpoint(session, authenticator); - oid4VCIssuerEndpoint.getCredentialOfferURI("inexistent-id", OfferUriType.URI, 0, 0); - }))); - - } - - @Test(expected = BadRequestException.class) - public void testGetCredentialOfferUriUnauthorized() throws Throwable { - withCausePropagation(() -> testingClient.server(TEST_REALM_NAME) - .run((session -> { - AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session); - authenticator.setTokenString(null); - OID4VCIssuerEndpoint oid4VCIssuerEndpoint = prepareIssuerEndpoint(session, authenticator); - oid4VCIssuerEndpoint.getCredentialOfferURI("test-credential", OfferUriType.URI, 0, 0); - }))); - } - - @Test(expected = BadRequestException.class) - public void testGetCredentialOfferUriInvalidToken() throws Throwable { - withCausePropagation(() -> testingClient.server(TEST_REALM_NAME) - .run((session -> { - AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session); - authenticator.setTokenString("invalid-token"); - OID4VCIssuerEndpoint oid4VCIssuerEndpoint = prepareIssuerEndpoint(session, authenticator); - oid4VCIssuerEndpoint.getCredentialOfferURI("test-credential", OfferUriType.URI, 0, 0); - }))); - } - - @Test - public void testGetCredentialOfferURI() { - String token = getBearerToken(oauth); - testingClient - .server(TEST_REALM_NAME) - .run((session) -> { - try { - AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session); - authenticator.setTokenString(token); - OID4VCIssuerEndpoint oid4VCIssuerEndpoint = prepareIssuerEndpoint(session, authenticator); - - Response response = oid4VCIssuerEndpoint.getCredentialOfferURI("test-credential", OfferUriType.URI, 0, 0); - - assertEquals("An offer uri should have been returned.", HttpStatus.SC_OK, response.getStatus()); - CredentialOfferURI credentialOfferURI = new ObjectMapper().convertValue(response.getEntity(), CredentialOfferURI.class); - assertNotNull("A nonce should be included.", credentialOfferURI.getNonce()); - assertNotNull("The issuer uri should be provided.", credentialOfferURI.getIssuer()); - } catch (Exception e) { - throw new RuntimeException(e); - } - }); - - } - - private static String getBearerToken(OAuthClient oAuthClient) { + protected String getBearerToken(OAuthClient oAuthClient) { OAuthClient.AuthorizationEndpointResponse authorizationEndpointResponse = oAuthClient.doLogin("john", "password"); return oAuthClient.doAccessTokenRequest(authorizationEndpointResponse.getCode(), "password").getAccessToken(); } - // ----- getCredentialOffer - - @Test(expected = BadRequestException.class) - public void testGetCredentialOfferUnauthorized() throws Throwable { - withCausePropagation(() -> { - testingClient - .server(TEST_REALM_NAME) - .run((session) -> { - AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session); - authenticator.setTokenString(null); - OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator); - issuerEndpoint.getCredentialOffer("nonce"); - }); - }); - } - - @Test(expected = BadRequestException.class) - public void testGetCredentialOfferWithoutNonce() throws Throwable { - String token = getBearerToken(oauth); - withCausePropagation(() -> { - testingClient - .server(TEST_REALM_NAME) - .run((session -> { - AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session); - authenticator.setTokenString(token); - OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator); - issuerEndpoint.getCredentialOffer(null); - })); - }); - } - - @Test(expected = BadRequestException.class) - public void testGetCredentialOfferWithoutAPreparedOffer() throws Throwable { - String token = getBearerToken(oauth); - withCausePropagation(() -> { - testingClient - .server(TEST_REALM_NAME) - .run((session -> { - AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session); - authenticator.setTokenString(token); - OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator); - issuerEndpoint.getCredentialOffer("unpreparedNonce"); - })); - }); - } - - @Test(expected = BadRequestException.class) - public void testGetCredentialOfferWithABrokenNote() throws Throwable { - String token = getBearerToken(oauth); - withCausePropagation(() -> { - testingClient - .server(TEST_REALM_NAME) - .run((session -> { - AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session); - authenticator.setTokenString(token); - String nonce = prepareNonce(authenticator, "invalidNote"); - OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator); - issuerEndpoint.getCredentialOffer(nonce); - })); - }); - } - - @Test - public void testGetCredentialOffer() { - String token = getBearerToken(oauth); - String rootURL = suiteContext.getAuthServerInfo().getContextRoot().toString(); - testingClient - .server(TEST_REALM_NAME) - .run((session) -> { - AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session); - authenticator.setTokenString(token); - - SupportedCredentialConfiguration supportedCredentialConfiguration = new SupportedCredentialConfiguration() - .setId("test-credential") - .setScope("VerifiableCredential") - .setFormat(Format.JWT_VC); - String nonce = prepareNonce(authenticator, OBJECT_MAPPER.writeValueAsString(supportedCredentialConfiguration)); - - OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator); - Response credentialOfferResponse = issuerEndpoint.getCredentialOffer(nonce); - assertEquals("The offer should have been returned.", HttpStatus.SC_OK, credentialOfferResponse.getStatus()); - Object credentialOfferEntity = credentialOfferResponse.getEntity(); - assertNotNull("An actual offer should be in the response.", credentialOfferEntity); - - CredentialsOffer credentialsOffer = OBJECT_MAPPER.convertValue(credentialOfferEntity, CredentialsOffer.class); - assertNotNull("Credentials should have been offered.", credentialsOffer.getCredentialConfigurationIds()); - assertFalse("Credentials should have been offered.", credentialsOffer.getCredentialConfigurationIds().isEmpty()); - List supportedCredentials = credentialsOffer.getCredentialConfigurationIds(); - assertEquals("Exactly one credential should have been returned.", 1, supportedCredentials.size()); - String offeredCredentialId = supportedCredentials.get(0); - assertEquals("The credential should be as defined in the note.", supportedCredentialConfiguration.getId(), offeredCredentialId); - - PreAuthorizedGrant grant = credentialsOffer.getGrants(); - assertNotNull("The grant should be included.", grant); - assertNotNull("The grant should contain the pre-authorized code.", grant.getPreAuthorizedCode()); - assertNotNull("The actual pre-authorized code should be included.", grant - .getPreAuthorizedCode() - .getPreAuthorizedCode()); - - assertEquals("The correct issuer should be included.", rootURL + "/auth/realms/" + TEST_REALM_NAME, credentialsOffer.getCredentialIssuer()); - }); - } - - // ----- requestCredential - - @Test(expected = BadRequestException.class) - public void testRequestCredentialUnauthorized() throws Throwable { - withCausePropagation(() -> { - testingClient - .server(TEST_REALM_NAME) - .run((session -> { - AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session); - authenticator.setTokenString(null); - OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator); - issuerEndpoint.requestCredential(new CredentialRequest() - .setFormat(Format.JWT_VC) - .setCredentialIdentifier("test-credential")); - })); - }); - } - - @Test(expected = BadRequestException.class) - public void testRequestCredentialInvalidToken() throws Throwable { - withCausePropagation(() -> { - testingClient - .server(TEST_REALM_NAME) - .run((session -> { - AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session); - authenticator.setTokenString("token"); - OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator); - issuerEndpoint.requestCredential(new CredentialRequest() - .setFormat(Format.JWT_VC) - .setCredentialIdentifier("test-credential")); - })); - }); - } - - @Test(expected = BadRequestException.class) - public void testRequestCredentialUnsupportedFormat() throws Throwable { - String token = getBearerToken(oauth); - withCausePropagation(() -> { - testingClient - .server(TEST_REALM_NAME) - .run((session -> { - AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session); - authenticator.setTokenString(token); - OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator); - issuerEndpoint.requestCredential(new CredentialRequest() - .setFormat(Format.SD_JWT_VC) - .setCredentialIdentifier("test-credential")); - })); - }); - } - - @Test(expected = BadRequestException.class) - public void testRequestCredentialUnsupportedCredential() throws Throwable { - String token = getBearerToken(oauth); - withCausePropagation(() -> { - testingClient - .server(TEST_REALM_NAME) - .run((session -> { - AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session); - authenticator.setTokenString(token); - OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator); - issuerEndpoint.requestCredential(new CredentialRequest() - .setFormat(Format.JWT_VC) - .setCredentialIdentifier("no-such-credential")); - })); - }); - } - - @Test - public void testRequestCredential() { - String token = getBearerToken(oauth); - ObjectMapper objectMapper = new ObjectMapper(); - testingClient - .server(TEST_REALM_NAME) - .run((session -> { - AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session); - authenticator.setTokenString(token); - OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator); - CredentialRequest credentialRequest = new CredentialRequest() - .setFormat(Format.JWT_VC) - .setCredentialIdentifier("test-credential"); - Response credentialResponse = issuerEndpoint.requestCredential(credentialRequest); - assertEquals("The credential request should be answered successfully.", HttpStatus.SC_OK, credentialResponse.getStatus()); - assertNotNull("A credential should be responded.", credentialResponse.getEntity()); - CredentialResponse credentialResponseVO = OBJECT_MAPPER.convertValue(credentialResponse.getEntity(), CredentialResponse.class); - JsonWebToken jsonWebToken = TokenVerifier.create((String) credentialResponseVO.getCredential(), JsonWebToken.class).getToken(); - - assertNotNull("A valid credential string should have been responded", jsonWebToken); - assertNotNull("The credentials should be included at the vc-claim.", jsonWebToken.getOtherClaims().get("vc")); - VerifiableCredential credential = objectMapper.convertValue(jsonWebToken.getOtherClaims().get("vc"), VerifiableCredential.class); - assertTrue("The static claim should be set.", credential.getCredentialSubject().getClaims().containsKey("VerifiableCredential")); - assertFalse("Only mappers supported for the requested type should have been evaluated.", credential.getCredentialSubject().getClaims().containsKey("AnotherCredentialType")); - })); - } - - // Tests the complete flow from - // 1. Retrieving the credential-offer-uri - // 2. Using the uri to get the actual credential offer - // 3. Get the issuer metadata - // 4. Get the openid-configuration - // 5. Get an access token for the pre-authorized code - // 6. Get the credential - @Test - public void testCredentialIssuance() throws Exception { - - String token = getBearerToken(oauth); - - // 1. Retrieving the credential-offer-uri - HttpGet getCredentialOfferURI = new HttpGet(getBasePath(TEST_REALM_NAME) + "credential-offer-uri?credential_configuration_id=test-credential"); - getCredentialOfferURI.addHeader(HttpHeaders.AUTHORIZATION, "Bearer " + token); - CloseableHttpResponse credentialOfferURIResponse = httpClient.execute(getCredentialOfferURI); - - assertEquals("A valid offer uri should be returned", HttpStatus.SC_OK, credentialOfferURIResponse.getStatusLine().getStatusCode()); - String s = IOUtils.toString(credentialOfferURIResponse.getEntity().getContent(), StandardCharsets.UTF_8); - CredentialOfferURI credentialOfferURI = JsonSerialization.readValue(s, CredentialOfferURI.class); - - // 2. Using the uri to get the actual credential offer - HttpGet getCredentialOffer = new HttpGet(credentialOfferURI.getIssuer() + "/" + credentialOfferURI.getNonce()); - getCredentialOffer.addHeader(HttpHeaders.AUTHORIZATION, "Bearer " + token); - CloseableHttpResponse credentialOfferResponse = httpClient.execute(getCredentialOffer); - - assertEquals("A valid offer should be returned", HttpStatus.SC_OK, credentialOfferResponse.getStatusLine().getStatusCode()); - s = IOUtils.toString(credentialOfferResponse.getEntity().getContent(), StandardCharsets.UTF_8); - CredentialsOffer credentialsOffer = JsonSerialization.readValue(s, CredentialsOffer.class); - - // 3. Get the issuer metadata - HttpGet getIssuerMetadata = new HttpGet(credentialsOffer.getCredentialIssuer() + "/.well-known/openid-credential-issuer"); - CloseableHttpResponse issuerMetadataResponse = httpClient.execute(getIssuerMetadata); - assertEquals(HttpStatus.SC_OK, issuerMetadataResponse.getStatusLine().getStatusCode()); - s = IOUtils.toString(issuerMetadataResponse.getEntity().getContent(), StandardCharsets.UTF_8); - CredentialIssuer credentialIssuer = JsonSerialization.readValue(s, CredentialIssuer.class); - - assertEquals("We only expect one authorization server.", 1, credentialIssuer.getAuthorizationServers().size()); - - // 4. Get the openid-configuration - HttpGet getOpenidConfiguration = new HttpGet(credentialIssuer.getAuthorizationServers().get(0) + "/.well-known/openid-configuration"); - CloseableHttpResponse openidConfigResponse = httpClient.execute(getOpenidConfiguration); - assertEquals(HttpStatus.SC_OK, openidConfigResponse.getStatusLine().getStatusCode()); - s = IOUtils.toString(openidConfigResponse.getEntity().getContent(), StandardCharsets.UTF_8); - OIDCConfigurationRepresentation openidConfig = JsonSerialization.readValue(s, OIDCConfigurationRepresentation.class); - - assertNotNull("A token endpoint should be included.", openidConfig.getTokenEndpoint()); - assertTrue("The pre-authorized code should be supported.", openidConfig.getGrantTypesSupported().contains(PreAuthorizedCodeGrantTypeFactory.GRANT_TYPE)); - - // 5. Get an access token for the pre-authorized code - HttpPost postPreAuthorizedCode = new HttpPost(openidConfig.getTokenEndpoint()); - List parameters = new LinkedList<>(); - parameters.add(new BasicNameValuePair(OAuth2Constants.GRANT_TYPE, PreAuthorizedCodeGrantTypeFactory.GRANT_TYPE)); - parameters.add(new BasicNameValuePair("pre-authorized_code", credentialsOffer.getGrants().getPreAuthorizedCode().getPreAuthorizedCode())); - UrlEncodedFormEntity formEntity = new UrlEncodedFormEntity(parameters, "UTF-8"); - postPreAuthorizedCode.setEntity(formEntity); - OAuthClient.AccessTokenResponse accessTokenResponse = new OAuthClient.AccessTokenResponse(httpClient.execute(postPreAuthorizedCode)); - assertEquals(HttpStatus.SC_OK, accessTokenResponse.getStatusCode()); - String theToken = accessTokenResponse.getAccessToken(); - - // 6. Get the credential - credentialsOffer.getCredentialConfigurationIds().stream() - .map(offeredCredentialId -> credentialIssuer.getCredentialsSupported().get(offeredCredentialId)) - .forEach(supportedCredential -> { - try { - requestOffer(theToken, credentialIssuer.getCredentialEndpoint(), supportedCredential); - } catch (IOException e) { - fail("Was not able to get the credential."); - } catch (VerificationException e) { - throw new RuntimeException(e); - } - }); - } - private ClientResource findClientByClientId(RealmResource realm, String clientId) { for (ClientRepresentation c : realm.clients().findAll()) { if (clientId.equals(c.getClientId())) { @@ -487,11 +138,11 @@ private void addCredentialConfigurationIdToClient(String clientId, String creden "vc." + credentialConfigurationId + ".scope", scope)); clientRepresentation.setProtocolMappers( List.of( - getRoleMapper(clientId), - getEmailMapper(), - getIdMapper(), - getStaticClaimMapper(scope), - getStaticClaimMapper("AnotherCredentialType") + getRoleMapper(clientId, "VerifiableCredential"), + getUserAttributeMapper("email", "email", "VerifiableCredential"), + getIdMapper("VerifiableCredential"), + getStaticClaimMapper(scope, "VerifiableCredential"), + getStaticClaimMapper("AnotherCredentialType", "VerifiableCredential") ) ); @@ -545,8 +196,8 @@ private void testCredentialIssuanceWithAuthZCodeFlow(Consumer f, Consumer> c) throws Exception { - testCredentialIssuanceWithAuthZCodeFlow(m->{ + protected void testCredentialIssuanceWithAuthZCodeFlow(BiFunction f, Consumer> c) throws Exception { + testCredentialIssuanceWithAuthZCodeFlow(m -> { String testClientId = m.get("clientId"); String testScope = m.get("scope"); String testFormat = m.get("format"); @@ -585,70 +236,17 @@ private void testCredentialIssuanceWithAuthZCodeFlow(BiFunction getBearerToken(oauth.clientId(testClientId).openid(false).scope(testScope)), - m -> { - String accessToken = (String)m.get("accessToken"); - WebTarget credentialTarget = (WebTarget)m.get("credentialTarget"); - CredentialRequest credentialRequest = (CredentialRequest)m.get("credentialRequest"); - - try (Response response = credentialTarget.request().header(HttpHeaders.AUTHORIZATION, "bearer " + accessToken).post(Entity.json(credentialRequest))) { - CredentialResponse credentialResponse = JsonSerialization.readValue(response.readEntity(String.class),CredentialResponse.class); - - assertEquals(200, response.getStatus()); - JsonWebToken jsonWebToken = TokenVerifier.create((String) credentialResponse.getCredential(), JsonWebToken.class).getToken(); - assertEquals("did:web:test.org", jsonWebToken.getIssuer()); - - VerifiableCredential credential = new ObjectMapper().convertValue(jsonWebToken.getOtherClaims().get("vc"), VerifiableCredential.class); - assertEquals(TEST_TYPES, credential.getType()); - assertEquals(TEST_DID, credential.getIssuer()); - assertEquals("john@email.cz", credential.getCredentialSubject().getClaims().get("email")); - } catch (IOException | VerificationException e) { - Assert.fail(); - } - }); - } - - @Test - public void testCredentialIssuanceWithAuthZCodeWithScopeUnmatched() throws Exception { - testCredentialIssuanceWithAuthZCodeFlow((testClientId, testScope) -> getBearerToken(oauth.clientId(testClientId).openid(false).scope("email")), // set registered different scope - m -> { - String accessToken = (String)m.get("accessToken"); - WebTarget credentialTarget = (WebTarget)m.get("credentialTarget"); - CredentialRequest credentialRequest = (CredentialRequest)m.get("credentialRequest"); - - try (Response response = credentialTarget.request().header(HttpHeaders.AUTHORIZATION, "bearer " + accessToken).post(Entity.json(credentialRequest))) { - assertEquals(400, response.getStatus()); - } - }); - } - - @Test - public void testCredentialIssuanceWithAuthZCodeSWithoutScope() throws Exception { - testCredentialIssuanceWithAuthZCodeFlow((testClientId, testScope) -> getBearerToken(oauth.clientId(testClientId).openid(false).scope(null)), // no scope - m -> { - String accessToken = (String)m.get("accessToken"); - WebTarget credentialTarget = (WebTarget)m.get("credentialTarget"); - CredentialRequest credentialRequest = (CredentialRequest)m.get("credentialRequest"); - - try (Response response = credentialTarget.request().header(HttpHeaders.AUTHORIZATION, "bearer " + accessToken).post(Entity.json(credentialRequest))) { - assertEquals(400, response.getStatus()); - } - }); - } - - private static String prepareNonce(AppAuthManager.BearerTokenAuthenticator authenticator, String note) { + protected static String prepareNonce(AppAuthManager.BearerTokenAuthenticator authenticator, String note) { String nonce = SecretGenerator.getInstance().randomString(); AuthenticationManager.AuthResult authResult = authenticator.authenticate(); UserSessionModel userSessionModel = authResult.getSession(); @@ -656,7 +254,7 @@ private static String prepareNonce(AppAuthManager.BearerTokenAuthenticator authe return nonce; } - private static OID4VCIssuerEndpoint prepareIssuerEndpoint(KeycloakSession session, AppAuthManager.BearerTokenAuthenticator authenticator) { + protected static OID4VCIssuerEndpoint prepareIssuerEndpoint(KeycloakSession session, AppAuthManager.BearerTokenAuthenticator authenticator) { JwtSigningService jwtSigningService = new JwtSigningService( session, getKeyFromSession(session).getKid(), @@ -667,7 +265,7 @@ private static OID4VCIssuerEndpoint prepareIssuerEndpoint(KeycloakSession sessio return new OID4VCIssuerEndpoint( session, "did:web:issuer.org", - Map.of(Format.JWT_VC.name(), jwtSigningService), + Map.of(jwtSigningService.locator(), jwtSigningService), authenticator, new ObjectMapper(), TIME_PROVIDER, @@ -675,15 +273,15 @@ private static OID4VCIssuerEndpoint prepareIssuerEndpoint(KeycloakSession sessio true); } - private String getBasePath(String realm) { + protected String getBasePath(String realm) { return getRealmPath(realm) + "/protocol/oid4vc/"; } - private String getRealmPath(String realm){ + private String getRealmPath(String realm) { return suiteContext.getAuthServerInfo().getContextRoot().toString() + "/auth/realms/" + realm; } - private void requestOffer(String token, String credentialEndpoint, SupportedCredentialConfiguration offeredCredential) throws IOException, VerificationException { + protected void requestOffer(String token, String credentialEndpoint, SupportedCredentialConfiguration offeredCredential, CredentialResponseHandler responseHandler) throws IOException, VerificationException { CredentialRequest request = new CredentialRequest(); request.setFormat(offeredCredential.getFormat()); request.setCredentialIdentifier(offeredCredential.getId()); @@ -698,26 +296,19 @@ private void requestOffer(String token, String credentialEndpoint, SupportedCred String s = IOUtils.toString(credentialRequestResponse.getEntity().getContent(), StandardCharsets.UTF_8); CredentialResponse credentialResponse = JsonSerialization.readValue(s, CredentialResponse.class); - assertNotNull("The credential should have been responded.", credentialResponse.getCredential()); - JsonWebToken jsonWebToken = TokenVerifier.create((String) credentialResponse.getCredential(), JsonWebToken.class).getToken(); - assertEquals("did:web:test.org", jsonWebToken.getIssuer()); - VerifiableCredential credential = new ObjectMapper().convertValue(jsonWebToken.getOtherClaims().get("vc"), VerifiableCredential.class); - assertEquals(List.of("VerifiableCredential"), credential.getType()); - assertEquals(URI.create("did:web:test.org"), credential.getIssuer()); - assertEquals("john@email.cz", credential.getCredentialSubject().getClaims().get("email")); - assertTrue("The static claim should be set.", credential.getCredentialSubject().getClaims().containsKey("VerifiableCredential")); - assertFalse("Only mappers supported for the requested type should have been evaluated.", credential.getCredentialSubject().getClaims().containsKey("AnotherCredentialType")); + // Use response handler to customize checks based on formats. + responseHandler.handleCredentialResponse(credentialResponse); } @Override public void configureTestRealm(RealmRepresentation testRealm) { if (testRealm.getComponents() != null) { - testRealm.getComponents().add("org.keycloak.keys.KeyProvider", getRsaKeyProvider(RSA_KEY)); - testRealm.getComponents().add("org.keycloak.protocol.oid4vc.issuance.signing.VerifiableCredentialsSigningService", getJwtSigningProvider(RSA_KEY)); + testRealm.getComponents().add("org.keycloak.keys.KeyProvider", getKeyProvider()); + testRealm.getComponents().addAll("org.keycloak.protocol.oid4vc.issuance.signing.VerifiableCredentialsSigningService", getSigningProviders()); } else { testRealm.setComponents(new MultivaluedHashMap<>( - Map.of("org.keycloak.keys.KeyProvider", List.of(getRsaKeyProvider(RSA_KEY)), - "org.keycloak.protocol.oid4vc.issuance.signing.VerifiableCredentialsSigningService", List.of(getJwtSigningProvider(RSA_KEY)) + Map.of("org.keycloak.keys.KeyProvider", List.of(getKeyProvider()), + "org.keycloak.protocol.oid4vc.issuance.signing.VerifiableCredentialsSigningService", getSigningProviders() ))); } ClientRepresentation clientRepresentation = getTestClient("did:web:test.org"); @@ -745,7 +336,7 @@ public void configureTestRealm(RealmRepresentation testRealm) { } } - private void withCausePropagation(Runnable r) throws Throwable { + protected void withCausePropagation(Runnable r) throws Throwable { try { r.run(); } catch (Exception e) { @@ -756,5 +347,25 @@ private void withCausePropagation(Runnable r) throws Throwable { } } -} + protected ComponentExportRepresentation getKeyProvider() { + return getRsaKeyProvider(RSA_KEY); + } + + protected List getSigningProviders() { + return List.of(getJwtSigningProvider(RSA_KEY)); + } + protected static class CredentialResponseHandler { + protected void handleCredentialResponse(CredentialResponse credentialResponse) throws VerificationException { + assertNotNull("The credential should have been responded.", credentialResponse.getCredential()); + JsonWebToken jsonWebToken = TokenVerifier.create((String) credentialResponse.getCredential(), JsonWebToken.class).getToken(); + assertEquals("did:web:test.org", jsonWebToken.getIssuer()); + VerifiableCredential credential = new ObjectMapper().convertValue(jsonWebToken.getOtherClaims().get("vc"), VerifiableCredential.class); + assertEquals(List.of("VerifiableCredential"), credential.getType()); + assertEquals(URI.create("did:web:test.org"), credential.getIssuer()); + assertEquals("john@email.cz", credential.getCredentialSubject().getClaims().get("email")); + assertTrue("The static claim should be set.", credential.getCredentialSubject().getClaims().containsKey("VerifiableCredential")); + assertFalse("Only mappers supported for the requested type should have been evaluated.", credential.getCredentialSubject().getClaims().containsKey("AnotherCredentialType")); + } + } +} diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCIssuerWellKnownProviderTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCIssuerWellKnownProviderTest.java index 73129a0d9900..27766b209931 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCIssuerWellKnownProviderTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCIssuerWellKnownProviderTest.java @@ -58,12 +58,7 @@ public void getConfig() { assertNotNull("The test-credential claim firstName is present.", credentialIssuer.getCredentialsSupported().get("test-credential").getClaims().get("firstName")); assertFalse("The test-credential claim firstName is not mandatory.", credentialIssuer.getCredentialsSupported().get("test-credential").getClaims().get("firstName").getMandatory()); assertEquals("The test-credential claim firstName shall be displayed as First Name", "First Name", credentialIssuer.getCredentialsSupported().get("test-credential").getClaims().get("firstName").getDisplay().get(0).getName()); - assertEquals("The test-credential should offer vct VerifiableCredential", "VerifiableCredential", credentialIssuer.getCredentialsSupported().get("test-credential").getVct()); - assertTrue("The test-credential should contain a cryptographic binding method supported named jwk", credentialIssuer.getCredentialsSupported().get("test-credential").getCryptographicBindingMethodsSupported().contains("jwk")); - assertTrue("The test-credential should contain a credential signing algorithm named ES256", credentialIssuer.getCredentialsSupported().get("test-credential").getCredentialSigningAlgValuesSupported().contains("ES256")); - assertTrue("The test-credential should contain a credential signing algorithm named ES384", credentialIssuer.getCredentialsSupported().get("test-credential").getCredentialSigningAlgValuesSupported().contains("ES384")); - assertEquals("The test-credential should display as Test Credential", "Test Credential", credentialIssuer.getCredentialsSupported().get("test-credential").getDisplay().get(0).getName()); - assertTrue("The test-credential should support a proof of type jwt with signing algorithm ES256", credentialIssuer.getCredentialsSupported().get("test-credential").getProofTypesSupported().getJwt().getProofSigningAlgValuesSupported().contains("ES256")); + // moved sd-jwt specific config to org.keycloak.testsuite.oid4vc.issuance.signing.OID4VCSdJwtIssuingEndpointTest.getConfig })); } diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCJWTIssuerEndpointTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCJWTIssuerEndpointTest.java index 212e36c62278..474807ca0b78 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCJWTIssuerEndpointTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCJWTIssuerEndpointTest.java @@ -1,4 +1,443 @@ package org.keycloak.testsuite.oid4vc.issuance.signing; -public class OID4VCJWTIssuerEndpointTest { +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.ws.rs.BadRequestException; +import jakarta.ws.rs.client.Entity; +import jakarta.ws.rs.client.WebTarget; +import jakarta.ws.rs.core.HttpHeaders; +import jakarta.ws.rs.core.Response; +import org.apache.commons.io.IOUtils; +import org.apache.http.HttpStatus; +import org.apache.http.NameValuePair; +import org.apache.http.client.entity.UrlEncodedFormEntity; +import org.apache.http.client.methods.CloseableHttpResponse; +import org.apache.http.client.methods.HttpGet; +import org.apache.http.client.methods.HttpPost; +import org.apache.http.message.BasicNameValuePair; +import org.junit.Test; +import org.keycloak.OAuth2Constants; +import org.keycloak.TokenVerifier; +import org.keycloak.common.VerificationException; +import org.keycloak.protocol.oid4vc.issuance.OID4VCIssuerEndpoint; +import org.keycloak.protocol.oid4vc.model.CredentialIssuer; +import org.keycloak.protocol.oid4vc.model.CredentialOfferURI; +import org.keycloak.protocol.oid4vc.model.CredentialRequest; +import org.keycloak.protocol.oid4vc.model.CredentialResponse; +import org.keycloak.protocol.oid4vc.model.CredentialsOffer; +import org.keycloak.protocol.oid4vc.model.Format; +import org.keycloak.protocol.oid4vc.model.OfferUriType; +import org.keycloak.protocol.oid4vc.model.PreAuthorizedGrant; +import org.keycloak.protocol.oid4vc.model.SupportedCredentialConfiguration; +import org.keycloak.protocol.oid4vc.model.VerifiableCredential; +import org.keycloak.protocol.oidc.grants.PreAuthorizedCodeGrantTypeFactory; +import org.keycloak.protocol.oidc.representations.OIDCConfigurationRepresentation; +import org.keycloak.representations.JsonWebToken; +import org.keycloak.services.managers.AppAuthManager; +import org.keycloak.testsuite.Assert; +import org.keycloak.testsuite.util.OAuthClient; +import org.keycloak.util.JsonSerialization; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.LinkedList; +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +/** + * Test from org.keycloak.testsuite.oid4vc.issuance.signing.OID4VCIssuerEndpointTest + */ +public class OID4VCJWTIssuerEndpointTest extends OID4VCIssuerEndpointTest { + + // ----- getCredentialOfferUri + + @Test(expected = BadRequestException.class) + public void testGetCredentialOfferUriUnsupportedCredential() throws Throwable { + String token = getBearerToken(oauth); + withCausePropagation(() -> testingClient.server(TEST_REALM_NAME) + .run((session -> { + AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session); + authenticator.setTokenString(token); + + OID4VCIssuerEndpoint oid4VCIssuerEndpoint = prepareIssuerEndpoint(session, authenticator); + oid4VCIssuerEndpoint.getCredentialOfferURI("inexistent-id", OfferUriType.URI, 0, 0); + }))); + + } + + @Test(expected = BadRequestException.class) + public void testGetCredentialOfferUriUnauthorized() throws Throwable { + withCausePropagation(() -> testingClient.server(TEST_REALM_NAME) + .run((session -> { + AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session); + authenticator.setTokenString(null); + OID4VCIssuerEndpoint oid4VCIssuerEndpoint = prepareIssuerEndpoint(session, authenticator); + oid4VCIssuerEndpoint.getCredentialOfferURI("test-credential", OfferUriType.URI, 0, 0); + }))); + } + + @Test(expected = BadRequestException.class) + public void testGetCredentialOfferUriInvalidToken() throws Throwable { + withCausePropagation(() -> testingClient.server(TEST_REALM_NAME) + .run((session -> { + AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session); + authenticator.setTokenString("invalid-token"); + OID4VCIssuerEndpoint oid4VCIssuerEndpoint = prepareIssuerEndpoint(session, authenticator); + oid4VCIssuerEndpoint.getCredentialOfferURI("test-credential", OfferUriType.URI, 0, 0); + }))); + } + + @Test + public void testGetCredentialOfferURI() { + String token = getBearerToken(oauth); + testingClient + .server(TEST_REALM_NAME) + .run((session) -> { + try { + AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session); + authenticator.setTokenString(token); + OID4VCIssuerEndpoint oid4VCIssuerEndpoint = prepareIssuerEndpoint(session, authenticator); + + Response response = oid4VCIssuerEndpoint.getCredentialOfferURI("test-credential", OfferUriType.URI, 0, 0); + + assertEquals("An offer uri should have been returned.", HttpStatus.SC_OK, response.getStatus()); + CredentialOfferURI credentialOfferURI = new ObjectMapper().convertValue(response.getEntity(), CredentialOfferURI.class); + assertNotNull("A nonce should be included.", credentialOfferURI.getNonce()); + assertNotNull("The issuer uri should be provided.", credentialOfferURI.getIssuer()); + } catch (Exception e) { + throw new RuntimeException(e); + } + }); + + } + + // ----- getCredentialOffer + + @Test(expected = BadRequestException.class) + public void testGetCredentialOfferUnauthorized() throws Throwable { + withCausePropagation(() -> { + testingClient + .server(TEST_REALM_NAME) + .run((session) -> { + AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session); + authenticator.setTokenString(null); + OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator); + issuerEndpoint.getCredentialOffer("nonce"); + }); + }); + } + + @Test(expected = BadRequestException.class) + public void testGetCredentialOfferWithoutNonce() throws Throwable { + String token = getBearerToken(oauth); + withCausePropagation(() -> { + testingClient + .server(TEST_REALM_NAME) + .run((session -> { + AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session); + authenticator.setTokenString(token); + OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator); + issuerEndpoint.getCredentialOffer(null); + })); + }); + } + + @Test(expected = BadRequestException.class) + public void testGetCredentialOfferWithoutAPreparedOffer() throws Throwable { + String token = getBearerToken(oauth); + withCausePropagation(() -> { + testingClient + .server(TEST_REALM_NAME) + .run((session -> { + AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session); + authenticator.setTokenString(token); + OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator); + issuerEndpoint.getCredentialOffer("unpreparedNonce"); + })); + }); + } + + @Test(expected = BadRequestException.class) + public void testGetCredentialOfferWithABrokenNote() throws Throwable { + String token = getBearerToken(oauth); + withCausePropagation(() -> { + testingClient + .server(TEST_REALM_NAME) + .run((session -> { + AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session); + authenticator.setTokenString(token); + String nonce = prepareNonce(authenticator, "invalidNote"); + OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator); + issuerEndpoint.getCredentialOffer(nonce); + })); + }); + } + + @Test + public void testGetCredentialOffer() { + String token = getBearerToken(oauth); + String rootURL = suiteContext.getAuthServerInfo().getContextRoot().toString(); + testingClient + .server(TEST_REALM_NAME) + .run((session) -> { + AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session); + authenticator.setTokenString(token); + + SupportedCredentialConfiguration supportedCredentialConfiguration = new SupportedCredentialConfiguration() + .setId("test-credential") + .setScope("VerifiableCredential") + .setFormat(Format.JWT_VC); + String nonce = prepareNonce(authenticator, OBJECT_MAPPER.writeValueAsString(supportedCredentialConfiguration)); + + OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator); + Response credentialOfferResponse = issuerEndpoint.getCredentialOffer(nonce); + assertEquals("The offer should have been returned.", HttpStatus.SC_OK, credentialOfferResponse.getStatus()); + Object credentialOfferEntity = credentialOfferResponse.getEntity(); + assertNotNull("An actual offer should be in the response.", credentialOfferEntity); + + CredentialsOffer credentialsOffer = OBJECT_MAPPER.convertValue(credentialOfferEntity, CredentialsOffer.class); + assertNotNull("Credentials should have been offered.", credentialsOffer.getCredentialConfigurationIds()); + assertFalse("Credentials should have been offered.", credentialsOffer.getCredentialConfigurationIds().isEmpty()); + List supportedCredentials = credentialsOffer.getCredentialConfigurationIds(); + assertEquals("Exactly one credential should have been returned.", 1, supportedCredentials.size()); + String offeredCredentialId = supportedCredentials.get(0); + assertEquals("The credential should be as defined in the note.", supportedCredentialConfiguration.getId(), offeredCredentialId); + + PreAuthorizedGrant grant = credentialsOffer.getGrants(); + assertNotNull("The grant should be included.", grant); + assertNotNull("The grant should contain the pre-authorized code.", grant.getPreAuthorizedCode()); + assertNotNull("The actual pre-authorized code should be included.", grant + .getPreAuthorizedCode() + .getPreAuthorizedCode()); + + assertEquals("The correct issuer should be included.", rootURL + "/auth/realms/" + TEST_REALM_NAME, credentialsOffer.getCredentialIssuer()); + }); + } + + // ----- requestCredential + + @Test(expected = BadRequestException.class) + public void testRequestCredentialUnauthorized() throws Throwable { + withCausePropagation(() -> { + testingClient + .server(TEST_REALM_NAME) + .run((session -> { + AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session); + authenticator.setTokenString(null); + OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator); + issuerEndpoint.requestCredential(new CredentialRequest() + .setFormat(Format.JWT_VC) + .setCredentialIdentifier("test-credential")); + })); + }); + } + + @Test(expected = BadRequestException.class) + public void testRequestCredentialInvalidToken() throws Throwable { + withCausePropagation(() -> { + testingClient + .server(TEST_REALM_NAME) + .run((session -> { + AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session); + authenticator.setTokenString("token"); + OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator); + issuerEndpoint.requestCredential(new CredentialRequest() + .setFormat(Format.JWT_VC) + .setCredentialIdentifier("test-credential")); + })); + }); + } + + @Test(expected = BadRequestException.class) + public void testRequestCredentialUnsupportedFormat() throws Throwable { + String token = getBearerToken(oauth); + withCausePropagation(() -> { + testingClient + .server(TEST_REALM_NAME) + .run((session -> { + AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session); + authenticator.setTokenString(token); + OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator); + issuerEndpoint.requestCredential(new CredentialRequest() + .setFormat(Format.SD_JWT_VC) + .setCredentialIdentifier("test-credential")); + })); + }); + } + + @Test(expected = BadRequestException.class) + public void testRequestCredentialUnsupportedCredential() throws Throwable { + String token = getBearerToken(oauth); + withCausePropagation(() -> { + testingClient + .server(TEST_REALM_NAME) + .run((session -> { + AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session); + authenticator.setTokenString(token); + OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator); + issuerEndpoint.requestCredential(new CredentialRequest() + .setFormat(Format.JWT_VC) + .setCredentialIdentifier("no-such-credential")); + })); + }); + } + + @Test + public void testRequestCredential() { + String token = getBearerToken(oauth); + ObjectMapper objectMapper = new ObjectMapper(); + testingClient + .server(TEST_REALM_NAME) + .run((session -> { + AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session); + authenticator.setTokenString(token); + OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator); + CredentialRequest credentialRequest = new CredentialRequest() + .setFormat(Format.JWT_VC) + .setCredentialIdentifier("test-credential"); + Response credentialResponse = issuerEndpoint.requestCredential(credentialRequest); + assertEquals("The credential request should be answered successfully.", HttpStatus.SC_OK, credentialResponse.getStatus()); + assertNotNull("A credential should be responded.", credentialResponse.getEntity()); + CredentialResponse credentialResponseVO = OBJECT_MAPPER.convertValue(credentialResponse.getEntity(), CredentialResponse.class); + JsonWebToken jsonWebToken = TokenVerifier.create((String) credentialResponseVO.getCredential(), JsonWebToken.class).getToken(); + + assertNotNull("A valid credential string should have been responded", jsonWebToken); + assertNotNull("The credentials should be included at the vc-claim.", jsonWebToken.getOtherClaims().get("vc")); + VerifiableCredential credential = objectMapper.convertValue(jsonWebToken.getOtherClaims().get("vc"), VerifiableCredential.class); + assertTrue("The static claim should be set.", credential.getCredentialSubject().getClaims().containsKey("VerifiableCredential")); + assertFalse("Only mappers supported for the requested type should have been evaluated.", credential.getCredentialSubject().getClaims().containsKey("AnotherCredentialType")); + })); + } + + // Tests the complete flow from + // 1. Retrieving the credential-offer-uri + // 2. Using the uri to get the actual credential offer + // 3. Get the issuer metadata + // 4. Get the openid-configuration + // 5. Get an access token for the pre-authorized code + // 6. Get the credential + @Test + public void testCredentialIssuance() throws Exception { + + String token = getBearerToken(oauth); + + // 1. Retrieving the credential-offer-uri + HttpGet getCredentialOfferURI = new HttpGet(getBasePath(TEST_REALM_NAME) + "credential-offer-uri?credential_configuration_id=test-credential"); + getCredentialOfferURI.addHeader(HttpHeaders.AUTHORIZATION, "Bearer " + token); + CloseableHttpResponse credentialOfferURIResponse = httpClient.execute(getCredentialOfferURI); + + assertEquals("A valid offer uri should be returned", HttpStatus.SC_OK, credentialOfferURIResponse.getStatusLine().getStatusCode()); + String s = IOUtils.toString(credentialOfferURIResponse.getEntity().getContent(), StandardCharsets.UTF_8); + CredentialOfferURI credentialOfferURI = JsonSerialization.readValue(s, CredentialOfferURI.class); + + // 2. Using the uri to get the actual credential offer + HttpGet getCredentialOffer = new HttpGet(credentialOfferURI.getIssuer() + "/" + credentialOfferURI.getNonce()); + getCredentialOffer.addHeader(HttpHeaders.AUTHORIZATION, "Bearer " + token); + CloseableHttpResponse credentialOfferResponse = httpClient.execute(getCredentialOffer); + + assertEquals("A valid offer should be returned", HttpStatus.SC_OK, credentialOfferResponse.getStatusLine().getStatusCode()); + s = IOUtils.toString(credentialOfferResponse.getEntity().getContent(), StandardCharsets.UTF_8); + CredentialsOffer credentialsOffer = JsonSerialization.readValue(s, CredentialsOffer.class); + + // 3. Get the issuer metadata + HttpGet getIssuerMetadata = new HttpGet(credentialsOffer.getCredentialIssuer() + "/.well-known/openid-credential-issuer"); + CloseableHttpResponse issuerMetadataResponse = httpClient.execute(getIssuerMetadata); + assertEquals(HttpStatus.SC_OK, issuerMetadataResponse.getStatusLine().getStatusCode()); + s = IOUtils.toString(issuerMetadataResponse.getEntity().getContent(), StandardCharsets.UTF_8); + CredentialIssuer credentialIssuer = JsonSerialization.readValue(s, CredentialIssuer.class); + + assertEquals("We only expect one authorization server.", 1, credentialIssuer.getAuthorizationServers().size()); + + // 4. Get the openid-configuration + HttpGet getOpenidConfiguration = new HttpGet(credentialIssuer.getAuthorizationServers().get(0) + "/.well-known/openid-configuration"); + CloseableHttpResponse openidConfigResponse = httpClient.execute(getOpenidConfiguration); + assertEquals(HttpStatus.SC_OK, openidConfigResponse.getStatusLine().getStatusCode()); + s = IOUtils.toString(openidConfigResponse.getEntity().getContent(), StandardCharsets.UTF_8); + OIDCConfigurationRepresentation openidConfig = JsonSerialization.readValue(s, OIDCConfigurationRepresentation.class); + + assertNotNull("A token endpoint should be included.", openidConfig.getTokenEndpoint()); + assertTrue("The pre-authorized code should be supported.", openidConfig.getGrantTypesSupported().contains(PreAuthorizedCodeGrantTypeFactory.GRANT_TYPE)); + + // 5. Get an access token for the pre-authorized code + HttpPost postPreAuthorizedCode = new HttpPost(openidConfig.getTokenEndpoint()); + List parameters = new LinkedList<>(); + parameters.add(new BasicNameValuePair(OAuth2Constants.GRANT_TYPE, PreAuthorizedCodeGrantTypeFactory.GRANT_TYPE)); + parameters.add(new BasicNameValuePair("pre-authorized_code", credentialsOffer.getGrants().getPreAuthorizedCode().getPreAuthorizedCode())); + UrlEncodedFormEntity formEntity = new UrlEncodedFormEntity(parameters, "UTF-8"); + postPreAuthorizedCode.setEntity(formEntity); + OAuthClient.AccessTokenResponse accessTokenResponse = new OAuthClient.AccessTokenResponse(httpClient.execute(postPreAuthorizedCode)); + assertEquals(HttpStatus.SC_OK, accessTokenResponse.getStatusCode()); + String theToken = accessTokenResponse.getAccessToken(); + + // 6. Get the credential + credentialsOffer.getCredentialConfigurationIds().stream() + .map(offeredCredentialId -> credentialIssuer.getCredentialsSupported().get(offeredCredentialId)) + .forEach(supportedCredential -> { + try { + requestOffer(theToken, credentialIssuer.getCredentialEndpoint(), supportedCredential, new CredentialResponseHandler()); + } catch (IOException e) { + fail("Was not able to get the credential."); + } catch (VerificationException e) { + throw new RuntimeException(e); + } + }); + } + + @Test + public void testCredentialIssuanceWithAuthZCodeWithScopeMatched() throws Exception { + testCredentialIssuanceWithAuthZCodeFlow((testClientId, testScope) -> getBearerToken(oauth.clientId(testClientId).openid(false).scope(testScope)), + m -> { + String accessToken = (String) m.get("accessToken"); + WebTarget credentialTarget = (WebTarget) m.get("credentialTarget"); + CredentialRequest credentialRequest = (CredentialRequest) m.get("credentialRequest"); + + try (Response response = credentialTarget.request().header(HttpHeaders.AUTHORIZATION, "bearer " + accessToken).post(Entity.json(credentialRequest))) { + CredentialResponse credentialResponse = JsonSerialization.readValue(response.readEntity(String.class), CredentialResponse.class); + + assertEquals(200, response.getStatus()); + JsonWebToken jsonWebToken = TokenVerifier.create((String) credentialResponse.getCredential(), JsonWebToken.class).getToken(); + assertEquals("did:web:test.org", jsonWebToken.getIssuer()); + + VerifiableCredential credential = new ObjectMapper().convertValue(jsonWebToken.getOtherClaims().get("vc"), VerifiableCredential.class); + assertEquals(TEST_TYPES, credential.getType()); + assertEquals(TEST_DID, credential.getIssuer()); + assertEquals("john@email.cz", credential.getCredentialSubject().getClaims().get("email")); + } catch (IOException | VerificationException e) { + Assert.fail(); + } + }); + } + + @Test + public void testCredentialIssuanceWithAuthZCodeWithScopeUnmatched() throws Exception { + testCredentialIssuanceWithAuthZCodeFlow((testClientId, testScope) -> getBearerToken(oauth.clientId(testClientId).openid(false).scope("email")), // set registered different scope + m -> { + String accessToken = (String) m.get("accessToken"); + WebTarget credentialTarget = (WebTarget) m.get("credentialTarget"); + CredentialRequest credentialRequest = (CredentialRequest) m.get("credentialRequest"); + + try (Response response = credentialTarget.request().header(HttpHeaders.AUTHORIZATION, "bearer " + accessToken).post(Entity.json(credentialRequest))) { + assertEquals(400, response.getStatus()); + } + }); + } + + @Test + public void testCredentialIssuanceWithAuthZCodeSWithoutScope() throws Exception { + testCredentialIssuanceWithAuthZCodeFlow((testClientId, testScope) -> getBearerToken(oauth.clientId(testClientId).openid(false).scope(null)), // no scope + m -> { + String accessToken = (String) m.get("accessToken"); + WebTarget credentialTarget = (WebTarget) m.get("credentialTarget"); + CredentialRequest credentialRequest = (CredentialRequest) m.get("credentialRequest"); + + try (Response response = credentialTarget.request().header(HttpHeaders.AUTHORIZATION, "bearer " + accessToken).post(Entity.json(credentialRequest))) { + assertEquals(400, response.getStatus()); + } + }); + } + } diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCSdJwtIssuingEndpointTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCSdJwtIssuingEndpointTest.java index f76ffa52c0b8..2c51e589757a 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCSdJwtIssuingEndpointTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCSdJwtIssuingEndpointTest.java @@ -14,17 +14,13 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - package org.keycloak.testsuite.oid4vc.issuance.signing; +import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; -import jakarta.ws.rs.BadRequestException; -import jakarta.ws.rs.client.Client; -import jakarta.ws.rs.client.Entity; -import jakarta.ws.rs.client.WebTarget; import jakarta.ws.rs.core.HttpHeaders; import jakarta.ws.rs.core.Response; -import jakarta.ws.rs.core.UriBuilder; +import org.apache.commons.collections4.map.HashedMap; import org.apache.commons.io.IOUtils; import org.apache.http.HttpStatus; import org.apache.http.NameValuePair; @@ -32,29 +28,18 @@ import org.apache.http.client.methods.CloseableHttpResponse; import org.apache.http.client.methods.HttpGet; import org.apache.http.client.methods.HttpPost; -import org.apache.http.entity.ContentType; -import org.apache.http.entity.StringEntity; -import org.apache.http.impl.client.CloseableHttpClient; -import org.apache.http.impl.client.HttpClientBuilder; import org.apache.http.message.BasicNameValuePair; -import org.junit.Before; import org.junit.Test; import org.keycloak.OAuth2Constants; import org.keycloak.TokenVerifier; -import org.keycloak.admin.client.resource.ClientResource; -import org.keycloak.admin.client.resource.RealmResource; -import org.keycloak.admin.client.resource.UserResource; import org.keycloak.common.VerificationException; -import org.keycloak.common.crypto.CryptoIntegration; +import org.keycloak.common.util.Base64Url; import org.keycloak.common.util.MultivaluedHashMap; -import org.keycloak.common.util.SecretGenerator; import org.keycloak.crypto.Algorithm; import org.keycloak.models.KeycloakSession; -import org.keycloak.models.UserSessionModel; +import org.keycloak.protocol.oid4vc.OID4VCLoginProtocolFactory; import org.keycloak.protocol.oid4vc.issuance.OID4VCIssuerEndpoint; -import org.keycloak.protocol.oid4vc.issuance.OID4VCIssuerWellKnownProviderFactory; -import org.keycloak.protocol.oid4vc.issuance.TimeProvider; -import org.keycloak.protocol.oid4vc.issuance.signing.JwtSigningService; +import org.keycloak.protocol.oid4vc.issuance.OID4VCIssuerWellKnownProvider; import org.keycloak.protocol.oid4vc.issuance.signing.SdJwtSigningService; import org.keycloak.protocol.oid4vc.model.CredentialConfigId; import org.keycloak.protocol.oid4vc.model.CredentialIssuer; @@ -63,37 +48,25 @@ import org.keycloak.protocol.oid4vc.model.CredentialResponse; import org.keycloak.protocol.oid4vc.model.CredentialsOffer; import org.keycloak.protocol.oid4vc.model.Format; -import org.keycloak.protocol.oid4vc.model.OfferUriType; -import org.keycloak.protocol.oid4vc.model.PreAuthorizedGrant; -import org.keycloak.protocol.oid4vc.model.SupportedCredentialConfiguration; -import org.keycloak.protocol.oid4vc.model.VerifiableCredential; import org.keycloak.protocol.oid4vc.model.VerifiableCredentialType; -import org.keycloak.protocol.oidc.OIDCLoginProtocol; import org.keycloak.protocol.oidc.grants.PreAuthorizedCodeGrantTypeFactory; import org.keycloak.protocol.oidc.representations.OIDCConfigurationRepresentation; import org.keycloak.representations.JsonWebToken; import org.keycloak.representations.idm.ClientRepresentation; -import org.keycloak.representations.idm.ClientScopeRepresentation; -import org.keycloak.representations.idm.RealmRepresentation; +import org.keycloak.representations.idm.ComponentExportRepresentation; +import org.keycloak.sdjwt.vp.SdJwtVP; import org.keycloak.services.managers.AppAuthManager; -import org.keycloak.services.managers.AuthenticationManager; -import org.keycloak.services.resources.RealmsResource; -import org.keycloak.testsuite.Assert; -import org.keycloak.testsuite.admin.ApiUtil; -import org.keycloak.testsuite.runonserver.RunOnServerException; -import org.keycloak.testsuite.util.AdminClientUtil; import org.keycloak.testsuite.util.OAuthClient; import org.keycloak.util.JsonSerialization; import java.io.IOException; -import java.net.URI; import java.nio.charset.StandardCharsets; import java.util.LinkedList; import java.util.List; import java.util.Map; import java.util.Optional; -import java.util.function.BiFunction; -import java.util.function.Consumer; +import java.util.UUID; +import java.util.stream.Collectors; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; @@ -101,309 +74,31 @@ import static org.junit.Assert.assertTrue; import static org.junit.Assert.fail; -public class OID4VCSdJwtIssuingEndpointTest extends OID4VCTest { - - private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); - private static final TimeProvider TIME_PROVIDER = new StaticTimeProvider(1000); - private CloseableHttpClient httpClient; - - - @Before - public void setup() { - CryptoIntegration.init(this.getClass().getClassLoader()); - httpClient = HttpClientBuilder.create().build(); - } - - - // ----- getCredentialOfferUri - - @Test(expected = BadRequestException.class) - public void testGetCredentialOfferUriUnsupportedCredential() throws Throwable { - String token = getBearerToken(oauth); - withCausePropagation(() -> testingClient.server(TEST_REALM_NAME) - .run((session -> { - AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session); - authenticator.setTokenString(token); - - OID4VCIssuerEndpoint oid4VCIssuerEndpoint = prepareIssuerEndpoint(session, authenticator); - oid4VCIssuerEndpoint.getCredentialOfferURI("inexistent-id", OfferUriType.URI, 0, 0); - }))); - - } - - @Test(expected = BadRequestException.class) - public void testGetCredentialOfferUriUnauthorized() throws Throwable { - withCausePropagation(() -> testingClient.server(TEST_REALM_NAME) - .run((session -> { - AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session); - authenticator.setTokenString(null); - OID4VCIssuerEndpoint oid4VCIssuerEndpoint = prepareIssuerEndpoint(session, authenticator); - oid4VCIssuerEndpoint.getCredentialOfferURI("test-credential", OfferUriType.URI, 0, 0); - }))); - } - - @Test(expected = BadRequestException.class) - public void testGetCredentialOfferUriInvalidToken() throws Throwable { - withCausePropagation(() -> testingClient.server(TEST_REALM_NAME) - .run((session -> { - AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session); - authenticator.setTokenString("invalid-token"); - OID4VCIssuerEndpoint oid4VCIssuerEndpoint = prepareIssuerEndpoint(session, authenticator); - oid4VCIssuerEndpoint.getCredentialOfferURI("test-credential", OfferUriType.URI, 0, 0); - }))); - } - - @Test - public void testGetCredentialOfferURI() { - String token = getBearerToken(oauth); - testingClient - .server(TEST_REALM_NAME) - .run((session) -> { - try { - AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session); - authenticator.setTokenString(token); - OID4VCIssuerEndpoint oid4VCIssuerEndpoint = prepareIssuerEndpoint(session, authenticator); - - Response response = oid4VCIssuerEndpoint.getCredentialOfferURI("test-credential", OfferUriType.URI, 0, 0); - - assertEquals("An offer uri should have been returned.", HttpStatus.SC_OK, response.getStatus()); - CredentialOfferURI credentialOfferURI = new ObjectMapper().convertValue(response.getEntity(), CredentialOfferURI.class); - assertNotNull("A nonce should be included.", credentialOfferURI.getNonce()); - assertNotNull("The issuer uri should be provided.", credentialOfferURI.getIssuer()); - } catch (Exception e) { - throw new RuntimeException(e); - } - }); - - } - - private static String getBearerToken(OAuthClient oAuthClient) { - OAuthClient.AuthorizationEndpointResponse authorizationEndpointResponse = oAuthClient.doLogin("john", "password"); - return oAuthClient.doAccessTokenRequest(authorizationEndpointResponse.getCode(), "password").getAccessToken(); - } - - // ----- getCredentialOffer - - @Test(expected = BadRequestException.class) - public void testGetCredentialOfferUnauthorized() throws Throwable { - withCausePropagation(() -> { - testingClient - .server(TEST_REALM_NAME) - .run((session) -> { - AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session); - authenticator.setTokenString(null); - OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator); - issuerEndpoint.getCredentialOffer("nonce"); - }); - }); - } - - @Test(expected = BadRequestException.class) - public void testGetCredentialOfferWithoutNonce() throws Throwable { - String token = getBearerToken(oauth); - withCausePropagation(() -> { - testingClient - .server(TEST_REALM_NAME) - .run((session -> { - AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session); - authenticator.setTokenString(token); - OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator); - issuerEndpoint.getCredentialOffer(null); - })); - }); - } - - @Test(expected = BadRequestException.class) - public void testGetCredentialOfferWithoutAPreparedOffer() throws Throwable { - String token = getBearerToken(oauth); - withCausePropagation(() -> { - testingClient - .server(TEST_REALM_NAME) - .run((session -> { - AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session); - authenticator.setTokenString(token); - OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator); - issuerEndpoint.getCredentialOffer("unpreparedNonce"); - })); - }); - } - - @Test(expected = BadRequestException.class) - public void testGetCredentialOfferWithABrokenNote() throws Throwable { - String token = getBearerToken(oauth); - withCausePropagation(() -> { - testingClient - .server(TEST_REALM_NAME) - .run((session -> { - AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session); - authenticator.setTokenString(token); - String nonce = prepareNonce(authenticator, "invalidNote"); - OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator); - issuerEndpoint.getCredentialOffer(nonce); - })); - }); - } - - @Test - public void testGetCredentialOffer() { - String token = getBearerToken(oauth); - String rootURL = suiteContext.getAuthServerInfo().getContextRoot().toString(); - testingClient - .server(TEST_REALM_NAME) - .run((session) -> { - AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session); - authenticator.setTokenString(token); - - SupportedCredentialConfiguration supportedCredentialConfiguration = new SupportedCredentialConfiguration() - .setId("test-credential") - .setScope("VerifiableCredential") - .setFormat(Format.JWT_VC); - String nonce = prepareNonce(authenticator, OBJECT_MAPPER.writeValueAsString(supportedCredentialConfiguration)); - - OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator); - Response credentialOfferResponse = issuerEndpoint.getCredentialOffer(nonce); - assertEquals("The offer should have been returned.", HttpStatus.SC_OK, credentialOfferResponse.getStatus()); - Object credentialOfferEntity = credentialOfferResponse.getEntity(); - assertNotNull("An actual offer should be in the response.", credentialOfferEntity); - - CredentialsOffer credentialsOffer = OBJECT_MAPPER.convertValue(credentialOfferEntity, CredentialsOffer.class); - assertNotNull("Credentials should have been offered.", credentialsOffer.getCredentialConfigurationIds()); - assertFalse("Credentials should have been offered.", credentialsOffer.getCredentialConfigurationIds().isEmpty()); - List supportedCredentials = credentialsOffer.getCredentialConfigurationIds(); - assertEquals("Exactly one credential should have been returned.", 1, supportedCredentials.size()); - String offeredCredentialId = supportedCredentials.get(0); - assertEquals("The credential should be as defined in the note.", supportedCredentialConfiguration.getId(), offeredCredentialId); - - PreAuthorizedGrant grant = credentialsOffer.getGrants(); - assertNotNull("The grant should be included.", grant); - assertNotNull("The grant should contain the pre-authorized code.", grant.getPreAuthorizedCode()); - assertNotNull("The actual pre-authorized code should be included.", grant - .getPreAuthorizedCode() - .getPreAuthorizedCode()); - - assertEquals("The correct issuer should be included.", rootURL + "/auth/realms/" + TEST_REALM_NAME, credentialsOffer.getCredentialIssuer()); - }); - } - - // ----- requestCredential - - @Test(expected = BadRequestException.class) - public void testRequestCredentialUnauthorized() throws Throwable { - withCausePropagation(() -> { - testingClient - .server(TEST_REALM_NAME) - .run((session -> { - AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session); - authenticator.setTokenString(null); - OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator); - issuerEndpoint.requestCredential(new CredentialRequest() - .setFormat(Format.JWT_VC) - .setCredentialIdentifier("test-credential")); - })); - }); - } - - @Test(expected = BadRequestException.class) - public void testRequestCredentialInvalidToken() throws Throwable { - withCausePropagation(() -> { - testingClient - .server(TEST_REALM_NAME) - .run((session -> { - AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session); - authenticator.setTokenString("token"); - OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator); - issuerEndpoint.requestCredential(new CredentialRequest() - .setFormat(Format.JWT_VC) - .setCredentialIdentifier("test-credential")); - })); - }); - } - - @Test(expected = BadRequestException.class) - public void testRequestCredentialUnsupportedFormat() throws Throwable { - String token = getBearerToken(oauth); - withCausePropagation(() -> { - testingClient - .server(TEST_REALM_NAME) - .run((session -> { - AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session); - authenticator.setTokenString(token); - OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator); - issuerEndpoint.requestCredential(new CredentialRequest() - .setFormat(Format.SD_JWT_VC) - .setCredentialIdentifier("test-credential")); - })); - }); - } - - @Test(expected = BadRequestException.class) - public void testRequestCredentialUnsupportedCredential() throws Throwable { - String token = getBearerToken(oauth); - withCausePropagation(() -> { - testingClient - .server(TEST_REALM_NAME) - .run((session -> { - AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session); - authenticator.setTokenString(token); - OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator); - issuerEndpoint.requestCredential(new CredentialRequest() - .setFormat(Format.JWT_VC) - .setCredentialIdentifier("no-such-credential")); - })); - }); - } +/** + * Endpoint test with sd-jwt specific config. + * + * @author Francis Pouatcha + */ +public class OID4VCSdJwtIssuingEndpointTest extends OID4VCIssuerEndpointTest { @Test - public void testRequestCredential() { + public void testRequestTestCredential() { String token = getBearerToken(oauth); - ObjectMapper objectMapper = new ObjectMapper(); + String vct = "https://credentials.example.com/test-credential"; testingClient .server(TEST_REALM_NAME) .run((session -> { AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session); authenticator.setTokenString(token); OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator); - CredentialRequest credentialRequest = new CredentialRequest() - .setFormat(Format.JWT_VC) - .setCredentialIdentifier("test-credential"); - Response credentialResponse = issuerEndpoint.requestCredential(credentialRequest); - assertEquals("The credential request should be answered successfully.", HttpStatus.SC_OK, credentialResponse.getStatus()); - assertNotNull("A credential should be responded.", credentialResponse.getEntity()); - CredentialResponse credentialResponseVO = OBJECT_MAPPER.convertValue(credentialResponse.getEntity(), CredentialResponse.class); - JsonWebToken jsonWebToken = TokenVerifier.create((String) credentialResponseVO.getCredential(), JsonWebToken.class).getToken(); - - assertNotNull("A valid credential string should have been responded", jsonWebToken); - assertNotNull("The credentials should be included at the vc-claim.", jsonWebToken.getOtherClaims().get("vc")); - VerifiableCredential credential = objectMapper.convertValue(jsonWebToken.getOtherClaims().get("vc"), VerifiableCredential.class); - assertTrue("The static claim should be set.", credential.getCredentialSubject().getClaims().containsKey("VerifiableCredential")); - assertFalse("Only mappers supported for the requested type should have been evaluated.", credential.getCredentialSubject().getClaims().containsKey("AnotherCredentialType")); - })); - } - - @Test - public void testRequestSdJwtCredential() { - String token = getBearerToken(oauth); - ObjectMapper objectMapper = new ObjectMapper(); - testingClient - .server(TEST_REALM_NAME) - .run((session -> { - AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session); - authenticator.setTokenString(token); - OID4VCIssuerEndpoint issuerEndpoint = prepareSdJwtIssuerEndpoint(session, authenticator); CredentialRequest credentialRequest = new CredentialRequest() .setFormat(Format.SD_JWT_VC) - .setVct("https://credentials.example.com/test-credential"); + .setVct(vct); Response credentialResponse = issuerEndpoint.requestCredential(credentialRequest); assertEquals("The credential request should be answered successfully.", HttpStatus.SC_OK, credentialResponse.getStatus()); assertNotNull("A credential should be responded.", credentialResponse.getEntity()); CredentialResponse credentialResponseVO = OBJECT_MAPPER.convertValue(credentialResponse.getEntity(), CredentialResponse.class); - JsonWebToken jsonWebToken = TokenVerifier.create((String) credentialResponseVO.getCredential(), JsonWebToken.class).getToken(); - - assertNotNull("A valid credential string should have been responded", jsonWebToken); - assertNotNull("The credentials should be included at the vc-claim.", jsonWebToken.getOtherClaims().get("vc")); - VerifiableCredential credential = objectMapper.convertValue(jsonWebToken.getOtherClaims().get("vc"), VerifiableCredential.class); - assertTrue("The static claim should be set.", credential.getCredentialSubject().getClaims().containsKey("VerifiableCredential")); - assertFalse("Only mappers supported for the requested type should have been evaluated.", credential.getCredentialSubject().getClaims().containsKey("AnotherCredentialType")); + new TestCredentialResponseHandler(vct).handleCredentialResponse(credentialResponseVO); })); } @@ -467,12 +162,14 @@ public void testCredentialIssuance() throws Exception { assertEquals(HttpStatus.SC_OK, accessTokenResponse.getStatusCode()); String theToken = accessTokenResponse.getAccessToken(); + final String vct = "https://credentials.example.com/test-credential"; + // 6. Get the credential credentialsOffer.getCredentialConfigurationIds().stream() .map(offeredCredentialId -> credentialIssuer.getCredentialsSupported().get(offeredCredentialId)) .forEach(supportedCredential -> { try { - requestOffer(theToken, credentialIssuer.getCredentialEndpoint(), supportedCredential); + requestOffer(theToken, credentialIssuer.getCredentialEndpoint(), supportedCredential, new TestCredentialResponseHandler(vct)); } catch (IOException e) { fail("Was not able to get the credential."); } catch (VerificationException e) { @@ -481,246 +178,80 @@ public void testCredentialIssuance() throws Exception { }); } - private ClientResource findClientByClientId(RealmResource realm, String clientId) { - for (ClientRepresentation c : realm.clients().findAll()) { - if (clientId.equals(c.getClientId())) { - return realm.clients().get(c.getId()); - } - } - return null; - } - - private String registerOptionalClientScope(String scopeName) { - ClientScopeRepresentation clientScope = new ClientScopeRepresentation(); - clientScope.setName(scopeName); - clientScope.setProtocol(OIDCLoginProtocol.LOGIN_PROTOCOL); - Response res = testRealm().clientScopes().create(clientScope); - String scopeId = ApiUtil.getCreatedId(res); - getCleanup().addClientScopeId(scopeId); // automatically removed when a test method is finished. - res.close(); - return scopeId; - } - - private void assignOptionalClientScopeToClient(String scopeId, String clientId) { - ClientResource clientResource = findClientByClientId(testRealm(), clientId); - clientResource.addOptionalClientScope(scopeId); - } - - private void addCredentialConfigurationIdToClient(String clientId, String credentialConfigurationId, String format, String scope) { - ClientRepresentation clientRepresentation = adminClient.realm(TEST_REALM_NAME).clients().findByClientId(clientId).get(0); - ClientResource clientResource = adminClient.realm(TEST_REALM_NAME).clients().get(clientRepresentation.getId()); - - clientRepresentation.setAttributes(Map.of( - "vc." + credentialConfigurationId + ".format", format, - "vc." + credentialConfigurationId + ".scope", scope)); - clientRepresentation.setProtocolMappers( - List.of( - getRoleMapper(clientId), - getEmailMapper(), - getIdMapper(), - getStaticClaimMapper(scope), - getStaticClaimMapper("AnotherCredentialType") - ) - ); - - clientResource.update(clientRepresentation); - } - - private void removeCredentialConfigurationIdToClient(String clientId) { - ClientRepresentation clientRepresentation = adminClient.realm(TEST_REALM_NAME).clients().findByClientId(clientId).get(0); - ClientResource clientResource = adminClient.realm(TEST_REALM_NAME).clients().get(clientRepresentation.getId()); - clientRepresentation.setAttributes(Map.of()); - clientResource.update(clientRepresentation); - } - - private void logoutUser(String clientId, String username) { - UserResource user = ApiUtil.findUserByUsernameId(adminClient.realm(TEST_REALM_NAME), username); - user.logout(); - } - - private void testCredentialIssuanceWithAuthZCodeFlow(Consumer> c) throws Exception { - // use pre-registered client for this test class whose clientId is "test-app" defined in testrealm.json - String testClientId = "test-app"; - - // use supported values by Credential Issuer Metadata - String testCredentialConfigurationId = "test-credential"; - String testScope = "VerifiableCredential"; - String testFormat = Format.JWT_VC.toString(); - - // register optional client scope - String scopeId = registerOptionalClientScope(testScope); - - // assign registered optional client scope - assignOptionalClientScopeToClient(scopeId, testClientId); // pre-registered client for this test class - - // add credential configuration id to a client as client attributes - addCredentialConfigurationIdToClient(testClientId, testCredentialConfigurationId, testFormat, testScope); - - c.accept(Map.of( - "clientId", testClientId, - "credentialConfigurationId", testCredentialConfigurationId, - "scope", testScope, - "format", testFormat) - ); - // clean-up - logoutUser(testClientId, "john"); - removeCredentialConfigurationIdToClient(testClientId); - oauth.clientId(null); - } - - // Tests the AuthZCode complete flow without scope from - // 1. Get authorization code without scope specified by wallet - // 2. Using the code to get access token - // 3. Get the credential configuration id from issuer metadata at .wellKnown - // 4. With the access token, get the credential - private void testCredentialIssuanceWithAuthZCodeFlow(BiFunction f, Consumer> c) throws Exception { - testCredentialIssuanceWithAuthZCodeFlow(m->{ - String testClientId = m.get("clientId"); - String testScope = m.get("scope"); - String testFormat = m.get("format"); - String testCredentialConfigurationId = m.get("credentialConfigurationId"); - - try (Client client = AdminClientUtil.createResteasyClient()) { - UriBuilder builder = UriBuilder.fromUri(OAuthClient.AUTH_SERVER_ROOT); - URI oid4vciDiscoveryUri = RealmsResource.wellKnownProviderUrl(builder).build(TEST_REALM_NAME, OID4VCIssuerWellKnownProviderFactory.PROVIDER_ID); - WebTarget oid4vciDiscoveryTarget = client.target(oid4vciDiscoveryUri); - - // 1. Get authoriZation code without scope specified by wallet - // 2. Using the code to get accesstoken - String token = f.apply(testClientId, testScope); - - // 3. Get the credential configuration id from issuer metadata at .wellKnown - try (Response discoveryResponse = oid4vciDiscoveryTarget.request().get()) { - CredentialIssuer oid4vciIssuerConfig = JsonSerialization.readValue(discoveryResponse.readEntity(String.class), CredentialIssuer.class); - assertEquals(200, discoveryResponse.getStatus()); - assertEquals(getRealmPath(TEST_REALM_NAME), oid4vciIssuerConfig.getCredentialIssuer()); - assertEquals(getBasePath(TEST_REALM_NAME) + "credential", oid4vciIssuerConfig.getCredentialEndpoint()); - - // 4. With the access token, get the credential - try (Client clientForCredentialRequest = AdminClientUtil.createResteasyClient()) { - UriBuilder credentialUriBuilder = UriBuilder.fromUri(oid4vciIssuerConfig.getCredentialEndpoint()); - URI credentialUri = credentialUriBuilder.build(); - WebTarget credentialTarget = clientForCredentialRequest.target(credentialUri); - - CredentialRequest request = new CredentialRequest(); - request.setFormat(oid4vciIssuerConfig.getCredentialsSupported().get(testCredentialConfigurationId).getFormat()); - request.setCredentialIdentifier(oid4vciIssuerConfig.getCredentialsSupported().get(testCredentialConfigurationId).getId()); - - assertEquals(testFormat, oid4vciIssuerConfig.getCredentialsSupported().get(testCredentialConfigurationId).getFormat().toString()); - assertEquals(testCredentialConfigurationId, oid4vciIssuerConfig.getCredentialsSupported().get(testCredentialConfigurationId).getId()); - - c.accept(Map.of( - "accessToken", token, - "credentialTarget", credentialTarget, - "credentialRequest", request - )); - } - } - } catch (IOException e) { - Assert.fail(); - } - - }); - } - - @Test - public void testCredentialIssuanceWithAuthZCodeWithScopeMatched() throws Exception { - testCredentialIssuanceWithAuthZCodeFlow((testClientId, testScope) -> getBearerToken(oauth.clientId(testClientId).openid(false).scope(testScope)), - m -> { - String accessToken = (String)m.get("accessToken"); - WebTarget credentialTarget = (WebTarget)m.get("credentialTarget"); - CredentialRequest credentialRequest = (CredentialRequest)m.get("credentialRequest"); - - try (Response response = credentialTarget.request().header(HttpHeaders.AUTHORIZATION, "bearer " + accessToken).post(Entity.json(credentialRequest))) { - CredentialResponse credentialResponse = JsonSerialization.readValue(response.readEntity(String.class),CredentialResponse.class); - - assertEquals(200, response.getStatus()); - JsonWebToken jsonWebToken = TokenVerifier.create((String) credentialResponse.getCredential(), JsonWebToken.class).getToken(); - assertEquals("did:web:test.org", jsonWebToken.getIssuer()); - - VerifiableCredential credential = new ObjectMapper().convertValue(jsonWebToken.getOtherClaims().get("vc"), VerifiableCredential.class); - assertEquals(TEST_TYPES, credential.getType()); - assertEquals(TEST_DID, credential.getIssuer()); - assertEquals("john@email.cz", credential.getCredentialSubject().getClaims().get("email")); - } catch (IOException | VerificationException e) { - Assert.fail(); - } - }); - } - - @Test - public void testCredentialIssuanceWithAuthZCodeWithScopeUnmatched() throws Exception { - testCredentialIssuanceWithAuthZCodeFlow((testClientId, testScope) -> getBearerToken(oauth.clientId(testClientId).openid(false).scope("email")), // set registered different scope - m -> { - String accessToken = (String)m.get("accessToken"); - WebTarget credentialTarget = (WebTarget)m.get("credentialTarget"); - CredentialRequest credentialRequest = (CredentialRequest)m.get("credentialRequest"); - - try (Response response = credentialTarget.request().header(HttpHeaders.AUTHORIZATION, "bearer " + accessToken).post(Entity.json(credentialRequest))) { - assertEquals(400, response.getStatus()); - } - }); - } - + /** + * This is testing the configuration exposed by OID4VCIssuerWellKnownProvider based on the client and signing config setup here. + */ @Test - public void testCredentialIssuanceWithAuthZCodeSWithoutScope() throws Exception { - testCredentialIssuanceWithAuthZCodeFlow((testClientId, testScope) -> getBearerToken(oauth.clientId(testClientId).openid(false).scope(null)), // no scope - m -> { - String accessToken = (String)m.get("accessToken"); - WebTarget credentialTarget = (WebTarget)m.get("credentialTarget"); - CredentialRequest credentialRequest = (CredentialRequest)m.get("credentialRequest"); - - try (Response response = credentialTarget.request().header(HttpHeaders.AUTHORIZATION, "bearer " + accessToken).post(Entity.json(credentialRequest))) { - assertEquals(400, response.getStatus()); - } - }); + public void getConfig() { + String expectedIssuer = suiteContext.getAuthServerInfo().getContextRoot().toString() + "/auth/realms/" + TEST_REALM_NAME; + String expectedCredentialsEndpoint = expectedIssuer + "/protocol/oid4vc/credential"; + String expectedAuthorizationServer = expectedIssuer; + testingClient + .server(TEST_REALM_NAME) + .run((session -> { + OID4VCIssuerWellKnownProvider oid4VCIssuerWellKnownProvider = new OID4VCIssuerWellKnownProvider(session); + Object issuerConfig = oid4VCIssuerWellKnownProvider.getConfig(); + assertTrue("Valid credential-issuer metadata should be returned.", issuerConfig instanceof CredentialIssuer); + CredentialIssuer credentialIssuer = (CredentialIssuer) issuerConfig; + assertEquals("The correct issuer should be included.", expectedIssuer, credentialIssuer.getCredentialIssuer()); + assertEquals("The correct credentials endpoint should be included.", expectedCredentialsEndpoint, credentialIssuer.getCredentialEndpoint()); + assertEquals("Since the authorization server is equal to the issuer, just 1 should be returned.", 1, credentialIssuer.getAuthorizationServers().size()); + assertEquals("The expected server should have been returned.", expectedAuthorizationServer, credentialIssuer.getAuthorizationServers().get(0)); + assertTrue("The test-credential should be supported.", credentialIssuer.getCredentialsSupported().containsKey("test-credential")); + assertEquals("The test-credential should offer type test-credential", "test-credential", credentialIssuer.getCredentialsSupported().get("test-credential").getScope()); + assertEquals("The test-credential should be offered in the sd-jwt format.", Format.SD_JWT_VC, credentialIssuer.getCredentialsSupported().get("test-credential").getFormat()); + assertNotNull("The test-credential can optionally provide a claims claim.", credentialIssuer.getCredentialsSupported().get("test-credential").getClaims()); + assertNotNull("The test-credential claim firstName is present.", credentialIssuer.getCredentialsSupported().get("test-credential").getClaims().get("firstName")); + assertFalse("The test-credential claim firstName is not mandatory.", credentialIssuer.getCredentialsSupported().get("test-credential").getClaims().get("firstName").getMandatory()); + assertEquals("The test-credential claim firstName shall be displayed as First Name", "First Name", credentialIssuer.getCredentialsSupported().get("test-credential").getClaims().get("firstName").getDisplay().get(0).getName()); + assertEquals("The test-credential should offer vct VerifiableCredential", "https://credentials.example.com/test-credential", credentialIssuer.getCredentialsSupported().get("test-credential").getVct()); + + // We are offering key binding only for identity credential + assertTrue("The IdentityCredential should contain a cryptographic binding method supported named jwk", credentialIssuer.getCredentialsSupported().get("IdentityCredential").getCryptographicBindingMethodsSupported().contains("jwk")); + assertTrue("The IdentityCredential should contain a credential signing algorithm named ES256", credentialIssuer.getCredentialsSupported().get("IdentityCredential").getCredentialSigningAlgValuesSupported().contains("ES256")); + assertTrue("The IdentityCredential should contain a credential signing algorithm named ES384", credentialIssuer.getCredentialsSupported().get("IdentityCredential").getCredentialSigningAlgValuesSupported().contains("ES384")); + assertEquals("The IdentityCredential should display as Test Credential", "Identity Credential", credentialIssuer.getCredentialsSupported().get("IdentityCredential").getDisplay().get(0).getName()); + assertTrue("The IdentityCredential should support a proof of type jwt with signing algorithm ES256", credentialIssuer.getCredentialsSupported().get("IdentityCredential").getProofTypesSupported().getJwt().getProofSigningAlgValuesSupported().contains("ES256")); + })); } - private static String prepareNonce(AppAuthManager.BearerTokenAuthenticator authenticator, String note) { - String nonce = SecretGenerator.getInstance().randomString(); - AuthenticationManager.AuthResult authResult = authenticator.authenticate(); - UserSessionModel userSessionModel = authResult.getSession(); - userSessionModel.getAuthenticatedClientSessionByClient(authResult.getClient().getId()).setNote(nonce, note); - return nonce; - } - private static OID4VCIssuerEndpoint prepareIssuerEndpoint(KeycloakSession session, AppAuthManager.BearerTokenAuthenticator authenticator) { - JwtSigningService jwtSigningService = new JwtSigningService( + protected static OID4VCIssuerEndpoint prepareIssuerEndpoint(KeycloakSession session, AppAuthManager.BearerTokenAuthenticator authenticator) { + SdJwtSigningService testCredentialSigningService = new SdJwtSigningService( session, + OBJECT_MAPPER, getKeyFromSession(session).getKid(), - Algorithm.RS256, - "JWT", - "did:web:issuer.org", - TIME_PROVIDER); - return new OID4VCIssuerEndpoint( - session, + Algorithm.ES256, + Format.SD_JWT_VC.toString(), + "sha-256", "did:web:issuer.org", - Map.of(jwtSigningService.locator(), jwtSigningService), - authenticator, - new ObjectMapper(), - TIME_PROVIDER, - 30, - true); - } + 2, + List.of(), + Optional.empty(), + VerifiableCredentialType.from("https://credentials.example.com/test-credential"), + CredentialConfigId.from("test-credential")); - private static OID4VCIssuerEndpoint prepareSdJwtIssuerEndpoint(KeycloakSession session, AppAuthManager.BearerTokenAuthenticator authenticator) { - SdJwtSigningService sdJwtSigningService = new SdJwtSigningService( + SdJwtSigningService identityCredentialSigningService = new SdJwtSigningService( session, - new ObjectMapper(), + OBJECT_MAPPER, getKeyFromSession(session).getKid(), - Algorithm.RS256, - "vc+sd-jwt", + Algorithm.ES256, + Format.SD_JWT_VC.toString(), "sha-256", "did:web:issuer.org", 0, - List.of(), + List.of("given_name"), Optional.empty(), - VerifiableCredentialType.from("https://credentials.example.com/test-credential"), - CredentialConfigId.from("test-credential")); + VerifiableCredentialType.from("https://credentials.example.com/identity_credential"), + CredentialConfigId.from("IdentityCredential")); + return new OID4VCIssuerEndpoint( session, "did:web:issuer.org", - Map.of(sdJwtSigningService.locator(), sdJwtSigningService), + Map.of( + testCredentialSigningService.locator(), testCredentialSigningService, + identityCredentialSigningService.locator(), identityCredentialSigningService + ), authenticator, new ObjectMapper(), TIME_PROVIDER, @@ -728,87 +259,139 @@ private static OID4VCIssuerEndpoint prepareSdJwtIssuerEndpoint(KeycloakSession s true); } - - private String getBasePath(String realm) { - return getRealmPath(realm) + "/protocol/oid4vc/"; + private ComponentExportRepresentation getIdCredentialSigningProvider() { + ComponentExportRepresentation componentExportRepresentation = new ComponentExportRepresentation(); + componentExportRepresentation.setName("sd-jwt-signing_identity_credential"); + componentExportRepresentation.setId(UUID.randomUUID().toString()); + componentExportRepresentation.setProviderId(Format.SD_JWT_VC.toString()); + + componentExportRepresentation.setConfig(new MultivaluedHashMap<>( + Map.of( + "algorithmType", List.of("ES256"), + "tokenType", List.of(Format.SD_JWT_VC.toString()), + "issuerDid", List.of(TEST_DID.toString()), + "hashAlgorithm", List.of("sha-256"), + "decoys", List.of("0"), + "vct", List.of("https://credentials.example.com/identity_credential"), + "vcConfigId", List.of("IdentityCredential") + ) + )); + return componentExportRepresentation; + } + + private ComponentExportRepresentation getTestCredentialSigningProvider() { + ComponentExportRepresentation componentExportRepresentation = new ComponentExportRepresentation(); + componentExportRepresentation.setName("sd-jwt-signing_test-credential"); + componentExportRepresentation.setId(UUID.randomUUID().toString()); + componentExportRepresentation.setProviderId(Format.SD_JWT_VC.toString()); + + componentExportRepresentation.setConfig(new MultivaluedHashMap<>( + Map.of( + "algorithmType", List.of("ES256"), + "tokenType", List.of(Format.SD_JWT_VC.toString()), + "issuerDid", List.of(TEST_DID.toString()), + "hashAlgorithm", List.of("sha-256"), + "decoys", List.of("2"), + "vct", List.of("https://credentials.example.com/test-credential"), + "vcConfigId", List.of("test-credential") + ) + )); + return componentExportRepresentation; } - private String getRealmPath(String realm){ - return suiteContext.getAuthServerInfo().getContextRoot().toString() + "/auth/realms/" + realm; + @Override + protected ClientRepresentation getTestClient(String clientId) { + ClientRepresentation clientRepresentation = new ClientRepresentation(); + clientRepresentation.setClientId(clientId); + clientRepresentation.setProtocol(OID4VCLoginProtocolFactory.PROTOCOL_ID); + clientRepresentation.setEnabled(true); + Map testCredentialAttributes = Map.of( + "vc.test-credential.expiry_in_s", "1800", + "vc.test-credential.format", Format.SD_JWT_VC.toString(), + "vc.test-credential.scope", "test-credential", + "vc.test-credential.claims", "{ \"firstName\": {\"mandatory\": false, \"display\": [{\"name\": \"First Name\", \"locale\": \"en-US\"}, {\"name\": \"名前\", \"locale\": \"ja-JP\"}]}, \"lastName\": {\"mandatory\": false}, \"email\": {\"mandatory\": false} }", + "vc.test-credential.vct", "https://credentials.example.com/test-credential", + "vc.test-credential.credential_signing_alg_values_supported", "ES256,ES384", + "vc.test-credential.display.0", "{\n \"name\": \"Test Credential\"\n}" + ); + Map identityCredentialAttributes = Map.of( + "vc.IdentityCredential.expiry_in_s", "31536000", + "vc.IdentityCredential.format", Format.SD_JWT_VC.toString(), + "vc.IdentityCredential.scope", "identity_credential", + "vc.IdentityCredential.vct", "https://credentials.example.com/identity_credential", + "vc.IdentityCredential.cryptographic_binding_methods_supported", "jwk", + "vc.IdentityCredential.credential_signing_alg_values_supported", "ES256,ES384", + "vc.IdentityCredential.claims", "{\"given_name\":{\"display\":[{\"name\":\"الاسم الشخصي\",\"locale\":\"ar\"},{\"name\":\"Vorname\",\"locale\":\"de\"},{\"name\":\"Given Name\",\"locale\":\"en\"},{\"name\":\"Nombre\",\"locale\":\"es\"},{\"name\":\"نام\",\"locale\":\"fa\"},{\"name\":\"Etunimi\",\"locale\":\"fi\"},{\"name\":\"Prénom\",\"locale\":\"fr\"},{\"name\":\"पहचानी गई नाम\",\"locale\":\"hi\"},{\"name\":\"Nome\",\"locale\":\"it\"},{\"name\":\"名\",\"locale\":\"ja\"},{\"name\":\"Овог нэр\",\"locale\":\"mn\"},{\"name\":\"Voornaam\",\"locale\":\"nl\"},{\"name\":\"Nome Próprio\",\"locale\":\"pt\"},{\"name\":\"Förnamn\",\"locale\":\"sv\"},{\"name\":\"مسلمان نام\",\"locale\":\"ur\"}]},\"family_name\":{\"display\":[{\"name\":\"اسم العائلة\",\"locale\":\"ar\"},{\"name\":\"Nachname\",\"locale\":\"de\"},{\"name\":\"Family Name\",\"locale\":\"en\"},{\"name\":\"Apellido\",\"locale\":\"es\"},{\"name\":\"نام خانوادگی\",\"locale\":\"fa\"},{\"name\":\"Sukunimi\",\"locale\":\"fi\"},{\"name\":\"Nom de famille\",\"locale\":\"fr\"},{\"name\":\"परिवार का नाम\",\"locale\":\"hi\"},{\"name\":\"Cognome\",\"locale\":\"it\"},{\"name\":\"姓\",\"locale\":\"ja\"},{\"name\":\"өөрийн нэр\",\"locale\":\"mn\"},{\"name\":\"Achternaam\",\"locale\":\"nl\"},{\"name\":\"Sobrenome\",\"locale\":\"pt\"},{\"name\":\"Efternamn\",\"locale\":\"sv\"},{\"name\":\"خاندانی نام\",\"locale\":\"ur\"}]},\"birthdate\":{\"display\":[{\"name\":\"تاريخ الميلاد\",\"locale\":\"ar\"},{\"name\":\"Geburtsdatum\",\"locale\":\"de\"},{\"name\":\"Date of Birth\",\"locale\":\"en\"},{\"name\":\"Fecha de Nacimiento\",\"locale\":\"es\"},{\"name\":\"تاریخ تولد\",\"locale\":\"fa\"},{\"name\":\"Syntymäaika\",\"locale\":\"fi\"},{\"name\":\"Date de naissance\",\"locale\":\"fr\"},{\"name\":\"जन्म की तारीख\",\"locale\":\"hi\"},{\"name\":\"Data di nascita\",\"locale\":\"it\"},{\"name\":\"生年月日\",\"locale\":\"ja\"},{\"name\":\"төрсөн өдөр\",\"locale\":\"mn\"},{\"name\":\"Geboortedatum\",\"locale\":\"nl\"},{\"name\":\"Data de Nascimento\",\"locale\":\"pt\"},{\"name\":\"Födelsedatum\",\"locale\":\"sv\"},{\"name\":\"تاریخ پیدائش\",\"locale\":\"ur\"}]}}", + "vc.IdentityCredential.display.0", "{\"name\": \"Identity Credential\"}", + "vc.IdentityCredential.proof_types_supported", "{\"jwt\":{\"proof_signing_alg_values_supported\":[\"ES256\"]}}" + ); + HashedMap allAttributes = new HashedMap<>(); + allAttributes.putAll(testCredentialAttributes); + allAttributes.putAll(identityCredentialAttributes); + clientRepresentation.setAttributes(allAttributes); + clientRepresentation.setProtocolMappers( + List.of( + getRoleMapper(clientId, "test-credential"), + getUserAttributeMapper("email", "email", "test-credential"), + getUserAttributeMapper("firstName", "firstName", "test-credential"), + getUserAttributeMapper("lastName", "lastName", "test-credential"), + getIdMapper("test-credential"), + getStaticClaimMapper("test-credential", "test-credential"), + + getUserAttributeMapper("given_name", "firstName", "identity_credential"), + getUserAttributeMapper("family_name", "lastName", "identity_credential") + ) + ); + return clientRepresentation; } - private void requestOffer(String token, String credentialEndpoint, SupportedCredentialConfiguration offeredCredential) throws IOException, VerificationException { - CredentialRequest request = new CredentialRequest(); - request.setFormat(offeredCredential.getFormat()); - request.setCredentialIdentifier(offeredCredential.getId()); - - StringEntity stringEntity = new StringEntity(OBJECT_MAPPER.writeValueAsString(request), ContentType.APPLICATION_JSON); - - HttpPost postCredential = new HttpPost(credentialEndpoint); - postCredential.addHeader(HttpHeaders.AUTHORIZATION, "Bearer " + token); - postCredential.setEntity(stringEntity); - CloseableHttpResponse credentialRequestResponse = httpClient.execute(postCredential); - assertEquals(HttpStatus.SC_OK, credentialRequestResponse.getStatusLine().getStatusCode()); - String s = IOUtils.toString(credentialRequestResponse.getEntity().getContent(), StandardCharsets.UTF_8); - CredentialResponse credentialResponse = JsonSerialization.readValue(s, CredentialResponse.class); - - assertNotNull("The credential should have been responded.", credentialResponse.getCredential()); - JsonWebToken jsonWebToken = TokenVerifier.create((String) credentialResponse.getCredential(), JsonWebToken.class).getToken(); - assertEquals("did:web:test.org", jsonWebToken.getIssuer()); - VerifiableCredential credential = new ObjectMapper().convertValue(jsonWebToken.getOtherClaims().get("vc"), VerifiableCredential.class); - assertEquals(List.of("VerifiableCredential"), credential.getType()); - assertEquals(URI.create("did:web:test.org"), credential.getIssuer()); - assertEquals("john@email.cz", credential.getCredentialSubject().getClaims().get("email")); - assertTrue("The static claim should be set.", credential.getCredentialSubject().getClaims().containsKey("VerifiableCredential")); - assertFalse("Only mappers supported for the requested type should have been evaluated.", credential.getCredentialSubject().getClaims().containsKey("AnotherCredentialType")); + protected ComponentExportRepresentation getKeyProvider(){ + return getEcKeyProvider(); } @Override - public void configureTestRealm(RealmRepresentation testRealm) { - if (testRealm.getComponents() != null) { - testRealm.getComponents().add("org.keycloak.keys.KeyProvider", getRsaKeyProvider(RSA_KEY)); - testRealm.getComponents().add("org.keycloak.protocol.oid4vc.issuance.signing.VerifiableCredentialsSigningService", getJwtSigningProvider(RSA_KEY)); - } else { - testRealm.setComponents(new MultivaluedHashMap<>( - Map.of("org.keycloak.keys.KeyProvider", List.of(getRsaKeyProvider(RSA_KEY)), - "org.keycloak.protocol.oid4vc.issuance.signing.VerifiableCredentialsSigningService", List.of(getJwtSigningProvider(RSA_KEY)) - ))); - } - ClientRepresentation clientRepresentation = getTestClient("did:web:test.org"); - if (testRealm.getClients() != null) { - testRealm.getClients().add(clientRepresentation); - } else { - testRealm.setClients(List.of(clientRepresentation)); - } - if (testRealm.getRoles() != null) { - testRealm.getRoles().getClient() - .put(clientRepresentation.getClientId(), List.of(getRoleRepresentation("testRole", clientRepresentation.getClientId()))); - } else { - testRealm.getRoles() - .setClient(Map.of(clientRepresentation.getClientId(), List.of(getRoleRepresentation("testRole", clientRepresentation.getClientId())))); - } - if (testRealm.getUsers() != null) { - testRealm.getUsers().add(getUserRepresentation(Map.of(clientRepresentation.getClientId(), List.of("testRole")))); - } else { - testRealm.setUsers(List.of(getUserRepresentation(Map.of(clientRepresentation.getClientId(), List.of("testRole"))))); - } - if (testRealm.getAttributes() != null) { - testRealm.getAttributes().put("issuerDid", TEST_DID.toString()); - } else { - testRealm.setAttributes(Map.of("issuerDid", TEST_DID.toString())); - } + protected List getSigningProviders() { + return List.of(getIdCredentialSigningProvider(), getTestCredentialSigningProvider()); } - private void withCausePropagation(Runnable r) throws Throwable { - try { - r.run(); - } catch (Exception e) { - if (e instanceof RunOnServerException) { - throw e.getCause(); - } - throw e; + static class TestCredentialResponseHandler extends CredentialResponseHandler { + final String vct; + TestCredentialResponseHandler(String vct){ + this.vct = vct; } - } + @Override + protected void handleCredentialResponse(CredentialResponse credentialResponse) throws VerificationException { + // SDJWT have a special format. + SdJwtVP sdJwtVP = SdJwtVP.of(credentialResponse.getCredential().toString()); + JsonWebToken jsonWebToken = TokenVerifier.create(sdJwtVP.getIssuerSignedJWT().getJwsString(), JsonWebToken.class).getToken(); + + assertNotNull("A valid credential string should have been responded", jsonWebToken); + assertNotNull("The credentials should be included at the vct-claim.", jsonWebToken.getOtherClaims().get("vct")); + assertEquals("The credentials should be included at the vct-claim.", vct, jsonWebToken.getOtherClaims().get("vct").toString()); + + Map disclosureMap = sdJwtVP.getDisclosures().values().stream() + .map(disclosure -> { + try { + JsonNode jsonNode = OBJECT_MAPPER.readTree(Base64Url.decode(disclosure)); + return Map.entry(jsonNode.get(1).asText(), jsonNode); // Create a Map.Entry + } catch (IOException e) { + throw new RuntimeException(e); // Re-throw as unchecked exception + } + }) + .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); + assertFalse("Only mappers supported for the requested type should have been evaluated.", disclosureMap.containsKey("given_name")); + assertTrue("The credentials should include the firstName claim.", disclosureMap.containsKey("firstName")); + assertEquals("firstName claim incorrectly mapped.", disclosureMap.get("firstName").get(2).asText(), "John"); + assertTrue("The credentials should include the lastName claim.", disclosureMap.containsKey("lastName")); + assertEquals("lastName claim incorrectly mapped.", disclosureMap.get("lastName").get(2).asText(), "Doe"); + assertTrue("The credentials should include the roles claim.", disclosureMap.containsKey("roles")); + assertTrue("The credentials should include the id claim", disclosureMap.containsKey("id")); + assertTrue("The credentials should include the test-credential claim.", disclosureMap.containsKey("test-credential")); + assertTrue("lastName claim incorrectly mapped.", disclosureMap.get("test-credential").get(2).asBoolean()); + assertTrue("The credentials should include the email claim.", disclosureMap.containsKey("email")); + assertEquals("email claim incorrectly mapped.", disclosureMap.get("email").get(2).asText(), "john@email.cz"); + } + } } diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCTest.java index 92300db93386..ecf9dcab7781 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCTest.java @@ -179,7 +179,7 @@ public static ComponentExportRepresentation getRsaKeyProvider(KeyWrapper keyWrap return componentExportRepresentation; } - public static ClientRepresentation getTestClient(String clientId) { + protected ClientRepresentation getTestClient(String clientId) { ClientRepresentation clientRepresentation = new ClientRepresentation(); clientRepresentation.setClientId(clientId); clientRepresentation.setProtocol(OID4VCLoginProtocolFactory.PROTOCOL_ID); @@ -189,19 +189,17 @@ public static ClientRepresentation getTestClient(String clientId) { "vc.test-credential.format", Format.JWT_VC.toString(), "vc.test-credential.scope", "VerifiableCredential", "vc.test-credential.claims", "{ \"firstName\": {\"mandatory\": false, \"display\": [{\"name\": \"First Name\", \"locale\": \"en-US\"}, {\"name\": \"名前\", \"locale\": \"ja-JP\"}]}, \"lastName\": {\"mandatory\": false}, \"email\": {\"mandatory\": false} }", - "vc.test-credential.vct", "VerifiableCredential", - "vc.test-credential.cryptographic_binding_methods_supported", "jwk", - "vc.test-credential.credential_signing_alg_values_supported", "ES256,ES384", - "vc.test-credential.display.0","{\n \"name\": \"Test Credential\"\n}", - "vc.test-credential.proof_types_supported","{\"jwt\":{\"proof_signing_alg_values_supported\":[\"ES256\"]}}" + "vc.test-credential.display.0","{\n \"name\": \"Test Credential\"\n}" + // Moved sd-jwt specific attributes to: org.keycloak.testsuite.oid4vc.issuance.signing.OID4VCSdJwtIssuingEndpointTest.getTestCredentialSigningProvider )); clientRepresentation.setProtocolMappers( List.of( - getRoleMapper(clientId), - getEmailMapper(), - getIdMapper(), - getStaticClaimMapper("VerifiableCredential"), - getStaticClaimMapper("AnotherCredentialType") + getRoleMapper(clientId, "VerifiableCredential"), + getUserAttributeMapper("email", "email", "VerifiableCredential"), + getIdMapper("VerifiableCredential"), + getStaticClaimMapper("VerifiableCredential", "VerifiableCredential"), + // This is used for negative test. Shall not land into the credential + getStaticClaimMapper("AnotherCredentialType", "AnotherCredentialType") ) ); return clientRepresentation; @@ -226,11 +224,15 @@ protected ComponentExportRepresentation getEcKeyProvider() { componentExportRepresentation.setName("ecdsa-issuer-key"); componentExportRepresentation.setId(UUID.randomUUID().toString()); componentExportRepresentation.setProviderId("ecdsa-generated"); - componentExportRepresentation.setConfig(new MultivaluedHashMap<>(Map.of("ecdsaEllipticCurveKey", List.of("P-256")))); + componentExportRepresentation.setConfig(new MultivaluedHashMap<>( + Map.of( + "ecdsaEllipticCurveKey", List.of("P-256"), + "algorithm", List.of("ES256") )) + ); return componentExportRepresentation; } - public static ProtocolMapperRepresentation getRoleMapper(String clientId) { + public static ProtocolMapperRepresentation getRoleMapper(String clientId, String supportedCredentialTypes) { ProtocolMapperRepresentation protocolMapperRepresentation = new ProtocolMapperRepresentation(); protocolMapperRepresentation.setName("role-mapper"); protocolMapperRepresentation.setId(UUID.randomUUID().toString()); @@ -240,27 +242,12 @@ public static ProtocolMapperRepresentation getRoleMapper(String clientId) { Map.of( "subjectProperty", "roles", "clientId", clientId, - "supportedCredentialTypes", "VerifiableCredential") - ); - return protocolMapperRepresentation; - } - - public static ProtocolMapperRepresentation getEmailMapper() { - ProtocolMapperRepresentation protocolMapperRepresentation = new ProtocolMapperRepresentation(); - protocolMapperRepresentation.setName("email-mapper"); - protocolMapperRepresentation.setProtocol("oid4vc"); - protocolMapperRepresentation.setId(UUID.randomUUID().toString()); - protocolMapperRepresentation.setProtocolMapper("oid4vc-user-attribute-mapper"); - protocolMapperRepresentation.setConfig( - Map.of( - "subjectProperty", "email", - "userAttribute", "email", - "supportedCredentialTypes", "VerifiableCredential") + "supportedCredentialTypes", supportedCredentialTypes) ); return protocolMapperRepresentation; } - public static ProtocolMapperRepresentation getIdMapper() { + public static ProtocolMapperRepresentation getIdMapper(String supportedCredentialTypes) { ProtocolMapperRepresentation protocolMapperRepresentation = new ProtocolMapperRepresentation(); protocolMapperRepresentation.setName("id-mapper"); protocolMapperRepresentation.setProtocol("oid4vc"); @@ -268,12 +255,12 @@ public static ProtocolMapperRepresentation getIdMapper() { protocolMapperRepresentation.setProtocolMapper("oid4vc-subject-id-mapper"); protocolMapperRepresentation.setConfig( Map.of( - "supportedCredentialTypes", "VerifiableCredential") + "supportedCredentialTypes", supportedCredentialTypes) ); return protocolMapperRepresentation; } - public static ProtocolMapperRepresentation getStaticClaimMapper(String supportedType) { + public static ProtocolMapperRepresentation getStaticClaimMapper(String scope, String supportedCredentialTypes) { ProtocolMapperRepresentation protocolMapperRepresentation = new ProtocolMapperRepresentation(); protocolMapperRepresentation.setName(UUID.randomUUID().toString()); protocolMapperRepresentation.setProtocol("oid4vc"); @@ -281,9 +268,9 @@ public static ProtocolMapperRepresentation getStaticClaimMapper(String supported protocolMapperRepresentation.setProtocolMapper("oid4vc-static-claim-mapper"); protocolMapperRepresentation.setConfig( Map.of( - "subjectProperty", supportedType, + "subjectProperty", scope, "staticValue", "true", - "supportedCredentialTypes", supportedType) + "supportedCredentialTypes", supportedCredentialTypes) ); return protocolMapperRepresentation; } @@ -366,4 +353,18 @@ public long currentTimeMillis() { } } + protected ProtocolMapperRepresentation getUserAttributeMapper(String subjectProperty, String atributeName, String supportedCredentialTypes) { + ProtocolMapperRepresentation protocolMapperRepresentation = new ProtocolMapperRepresentation(); + protocolMapperRepresentation.setName(supportedCredentialTypes + "-" + atributeName + "-mapper"); + protocolMapperRepresentation.setProtocol("oid4vc"); + protocolMapperRepresentation.setId(UUID.randomUUID().toString()); + protocolMapperRepresentation.setProtocolMapper("oid4vc-user-attribute-mapper"); + protocolMapperRepresentation.setConfig( + Map.of( + "subjectProperty", subjectProperty, + "userAttribute", atributeName, + "supportedCredentialTypes", supportedCredentialTypes) + ); + return protocolMapperRepresentation; + } } diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/SdJwtSigningServiceTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/SdJwtSigningServiceTest.java index 515b93fee318..f9babe80a1c9 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/SdJwtSigningServiceTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/SdJwtSigningServiceTest.java @@ -34,8 +34,10 @@ import org.keycloak.protocol.oid4vc.issuance.signing.JwtSigningService; import org.keycloak.protocol.oid4vc.issuance.signing.SdJwtSigningService; import org.keycloak.protocol.oid4vc.issuance.signing.SigningServiceException; +import org.keycloak.protocol.oid4vc.model.CredentialConfigId; import org.keycloak.protocol.oid4vc.model.SupportedCredentialConfiguration; import org.keycloak.protocol.oid4vc.model.VerifiableCredential; +import org.keycloak.protocol.oid4vc.model.VerifiableCredentialType; import org.keycloak.representations.JsonWebToken; import org.keycloak.representations.idm.RealmRepresentation; import org.keycloak.sdjwt.SdJwtUtils; @@ -76,8 +78,9 @@ public void testUnsupportedAlgorithm() throws Throwable { "did:web:test.org", 0, List.of(), - new StaticTimeProvider(1000), - Optional.empty(), null)); + Optional.empty(), + VerifiableCredentialType.from("https://credentials.example.com/test-credential"), + CredentialConfigId.from("test-credential"))); } catch (RunOnServerException ros) { throw ros.getCause(); } @@ -195,8 +198,9 @@ public static void testSignSDJwtCredential(KeycloakSession session, Optional sds = (List) theToken.getOtherClaims().get("_sd"); if (sds != null && !sds.isEmpty()){ From 346b4151539737e83126f8cc9705b462441c1081 Mon Sep 17 00:00:00 2001 From: Francis Pouatcha Date: Mon, 15 Jul 2024 22:07:48 +0100 Subject: [PATCH 14/19] Processing review comments Signed-off-by: Francis Pouatcha --- .../oid4vc/issuance/OID4VCIssuerEndpoint.java | 2 +- .../issuance/signing/SigningService.java | 4 +- .../VerifiableCredentialsSigningService.java | 1 - .../adapters/undertow/ChangeSessionId.java | 68 ++++ .../adapters/undertow/SavedRequest.java | 82 +++++ .../adapters/undertow/ServletHttpFacade.java | 96 ++++++ .../undertow/SessionManagementBridge.java | 48 +++ .../adapters/undertow/UndertowHttpFacade.java | 298 ++++++++++++++++++ .../UndertowUserSessionManagement.java | 160 ++++++++++ .../signing/OID4VCJWTIssuerEndpointTest.java | 2 +- .../OID4VCSdJwtIssuingEndpointTest.java | 2 +- .../oid4vc/issuance/signing/OID4VCTest.java | 2 +- 12 files changed, 758 insertions(+), 7 deletions(-) create mode 100644 testsuite/integration-arquillian/servers/adapter-spi/undertow-adapter-spi-jakarta/src/main/java/org/keycloak/adapters/undertow/ChangeSessionId.java create mode 100644 testsuite/integration-arquillian/servers/adapter-spi/undertow-adapter-spi-jakarta/src/main/java/org/keycloak/adapters/undertow/SavedRequest.java create mode 100644 testsuite/integration-arquillian/servers/adapter-spi/undertow-adapter-spi-jakarta/src/main/java/org/keycloak/adapters/undertow/ServletHttpFacade.java create mode 100644 testsuite/integration-arquillian/servers/adapter-spi/undertow-adapter-spi-jakarta/src/main/java/org/keycloak/adapters/undertow/SessionManagementBridge.java create mode 100644 testsuite/integration-arquillian/servers/adapter-spi/undertow-adapter-spi-jakarta/src/main/java/org/keycloak/adapters/undertow/UndertowHttpFacade.java create mode 100644 testsuite/integration-arquillian/servers/adapter-spi/undertow-adapter-spi-jakarta/src/main/java/org/keycloak/adapters/undertow/UndertowUserSessionManagement.java 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 ac0c939b85d1..0d0ed51932b9 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 @@ -463,7 +463,7 @@ private Object getCredential(AuthenticationManager.AuthResult authResult, Suppor return Optional.ofNullable(signingService) .map(service -> service.signCredential(vcIssuanceContext)) - .orElseThrow(() -> new IllegalArgumentException( + .orElseThrow(() -> new BadRequestException( String.format("No signer found for specific config '%s' or '%s' or format '%s'.", fullyQualifiedConfigKey, formatAndTypeKey, formatOnlyKey) )); } diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/signing/SigningService.java b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/signing/SigningService.java index 4046615462d6..611618606bbe 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/signing/SigningService.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/signing/SigningService.java @@ -69,7 +69,7 @@ public String locator() { protected KeyWrapper getKey(String kid, String algorithm) { // Allow the service to work with the active key if keyId is null // And we still have to figure out how to proceed with key rotation - if(keyId==null){ + if (keyId == null) { return keycloakSession.keys().getActiveKey(keycloakSession.getContext().getRealm(), KeyUse.SIG, algorithm); } return keycloakSession.keys().getKey(keycloakSession.getContext().getRealm(), kid, KeyUse.SIG, algorithm); @@ -93,7 +93,7 @@ private KeyWrapper getKeyWraper(JWK jwk, String algorithm, KeyUse keyUse) { keyWrapper.setCurve((String) jwk.getOtherClaims().get(OKPPublicJWK.CRV)); } - keyWrapper.setUse(KeyUse.SIG); + keyWrapper.setUse(keyUse); JWKParser parser = JWKParser.create(jwk); keyWrapper.setPublicKey(parser.toPublicKey()); return keyWrapper; diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/signing/VerifiableCredentialsSigningService.java b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/signing/VerifiableCredentialsSigningService.java index 0ae3b1ab2b62..9a572378159f 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/signing/VerifiableCredentialsSigningService.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/signing/VerifiableCredentialsSigningService.java @@ -21,7 +21,6 @@ import org.keycloak.protocol.oid4vc.issuance.VCIssuerException; import org.keycloak.protocol.oid4vc.model.CredentialConfigId; import org.keycloak.protocol.oid4vc.model.VerifiableCredentialType; -import org.keycloak.protocol.oid4vc.model.Format; import org.keycloak.provider.Provider; /** diff --git a/testsuite/integration-arquillian/servers/adapter-spi/undertow-adapter-spi-jakarta/src/main/java/org/keycloak/adapters/undertow/ChangeSessionId.java b/testsuite/integration-arquillian/servers/adapter-spi/undertow-adapter-spi-jakarta/src/main/java/org/keycloak/adapters/undertow/ChangeSessionId.java new file mode 100644 index 000000000000..24eef7e0ddcb --- /dev/null +++ b/testsuite/integration-arquillian/servers/adapter-spi/undertow-adapter-spi-jakarta/src/main/java/org/keycloak/adapters/undertow/ChangeSessionId.java @@ -0,0 +1,68 @@ +/* + * Copyright 2016 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.adapters.undertow; + +import io.undertow.server.HttpServerExchange; +import io.undertow.server.session.Session; +import io.undertow.servlet.api.DeploymentInfo; +import io.undertow.servlet.handlers.ServletRequestContext; +import io.undertow.servlet.spec.HttpSessionImpl; +import io.undertow.servlet.spec.ServletContextImpl; + +import java.lang.reflect.Method; +import java.security.AccessController; + +/** + * @author Bill Burke + * @version $Revision: 1 $ + */ +public class ChangeSessionId { + /** + * This is a hack to be backward compatible between Undertow 1.3+ and versions lower. In Undertow 1.3, a new + * switch was added setChangeSessionIdOnLogin, this screws up session management for keycloak as after the session id + * is uploaded to Keycloak, undertow changes the session id and it can't be invalidated. + * + * @param deploymentInfo + */ + public static void turnOffChangeSessionIdOnLogin(DeploymentInfo deploymentInfo) { + try { + Method method = DeploymentInfo.class.getMethod("setChangeSessionIdOnLogin", boolean.class); + method.invoke(deploymentInfo, false); + } catch (Exception ignore) { + + } + } + + public static String changeSessionId(HttpServerExchange exchange, boolean create) { + final ServletRequestContext sc = exchange.getAttachment(ServletRequestContext.ATTACHMENT_KEY); + ServletContextImpl currentServletContext = sc.getCurrentServletContext(); + HttpSessionImpl session = currentServletContext.getSession(exchange, create); + if (session == null) { + return null; + } + Session underlyingSession; + if(System.getSecurityManager() == null) { + underlyingSession = session.getSession(); + } else { + underlyingSession = AccessController.doPrivileged(new HttpSessionImpl.UnwrapSessionAction(session)); + } + + + return underlyingSession.changeSessionId(exchange, currentServletContext.getSessionConfig()); + } +} diff --git a/testsuite/integration-arquillian/servers/adapter-spi/undertow-adapter-spi-jakarta/src/main/java/org/keycloak/adapters/undertow/SavedRequest.java b/testsuite/integration-arquillian/servers/adapter-spi/undertow-adapter-spi-jakarta/src/main/java/org/keycloak/adapters/undertow/SavedRequest.java new file mode 100644 index 000000000000..478fd275ca5b --- /dev/null +++ b/testsuite/integration-arquillian/servers/adapter-spi/undertow-adapter-spi-jakarta/src/main/java/org/keycloak/adapters/undertow/SavedRequest.java @@ -0,0 +1,82 @@ +/* + * Copyright 2016 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.adapters.undertow; + +import io.undertow.server.HttpServerExchange; +import io.undertow.server.session.Session; +import io.undertow.servlet.handlers.ServletRequestContext; +import io.undertow.servlet.spec.HttpSessionImpl; + +import jakarta.servlet.http.HttpSession; +import java.io.Serializable; +import java.security.AccessController; + +/** + * Saved servlet request. + * + * Note bill burke: I had to fork this because Undertow was automatically restoring the request before the code could be + * processed and redirected. + * + * CachedAuthenticatedSessionHandler was restoring the request before the authentication manager could read the code from the URI + * Originally, I copied SavedRequest as is, but there are type mismatches between Undertow 1.1.1 and 1.3.10. + * So, trySaveRequest calls the same undertow version, removes the saved request, stores it in a different session attribute, + * then restores the old attribute later + * + * + * @author Stuart Douglas + */ +public class SavedRequest implements Serializable { + + private static final String SESSION_KEY = SavedRequest.class.getName(); + + public static void trySaveRequest(final HttpServerExchange exchange) { + io.undertow.servlet.util.SavedRequest.trySaveRequest(exchange); + final ServletRequestContext sc = exchange.getAttachment(ServletRequestContext.ATTACHMENT_KEY); + HttpSessionImpl session = sc.getCurrentServletContext().getSession(exchange, true); + Session underlyingSession; + if(System.getSecurityManager() == null) { + underlyingSession = session.getSession(); + } else { + underlyingSession = AccessController.doPrivileged(new HttpSessionImpl.UnwrapSessionAction(session)); + } + io.undertow.servlet.util.SavedRequest request = (io.undertow.servlet.util.SavedRequest) underlyingSession.removeAttribute(io.undertow.servlet.util.SavedRequest.class.getName()); + if (request != null) underlyingSession.setAttribute(SESSION_KEY, request); + + + } + + public static void tryRestoreRequest(final HttpServerExchange exchange, HttpSession session) { + if(session instanceof HttpSessionImpl) { + + Session underlyingSession; + if(System.getSecurityManager() == null) { + underlyingSession = ((HttpSessionImpl) session).getSession(); + } else { + underlyingSession = AccessController.doPrivileged(new HttpSessionImpl.UnwrapSessionAction(session)); + } + io.undertow.servlet.util.SavedRequest request = (io.undertow.servlet.util.SavedRequest) underlyingSession.removeAttribute(SESSION_KEY); + if (request != null) { + underlyingSession.setAttribute(io.undertow.servlet.util.SavedRequest.class.getName(), request); + io.undertow.servlet.util.SavedRequest.tryRestoreRequest(exchange, session); + + } + + } + } + +} diff --git a/testsuite/integration-arquillian/servers/adapter-spi/undertow-adapter-spi-jakarta/src/main/java/org/keycloak/adapters/undertow/ServletHttpFacade.java b/testsuite/integration-arquillian/servers/adapter-spi/undertow-adapter-spi-jakarta/src/main/java/org/keycloak/adapters/undertow/ServletHttpFacade.java new file mode 100644 index 000000000000..c89f7e9e579b --- /dev/null +++ b/testsuite/integration-arquillian/servers/adapter-spi/undertow-adapter-spi-jakarta/src/main/java/org/keycloak/adapters/undertow/ServletHttpFacade.java @@ -0,0 +1,96 @@ +/* + * Copyright 2016 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.adapters.undertow; + +import io.undertow.server.HttpServerExchange; +import io.undertow.servlet.handlers.ServletRequestContext; +import org.keycloak.adapters.spi.AuthenticationError; +import org.keycloak.adapters.spi.LogoutError; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +/** + * @author Bill Burke + * @version $Revision: 1 $ + */ +public class ServletHttpFacade extends UndertowHttpFacade { + protected HttpServletRequest request; + protected HttpServletResponse response; + + public ServletHttpFacade(HttpServerExchange exchange) { + super(exchange); + final ServletRequestContext servletRequestContext = exchange.getAttachment(ServletRequestContext.ATTACHMENT_KEY); + request = (HttpServletRequest)servletRequestContext.getServletRequest(); + response = (HttpServletResponse)servletRequestContext.getServletResponse(); + } + + protected class RequestFacade extends UndertowHttpFacade.RequestFacade { + @Override + public String getFirstParam(String param) { + return request.getParameter(param); + } + + @Override + public void setError(AuthenticationError error) { + request.setAttribute(AuthenticationError.class.getName(), error); + + } + + @Override + public void setError(LogoutError error) { + request.setAttribute(LogoutError.class.getName(), error); + } + + + } + + protected class ResponseFacade extends UndertowHttpFacade.ResponseFacade { + // can't call sendError from a challenge. Undertow ends up calling send error. + /* + @Override + public void sendError(int code) { + try { + response.sendError(code); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + @Override + public void sendError(int code, String message) { + try { + response.sendError(code, message); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + */ + + } + + @Override + public Response getResponse() { + return new ResponseFacade(); + } + + @Override + public Request getRequest() { + return new RequestFacade(); + } +} diff --git a/testsuite/integration-arquillian/servers/adapter-spi/undertow-adapter-spi-jakarta/src/main/java/org/keycloak/adapters/undertow/SessionManagementBridge.java b/testsuite/integration-arquillian/servers/adapter-spi/undertow-adapter-spi-jakarta/src/main/java/org/keycloak/adapters/undertow/SessionManagementBridge.java new file mode 100644 index 000000000000..fcde53737bbc --- /dev/null +++ b/testsuite/integration-arquillian/servers/adapter-spi/undertow-adapter-spi-jakarta/src/main/java/org/keycloak/adapters/undertow/SessionManagementBridge.java @@ -0,0 +1,48 @@ +/* + * Copyright 2016 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.adapters.undertow; + +import io.undertow.server.session.SessionManager; +import org.keycloak.adapters.spi.UserSessionManagement; + +import java.util.List; + +/** + * @author Bill Burke + * @version $Revision: 1 $ + */ +public class SessionManagementBridge implements UserSessionManagement { + + protected UndertowUserSessionManagement userSessionManagement; + protected SessionManager sessionManager; + + public SessionManagementBridge(UndertowUserSessionManagement userSessionManagement, SessionManager sessionManager) { + this.userSessionManagement = userSessionManagement; + this.sessionManager = sessionManager; + } + + @Override + public void logoutAll() { + userSessionManagement.logoutAll(sessionManager); + } + + @Override + public void logoutHttpSessions(List ids) { + userSessionManagement.logoutHttpSessions(sessionManager, ids); + } + +} diff --git a/testsuite/integration-arquillian/servers/adapter-spi/undertow-adapter-spi-jakarta/src/main/java/org/keycloak/adapters/undertow/UndertowHttpFacade.java b/testsuite/integration-arquillian/servers/adapter-spi/undertow-adapter-spi-jakarta/src/main/java/org/keycloak/adapters/undertow/UndertowHttpFacade.java new file mode 100644 index 000000000000..ceac32880416 --- /dev/null +++ b/testsuite/integration-arquillian/servers/adapter-spi/undertow-adapter-spi-jakarta/src/main/java/org/keycloak/adapters/undertow/UndertowHttpFacade.java @@ -0,0 +1,298 @@ +/* + * Copyright 2016 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.adapters.undertow; + +import io.undertow.server.HttpServerExchange; +import io.undertow.server.handlers.CookieImpl; +import io.undertow.server.handlers.form.FormData; +import io.undertow.server.handlers.form.FormData.FormValue; +import io.undertow.server.handlers.form.FormDataParser; +import io.undertow.server.handlers.form.FormParserFactory; +import io.undertow.servlet.handlers.ServletRequestContext; +import io.undertow.util.AttachmentKey; +import io.undertow.util.Headers; +import io.undertow.util.HttpString; +import org.keycloak.adapters.spi.AuthenticationError; +import org.keycloak.adapters.spi.HttpFacade; +import org.keycloak.adapters.spi.LogoutError; +import org.keycloak.common.util.KeycloakUriBuilder; + +import javax.security.cert.X509Certificate; +import jakarta.servlet.ServletInputStream; +import jakarta.servlet.ServletRequest; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletRequestWrapper; + +import java.io.BufferedInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.util.Deque; +import java.util.List; +import java.util.Map; + +/** + * @author Bill Burke + * @version $Revision: 1 $ + */ +public class UndertowHttpFacade implements HttpFacade { + public static final AttachmentKey AUTH_ERROR_ATTACHMENT_KEY = AttachmentKey.create(AuthenticationError.class); + public static final AttachmentKey LOGOUT_ERROR_ATTACHMENT_KEY = AttachmentKey.create(LogoutError.class); + + protected HttpServerExchange exchange; + protected RequestFacade requestFacade = new RequestFacade(); + protected ResponseFacade responseFacade = new ResponseFacade(); + + public UndertowHttpFacade(HttpServerExchange exchange) { + this.exchange = exchange; + } + + @Override + public Request getRequest() { + return requestFacade; + } + + @Override + public Response getResponse() { + return responseFacade; + } + + @Override + public X509Certificate[] getCertificateChain() { + X509Certificate[] chain = new X509Certificate[0]; + try { + chain = exchange.getConnection().getSslSessionInfo().getPeerCertificateChain(); + } catch (Exception ignore) { + + } + return chain; + } + + protected class RequestFacade implements Request { + + private InputStream inputStream; + private final FormParserFactory formParserFactory = FormParserFactory.builder().build(); + private FormData formData; + + @Override + public String getURI() { + KeycloakUriBuilder uriBuilder = KeycloakUriBuilder.fromUri(exchange.getRequestURI()) + .replaceQuery(exchange.getQueryString()); + if (!exchange.isHostIncludedInRequestURI()) uriBuilder.scheme(exchange.getRequestScheme()).host(exchange.getHostAndPort()); + return uriBuilder.buildAsString(); + } + + @Override + public String getRelativePath() { + return exchange.getRelativePath(); + } + + @Override + public boolean isSecure() { + String protocol = exchange.getRequestScheme(); + return protocol.equalsIgnoreCase("https"); + } + + @Override + public String getFirstParam(String param) { + Deque values = exchange.getQueryParameters().get(param); + + if (values != null && !values.isEmpty()) { + return values.getFirst(); + } + + if (formData == null && "post".equalsIgnoreCase(getMethod())) { + FormDataParser parser = formParserFactory.createParser(exchange); + try { + formData = parser.parseBlocking(); + } catch (IOException cause) { + throw new RuntimeException("Failed to parse form parameters", cause); + } + } + + if (formData != null) { + Deque formValues = formData.get(param); + + if (formValues != null && !formValues.isEmpty()) { + FormValue firstValue = formValues.getFirst(); + + if (!firstValue.isFile()) { + return firstValue.getValue(); + } + } + } + + return null; + } + + @Override + public String getQueryParamValue(String param) { + Map> queryParameters = exchange.getQueryParameters(); + if (queryParameters == null) return null; + Deque strings = queryParameters.get(param); + if (strings == null) return null; + return strings.getFirst(); + } + + @Override + public Cookie getCookie(String cookieName) { + Map requestCookies = exchange.getRequestCookies(); + if (requestCookies == null) return null; + io.undertow.server.handlers.Cookie cookie = requestCookies.get(cookieName); + if (cookie == null) return null; + return new Cookie(cookie.getName(), cookie.getValue(), cookie.getVersion(), cookie.getDomain(), cookie.getPath()); + } + + @Override + public List getHeaders(String name) { + return exchange.getRequestHeaders().get(name); + } + + @Override + public String getMethod() { + return exchange.getRequestMethod().toString(); + } + + + + @Override + public String getHeader(String name) { + return exchange.getRequestHeaders().getFirst(name); + } + + @Override + public InputStream getInputStream() { + return getInputStream(false); + } + + @Override + public InputStream getInputStream(boolean buffered) { + if (!exchange.isBlocking()) exchange.startBlocking(); + + if (inputStream != null) { + return inputStream; + } + + if (buffered) { + ServletRequestContext context = exchange.getAttachment(ServletRequestContext.ATTACHMENT_KEY); + ServletRequest servletRequest = context.getServletRequest(); + + inputStream = new BufferedInputStream(exchange.getInputStream()); + + context.setServletRequest(UndertowHttpServletRequest.setupServletInputStream(servletRequest, inputStream)); + return inputStream; + } + + return exchange.getInputStream(); + } + + @Override + public String getRemoteAddr() { + InetSocketAddress sourceAddress = exchange.getSourceAddress(); + if (sourceAddress == null) { + return ""; + } + InetAddress address = sourceAddress.getAddress(); + if (address == null) { + // this is unresolved, so we just return the host name not exactly spec, but if the name should be + // resolved then a PeerNameResolvingHandler should be used and this is probably better than just + // returning null + return sourceAddress.getHostString(); + } + return address.getHostAddress(); + } + + @Override + public void setError(AuthenticationError error) { + exchange.putAttachment(AUTH_ERROR_ATTACHMENT_KEY, error); + } + + @Override + public void setError(LogoutError error) { + exchange.putAttachment(LOGOUT_ERROR_ATTACHMENT_KEY, error); + + } + } + + protected class ResponseFacade implements Response { + @Override + public void setStatus(int status) { + exchange.setResponseCode(status); + } + + @Override + public void addHeader(String name, String value) { + exchange.getResponseHeaders().add(new HttpString(name), value); + } + + @Override + public void setHeader(String name, String value) { + exchange.getResponseHeaders().put(new HttpString(name), value); + } + + @Override + public void resetCookie(String name, String path) { + CookieImpl cookie = new CookieImpl(name, ""); + cookie.setMaxAge(0); + cookie.setPath(path); + exchange.setResponseCookie(cookie); + } + + @Override + public void setCookie(String name, String value, String path, String domain, int maxAge, boolean secure, boolean httpOnly) { + CookieImpl cookie = new CookieImpl(name, value); + cookie.setPath(path); + cookie.setDomain(domain); + cookie.setMaxAge(maxAge); + cookie.setSecure(secure); + cookie.setHttpOnly(httpOnly); + exchange.setResponseCookie(cookie); + } + + @Override + public OutputStream getOutputStream() { + if (!exchange.isBlocking()) exchange.startBlocking(); + return exchange.getOutputStream(); + } + + @Override + public void sendError(int code) { + exchange.setResponseCode(code); + } + + @Override + public void sendError(int code, String message) { + exchange.setResponseCode(code); + exchange.getResponseHeaders().put(Headers.CONTENT_TYPE, "text/html"); + try { + exchange.getOutputStream().write(message.getBytes()); + } catch (IOException e) { + throw new RuntimeException(e); + } + exchange.endExchange(); + } + + + @Override + public void end() { + exchange.endExchange(); + } + } +} diff --git a/testsuite/integration-arquillian/servers/adapter-spi/undertow-adapter-spi-jakarta/src/main/java/org/keycloak/adapters/undertow/UndertowUserSessionManagement.java b/testsuite/integration-arquillian/servers/adapter-spi/undertow-adapter-spi-jakarta/src/main/java/org/keycloak/adapters/undertow/UndertowUserSessionManagement.java new file mode 100644 index 000000000000..953080c214c0 --- /dev/null +++ b/testsuite/integration-arquillian/servers/adapter-spi/undertow-adapter-spi-jakarta/src/main/java/org/keycloak/adapters/undertow/UndertowUserSessionManagement.java @@ -0,0 +1,160 @@ +/* + * Copyright 2016 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.adapters.undertow; + +import io.undertow.server.HttpServerExchange; +import io.undertow.server.session.Session; +import io.undertow.server.session.SessionConfig; +import io.undertow.server.session.SessionListener; +import io.undertow.server.session.SessionManager; +import org.jboss.logging.Logger; + +import java.util.List; +import java.util.Set; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +/** + * Manages relationship to users and sessions so that forced admin logout can be implemented + * + * @author Bill Burke + * @version $Revision: 1 $ + */ +public class UndertowUserSessionManagement implements SessionListener { + private static final Logger log = Logger.getLogger(UndertowUserSessionManagement.class); + protected volatile boolean registered; + private final ExecutorService executor = Executors.newSingleThreadExecutor(); + + public void login(SessionManager manager) { + if (!registered) { + manager.registerSessionListener(this); + registered = true; + } + } + + /** + * This method runs the given runnable in the current thread if the session manager does not use distributed sessions, + * or in a separate thread if it does. This is to work around: + *

+     *   org.infinispan.util.concurrent.TimeoutException: ISPN000299: Unable to acquire lock after 15 seconds for key SessionCreationMetaDataKey
+     * 
+ * See https://issues.jboss.org/browse/KEYCLOAK-9822 + * @param r + */ + private void workaroundIspnDeadlock(final SessionManager manager, Runnable r) { + if (manager.getClass().getName().equals("org.wildfly.clustering.web.undertow.session.DistributableSessionManager")) { + executor.submit(r); + } else { + r.run(); + } + } + + public void logoutAll(final SessionManager manager) { + final Set allSessions = manager.getAllSessions(); + workaroundIspnDeadlock(manager, new Runnable() { + @Override + public void run() { + for (String sessionId : allSessions) logoutSession(manager, sessionId); + } + }); + } + + public void logoutHttpSessions(final SessionManager manager, final List sessionIds) { + log.debugf("logoutHttpSessions: %s", sessionIds); + + workaroundIspnDeadlock(manager, new Runnable() { + @Override + public void run() { + for (String sessionId : sessionIds) { + logoutSession(manager, sessionId); + } + } + }); + } + + protected void logoutSession(SessionManager manager, String httpSessionId) { + log.debugf("logoutHttpSession: %s", httpSessionId); + Session session = getSessionById(manager, httpSessionId); + try { + if (session != null) session.invalidate(null); + } catch (Exception e) { + log.warnf("Session %s not present or already invalidated.", httpSessionId); + } + } + + protected Session getSessionById(SessionManager manager, final String sessionId) { + // TODO: Workaround for WFLY-3345. Remove this once we move to wildfly 8.2 + if (manager.getClass().getName().equals("org.wildfly.clustering.web.undertow.session.DistributableSessionManager")) { + return manager.getSession(null, new SessionConfig() { + + @Override + public void setSessionId(HttpServerExchange exchange, String sessionId) { + } + + @Override + public void clearSession(HttpServerExchange exchange, String sessionId) { + } + + @Override + public String findSessionId(HttpServerExchange exchange) { + return sessionId; + } + + @Override + public SessionCookieSource sessionCookieSource(HttpServerExchange exchange) { + return null; + } + + @Override + public String rewriteUrl(String originalUrl, String sessionId) { + return null; + } + + }); + + } else { + return manager.getSession(sessionId); + } + } + + + @Override + public void sessionCreated(Session session, HttpServerExchange exchange) { + } + + @Override + public void sessionDestroyed(Session session, HttpServerExchange exchange, SessionDestroyedReason reason) { + } + + + @Override + public void sessionIdChanged(Session session, String oldSessionId) { + } + + @Override + public void attributeAdded(Session session, String name, Object value) { + } + + @Override + public void attributeUpdated(Session session, String name, Object newValue, Object oldValue) { + } + + @Override + public void attributeRemoved(Session session, String name, Object oldValue) { + } + +} diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCJWTIssuerEndpointTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCJWTIssuerEndpointTest.java index 474807ca0b78..9cb2f1119ebb 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCJWTIssuerEndpointTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCJWTIssuerEndpointTest.java @@ -366,7 +366,7 @@ public void testCredentialIssuance() throws Exception { HttpPost postPreAuthorizedCode = new HttpPost(openidConfig.getTokenEndpoint()); List parameters = new LinkedList<>(); parameters.add(new BasicNameValuePair(OAuth2Constants.GRANT_TYPE, PreAuthorizedCodeGrantTypeFactory.GRANT_TYPE)); - parameters.add(new BasicNameValuePair("pre-authorized_code", credentialsOffer.getGrants().getPreAuthorizedCode().getPreAuthorizedCode())); + parameters.add(new BasicNameValuePair(PreAuthorizedCodeGrantTypeFactory.CODE_REQUEST_PARAM, credentialsOffer.getGrants().getPreAuthorizedCode().getPreAuthorizedCode())); UrlEncodedFormEntity formEntity = new UrlEncodedFormEntity(parameters, "UTF-8"); postPreAuthorizedCode.setEntity(formEntity); OAuthClient.AccessTokenResponse accessTokenResponse = new OAuthClient.AccessTokenResponse(httpClient.execute(postPreAuthorizedCode)); diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCSdJwtIssuingEndpointTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCSdJwtIssuingEndpointTest.java index 2c51e589757a..6c7df55f5e91 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCSdJwtIssuingEndpointTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCSdJwtIssuingEndpointTest.java @@ -155,7 +155,7 @@ public void testCredentialIssuance() throws Exception { HttpPost postPreAuthorizedCode = new HttpPost(openidConfig.getTokenEndpoint()); List parameters = new LinkedList<>(); parameters.add(new BasicNameValuePair(OAuth2Constants.GRANT_TYPE, PreAuthorizedCodeGrantTypeFactory.GRANT_TYPE)); - parameters.add(new BasicNameValuePair("pre-authorized_code", credentialsOffer.getGrants().getPreAuthorizedCode().getPreAuthorizedCode())); + parameters.add(new BasicNameValuePair(PreAuthorizedCodeGrantTypeFactory.CODE_REQUEST_PARAM, credentialsOffer.getGrants().getPreAuthorizedCode().getPreAuthorizedCode())); UrlEncodedFormEntity formEntity = new UrlEncodedFormEntity(parameters, "UTF-8"); postPreAuthorizedCode.setEntity(formEntity); OAuthClient.AccessTokenResponse accessTokenResponse = new OAuthClient.AccessTokenResponse(httpClient.execute(postPreAuthorizedCode)); diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCTest.java index ecf9dcab7781..ed5412ff9c5d 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCTest.java @@ -106,7 +106,7 @@ public static KeyWrapper getECKey(String keyId) { kw.setPrivateKey(keyPair.getPrivate()); kw.setPublicKey(keyPair.getPublic()); kw.setUse(KeyUse.SIG); - if(keyId!=null) { + if (keyId != null) { kw.setKid(keyId); } else { kw.setKid(KeyUtils.createKeyId(keyPair.getPublic())); From c7d69ab37377f63ff27399014e754ded6298f736 Mon Sep 17 00:00:00 2001 From: Francis Pouatcha Date: Mon, 15 Jul 2024 22:10:20 +0100 Subject: [PATCH 15/19] Deleting from last main merge Signed-off-by: Francis Pouatcha --- .../adapters/undertow/ChangeSessionId.java | 68 ---- .../adapters/undertow/SavedRequest.java | 82 ----- .../adapters/undertow/ServletHttpFacade.java | 96 ------ .../undertow/SessionManagementBridge.java | 48 --- .../adapters/undertow/UndertowHttpFacade.java | 298 ------------------ .../UndertowUserSessionManagement.java | 160 ---------- 6 files changed, 752 deletions(-) delete mode 100644 testsuite/integration-arquillian/servers/adapter-spi/undertow-adapter-spi-jakarta/src/main/java/org/keycloak/adapters/undertow/ChangeSessionId.java delete mode 100644 testsuite/integration-arquillian/servers/adapter-spi/undertow-adapter-spi-jakarta/src/main/java/org/keycloak/adapters/undertow/SavedRequest.java delete mode 100644 testsuite/integration-arquillian/servers/adapter-spi/undertow-adapter-spi-jakarta/src/main/java/org/keycloak/adapters/undertow/ServletHttpFacade.java delete mode 100644 testsuite/integration-arquillian/servers/adapter-spi/undertow-adapter-spi-jakarta/src/main/java/org/keycloak/adapters/undertow/SessionManagementBridge.java delete mode 100644 testsuite/integration-arquillian/servers/adapter-spi/undertow-adapter-spi-jakarta/src/main/java/org/keycloak/adapters/undertow/UndertowHttpFacade.java delete mode 100644 testsuite/integration-arquillian/servers/adapter-spi/undertow-adapter-spi-jakarta/src/main/java/org/keycloak/adapters/undertow/UndertowUserSessionManagement.java diff --git a/testsuite/integration-arquillian/servers/adapter-spi/undertow-adapter-spi-jakarta/src/main/java/org/keycloak/adapters/undertow/ChangeSessionId.java b/testsuite/integration-arquillian/servers/adapter-spi/undertow-adapter-spi-jakarta/src/main/java/org/keycloak/adapters/undertow/ChangeSessionId.java deleted file mode 100644 index 24eef7e0ddcb..000000000000 --- a/testsuite/integration-arquillian/servers/adapter-spi/undertow-adapter-spi-jakarta/src/main/java/org/keycloak/adapters/undertow/ChangeSessionId.java +++ /dev/null @@ -1,68 +0,0 @@ -/* - * Copyright 2016 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.adapters.undertow; - -import io.undertow.server.HttpServerExchange; -import io.undertow.server.session.Session; -import io.undertow.servlet.api.DeploymentInfo; -import io.undertow.servlet.handlers.ServletRequestContext; -import io.undertow.servlet.spec.HttpSessionImpl; -import io.undertow.servlet.spec.ServletContextImpl; - -import java.lang.reflect.Method; -import java.security.AccessController; - -/** - * @author Bill Burke - * @version $Revision: 1 $ - */ -public class ChangeSessionId { - /** - * This is a hack to be backward compatible between Undertow 1.3+ and versions lower. In Undertow 1.3, a new - * switch was added setChangeSessionIdOnLogin, this screws up session management for keycloak as after the session id - * is uploaded to Keycloak, undertow changes the session id and it can't be invalidated. - * - * @param deploymentInfo - */ - public static void turnOffChangeSessionIdOnLogin(DeploymentInfo deploymentInfo) { - try { - Method method = DeploymentInfo.class.getMethod("setChangeSessionIdOnLogin", boolean.class); - method.invoke(deploymentInfo, false); - } catch (Exception ignore) { - - } - } - - public static String changeSessionId(HttpServerExchange exchange, boolean create) { - final ServletRequestContext sc = exchange.getAttachment(ServletRequestContext.ATTACHMENT_KEY); - ServletContextImpl currentServletContext = sc.getCurrentServletContext(); - HttpSessionImpl session = currentServletContext.getSession(exchange, create); - if (session == null) { - return null; - } - Session underlyingSession; - if(System.getSecurityManager() == null) { - underlyingSession = session.getSession(); - } else { - underlyingSession = AccessController.doPrivileged(new HttpSessionImpl.UnwrapSessionAction(session)); - } - - - return underlyingSession.changeSessionId(exchange, currentServletContext.getSessionConfig()); - } -} diff --git a/testsuite/integration-arquillian/servers/adapter-spi/undertow-adapter-spi-jakarta/src/main/java/org/keycloak/adapters/undertow/SavedRequest.java b/testsuite/integration-arquillian/servers/adapter-spi/undertow-adapter-spi-jakarta/src/main/java/org/keycloak/adapters/undertow/SavedRequest.java deleted file mode 100644 index 478fd275ca5b..000000000000 --- a/testsuite/integration-arquillian/servers/adapter-spi/undertow-adapter-spi-jakarta/src/main/java/org/keycloak/adapters/undertow/SavedRequest.java +++ /dev/null @@ -1,82 +0,0 @@ -/* - * Copyright 2016 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.adapters.undertow; - -import io.undertow.server.HttpServerExchange; -import io.undertow.server.session.Session; -import io.undertow.servlet.handlers.ServletRequestContext; -import io.undertow.servlet.spec.HttpSessionImpl; - -import jakarta.servlet.http.HttpSession; -import java.io.Serializable; -import java.security.AccessController; - -/** - * Saved servlet request. - * - * Note bill burke: I had to fork this because Undertow was automatically restoring the request before the code could be - * processed and redirected. - * - * CachedAuthenticatedSessionHandler was restoring the request before the authentication manager could read the code from the URI - * Originally, I copied SavedRequest as is, but there are type mismatches between Undertow 1.1.1 and 1.3.10. - * So, trySaveRequest calls the same undertow version, removes the saved request, stores it in a different session attribute, - * then restores the old attribute later - * - * - * @author Stuart Douglas - */ -public class SavedRequest implements Serializable { - - private static final String SESSION_KEY = SavedRequest.class.getName(); - - public static void trySaveRequest(final HttpServerExchange exchange) { - io.undertow.servlet.util.SavedRequest.trySaveRequest(exchange); - final ServletRequestContext sc = exchange.getAttachment(ServletRequestContext.ATTACHMENT_KEY); - HttpSessionImpl session = sc.getCurrentServletContext().getSession(exchange, true); - Session underlyingSession; - if(System.getSecurityManager() == null) { - underlyingSession = session.getSession(); - } else { - underlyingSession = AccessController.doPrivileged(new HttpSessionImpl.UnwrapSessionAction(session)); - } - io.undertow.servlet.util.SavedRequest request = (io.undertow.servlet.util.SavedRequest) underlyingSession.removeAttribute(io.undertow.servlet.util.SavedRequest.class.getName()); - if (request != null) underlyingSession.setAttribute(SESSION_KEY, request); - - - } - - public static void tryRestoreRequest(final HttpServerExchange exchange, HttpSession session) { - if(session instanceof HttpSessionImpl) { - - Session underlyingSession; - if(System.getSecurityManager() == null) { - underlyingSession = ((HttpSessionImpl) session).getSession(); - } else { - underlyingSession = AccessController.doPrivileged(new HttpSessionImpl.UnwrapSessionAction(session)); - } - io.undertow.servlet.util.SavedRequest request = (io.undertow.servlet.util.SavedRequest) underlyingSession.removeAttribute(SESSION_KEY); - if (request != null) { - underlyingSession.setAttribute(io.undertow.servlet.util.SavedRequest.class.getName(), request); - io.undertow.servlet.util.SavedRequest.tryRestoreRequest(exchange, session); - - } - - } - } - -} diff --git a/testsuite/integration-arquillian/servers/adapter-spi/undertow-adapter-spi-jakarta/src/main/java/org/keycloak/adapters/undertow/ServletHttpFacade.java b/testsuite/integration-arquillian/servers/adapter-spi/undertow-adapter-spi-jakarta/src/main/java/org/keycloak/adapters/undertow/ServletHttpFacade.java deleted file mode 100644 index c89f7e9e579b..000000000000 --- a/testsuite/integration-arquillian/servers/adapter-spi/undertow-adapter-spi-jakarta/src/main/java/org/keycloak/adapters/undertow/ServletHttpFacade.java +++ /dev/null @@ -1,96 +0,0 @@ -/* - * Copyright 2016 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.adapters.undertow; - -import io.undertow.server.HttpServerExchange; -import io.undertow.servlet.handlers.ServletRequestContext; -import org.keycloak.adapters.spi.AuthenticationError; -import org.keycloak.adapters.spi.LogoutError; - -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; - -/** - * @author Bill Burke - * @version $Revision: 1 $ - */ -public class ServletHttpFacade extends UndertowHttpFacade { - protected HttpServletRequest request; - protected HttpServletResponse response; - - public ServletHttpFacade(HttpServerExchange exchange) { - super(exchange); - final ServletRequestContext servletRequestContext = exchange.getAttachment(ServletRequestContext.ATTACHMENT_KEY); - request = (HttpServletRequest)servletRequestContext.getServletRequest(); - response = (HttpServletResponse)servletRequestContext.getServletResponse(); - } - - protected class RequestFacade extends UndertowHttpFacade.RequestFacade { - @Override - public String getFirstParam(String param) { - return request.getParameter(param); - } - - @Override - public void setError(AuthenticationError error) { - request.setAttribute(AuthenticationError.class.getName(), error); - - } - - @Override - public void setError(LogoutError error) { - request.setAttribute(LogoutError.class.getName(), error); - } - - - } - - protected class ResponseFacade extends UndertowHttpFacade.ResponseFacade { - // can't call sendError from a challenge. Undertow ends up calling send error. - /* - @Override - public void sendError(int code) { - try { - response.sendError(code); - } catch (IOException e) { - throw new RuntimeException(e); - } - } - - @Override - public void sendError(int code, String message) { - try { - response.sendError(code, message); - } catch (IOException e) { - throw new RuntimeException(e); - } - } - */ - - } - - @Override - public Response getResponse() { - return new ResponseFacade(); - } - - @Override - public Request getRequest() { - return new RequestFacade(); - } -} diff --git a/testsuite/integration-arquillian/servers/adapter-spi/undertow-adapter-spi-jakarta/src/main/java/org/keycloak/adapters/undertow/SessionManagementBridge.java b/testsuite/integration-arquillian/servers/adapter-spi/undertow-adapter-spi-jakarta/src/main/java/org/keycloak/adapters/undertow/SessionManagementBridge.java deleted file mode 100644 index fcde53737bbc..000000000000 --- a/testsuite/integration-arquillian/servers/adapter-spi/undertow-adapter-spi-jakarta/src/main/java/org/keycloak/adapters/undertow/SessionManagementBridge.java +++ /dev/null @@ -1,48 +0,0 @@ -/* - * Copyright 2016 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.adapters.undertow; - -import io.undertow.server.session.SessionManager; -import org.keycloak.adapters.spi.UserSessionManagement; - -import java.util.List; - -/** - * @author Bill Burke - * @version $Revision: 1 $ - */ -public class SessionManagementBridge implements UserSessionManagement { - - protected UndertowUserSessionManagement userSessionManagement; - protected SessionManager sessionManager; - - public SessionManagementBridge(UndertowUserSessionManagement userSessionManagement, SessionManager sessionManager) { - this.userSessionManagement = userSessionManagement; - this.sessionManager = sessionManager; - } - - @Override - public void logoutAll() { - userSessionManagement.logoutAll(sessionManager); - } - - @Override - public void logoutHttpSessions(List ids) { - userSessionManagement.logoutHttpSessions(sessionManager, ids); - } - -} diff --git a/testsuite/integration-arquillian/servers/adapter-spi/undertow-adapter-spi-jakarta/src/main/java/org/keycloak/adapters/undertow/UndertowHttpFacade.java b/testsuite/integration-arquillian/servers/adapter-spi/undertow-adapter-spi-jakarta/src/main/java/org/keycloak/adapters/undertow/UndertowHttpFacade.java deleted file mode 100644 index ceac32880416..000000000000 --- a/testsuite/integration-arquillian/servers/adapter-spi/undertow-adapter-spi-jakarta/src/main/java/org/keycloak/adapters/undertow/UndertowHttpFacade.java +++ /dev/null @@ -1,298 +0,0 @@ -/* - * Copyright 2016 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.adapters.undertow; - -import io.undertow.server.HttpServerExchange; -import io.undertow.server.handlers.CookieImpl; -import io.undertow.server.handlers.form.FormData; -import io.undertow.server.handlers.form.FormData.FormValue; -import io.undertow.server.handlers.form.FormDataParser; -import io.undertow.server.handlers.form.FormParserFactory; -import io.undertow.servlet.handlers.ServletRequestContext; -import io.undertow.util.AttachmentKey; -import io.undertow.util.Headers; -import io.undertow.util.HttpString; -import org.keycloak.adapters.spi.AuthenticationError; -import org.keycloak.adapters.spi.HttpFacade; -import org.keycloak.adapters.spi.LogoutError; -import org.keycloak.common.util.KeycloakUriBuilder; - -import javax.security.cert.X509Certificate; -import jakarta.servlet.ServletInputStream; -import jakarta.servlet.ServletRequest; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletRequestWrapper; - -import java.io.BufferedInputStream; -import java.io.IOException; -import java.io.InputStream; -import java.io.OutputStream; -import java.net.InetAddress; -import java.net.InetSocketAddress; -import java.util.Deque; -import java.util.List; -import java.util.Map; - -/** - * @author Bill Burke - * @version $Revision: 1 $ - */ -public class UndertowHttpFacade implements HttpFacade { - public static final AttachmentKey AUTH_ERROR_ATTACHMENT_KEY = AttachmentKey.create(AuthenticationError.class); - public static final AttachmentKey LOGOUT_ERROR_ATTACHMENT_KEY = AttachmentKey.create(LogoutError.class); - - protected HttpServerExchange exchange; - protected RequestFacade requestFacade = new RequestFacade(); - protected ResponseFacade responseFacade = new ResponseFacade(); - - public UndertowHttpFacade(HttpServerExchange exchange) { - this.exchange = exchange; - } - - @Override - public Request getRequest() { - return requestFacade; - } - - @Override - public Response getResponse() { - return responseFacade; - } - - @Override - public X509Certificate[] getCertificateChain() { - X509Certificate[] chain = new X509Certificate[0]; - try { - chain = exchange.getConnection().getSslSessionInfo().getPeerCertificateChain(); - } catch (Exception ignore) { - - } - return chain; - } - - protected class RequestFacade implements Request { - - private InputStream inputStream; - private final FormParserFactory formParserFactory = FormParserFactory.builder().build(); - private FormData formData; - - @Override - public String getURI() { - KeycloakUriBuilder uriBuilder = KeycloakUriBuilder.fromUri(exchange.getRequestURI()) - .replaceQuery(exchange.getQueryString()); - if (!exchange.isHostIncludedInRequestURI()) uriBuilder.scheme(exchange.getRequestScheme()).host(exchange.getHostAndPort()); - return uriBuilder.buildAsString(); - } - - @Override - public String getRelativePath() { - return exchange.getRelativePath(); - } - - @Override - public boolean isSecure() { - String protocol = exchange.getRequestScheme(); - return protocol.equalsIgnoreCase("https"); - } - - @Override - public String getFirstParam(String param) { - Deque values = exchange.getQueryParameters().get(param); - - if (values != null && !values.isEmpty()) { - return values.getFirst(); - } - - if (formData == null && "post".equalsIgnoreCase(getMethod())) { - FormDataParser parser = formParserFactory.createParser(exchange); - try { - formData = parser.parseBlocking(); - } catch (IOException cause) { - throw new RuntimeException("Failed to parse form parameters", cause); - } - } - - if (formData != null) { - Deque formValues = formData.get(param); - - if (formValues != null && !formValues.isEmpty()) { - FormValue firstValue = formValues.getFirst(); - - if (!firstValue.isFile()) { - return firstValue.getValue(); - } - } - } - - return null; - } - - @Override - public String getQueryParamValue(String param) { - Map> queryParameters = exchange.getQueryParameters(); - if (queryParameters == null) return null; - Deque strings = queryParameters.get(param); - if (strings == null) return null; - return strings.getFirst(); - } - - @Override - public Cookie getCookie(String cookieName) { - Map requestCookies = exchange.getRequestCookies(); - if (requestCookies == null) return null; - io.undertow.server.handlers.Cookie cookie = requestCookies.get(cookieName); - if (cookie == null) return null; - return new Cookie(cookie.getName(), cookie.getValue(), cookie.getVersion(), cookie.getDomain(), cookie.getPath()); - } - - @Override - public List getHeaders(String name) { - return exchange.getRequestHeaders().get(name); - } - - @Override - public String getMethod() { - return exchange.getRequestMethod().toString(); - } - - - - @Override - public String getHeader(String name) { - return exchange.getRequestHeaders().getFirst(name); - } - - @Override - public InputStream getInputStream() { - return getInputStream(false); - } - - @Override - public InputStream getInputStream(boolean buffered) { - if (!exchange.isBlocking()) exchange.startBlocking(); - - if (inputStream != null) { - return inputStream; - } - - if (buffered) { - ServletRequestContext context = exchange.getAttachment(ServletRequestContext.ATTACHMENT_KEY); - ServletRequest servletRequest = context.getServletRequest(); - - inputStream = new BufferedInputStream(exchange.getInputStream()); - - context.setServletRequest(UndertowHttpServletRequest.setupServletInputStream(servletRequest, inputStream)); - return inputStream; - } - - return exchange.getInputStream(); - } - - @Override - public String getRemoteAddr() { - InetSocketAddress sourceAddress = exchange.getSourceAddress(); - if (sourceAddress == null) { - return ""; - } - InetAddress address = sourceAddress.getAddress(); - if (address == null) { - // this is unresolved, so we just return the host name not exactly spec, but if the name should be - // resolved then a PeerNameResolvingHandler should be used and this is probably better than just - // returning null - return sourceAddress.getHostString(); - } - return address.getHostAddress(); - } - - @Override - public void setError(AuthenticationError error) { - exchange.putAttachment(AUTH_ERROR_ATTACHMENT_KEY, error); - } - - @Override - public void setError(LogoutError error) { - exchange.putAttachment(LOGOUT_ERROR_ATTACHMENT_KEY, error); - - } - } - - protected class ResponseFacade implements Response { - @Override - public void setStatus(int status) { - exchange.setResponseCode(status); - } - - @Override - public void addHeader(String name, String value) { - exchange.getResponseHeaders().add(new HttpString(name), value); - } - - @Override - public void setHeader(String name, String value) { - exchange.getResponseHeaders().put(new HttpString(name), value); - } - - @Override - public void resetCookie(String name, String path) { - CookieImpl cookie = new CookieImpl(name, ""); - cookie.setMaxAge(0); - cookie.setPath(path); - exchange.setResponseCookie(cookie); - } - - @Override - public void setCookie(String name, String value, String path, String domain, int maxAge, boolean secure, boolean httpOnly) { - CookieImpl cookie = new CookieImpl(name, value); - cookie.setPath(path); - cookie.setDomain(domain); - cookie.setMaxAge(maxAge); - cookie.setSecure(secure); - cookie.setHttpOnly(httpOnly); - exchange.setResponseCookie(cookie); - } - - @Override - public OutputStream getOutputStream() { - if (!exchange.isBlocking()) exchange.startBlocking(); - return exchange.getOutputStream(); - } - - @Override - public void sendError(int code) { - exchange.setResponseCode(code); - } - - @Override - public void sendError(int code, String message) { - exchange.setResponseCode(code); - exchange.getResponseHeaders().put(Headers.CONTENT_TYPE, "text/html"); - try { - exchange.getOutputStream().write(message.getBytes()); - } catch (IOException e) { - throw new RuntimeException(e); - } - exchange.endExchange(); - } - - - @Override - public void end() { - exchange.endExchange(); - } - } -} diff --git a/testsuite/integration-arquillian/servers/adapter-spi/undertow-adapter-spi-jakarta/src/main/java/org/keycloak/adapters/undertow/UndertowUserSessionManagement.java b/testsuite/integration-arquillian/servers/adapter-spi/undertow-adapter-spi-jakarta/src/main/java/org/keycloak/adapters/undertow/UndertowUserSessionManagement.java deleted file mode 100644 index 953080c214c0..000000000000 --- a/testsuite/integration-arquillian/servers/adapter-spi/undertow-adapter-spi-jakarta/src/main/java/org/keycloak/adapters/undertow/UndertowUserSessionManagement.java +++ /dev/null @@ -1,160 +0,0 @@ -/* - * Copyright 2016 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.adapters.undertow; - -import io.undertow.server.HttpServerExchange; -import io.undertow.server.session.Session; -import io.undertow.server.session.SessionConfig; -import io.undertow.server.session.SessionListener; -import io.undertow.server.session.SessionManager; -import org.jboss.logging.Logger; - -import java.util.List; -import java.util.Set; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; - -/** - * Manages relationship to users and sessions so that forced admin logout can be implemented - * - * @author Bill Burke - * @version $Revision: 1 $ - */ -public class UndertowUserSessionManagement implements SessionListener { - private static final Logger log = Logger.getLogger(UndertowUserSessionManagement.class); - protected volatile boolean registered; - private final ExecutorService executor = Executors.newSingleThreadExecutor(); - - public void login(SessionManager manager) { - if (!registered) { - manager.registerSessionListener(this); - registered = true; - } - } - - /** - * This method runs the given runnable in the current thread if the session manager does not use distributed sessions, - * or in a separate thread if it does. This is to work around: - *
-     *   org.infinispan.util.concurrent.TimeoutException: ISPN000299: Unable to acquire lock after 15 seconds for key SessionCreationMetaDataKey
-     * 
- * See https://issues.jboss.org/browse/KEYCLOAK-9822 - * @param r - */ - private void workaroundIspnDeadlock(final SessionManager manager, Runnable r) { - if (manager.getClass().getName().equals("org.wildfly.clustering.web.undertow.session.DistributableSessionManager")) { - executor.submit(r); - } else { - r.run(); - } - } - - public void logoutAll(final SessionManager manager) { - final Set allSessions = manager.getAllSessions(); - workaroundIspnDeadlock(manager, new Runnable() { - @Override - public void run() { - for (String sessionId : allSessions) logoutSession(manager, sessionId); - } - }); - } - - public void logoutHttpSessions(final SessionManager manager, final List sessionIds) { - log.debugf("logoutHttpSessions: %s", sessionIds); - - workaroundIspnDeadlock(manager, new Runnable() { - @Override - public void run() { - for (String sessionId : sessionIds) { - logoutSession(manager, sessionId); - } - } - }); - } - - protected void logoutSession(SessionManager manager, String httpSessionId) { - log.debugf("logoutHttpSession: %s", httpSessionId); - Session session = getSessionById(manager, httpSessionId); - try { - if (session != null) session.invalidate(null); - } catch (Exception e) { - log.warnf("Session %s not present or already invalidated.", httpSessionId); - } - } - - protected Session getSessionById(SessionManager manager, final String sessionId) { - // TODO: Workaround for WFLY-3345. Remove this once we move to wildfly 8.2 - if (manager.getClass().getName().equals("org.wildfly.clustering.web.undertow.session.DistributableSessionManager")) { - return manager.getSession(null, new SessionConfig() { - - @Override - public void setSessionId(HttpServerExchange exchange, String sessionId) { - } - - @Override - public void clearSession(HttpServerExchange exchange, String sessionId) { - } - - @Override - public String findSessionId(HttpServerExchange exchange) { - return sessionId; - } - - @Override - public SessionCookieSource sessionCookieSource(HttpServerExchange exchange) { - return null; - } - - @Override - public String rewriteUrl(String originalUrl, String sessionId) { - return null; - } - - }); - - } else { - return manager.getSession(sessionId); - } - } - - - @Override - public void sessionCreated(Session session, HttpServerExchange exchange) { - } - - @Override - public void sessionDestroyed(Session session, HttpServerExchange exchange, SessionDestroyedReason reason) { - } - - - @Override - public void sessionIdChanged(Session session, String oldSessionId) { - } - - @Override - public void attributeAdded(Session session, String name, Object value) { - } - - @Override - public void attributeUpdated(Session session, String name, Object newValue, Object oldValue) { - } - - @Override - public void attributeRemoved(Session session, String name, Object oldValue) { - } - -} From 3753e0f914ae216c1ef7a2b87819617418f5e632 Mon Sep 17 00:00:00 2001 From: Francis Pouatcha Date: Mon, 15 Jul 2024 22:41:56 +0100 Subject: [PATCH 16/19] Processing more review comments Signed-off-by: Francis Pouatcha --- .../signing/JwtProofBasedSigningService.java | 23 +++++++++---------- .../oid4vc/model/ProofTypeDeserializer.java | 20 ++++++++++++++++ .../oid4vc/model/ProofTypeSerializer.java | 20 ++++++++++++++++ .../oid4vc/model/ProofSerializationTest.java | 22 +++++++++++++++++- 4 files changed, 72 insertions(+), 13 deletions(-) diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/signing/JwtProofBasedSigningService.java b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/signing/JwtProofBasedSigningService.java index d16c1d28c7b2..1487c4c6b789 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/signing/JwtProofBasedSigningService.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/signing/JwtProofBasedSigningService.java @@ -17,6 +17,7 @@ package org.keycloak.protocol.oid4vc.issuance.signing; import java.io.IOException; +import java.nio.charset.StandardCharsets; import java.util.Arrays; import java.util.Objects; import java.util.Optional; @@ -34,6 +35,9 @@ import org.keycloak.protocol.oid4vc.model.Format; import org.keycloak.protocol.oid4vc.model.Proof; import org.keycloak.protocol.oid4vc.model.ProofType; +import org.keycloak.protocol.oid4vc.model.ProofTypeJWT; +import org.keycloak.protocol.oid4vc.model.ProofTypesSupported; +import org.keycloak.protocol.oid4vc.model.SupportedCredentialConfiguration; import org.keycloak.representations.AccessToken; import org.keycloak.util.JsonSerialization; @@ -71,7 +75,7 @@ protected JWK validateProof(VCIssuanceContext vcIssuanceContext) throws VCIssuer Optional optionalProof = getProofFromContext(vcIssuanceContext); - if (!optionalProof.isPresent()) { + if (optionalProof.isEmpty()) { return null; // No proof support } @@ -90,10 +94,10 @@ protected JWK validateProof(VCIssuanceContext vcIssuanceContext) throws VCIssuer validateProofPayload(vcIssuanceContext, proofPayload); SignatureVerifierContext signatureVerifierContext = getVerifier(jwk, jwsHeader.getAlgorithm().name()); - if(signatureVerifierContext==null){ + if (signatureVerifierContext == null) { throw new VCIssuerException("No verifier configured for " +jwsHeader.getAlgorithm()); } - if (!signatureVerifierContext.verify(jwsInput.getEncodedSignatureInput().getBytes("UTF-8"), jwsInput.getSignature())) { + if (!signatureVerifierContext.verify(jwsInput.getEncodedSignatureInput().getBytes(StandardCharsets.UTF_8), jwsInput.getSignature())) { throw new VCIssuerException("Could not verify provided proof"); } @@ -110,13 +114,8 @@ private void checkCryptographicKeyBinding(VCIssuanceContext vcIssuanceContext){ private Optional getProofFromContext(VCIssuanceContext vcIssuanceContext) throws VCIssuerException { return Optional.ofNullable(vcIssuanceContext.getCredentialConfig()) - .map(config -> config.getProofTypesSupported()) + .map(SupportedCredentialConfiguration::getProofTypesSupported) .flatMap(proofTypesSupported -> { - if (proofTypesSupported == null) { - LOGGER.debugf("No proof support. Will skip proof validation."); - return Optional.empty(); - } - Optional.ofNullable(proofTypesSupported.getJwt()) .orElseThrow(() -> new VCIssuerException("SD-JWT supports only jwt proof type.")); @@ -150,9 +149,9 @@ private void validateJwsHeader(VCIssuanceContext vcIssuanceContext, JWSHeader jw // As we limit accepted algorithm to the ones listed by the server, we can omit checking for "none" // The Algorithm enum class does not list the none value anyway. Optional.ofNullable(vcIssuanceContext.getCredentialConfig()) - .map(config -> config.getProofTypesSupported()) - .map(proofTypesSupported -> proofTypesSupported.getJwt()) - .map(jwt -> jwt.getProofSigningAlgValuesSupported()) + .map(SupportedCredentialConfiguration::getProofTypesSupported) + .map(ProofTypesSupported::getJwt) + .map(ProofTypeJWT::getProofSigningAlgValuesSupported) .filter(supportedAlgs -> supportedAlgs.contains(jwsHeader.getAlgorithm().name())) .orElseThrow(() -> new VCIssuerException("Proof signature algorithm not supported: " + jwsHeader.getAlgorithm().name())); diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/model/ProofTypeDeserializer.java b/services/src/main/java/org/keycloak/protocol/oid4vc/model/ProofTypeDeserializer.java index 6e178e807a8e..be29128ce465 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/model/ProofTypeDeserializer.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/model/ProofTypeDeserializer.java @@ -1,3 +1,19 @@ +/* + * 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 com.fasterxml.jackson.core.JsonParser; @@ -8,6 +24,10 @@ import java.io.IOException; import java.util.Map; +/** + * + * @author Francis Pouatcha + */ public class ProofTypeDeserializer extends StdDeserializer { private static final Map PROOF_TYPE_MAP = Map.of( diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/model/ProofTypeSerializer.java b/services/src/main/java/org/keycloak/protocol/oid4vc/model/ProofTypeSerializer.java index c23086205d93..d0b917a01dc1 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/model/ProofTypeSerializer.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/model/ProofTypeSerializer.java @@ -1,3 +1,19 @@ +/* + * 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 com.fasterxml.jackson.core.JsonGenerator; @@ -6,6 +22,10 @@ import java.io.IOException; +/** + * + * @author Francis Pouatcha + */ public class ProofTypeSerializer extends StdSerializer { protected ProofTypeSerializer() { super(ProofType.class); diff --git a/services/src/test/java/org/keycloak/protocol/oid4vc/model/ProofSerializationTest.java b/services/src/test/java/org/keycloak/protocol/oid4vc/model/ProofSerializationTest.java index ccb98dbaa056..857a2f713635 100644 --- a/services/src/test/java/org/keycloak/protocol/oid4vc/model/ProofSerializationTest.java +++ b/services/src/test/java/org/keycloak/protocol/oid4vc/model/ProofSerializationTest.java @@ -1,10 +1,30 @@ +/* + * 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 com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.Test; -import static org.junit.Assert.*; +import static org.junit.Assert.assertEquals; +/** + * + * @author Francis Pouatcha + */ public class ProofSerializationTest { @Test public void testSerializeProof() throws JsonProcessingException { From d8cd042dc629ebb73c665be5831483de8b9c2174 Mon Sep 17 00:00:00 2001 From: Francis Pouatcha Date: Mon, 22 Jul 2024 11:10:57 -0400 Subject: [PATCH 17/19] Removed unnecessary toString. Proofreading from review comments. Signed-off-by: Francis Pouatcha --- .../oid4vc/OID4VCLoginProtocolFactory.java | 2 -- .../oid4vc/issuance/OID4VCIssuerEndpoint.java | 8 ++++---- .../OID4VCClientRegistrationProviderTest.java | 10 +++++----- .../signing/OID4VCIssuerEndpointTest.java | 2 +- .../signing/OID4VCSdJwtIssuingEndpointTest.java | 16 ++++++++-------- .../oid4vc/issuance/signing/OID4VCTest.java | 2 +- 6 files changed, 19 insertions(+), 21 deletions(-) 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 2bcd063175a7..7f86e79416ea 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/OID4VCLoginProtocolFactory.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/OID4VCLoginProtocolFactory.java @@ -39,12 +39,10 @@ import org.keycloak.protocol.oid4vc.issuance.mappers.OID4VCUserAttributeMapper; import org.keycloak.protocol.oid4vc.issuance.signing.VCSigningServiceProviderFactory; import org.keycloak.protocol.oid4vc.issuance.signing.VerifiableCredentialsSigningService; -import org.keycloak.protocol.oid4vc.model.Format; import org.keycloak.provider.ProviderFactory; import org.keycloak.representations.idm.ClientRepresentation; import org.keycloak.services.managers.AppAuthManager; -import java.util.EnumMap; import java.util.HashMap; import java.util.Map; import java.util.Optional; 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 97afe8467635..2edd733a64c0 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 @@ -315,13 +315,13 @@ public Response requestCredential( // Both Format and identifier are optional. // If the credential_identifier is present, Format can't be present. But this implementation will // tolerate the presence of both, waiting for clarity in specifications. - // This implementation will priviledge the presence of the credential config identifier. + // This implementation will privilege the presence of the credential config identifier. String requestedCredentialId = credentialRequestVO.getCredentialIdentifier(); String requestedFormat = credentialRequestVO.getFormat(); // Check if at least one of both is available. - if(requestedCredentialId==null && requestedFormat==null){ - LOGGER.debugf("Missing both configuration id and requested format. At least one shall be specified."); + if(requestedCredentialId == null && requestedFormat == null){ + LOGGER.debugf("Missing both configuration id and requested format. At least one shall be specified."); throw new BadRequestException(getErrorResponse(ErrorType.MISSING_CREDENTIAL_CONFIG_AND_FORMAT)); } @@ -329,7 +329,7 @@ public Response requestCredential( // resolve from identifier first SupportedCredentialConfiguration supportedCredentialConfiguration = null; - if (requestedCredentialId!=null) { + if (requestedCredentialId != null) { supportedCredentialConfiguration = supportedCredentials.get(requestedCredentialId); if(supportedCredentialConfiguration==null){ LOGGER.debugf("Credential with configuration id %s not found.", requestedCredentialId); diff --git a/services/src/test/java/org/keycloak/protocol/oid4vc/OID4VCClientRegistrationProviderTest.java b/services/src/test/java/org/keycloak/protocol/oid4vc/OID4VCClientRegistrationProviderTest.java index b6caeff7398d..2afc66c48368 100644 --- a/services/src/test/java/org/keycloak/protocol/oid4vc/OID4VCClientRegistrationProviderTest.java +++ b/services/src/test/java/org/keycloak/protocol/oid4vc/OID4VCClientRegistrationProviderTest.java @@ -42,7 +42,7 @@ public static Collection parameters() { { "Single Supported Credential with format and single-type.", Map.of( - "vc.credential-id.format", Format.JWT_VC.toString(), + "vc.credential-id.format", Format.JWT_VC, "vc.credential-id.scope", "VerifiableCredential"), new OID4VCClient(null, "did:web:test.org", List.of(new SupportedCredentialConfiguration() @@ -54,7 +54,7 @@ public static Collection parameters() { { "Single Supported Credential with format and multi-type.", Map.of( - "vc.credential-id.format", Format.JWT_VC.toString(), + "vc.credential-id.format", Format.JWT_VC, "vc.credential-id.scope", "AnotherCredential"), new OID4VCClient(null, "did:web:test.org", List.of(new SupportedCredentialConfiguration() @@ -66,7 +66,7 @@ public static Collection parameters() { { "Single Supported Credential with format, multi-type and a display object.", Map.of( - "vc.credential-id.format", Format.JWT_VC.toString(), + "vc.credential-id.format", Format.JWT_VC, "vc.credential-id.scope", "AnotherCredential", "vc.credential-id.display.0", "{\"name\":\"Another\",\"locale\":\"en\"}"), new OID4VCClient(null, "did:web:test.org", @@ -80,10 +80,10 @@ public static Collection parameters() { { "Multiple Supported Credentials.", Map.of( - "vc.first-id.format", Format.JWT_VC.toString(), + "vc.first-id.format", Format.JWT_VC, "vc.first-id.scope", "AnotherCredential", "vc.first-id.display.0", "{\"name\":\"First\",\"locale\":\"en\"}", - "vc.second-id.format", Format.SD_JWT_VC.toString(), + "vc.second-id.format", Format.SD_JWT_VC, "vc.second-id.scope", "MyType", "vc.second-id.display.0", "{\"name\":\"Second Credential\",\"locale\":\"de\"}", "vc.second-id.proof_types_supported","{\"jwt\":{\"proof_signing_alg_values_supported\":[\"ES256\"]}}"), diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCIssuerEndpointTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCIssuerEndpointTest.java index da5d01a5883a..6c1995e02e5e 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCIssuerEndpointTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCIssuerEndpointTest.java @@ -166,7 +166,7 @@ private void testCredentialIssuanceWithAuthZCodeFlow(Consumer( Map.of( "algorithmType", List.of("ES256"), - "tokenType", List.of(Format.SD_JWT_VC.toString()), + "tokenType", List.of(Format.SD_JWT_VC), "issuerDid", List.of(TEST_DID.toString()), "hashAlgorithm", List.of("sha-256"), "decoys", List.of("0"), @@ -283,12 +283,12 @@ private ComponentExportRepresentation getTestCredentialSigningProvider() { ComponentExportRepresentation componentExportRepresentation = new ComponentExportRepresentation(); componentExportRepresentation.setName("sd-jwt-signing_test-credential"); componentExportRepresentation.setId(UUID.randomUUID().toString()); - componentExportRepresentation.setProviderId(Format.SD_JWT_VC.toString()); + componentExportRepresentation.setProviderId(Format.SD_JWT_VC); componentExportRepresentation.setConfig(new MultivaluedHashMap<>( Map.of( "algorithmType", List.of("ES256"), - "tokenType", List.of(Format.SD_JWT_VC.toString()), + "tokenType", List.of(Format.SD_JWT_VC), "issuerDid", List.of(TEST_DID.toString()), "hashAlgorithm", List.of("sha-256"), "decoys", List.of("2"), @@ -307,7 +307,7 @@ protected ClientRepresentation getTestClient(String clientId) { clientRepresentation.setEnabled(true); Map testCredentialAttributes = Map.of( "vc.test-credential.expiry_in_s", "1800", - "vc.test-credential.format", Format.SD_JWT_VC.toString(), + "vc.test-credential.format", Format.SD_JWT_VC, "vc.test-credential.scope", "test-credential", "vc.test-credential.claims", "{ \"firstName\": {\"mandatory\": false, \"display\": [{\"name\": \"First Name\", \"locale\": \"en-US\"}, {\"name\": \"名前\", \"locale\": \"ja-JP\"}]}, \"lastName\": {\"mandatory\": false}, \"email\": {\"mandatory\": false} }", "vc.test-credential.vct", "https://credentials.example.com/test-credential", @@ -316,7 +316,7 @@ protected ClientRepresentation getTestClient(String clientId) { ); Map identityCredentialAttributes = Map.of( "vc.IdentityCredential.expiry_in_s", "31536000", - "vc.IdentityCredential.format", Format.SD_JWT_VC.toString(), + "vc.IdentityCredential.format", Format.SD_JWT_VC, "vc.IdentityCredential.scope", "identity_credential", "vc.IdentityCredential.vct", "https://credentials.example.com/identity_credential", "vc.IdentityCredential.cryptographic_binding_methods_supported", "jwk", diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCTest.java index 04cc1005554a..b5785467d8b8 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCTest.java @@ -185,7 +185,7 @@ protected ClientRepresentation getTestClient(String clientId) { clientRepresentation.setEnabled(true); clientRepresentation.setAttributes(Map.of( "vc.test-credential.expiry_in_s", "100", - "vc.test-credential.format", Format.JWT_VC.toString(), + "vc.test-credential.format", Format.JWT_VC, "vc.test-credential.scope", "VerifiableCredential", "vc.test-credential.claims", "{ \"firstName\": {\"mandatory\": false, \"display\": [{\"name\": \"First Name\", \"locale\": \"en-US\"}, {\"name\": \"名前\", \"locale\": \"ja-JP\"}]}, \"lastName\": {\"mandatory\": false}, \"email\": {\"mandatory\": false} }", "vc.test-credential.display.0","{\n \"name\": \"Test Credential\"\n}" From 5f99e1671d30c85c0718a56dd88d3508f4a20d09 Mon Sep 17 00:00:00 2001 From: Francis Pouatcha Date: Mon, 22 Jul 2024 16:13:32 -0400 Subject: [PATCH 18/19] Processed review comments, formating, coding rules Signed-off-by: Francis Pouatcha --- .../oid4vc/issuance/OID4VCIssuerEndpoint.java | 46 +++++++++++-------- .../signing/JwtProofBasedSigningService.java | 5 +- .../issuance/signing/SdJwtSigningService.java | 2 +- .../SdJwtSigningServiceProviderFactory.java | 6 +-- .../issuance/signing/SigningService.java | 7 ++- .../VCSigningServiceProviderFactory.java | 3 +- .../VerifiableCredentialsSigningService.java | 8 ++-- .../oid4vc/model/CredentialConfigId.java | 3 +- .../protocol/oid4vc/model/Format.java | 4 ++ .../SupportedCredentialConfiguration.java | 6 +-- .../model/VerifiableCredentialType.java | 2 +- .../signing/OID4VCJWTIssuerEndpointTest.java | 16 +++++++ .../oid4vc/issuance/signing/OID4VCTest.java | 6 +-- .../signing/SdJwtSigningServiceTest.java | 4 +- 14 files changed, 70 insertions(+), 48 deletions(-) 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 2edd733a64c0..7fd3aa67e2fc 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 @@ -84,8 +84,10 @@ import java.util.Optional; import java.util.stream.Collectors; +import static org.keycloak.protocol.oid4vc.model.Format.JWT_VC; import static org.keycloak.protocol.oid4vc.model.Format.LDP_VC; import static org.keycloak.protocol.oid4vc.model.Format.SD_JWT_VC; +import static org.keycloak.protocol.oid4vc.model.Format.SUPPORTED_FORMATS; /** * Provides the (REST-)endpoints required for the OID4VCI protocol. @@ -331,22 +333,22 @@ public Response requestCredential( SupportedCredentialConfiguration supportedCredentialConfiguration = null; if (requestedCredentialId != null) { supportedCredentialConfiguration = supportedCredentials.get(requestedCredentialId); - if(supportedCredentialConfiguration==null){ + if(supportedCredentialConfiguration == null){ LOGGER.debugf("Credential with configuration id %s not found.", requestedCredentialId); throw new BadRequestException(getErrorResponse(ErrorType.UNSUPPORTED_CREDENTIAL_TYPE)); } // Then for format. We know spec does not allow both parameter. But we are tolerant if you send both // Was found by id, check that the format matches. - if (requestedFormat!=null && !requestedFormat.equals(supportedCredentialConfiguration.getFormat())){ + if (requestedFormat != null && !requestedFormat.equals(supportedCredentialConfiguration.getFormat())){ LOGGER.debugf("Credential with configuration id %s does not support requested format %s, but supports %s.", requestedCredentialId, requestedFormat, supportedCredentialConfiguration.getFormat()); throw new BadRequestException(getErrorResponse(ErrorType.UNSUPPORTED_CREDENTIAL_FORMAT)); } } - if(supportedCredentialConfiguration==null && requestedFormat!=null) { + if(supportedCredentialConfiguration == null && requestedFormat != null) { // Search by format supportedCredentialConfiguration = getSupportedCredentialConfiguration(credentialRequestVO, supportedCredentials, requestedFormat); - if(supportedCredentialConfiguration==null) { + if(supportedCredentialConfiguration == null) { LOGGER.debugf("Credential with requested format %s, not supported.", requestedFormat); throw new BadRequestException(getErrorResponse(ErrorType.UNSUPPORTED_CREDENTIAL_FORMAT)); } @@ -355,7 +357,7 @@ public Response requestCredential( CredentialResponse responseVO = new CredentialResponse(); Object theCredential = getCredential(authResult, supportedCredentialConfiguration, credentialRequestVO); - if(Objects.equals(requestedFormat, Format.JWT_VC) || Objects.equals(requestedFormat, LDP_VC) || Objects.equals(requestedFormat, SD_JWT_VC)) { + if(SUPPORTED_FORMATS.contains(requestedFormat)) { responseVO.setCredential(theCredential); } else { throw new BadRequestException(getErrorResponse(ErrorType.UNSUPPORTED_CREDENTIAL_TYPE)); @@ -366,23 +368,27 @@ public Response requestCredential( private SupportedCredentialConfiguration getSupportedCredentialConfiguration(CredentialRequest credentialRequestVO, Map supportedCredentials, String requestedFormat) { // 1. Format resolver List configs = supportedCredentials.values().stream() - .filter(supportedCredential -> Objects.equals(supportedCredential.getFormat(),requestedFormat)) + .filter(supportedCredential -> Objects.equals(supportedCredential.getFormat(), requestedFormat)) .collect(Collectors.toList()); List matchingConfigs; - if(Objects.equals(requestedFormat, Format.JWT_VC) || Objects.equals(requestedFormat, LDP_VC)) { - // Will detach this when each format provides logic on how to resolve from definition. - matchingConfigs = configs.stream() - .filter(supportedCredential -> Objects.equals(supportedCredential.getCredentialDefinition(), credentialRequestVO.getCredentialDefinition())) - .collect(Collectors.toList()); - } else if (Objects.equals(requestedFormat, Format.SD_JWT_VC)) { - // Resolve from vct for sd-jwt - matchingConfigs = configs.stream() - .filter(supportedCredential -> Objects.equals(supportedCredential.getVct(), credentialRequestVO.getVct())) - .collect(Collectors.toList()); - } else { - throw new BadRequestException(getErrorResponse(ErrorType.UNSUPPORTED_CREDENTIAL_FORMAT)); + switch (requestedFormat) { + case SD_JWT_VC: + // Resolve from vct for sd-jwt + matchingConfigs = configs.stream() + .filter(supportedCredential -> Objects.equals(supportedCredential.getVct(), credentialRequestVO.getVct())) + .collect(Collectors.toList()); + break; + case JWT_VC: + case LDP_VC: + // Will detach this when each format provides logic on how to resolve from definition. + matchingConfigs = configs.stream() + .filter(supportedCredential -> Objects.equals(supportedCredential.getCredentialDefinition(), credentialRequestVO.getCredentialDefinition())) + .collect(Collectors.toList()); + break; + default: + throw new BadRequestException(getErrorResponse(ErrorType.UNSUPPORTED_CREDENTIAL_FORMAT)); } if (matchingConfigs.isEmpty()) { @@ -423,7 +429,7 @@ private AuthenticationManager.AuthResult getAuthResult(WebApplicationException e * * @param authResult authResult containing the userSession to create the credential for * @param credentialConfig the supported credential configuration - * @param credentialRequestVO + * @param credentialRequestVO the credential request * @return the signed credential */ private Object getCredential(AuthenticationManager.AuthResult authResult, SupportedCredentialConfiguration credentialConfig, CredentialRequest credentialRequestVO) { @@ -492,7 +498,7 @@ private Response getErrorResponse(ErrorType errorType) { } // Return all {@link OID4VCClient}s that support the given scope and format - // Scope might be different from vct. In the case of sd-jwt for eaxample + // Scope might be different from vct. In the case of sd-jwt for example private List getClientsOfScope(String vcScope, String format) { LOGGER.debugf("Retrieve all clients of scope %s, supporting format %s", vcScope, format); diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/signing/JwtProofBasedSigningService.java b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/signing/JwtProofBasedSigningService.java index 23d8e9ea1eba..47f9fa27f2c6 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/signing/JwtProofBasedSigningService.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/signing/JwtProofBasedSigningService.java @@ -32,7 +32,6 @@ import org.keycloak.models.KeycloakSession; import org.keycloak.protocol.oid4vc.issuance.VCIssuanceContext; import org.keycloak.protocol.oid4vc.issuance.VCIssuerException; -import org.keycloak.protocol.oid4vc.model.Format; import org.keycloak.protocol.oid4vc.model.Proof; import org.keycloak.protocol.oid4vc.model.ProofType; import org.keycloak.protocol.oid4vc.model.ProofTypeJWT; @@ -95,7 +94,7 @@ protected JWK validateProof(VCIssuanceContext vcIssuanceContext) throws VCIssuer SignatureVerifierContext signatureVerifierContext = getVerifier(jwk, jwsHeader.getAlgorithm().name()); if (signatureVerifierContext == null) { - throw new VCIssuerException("No verifier configured for " +jwsHeader.getAlgorithm()); + throw new VCIssuerException("No verifier configured for " + jwsHeader.getAlgorithm()); } if (!signatureVerifierContext.verify(jwsInput.getEncodedSignatureInput().getBytes(StandardCharsets.UTF_8), jwsInput.getSignature())) { throw new VCIssuerException("Could not verify provided proof"); @@ -184,7 +183,7 @@ private void validateProofPayload(VCIssuanceContext vcIssuanceContext, AccessTok "Proof not produced for this audience. Audience claim must be: " + credentialIssuer + " but are " + Arrays.asList(proofPayload.getAudience()))); // Validate mandatory iat. - // I do not understand the rationale behind requiring a issue time if we are not checking expiration. + // I do not understand the rationale behind requiring an issue time if we are not checking expiration. Optional.ofNullable(proofPayload.getIat()) .orElseThrow(() -> new VCIssuerException("Missing proof issuing time. iat claim must be provided.")); diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/signing/SdJwtSigningService.java b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/signing/SdJwtSigningService.java index 0c941b3ee138..9e282034d614 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/signing/SdJwtSigningService.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/signing/SdJwtSigningService.java @@ -100,7 +100,7 @@ public SdJwtSigningService(KeycloakSession keycloakSession, ObjectMapper objectM if (signingKey == null) { throw new SigningServiceException(String.format("No key for id %s and algorithm %s available.", keyId, algorithmType)); } - // @Francis: keyId header can be confusing if there is any key rotation, as key ids have to be immutable. It can lead + // keyId header can be confusing if there is any key rotation, as key ids have to be immutable. It can lead // to different keys being exposed under the same id. // set the configured kid if present. if (kid.isPresent()) { diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/signing/SdJwtSigningServiceProviderFactory.java b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/signing/SdJwtSigningServiceProviderFactory.java index 238a0df75549..689795e37259 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/signing/SdJwtSigningServiceProviderFactory.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/signing/SdJwtSigningServiceProviderFactory.java @@ -57,7 +57,7 @@ public VerifiableCredentialsSigningService create(KeycloakSession session, Compo String vcConfigId = model.get(SigningProperties.VC_CONFIG_ID.getKey()); List visibleClaims = Optional.ofNullable(model.get(SigningProperties.VISIBLE_CLAIMS.getKey())) - .map(visibileClaims -> visibileClaims.split(",")) + .map(vsbleClaims -> vsbleClaims.split(",")) .map(Arrays::asList) .orElse(List.of()); @@ -92,7 +92,7 @@ public List getConfigProperties() { @Override public String getId() { - return SUPPORTED_FORMAT.toString(); + return SUPPORTED_FORMAT; } @Override @@ -103,7 +103,7 @@ public void validateSpecificConfiguration(KeycloakSession session, RealmModel re .checkRequired(SigningProperties.TOKEN_TYPE.asConfigProperty()) .checkInt(SigningProperties.DECOYS.asConfigProperty(), true); // Make sure VCT is set if vc config id is set. - if(model.get(SigningProperties.VC_CONFIG_ID.getKey())!=null){ + if (model.get(SigningProperties.VC_CONFIG_ID.getKey()) != null) { helper.checkRequired(SigningProperties.VC_VCT.asConfigProperty()); } } diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/signing/SigningService.java b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/signing/SigningService.java index 611618606bbe..ab18be852c47 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/signing/SigningService.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/signing/SigningService.java @@ -26,7 +26,6 @@ import org.keycloak.jose.jwk.JWKParser; import org.keycloak.jose.jwk.OKPPublicJWK; import org.keycloak.models.KeycloakSession; -import org.keycloak.protocol.oid4vc.model.Format; /** * Abstract base class to provide the Signing Services common functionality @@ -54,7 +53,7 @@ protected SigningService(KeycloakSession keycloakSession, String keyId, String f @Override public String locator() { - // Future implementation might consider credential type or even cofiguration specific signers. + // Future implementation might consider credential type or even configuration specific signers. // See: org.keycloak.protocol.oid4vc.issuance.signing.SdJwtSigningService.locator return VerifiableCredentialsSigningService.locator(format, null, null); } @@ -77,10 +76,10 @@ protected KeyWrapper getKey(String kid, String algorithm) { protected SignatureVerifierContext getVerifier(JWK jwk, String jwsAlgorithm) throws VerificationException { SignatureProvider signatureProvider = keycloakSession.getProvider(SignatureProvider.class, jwsAlgorithm); - return signatureProvider.verifier(getKeyWraper(jwk, jwsAlgorithm, KeyUse.SIG)); + return signatureProvider.verifier(getKeyWrapper(jwk, jwsAlgorithm, KeyUse.SIG)); } - private KeyWrapper getKeyWraper(JWK jwk, String algorithm, KeyUse keyUse) { + private KeyWrapper getKeyWrapper(JWK jwk, String algorithm, KeyUse keyUse) { KeyWrapper keyWrapper = new KeyWrapper(); keyWrapper.setType(jwk.getKeyType()); diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/signing/VCSigningServiceProviderFactory.java b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/signing/VCSigningServiceProviderFactory.java index bb438852111e..c6e3a7c19fa8 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/signing/VCSigningServiceProviderFactory.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/signing/VCSigningServiceProviderFactory.java @@ -26,7 +26,6 @@ import org.keycloak.models.KeycloakSessionFactory; import org.keycloak.models.RealmModel; import org.keycloak.protocol.oid4vc.OID4VCEnvironmentProviderFactory; -import org.keycloak.protocol.oid4vc.model.Format; import org.keycloak.provider.ConfigurationValidationHelper; import org.keycloak.provider.ProviderConfigurationBuilder; @@ -42,7 +41,7 @@ public interface VCSigningServiceProviderFactory extends ComponentFactory extends Provider { */ String locator(); - static final String LOCATION_SEPARATOR = "::"; + String LOCATION_SEPARATOR = "::"; /** * We are forcing a structure with 3 components. format::type::configId. We assume format is always set, as @@ -57,8 +57,8 @@ public interface VerifiableCredentialsSigningService extends Provider { * @return */ static String locator(String format, VerifiableCredentialType credentialType, CredentialConfigId vcConfigId){ - return (format==null? "" : format) + LOCATION_SEPARATOR + - (credentialType==null?"":credentialType.getValue()) + LOCATION_SEPARATOR + - (vcConfigId==null?"":vcConfigId.getValue()); + return (format == null ? "" : format) + LOCATION_SEPARATOR + + (credentialType == null ? "" : credentialType.getValue()) + LOCATION_SEPARATOR + + (vcConfigId == null ? "" : vcConfigId.getValue()); } } diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/model/CredentialConfigId.java b/services/src/main/java/org/keycloak/protocol/oid4vc/model/CredentialConfigId.java index d93344b9f978..c1e1f445f04e 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/model/CredentialConfigId.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/model/CredentialConfigId.java @@ -23,8 +23,7 @@ public class CredentialConfigId { private String value; public static CredentialConfigId from(String value) { - - return value==null ? null : new CredentialConfigId(value); + return value == null ? null : new CredentialConfigId(value); } public CredentialConfigId(String value) { diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/model/Format.java b/services/src/main/java/org/keycloak/protocol/oid4vc/model/Format.java index 5e801b6058f7..02ec709c3539 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/model/Format.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/model/Format.java @@ -17,6 +17,9 @@ package org.keycloak.protocol.oid4vc.model; +import java.util.Collections; +import java.util.Set; + /** * Enum of supported credential formats * @@ -39,4 +42,5 @@ public class Format { */ public static final String SD_JWT_VC = "vc+sd-jwt"; + public static final Set SUPPORTED_FORMATS = Collections.unmodifiableSet(Set.of(JWT_VC, LDP_VC, SD_JWT_VC)); } 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 ad92939397a6..39423ec18224 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 @@ -108,7 +108,7 @@ public String getFormat() { * @return */ public VerifiableCredentialType deriveType() { - if(Objects.equals(format, Format.SD_JWT_VC)) { + if (Objects.equals(format, Format.SD_JWT_VC)) { return VerifiableCredentialType.from(vct); } return null; @@ -218,7 +218,7 @@ public SupportedCredentialConfiguration setProofTypesSupported(ProofTypesSupport public Map toDotNotation() { Map dotNotation = new HashMap<>(); - Optional.ofNullable(format).ifPresent(format -> dotNotation.put(id + DOT_SEPARATOR + FORMAT_KEY, format.toString())); + Optional.ofNullable(format).ifPresent(format -> dotNotation.put(id + DOT_SEPARATOR + FORMAT_KEY, format)); Optional.ofNullable(vct).ifPresent(vct -> dotNotation.put(id + DOT_SEPARATOR + VERIFIABLE_CREDENTIAL_TYPE_KEY, vct)); Optional.ofNullable(scope).ifPresent(scope -> dotNotation.put(id + DOT_SEPARATOR + SCOPE_KEY, scope)); Optional.ofNullable(cryptographicBindingMethodsSupported).ifPresent(types -> @@ -273,7 +273,7 @@ public static SupportedCredentialConfiguration fromDotNotation(String credential .map(entry -> DisplayObject.fromJsonString(entry.getValue())) .collect(Collectors.toList()); - if(!displayList.isEmpty()){ + if (!displayList.isEmpty()){ supportedCredentialConfiguration.setDisplay(displayList); } diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/model/VerifiableCredentialType.java b/services/src/main/java/org/keycloak/protocol/oid4vc/model/VerifiableCredentialType.java index 53b963606efb..43e37067bafe 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/model/VerifiableCredentialType.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/model/VerifiableCredentialType.java @@ -23,7 +23,7 @@ public class VerifiableCredentialType { private String value; public static VerifiableCredentialType from(String value){ - return value==null? null : new VerifiableCredentialType(value); + return value == null? null : new VerifiableCredentialType(value); } public VerifiableCredentialType(String value) { this.value = value; diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCJWTIssuerEndpointTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCJWTIssuerEndpointTest.java index 967bbd564d89..31c8d5b05630 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCJWTIssuerEndpointTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCJWTIssuerEndpointTest.java @@ -1,3 +1,19 @@ +/* + * 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.testsuite.oid4vc.issuance.signing; import jakarta.ws.rs.BadRequestException; diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCTest.java index b5785467d8b8..024e72955455 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCTest.java @@ -352,16 +352,16 @@ public long currentTimeMillis() { } } - protected ProtocolMapperRepresentation getUserAttributeMapper(String subjectProperty, String atributeName, String supportedCredentialTypes) { + protected ProtocolMapperRepresentation getUserAttributeMapper(String subjectProperty, String attributeName, String supportedCredentialTypes) { ProtocolMapperRepresentation protocolMapperRepresentation = new ProtocolMapperRepresentation(); - protocolMapperRepresentation.setName(supportedCredentialTypes + "-" + atributeName + "-mapper"); + protocolMapperRepresentation.setName(supportedCredentialTypes + "-" + attributeName + "-mapper"); protocolMapperRepresentation.setProtocol("oid4vc"); protocolMapperRepresentation.setId(UUID.randomUUID().toString()); protocolMapperRepresentation.setProtocolMapper("oid4vc-user-attribute-mapper"); protocolMapperRepresentation.setConfig( Map.of( "subjectProperty", subjectProperty, - "userAttribute", atributeName, + "userAttribute", attributeName, "supportedCredentialTypes", supportedCredentialTypes) ); return protocolMapperRepresentation; diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/SdJwtSigningServiceTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/SdJwtSigningServiceTest.java index f71fea371c0f..ca231f21b075 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/SdJwtSigningServiceTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/SdJwtSigningServiceTest.java @@ -61,7 +61,7 @@ public class SdJwtSigningServiceTest extends OID4VCTest { private static KeyWrapper rsaKey = getRsaKey(); - // If an unsupported algorithm is provided, the JWT Sigining Service should not be instantiated. + // If an unsupported algorithm is provided, the JWT Signing Service should not be instantiated. @Test(expected = SigningServiceException.class) public void testUnsupportedAlgorithm() throws Throwable { try { @@ -86,7 +86,7 @@ public void testUnsupportedAlgorithm() throws Throwable { } } - // If no key is provided, the JWT Sigining Service should not be instantiated. + // If no key is provided, the JWT Signing Service should not be instantiated. @Test(expected = SigningServiceException.class) public void testFailIfNoKey() throws Throwable { try { From 9717c876021ed88f5b6b1341ba5f07e9ff0b67c2 Mon Sep 17 00:00:00 2001 From: Francis Pouatcha Date: Tue, 23 Jul 2024 13:45:45 -0400 Subject: [PATCH 19/19] Review comments. Final fields on immutable classes. Signed-off-by: Francis Pouatcha --- .../org/keycloak/protocol/oid4vc/model/CredentialConfigId.java | 2 +- .../protocol/oid4vc/model/VerifiableCredentialType.java | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/model/CredentialConfigId.java b/services/src/main/java/org/keycloak/protocol/oid4vc/model/CredentialConfigId.java index c1e1f445f04e..6bb1ac83351f 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/model/CredentialConfigId.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/model/CredentialConfigId.java @@ -20,7 +20,7 @@ * @author Francis Pouatcha */ public class CredentialConfigId { - private String value; + private final String value; public static CredentialConfigId from(String value) { return value == null ? null : new CredentialConfigId(value); diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/model/VerifiableCredentialType.java b/services/src/main/java/org/keycloak/protocol/oid4vc/model/VerifiableCredentialType.java index 43e37067bafe..c0ab26beeb85 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/model/VerifiableCredentialType.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/model/VerifiableCredentialType.java @@ -20,7 +20,7 @@ * @author Francis Pouatcha */ public class VerifiableCredentialType { - private String value; + private final String value; public static VerifiableCredentialType from(String value){ return value == null? null : new VerifiableCredentialType(value);