[OID4VCI]: Add backward compatibility for Draft 15 wallets (single proof support) (#43951)

Closes #43926

Signed-off-by: Awambeng Rodrick <awambengrodrick@gmail.com>
This commit is contained in:
Awambeng 2025-11-12 14:30:33 +01:00 committed by GitHub
parent a05ed3154c
commit c0be5c42b9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 186 additions and 3 deletions

View File

@ -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.
* <p>
* 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<String> supportedAlgs = metadata.getAlgValuesSupported();
if (supportedAlgs == null || supportedAlgs.isEmpty()) {

View File

@ -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(

View File

@ -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 <a href="https://github.com/wistefan">Stefan Wiedemann</a>
*/
@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;
}

View File

@ -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 <a href="https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0-15.html#name-credential-request">OID4VCI Credential Request</a>
*/
@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;
}
}

View File

@ -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());
}
});
}
}