[OID4VCI] Add support for credential_request_encryption in metadat (#42169)

closes #41594
closes #41593
closes #41592
closes #41582
closes #41595


Signed-off-by: Ogenbertrand <ogenbertrand@gmail.com>
This commit is contained in:
Ogen Bertrand 2025-09-15 08:19:15 +01:00 committed by GitHub
parent 20f5a15278
commit 70b50e93e9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 1166 additions and 550 deletions

View File

@ -35,12 +35,16 @@ import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.WebApplicationException;
import jakarta.ws.rs.core.HttpHeaders;
import jakarta.ws.rs.core.Response;
import org.keycloak.OAuth2Constants;
import org.apache.commons.io.IOUtils;
import org.jboss.logging.Logger;
import org.keycloak.common.util.SecretGenerator;
import org.keycloak.crypto.KeyUse;
import org.keycloak.crypto.KeyWrapper;
import org.keycloak.OAuth2Constants;
import org.keycloak.constants.Oid4VciConstants;
import org.keycloak.events.Errors;
import org.keycloak.events.EventBuilder;
import org.keycloak.jose.JOSEHeader;
import org.keycloak.jose.jwe.JWE;
import org.keycloak.jose.jwe.JWEException;
import org.keycloak.jose.jwe.JWEHeader;
@ -49,6 +53,7 @@ import org.keycloak.jose.jwk.JWKParser;
import org.keycloak.models.AuthenticatedClientSessionModel;
import org.keycloak.models.ClientModel;
import org.keycloak.models.ClientScopeModel;
import org.keycloak.models.KeyManager;
import org.keycloak.models.oid4vci.CredentialScopeModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.KeycloakSessionFactory;
@ -64,8 +69,10 @@ 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.CredentialIssuer;
import org.keycloak.protocol.oid4vc.model.CredentialOfferURI;
import org.keycloak.protocol.oid4vc.model.CredentialRequest;
import org.keycloak.protocol.oid4vc.model.CredentialRequestEncryptionMetadata;
import org.keycloak.protocol.oid4vc.model.CredentialResponse;
import org.keycloak.protocol.oid4vc.model.CredentialResponseEncryption;
import org.keycloak.protocol.oid4vc.model.CredentialResponseEncryptionMetadata;
@ -87,6 +94,7 @@ import org.keycloak.protocol.oidc.grants.PreAuthorizedCodeGrantTypeFactory;
import org.keycloak.protocol.oidc.utils.OAuth2Code;
import org.keycloak.protocol.oidc.utils.OAuth2CodeParser;
import org.keycloak.representations.AccessToken;
import org.keycloak.saml.processing.api.util.DeflateUtil;
import org.keycloak.services.CorsErrorResponseException;
import org.keycloak.services.cors.Cors;
import org.keycloak.services.managers.AppAuthManager;
@ -128,6 +136,7 @@ public class OID4VCIssuerEndpoint {
private static final String CODE_LIFESPAN_REALM_ATTRIBUTE_KEY = "preAuthorizedCodeLifespanS";
private static final int DEFAULT_CODE_LIFESPAN_S = 30;
public static final String DEFLATE_COMPRESSION = "DEF";
public static final String NONCE_PATH = "nonce";
public static final String CREDENTIAL_PATH = "credential";
public static final String CREDENTIAL_OFFER_PATH = "credential-offer/";
@ -348,7 +357,7 @@ public class OID4VCIssuerEndpoint {
}
/**
* Provides an OID4VCI compliant credentials offer
* Provides an OID4VCI compliant credential offer
*/
@GET
@Produces(MediaType.APPLICATION_JSON)
@ -395,14 +404,24 @@ public class OID4VCIssuerEndpoint {
* Returns a verifiable credential
*/
@POST
@Consumes(MediaType.APPLICATION_JSON)
@Consumes({MediaType.APPLICATION_JSON, MediaType.APPLICATION_JWT})
@Produces({MediaType.APPLICATION_JSON, MediaType.APPLICATION_JWT})
@Path(CREDENTIAL_PATH)
public Response requestCredential(CredentialRequest credentialRequestVO) {
LOGGER.debugf("Received credentials request %s.", credentialRequestVO);
public Response requestCredential(String requestPayload) {
LOGGER.debugf("Received credentials request with payload: %s", requestPayload);
if (requestPayload == null || requestPayload.trim().isEmpty()) {
String errorMessage = "Request payload is null or empty.";
LOGGER.debug(errorMessage);
throw new BadRequestException(getErrorResponse(ErrorType.INVALID_CREDENTIAL_REQUEST, errorMessage));
}
cors = Cors.builder().auth().allowedMethods("POST").auth().exposedHeaders(Cors.ACCESS_CONTROL_ALLOW_METHODS);
CredentialIssuer issuerMetadata = (CredentialIssuer) new OID4VCIssuerWellKnownProvider(session).getConfig();
// Validate request encryption
CredentialRequest credentialRequestVO = validateRequestEncryption(requestPayload, issuerMetadata);
// Authenticate first to fail fast on auth errors
AuthenticationManager.AuthResult authResult = getAuthResult();
@ -416,26 +435,39 @@ public class OID4VCIssuerEndpoint {
// Check if encryption is required but not provided
if (isEncryptionRequired && encryptionParams == null) {
String errorMessage = "Encryption is required by the Credential Issuer, but no encryption parameters were provided.";
String errorMessage = "Response encryption is required by the Credential Issuer, but no encryption parameters were provided.";
LOGGER.debug(errorMessage);
throw new BadRequestException(getErrorResponse(ErrorType.INVALID_ENCRYPTION_PARAMETERS, errorMessage));
}
// Validate encryption parameters if provided
if (encryptionParams != null) {
try {
validateEncryptionParameters(encryptionParams);
validateEncryptionParameters(encryptionParams);
// Check if the encryption algorithms are supported
if (!isSupportedEncryption(encryptionMetadata, encryptionParams.getAlg(), encryptionParams.getEnc())) {
String errorMessage = String.format("Unsupported encryption parameters: alg=%s, enc=%s",
encryptionParams.getAlg(), encryptionParams.getEnc());
LOGGER.debug(errorMessage);
throw new BadRequestException(getErrorResponse(ErrorType.INVALID_ENCRYPTION_PARAMETERS, errorMessage));
}
} catch (BadRequestException e) {
// Re-throw with proper error type
throw e;
// 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());
LOGGER.debug(errorMessage);
throw new BadRequestException(getErrorResponse(ErrorType.INVALID_ENCRYPTION_PARAMETERS, errorMessage));
}
// Check if enc is supported
if (!encryptionMetadata.getEncValuesSupported().contains(encryptionParams.getEnc())) {
String errorMessage = String.format("Unsupported content encryption algorithm: enc=%s",
encryptionParams.getEnc());
LOGGER.debug(errorMessage);
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());
LOGGER.debug(errorMessage);
throw new BadRequestException(getErrorResponse(ErrorType.INVALID_ENCRYPTION_PARAMETERS, errorMessage));
}
}
@ -451,12 +483,11 @@ public class OID4VCIssuerEndpoint {
// Check if at least one of both is available.
if (requestedCredentialConfigurationId == null && requestedCredentialIdentifier == null) {
LOGGER.debugf("Missing both credential_configuration_id and credential_identifier. " +
"At least one must be specified.");
LOGGER.debugf("Missing both credential_configuration_id and credential_identifier. At least one must be specified.");
throw new BadRequestException(getErrorResponse(ErrorType.MISSING_CREDENTIAL_IDENTIFIER_AND_CONFIGURATION_ID));
}
// Find the requested credential scope
// Find the requested credential
CredentialScopeModel requestedCredential = credentialRequestVO.findCredentialScope(session).orElseThrow(() -> {
LOGGER.debugf("Credential for request '%s' not found.", credentialRequestVO.toString());
@ -479,6 +510,7 @@ public class OID4VCIssuerEndpoint {
// Get the list of all proofs (handles single proof, multiple proofs, or none)
List<String> allProofs = getAllProofs(credentialRequestVO);
// Generate credential response
CredentialResponse responseVO = new CredentialResponse();
responseVO.setNotificationId(generateNotificationId());
@ -487,7 +519,7 @@ public class OID4VCIssuerEndpoint {
Object theCredential = getCredential(authResult, supportedCredential, credentialRequestVO);
responseVO.addCredential(theCredential);
} else {
// Issue credentials for each proof (or one if no proofs)
// Issue credentials for each proof
Proofs originalProofs = credentialRequestVO.getProofs();
for (String currentProof : allProofs) {
credentialRequestVO.setProofs(new Proofs().setJwt(List.of(currentProof)));
@ -497,17 +529,188 @@ public class OID4VCIssuerEndpoint {
credentialRequestVO.setProofs(originalProofs);
}
if (encryptionParams != null) {
String jwe = encryptCredentialResponse(responseVO, encryptionParams);
// Encrypt all responses if encryption parameters are provided, except for error credential responses
if (encryptionParams != null && !responseVO.getCredentials().isEmpty()) {
String jwe = encryptCredentialResponse(responseVO, encryptionParams, encryptionMetadata);
return Response.ok()
.type(MediaType.APPLICATION_JWT)
.entity(jwe)
.build();
}
return Response.ok().entity(responseVO).build();
}
private CredentialRequest validateRequestEncryption(String requestPayload, CredentialIssuer issuerMetadata) throws BadRequestException {
CredentialRequestEncryptionMetadata requestEncryptionMetadata = issuerMetadata.getCredentialRequestEncryption();
boolean isRequestEncryptionRequired = Optional.ofNullable(requestEncryptionMetadata)
.map(CredentialRequestEncryptionMetadata::isEncryptionRequired)
.orElse(false);
// Determine if the request is a JWE based on content-type
String contentType = session.getContext().getHttpRequest().getHttpHeaders()
.getHeaderString(HttpHeaders.CONTENT_TYPE);
if (contentType != null) {
contentType = contentType.split(";")[0].trim(); // Handle parameters like charset
}
boolean contentTypeIsJwt = MediaType.APPLICATION_JWT.equalsIgnoreCase(contentType);
if (isRequestEncryptionRequired || contentTypeIsJwt) {
if (requestEncryptionMetadata == null && contentTypeIsJwt) {
String errorMessage = "Received JWT content-type request, but credential_request_encryption is not supported.";
LOGGER.debug(errorMessage);
throw new BadRequestException(getErrorResponse(ErrorType.INVALID_ENCRYPTION_PARAMETERS, errorMessage));
}
try {
return decryptCredentialRequest(requestPayload, requestEncryptionMetadata);
} catch (Exception e) {
if (isRequestEncryptionRequired) {
String errorMessage = "Encryption is required but request is not a valid JWE: " + e.getMessage();
LOGGER.debug(errorMessage);
throw new BadRequestException(getErrorResponse(ErrorType.INVALID_ENCRYPTION_PARAMETERS, errorMessage));
}
if (contentTypeIsJwt) {
String errorMessage = "Request has JWT content-type but is not a valid JWE: " + e.getMessage();
LOGGER.debug(errorMessage);
throw new BadRequestException(getErrorResponse(ErrorType.INVALID_ENCRYPTION_PARAMETERS, errorMessage));
}
}
}
try {
return JsonSerialization.mapper.readValue(requestPayload, CredentialRequest.class);
} catch (JsonProcessingException e) {
String errorMessage = "Failed to parse JSON request: " + e.getMessage();
LOGGER.debug(errorMessage);
throw new BadRequestException(getErrorResponse(ErrorType.INVALID_CREDENTIAL_REQUEST, errorMessage));
}
}
/**
* Decrypts a JWE-encoded Credential Request and validates it against metadata.
*
* @param jweString The JWE compact serialization
* @param metadata The CredentialRequestEncryptionMetadata
* @return The parsed CredentialRequest
* @throws JWEException If decryption or validation fails
*/
private CredentialRequest decryptCredentialRequest(String jweString, CredentialRequestEncryptionMetadata metadata) throws Exception {
JWE jwe = new JWE(jweString);
JOSEHeader rawHeader = jwe.getHeader();
if (!(rawHeader instanceof JWEHeader)) {
throw new JWEException("Invalid header type: expected JWEHeader but got " + rawHeader.getClass().getName());
}
JWEHeader header = (JWEHeader) rawHeader;
// Validate alg and enc against supported values
String enc = header.getEncryptionAlgorithm();
if (!metadata.getEncValuesSupported().contains(enc)) {
String errorMessage = String.format("Unsupported content encryption algorithm: enc=%s", enc);
LOGGER.debugf(errorMessage);
throw new JWEException(String.valueOf(ErrorType.INVALID_ENCRYPTION_PARAMETERS));
}
// Handle compression if present
String zip = header.getCompressionAlgorithm();
if (zip != null) {
if (!DEFLATE_COMPRESSION.equals(zip) || metadata.getZipValuesSupported() == null || !metadata.getZipValuesSupported().contains(zip)) {
String errorMessage = String.format("Unsupported compression algorithm: zip=%s", zip);
LOGGER.debugf(errorMessage);
throw new JWEException(String.valueOf(ErrorType.INVALID_ENCRYPTION_PARAMETERS));
}
}
// Get a private key from KeyManager based on kid
String kid = header.getKeyId();
if (kid == null) {
throw new JWEException("Missing kid in JWE header");
}
RealmModel realm = session.getContext().getRealm();
KeyManager keyManager = session.keys();
List<KeyWrapper> matchingKeys = keyManager.getKeysStream(realm)
.filter(key -> KeyUse.ENC.equals(key.getUse()) && kid.equals(key.getKid()))
.collect(Collectors.toList());
if (matchingKeys.isEmpty()) {
throw new JWEException("No encryption key found for kid: " + kid);
}
if (matchingKeys.size() > 1) {
throw new JWEException("Multiple encryption keys found for kid: " + kid);
}
KeyWrapper keyWrapper = matchingKeys.get(0);
// Set the decryption key
jwe.getKeyStorage().setDecryptionKey(keyWrapper.getPrivateKey());
// Decrypt the JWE
try {
jwe.verifyAndDecodeJwe();
} catch (JWEException e) {
throw new JWEException("Failed to decrypt JWE: " + e.getMessage());
}
// Handle decompression if needed
byte[] content = jwe.getContent();
if (zip != null) {
content = decompress(content, zip);
}
// Parse decrypted content to CredentialRequest
try {
return JsonSerialization.mapper.readValue(content, CredentialRequest.class);
} catch (JsonProcessingException e) {
throw new JWEException("Failed to parse decrypted JWE payload: " + e.getMessage());
}
}
/**
* Decompresses content using the specified algorithm.
*
* @param content The compressed content
* @param zipAlgorithm The compression algorithm (e.g., "DEF")
* @return The decompressed content
* @throws JWEException If decompression fails
*/
// TODO handle compression/decompression transparently at the JWE software layer.
private byte[] decompress(byte[] content, String zipAlgorithm) throws JWEException {
if (DEFLATE_COMPRESSION.equals(zipAlgorithm)) {
try {
return IOUtils.toByteArray(DeflateUtil.decode(content));
} catch (IOException e) {
throw new JWEException("Failed to decompress: " + e.getMessage());
}
}
throw new JWEException("Unsupported compression algorithm");
}
private String selectKeyManagementAlg(CredentialResponseEncryptionMetadata metadata, JWK jwk) {
List<String> supportedAlgs = metadata.getAlgValuesSupported();
if (supportedAlgs == null || supportedAlgs.isEmpty()) {
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());
return null;
}
// Verify the alg is supported by the server
if (supportedAlgs.contains(jwkAlg)) {
return jwkAlg;
}
// 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));
}
private List<String> getAllProofs(CredentialRequest credentialRequestVO) {
List<String> allProofs = new ArrayList<>();
@ -534,12 +737,13 @@ public class OID4VCIssuerEndpoint {
* @throws BadRequestException If encryption parameters are invalid
* @throws WebApplicationException If encryption fails due to server issues
*/
private String encryptCredentialResponse(CredentialResponse response, CredentialResponseEncryption encryptionParams) {
// Validate input parameters
private String encryptCredentialResponse(CredentialResponse response,
CredentialResponseEncryption encryptionParams,
CredentialResponseEncryptionMetadata metadata) {
validateEncryptionParameters(encryptionParams);
String alg = encryptionParams.getAlg();
String enc = encryptionParams.getEnc();
String zip = encryptionParams.getZip();
JWK jwk = encryptionParams.getJwk();
// Parse public key
@ -557,37 +761,61 @@ public class OID4VCIssuerEndpoint {
"Invalid JWK: Failed to parse public key."));
}
// Select alg
String selectedAlg = selectKeyManagementAlg(metadata, jwk);
// Perform encryption
try {
byte[] content = JsonSerialization.writeValueAsBytes(response);
// Apply compression if specified
if (zip != null) {
content = compressContent(content, zip);
}
JWEHeader header = new JWEHeader.JWEHeaderBuilder()
.algorithm(alg)
.algorithm(selectedAlg)
.encryptionAlgorithm(enc)
.compressionAlgorithm(zip)
.keyId(jwk.getKeyId())
.build();
JWE jwe = new JWE()
.header(header)
.content(content);
jwe.getKeyStorage().setEncryptionKey(publicKey);
return jwe.encodeJwe();
} catch (IOException e) {
LOGGER.errorf("Serialization failed: %s", e.getMessage());
throw new WebApplicationException(
Response.status(Response.Status.INTERNAL_SERVER_ERROR)
.entity(new ErrorResponse()
.setErrorDescription("Failed to serialize response"))
.entity(new ErrorResponse().setErrorDescription("Failed to serialize response"))
.type(MediaType.APPLICATION_JSON)
.build());
} catch (JWEException e) {
LOGGER.errorf("Encryption operation failed: %s", e.getMessage());
throw new WebApplicationException(
Response.status(Response.Status.BAD_REQUEST)
.entity(new ErrorResponse()
.setErrorDescription("Encryption operation failed"))
.entity(new ErrorResponse().setErrorDescription("Encryption operation failed: " + e.getMessage()))
.type(MediaType.APPLICATION_JSON)
.build());
}
}
/**
* Compress content using the specified algorithm
*/
// TODO handle compression/decompression transparently at the JWE software layer.
private byte[] compressContent(byte[] content, String zipAlgorithm) throws IOException {
if (DEFLATE_COMPRESSION.equals(zipAlgorithm)) {
return DeflateUtil.encode(content);
}
throw new IllegalArgumentException("Unsupported compression algorithm: " + zipAlgorithm);
}
/**
* Validate the encryption parameters for a credential response.
*
@ -597,25 +825,24 @@ public class OID4VCIssuerEndpoint {
private void validateEncryptionParameters(CredentialResponseEncryption encryptionParams) {
if (encryptionParams == null) {
throw new BadRequestException(getErrorResponse(ErrorType.INVALID_ENCRYPTION_PARAMETERS,
"Missing required encryption parameters (alg, enc, and jwk)."));
"Missing required encryption parameters (enc and jwk)."));
}
List<String> missingParams = new ArrayList<>();
if (encryptionParams.getAlg() == null) missingParams.add("alg");
if (encryptionParams.getEnc() == null) missingParams.add("enc");
if (encryptionParams.getJwk() == null) missingParams.add("jwk");
if (!missingParams.isEmpty()) {
String errorMessage = String.format("Missing required encryption parameters: %s", String.join(", ", missingParams));
LOGGER.debug(errorMessage);
throw new BadRequestException(getErrorResponse(ErrorType.INVALID_ENCRYPTION_PARAMETERS, errorMessage));
throw new BadRequestException(getErrorResponse(
ErrorType.INVALID_ENCRYPTION_PARAMETERS,
String.format("Missing required parameters: %s", String.join(", ", missingParams))
));
}
if (!isValidJwkForEncryption(encryptionParams.getJwk(), encryptionParams.getAlg())) {
String errorMessage = String.format("Invalid JWK: Not suitable for encryption with algorithm %s", encryptionParams.getAlg());
if (!isValidJwkForEncryption(encryptionParams.getJwk())) {
String errorMessage = "Invalid JWK: Not suitable for encryption";
LOGGER.debug(errorMessage);
throw new BadRequestException(getErrorResponse(ErrorType.INVALID_ENCRYPTION_PARAMETERS, errorMessage));
}
}
@ -623,34 +850,20 @@ public class OID4VCIssuerEndpoint {
* Validates if the provided JWK is suitable for encryption.
*
* @param jwk The JWK to validate
* @param expectedAlg The expected algorithm (e.g., "RSA-OAEP")
* @return true if the JWK is valid for encryption, false otherwise
*/
private boolean isValidJwkForEncryption(JWK jwk, String expectedAlg) {
private boolean isValidJwkForEncryption(JWK jwk) {
if (jwk == null) {
return false;
}
if (expectedAlg != null && !expectedAlg.equals(jwk.getAlgorithm())) {
return false;
}
String publicKeyUse = jwk.getPublicKeyUse();
return publicKeyUse == null || "enc".equals(publicKeyUse);
}
private boolean isSupportedEncryption(CredentialResponseEncryptionMetadata metadata, String alg, String enc) {
if (metadata == null) {
return false;
}
if (metadata.getAlgValuesSupported() == null ||
metadata.getEncValuesSupported() == null ||
metadata.getAlgValuesSupported().isEmpty() ||
metadata.getEncValuesSupported().isEmpty()) {
return false;
}
return metadata.getAlgValuesSupported().contains(alg) &&
metadata.getEncValuesSupported().contains(enc);
private boolean isSupportedCompression(CredentialResponseEncryptionMetadata metadata, String zip) {
return metadata != null &&
metadata.getZipValuesSupported() != null &&
metadata.getZipValuesSupported().contains(zip);
}
private AuthenticatedClientSessionModel getAuthenticatedClientSession() {

View File

@ -27,6 +27,8 @@ import org.keycloak.crypto.KeyWrapper;
import org.keycloak.crypto.SignatureProvider;
import org.keycloak.crypto.SignatureSignerContext;
import org.keycloak.jose.jwe.JWEConstants;
import org.keycloak.jose.jwk.JSONWebKeySet;
import org.keycloak.jose.jwk.JWK;
import org.keycloak.jose.jws.JWSBuilder;
import org.keycloak.models.KeyManager;
import org.keycloak.models.KeycloakContext;
@ -37,8 +39,8 @@ import org.keycloak.protocol.oid4vc.OID4VCLoginProtocolFactory;
import org.keycloak.protocol.oid4vc.issuance.credentialbuilder.CredentialBuilder;
import org.keycloak.protocol.oid4vc.model.CredentialIssuer;
import org.keycloak.protocol.oid4vc.model.CredentialResponseEncryptionMetadata;
import org.keycloak.protocol.oid4vc.model.CredentialRequestEncryptionMetadata;
import org.keycloak.protocol.oid4vc.model.CredentialResponseEncryptionMetadata;
import org.keycloak.protocol.oid4vc.model.SupportedCredentialConfiguration;
import org.keycloak.representations.JsonWebToken;
import org.keycloak.protocol.oidc.utils.JWKSServerUtils;
@ -48,14 +50,12 @@ import org.keycloak.util.JsonSerialization;
import org.keycloak.utils.MediaType;
import org.keycloak.wellknown.WellKnownProvider;
import org.jboss.logging.Logger;
import org.keycloak.jose.jwk.JWK;
import org.keycloak.jose.jwk.JSONWebKeySet;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import static org.keycloak.constants.Oid4VciConstants.SIGNED_METADATA_JWT_TYPE;
import static org.keycloak.crypto.KeyType.RSA;
@ -80,6 +80,8 @@ public class OID4VCIssuerWellKnownProvider implements WellKnownProvider {
public static final String VC_KEY = "vc";
public static final String ATTR_ENCRYPTION_REQUIRED = "oid4vci.encryption.required";
public static final String DEFLATE_COMPRESSION = "DEF";
public static final String ATTR_REQUEST_ZIP_ALGS = "oid4vci.request.zip.algorithms";
protected final KeycloakSession keycloakSession;
@ -100,6 +102,33 @@ public class OID4VCIssuerWellKnownProvider implements WellKnownProvider {
public CredentialIssuer getIssuerMetadata() {
KeycloakContext context = keycloakSession.getContext();
// Build encryption metadata first to enforce coupling rule from spec:
// If credential_response_encryption is included, credential_request_encryption MUST also be included.
CredentialResponseEncryptionMetadata responseEnc = getCredentialResponseEncryption(keycloakSession);
CredentialRequestEncryptionMetadata requestEnc = getCredentialRequestEncryption(keycloakSession);
// Keep response encryption metadata even if request encryption metadata is missing
if (responseEnc != null && requestEnc == null) {
LOGGER.warn("credential_response_encryption is advertised but credential_request_encryption metadata is not available. " +
"If response encryption is included, request encryption should also be included. " +
"keep response metadata and setting encryption_required=false.");
if (Boolean.TRUE.equals(responseEnc.getEncryptionRequired())) {
responseEnc.setEncryptionRequired(false);
}
}
// Consistency rule: if both are present and response encryption is required, mark request encryption as required too
if (responseEnc != null && requestEnc != null) {
boolean responseRequired = Boolean.TRUE.equals(responseEnc.getEncryptionRequired());
boolean requestRequired = Boolean.TRUE.equals(requestEnc.isEncryptionRequired());
if (responseRequired && !requestRequired) {
LOGGER.warn("credential_response_encryption.encryption_required=true while credential_request_encryption.encryption_required is false. " +
"Marking request encryption as required to maintain consistency.");
requestEnc.setEncryptionRequired(true);
}
}
return new CredentialIssuer()
.setCredentialIssuer(getIssuer(context))
.setCredentialEndpoint(getCredentialsEndpoint(context))
@ -107,9 +136,9 @@ public class OID4VCIssuerWellKnownProvider implements WellKnownProvider {
.setDeferredCredentialEndpoint(getDeferredCredentialEndpoint(context))
.setCredentialsSupported(getSupportedCredentials(keycloakSession))
.setAuthorizationServers(List.of(getIssuer(context)))
.setCredentialResponseEncryption(getCredentialResponseEncryption(keycloakSession))
.setBatchCredentialIssuance(getBatchCredentialIssuance(keycloakSession))
.setCredentialRequestEncryption(getCredentialRequestEncryption(keycloakSession));
.setCredentialResponseEncryption(responseEnc)
.setCredentialRequestEncryption(requestEnc)
.setBatchCredentialIssuance(getBatchCredentialIssuance(keycloakSession));
}
public Object getMetadataResponse(CredentialIssuer issuer, KeycloakSession session) {
@ -233,7 +262,6 @@ public class OID4VCIssuerWellKnownProvider implements WellKnownProvider {
}
}
private String getSigningAlgorithm(RealmModel realm, KeycloakSession session) {
List<String> supportedAlgorithms = getSupportedAsymmetricSignatureAlgorithms(session);
@ -279,7 +307,6 @@ public class OID4VCIssuerWellKnownProvider implements WellKnownProvider {
}
}
/**
* Returns the credential response encryption for the issuer.
* Now determines supported algorithms from available realm keys.
@ -292,34 +319,47 @@ public class OID4VCIssuerWellKnownProvider implements WellKnownProvider {
CredentialResponseEncryptionMetadata metadata = new CredentialResponseEncryptionMetadata();
// Get supported algorithms from available encryption keys
metadata.setAlgValuesSupported(getSupportedEncryptionAlgorithms(session));
metadata.setEncValuesSupported(getSupportedEncryptionMethods());
metadata.setZipValuesSupported(getSupportedCompressionMethods());
metadata.setEncryptionRequired(isEncryptionRequired(realm));
metadata.setAlgValuesSupported(getSupportedEncryptionAlgorithms(session))
.setEncValuesSupported(getSupportedEncryptionMethods())
.setZipValuesSupported(getSupportedZipAlgorithms(realm))
.setEncryptionRequired(isEncryptionRequired(realm));
return metadata;
}
/**
* Returns the credential request encryption metadata for the issuer.
* Determines supported algorithms from available realm keys.
*
* @param session The Keycloak session
* @return The credential request encryption metadata
* Determines supported algorithms and JWK Set from available realm keys
*/
public static CredentialRequestEncryptionMetadata getCredentialRequestEncryption(KeycloakSession session) {
RealmModel realm = session.getContext().getRealm();
CredentialRequestEncryptionMetadata metadata = new CredentialRequestEncryptionMetadata();
// Get supported algorithms from available encryption keys
metadata.setJwks(getEncryptionJwks(session));
metadata.setEncValuesSupported(getSupportedEncryptionMethods());
metadata.setZipValuesSupported(getSupportedCompressionMethods());
metadata.setEncryptionRequired(isEncryptionRequired(realm));
// Build JWKS with public encryption keys
JSONWebKeySet jwks = buildJwks(session);
// If encryption is required but no keys exist reject unencrypted requests
boolean encryptionRequired = isEncryptionRequired(realm);
if (jwks.getKeys() == null || jwks.getKeys().length == 0) {
if (encryptionRequired) {
LOGGER.error("Encryption is required but no valid encryption keys are available.");
throw new IllegalStateException("Missing encryption keys for required credential_request_encryption.");
} else {
LOGGER.warn("No valid encryption keys found; omitting credential_request_encryption metadata.");
return null; // Entire object omitted
}
}
// Build metadata
CredentialRequestEncryptionMetadata metadata = new CredentialRequestEncryptionMetadata()
.setJwks(jwks)
.setEncValuesSupported(getSupportedEncryptionMethods())
.setZipValuesSupported(getSupportedZipAlgorithms(realm))
.setEncryptionRequired(encryptionRequired);
return metadata;
}
/**
* Returns the supported encryption algorithms from realm attributes.
*/
@ -334,6 +374,7 @@ public class OID4VCIssuerWellKnownProvider implements WellKnownProvider {
.distinct()
.collect(Collectors.toList());
// Default algorithms if none configured
if (supportedEncryptionAlgorithms.isEmpty()) {
boolean hasRsaKeys = keyManager.getKeysStream(realm)
.filter(key -> KeyUse.ENC.equals(key.getUse()))
@ -348,6 +389,40 @@ public class OID4VCIssuerWellKnownProvider implements WellKnownProvider {
return supportedEncryptionAlgorithms;
}
/**
* Builds JWKS from realm encryption keys with use=enc.
*/
private static JSONWebKeySet buildJwks(KeycloakSession session) {
RealmModel realm = session.getContext().getRealm();
JSONWebKeySet jwks = JWKSServerUtils.getRealmJwks(session, realm);
// Filter for encryption keys only and exclude symmetric keys (oct)
JWK[] encKeys = Arrays.stream(jwks.getKeys())
.filter(jwk -> JWK.Use.ENCRYPTION.asString().equals(jwk.getPublicKeyUse()))
.filter(jwk -> jwk.getKeyType() != null && !jwk.getKeyType().equals("oct"))
.toArray(JWK[]::new);
jwks.setKeys(encKeys);
return jwks;
}
/**
* Returns supported zip algorithms from realm attributes (optional).
*/
private static List<String> getSupportedZipAlgorithms(RealmModel realm) {
String zipAlgs = realm.getAttribute(ATTR_REQUEST_ZIP_ALGS);
if (zipAlgs != null && !zipAlgs.isEmpty()) {
return Arrays.stream(zipAlgs.split(","))
.map(String::trim)
.filter(alg -> alg.equals(DEFLATE_COMPRESSION)) // Only support DEFLATE for now
.collect(Collectors.toList());
}
return null; // Omit if not configured
}
/**
* Returns the supported encryption methods from realm attributes.
*/
@ -355,37 +430,6 @@ public class OID4VCIssuerWellKnownProvider implements WellKnownProvider {
return List.of(JWEConstants.A256GCM);
}
/**
* Returns the supported compression methods from realm attributes.
*
* Note: Keycloak's JWE implementation currently only has placeholder support for compression
* in the JWEHeader class, but no actual compression/decompression logic is implemented.
* The compression algorithm field exists but is not processed during JWE encoding/decoding.
*
* TODO: Implement JWE compression support when Keycloak core adds compression functionality
*/
private static List<String> getSupportedCompressionMethods() {
// Keycloak JWE implementation lacks compression support - only header placeholder exists
return List.of();
}
/**
* Returns the encryption JWKS from realm keys.
* Filters the realm JWKS to include only encryption keys.
*/
private static List<JWK> getEncryptionJwks(KeycloakSession session) {
RealmModel realm = session.getContext().getRealm();
JSONWebKeySet realmJwks = JWKSServerUtils.getRealmJwks(session, realm);
if (realmJwks.getKeys() == null) {
return List.of();
}
return Stream.of(realmJwks.getKeys())
.filter(jwk -> KeyUse.ENC.getSpecName().equals(jwk.getPublicKeyUse()))
.toList();
}
/**
* Returns whether encryption is required from realm attributes.
*/
@ -394,6 +438,7 @@ public class OID4VCIssuerWellKnownProvider implements WellKnownProvider {
return Boolean.parseBoolean(required);
}
/**
* Return the supported credentials from the current session.
* It will take into account the configured {@link CredentialBuilder}'s and their supported format

View File

@ -19,21 +19,21 @@ package org.keycloak.protocol.oid4vc.model;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty;
import org.keycloak.jose.jwk.JWK;
import org.keycloak.jose.jwk.JSONWebKeySet;
import java.util.List;
/**
* Represents the credential_request_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 https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0-16.html#name-credential-issuer-metadata-p
*
* @author <a href="https://github.com/forkimenjeckayang">Forkim Akwichek</a>
* @author <a href="mailto:Bertrand.Ogen@adorsys.com">Bertrand Ogen</a>
*/
@JsonInclude(JsonInclude.Include.NON_NULL)
public class CredentialRequestEncryptionMetadata {
@JsonProperty("jwks")
private List<JWK> jwks;
private JSONWebKeySet jwks;
@JsonProperty("enc_values_supported")
private List<String> encValuesSupported;
@ -44,11 +44,11 @@ public class CredentialRequestEncryptionMetadata {
@JsonProperty("encryption_required")
private Boolean encryptionRequired;
public List<JWK> getJwks() {
public JSONWebKeySet getJwks() {
return jwks;
}
public CredentialRequestEncryptionMetadata setJwks(List<JWK> jwks) {
public CredentialRequestEncryptionMetadata setJwks(JSONWebKeySet jwks) {
this.jwks = jwks;
return this;
}
@ -71,7 +71,7 @@ public class CredentialRequestEncryptionMetadata {
return this;
}
public Boolean getEncryptionRequired() {
public Boolean isEncryptionRequired() {
return encryptionRequired;
}

View File

@ -29,33 +29,24 @@ import org.keycloak.jose.jwk.JWK;
@JsonInclude(JsonInclude.Include.NON_NULL)
public class CredentialResponseEncryption {
/**
* REQUIRED. A string specifying the algorithm to be used for encrypting the Credential Response,
* as per the supported key management algorithms in the Credential Issuer Metadata.
*/
private String alg;
/**
* REQUIRED. A string specifying the content encryption algorithm to be used for encrypting the
* Credential Response, as per the supported content encryption algorithms in the Credential Issuer Metadata.
*/
private String enc;
/**
* OPTIONAL. A string specifying the compression algorithm to be used for compressing the
* Credential Response prior to encryption.
*/
private String zip;
/**
* REQUIRED if credential_response_encryption is included in the Credential Request.
* A JSON Web Key (JWK) that represents the public key to which the Credential Response will be encrypted.
*/
private JWK jwk;
public String getAlg() {
return alg;
}
public CredentialResponseEncryption setAlg(String alg) {
this.alg = alg;
return this;
}
public String getEnc() {
return enc;
}
@ -65,6 +56,15 @@ public class CredentialResponseEncryption {
return this;
}
public String getZip() {
return zip;
}
public CredentialResponseEncryption setZip(String zip) {
this.zip = zip;
return this;
}
public JWK getJwk() {
return jwk;
}

View File

@ -24,7 +24,7 @@ import java.util.List;
/**
* 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 https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0-16.html#name-credential-issuer-metadata-p
*
* @author <a href="mailto:Bertrand.Ogen@adorsys.com">Bertrand Ogen</a>
*/
@ -47,31 +47,35 @@ public class CredentialResponseEncryptionMetadata {
return algValuesSupported;
}
public void setAlgValuesSupported(List<String> algValuesSupported) {
public CredentialResponseEncryptionMetadata setAlgValuesSupported(List<String> algValuesSupported) {
this.algValuesSupported = algValuesSupported;
return this;
}
public List<String> getEncValuesSupported() {
return encValuesSupported;
}
public void setEncValuesSupported(List<String> encValuesSupported) {
public CredentialResponseEncryptionMetadata setEncValuesSupported(List<String> encValuesSupported) {
this.encValuesSupported = encValuesSupported;
return this;
}
public List<String> getZipValuesSupported() {
return zipValuesSupported;
}
public void setZipValuesSupported(List<String> zipValuesSupported) {
public CredentialResponseEncryptionMetadata setZipValuesSupported(List<String> zipValuesSupported) {
this.zipValuesSupported = zipValuesSupported;
return this;
}
public Boolean getEncryptionRequired() {
return encryptionRequired;
}
public void setEncryptionRequired(Boolean encryptionRequired) {
public CredentialResponseEncryptionMetadata setEncryptionRequired(Boolean encryptionRequired) {
this.encryptionRequired = encryptionRequired;
return this;
}
}

View File

@ -0,0 +1,545 @@
/*
* 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.testsuite.oid4vc.issuance.signing;
import jakarta.ws.rs.BadRequestException;
import jakarta.ws.rs.client.Entity;
import jakarta.ws.rs.client.WebTarget;
import jakarta.ws.rs.core.HttpHeaders;
import jakarta.ws.rs.core.Response;
import org.apache.http.HttpStatus;
import org.jboss.logging.Logger;
import org.junit.Test;
import org.keycloak.TokenVerifier;
import org.keycloak.common.VerificationException;
import org.keycloak.crypto.KeyUse;
import org.keycloak.crypto.KeyWrapper;
import org.keycloak.jose.jwe.JWEException;
import org.keycloak.jose.jwk.JWK;
import org.keycloak.jose.jwk.JWKParser;
import org.keycloak.models.KeyManager;
import org.keycloak.models.RealmModel;
import org.keycloak.protocol.oid4vc.issuance.OID4VCIssuerEndpoint;
import org.keycloak.protocol.oid4vc.model.CredentialRequest;
import org.keycloak.protocol.oid4vc.model.CredentialResponse;
import org.keycloak.protocol.oid4vc.model.CredentialResponseEncryption;
import org.keycloak.protocol.oid4vc.model.ErrorResponse;
import org.keycloak.protocol.oid4vc.model.Format;
import org.keycloak.protocol.oid4vc.model.VerifiableCredential;
import org.keycloak.representations.JsonWebToken;
import org.keycloak.services.managers.AppAuthManager;
import org.keycloak.testsuite.Assert;
import org.keycloak.util.JsonSerialization;
import org.keycloak.utils.MediaType;
import java.io.IOException;
import java.security.NoSuchAlgorithmException;
import java.security.PrivateKey;
import java.util.List;
import java.util.Map;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertTrue;
import static org.junit.Assert.fail;
import static org.keycloak.jose.jwe.JWEConstants.A256GCM;
import static org.keycloak.protocol.oid4vc.model.ErrorType.INVALID_ENCRYPTION_PARAMETERS;
import static org.keycloak.utils.MediaType.APPLICATION_JWT;
/**
* Test class for Credential Request and Response Encryption
*
* @author Bertrand Ogen
*/
public class OID4VCIssuerEndpointEncryptionTest extends OID4VCIssuerEndpointTest {
private static final Logger LOGGER = Logger.getLogger(OID4VCIssuerEndpointEncryptionTest.class);
@Test
public void testRequestCredentialWithEncryption() {
final String scopeName = jwtTypeCredentialClientScope.getName();
String token = getBearerToken(oauth, client, scopeName);
testingClient
.server(TEST_REALM_NAME)
.run((session -> {
AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session);
authenticator.setTokenString(token);
OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator);
Map<String, Object> jwkPair;
try {
jwkPair = generateRsaJwkWithPrivateKey();
} catch (NoSuchAlgorithmException e) {
throw new RuntimeException("Failed to generate JWK", e);
}
JWK jwk = (JWK) jwkPair.get("jwk");
PrivateKey privateKey = (PrivateKey) jwkPair.get("privateKey");
CredentialRequest credentialRequest = new CredentialRequest()
.setFormat(Format.JWT_VC)
.setCredentialIdentifier(scopeName)
.setCredentialResponseEncryption(
new CredentialResponseEncryption()
.setEnc(A256GCM)
.setJwk(jwk));
String credentialRequestPayload = JsonSerialization.writeValueAsString(credentialRequest);
Response credentialResponse = issuerEndpoint.requestCredential(credentialRequestPayload);
assertEquals("The credential request should be answered successfully.",
HttpStatus.SC_OK, credentialResponse.getStatus());
assertEquals("Response should be JWT type for encrypted responses",
APPLICATION_JWT, credentialResponse.getMediaType().toString());
String encryptedResponse = (String) credentialResponse.getEntity();
CredentialResponse decryptedResponse;
try {
decryptedResponse = decryptJweResponse(encryptedResponse, privateKey);
} catch (IOException | JWEException e) {
fail("Failed to decrypt JWE response: " + e.getMessage());
return;
}
// Verify the decrypted payload
assertNotNull("Decrypted response should contain a credential", decryptedResponse.getCredentials());
JsonWebToken jsonWebToken;
try {
jsonWebToken = TokenVerifier.create((String) decryptedResponse.getCredentials().get(0).getCredential(), JsonWebToken.class).getToken();
} catch (VerificationException e) {
fail("Failed to verify JWT: " + e.getMessage());
return;
}
assertNotNull("A valid credential string should have been responded", jsonWebToken);
VerifiableCredential credential = JsonSerialization.mapper.convertValue(
jsonWebToken.getOtherClaims().get("vc"), VerifiableCredential.class);
assertTrue("The static claim should be set.", credential.getCredentialSubject().getClaims().containsKey("scope-name"));
}));
}
@Test
public void testUnencryptedRequestWhenEncryptionRequired() {
String token = getBearerToken(oauth, client);
testingClient.server(TEST_REALM_NAME).run(session -> {
RealmModel realm = session.getContext().getRealm();
realm.setAttribute("oid4vci.encryption.required", "true");
realm.setAttribute("oid4vci.request.enc.algorithms", "A256GCM");
try {
AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session);
authenticator.setTokenString(token);
OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator);
CredentialRequest credentialRequest = new CredentialRequest()
.setFormat(Format.JWT_VC)
.setCredentialIdentifier("test-credential");
String requestPayload = JsonSerialization.writeValueAsString(credentialRequest);
try {
issuerEndpoint.requestCredential(requestPayload);
fail("Expected BadRequestException due to unencrypted request when encryption is required");
} catch (BadRequestException e) {
ErrorResponse error = (ErrorResponse) e.getResponse().getEntity();
assertEquals(INVALID_ENCRYPTION_PARAMETERS, error.getError());
assertEquals("Encryption is required but request is not a valid JWE: Not a JWE String", error.getErrorDescription());
}
} finally {
realm.removeAttribute("oid4vci.encryption.required");
realm.removeAttribute("oid4vci.request.enc.algorithms");
}
});
}
@Test
public void testEncryptedCredentialRequest() {
final String scopeName = jwtTypeCredentialClientScope.getName();
String token = getBearerToken(oauth, client, scopeName);
testingClient.server(TEST_REALM_NAME).run(session -> {
try {
// Enable request encryption requirement
RealmModel realm = session.getContext().getRealm();
realm.setAttribute("oid4vci.encryption.required", "true");
realm.setAttribute("oid4vci.request.enc.algorithms", "A256GCM");
AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session);
authenticator.setTokenString(token);
OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator);
// Generate keys for request encryption
KeyManager keyManager = session.keys();
KeyWrapper encryptionKey = keyManager.getKeysStream(realm)
.filter(key -> KeyUse.ENC.equals(key.getUse()))
.findFirst()
.orElseThrow(() -> new RuntimeException("No encryption key found"));
// Generate keys for response encryption
Map<String, Object> jwkPair = generateRsaJwkWithPrivateKey();
JWK responseJwk = (JWK) jwkPair.get("jwk");
PrivateKey responsePrivateKey = (PrivateKey) jwkPair.get("privateKey");
CredentialRequest credentialRequest = new CredentialRequest()
.setFormat(Format.JWT_VC)
.setCredentialIdentifier(scopeName)
.setCredentialResponseEncryption(
new CredentialResponseEncryption()
.setEnc(A256GCM)
.setJwk(responseJwk));
String requestJson = JsonSerialization.writeValueAsString(credentialRequest);
String encryptedRequest = createEncryptedCredentialRequest(requestJson, encryptionKey);
Response response = issuerEndpoint.requestCredential(encryptedRequest);
assertEquals("Encrypted request should be processed successfully",
200, response.getStatus());
assertEquals("Response should be JWT type for encrypted responses",
APPLICATION_JWT, response.getMediaType().toString());
// Decrypt and verify response
String encryptedResponse = (String) response.getEntity();
CredentialResponse decryptedResponse = decryptJweResponse(encryptedResponse, responsePrivateKey);
assertNotNull("Decrypted response should contain a credential", decryptedResponse.getCredentials());
JsonWebToken jsonWebToken = TokenVerifier.create(
(String) decryptedResponse.getCredentials().get(0).getCredential(),
JsonWebToken.class).getToken();
assertNotNull("A valid credential string should have been responded", jsonWebToken);
VerifiableCredential credential = JsonSerialization.mapper.convertValue(
jsonWebToken.getOtherClaims().get("vc"),
VerifiableCredential.class);
assertTrue("The static claim should be set.",
credential.getCredentialSubject().getClaims().containsKey("scope-name"));
} catch (Exception e) {
fail("Test failed with exception: " + e.getClass().getName() + ": " + e.getMessage());
}
});
}
@Test
public void testEncryptedCredentialRequestWithCompression() {
final String scopeName = jwtTypeCredentialClientScope.getName();
String token = getBearerToken(oauth, client, scopeName);
testingClient.server(TEST_REALM_NAME).run(session -> {
try {
// Enable request encryption and compression
RealmModel realm = session.getContext().getRealm();
realm.setAttribute("oid4vci.encryption.required", "true");
realm.setAttribute("oid4vci.request.enc.algorithms", "A256GCM");
realm.setAttribute("oid4vci.request.zip.algorithms", "DEF");
AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session);
authenticator.setTokenString(token);
OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator);
// Generate keys for request encryption
KeyManager keyManager = session.keys();
KeyWrapper encryptionKey = keyManager.getKeysStream(realm)
.filter(key -> KeyUse.ENC.equals(key.getUse()))
.findFirst()
.orElseThrow(() -> new RuntimeException("No encryption key found"));
// Generate keys for response encryption
Map<String, Object> jwkPair = generateRsaJwkWithPrivateKey();
JWK responseJwk = (JWK) jwkPair.get("jwk");
PrivateKey responsePrivateKey = (PrivateKey) jwkPair.get("privateKey");
// Create credential request with response encryption parameters
CredentialRequest credentialRequest = new CredentialRequest()
.setFormat(Format.JWT_VC)
.setCredentialIdentifier(scopeName)
.setCredentialResponseEncryption(
new CredentialResponseEncryption()
.setEnc(A256GCM)
.setJwk(responseJwk));
String requestJson = JsonSerialization.writeValueAsString(credentialRequest);
// Encrypt the request with compression
String encryptedRequest = createEncryptedCredentialRequestWithCompression(requestJson, encryptionKey);
// Test with encrypted and compressed request
Response response = issuerEndpoint.requestCredential(encryptedRequest);
// Verify response
assertEquals("Encrypted compressed request should be processed successfully",
200, response.getStatus());
assertEquals("Response should be JWT type for encrypted responses",
MediaType.APPLICATION_JWT, response.getMediaType().toString());
// Decrypt and verify the response
String encryptedResponse = (String) response.getEntity();
CredentialResponse decryptedResponse = decryptJweResponse(encryptedResponse, responsePrivateKey);
assertNotNull("Decrypted response should contain a credential", decryptedResponse.getCredentials());
JsonWebToken jsonWebToken = TokenVerifier.create(
(String) decryptedResponse.getCredentials().get(0).getCredential(),
JsonWebToken.class).getToken();
assertNotNull("A valid credential string should have been responded", jsonWebToken);
VerifiableCredential credential = JsonSerialization.mapper.convertValue(
jsonWebToken.getOtherClaims().get("vc"),
VerifiableCredential.class);
assertTrue("The static claim should be set.",
credential.getCredentialSubject().getClaims().containsKey("scope-name"));
} catch (Exception e) {
LOGGER.error("Test failed", e);
fail("Test failed with exception: " + e.getClass().getName() + ": " + e.getMessage());
}
});
};
@Test
public void testRequestCredentialWithIncompleteEncryptionParams() throws Throwable {
String token = getBearerToken(oauth, client, jwtTypeCredentialClientScope.getName());
testingClient.server(TEST_REALM_NAME).run(session -> {
AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session);
authenticator.setTokenString(token);
OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator);
// Missing enc parameter
JWK jwk = JWKParser.create().parse("{\"kty\":\"RSA\",\"n\":\"test-n\",\"e\":\"AQAB\"}").getJwk();
CredentialRequest credentialRequest = new CredentialRequest()
.setFormat(Format.JWT_VC)
.setCredentialIdentifier("test-credential")
.setCredentialResponseEncryption(
new CredentialResponseEncryption()
.setJwk(jwk));
String credentialRequestPayload = JsonSerialization.writeValueAsString(credentialRequest);
try {
issuerEndpoint.requestCredential(credentialRequestPayload);
Assert.fail("Expected BadRequestException due to missing encryption parameter 'enc'");
} catch (BadRequestException e) {
ErrorResponse error = (ErrorResponse) e.getResponse().getEntity();
assertEquals(INVALID_ENCRYPTION_PARAMETERS, error.getError());
assertTrue("Error message should specify missing parameter 'enc'",
error.getErrorDescription().contains("Missing required parameters: enc"));
}
});
}
@Test
public void testCredentialIssuanceWithEncryption() throws Exception {
// Integration test for the full credential issuance flow with encryption
testCredentialIssuanceWithAuthZCodeFlow(jwtTypeCredentialClientScope,
(testClientId, testScope) -> {
String scopeName = jwtTypeCredentialClientScope.getName();
return getBearerToken(oauth.clientId(testClientId).openid(false).scope(scopeName));
},
m -> {
String accessToken = (String) m.get("accessToken");
WebTarget credentialTarget = (WebTarget) m.get("credentialTarget");
CredentialRequest credentialRequest = (CredentialRequest) m.get("credentialRequest");
Map<String, Object> jwkPair;
try {
jwkPair = generateRsaJwkWithPrivateKey();
} catch (NoSuchAlgorithmException e) {
throw new RuntimeException("Failed to generate JWK", e);
}
JWK jwk = (JWK) jwkPair.get("jwk");
PrivateKey privateKey = (PrivateKey) jwkPair.get("privateKey");
credentialRequest.setCredentialResponseEncryption(
new CredentialResponseEncryption()
.setEnc(A256GCM)
.setJwk(jwk));
try (Response response = credentialTarget.request()
.header(HttpHeaders.AUTHORIZATION, "bearer " + accessToken)
.post(Entity.json(credentialRequest))) {
assertEquals(200, response.getStatus());
assertEquals("application/jwt", response.getMediaType().toString());
String encryptedResponse = response.readEntity(String.class);
CredentialResponse decryptedResponse;
try {
decryptedResponse = decryptJweResponse(encryptedResponse, privateKey);
} catch (IOException | JWEException e) {
fail("Failed to decrypt JWE response: " + e.getMessage());
return;
}
// Verify the decrypted payload
JsonWebToken jsonWebToken;
try {
jsonWebToken = TokenVerifier.create(
(String) decryptedResponse.getCredentials().get(0).getCredential(),
JsonWebToken.class
).getToken();
} catch (VerificationException e) {
fail("Failed to verify JWT: " + e.getMessage());
return;
}
assertEquals("did:web:test.org", jsonWebToken.getIssuer());
VerifiableCredential credential = JsonSerialization.mapper.convertValue(
jsonWebToken.getOtherClaims().get("vc"),
VerifiableCredential.class
);
assertEquals(List.of(jwtTypeCredentialClientScope.getName()), credential.getType());
assertEquals(TEST_DID, credential.getIssuer());
assertEquals("john@email.cz", credential.getCredentialSubject().getClaims().get("email"));
}
});
}
@Test
public void testRequestCredentialWithUnsupportedResponseEncryption() {
String token = getBearerToken(oauth, client, jwtTypeCredentialClientScope.getName());
testingClient.server(TEST_REALM_NAME).run(session -> {
AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session);
authenticator.setTokenString(token);
OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator);
JWK jwk;
try {
jwk = generateRsaJwk();
} catch (NoSuchAlgorithmException e) {
throw new RuntimeException("Failed to generate JWK", e);
}
CredentialRequest credentialRequest = new CredentialRequest()
.setFormat(Format.JWT_VC)
.setCredentialIdentifier("test-credential")
.setCredentialResponseEncryption(
new CredentialResponseEncryption()
.setEnc("A128GCM")
.setJwk(jwk));
String requestPayload = JsonSerialization.writeValueAsString(credentialRequest);
try {
issuerEndpoint.requestCredential(requestPayload);
fail("Expected BadRequestException due to unsupported encryption algorithm");
} catch (BadRequestException e) {
ErrorResponse error = (ErrorResponse) e.getResponse().getEntity();
assertEquals(INVALID_ENCRYPTION_PARAMETERS, error.getError());
assertTrue(error.getErrorDescription().contains("Unsupported content encryption algorithm"));
}
});
}
@Test
public void testRequestCredentialWithUnsupportedResponseCompression() {
String token = getBearerToken(oauth, client, jwtTypeCredentialClientScope.getName());
testingClient.server(TEST_REALM_NAME).run(session -> {
AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session);
authenticator.setTokenString(token);
OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator);
JWK jwk;
try {
jwk = generateRsaJwk();
} catch (NoSuchAlgorithmException e) {
throw new RuntimeException("Failed to generate JWK", e);
}
CredentialRequest credentialRequest = new CredentialRequest()
.setFormat(Format.JWT_VC)
.setCredentialIdentifier("test-credential")
.setCredentialResponseEncryption(
new CredentialResponseEncryption()
.setEnc("A256GCM")
.setZip("UNSUPPORTED-ZIP")
.setJwk(jwk));
String requestPayload = JsonSerialization.writeValueAsString(credentialRequest);
try {
issuerEndpoint.requestCredential(requestPayload);
fail("Expected BadRequestException due to unsupported compression algorithm");
} catch (BadRequestException e) {
ErrorResponse error = (ErrorResponse) e.getResponse().getEntity();
assertEquals(INVALID_ENCRYPTION_PARAMETERS, error.getError());
}
});
}
@Test
public void testRequestCredentialWithInvalidJWK() throws Throwable {
final String scopeName = jwtTypeCredentialClientScope.getName();
String token = getBearerToken(oauth, client, scopeName);
testingClient.server(TEST_REALM_NAME).run(session -> {
AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session);
authenticator.setTokenString(token);
OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator);
// Invalid JWK (missing modulus but WITH alg parameter)
JWK jwk = JWKParser.create().parse("{\"kty\":\"RSA\",\"alg\":\"RSA-OAEP-256\",\"e\":\"AQAB\"}").getJwk();
CredentialRequest credentialRequest = new CredentialRequest()
.setFormat(Format.JWT_VC)
.setCredentialIdentifier(scopeName)
.setCredentialResponseEncryption(
new CredentialResponseEncryption()
.setEnc("A256GCM")
.setJwk(jwk));
String requestPayload = JsonSerialization.writeValueAsString(credentialRequest);
try {
issuerEndpoint.requestCredential(requestPayload);
Assert.fail("Expected BadRequestException due to invalid JWK missing modulus");
} catch (BadRequestException e) {
ErrorResponse error = (ErrorResponse) e.getResponse().getEntity();
assertEquals(INVALID_ENCRYPTION_PARAMETERS, error.getError());
assertTrue("Error should mention invalid JWK. Actual: " + error.getErrorDescription(),
error.getErrorDescription().contains("Invalid JWK"));
}
});
}
@Test
public void testRequestCredentialWithMissingResponseEncryptionWhenRequired() {
String scopeName = jwtTypeCredentialClientScope.getName();
String token = getBearerToken(oauth, client, scopeName);
testingClient.server(TEST_REALM_NAME).run(session -> {
RealmModel realm = session.getContext().getRealm();
realm.setAttribute("oid4vci.encryption.required", "true");
try {
AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session);
authenticator.setTokenString(token);
OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator);
CredentialRequest credentialRequest = new CredentialRequest()
.setFormat(Format.JWT_VC)
.setCredentialIdentifier(scopeName);
String requestPayload = JsonSerialization.writeValueAsString(credentialRequest);
try {
issuerEndpoint.requestCredential(requestPayload);
fail("Expected BadRequestException due to missing request encryption when required");
} catch (BadRequestException e) {
ErrorResponse error = (ErrorResponse) e.getResponse().getEntity();
assertEquals(INVALID_ENCRYPTION_PARAMETERS, error.getError());
assertEquals("Encryption is required but request is not a valid JWE: Not a JWE String", error.getErrorDescription());
}
} finally {
realm.removeAttribute("oid4vci.encryption.required");
}
});
}
}

View File

@ -31,6 +31,7 @@ import org.apache.http.entity.ContentType;
import org.apache.http.entity.StringEntity;
import org.apache.http.impl.client.CloseableHttpClient;
import org.apache.http.impl.client.HttpClientBuilder;
import org.jboss.logging.Logger;
import org.junit.Before;
import org.keycloak.TokenVerifier;
import org.keycloak.admin.client.resource.ClientResource;
@ -43,8 +44,10 @@ import org.keycloak.common.util.MultivaluedHashMap;
import org.keycloak.common.util.SecretGenerator;
import org.keycloak.common.util.Time;
import org.keycloak.constants.Oid4VciConstants;
import org.keycloak.crypto.KeyWrapper;
import org.keycloak.jose.jwe.JWE;
import org.keycloak.jose.jwe.JWEException;
import org.keycloak.jose.jwe.JWEHeader;
import org.keycloak.jose.jwk.JWK;
import org.keycloak.jose.jwk.RSAPublicJWK;
import org.keycloak.models.AuthenticatedClientSessionModel;
@ -86,6 +89,7 @@ import org.keycloak.testsuite.util.oauth.AuthorizationEndpointResponse;
import org.keycloak.testsuite.util.oauth.OAuthClient;
import org.keycloak.util.JsonSerialization;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.net.URI;
@ -103,14 +107,19 @@ import java.util.Optional;
import java.util.function.BiConsumer;
import java.util.function.BiFunction;
import java.util.function.Consumer;
import java.util.zip.Deflater;
import java.util.zip.DeflaterOutputStream;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertTrue;
import static org.keycloak.jose.jwe.JWEConstants.A128GCM;
import static org.keycloak.jose.jwe.JWEConstants.A256GCM;
import static org.keycloak.jose.jwe.JWEConstants.RSA_OAEP;
import static org.keycloak.jose.jwe.JWEConstants.RSA_OAEP_256;
import static org.keycloak.protocol.oid4vc.issuance.OID4VCIssuerEndpoint.CREDENTIAL_OFFER_URI_CODE_SCOPE;
import static org.keycloak.protocol.oid4vc.model.ProofType.JWT;
import static org.keycloak.testsuite.forms.PassThroughClientAuthenticator.clientId;
/**
@ -121,6 +130,8 @@ public abstract class OID4VCIssuerEndpointTest extends OID4VCTest {
protected static final TimeProvider TIME_PROVIDER = new OID4VCTest.StaticTimeProvider(1000);
protected static final String sdJwtCredentialVct = "https://credentials.example.com/SD-JWT-Credential";
private static final Logger LOGGER = Logger.getLogger(OID4VCIssuerEndpointTest.class);
protected static ClientScopeRepresentation sdJwtTypeCredentialClientScope;
protected static ClientScopeRepresentation jwtTypeCredentialClientScope;
protected static ClientScopeRepresentation minimalJwtTypeCredentialClientScope;
@ -360,7 +371,7 @@ public abstract class OID4VCIssuerEndpointTest extends OID4VCTest {
return result;
}
protected static CredentialResponse decryptJweResponse(String encryptedResponse, PrivateKey privateKey) throws IOException, JWEException {
public static CredentialResponse decryptJweResponse(String encryptedResponse, PrivateKey privateKey) throws IOException, JWEException {
assertNotNull("Encrypted response should not be null", encryptedResponse);
assertEquals("Response should be a JWE", 5, encryptedResponse.split("\\.").length);
@ -371,6 +382,50 @@ public abstract class OID4VCIssuerEndpointTest extends OID4VCTest {
return JsonSerialization.readValue(decryptedContent, CredentialResponse.class);
}
public static String createEncryptedCredentialRequest(String payload, KeyWrapper encryptionKey) throws Exception {
byte[] content = payload.getBytes(StandardCharsets.UTF_8);
JWEHeader header = new JWEHeader.JWEHeaderBuilder()
.keyId(encryptionKey.getKid())
.algorithm(encryptionKey.getAlgorithm())
.encryptionAlgorithm(A256GCM)
.type(JWT)
.build();
JWE jwe = new JWE()
.header(header)
.content(content);
jwe.getKeyStorage().setEncryptionKey(encryptionKey.getPublicKey());
return jwe.encodeJwe();
}
public static String createEncryptedCredentialRequestWithCompression(String payload, KeyWrapper encryptionKey) throws Exception {
byte[] content = compressPayload(payload.getBytes(StandardCharsets.UTF_8));
LOGGER.debugf("Compressed payload size: %d bytes", content.length);
JWEHeader header = new JWEHeader.JWEHeaderBuilder()
.keyId(encryptionKey.getKid())
.algorithm(encryptionKey.getAlgorithm())
.encryptionAlgorithm(A256GCM)
.compressionAlgorithm("DEF")
.type(JWT)
.build();
JWE jwe = new JWE()
.header(header)
.content(content);
jwe.getKeyStorage().setEncryptionKey(encryptionKey.getPublicKey());
return jwe.encodeJwe();
}
public static byte[] compressPayload(byte[] payload) throws IOException {
ByteArrayOutputStream out = new ByteArrayOutputStream();
try (DeflaterOutputStream deflater = new DeflaterOutputStream(out, new Deflater(Deflater.DEFAULT_COMPRESSION, true))) {
deflater.write(payload);
}
return out.toByteArray();
}
void setClientOid4vciEnabled(String clientId, boolean enabled) {
ClientRepresentation clientRepresentation = adminClient.realm(TEST_REALM_NAME).clients().findByClientId(clientId).get(0);
ClientResource clientResource = adminClient.realm(TEST_REALM_NAME).clients().get(clientRepresentation.getId());
@ -511,7 +566,7 @@ public abstract class OID4VCIssuerEndpointTest extends OID4VCTest {
.findFirst()
.orElseThrow(() -> new IllegalStateException("Client with ID " + clientId + " not found in realm"));
// Add role to existing client
// Add a role to an existing client
if (testRealm.getRoles() != null) {
Map<String, List<RoleRepresentation>> clientRoles = testRealm.getRoles().getClient();
clientRoles.merge(

View File

@ -40,10 +40,11 @@ import org.keycloak.crypto.KeyUse;
import org.keycloak.crypto.KeyWrapper;
import org.keycloak.crypto.SignatureProvider;
import org.keycloak.crypto.SignatureVerifierContext;
import org.keycloak.jose.jwk.JWK;
import org.keycloak.jose.jws.JWSHeader;
import org.keycloak.jose.jws.JWSInput;
import org.keycloak.models.RealmModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.models.oid4vci.CredentialScopeModel;
import org.keycloak.models.oid4vci.Oid4vcProtocolMapperModel;
import org.keycloak.models.ProtocolMapperModel;
@ -57,8 +58,8 @@ import org.keycloak.protocol.oid4vc.model.Claim;
import org.keycloak.protocol.oid4vc.model.ClaimDisplay;
import org.keycloak.protocol.oid4vc.model.Claims;
import org.keycloak.protocol.oid4vc.model.CredentialIssuer;
import org.keycloak.protocol.oid4vc.model.CredentialResponseEncryptionMetadata;
import org.keycloak.protocol.oid4vc.model.CredentialRequestEncryptionMetadata;
import org.keycloak.protocol.oid4vc.model.CredentialResponseEncryptionMetadata;
import org.keycloak.protocol.oid4vc.model.DisplayObject;
import org.keycloak.protocol.oid4vc.model.Format;
import org.keycloak.protocol.oid4vc.model.ProofTypesSupported;
@ -75,6 +76,7 @@ import org.keycloak.testsuite.util.oauth.OAuthClient;
import org.keycloak.util.JsonSerialization;
import org.keycloak.utils.MediaType;
import org.keycloak.utils.StringUtil;
import org.keycloak.constants.Oid4VciConstants;
import java.io.IOException;
import java.io.Serializable;
@ -97,8 +99,8 @@ import static org.keycloak.jose.jwe.JWEConstants.A256GCM;
import static org.keycloak.jose.jwe.JWEConstants.RSA_OAEP;
import static org.keycloak.jose.jwe.JWEConstants.RSA_OAEP_256;
import static org.keycloak.protocol.oid4vc.issuance.OID4VCIssuerWellKnownProvider.ATTR_ENCRYPTION_REQUIRED;
import org.keycloak.constants.Oid4VciConstants;
import static org.keycloak.protocol.oid4vc.issuance.OID4VCIssuerWellKnownProvider.ATTR_REQUEST_ZIP_ALGS;
import static org.keycloak.protocol.oid4vc.issuance.OID4VCIssuerWellKnownProvider.DEFLATE_COMPRESSION;
public class OID4VCIssuerWellKnownProviderTest extends OID4VCIssuerEndpointTest {
@ -106,10 +108,9 @@ public class OID4VCIssuerWellKnownProviderTest extends OID4VCIssuerEndpointTest
public void configureTestRealm(RealmRepresentation testRealm) {
Map<String, String> attributes = Optional.ofNullable(testRealm.getAttributes()).orElseGet(HashMap::new);
attributes.put("credential_response_encryption.encryption_required", "true");
attributes.put(OID4VCIssuerWellKnownProvider.ATTR_ENCRYPTION_REQUIRED, "true");
attributes.put(Oid4VciConstants.BATCH_CREDENTIAL_ISSUANCE_BATCH_SIZE, "10");
attributes.put(ATTR_ENCRYPTION_REQUIRED, "true");
attributes.put(Oid4VciConstants.BATCH_CREDENTIAL_ISSUANCE_BATCH_SIZE, "10");
attributes.put(ATTR_REQUEST_ZIP_ALGS, DEFLATE_COMPRESSION);
testRealm.setAttributes(attributes);
if (testRealm.getComponents() == null) {
@ -134,9 +135,7 @@ public class OID4VCIssuerWellKnownProviderTest extends OID4VCIssuerEndpointTest
// Configure realm for unsigned metadata
testingClient.server(TEST_REALM_NAME).run(session -> {
RealmModel realm = session.getContext().getRealm();
realm.setAttribute(OID4VCIssuerWellKnownProvider.SIGNED_METADATA_ENABLED_ATTR, "true");
realm.setAttribute(OID4VCIssuerWellKnownProvider.SIGNED_METADATA_ALG_ATTR, "RS256");
realm.setAttribute(OID4VCIssuerWellKnownProvider.SIGNED_METADATA_LIFESPAN_ATTR, "3600");
realm.setAttribute(OID4VCIssuerWellKnownProvider.SIGNED_METADATA_ENABLED_ATTR, "false");
});
HttpGet getJsonMetadata = new HttpGet(wellKnownUri);
@ -333,7 +332,6 @@ public class OID4VCIssuerWellKnownProviderTest extends OID4VCIssuerEndpointTest
}
}
/**
* This test uses the configured scopes {@link #jwtTypeCredentialClientScope} and
* {@link #sdJwtTypeCredentialClientScope} to verify that the metadata endpoint is presenting the expected data
@ -366,9 +364,8 @@ public class OID4VCIssuerWellKnownProviderTest extends OID4VCIssuerEndpointTest
assertNotNull("credential_request_encryption should be present", requestEncryption);
assertEquals(List.of(A256GCM), requestEncryption.getEncValuesSupported());
assertNotNull("zip_values_supported should be present", requestEncryption.getZipValuesSupported());
assertTrue("encryption_required should be true", requestEncryption.getEncryptionRequired());
assertTrue("encryption_required should be true", requestEncryption.isEncryptionRequired());
assertNotNull("JWKS should be present", requestEncryption.getJwks());
assertFalse("JWKS should not be empty when encryption keys are available", requestEncryption.getJwks().isEmpty());
CredentialIssuer.BatchCredentialIssuance batch = credentialIssuer.getBatchCredentialIssuance();
assertNotNull("batch_credential_issuance should be present", batch);
@ -390,6 +387,7 @@ public class OID4VCIssuerWellKnownProviderTest extends OID4VCIssuerEndpointTest
CredentialIssuer credentialIssuer = getCredentialIssuerMetadata();
SupportedCredentialConfiguration supportedConfig = credentialIssuer.getCredentialsSupported()
.get(clientScope.getName());
assertNotNull(supportedConfig);
assertEquals(Format.SD_JWT_VC, supportedConfig.getFormat());
assertEquals(clientScope.getName(), supportedConfig.getScope());
@ -403,7 +401,6 @@ public class OID4VCIssuerWellKnownProviderTest extends OID4VCIssuerEndpointTest
compareClaims(supportedConfig.getFormat(), supportedConfig.getCredentialMetadata().getClaims(), clientScope.getProtocolMappers());
}
@Test
public void testCredentialIssuerMetadataFields() {
KeycloakTestingClient testingClient = this.testingClient;
@ -426,8 +423,18 @@ public class OID4VCIssuerWellKnownProviderTest extends OID4VCIssuerEndpointTest
assertNotNull("credential_request_encryption should be present", requestEncryption);
assertTrue("Supported encryption methods should include A256GCM", requestEncryption.getEncValuesSupported().contains(A256GCM));
assertNotNull("zip_values_supported should be present", requestEncryption.getZipValuesSupported());
assertTrue("encryption_required should be true", requestEncryption.getEncryptionRequired());
assertTrue("encryption_required should be true", requestEncryption.isEncryptionRequired());
assertEquals(Integer.valueOf(10), issuer.getBatchCredentialIssuance().getBatchSize());
// Additional JWK checks from HEAD's testCredentialRequestEncryptionMetadataFields
assertNotNull(requestEncryption.getJwks());
JWK[] keys = requestEncryption.getJwks().getKeys();
assertEquals(4, keys.length); // Adjust based on actual key configuration
for (JWK jwk : keys) {
assertNotNull("JWK must have kid", jwk.getKeyId());
assertNotNull("JWK must have alg", jwk.getAlgorithm());
assertEquals("JWK must have use=enc", "enc", jwk.getPublicKeyUse());
}
});
}
@ -435,6 +442,7 @@ public class OID4VCIssuerWellKnownProviderTest extends OID4VCIssuerEndpointTest
RealmModel realm = session.getContext().getRealm();
realm.setAttribute(ATTR_ENCRYPTION_REQUIRED, "true");
realm.setAttribute(ATTR_REQUEST_ZIP_ALGS, DEFLATE_COMPRESSION);
realm.setAttribute(Oid4VciConstants.BATCH_CREDENTIAL_ISSUANCE_BATCH_SIZE, "10");
OID4VCIssuerWellKnownProvider provider = new OID4VCIssuerWellKnownProvider(session);
@ -475,8 +483,6 @@ public class OID4VCIssuerWellKnownProviderTest extends OID4VCIssuerEndpointTest
oid4vciIssuerConfig.getCredentialRequestEncryption().getEncValuesSupported().contains("A256GCM"));
assertNotNull("JWKS should be present in credential request encryption",
oid4vciIssuerConfig.getCredentialRequestEncryption().getJwks());
assertFalse("JWKS should not be empty when encryption keys are available",
oid4vciIssuerConfig.getCredentialRequestEncryption().getJwks().isEmpty());
}
}
}
@ -550,7 +556,6 @@ public class OID4VCIssuerWellKnownProviderTest extends OID4VCIssuerEndpointTest
}
compareClaims(expectedFormat, supportedConfig.getCredentialMetadata().getClaims(), clientScope.getProtocolMappers());
}
private void compareDisplay(SupportedCredentialConfiguration supportedConfig, ClientScopeRepresentation clientScope) {
@ -605,8 +610,7 @@ public class OID4VCIssuerWellKnownProviderTest extends OID4VCIssuerEndpointTest
.findFirst().orElse(null);
if (mapper.includeInMetadata()) {
assertNotNull("There should be a claim matching the protocol-mappers config!", claim);
}
else {
} else {
assertNull("This claim should not be included in the metadata-config!", claim);
// no other checks to do for this claim
continue;
@ -622,8 +626,7 @@ public class OID4VCIssuerWellKnownProviderTest extends OID4VCIssuerEndpointTest
List<ClaimDisplay> actualDisplayList = claim.getDisplay();
if (expectedDisplayList == null) {
assertNull(actualDisplayList);
}
else {
} else {
assertEquals(expectedDisplayList.size(), actualDisplayList.size());
MatcherAssert.assertThat(actualDisplayList,
Matchers.containsInAnyOrder(expectedDisplayList.toArray()));

View File

@ -1,14 +1,15 @@
package org.keycloak.testsuite.oid4vc.issuance.signing;
import com.fasterxml.jackson.core.JsonProcessingException;
import jakarta.ws.rs.core.Response;
import org.junit.Test;
import org.keycloak.protocol.oid4vc.issuance.OID4VCIssuerEndpoint;
import org.keycloak.protocol.oid4vc.model.CredentialRequest;
import org.keycloak.protocol.oid4vc.model.Format;
import org.keycloak.protocol.oid4vc.model.OfferUriType;
import org.keycloak.services.CorsErrorResponseException;
import org.keycloak.services.managers.AppAuthManager;
import org.keycloak.testsuite.Assert;
import org.keycloak.util.JsonSerialization;
import java.util.function.Consumer;
@ -41,8 +42,15 @@ public class OID4VCJWTIssuerEndpointDisabledTest extends OID4VCIssuerEndpointTes
// Test requestCredential
CredentialRequest credentialRequest = new CredentialRequest()
.setCredentialIdentifier(jwtTypeCredentialScopeName);
String requestPayload;
try {
requestPayload = JsonSerialization.writeValueAsString(credentialRequest);
} catch (JsonProcessingException e) {
Assert.fail("Failed to serialize CredentialRequest: " + e.getMessage());
return;
}
CorsErrorResponseException requestException = Assert.assertThrows(CorsErrorResponseException.class, () ->
issuerEndpoint.requestCredential(credentialRequest)
issuerEndpoint.requestCredential(requestPayload)
);
assertEquals("Should fail with 403 Forbidden when client is not OID4VCI-enabled",
Response.Status.FORBIDDEN.getStatusCode(), requestException.getResponse().getStatus());

View File

@ -16,6 +16,7 @@
*/
package org.keycloak.testsuite.oid4vc.issuance.signing;
import com.fasterxml.jackson.core.JsonProcessingException;
import jakarta.ws.rs.BadRequestException;
import jakarta.ws.rs.client.Entity;
import jakarta.ws.rs.client.WebTarget;
@ -36,10 +37,6 @@ import org.keycloak.OAuth2Constants;
import org.keycloak.TokenVerifier;
import org.keycloak.common.VerificationException;
import org.keycloak.constants.Oid4VciConstants;
import org.keycloak.jose.jwe.JWEException;
import org.keycloak.jose.jwk.JWK;
import org.keycloak.jose.jwk.JWKParser;
import org.keycloak.models.RealmModel;
import org.keycloak.models.oid4vci.CredentialScopeModel;
import org.keycloak.protocol.oid4vc.issuance.OID4VCIssuerEndpoint;
import org.keycloak.protocol.oid4vc.issuance.OID4VCIssuerWellKnownProvider;
@ -50,7 +47,6 @@ import org.keycloak.protocol.oid4vc.model.CredentialIssuer;
import org.keycloak.protocol.oid4vc.model.CredentialOfferURI;
import org.keycloak.protocol.oid4vc.model.CredentialRequest;
import org.keycloak.protocol.oid4vc.model.CredentialResponse;
import org.keycloak.protocol.oid4vc.model.CredentialResponseEncryption;
import org.keycloak.protocol.oid4vc.model.CredentialsOffer;
import org.keycloak.protocol.oid4vc.model.ErrorResponse;
import org.keycloak.protocol.oid4vc.model.ErrorType;
@ -252,36 +248,41 @@ public class OID4VCJWTIssuerEndpointTest extends OID4VCIssuerEndpointTest {
});
}
// ----- requestCredential
// ----- requestCredential
@Test(expected = BadRequestException.class)
public void testRequestCredentialUnauthorized() throws Throwable {
withCausePropagation(() -> {
testingClient
.server(TEST_REALM_NAME)
.run((session -> {
AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session);
authenticator.setTokenString(null);
OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator);
Response response = issuerEndpoint.requestCredential(new CredentialRequest()
.setCredentialIdentifier("test-credential"));
assertEquals(MediaType.APPLICATION_JSON_TYPE, response.getMediaType());
}));
testingClient.server(TEST_REALM_NAME).run(session -> {
AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session);
authenticator.setTokenString(null);
OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator);
CredentialRequest credentialRequest = new CredentialRequest()
.setCredentialIdentifier("test-credential");
String requestPayload;
requestPayload = JsonSerialization.writeValueAsString(credentialRequest);
Response response = issuerEndpoint.requestCredential(requestPayload);
assertEquals(MediaType.APPLICATION_JSON_TYPE, response.getMediaType());
});
});
}
@Test(expected = BadRequestException.class)
public void testRequestCredentialInvalidToken() throws Throwable {
withCausePropagation(() -> {
testingClient
.server(TEST_REALM_NAME)
.run((session -> {
AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session);
authenticator.setTokenString("token");
OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator);
issuerEndpoint.requestCredential(new CredentialRequest()
.setCredentialIdentifier("test-credential"));
}));
testingClient.server(TEST_REALM_NAME).run(session -> {
AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session);
authenticator.setTokenString("token");
OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator);
CredentialRequest credentialRequest = new CredentialRequest()
.setCredentialIdentifier("test-credential");
String requestPayload = JsonSerialization.writeValueAsString(credentialRequest);
issuerEndpoint.requestCredential(requestPayload);
});
});
}
@ -304,7 +305,9 @@ public class OID4VCJWTIssuerEndpointTest extends OID4VCIssuerEndpointTest {
CredentialRequest credentialRequest =
new CredentialRequest().setCredentialConfigurationId(credentialConfigurationId);
issuerEndpoint.requestCredential(credentialRequest);
String requestPayload = JsonSerialization.writeValueAsString(credentialRequest);
issuerEndpoint.requestCredential(requestPayload);
}));
});
Assert.fail("Should have thrown an exception");
@ -318,15 +321,17 @@ public class OID4VCJWTIssuerEndpointTest extends OID4VCIssuerEndpointTest {
public void testRequestCredentialUnsupportedCredential() throws Throwable {
String token = getBearerToken(oauth);
withCausePropagation(() -> {
testingClient
.server(TEST_REALM_NAME)
.run((session -> {
AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session);
authenticator.setTokenString(token);
OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator);
issuerEndpoint.requestCredential(new CredentialRequest()
.setCredentialIdentifier("no-such-credential"));
}));
testingClient.server(TEST_REALM_NAME).run(session -> {
AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session);
authenticator.setTokenString(token);
OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator);
CredentialRequest credentialRequest = new CredentialRequest()
.setCredentialIdentifier("no-such-credential");
String requestPayload = JsonSerialization.writeValueAsString(credentialRequest);
issuerEndpoint.requestCredential(requestPayload);
});
});
}
@ -334,350 +339,65 @@ public class OID4VCJWTIssuerEndpointTest extends OID4VCIssuerEndpointTest {
public void testRequestCredential() {
final String scopeName = jwtTypeCredentialClientScope.getName();
String token = getBearerToken(oauth, client, scopeName);
testingClient
.server(TEST_REALM_NAME)
.run((session -> {
AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session);
authenticator.setTokenString(token);
OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator);
CredentialRequest credentialRequest = new CredentialRequest()
.setCredentialIdentifier(scopeName);
Response credentialResponse = issuerEndpoint.requestCredential(credentialRequest);
assertEquals("The credential request should be answered successfully.",
HttpStatus.SC_OK,
credentialResponse.getStatus());
assertNotNull("A credential should be responded.", credentialResponse.getEntity());
CredentialResponse credentialResponseVO = JsonSerialization.mapper
.convertValue(credentialResponse.getEntity(),
CredentialResponse.class);
JsonWebToken jsonWebToken = TokenVerifier.create((String) credentialResponseVO.getCredentials().get(0).getCredential(),
JsonWebToken.class).getToken();
testingClient.server(TEST_REALM_NAME).run(session -> {
AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session);
authenticator.setTokenString(token);
OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator);
CredentialRequest credentialRequest = new CredentialRequest()
.setCredentialIdentifier(scopeName);
assertNotNull("A valid credential string should have been responded", jsonWebToken);
assertNotNull("The credentials should be included at the vc-claim.",
jsonWebToken.getOtherClaims().get("vc"));
VerifiableCredential credential =
JsonSerialization.mapper.convertValue(jsonWebToken.getOtherClaims().get("vc"),
VerifiableCredential.class);
assertTrue("The static claim should be set.",
credential.getCredentialSubject().getClaims().containsKey("scope-name"));
assertEquals("The static claim should be set.",
scopeName,
credential.getCredentialSubject().getClaims().get("scope-name"));
assertFalse("Only mappers supported for the requested type should have been evaluated.",
credential.getCredentialSubject().getClaims().containsKey("AnotherCredentialType"));
}));
String requestPayload = JsonSerialization.writeValueAsString(credentialRequest);
Response credentialResponse = issuerEndpoint.requestCredential(requestPayload);
assertEquals("The credential request should be answered successfully.",
HttpStatus.SC_OK, credentialResponse.getStatus());
assertNotNull("A credential should be responded.", credentialResponse.getEntity());
CredentialResponse credentialResponseVO = JsonSerialization.mapper
.convertValue(credentialResponse.getEntity(), CredentialResponse.class);
JsonWebToken jsonWebToken;
try {
jsonWebToken = TokenVerifier.create((String) credentialResponseVO.getCredentials().get(0).getCredential(),
JsonWebToken.class).getToken();
} catch (VerificationException e) {
Assert.fail("Failed to verify JWT: " + e.getMessage());
return;
}
assertNotNull("A valid credential string should have been responded", jsonWebToken);
assertNotNull("The credentials should be included at the vc-claim.",
jsonWebToken.getOtherClaims().get("vc"));
VerifiableCredential credential = JsonSerialization.mapper.convertValue(
jsonWebToken.getOtherClaims().get("vc"), VerifiableCredential.class);
assertTrue("The static claim should be set.",
credential.getCredentialSubject().getClaims().containsKey("scope-name"));
assertEquals("The static claim should be set.",
scopeName, credential.getCredentialSubject().getClaims().get("scope-name"));
assertFalse("Only mappers supported for the requested type should have been evaluated.",
credential.getCredentialSubject().getClaims().containsKey("AnotherCredentialType"));
});
}
@Test
public void testRequestCredentialWithConfigurationIdNotSet() {
final String scopeName = minimalJwtTypeCredentialClientScope.getName();
String token = getBearerToken(oauth, client, scopeName);
testingClient
.server(TEST_REALM_NAME)
.run((session -> {
AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session);
authenticator.setTokenString(token);
OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator);
CredentialRequest credentialRequest = new CredentialRequest()
.setCredentialIdentifier(scopeName);
Response credentialResponse = issuerEndpoint.requestCredential(credentialRequest);
assertEquals("The credential request should be answered successfully.",
HttpStatus.SC_OK,
credentialResponse.getStatus());
assertNotNull("A credential should be responded.", credentialResponse.getEntity());
CredentialResponse credentialResponseVO = JsonSerialization.mapper
.convertValue(credentialResponse.getEntity(),
CredentialResponse.class);
String credentialString = (String)credentialResponseVO.getCredentials().get(0).getCredential();
SdJwtVP sdJwtVP = SdJwtVP.of(credentialString);
assertNotNull("A valid credential string should have been responded", sdJwtVP);
}));
}
@Test
public void testRequestCredentialWithEncryption() {
final String scopeName = jwtTypeCredentialClientScope.getName();
String token = getBearerToken(oauth, client, scopeName);
testingClient
.server(TEST_REALM_NAME)
.run((session -> {
AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session);
authenticator.setTokenString(token);
OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator);
Map<String, Object> jwkPair;
try {
jwkPair = generateRsaJwkWithPrivateKey();
} catch (NoSuchAlgorithmException e) {
throw new RuntimeException("Failed to generate JWK", e);
}
JWK jwk = (JWK) jwkPair.get("jwk");
PrivateKey privateKey = (PrivateKey) jwkPair.get("privateKey");
CredentialRequest credentialRequest = new CredentialRequest()
.setFormat(Format.JWT_VC)
.setCredentialIdentifier(scopeName)
.setCredentialResponseEncryption(
new CredentialResponseEncryption()
.setAlg("RSA-OAEP")
.setEnc("A256GCM")
.setJwk(jwk));
Response credentialResponse = issuerEndpoint.requestCredential(credentialRequest);
assertEquals("The credential request should be answered successfully.",
HttpStatus.SC_OK, credentialResponse.getStatus());
assertEquals("Response should be JWT type for encrypted responses",
org.keycloak.utils.MediaType.APPLICATION_JWT, credentialResponse.getMediaType().toString());
String encryptedResponse = (String) credentialResponse.getEntity();
CredentialResponse decryptedResponse;
try {
decryptedResponse = decryptJweResponse(encryptedResponse, privateKey);
} catch (IOException | JWEException e) {
Assert.fail("Failed to decrypt JWE response: " + e.getMessage());
return;
}
// Verify the decrypted payload
assertNotNull("Decrypted response should contain a credential", decryptedResponse.getCredentials());
JsonWebToken jsonWebToken;
try {
jsonWebToken = TokenVerifier.create((String) decryptedResponse.getCredentials().get(0).getCredential(), JsonWebToken.class).getToken();
} catch (VerificationException e) {
Assert.fail("Failed to verify JWT: " + e.getMessage());
return;
}
assertNotNull("A valid credential string should have been responded", jsonWebToken);
VerifiableCredential credential = JsonSerialization.mapper.convertValue(
jsonWebToken.getOtherClaims().get("vc"), VerifiableCredential.class);
assertTrue("The static claim should be set.", credential.getCredentialSubject().getClaims().containsKey("scope-name"));
}));
}
@Test
public void testRequestCredentialWithIncompleteEncryptionParams() throws Throwable {
String token = getBearerToken(oauth, client, jwtTypeCredentialClientScope.getName());
testingClient.server(TEST_REALM_NAME).run(session -> {
AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session);
authenticator.setTokenString(token);
OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator);
// Missing enc parameter
JWK jwk = JWKParser.create().parse("{\"kty\":\"RSA\",\"n\":\"test-n\",\"e\":\"AQAB\"}").getJwk();
CredentialRequest credentialRequest = new CredentialRequest()
.setFormat(Format.JWT_VC)
.setCredentialIdentifier("test-credential")
.setCredentialResponseEncryption(
new CredentialResponseEncryption()
.setAlg("RSA-OAEP")
.setJwk(jwk));
.setCredentialIdentifier(scopeName);
try {
issuerEndpoint.requestCredential(credentialRequest);
Assert.fail("Expected BadRequestException due to missing encryption parameter 'enc'");
} catch (BadRequestException e) {
ErrorResponse error = (ErrorResponse) e.getResponse().getEntity();
assertEquals(ErrorType.INVALID_ENCRYPTION_PARAMETERS, error.getError());
assertTrue("Error message should specify missing parameters",
error.getErrorDescription().contains("Missing required encryption parameters: enc"));
}
});
}
String requestPayload = JsonSerialization.writeValueAsString(credentialRequest);
@Test
public void testCredentialIssuanceWithEncryption() throws Exception {
// Integration test for the full credential issuance flow with encryption
testCredentialIssuanceWithAuthZCodeFlow(jwtTypeCredentialClientScope,
(testClientId, testScope) -> {
String scopeName = jwtTypeCredentialClientScope.getName();
return getBearerToken(oauth.clientId(testClientId).openid(false).scope(scopeName));
},
m -> {
String accessToken = (String) m.get("accessToken");
WebTarget credentialTarget = (WebTarget) m.get("credentialTarget");
CredentialRequest credentialRequest = (CredentialRequest) m.get("credentialRequest");
Map<String, Object> jwkPair;
try {
jwkPair = generateRsaJwkWithPrivateKey();
} catch (NoSuchAlgorithmException e) {
throw new RuntimeException("Failed to generate JWK", e);
}
JWK jwk = (JWK) jwkPair.get("jwk");
PrivateKey privateKey = (PrivateKey) jwkPair.get("privateKey");
credentialRequest.setCredentialResponseEncryption(
new CredentialResponseEncryption()
.setAlg("RSA-OAEP")
.setEnc("A256GCM")
.setJwk(jwk));
try (Response response = credentialTarget.request()
.header(HttpHeaders.AUTHORIZATION, "bearer " + accessToken)
.post(Entity.json(credentialRequest))) {
assertEquals(200, response.getStatus());
assertEquals("application/jwt", response.getMediaType().toString());
String encryptedResponse = response.readEntity(String.class);
CredentialResponse decryptedResponse;
try {
decryptedResponse = decryptJweResponse(encryptedResponse, privateKey);
} catch (IOException | JWEException e) {
Assert.fail("Failed to decrypt JWE response: " + e.getMessage());
return;
}
// Verify the decrypted payload
JsonWebToken jsonWebToken;
try {
jsonWebToken = TokenVerifier.create(
(String) decryptedResponse.getCredentials().get(0).getCredential(),
JsonWebToken.class
).getToken();
} catch (VerificationException e) {
Assert.fail("Failed to verify JWT: " + e.getMessage());
return;
}
assertEquals("did:web:test.org", jsonWebToken.getIssuer());
VerifiableCredential credential = JsonSerialization.mapper.convertValue(
jsonWebToken.getOtherClaims().get("vc"),
VerifiableCredential.class
);
assertEquals(List.of(jwtTypeCredentialClientScope.getName()), credential.getType());
assertEquals(TEST_DID, credential.getIssuer());
assertEquals("john@email.cz", credential.getCredentialSubject().getClaims().get("email"));
}
});
}
@Test
public void testRequestCredentialWithUnsupportedAlgorithms() throws Throwable {
String token = getBearerToken(oauth, client, jwtTypeCredentialClientScope.getName());
testingClient.server(TEST_REALM_NAME).run(session -> {
AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session);
authenticator.setTokenString(token);
OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator);
JWK jwk;
try {
jwk = generateRsaJwk();
} catch (NoSuchAlgorithmException e) {
throw new RuntimeException("Failed to generate JWK", e);
}
CredentialRequest credentialRequest = new CredentialRequest()
.setFormat(Format.JWT_VC)
.setCredentialIdentifier("test-credential")
.setCredentialResponseEncryption(
new CredentialResponseEncryption()
.setAlg("UNSUPPORTED-ALG")
.setEnc("A256GCM")
.setJwk(jwk));
try {
issuerEndpoint.requestCredential(credentialRequest);
Assert.fail("Expected BadRequestException due to unsupported algorithm");
} catch (BadRequestException e) {
ErrorResponse error = (ErrorResponse) e.getResponse().getEntity();
assertEquals(ErrorType.INVALID_ENCRYPTION_PARAMETERS, error.getError());
assertTrue(error.getErrorDescription().contains("UNSUPPORTED-ALG"));
}
});
}
@Test
public void testRequestCredentialWithInvalidJWK() throws Throwable {
String token = getBearerToken(oauth, client, jwtTypeCredentialClientScope.getName());
testingClient.server(TEST_REALM_NAME).run(session -> {
AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session);
authenticator.setTokenString(token);
OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator);
// Invalid JWK (missing modulus)
JWK jwk = JWKParser.create().parse("{\"kty\":\"RSA\",\"e\":\"AQAB\"}").getJwk();
CredentialRequest credentialRequest = new CredentialRequest()
.setFormat(Format.JWT_VC)
.setCredentialIdentifier("test-credential")
.setCredentialResponseEncryption(
new CredentialResponseEncryption()
.setAlg("RSA-OAEP")
.setEnc("A256GCM")
.setJwk(jwk));
try {
issuerEndpoint.requestCredential(credentialRequest);
Assert.fail("Expected BadRequestException due to invalid JWK missing modulus");
} catch (BadRequestException e) {
ErrorResponse error = (ErrorResponse) e.getResponse().getEntity();
assertEquals(ErrorType.INVALID_ENCRYPTION_PARAMETERS, error.getError());
assertTrue(error.getErrorDescription().contains("JWK"));
}
});
}
@Test
public void testRequestCredentialWithWrongKeyTypeJWK() throws Throwable {
String token = getBearerToken(oauth, client, jwtTypeCredentialClientScope.getName());
testingClient.server(TEST_REALM_NAME).run(session -> {
AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session);
authenticator.setTokenString(token);
OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator);
JWK jwk = JWKParser.create().parse("{\"kty\":\"EC\",\"crv\":\"P-256\",\"x\":\"test-x\",\"y\":\"test-y\"}").getJwk();
CredentialRequest credentialRequest = new CredentialRequest()
.setFormat(Format.JWT_VC)
.setCredentialIdentifier("test-credential")
.setCredentialResponseEncryption(
new CredentialResponseEncryption()
.setAlg("RSA-OAEP")
.setEnc("A256GCM")
.setJwk(jwk));
try {
issuerEndpoint.requestCredential(credentialRequest);
Assert.fail("Expected BadRequestException due to wrong JWK key type");
} catch (BadRequestException e) {
ErrorResponse error = (ErrorResponse) e.getResponse().getEntity();
assertEquals(ErrorType.INVALID_ENCRYPTION_PARAMETERS, error.getError());
assertTrue(error.getErrorDescription().contains("JWK"));
}
});
}
@Test
public void testRequestCredentialEncryptionRequiredButMissing() {
String token = getBearerToken(oauth, client, jwtTypeCredentialClientScope.getName());
testingClient.server(TEST_REALM_NAME).run(session -> {
RealmModel realm = session.getContext().getRealm();
realm.setAttribute("oid4vci.encryption.required", "true");
realm.setAttribute("oid4vci.encryption.algs", "RSA-OAEP");
realm.setAttribute("oid4vci.encryption.encs", "A256GCM");
try {
AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session);
authenticator.setTokenString(token);
OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator);
CredentialRequest credentialRequest = new CredentialRequest()
.setFormat(Format.JWT_VC)
.setCredentialIdentifier("test-credential");
issuerEndpoint.requestCredential(credentialRequest);
Assert.fail("Expected BadRequestException due to missing encryption parameters when required");
} catch (BadRequestException e) {
ErrorResponse error = (ErrorResponse) e.getResponse().getEntity();
assertEquals(ErrorType.INVALID_ENCRYPTION_PARAMETERS, error.getError());
assertEquals("Encryption is required by the Credential Issuer, but no encryption parameters were provided.", error.getErrorDescription());
} finally {
// Clean up realm attributes
realm.removeAttribute("oid4vci.encryption.required");
realm.removeAttribute("oid4vci.encryption.algs");
realm.removeAttribute("oid4vci.encryption.encs");
}
Response credentialResponse = issuerEndpoint.requestCredential(requestPayload);
assertEquals("The credential request should be answered successfully.",
HttpStatus.SC_OK, credentialResponse.getStatus());
assertNotNull("A credential should be responded.", credentialResponse.getEntity());
CredentialResponse credentialResponseVO = JsonSerialization.mapper
.convertValue(credentialResponse.getEntity(), CredentialResponse.class);
String credentialString = (String) credentialResponseVO.getCredentials().get(0).getCredential();
SdJwtVP sdJwtVP = SdJwtVP.of(credentialString);
assertNotNull("A valid credential string should have been responded", sdJwtVP);
});
}
@ -874,23 +594,27 @@ public class OID4VCJWTIssuerEndpointTest extends OID4VCIssuerEndpointTest {
AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session);
authenticator.setTokenString(token);
OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator);
CredentialRequest credentialRequest = new CredentialRequest()
.setCredentialIdentifier(scopeName);
CredentialRequest credentialRequest = new CredentialRequest().setCredentialIdentifier(scopeName);
String requestPayload = JsonSerialization.writeValueAsString(credentialRequest);
// First credential request
Response response1 = issuerEndpoint.requestCredential(credentialRequest);
assertEquals("The credential request should be successful.", 200, response1.getStatus());
CredentialResponse credentialResponse1 = JsonSerialization.mapper.convertValue(response1.getEntity(), CredentialResponse.class);
Response response1 = issuerEndpoint.requestCredential(requestPayload);
assertEquals("The credential request should be successful.", HttpStatus.SC_OK, response1.getStatus());
CredentialResponse credentialResponse1 = JsonSerialization.mapper.convertValue(
response1.getEntity(), CredentialResponse.class);
assertNotNull("Credential response should not be null", credentialResponse1);
assertNotNull("Credential should be present", credentialResponse1.getCredentials());
assertNotNull("Notification ID should be present", credentialResponse1.getNotificationId());
assertFalse("Notification ID should not be empty", credentialResponse1.getNotificationId().isEmpty());
// Second credential request
Response response2 = issuerEndpoint.requestCredential(credentialRequest);
assertEquals("The second credential request should be successful.", 200, response2.getStatus());
CredentialResponse credentialResponse2 = JsonSerialization.mapper.convertValue(response2.getEntity(), CredentialResponse.class);
assertNotEquals("Notification IDs should be unique", credentialResponse1.getNotificationId(), credentialResponse2.getNotificationId());
Response response2 = issuerEndpoint.requestCredential(requestPayload);
assertEquals("The second credential request should be successful.", HttpStatus.SC_OK, response2.getStatus());
CredentialResponse credentialResponse2 = JsonSerialization.mapper.convertValue(
response2.getEntity(), CredentialResponse.class);
assertNotEquals("Notification IDs should be unique",
credentialResponse1.getNotificationId(), credentialResponse2.getNotificationId());
});
}
@ -921,7 +645,9 @@ public class OID4VCJWTIssuerEndpointTest extends OID4VCIssuerEndpointTest {
OID4VCIssuerEndpoint endpoint = prepareIssuerEndpoint(session, authenticator);
Response response = endpoint.requestCredential(request);
String requestPayload = JsonSerialization.writeValueAsString(request);
Response response = endpoint.requestCredential(requestPayload);
assertEquals("Response status should be OK", Response.Status.OK.getStatusCode(), response.getStatus());
CredentialResponse credentialResponse = JsonSerialization.mapper
@ -1141,8 +867,10 @@ public class OID4VCJWTIssuerEndpointTest extends OID4VCIssuerEndpointTest {
CredentialRequest credentialRequest = new CredentialRequest()
.setCredentialIdentifier("unknown-credential-identifier");
String requestPayload = JsonSerialization.writeValueAsString(credentialRequest);
try {
issuerEndpoint.requestCredential(credentialRequest);
issuerEndpoint.requestCredential(requestPayload);
Assert.fail("Expected BadRequestException due to unknown credential identifier");
} catch (BadRequestException e) {
ErrorResponse error = (ErrorResponse) e.getResponse().getEntity();
@ -1168,8 +896,10 @@ public class OID4VCJWTIssuerEndpointTest extends OID4VCIssuerEndpointTest {
CredentialRequest credentialRequest = new CredentialRequest()
.setCredentialConfigurationId("unknown-configuration-id");
String requestPayload = JsonSerialization.writeValueAsString(credentialRequest);
try {
issuerEndpoint.requestCredential(credentialRequest);
issuerEndpoint.requestCredential(requestPayload);
Assert.fail("Expected BadRequestException due to unknown credential configuration");
} catch (BadRequestException e) {
ErrorResponse error = (ErrorResponse) e.getResponse().getEntity();
@ -1196,8 +926,10 @@ public class OID4VCJWTIssuerEndpointTest extends OID4VCIssuerEndpointTest {
CredentialRequest credentialRequest = new CredentialRequest()
.setCredentialConfigurationId(jwtTypeCredentialConfigurationIdName);
String requestPayload = JsonSerialization.writeValueAsString(credentialRequest);
try {
issuerEndpoint.requestCredential(credentialRequest);
issuerEndpoint.requestCredential(requestPayload);
Assert.fail("Expected BadRequestException due to missing credential builder for format");
} catch (BadRequestException e) {
ErrorResponse error = (ErrorResponse) e.getResponse().getEntity();

View File

@ -1,14 +1,15 @@
package org.keycloak.testsuite.oid4vc.issuance.signing;
import com.fasterxml.jackson.core.JsonProcessingException;
import jakarta.ws.rs.core.Response;
import org.junit.Test;
import org.keycloak.protocol.oid4vc.issuance.OID4VCIssuerEndpoint;
import org.keycloak.protocol.oid4vc.model.CredentialRequest;
import org.keycloak.protocol.oid4vc.model.Format;
import org.keycloak.protocol.oid4vc.model.OfferUriType;
import org.keycloak.services.CorsErrorResponseException;
import org.keycloak.services.managers.AppAuthManager;
import org.keycloak.testsuite.Assert;
import org.keycloak.util.JsonSerialization;
import java.util.function.Consumer;
@ -38,11 +39,17 @@ public class OID4VCSdJwtIssuingEndpointDisabledTest extends OID4VCIssuerEndpoint
assertEquals("Should fail with 403 Forbidden when client is not OID4VCI-enabled",
Response.Status.FORBIDDEN.getStatusCode(), offerUriException.getResponse().getStatus());
// Test requestCredential
CredentialRequest credentialRequest = new CredentialRequest()
.setCredentialConfigurationId(sdJwtTypeCredentialConfigurationIdName);
String requestPayload;
try {
requestPayload = JsonSerialization.writeValueAsString(credentialRequest);
} catch (JsonProcessingException e) {
Assert.fail("Failed to serialize CredentialRequest: " + e.getMessage());
return;
}
CorsErrorResponseException requestException = Assert.assertThrows(CorsErrorResponseException.class, () ->
issuerEndpoint.requestCredential(credentialRequest)
issuerEndpoint.requestCredential(requestPayload)
);
assertEquals("Should fail with 403 Forbidden when client is not OID4VCI-enabled",
Response.Status.FORBIDDEN.getStatusCode(), requestException.getResponse().getStatus());

View File

@ -256,8 +256,9 @@ public class OID4VCSdJwtIssuingEndpointTest extends OID4VCIssuerEndpointTest {
}
private static SdJwtVP testRequestTestCredential(KeycloakSession session, ClientScopeRepresentation clientScope,
String token, Proofs proof)
throws VerificationException {
throws VerificationException, IOException {
AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session);
authenticator.setTokenString(token);
@ -268,8 +269,11 @@ public class OID4VCSdJwtIssuingEndpointTest extends OID4VCIssuerEndpointTest {
.setCredentialConfigurationId(credentialConfigurationId)
.setProofs(proof);
Response credentialResponse = issuerEndpoint.requestCredential(credentialRequest);
String requestPayload = JsonSerialization.writeValueAsString(credentialRequest);
Response credentialResponse = issuerEndpoint.requestCredential(requestPayload);
assertEquals("The credential request should be answered successfully.",
HttpStatus.SC_OK,
credentialResponse.getStatus());
assertNotNull("A credential should be responded.", credentialResponse.getEntity());