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 1515ac6b026..82882cb815e 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
@@ -83,6 +83,7 @@ import org.keycloak.protocol.oid4vc.issuance.keybinding.JwtCNonceHandler;
import org.keycloak.protocol.oid4vc.issuance.keybinding.ProofValidator;
import org.keycloak.protocol.oid4vc.issuance.mappers.OID4VCMapper;
import org.keycloak.protocol.oid4vc.issuance.signing.CredentialSigner;
+import org.keycloak.protocol.oid4vc.model.AttestationProof;
import org.keycloak.protocol.oid4vc.model.ClaimsDescription;
import org.keycloak.protocol.oid4vc.model.CredentialIssuer;
import org.keycloak.protocol.oid4vc.model.CredentialOfferURI;
@@ -985,11 +986,28 @@ public class OID4VCIssuerEndpoint {
if (credentialRequest.getProof() != null) {
LOGGER.debugf("Converting single 'proof' field to 'proofs' array for backward compatibility");
- JwtProof singleProof = credentialRequest.getProof();
+ Object singleProof = credentialRequest.getProof();
Proofs proofsArray = new Proofs();
- if (singleProof.getJwt() != null) {
- proofsArray.setJwt(List.of(singleProof.getJwt()));
+
+ // Handle AttestationProof
+ if (singleProof instanceof AttestationProof attestationProof) {
+ String attestationValue = attestationProof.getAttestation();
+ if (attestationValue != null) {
+ proofsArray.setAttestation(List.of(attestationValue));
+ }
}
+ // Handle JwtProof
+ else if (singleProof instanceof JwtProof jwtProof) {
+ String jwtValue = jwtProof.getJwt();
+ if (jwtValue != null) {
+ proofsArray.setJwt(List.of(jwtValue));
+ }
+ } else {
+ String message = "Unsupported proof type: " + (singleProof != null ? singleProof.getClass().getName() : "null");
+ LOGGER.debug(message);
+ throw new BadRequestException(getErrorResponse(ErrorType.INVALID_CREDENTIAL_REQUEST, message));
+ }
+
credentialRequest.setProofs(proofsArray);
credentialRequest.setProof(null);
}
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 e95f66d2214..3387b40f547 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
@@ -67,6 +67,7 @@ import org.keycloak.util.JsonSerialization;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
+import org.jboss.logging.Logger;
import static org.keycloak.protocol.oid4vc.model.ProofType.JWT;
import static org.keycloak.services.clientpolicy.executor.FapiConstant.ALLOWED_ALGORITHMS;
@@ -78,7 +79,11 @@ import static org.keycloak.services.clientpolicy.executor.FapiConstant.ALLOWED_A
*/
public class AttestationValidatorUtil {
+ private static final Logger LOGGER = Logger.getLogger(AttestationValidatorUtil.class);
+
public static final String ATTESTATION_JWT_TYP = "key-attestation+jwt";
+ @Deprecated
+ public static final String LEGACY_ATTESTATION_JWT_TYP = "keyattestation+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(
@@ -291,8 +296,16 @@ public class AttestationValidatorUtil {
". Allowed algorithms: " + ALLOWED_ALGORITHMS);
}
- if (!ATTESTATION_JWT_TYP.equals(header.getType())) {
- throw new VCIssuerException("Invalid JWT typ: expected " + ATTESTATION_JWT_TYP);
+ String typ = Optional.ofNullable(header.getType())
+ .map(Object::toString)
+ .orElseThrow(() -> new VCIssuerException("Missing typ in JWS header"));
+
+ if (!ATTESTATION_JWT_TYP.equals(typ)) {
+ if (LEGACY_ATTESTATION_JWT_TYP.equals(typ)) {
+ LOGGER.debugf("Accepting deprecated attestation JWT typ '%s' for backward compatibility", typ);
+ } else {
+ throw new VCIssuerException("Invalid JWT typ: expected " + ATTESTATION_JWT_TYP);
+ }
}
}
diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/model/AttestationProof.java b/services/src/main/java/org/keycloak/protocol/oid4vc/model/AttestationProof.java
new file mode 100644
index 00000000000..706b0659db7
--- /dev/null
+++ b/services/src/main/java/org/keycloak/protocol/oid4vc/model/AttestationProof.java
@@ -0,0 +1,49 @@
+package org.keycloak.protocol.oid4vc.model;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+import com.fasterxml.jackson.annotation.JsonProperty;
+
+/**
+ * Deprecated: Represents a single attestation-based proof (historical 'proof' structure).
+ * Prefer using {@link Proofs} with the appropriate array field (e.g., attestation).
+ * This class is kept for backward compatibility only.
+ * Supports 'attestation' proof type as per OID4VCI Draft 15.
+ *
+ * @see OID4VCI Credential Request
+ */
+@JsonInclude(JsonInclude.Include.NON_NULL)
+@Deprecated
+public class AttestationProof {
+
+ @JsonProperty("attestation")
+ private String attestation;
+
+ @JsonProperty("proof_type")
+ private String proofType;
+
+ public AttestationProof() {
+ }
+
+ public AttestationProof(String attestation, String proofType) {
+ this.attestation = attestation;
+ this.proofType = proofType;
+ }
+
+ public String getAttestation() {
+ return attestation;
+ }
+
+ public AttestationProof setAttestation(String attestation) {
+ this.attestation = attestation;
+ return this;
+ }
+
+ public String getProofType() {
+ return proofType;
+ }
+
+ public AttestationProof setProofType(String proofType) {
+ this.proofType = proofType;
+ return this;
+ }
+}
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 0ca3baeba66..c424b9f02b0 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
@@ -28,6 +28,8 @@ import org.keycloak.util.JsonSerialization;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty;
+import com.fasterxml.jackson.annotation.JsonSubTypes;
+import com.fasterxml.jackson.annotation.JsonTypeInfo;
import com.fasterxml.jackson.core.JsonProcessingException;
/**
@@ -52,10 +54,20 @@ public class CredentialRequest {
/**
* Deprecated: use {@link #proofs} instead.
* This field is kept only for backward compatibility with clients sending a single 'proof'.
+ * Can be either {@link JwtProof} or {@link AttestationProof} depending on the proof type.
*/
@Deprecated
@JsonProperty("proof")
- private JwtProof proof;
+ @JsonTypeInfo(
+ use = JsonTypeInfo.Id.NAME,
+ include = JsonTypeInfo.As.EXISTING_PROPERTY,
+ property = "proof_type"
+ )
+ @JsonSubTypes({
+ @JsonSubTypes.Type(value = JwtProof.class, name = "jwt"),
+ @JsonSubTypes.Type(value = AttestationProof.class, name = "attestation")
+ })
+ private Object proof;
// See: https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#name-format-identifier-3
@JsonProperty("credential_definition")
@@ -91,11 +103,11 @@ public class CredentialRequest {
return this;
}
- public JwtProof getProof() {
+ public Object getProof() {
return proof;
}
- public CredentialRequest setProof(JwtProof proof) {
+ public CredentialRequest setProof(Object proof) {
this.proof = proof;
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
index 3506116a835..94dda7e3ac3 100644
--- a/services/src/main/java/org/keycloak/protocol/oid4vc/model/JwtProof.java
+++ b/services/src/main/java/org/keycloak/protocol/oid4vc/model/JwtProof.java
@@ -24,6 +24,7 @@ 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.
+ * Supports 'jwt' proof type as per OID4VCI Draft 15.
*
* @see OID4VCI Credential Request
*/
diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/model/SupportedProofTypeData.java b/services/src/main/java/org/keycloak/protocol/oid4vc/model/SupportedProofTypeData.java
index de4659f076e..93aaa9f64e5 100644
--- a/services/src/main/java/org/keycloak/protocol/oid4vc/model/SupportedProofTypeData.java
+++ b/services/src/main/java/org/keycloak/protocol/oid4vc/model/SupportedProofTypeData.java
@@ -58,6 +58,15 @@ public class SupportedProofTypeData {
return this;
}
+ /**
+ * Returns the key attestations required.
+ * According to the spec:
+ * - If the Credential Issuer does not require a key attestation, this parameter MUST NOT be present (should be null).
+ * - If both key_storage and user_authentication parameters are absent, the key_attestations_required parameter
+ * may be empty (both fields null), indicating a key attestation is needed without additional constraints.
+ *
+ * @return KeyAttestationsRequired object, or null if attestation is not required
+ */
public KeyAttestationsRequired getKeyAttestationsRequired() {
return keyAttestationsRequired;
}
diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCAttestationProofTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCAttestationProofTest.java
index 697bcfed5a4..4765c33fe30 100644
--- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCAttestationProofTest.java
+++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCAttestationProofTest.java
@@ -57,6 +57,37 @@ public class OID4VCAttestationProofTest extends OID4VCIssuerEndpointTest {
});
}
+ @Test
+ public void testAttestationProofAcceptsLegacyTyp() {
+ String cNonce = getCNonce();
+ testingClient.server(TEST_REALM_NAME).run(session -> {
+ try {
+ KeyWrapper attestationKey = createECKey("legacyAttestationKey");
+ KeyWrapper proofKey = createECKey("legacyProofKey");
+
+ JWK proofJwk = createJWK(proofKey);
+ String attestationJwt = createValidAttestationJwt(
+ session,
+ attestationKey,
+ List.of(proofJwk),
+ cNonce,
+ AttestationValidatorUtil.LEGACY_ATTESTATION_JWT_TYP);
+
+ configureTrustedKeysInRealm(session, List.of(createJWK(attestationKey)));
+
+ VCIssuanceContext vcIssuanceContext = createVCIssuanceContextWithAttestationProof(session, attestationJwt);
+
+ AttestationProofValidatorFactory factory = new AttestationProofValidatorFactory();
+ AttestationProofValidator validator = (AttestationProofValidator) factory.create(session);
+
+ validateProofAndAssert(validator, vcIssuanceContext, proofKey);
+ } catch (Exception e) {
+ LOGGER.error("Legacy typ test failed with exception", e);
+ fail("Legacy typ attestation proof should be accepted: " + e.getMessage());
+ }
+ });
+ }
+
@Test
public void testAttestationProofExtractsAttestedKeysFromPayload() {
String cNonce = getCNonce();
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 6202548b0b4..20643398184 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
@@ -641,13 +641,23 @@ public abstract class OID4VCTest extends AbstractTestRealmKeycloakTest {
KeyWrapper attestationKey,
JWK proofJwk,
String cNonce) {
- return createValidAttestationJwt(session, attestationKey, List.of(proofJwk), cNonce);
- }
+ return createValidAttestationJwt(session, attestationKey, List.of(proofJwk), cNonce,
+ AttestationValidatorUtil.ATTESTATION_JWT_TYP);
+ }
protected static String createValidAttestationJwt(KeycloakSession session,
KeyWrapper attestationKey,
List proofJwks,
String cNonce) {
+ return createValidAttestationJwt(session, attestationKey, proofJwks, cNonce,
+ AttestationValidatorUtil.ATTESTATION_JWT_TYP);
+ }
+
+ protected static String createValidAttestationJwt(KeycloakSession session,
+ KeyWrapper attestationKey,
+ List proofJwks,
+ String cNonce,
+ String typ) {
try {
KeyAttestationJwtBody payload = new KeyAttestationJwtBody();
payload.setIat((long) TIME_PROVIDER.currentTimeSeconds());
@@ -657,7 +667,7 @@ public abstract class OID4VCTest extends AbstractTestRealmKeycloakTest {
payload.setUserAuthentication(List.of(KeyAttestationResistanceLevels.HIGH));
return new JWSBuilder()
- .type(AttestationValidatorUtil.ATTESTATION_JWT_TYP)
+ .type(typ)
.kid(attestationKey.getKid())
.jsonContent(payload)
.sign(new ECDSASignatureSignerContext(attestationKey));