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