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 b0b2edafe49..e8527dfdd48 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 @@ -37,8 +37,6 @@ import jakarta.ws.rs.core.HttpHeaders; import jakarta.ws.rs.core.Response; import org.jboss.logging.Logger; import org.keycloak.common.util.SecretGenerator; -import org.keycloak.component.ComponentFactory; -import org.keycloak.component.ComponentModel; import org.keycloak.events.Errors; import org.keycloak.events.EventBuilder; import org.keycloak.jose.jwe.JWE; @@ -77,7 +75,8 @@ import org.keycloak.protocol.oid4vc.model.NonceResponse; import org.keycloak.protocol.oid4vc.model.OfferUriType; import org.keycloak.protocol.oid4vc.model.PreAuthorizedCode; import org.keycloak.protocol.oid4vc.model.PreAuthorizedGrant; -import org.keycloak.protocol.oid4vc.model.Proof; +import org.keycloak.protocol.oid4vc.model.ProofType; +import org.keycloak.protocol.oid4vc.model.Proofs; import org.keycloak.protocol.oid4vc.model.SupportedCredentialConfiguration; import org.keycloak.protocol.oid4vc.model.VerifiableCredential; import org.keycloak.protocol.oidc.grants.PreAuthorizedCodeGrantType; @@ -765,25 +764,33 @@ public class OID4VCIssuerEndpoint { * Enforce key binding: Validate proof and bind associated key to credential in issuance context. */ private void enforceKeyBindingIfProofProvided(VCIssuanceContext vcIssuanceContext) { - Proof proof = vcIssuanceContext.getCredentialRequest().getProof(); - if (proof == null) { - LOGGER.debugf("No proof provided, skipping key binding"); + Proofs proofs = vcIssuanceContext.getCredentialRequest().getProofs(); + if (proofs == null) { + LOGGER.debugf("No proofs provided, skipping key binding"); return; } - String proofType = proof.getProofType(); + // Validate each JWT proof if present + if (proofs.getJwt() != null && !proofs.getJwt().isEmpty()) { + validateProofs(vcIssuanceContext, ProofType.JWT); + } + } + private void validateProofs(VCIssuanceContext vcIssuanceContext, String proofType) { ProofValidator proofValidator = session.getProvider(ProofValidator.class, proofType); if (proofValidator == null) { throw new BadRequestException(String.format("Unable to validate proofs of type %s", proofType)); } - // Validate proof and bind public key to credential + // Validate proof and bind public keys to credential try { - Optional.ofNullable(proofValidator.validateProof(vcIssuanceContext)) - .ifPresent(jwk -> vcIssuanceContext.getCredentialBody().addKeyBinding(jwk)); + List jwks = proofValidator.validateProof(vcIssuanceContext); + if (jwks != null && !jwks.isEmpty()) { + // Bind the first JWK to the credential + vcIssuanceContext.getCredentialBody().addKeyBinding(jwks.get(0)); + } } catch (VCIssuerException e) { - throw new BadRequestException("Could not validate provided proof", e); + throw new BadRequestException(String.format("Could not validate provided %s proof", proofType), e); } } diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/keybinding/JwtProofValidator.java b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/keybinding/JwtProofValidator.java index 72d6c3ed652..454fbea9f9a 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/keybinding/JwtProofValidator.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/keybinding/JwtProofValidator.java @@ -28,22 +28,22 @@ import org.keycloak.models.KeycloakSession; import org.keycloak.protocol.oid4vc.issuance.OID4VCIssuerWellKnownProvider; import org.keycloak.protocol.oid4vc.issuance.VCIssuanceContext; import org.keycloak.protocol.oid4vc.issuance.VCIssuerException; -import org.keycloak.protocol.oid4vc.model.JwtProof; -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.Proofs; import org.keycloak.protocol.oid4vc.model.SupportedCredentialConfiguration; import org.keycloak.representations.AccessToken; import org.keycloak.util.JsonSerialization; import java.io.IOException; import java.nio.charset.StandardCharsets; +import java.util.ArrayList; import java.util.Arrays; import java.util.List; import java.util.Map; import java.util.Objects; import java.util.Optional; +import org.jboss.logging.Logger; /** * Validates the conformance and authenticity of presented JWT proofs. @@ -54,6 +54,7 @@ public class JwtProofValidator extends AbstractProofValidator { public static final String PROOF_JWT_TYP = "openid4vci-proof+jwt"; private static final String CRYPTOGRAPHIC_BINDING_METHOD_JWK = "jwk"; + private static final Logger LOGGER = Logger.getLogger(JwtProofValidator.class); protected JwtProofValidator(KeycloakSession keycloakSession) { super(keycloakSession); @@ -64,7 +65,8 @@ public class JwtProofValidator extends AbstractProofValidator { return ProofType.JWT; } - public JWK validateProof(VCIssuanceContext vcIssuanceContext) throws VCIssuerException { + @Override + public List validateProof(VCIssuanceContext vcIssuanceContext) throws VCIssuerException { try { return validateJwtProof(vcIssuanceContext); } catch (JWSInputException | VerificationException | IOException e) { @@ -87,20 +89,44 @@ public class JwtProofValidator extends AbstractProofValidator { * @throws IllegalStateException: is credential type badly configured * @throws IOException */ - private JWK validateJwtProof(VCIssuanceContext vcIssuanceContext) throws VCIssuerException, JWSInputException, VerificationException, IOException { + private List validateJwtProof(VCIssuanceContext vcIssuanceContext) throws VCIssuerException, JWSInputException, VerificationException, IOException { - Optional optionalProof = getProofFromContext(vcIssuanceContext); + Optional> optionalProof = getProofFromContext(vcIssuanceContext); - if (optionalProof.isEmpty() || !(optionalProof.get() instanceof JwtProof)) { + if (optionalProof.isEmpty() || optionalProof.get().isEmpty()) { return null; // No proof support } - JwtProof proof = (JwtProof) optionalProof.get(); + List jwtProofs = optionalProof.get(); // Check key binding config for jwt. Only type supported. checkCryptographicKeyBinding(vcIssuanceContext); - JWSInput jwsInput = getJwsInput(proof); + // Validate all JWT proofs in the array + List validJwks = new ArrayList<>(); + + for (int i = 0; i < jwtProofs.size(); i++) { + String jwt = jwtProofs.get(i); + try { + JWK jwk = validateSingleJwtProof(vcIssuanceContext, jwt); + validJwks.add(jwk); + LOGGER.debugf("Successfully validated JWT proof at index %d", i); + } catch (VCIssuerException e) { + // If any proof fails validation, throw the exception + throw new VCIssuerException(String.format("Failed to validate JWT proof at index %d: %s", i, e.getMessage()), e); + } + } + + if (validJwks.isEmpty()) { + throw new VCIssuerException("No valid JWT proof found in the proofs array"); + } + + LOGGER.debugf("Successfully validated %d JWT proofs", validJwks.size()); + return validJwks; + } + + private JWK validateSingleJwtProof(VCIssuanceContext vcIssuanceContext, String jwt) throws VCIssuerException, JWSInputException, VerificationException, IOException { + JWSInput jwsInput = getJwsInput(jwt); JWSHeader jwsHeader = jwsInput.getHeader(); validateJwsHeader(vcIssuanceContext, jwsHeader); @@ -130,33 +156,24 @@ public class JwtProofValidator extends AbstractProofValidator { } } - private Optional getProofFromContext(VCIssuanceContext vcIssuanceContext) throws VCIssuerException { + private Optional> getProofFromContext(VCIssuanceContext vcIssuanceContext) throws VCIssuerException { return Optional.ofNullable(vcIssuanceContext.getCredentialConfig()) .map(SupportedCredentialConfiguration::getProofTypesSupported) .flatMap(proofTypesSupported -> { Optional.ofNullable(proofTypesSupported.getSupportedProofTypes().get("jwt")) .orElseThrow(() -> new VCIssuerException("SD-JWT supports only jwt proof type.")); - Proof proofObject = vcIssuanceContext.getCredentialRequest().getProof(); - if (proofObject == null) { + Proofs proofs = vcIssuanceContext.getCredentialRequest().getProofs(); + if (proofs == null || proofs.getJwt() == null || proofs.getJwt().isEmpty()) { throw new VCIssuerException("Credential configuration requires a proof of type: " + ProofType.JWT); } - if (!(proofObject instanceof JwtProof)) { - throw new VCIssuerException("Wrong proof type. Expected JwtProof, but got: " + proofObject.getClass().getSimpleName()); - } - - Proof proof = (Proof) proofObject; - if (!Objects.equals(proof.getProofType(), ProofType.JWT)) { - throw new VCIssuerException("Wrong proof type"); - } - - return Optional.of(proof); + return Optional.of(proofs.getJwt()); }); } - private JWSInput getJwsInput(JwtProof proof) throws JWSInputException { - return new JWSInput(proof.getJwt()); + private JWSInput getJwsInput(String jwt) throws JWSInputException { + return new JWSInput(jwt); } /** diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/keybinding/ProofValidator.java b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/keybinding/ProofValidator.java index a0739e83947..6211598510f 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/keybinding/ProofValidator.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/keybinding/ProofValidator.java @@ -22,6 +22,8 @@ import org.keycloak.protocol.oid4vc.issuance.VCIssuanceContext; import org.keycloak.protocol.oid4vc.issuance.VCIssuerException; import org.keycloak.provider.Provider; +import java.util.List; + public interface ProofValidator extends Provider { @Override @@ -31,10 +33,10 @@ public interface ProofValidator extends Provider { String getProofType(); /** - * Validates a client-provided key binding proof. + * Validates client-provided key binding proofs. * * @param vcIssuanceContext the issuance context with credential request and config - * @return the JWK to bind to the credential + * @return the list of JWKs to bind to credentials (one JWK per credential) */ - JWK validateProof(VCIssuanceContext vcIssuanceContext) throws VCIssuerException; + List validateProof(VCIssuanceContext vcIssuanceContext) throws VCIssuerException; } 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 6ecb0dcd29c..acc509cd4ab 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 @@ -45,13 +45,8 @@ public class CredentialRequest { @JsonProperty("credential_identifier") private String credentialIdentifier; - @JsonProperty("proof") - @JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.PROPERTY, property = "proof_type") - @JsonSubTypes({ - @JsonSubTypes.Type(value = JwtProof.class, name = ProofType.JWT), - @JsonSubTypes.Type(value = LdpVpProof.class, name = ProofType.LD_PROOF) - }) - private Proof proof; + @JsonProperty("proofs") + private Proofs proofs; // See: https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#name-format-identifier-3 @JsonProperty("credential_definition") @@ -80,12 +75,12 @@ public class CredentialRequest { return this; } - public Proof getProof() { - return proof; + public Proofs getProofs() { + return proofs; } - public CredentialRequest setProof(Proof proof) { - this.proof = proof; + public CredentialRequest setProofs(Proofs proofs) { + this.proofs = proofs; return this; } diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/model/DiVpProof.java b/services/src/main/java/org/keycloak/protocol/oid4vc/model/DiVpProof.java new file mode 100644 index 00000000000..c521bf1a223 --- /dev/null +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/model/DiVpProof.java @@ -0,0 +1,210 @@ +/* + * Copyright 2025 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; + +/** + * W3C Verifiable Presentation as defined by [VC_DATA_2.0] or [VC_DATA] secured using Data Integrity [VC_Data_Integrity]. + * Used as a proof type in OID4VCI (Section F.2). + * + * @see OID4VCI di_vp Proof Type + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +public class DiVpProof { + + @JsonProperty("@context") + private List context; + + @JsonProperty("type") + private List type; + + @JsonProperty("holder") + private String holder; + + @JsonProperty("proof") + private List proof; + + public DiVpProof() { + } + + public DiVpProof(List context, List type, String holder, List proof) { + this.context = context; + this.type = type; + this.holder = holder; + this.proof = proof; + } + + public List getContext() { + return context; + } + + public DiVpProof setContext(List context) { + this.context = context; + return this; + } + + public List getType() { + return type; + } + + public DiVpProof setType(List type) { + this.type = type; + return this; + } + + public String getHolder() { + return holder; + } + + public DiVpProof setHolder(String holder) { + this.holder = holder; + return this; + } + + public List getProof() { + return proof; + } + + public DiVpProof setProof(List proof) { + this.proof = proof; + return this; + } + + /** + * Data Integrity Proof as defined in [VC_Data_Integrity]. + */ + @JsonInclude(JsonInclude.Include.NON_NULL) + public static class DataIntegrityProof { + + @JsonProperty("type") + private String type; + + @JsonProperty("cryptosuite") + private String cryptosuite; + + @JsonProperty("proofPurpose") + private String proofPurpose; + + @JsonProperty("verificationMethod") + private String verificationMethod; + + @JsonProperty("created") + private String created; + + @JsonProperty("challenge") + private String challenge; + + @JsonProperty("domain") + private String domain; + + @JsonProperty("proofValue") + private String proofValue; + + public DataIntegrityProof() { + } + + public DataIntegrityProof(String type, String cryptosuite, String proofPurpose, + String verificationMethod, String created, String challenge, + String domain, String proofValue) { + this.type = type; + this.cryptosuite = cryptosuite; + this.proofPurpose = proofPurpose; + this.verificationMethod = verificationMethod; + this.created = created; + this.challenge = challenge; + this.domain = domain; + this.proofValue = proofValue; + } + + public String getType() { + return type; + } + + public DataIntegrityProof setType(String type) { + this.type = type; + return this; + } + + public String getCryptosuite() { + return cryptosuite; + } + + public DataIntegrityProof setCryptosuite(String cryptosuite) { + this.cryptosuite = cryptosuite; + return this; + } + + public String getProofPurpose() { + return proofPurpose; + } + + public DataIntegrityProof setProofPurpose(String proofPurpose) { + this.proofPurpose = proofPurpose; + return this; + } + + public String getVerificationMethod() { + return verificationMethod; + } + + public DataIntegrityProof setVerificationMethod(String verificationMethod) { + this.verificationMethod = verificationMethod; + return this; + } + + public String getCreated() { + return created; + } + + public DataIntegrityProof setCreated(String created) { + this.created = created; + return this; + } + + public String getChallenge() { + return challenge; + } + + public DataIntegrityProof setChallenge(String challenge) { + this.challenge = challenge; + return this; + } + + public String getDomain() { + return domain; + } + + public DataIntegrityProof setDomain(String domain) { + this.domain = domain; + return this; + } + + public String getProofValue() { + return proofValue; + } + + public DataIntegrityProof setProofValue(String proofValue) { + this.proofValue = proofValue; + return this; + } + } +} diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/model/JwtProof.java b/services/src/main/java/org/keycloak/protocol/oid4vc/model/JwtProof.java deleted file mode 100644 index 3f743512469..00000000000 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/model/JwtProof.java +++ /dev/null @@ -1,59 +0,0 @@ -/* - * 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; - -/** - * JWT Proof for Credential Request in OID4VCI (Section 8.2.1.1). - * Represents a signed JWT for holder binding. - * - * @author Stefan Wiedemann - * @see OID4VCI Credential Request - */ -@JsonInclude(JsonInclude.Include.NON_NULL) -public class JwtProof implements Proof { - - @JsonProperty("proof_type") - private final String proofType = ProofType.JWT; - - @JsonProperty("jwt") - private String jwt; - - public JwtProof() { - } - - public JwtProof(String jwt) { - this.jwt = jwt; - } - - @Override - public String getProofType() { - return proofType; - } - - public String getJwt() { - return jwt; - } - - public JwtProof setJwt(String jwt) { - this.jwt = jwt; - return this; - } -} diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/model/LdpVpProof.java b/services/src/main/java/org/keycloak/protocol/oid4vc/model/LdpVpProof.java deleted file mode 100644 index 5f278f2522e..00000000000 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/model/LdpVpProof.java +++ /dev/null @@ -1,58 +0,0 @@ -/* - * 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; - -/** - * LDP-VP Proof for Credential Request in OID4VCI (Section 8.2.1.1). - * Represents a JSON-LD Verifiable Presentation for holder binding. - * - * @see OID4VCI Credential Request - */ -@JsonInclude(JsonInclude.Include.NON_NULL) -public class LdpVpProof implements Proof { - - @JsonProperty("proof_type") - private final String proofType = ProofType.LD_PROOF; - - @JsonProperty("ldp_vp") - private Object ldpVp; - - public LdpVpProof() { - } - - public LdpVpProof(Object ldpVp) { - this.ldpVp = ldpVp; - } - - @Override - public String getProofType() { - return proofType; - } - - public Object getLdpVp() { - return ldpVp; - } - - public LdpVpProof setLdpVp(Object ldpVp) { - this.ldpVp = ldpVp; - return this; - } -} 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 deleted file mode 100644 index 8e72d41b64d..00000000000 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/model/Proof.java +++ /dev/null @@ -1,30 +0,0 @@ -/* - * 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.JsonProperty; - -/** - * Interface for proof types in OID4VCI Credential Request (Section 8.2.1.1). - * - * @see OID4VCI Credential Request - */ -public interface Proof { - @JsonProperty("proof_type") - String getProofType(); -} 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 b38093b4fff..fffdfea24ed 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 @@ -26,6 +26,6 @@ package org.keycloak.protocol.oid4vc.model; public final class ProofType { public static final String JWT = "jwt"; - public static final String LD_PROOF = "ldp_vp"; + public static final String DI_PROOF = "di_vp"; } diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/model/ProofTypeLdpVp.java b/services/src/main/java/org/keycloak/protocol/oid4vc/model/ProofTypeDiVp.java similarity index 88% rename from services/src/main/java/org/keycloak/protocol/oid4vc/model/ProofTypeLdpVp.java rename to services/src/main/java/org/keycloak/protocol/oid4vc/model/ProofTypeDiVp.java index 7de5c83a9f0..4736d2d8dc6 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/model/ProofTypeLdpVp.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/model/ProofTypeDiVp.java @@ -1,5 +1,5 @@ /* - * Copyright 2024 Red Hat, Inc. and/or its affiliates + * Copyright 2025 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"); @@ -19,10 +19,10 @@ 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 + * See: https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#name-di_vp-proof-type * * @author Francis Pouatcha */ @JsonInclude(JsonInclude.Include.NON_NULL) -public class ProofTypeLdpVp { +public class ProofTypeDiVp { } diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/model/Proofs.java b/services/src/main/java/org/keycloak/protocol/oid4vc/model/Proofs.java new file mode 100644 index 00000000000..13bc0a8189f --- /dev/null +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/model/Proofs.java @@ -0,0 +1,78 @@ +/* + * Copyright 2025 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; + +/** + * Proofs object for Credential Request in OID4VCI (Section 8.2). + * Contains arrays of different proof types (jwt, di_vp, attestation). + * + * @see OID4VCI Credential Request + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +public class Proofs { + + @JsonProperty("jwt") + private List jwt; + + @JsonProperty("di_vp") + private List diVp; + + @JsonProperty("attestation") + private List attestation; + + public Proofs() { + } + + public Proofs(List jwt, List diVp, List attestation) { + this.jwt = jwt; + this.diVp = diVp; + this.attestation = attestation; + } + + public List getJwt() { + return jwt; + } + + public Proofs setJwt(List jwt) { + this.jwt = jwt; + return this; + } + + public List getDiVp() { + return diVp; + } + + public Proofs setDiVp(List diVp) { + this.diVp = diVp; + return this; + } + + public List getAttestation() { + return attestation; + } + + public Proofs setAttestation(List attestation) { + this.attestation = attestation; + return this; + } +} 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 c920520c99c..c1db014652e 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 @@ -20,6 +20,9 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.Test; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; + +import java.util.List; /** * @@ -27,10 +30,12 @@ import static org.junit.Assert.assertEquals; */ public class ProofSerializationTest { @Test - public void testSerializeProof() throws JsonProcessingException { + public void testSerializeProofs() throws JsonProcessingException { ObjectMapper objectMapper = new ObjectMapper(); - String proofStr = " { \"proof_type\": \"jwt\", \"jwt\": \"ewogICJhbGciOiAiRVMyNTYiLAogICJ0eXAiOiAib3BlbmlkNHZjaS1wcm9vZitqd3QiLAogICJqd2siOiB7CiAgICAia3R5IjogIkVDIiwKICAgICJjcnYiOiAiUC0yNTYiLAogICAgIngiOiAiWEdkNU9GU1pwc080VkRRTUZrR3Z0TDVHU2FXWWE3SzBrNGhxUUdLbFBjWSIsCiAgICAieSI6ICJiSXFDaGhoVDdfdnYtYmhuRmVuREljVzVSUjRKTS1nME5sUi1qZGlHemNFIgogIH0KfQo.ewogICJpc3MiOiAib2lkNHZjaS1jbGllbnQiLAogICJhdWQiOiAiaHR0cDovL2xvY2FsaG9zdDo4MDgwL3JlYWxtcy9tYXN0ZXIiLAogICJpYXQiOiAxNzE4OTU5MzY5LAogICJub25jZSI6ICJOODAxTEpVam1qQ1FDMUpzTm5lTllXWFpqZHQ2UEZSd01pNkpoTTU1OF9JIgp9Cg.mKKrkRkG1BfOzgsKwcZhop74EHflzHslO_NFOloKPnZ-ms6t0SnsTNDQjM_o4FBQAgtv_fnFEWRgnkNIa34gvQ\" } "; - Proof proof = objectMapper.readValue(proofStr, JwtProof.class); - assertEquals(ProofType.JWT, proof.getProofType()); + String proofsStr = " { \"jwt\": [\"ewogICJhbGciOiAiRVMyNTYiLAogICJ0eXAiOiAib3BlbmlkNHZjaS1wcm9vZitqd3QiLAogICJqd2siOiB7CiAgICAia3R5IjogIkVDIiwKICAgICJjcnYiOiAiUC0yNTYiLAogICAgIngiOiAiWEdkNU9GU1pwc080VkRRTUZrR3Z0TDVHU2FXWWE3SzBrNGhxUUdLbFBjWSIsCiAgICAieSI6ICJiSXFDaGhoVDdfdnYtYmhuRmVuREljVzVSUjRKTS1nME5sUi1qZGlHemNFIgogIH0KfQo.ewogICJpc3MiOiAib2lkNHZjaS1jbGllbnQiLAogICJhdWQiOiAiaHR0cDovL2xvY2FsaG9zdDo4MDgwL3JlYWxtcy9tYXN0ZXIiLAogICJpYXQiOiAxNzE4OTU5MzY5LAogICJub25jZSI6ICJOODAxTEpVam1qQ1FDMUpzTm5lTllXWFpqZHQ2UEZSd01pNkpoTTU1OF9JIgp9Cg.mKKrkRkG1BfOzgsKwcZhop74EHflzHslO_NFOloKPnZ-ms6t0SnsTNDQjM_o4FBQAgtv_fnFEWRgnkNIa34gvQ\"] } "; + Proofs proofs = objectMapper.readValue(proofsStr, Proofs.class); + assertNotNull("Proofs should not be null", proofs); + assertNotNull("JWT proofs should not be null", proofs.getJwt()); + assertEquals("Should have one JWT proof", 1, proofs.getJwt().size()); } } 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 60910424a33..1f70e01861c 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 @@ -51,12 +51,11 @@ import org.keycloak.protocol.oid4vc.model.Claim; import org.keycloak.protocol.oid4vc.model.Claims; 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.JwtProof; -import org.keycloak.protocol.oid4vc.model.Proof; +import org.keycloak.protocol.oid4vc.model.Proofs; +import org.keycloak.protocol.oid4vc.model.CredentialRequest; import org.keycloak.protocol.oid4vc.model.SupportedCredentialConfiguration; import org.keycloak.protocol.oidc.grants.PreAuthorizedCodeGrantTypeFactory; import org.keycloak.protocol.oidc.representations.OIDCConfigurationRepresentation; @@ -116,8 +115,8 @@ public class OID4VCSdJwtIssuingEndpointTest extends OID4VCIssuerEndpointTest { testingClient .server(TEST_REALM_NAME) .run((session -> { - JwtProof proof = new JwtProof() - .setJwt(generateJwtProof(getCredentialIssuer(session), cNonce)); + Proofs proof = new Proofs() + .setJwt(List.of(generateJwtProof(getCredentialIssuer(session), cNonce))); ClientScopeRepresentation clientScope = fromJsonString(clientScopeString, ClientScopeRepresentation.class); @@ -137,8 +136,8 @@ public class OID4VCSdJwtIssuingEndpointTest extends OID4VCIssuerEndpointTest { try { withCausePropagation(() -> { testingClient.server(TEST_REALM_NAME).run((session -> { - JwtProof proof = new JwtProof() - .setJwt(generateInvalidJwtProof(getCredentialIssuer(session), cNonce)); + Proofs proof = new Proofs() + .setJwt(List.of(generateInvalidJwtProof(getCredentialIssuer(session), cNonce))); ClientScopeRepresentation clientScope = fromJsonString(clientScopeString, ClientScopeRepresentation.class); @@ -148,7 +147,7 @@ public class OID4VCSdJwtIssuingEndpointTest extends OID4VCIssuerEndpointTest { }); Assert.fail("Should have thrown an exception"); } catch (BadRequestException ex) { - Assert.assertEquals("Could not validate provided proof", ex.getMessage()); + Assert.assertEquals("Could not validate provided jwt proof", ex.getMessage()); } } @@ -167,7 +166,7 @@ public class OID4VCSdJwtIssuingEndpointTest extends OID4VCIssuerEndpointTest { String cNonce = cNonceHandler.buildCNonce(null, Map.of(JwtCNonceHandler.SOURCE_ENDPOINT, nonceEndpoint)); - Proof proof = new JwtProof().setJwt(generateJwtProof(getCredentialIssuer(session), cNonce)); + Proofs proof = new Proofs().setJwt(List.of(generateJwtProof(getCredentialIssuer(session), cNonce))); ClientScopeRepresentation clientScope = fromJsonString(clientScopeString, ClientScopeRepresentation.class); @@ -197,7 +196,7 @@ public class OID4VCSdJwtIssuingEndpointTest extends OID4VCIssuerEndpointTest { OID4VCIssuerWellKnownProvider.getCredentialsEndpoint(session.getContext()); // creates a cNonce with missing data String cNonce = cNonceHandler.buildCNonce(List.of(credentialsEndpoint), null); - Proof proof = new JwtProof().setJwt(generateJwtProof(getCredentialIssuer(session), cNonce)); + Proofs proof = new Proofs().setJwt(List.of(generateJwtProof(getCredentialIssuer(session), cNonce))); ClientScopeRepresentation clientScope = fromJsonString(clientScopeString, ClientScopeRepresentation.class); @@ -231,7 +230,7 @@ public class OID4VCSdJwtIssuingEndpointTest extends OID4VCIssuerEndpointTest { session.getContext().getRealm().setAttribute(Oid4VciConstants.C_NONCE_LIFETIME_IN_SECONDS, -1); String cNonce = cNonceHandler.buildCNonce(List.of(credentialsEndpoint), Map.of(JwtCNonceHandler.SOURCE_ENDPOINT, nonceEndpoint)); - Proof proof = new JwtProof().setJwt(generateJwtProof(getCredentialIssuer(session), cNonce)); + Proofs proof = new Proofs().setJwt(List.of(generateJwtProof(getCredentialIssuer(session), cNonce))); ClientScopeRepresentation clientScope = fromJsonString(clientScopeString, ClientScopeRepresentation.class); @@ -254,7 +253,7 @@ public class OID4VCSdJwtIssuingEndpointTest extends OID4VCIssuerEndpointTest { } private static SdJwtVP testRequestTestCredential(KeycloakSession session, ClientScopeRepresentation clientScope, - String token, Proof proof) + String token, Proofs proof) throws VerificationException { AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session); @@ -264,7 +263,7 @@ public class OID4VCSdJwtIssuingEndpointTest extends OID4VCIssuerEndpointTest { final String credentialConfigurationId = clientScope.getAttributes().get(CredentialScopeModel.CONFIGURATION_ID); CredentialRequest credentialRequest = new CredentialRequest() .setCredentialConfigurationId(credentialConfigurationId) - .setProof(proof); + .setProofs(proof); Response credentialResponse = issuerEndpoint.requestCredential(credentialRequest); assertEquals("The credential request should be answered successfully.",