From c0be5c42b9c8a6867977ef5a0d83352b0c51d52a Mon Sep 17 00:00:00 2001 From: Awambeng <114798938+Awambeng@users.noreply.github.com> Date: Wed, 12 Nov 2025 14:30:33 +0100 Subject: [PATCH] [OID4VCI]: Add backward compatibility for Draft 15 wallets (single proof support) (#43951) Closes #43926 Signed-off-by: Awambeng Rodrick --- .../oid4vc/issuance/OID4VCIssuerEndpoint.java | 39 ++++++++++- .../keybinding/AttestationValidatorUtil.java | 2 +- .../oid4vc/model/CredentialRequest.java | 19 ++++++ .../protocol/oid4vc/model/JwtProof.java | 65 +++++++++++++++++++ .../signing/OID4VCJWTIssuerEndpointTest.java | 64 ++++++++++++++++++ 5 files changed, 186 insertions(+), 3 deletions(-) create mode 100644 services/src/main/java/org/keycloak/protocol/oid4vc/model/JwtProof.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 9d6209f7f48..53ecff18dc0 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 @@ -81,6 +81,7 @@ import org.keycloak.protocol.oid4vc.model.CredentialResponse; import org.keycloak.protocol.oid4vc.model.CredentialResponseEncryption; import org.keycloak.protocol.oid4vc.model.CredentialResponseEncryptionMetadata; import org.keycloak.protocol.oid4vc.model.CredentialsOffer; +import org.keycloak.protocol.oid4vc.model.JwtProof; import org.keycloak.services.ErrorResponseException; import org.keycloak.protocol.oid4vc.model.ErrorResponse; import org.keycloak.protocol.oid4vc.model.ErrorType; @@ -699,7 +700,9 @@ public class OID4VCIssuerEndpoint { } try { - return JsonSerialization.mapper.readValue(requestPayload, CredentialRequest.class); + CredentialRequest credentialRequest = JsonSerialization.mapper.readValue(requestPayload, CredentialRequest.class); + normalizeProofFields(credentialRequest); + return credentialRequest; } catch (JsonProcessingException e) { String errorMessage = "Failed to parse JSON request: " + e.getMessage(); LOGGER.debug(errorMessage); @@ -779,7 +782,9 @@ public class OID4VCIssuerEndpoint { // Parse decrypted content to CredentialRequest try { - return JsonSerialization.mapper.readValue(content, CredentialRequest.class); + CredentialRequest credentialRequest = JsonSerialization.mapper.readValue(content, CredentialRequest.class); + normalizeProofFields(credentialRequest); + return credentialRequest; } catch (JsonProcessingException e) { throw new JWEException("Failed to parse decrypted JWE payload: " + e.getMessage()); } @@ -806,6 +811,36 @@ public class OID4VCIssuerEndpoint { throw new JWEException("Unsupported compression algorithm"); } + /** + * Normalizes legacy 'proof' field into 'proofs' and validates mutual exclusivity. + *

+ * If a single 'proof' is present and 'proofs' is absent, converts it into a + * single-element JWT list under 'proofs' for backward compatibility. + * If both are present, throws a BadRequestException. + */ + private void normalizeProofFields(CredentialRequest credentialRequest) { + if (credentialRequest == null) { + return; + } + + if (credentialRequest.getProof() != null && credentialRequest.getProofs() != null) { + String message = "Both 'proof' and 'proofs' must not be present at the same time"; + LOGGER.debug(message); + throw new BadRequestException(getErrorResponse(ErrorType.INVALID_CREDENTIAL_REQUEST, message)); + } + + if (credentialRequest.getProof() != null) { + LOGGER.debugf("Converting single 'proof' field to 'proofs' array for backward compatibility"); + JwtProof singleProof = credentialRequest.getProof(); + Proofs proofsArray = new Proofs(); + if (singleProof.getJwt() != null) { + proofsArray.setJwt(List.of(singleProof.getJwt())); + } + credentialRequest.setProofs(proofsArray); + credentialRequest.setProof(null); + } + } + private String selectKeyManagementAlg(CredentialResponseEncryptionMetadata metadata, JWK jwk) { List supportedAlgs = metadata.getAlgValuesSupported(); if (supportedAlgs == null || supportedAlgs.isEmpty()) { diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/keybinding/AttestationValidatorUtil.java b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/keybinding/AttestationValidatorUtil.java index 4e916a864a2..470d3f4673c 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/keybinding/AttestationValidatorUtil.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/keybinding/AttestationValidatorUtil.java @@ -77,7 +77,7 @@ import static org.keycloak.services.clientpolicy.executor.FapiConstant.ALLOWED_A */ public class AttestationValidatorUtil { - public static final String ATTESTATION_JWT_TYP = "key-attestation+jwt "; + public static final String ATTESTATION_JWT_TYP = "key-attestation+jwt"; private static final String CACERTS_PATH = System.getProperty("javax.net.ssl.trustStore", System.getProperty("java.home") + "/lib/security/cacerts"); private static final char[] DEFAULT_TRUSTSTORE_PASSWORD = System.getProperty( 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 3931a68622a..51a52470b8f 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 @@ -17,6 +17,7 @@ package org.keycloak.protocol.oid4vc.model; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.core.JsonProcessingException; @@ -35,6 +36,7 @@ import java.util.Optional; * @author Stefan Wiedemann */ @JsonInclude(JsonInclude.Include.NON_NULL) +@JsonIgnoreProperties(ignoreUnknown = true) public class CredentialRequest { @JsonProperty("credential_configuration_id") @@ -46,6 +48,14 @@ public class CredentialRequest { @JsonProperty("proofs") private Proofs proofs; + /** + * Deprecated: use {@link #proofs} instead. + * This field is kept only for backward compatibility with clients sending a single 'proof'. + */ + @Deprecated + @JsonProperty("proof") + private JwtProof proof; + // See: https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#name-format-identifier-3 @JsonProperty("credential_definition") private CredentialDefinition credentialDefinition; @@ -80,6 +90,15 @@ public class CredentialRequest { return this; } + public JwtProof getProof() { + return proof; + } + + public CredentialRequest setProof(JwtProof proof) { + this.proof = proof; + return this; + } + public CredentialDefinition getCredentialDefinition() { return credentialDefinition; } 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 new file mode 100644 index 00000000000..3506116a835 --- /dev/null +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/model/JwtProof.java @@ -0,0 +1,65 @@ +/* + * 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; + +/** + * Deprecated: Represents a single JWT-based proof (historical 'proof' structure). + * Prefer using {@link Proofs} with the appropriate array field (e.g., jwt). + * This class is kept for backward compatibility only. + * + * @see OID4VCI Credential Request + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +@Deprecated +public class JwtProof { + + @JsonProperty("jwt") + private String jwt; + + @JsonProperty("proof_type") + private String proofType; + + public JwtProof() { + } + + public JwtProof(String jwt, String proofType) { + this.jwt = jwt; + this.proofType = proofType; + } + + public String getJwt() { + return jwt; + } + + public JwtProof setJwt(String jwt) { + this.jwt = jwt; + return this; + } + + public String getProofType() { + return proofType; + } + + public JwtProof setProofType(String proofType) { + this.proofType = proofType; + return this; + } +} 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 538c27ea986..8d7ecd8cb0c 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 @@ -53,6 +53,7 @@ import org.keycloak.protocol.oid4vc.model.Format; 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.JwtProof; import org.keycloak.protocol.oid4vc.model.Proofs; import org.keycloak.protocol.oid4vc.model.SupportedCredentialConfiguration; import org.keycloak.protocol.oid4vc.model.VerifiableCredential; @@ -947,4 +948,67 @@ public class OID4VCJWTIssuerEndpointTest extends OID4VCIssuerEndpointTest { } }); } + + /** + * Test that verifies the conversion from 'proof' (singular) to 'proofs' (array) works correctly. + * This test ensures backward compatibility with clients that send 'proof' instead of 'proofs'. + */ + @Test + public void testProofToProofsConversion() throws Exception { + String token = getBearerToken(oauth, client, jwtTypeCredentialClientScope.getName()); + final String credentialConfigurationId = jwtTypeCredentialClientScope.getAttributes() + .get(CredentialScopeModel.CONFIGURATION_ID); + + testingClient.server(TEST_REALM_NAME).run(session -> { + AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session); + authenticator.setTokenString(token); + OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator); + + // Test 1: Create a request with single proof field - should be converted to proofs array + CredentialRequest requestWithProof = new CredentialRequest() + .setCredentialConfigurationId(credentialConfigurationId); + + // Create a single proof object + JwtProof singleProof = new JwtProof() + .setJwt("dummy-jwt") + .setProofType("jwt"); + requestWithProof.setProof(singleProof); + + String requestPayload = JsonSerialization.writeValueAsString(requestWithProof); + + try { + // This should work because the conversion happens in validateRequestEncryption + issuerEndpoint.requestCredential(requestPayload); + Assert.fail(); + } catch (Exception e) { + // We expect JWT validation to fail, but the conversion should have worked + assertTrue("Error should be related to JWT validation, not conversion", + e.getMessage().contains("Could not validate provided proof")); + } + + // Test 2: Create a request with both proof and proofs fields - should fail validation + CredentialRequest requestWithBoth = new CredentialRequest() + .setCredentialConfigurationId(credentialConfigurationId); + + requestWithBoth.setProof(singleProof); + + Proofs proofsArray = new Proofs(); + proofsArray.setJwt(List.of("dummy-jwt")); + requestWithBoth.setProofs(proofsArray); + + String bothFieldsPayload = JsonSerialization.writeValueAsString(requestWithBoth); + + try { + issuerEndpoint.requestCredential(bothFieldsPayload); + Assert.fail("Expected BadRequestException when both proof and proofs are provided"); + } catch (BadRequestException e) { + int statusCode = e.getResponse().getStatus(); + assertEquals("Expected HTTP 400 Bad Request", 400, statusCode); + ErrorResponse error = (ErrorResponse) e.getResponse().getEntity(); + assertEquals(ErrorType.INVALID_CREDENTIAL_REQUEST, error.getError()); + assertEquals("Both 'proof' and 'proofs' must not be present at the same time", + error.getErrorDescription()); + } + }); + } }