mirror of
https://github.com/keycloak/keycloak.git
synced 2026-01-09 23:12:06 -03:30
[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:
parent
a05ed3154c
commit
c0be5c42b9
@ -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()) {
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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());
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user