mirror of
https://github.com/keycloak/keycloak.git
synced 2026-01-10 15:32:05 -03:30
[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:
parent
20f5a15278
commit
70b50e93e9
@ -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() {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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()));
|
||||
|
||||
@ -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());
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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());
|
||||
|
||||
@ -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());
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user