From cc3d6ce5ef6bf1b24baa449491c3684ec3f2c1da Mon Sep 17 00:00:00 2001
From: Thomas Diesler
Date: Thu, 16 Apr 2026 19:57:44 +0200
Subject: [PATCH] [OID4VCI-HAIP] Pass oid4vci-1_0-issuer-happy-flow (encrypted)
Signed-off-by: Thomas Diesler
---
.../java/org/keycloak/crypto/CryptoUtils.java | 8 +-
.../oid4vc/issuance/OID4VCIssuerEndpoint.java | 91 ++++++++-----------
.../OID4VCIssuerWellKnownProvider.java | 14 +--
.../CredentialResponseEncryptionMetadata.java | 2 +-
.../protocol/oidc/OIDCWellKnownProvider.java | 3 +-
.../tests/oid4vc/OID4VCIssuerTestBase.java | 19 ++--
.../oid4vc/OID4VCJWTIssuerEndpointTest.java | 2 +-
7 files changed, 57 insertions(+), 82 deletions(-)
diff --git a/services/src/main/java/org/keycloak/crypto/CryptoUtils.java b/services/src/main/java/org/keycloak/crypto/CryptoUtils.java
index 5a1595a7b334..72e72263e579 100644
--- a/services/src/main/java/org/keycloak/crypto/CryptoUtils.java
+++ b/services/src/main/java/org/keycloak/crypto/CryptoUtils.java
@@ -27,6 +27,7 @@
import org.keycloak.models.KeycloakSession;
import org.keycloak.provider.ProviderFactory;
+import org.keycloak.util.Strings;
/**
* Utility class for common cryptographic operations and algorithm discovery.
@@ -59,10 +60,10 @@ public static List getSupportedAsymmetricSignatureAlgorithms(KeycloakSes
* for those that use asymmetric algorithms (RSA, EC, EdDSA, etc.).
*
* @param session The Keycloak session
- * @return List of asymmetric signature algorithm names
+ * @return List of asymmetric encryption algorithm names
*/
public static List getSupportedAsymmetricEncryptionAlgorithms(KeycloakSession session) {
- return session.keys()
+ List encAlgos = session.keys()
.getKeysStream(session.getContext().getRealm())
.filter(key -> KeyUse.ENC.equals(key.getUse()))
.filter(key -> {
@@ -71,8 +72,9 @@ public static List getSupportedAsymmetricEncryptionAlgorithms(KeycloakSe
return k instanceof PublicKey || key.getPrivateKey() instanceof PrivateKey;
})
.map(KeyWrapper::getAlgorithm)
- .filter(algorithm -> algorithm != null && !algorithm.isEmpty())
+ .filter(alg -> !Strings.isEmpty(alg))
.distinct()
.toList();
+ return encAlgos;
}
}
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 dab3d3a57b29..5d17f0d94929 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
@@ -720,48 +720,32 @@ public Response requestCredential(String requestPayload) {
.map(CredentialResponseEncryptionMetadata::getEncryptionRequired)
.orElse(false);
- // Check if encryption is required but not provided
- if (isEncryptionRequired && encryptionParams == null) {
- String errorMessage = "Response encryption is required by the Credential Issuer, but no encryption parameters were provided.";
- LOGGER.debug(errorMessage);
- eventBuilder.detail(Details.REASON, errorMessage)
- .error(ErrorType.INVALID_ENCRYPTION_PARAMETERS.getValue());
- throw new BadRequestException(getErrorResponse(ErrorType.INVALID_ENCRYPTION_PARAMETERS, errorMessage));
- }
-
// Validate encryption parameters if provided
- if (encryptionParams != null) {
- validateEncryptionParameters(encryptionParams);
+ if (isEncryptionRequired || encryptionParams != null) {
- // Select and validate alg
- String selectedAlg = selectKeyManagementAlg(encryptionMetadata, encryptionParams.getJwk());
- if (selectedAlg == null) {
- String errorMessage = String.format("No supported key management algorithm (alg) for provided JWK (kty=%s)",
- encryptionParams.getJwk().getKeyType());
+ // Check if encryption is required but not provided
+ if (encryptionParams == null) {
+ String errorMessage = "Response encryption is required by the Credential Issuer, but no encryption parameters were provided.";
LOGGER.debug(errorMessage);
- eventBuilder.detail(Details.REASON, errorMessage)
- .error(ErrorType.INVALID_ENCRYPTION_PARAMETERS.getValue());
+ eventBuilder.detail(Details.REASON, errorMessage).error(ErrorType.INVALID_ENCRYPTION_PARAMETERS.getValue());
throw new BadRequestException(getErrorResponse(ErrorType.INVALID_ENCRYPTION_PARAMETERS, errorMessage));
}
+ validateEncryptionParameters(encryptionParams);
+
// Check if enc is supported
if (!encryptionMetadata.getEncValuesSupported().contains(encryptionParams.getEnc())) {
- String errorMessage = String.format("Unsupported content encryption algorithm: enc=%s",
- encryptionParams.getEnc());
+ String errorMessage = String.format("Unsupported content encryption algorithm: enc=%s", encryptionParams.getEnc());
LOGGER.debug(errorMessage);
- eventBuilder.detail(Details.REASON, errorMessage)
- .error(ErrorType.INVALID_ENCRYPTION_PARAMETERS.getValue());
+ eventBuilder.detail(Details.REASON, errorMessage).error(ErrorType.INVALID_ENCRYPTION_PARAMETERS.getValue());
throw new BadRequestException(getErrorResponse(ErrorType.INVALID_ENCRYPTION_PARAMETERS, errorMessage));
}
// Check compression (unchanged)
- if (encryptionParams.getZip() != null &&
- !isSupportedCompression(encryptionMetadata, encryptionParams.getZip())) {
- String errorMessage = String.format("Unsupported compression parameter: zip=%s",
- encryptionParams.getZip());
+ if (encryptionParams.getZip() != null && !isSupportedCompression(encryptionMetadata, encryptionParams.getZip())) {
+ String errorMessage = String.format("Unsupported compression parameter: zip=%s", encryptionParams.getZip());
LOGGER.debug(errorMessage);
- eventBuilder.detail(Details.REASON, errorMessage)
- .error(ErrorType.INVALID_ENCRYPTION_PARAMETERS.getValue());
+ eventBuilder.detail(Details.REASON, errorMessage).error(ErrorType.INVALID_ENCRYPTION_PARAMETERS.getValue());
throw new BadRequestException(getErrorResponse(ErrorType.INVALID_ENCRYPTION_PARAMETERS, errorMessage));
}
}
@@ -958,7 +942,7 @@ public Response requestCredential(String requestPayload) {
// Encrypt all responses if encryption parameters are provided, except for error credential responses
Response response;
if (encryptionParams != null && !responseVO.getCredentials().isEmpty()) {
- String jwe = encryptCredentialResponse(responseVO, encryptionParams, encryptionMetadata);
+ String jwe = encryptCredentialResponse(eventBuilder, responseVO, encryptionParams, encryptionMetadata);
response = Response.ok()
.type(MediaType.APPLICATION_JWT)
.entity(jwe)
@@ -1203,30 +1187,30 @@ else if (singleProof instanceof JwtProof jwtProof) {
validateProofTypes(credentialRequest.getProofs());
}
- private String selectKeyManagementAlg(CredentialResponseEncryptionMetadata metadata, JWK jwk) {
+ private String selectKeyManagementAlg(EventBuilder eventBuilder, CredentialResponseEncryptionMetadata metadata, JWK jwk) {
+
List supportedAlgs = metadata.getAlgValuesSupported();
if (supportedAlgs == null || supportedAlgs.isEmpty()) {
+ LOGGER.warn("No supported encryption algorithms");
return null;
}
// The alg parameter MUST be present in the JWK
String jwkAlg = jwk.getAlgorithm();
if (jwkAlg == null) {
- // If alg is missing from JWK, this is invalid
- LOGGER.debugf("JWK is missing required 'alg' parameter for key type: %s", jwk.getKeyType());
+ LOGGER.warnf("JWK is missing required 'alg' parameter for key type: %s", jwk.getKeyType());
return null;
}
// Verify the alg is supported by the server
- if (supportedAlgs.contains(jwkAlg)) {
- return jwkAlg;
+ if (!supportedAlgs.contains(jwkAlg)) {
+ String errorMessage = String.format("JWK algorithm '%s' is not supported. Supported algorithms: %s", jwkAlg, supportedAlgs);
+ LOGGER.debug(errorMessage);
+ eventBuilder.detail(Details.REASON, errorMessage).error(ErrorType.INVALID_ENCRYPTION_PARAMETERS.getValue());
+ throw new BadRequestException(getErrorResponse(ErrorType.INVALID_ENCRYPTION_PARAMETERS, errorMessage));
}
- // If the JWK's alg is not supported, we cannot proceed
- LOGGER.debugf("JWK algorithm '%s' is not supported by the server. Supported algorithms: %s",
- jwkAlg, supportedAlgs);
- throw new IllegalArgumentException(String.format("JWK algorithm '%s' is not supported. Supported algorithms: %s", jwkAlg, supportedAlgs));
-
+ return jwkAlg;
}
private List getAllProofs(CredentialRequest credentialRequestVO) {
@@ -1281,13 +1265,13 @@ private boolean hasProofEntries(List proofs) {
/**
* Encrypts a CredentialResponse as a JWE using the provided encryption parameters.
*
- * @param response The CredentialResponse to encrypt
+ * @param response The CredentialResponse to encrypt
* @param encryptionParams The encryption parameters (alg, enc, jwk)
* @return The compact JWE serialization
- * @throws BadRequestException If encryption parameters are invalid
+ * @throws BadRequestException If encryption parameters are invalid
* @throws WebApplicationException If encryption fails due to server issues
*/
- private String encryptCredentialResponse(CredentialResponse response,
+ private String encryptCredentialResponse(EventBuilder eventBuilder, CredentialResponse response,
CredentialResponseEncryption encryptionParams,
CredentialResponseEncryptionMetadata metadata) {
validateEncryptionParameters(encryptionParams);
@@ -1298,21 +1282,22 @@ private String encryptCredentialResponse(CredentialResponse response,
// Parse public key
PublicKey publicKey;
- try {
- publicKey = JWKParser.create(jwk).toPublicKey();
- if (publicKey == null) {
- LOGGER.debug("Invalid JWK: Failed to parse public key");
- throw new BadRequestException(getErrorResponse(ErrorType.INVALID_ENCRYPTION_PARAMETERS,
- "Invalid JWK: Failed to parse public key."));
+ {
+ String message = "Invalid JWK: Failed to parse public key";
+ try {
+ publicKey = JWKParser.create(jwk).toPublicKey();
+ if (publicKey == null) {
+ LOGGER.debug(message);
+ throw new BadRequestException(getErrorResponse(ErrorType.INVALID_ENCRYPTION_PARAMETERS, message));
+ }
+ } catch (Exception e) {
+ LOGGER.debug(message);
+ throw new BadRequestException(getErrorResponse(ErrorType.INVALID_ENCRYPTION_PARAMETERS, message));
}
- } catch (Exception e) {
- LOGGER.debugf("Failed to parse JWK: %s", e.getMessage());
- throw new BadRequestException(getErrorResponse(ErrorType.INVALID_ENCRYPTION_PARAMETERS,
- "Invalid JWK: Failed to parse public key."));
}
// Select alg
- String selectedAlg = selectKeyManagementAlg(metadata, jwk);
+ String selectedAlg = selectKeyManagementAlg(eventBuilder, metadata, jwk);
// Perform encryption
try {
diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/OID4VCIssuerWellKnownProvider.java b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/OID4VCIssuerWellKnownProvider.java
index 7c574aeb680a..cdbec8e00848 100644
--- a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/OID4VCIssuerWellKnownProvider.java
+++ b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/OID4VCIssuerWellKnownProvider.java
@@ -67,7 +67,6 @@
import static org.keycloak.OID4VCConstants.SIGNED_METADATA_JWT_TYPE;
import static org.keycloak.OID4VCConstants.WELL_KNOWN_OPENID_CREDENTIAL_ISSUER;
import static org.keycloak.constants.OID4VCIConstants.BATCH_CREDENTIAL_ISSUANCE_BATCH_SIZE;
-import static org.keycloak.crypto.KeyType.RSA;
import static org.keycloak.jose.jwk.RSAPublicJWK.RS256;
/**
@@ -379,21 +378,10 @@ public static CredentialRequestEncryptionMetadata getCredentialRequestEncryption
* Returns the supported encryption algorithms from realm attributes.
*/
public static List getSupportedEncryptionAlgorithms(KeycloakSession session) {
- RealmModel realm = session.getContext().getRealm();
- KeyManager keyManager = session.keys();
List supportedEncryptionAlgorithms = CryptoUtils.getSupportedAsymmetricEncryptionAlgorithms(session);
-
- // Default algorithms if none configured
if (supportedEncryptionAlgorithms.isEmpty()) {
- boolean hasRsaKeys = keyManager.getKeysStream(realm)
- .filter(key -> KeyUse.ENC.equals(key.getUse()))
- .anyMatch(key -> RSA.equals(key.getType()));
-
- if (hasRsaKeys) {
- supportedEncryptionAlgorithms.add(JWEConstants.RSA_OAEP);
- supportedEncryptionAlgorithms.add(JWEConstants.RSA_OAEP_256);
- }
+ supportedEncryptionAlgorithms = List.of(JWEConstants.RSA_OAEP, JWEConstants.RSA_OAEP_256);
}
return supportedEncryptionAlgorithms;
diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/model/CredentialResponseEncryptionMetadata.java b/services/src/main/java/org/keycloak/protocol/oid4vc/model/CredentialResponseEncryptionMetadata.java
index fc686ff18682..5297dd4001df 100644
--- a/services/src/main/java/org/keycloak/protocol/oid4vc/model/CredentialResponseEncryptionMetadata.java
+++ b/services/src/main/java/org/keycloak/protocol/oid4vc/model/CredentialResponseEncryptionMetadata.java
@@ -24,7 +24,7 @@
/**
* Represents the credential_response_encryption metadata for an OID4VCI Credential Issuer.
- * @see https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0-16.html#name-credential-issuer-metadata-p
+ * @see
*
* @author Bertrand Ogen
*/
diff --git a/services/src/main/java/org/keycloak/protocol/oidc/OIDCWellKnownProvider.java b/services/src/main/java/org/keycloak/protocol/oidc/OIDCWellKnownProvider.java
index b9af5336f014..e4b4dbc13b5b 100755
--- a/services/src/main/java/org/keycloak/protocol/oidc/OIDCWellKnownProvider.java
+++ b/services/src/main/java/org/keycloak/protocol/oidc/OIDCWellKnownProvider.java
@@ -232,10 +232,9 @@ public Object getConfig() {
config.setAuthorizationDetailsTypesSupported(authorizationDetailsTypesSupported);
}
+ // HAIP-1.0 does not want to see this property (don't set to false)
if (Profile.isFeatureEnabled(Profile.Feature.CIMD)) {
config.setClientIdMetadataDocumentSupported(true);
- } else {
- config.setClientIdMetadataDocumentSupported(false);
}
config = checkConfigOverride(config);
diff --git a/tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCIssuerTestBase.java b/tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCIssuerTestBase.java
index 9b23c7c3e556..af727c0f881b 100644
--- a/tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCIssuerTestBase.java
+++ b/tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCIssuerTestBase.java
@@ -39,7 +39,6 @@
import org.keycloak.events.EventType;
import org.keycloak.keys.KeyProvider;
import org.keycloak.models.UserModel;
-import org.keycloak.models.oid4vci.CredentialScopeModel;
import org.keycloak.models.utils.KeycloakModelUtils;
import org.keycloak.protocol.oid4vc.issuance.OID4VCAuthorizationDetailsParser;
import org.keycloak.protocol.oid4vc.issuance.TimeProvider;
@@ -94,6 +93,10 @@
import static org.keycloak.OID4VCConstants.OPENID_CREDENTIAL;
import static org.keycloak.constants.OID4VCIConstants.CREDENTIAL_OFFER_CREATE;
import static org.keycloak.models.Constants.CREATE_DEFAULT_CLIENT_SCOPES;
+import static org.keycloak.models.oid4vci.CredentialScopeModel.CRYPTOGRAPHIC_BINDING_METHODS_DEFAULT;
+import static org.keycloak.models.oid4vci.CredentialScopeModel.VC_BINDING_REQUIRED;
+import static org.keycloak.models.oid4vci.CredentialScopeModel.VC_BINDING_REQUIRED_PROOF_TYPES;
+import static org.keycloak.models.oid4vci.CredentialScopeModel.VC_CRYPTOGRAPHIC_BINDING_METHODS;
import static org.keycloak.models.oid4vci.CredentialScopeModel.VC_FORMAT_DEFAULT;
/**
@@ -447,10 +450,9 @@ public RealmConfigBuilder configure(RealmConfigBuilder realm) {
List.of(OID4VCConstants.KeyAttestationResistanceLevels.HIGH, OID4VCConstants.KeyAttestationResistanceLevels.MODERATE)
);
Map sdJwtAttrs = Optional.ofNullable(sdJwtScope.getAttributes()).orElseGet(HashMap::new);
- sdJwtAttrs.put(CredentialScopeModel.VC_BINDING_REQUIRED, "true");
- sdJwtAttrs.put(CredentialScopeModel.VC_BINDING_REQUIRED_PROOF_TYPES, "jwt");
- sdJwtAttrs.put(CredentialScopeModel.VC_CRYPTOGRAPHIC_BINDING_METHODS,
- CredentialScopeModel.CRYPTOGRAPHIC_BINDING_METHODS_DEFAULT);
+ sdJwtAttrs.put(VC_BINDING_REQUIRED, "true");
+ sdJwtAttrs.put(VC_BINDING_REQUIRED_PROOF_TYPES, "jwt");
+ sdJwtAttrs.put(VC_CRYPTOGRAPHIC_BINDING_METHODS, CRYPTOGRAPHIC_BINDING_METHODS_DEFAULT);
sdJwtScope.setAttributes(sdJwtAttrs);
realm.addClientScope(sdJwtScope);
@@ -465,10 +467,9 @@ public RealmConfigBuilder configure(RealmConfigBuilder realm) {
Collections.emptyList()
);
Map jwtVcAttrs = Optional.ofNullable(jwtVcScope.getAttributes()).orElseGet(HashMap::new);
- jwtVcAttrs.put(CredentialScopeModel.VC_BINDING_REQUIRED, "true");
- jwtVcAttrs.put(CredentialScopeModel.VC_BINDING_REQUIRED_PROOF_TYPES, "jwt,attestation");
- jwtVcAttrs.put(CredentialScopeModel.VC_CRYPTOGRAPHIC_BINDING_METHODS,
- CredentialScopeModel.CRYPTOGRAPHIC_BINDING_METHODS_DEFAULT);
+ jwtVcAttrs.put(VC_BINDING_REQUIRED, "true");
+ jwtVcAttrs.put(VC_BINDING_REQUIRED_PROOF_TYPES, "jwt,attestation");
+ jwtVcAttrs.put(VC_CRYPTOGRAPHIC_BINDING_METHODS, CRYPTOGRAPHIC_BINDING_METHODS_DEFAULT);
jwtVcScope.setAttributes(jwtVcAttrs);
realm.addClientScope(jwtVcScope);
diff --git a/tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCJWTIssuerEndpointTest.java b/tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCJWTIssuerEndpointTest.java
index daaa0ff9ac1a..b3a5cdfc2b46 100644
--- a/tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCJWTIssuerEndpointTest.java
+++ b/tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCJWTIssuerEndpointTest.java
@@ -942,7 +942,7 @@ public void testRequestCredentialWithCredentialConfigurationId() {
.credentialConfigurationId(authDetails.get(0).getCredentialConfigurationId())
.bearerToken(tokenResponse.getAccessToken())
.send().getCredentialResponse());
- assertTrue(ex.getMessage().contains("Credential must be requested by credential identifier from authorization_details"), "Unexpected - " + ex.getMessage());
+ assertTrue(ex.getMessage().contains("Credential must be requested by credential identifier from authorization_details"), ex.getMessage());
}
@Test