diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/OID4VCIssuerEndpoint.java b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/OID4VCIssuerEndpoint.java index 0ca149d7326..b0b2edafe49 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/OID4VCIssuerEndpoint.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/OID4VCIssuerEndpoint.java @@ -1,5 +1,5 @@ /* - * Copyright 2024 Red Hat, Inc. and/or its affiliates + * 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"); @@ -41,6 +41,11 @@ import org.keycloak.component.ComponentFactory; import org.keycloak.component.ComponentModel; import org.keycloak.events.Errors; import org.keycloak.events.EventBuilder; +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.JWKParser; import org.keycloak.models.AuthenticatedClientSessionModel; import org.keycloak.models.ClientModel; import org.keycloak.models.ClientScopeModel; @@ -62,6 +67,8 @@ import org.keycloak.protocol.oid4vc.issuance.signing.CredentialSigner; 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.CredentialResponseEncryptionMetadata; import org.keycloak.protocol.oid4vc.model.CredentialsOffer; import org.keycloak.protocol.oid4vc.model.ErrorResponse; import org.keycloak.protocol.oid4vc.model.ErrorType; @@ -89,7 +96,9 @@ import java.io.ByteArrayOutputStream; import java.io.IOException; import java.net.URLEncoder; import java.nio.charset.StandardCharsets; +import java.security.PublicKey; import java.time.Instant; +import java.util.ArrayList; import java.util.Arrays; import java.util.HashMap; import java.util.List; @@ -174,10 +183,10 @@ public class OID4VCIssuerEndpoint { private Map loadCredentialBuilders(KeycloakSession keycloakSession) { KeycloakSessionFactory keycloakSessionFactory = keycloakSession.getKeycloakSessionFactory(); return keycloakSessionFactory.getProviderFactoriesStream(CredentialBuilder.class) - .map(factory -> (CredentialBuilderFactory) factory) - .map(factory -> factory.create(keycloakSession, null)) - .collect(Collectors.toMap(CredentialBuilder::getSupportedFormat, - credentialBuilder -> credentialBuilder)); + .map(factory -> (CredentialBuilderFactory) factory) + .map(factory -> factory.create(keycloakSession, null)) + .collect(Collectors.toMap(CredentialBuilder::getSupportedFormat, + credentialBuilder -> credentialBuilder)); } /** @@ -346,16 +355,16 @@ public class OID4VCIssuerEndpoint { if (Arrays.stream(accessToken.getScope().split(" ")) .noneMatch(tokenScope -> tokenScope.equals(requestedCredential.getScope()))) { LOGGER.debugf("Scope check failure: required scope = %s, " + - "scope in access token = %s.", - requestedCredential.getName(), accessToken.getScope()); + "scope in access token = %s.", + requestedCredential.getName(), accessToken.getScope()); throw new CorsErrorResponseException(cors, ErrorType.UNSUPPORTED_CREDENTIAL_TYPE.toString(), "Scope check failure", Response.Status.BAD_REQUEST); } else { LOGGER.debugf("Scope check success: required scope = %s, #" + - "scope in access token = %s.", - requestedCredential.getScope(), accessToken.getScope()); + "scope in access token = %s.", + requestedCredential.getScope(), accessToken.getScope()); } } else { clientSession.removeNote(PreAuthorizedCodeGrantType.VC_ISSUANCE_FLOW); @@ -367,17 +376,48 @@ public class OID4VCIssuerEndpoint { */ @POST @Consumes(MediaType.APPLICATION_JSON) - @Produces(MediaType.APPLICATION_JSON) + @Produces({MediaType.APPLICATION_JSON, MediaType.APPLICATION_JWT}) @Path(CREDENTIAL_PATH) - public Response requestCredential( - CredentialRequest credentialRequestVO) { + public Response requestCredential(CredentialRequest credentialRequestVO) { LOGGER.debugf("Received credentials request %s.", credentialRequestVO); cors = Cors.builder().auth().allowedMethods("POST").auth().exposedHeaders(Cors.ACCESS_CONTROL_ALLOW_METHODS); - // do first to fail fast on auth + // Authenticate first to fail fast on auth errors AuthenticationManager.AuthResult authResult = getAuthResult(); + // Validate encryption parameters if present + CredentialResponseEncryption encryptionParams = credentialRequestVO.getCredentialResponseEncryption(); + CredentialResponseEncryptionMetadata encryptionMetadata = OID4VCIssuerWellKnownProvider.getCredentialResponseEncryption(session); + boolean isEncryptionRequired = Optional.ofNullable(encryptionMetadata) + .map(CredentialResponseEncryptionMetadata::getEncryptionRequired) + .orElse(false); + + // 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."; + LOGGER.debug(errorMessage); + throw new BadRequestException(getErrorResponse(ErrorType.INVALID_ENCRYPTION_PARAMETERS, errorMessage)); + } + + // Validate encryption parameters if provided + if (encryptionParams != null) { + try { + 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; + } + } + // checkClientEnabled call after authentication checkClientEnabled(); @@ -395,9 +435,9 @@ public class OID4VCIssuerEndpoint { throw new BadRequestException(getErrorResponse(ErrorType.MISSING_CREDENTIAL_IDENTIFIER_AND_CONFIGURATION_ID)); } + // Find the requested credential scope CredentialScopeModel requestedCredential = credentialRequestVO.findCredentialScope(session).orElseThrow(() -> { - LOGGER.debugf("Credential for request '%s' not found.", - credentialRequestVO.toString()); + LOGGER.debugf("Credential for request '%s' not found.", credentialRequestVO.toString()); return new BadRequestException(getErrorResponse(ErrorType.UNSUPPORTED_CREDENTIAL_TYPE)); }); @@ -408,13 +448,151 @@ public class OID4VCIssuerEndpoint { Object theCredential = getCredential(authResult, supportedCredential, credentialRequestVO); + // Generate credential response CredentialResponse responseVO = new CredentialResponse(); responseVO - .addCredential(theCredential) - .setNotificationId(generateNotificationId()); + .addCredential(theCredential) + .setNotificationId(generateNotificationId()); + + if (encryptionParams != null) { + String jwe = encryptCredentialResponse(responseVO, encryptionParams); + return Response.ok() + .type(MediaType.APPLICATION_JWT) + .entity(jwe) + .build(); + } + return Response.ok().entity(responseVO).build(); } + /** + * Encrypts a CredentialResponse as a JWE using the provided encryption parameters. + * + * @param response The CredentialResponse to encrypt + * @param encryptionParams The encryption parameters (alg, enc, jwk) + * @return The compact JWE serialization + * @throws BadRequestException If encryption parameters are invalid + * @throws WebApplicationException If encryption fails due to server issues + */ + private String encryptCredentialResponse(CredentialResponse response, CredentialResponseEncryption encryptionParams) { + // Validate input parameters + validateEncryptionParameters(encryptionParams); + + String alg = encryptionParams.getAlg(); + String enc = encryptionParams.getEnc(); + JWK jwk = encryptionParams.getJwk(); + + // Parse public key + PublicKey publicKey; + try { + publicKey = JWKParser.create(jwk).toPublicKey(); + if (publicKey == null) { + LOGGER.debug("Invalid JWK: Failed to parse public key"); + throw new BadRequestException(getErrorResponse(ErrorType.INVALID_ENCRYPTION_PARAMETERS, + "Invalid JWK: Failed to parse public key.")); + } + } catch (Exception e) { + LOGGER.debugf("Failed to parse JWK: %s", e.getMessage()); + throw new BadRequestException(getErrorResponse(ErrorType.INVALID_ENCRYPTION_PARAMETERS, + "Invalid JWK: Failed to parse public key.")); + } + + // Perform encryption + try { + byte[] content = JsonSerialization.writeValueAsBytes(response); + JWEHeader header = new JWEHeader.JWEHeaderBuilder() + .algorithm(alg) + .encryptionAlgorithm(enc) + .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")) + .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")) + .type(MediaType.APPLICATION_JSON) + .build()); + } + } + + /** + * Validate the encryption parameters for a credential response. + * + * @param encryptionParams The encryption parameters to validate + * @throws BadRequestException If the encryption parameters are invalid + */ + private void validateEncryptionParameters(CredentialResponseEncryption encryptionParams) { + if (encryptionParams == null) { + throw new BadRequestException(getErrorResponse(ErrorType.INVALID_ENCRYPTION_PARAMETERS, + "Missing required encryption parameters (alg, enc, and jwk).")); + } + + List 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)); + } + + if (!isValidJwkForEncryption(encryptionParams.getJwk(), encryptionParams.getAlg())) { + String errorMessage = String.format("Invalid JWK: Not suitable for encryption with algorithm %s", encryptionParams.getAlg()); + LOGGER.debug(errorMessage); + throw new BadRequestException(getErrorResponse(ErrorType.INVALID_ENCRYPTION_PARAMETERS, errorMessage)); + + } + } + + /** + * 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) { + 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 AuthenticatedClientSessionModel getAuthenticatedClientSession() { AuthenticationManager.AuthResult authResult = getAuthResult(); UserSessionModel userSessionModel = authResult.getSession(); @@ -479,7 +657,7 @@ public class OID4VCIssuerEndpoint { // Retrieve matching credential signer CredentialSigner credentialSigner = session.getProvider(CredentialSigner.class, - credentialConfig.getFormat()); + credentialConfig.getFormat()); return Optional.ofNullable(credentialSigner) .map(signer -> signer.signCredential( @@ -540,8 +718,12 @@ public class OID4VCIssuerEndpoint { } private Response getErrorResponse(ErrorType errorType) { + return getErrorResponse(errorType, null); + } + + private Response getErrorResponse(ErrorType errorType, String errorDescription) { var errorResponse = new ErrorResponse(); - errorResponse.setError(errorType); + errorResponse.setError(errorType).setErrorDescription(errorDescription); return Response .status(Response.Status.BAD_REQUEST) .entity(errorResponse) @@ -570,7 +752,7 @@ public class OID4VCIssuerEndpoint { // Build format-specific credential CredentialBody credentialBody = this.findCredentialBuilder(credentialConfig) - .buildCredentialBody(vc, credentialConfig.getCredentialBuildConfig()); + .buildCredentialBody(vc, credentialConfig.getCredentialBuildConfig()); return new VCIssuanceContext() .setAuthResult(authResult) diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/OID4VCIssuerWellKnownProvider.java b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/OID4VCIssuerWellKnownProvider.java index fae80ad145e..ae972d4190c 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/OID4VCIssuerWellKnownProvider.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/OID4VCIssuerWellKnownProvider.java @@ -21,6 +21,7 @@ import jakarta.ws.rs.core.UriInfo; import org.keycloak.constants.Oid4VciConstants; import org.keycloak.crypto.KeyUse; import org.keycloak.crypto.KeyWrapper; +import org.keycloak.jose.jwe.JWEConstants; import org.keycloak.models.KeyManager; import org.keycloak.models.KeycloakContext; import org.keycloak.models.KeycloakSession; @@ -29,20 +30,22 @@ import org.keycloak.models.oid4vci.CredentialScopeModel; 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.SupportedCredentialConfiguration; import org.keycloak.services.Urls; import org.keycloak.urls.UrlType; import org.keycloak.wellknown.WellKnownProvider; -import com.fasterxml.jackson.core.type.TypeReference; -import com.fasterxml.jackson.databind.ObjectMapper; import org.jboss.logging.Logger; import java.util.List; import java.util.Map; import java.util.stream.Collectors; +import static org.keycloak.crypto.KeyType.RSA; + /** - * {@link WellKnownProvider} implementation to provide the .well-known/openid-credential-issuer endpoint, offering + * {@link WellKnownProvider} implementation to provide the .well-known/openid-credential-issuer endpoint, offering * the Credential Issuer Metadata as defined by the OID4VCI protocol * {@see https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#section-11.2.2} * @@ -56,6 +59,8 @@ public class OID4VCIssuerWellKnownProvider implements WellKnownProvider { protected final KeycloakSession keycloakSession; + public static final String ATTR_ENCRYPTION_REQUIRED = "oid4vci.encryption.required"; + public OID4VCIssuerWellKnownProvider(KeycloakSession keycloakSession) { this.keycloakSession = keycloakSession; } @@ -85,27 +90,6 @@ public class OID4VCIssuerWellKnownProvider implements WellKnownProvider { return getIssuer(context) + "/protocol/" + OID4VCLoginProtocolFactory.PROTOCOL_ID + "/deferred_credential"; } - private CredentialIssuer.CredentialResponseEncryption getCredentialResponseEncryption(KeycloakSession session) { - RealmModel realm = session.getContext().getRealm(); - String algs = realm.getAttribute("credential_response_encryption.alg_values_supported"); - String encs = realm.getAttribute("credential_response_encryption.enc_values_supported"); - String required = realm.getAttribute("credential_response_encryption.encryption_required"); - if (algs != null && encs != null && required != null) { - try { - ObjectMapper mapper = new ObjectMapper(); - return new CredentialIssuer.CredentialResponseEncryption() - .setAlgValuesSupported(mapper.readValue(algs, new TypeReference>() { - })) - .setEncValuesSupported(mapper.readValue(encs, new TypeReference>() { - })) - .setEncryptionRequired(Boolean.parseBoolean(required)); - } catch (Exception e) { - LOGGER.warnf(e, "Failed to parse credential_response_encryption fields from realm attributes."); - } - } - return null; - } - private CredentialIssuer.BatchCredentialIssuance getBatchCredentialIssuance(KeycloakSession session) { RealmModel realm = session.getContext().getRealm(); String batchSize = realm.getAttribute("batch_credential_issuance.batch_size"); @@ -125,9 +109,71 @@ public class OID4VCIssuerWellKnownProvider implements WellKnownProvider { return realm.getAttribute("signed_metadata"); } + /** + * Returns the credential response encryption высоко for the issuer. + * Now determines supported algorithms from available realm keys. + * + * @param session The Keycloak session + * @return The credential response encryption metadata + */ + public static CredentialResponseEncryptionMetadata getCredentialResponseEncryption(KeycloakSession session) { + RealmModel realm = session.getContext().getRealm(); + CredentialResponseEncryptionMetadata metadata = new CredentialResponseEncryptionMetadata(); + + // Get supported algorithms from available encryption keys + metadata.setAlgValuesSupported(getSupportedEncryptionAlgorithms(session)); + metadata.setEncValuesSupported(getSupportedEncryptionMethods()); + metadata.setEncryptionRequired(isEncryptionRequired(realm)); + + return metadata; + } + + /** + * Returns the supported encryption algorithms from realm attributes. + */ + public static List getSupportedEncryptionAlgorithms(KeycloakSession session) { + RealmModel realm = session.getContext().getRealm(); + KeyManager keyManager = session.keys(); + + List supportedEncryptionAlgorithms = keyManager.getKeysStream(realm) + .filter(key -> KeyUse.ENC.equals(key.getUse())) + .map(KeyWrapper::getAlgorithm) + .filter(algorithm -> algorithm != null && !algorithm.isEmpty()) + .distinct() + .collect(Collectors.toList()); + + if (supportedEncryptionAlgorithms.isEmpty()) { + boolean hasRsaKeys = keyManager.getKeysStream(realm) + .filter(key -> KeyUse.ENC.equals(key.getUse())) + .anyMatch(key -> RSA.equals(key.getType())); + + if (hasRsaKeys) { + supportedEncryptionAlgorithms.add(JWEConstants.RSA_OAEP); + supportedEncryptionAlgorithms.add(JWEConstants.RSA_OAEP_256); + } + } + + return supportedEncryptionAlgorithms; + } + + /** + * Returns the supported encryption methods from realm attributes. + */ + private static List getSupportedEncryptionMethods() { + return List.of(JWEConstants.A256GCM); + } + + /** + * Returns whether encryption is required from realm attributes. + */ + private static boolean isEncryptionRequired(RealmModel realm) { + String required = realm.getAttribute(ATTR_ENCRYPTION_REQUIRED); + return Boolean.parseBoolean(required); + } + /** * Return the supported credentials from the current session. - * It will take into account the configured {@link CredentialBuilder}'s and there supported format + * It will take into account the configured {@link CredentialBuilder}'s and their supported format * and the credentials supported by the clients available in the session. */ public static Map getSupportedCredentials(KeycloakSession keycloakSession) { @@ -136,17 +182,16 @@ public class OID4VCIssuerWellKnownProvider implements WellKnownProvider { RealmModel realm = keycloakSession.getContext().getRealm(); Map supportedCredentialConfigurations = keycloakSession.clientScopes() - .getClientScopesByProtocol(realm, Oid4VciConstants.OID4VC_PROTOCOL) - .map(CredentialScopeModel::new) - .map(clientScope -> { - return SupportedCredentialConfiguration.parse(keycloakSession, - clientScope, - globalSupportedSigningAlgorithms - ); - }) - .collect(Collectors.toMap(SupportedCredentialConfiguration::getId, sc -> sc, (sc1, sc2) -> sc1)); + .getClientScopesByProtocol(realm, Oid4VciConstants.OID4VC_PROTOCOL) + .map(CredentialScopeModel::new) + .map(clientScope -> { + return SupportedCredentialConfiguration.parse(keycloakSession, + clientScope, + globalSupportedSigningAlgorithms + ); + }) + .collect(Collectors.toMap(SupportedCredentialConfiguration::getId, sc -> sc, (sc1, sc2) -> sc1)); - // Aggregating attributes. Having realm attributes take preference. return supportedCredentialConfigurations; } @@ -154,8 +199,8 @@ public class OID4VCIssuerWellKnownProvider implements WellKnownProvider { CredentialScopeModel credentialModel) { List globalSupportedSigningAlgorithms = getSupportedSignatureAlgorithms(keycloakSession); return SupportedCredentialConfiguration.parse(keycloakSession, - credentialModel, - globalSupportedSigningAlgorithms); + credentialModel, + globalSupportedSigningAlgorithms); } /** @@ -165,7 +210,6 @@ public class OID4VCIssuerWellKnownProvider implements WellKnownProvider { UriInfo frontendUriInfo = context.getUri(UrlType.FRONTEND); return Urls.realmIssuer(frontendUriInfo.getBaseUri(), context.getRealm().getName()); - } /** diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/model/CredentialIssuer.java b/services/src/main/java/org/keycloak/protocol/oid4vc/model/CredentialIssuer.java index 77ed8a3ff91..b20949816f6 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/model/CredentialIssuer.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/model/CredentialIssuer.java @@ -52,9 +52,6 @@ public class CredentialIssuer { @JsonProperty("notification_endpoint") private String notificationEndpoint; - @JsonProperty("credential_response_encryption") - private CredentialResponseEncryption credentialResponseEncryption; - @JsonProperty("batch_credential_issuance") private BatchCredentialIssuance batchCredentialIssuance; @@ -67,6 +64,9 @@ public class CredentialIssuer { @JsonProperty("display") private List display; + @JsonProperty("credential_response_encryption") + private CredentialResponseEncryptionMetadata credentialResponseEncryption; + public String getCredentialIssuer() { return credentialIssuer; } @@ -121,15 +121,6 @@ public class CredentialIssuer { return this; } - public CredentialResponseEncryption getCredentialResponseEncryption() { - return credentialResponseEncryption; - } - - public CredentialIssuer setCredentialResponseEncryption(CredentialResponseEncryption credentialResponseEncryption) { - this.credentialResponseEncryption = credentialResponseEncryption; - return this; - } - public BatchCredentialIssuance getBatchCredentialIssuance() { return batchCredentialIssuance; } @@ -169,46 +160,13 @@ public class CredentialIssuer { return this; } - /** - * Represents the credential_response_encryption metadata parameter. - */ - @JsonInclude(JsonInclude.Include.NON_NULL) - public static class CredentialResponseEncryption { - @JsonProperty("alg_values_supported") - private List algValuesSupported; + public CredentialResponseEncryptionMetadata getCredentialResponseEncryption() { + return credentialResponseEncryption; + } - @JsonProperty("enc_values_supported") - private List encValuesSupported; - - @JsonProperty("encryption_required") - private Boolean encryptionRequired; - - public List getAlgValuesSupported() { - return algValuesSupported; - } - - public CredentialResponseEncryption setAlgValuesSupported(List algValuesSupported) { - this.algValuesSupported = algValuesSupported; - return this; - } - - public List getEncValuesSupported() { - return encValuesSupported; - } - - public CredentialResponseEncryption setEncValuesSupported(List encValuesSupported) { - this.encValuesSupported = encValuesSupported; - return this; - } - - public Boolean getEncryptionRequired() { - return encryptionRequired; - } - - public CredentialResponseEncryption setEncryptionRequired(Boolean encryptionRequired) { - this.encryptionRequired = encryptionRequired; - return this; - } + public CredentialIssuer setCredentialResponseEncryption(CredentialResponseEncryptionMetadata credentialResponseEncryption) { + this.credentialResponseEncryption = credentialResponseEncryption; + return this; } /** diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/model/CredentialRequest.java b/services/src/main/java/org/keycloak/protocol/oid4vc/model/CredentialRequest.java index fe5e15e602c..6ecb0dcd29c 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/model/CredentialRequest.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/model/CredentialRequest.java @@ -31,7 +31,7 @@ import com.fasterxml.jackson.annotation.JsonSubTypes; import com.fasterxml.jackson.annotation.JsonTypeInfo; /** - * Represents a CredentialRequest according to OID4VCI + * Represents a CredentialRequest according to OID4VCI * {@see https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#name-credential-request} * * @author Stefan Wiedemann @@ -57,6 +57,11 @@ public class CredentialRequest { @JsonProperty("credential_definition") private CredentialDefinition credentialDefinition; + @JsonProperty("credential_response_encryption") + private CredentialResponseEncryption credentialResponseEncryption; + + private String format; + public String getCredentialIdentifier() { return credentialIdentifier; } @@ -93,6 +98,24 @@ public class CredentialRequest { return this; } + public CredentialResponseEncryption getCredentialResponseEncryption() { + return credentialResponseEncryption; + } + + public CredentialRequest setCredentialResponseEncryption(CredentialResponseEncryption credentialResponseEncryption) { + this.credentialResponseEncryption = credentialResponseEncryption; + return this; + } + + public String getFormat() { + return format; + } + + public CredentialRequest setFormat(String format) { + this.format = format; + return this; + } + public Optional findCredentialScope(KeycloakSession keycloakSession) { Map searchAttributeMap = Optional.ofNullable(credentialConfigurationId) @@ -105,9 +128,9 @@ public class CredentialRequest { RealmModel currentRealm = keycloakSession.getContext().getRealm(); final boolean useOrExpression = false; return keycloakSession.clientScopes() - .getClientScopesByAttributes(currentRealm, searchAttributeMap, useOrExpression) - .map(CredentialScopeModel::new) - .findAny(); + .getClientScopesByAttributes(currentRealm, searchAttributeMap, useOrExpression) + .map(CredentialScopeModel::new) + .findAny(); } @Override diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/model/CredentialResponseEncryption.java b/services/src/main/java/org/keycloak/protocol/oid4vc/model/CredentialResponseEncryption.java new file mode 100644 index 00000000000..26c402e390b --- /dev/null +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/model/CredentialResponseEncryption.java @@ -0,0 +1,76 @@ +/* + * 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.protocol.oid4vc.model; + +import com.fasterxml.jackson.annotation.JsonInclude; +import org.keycloak.jose.jwk.JWK; + +/** + * Represents the credential_response_encryption object in a Credential Request. + * {@see https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#name-credential-request} + * + * @author Bertrand Ogen + */ +@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; + + /** + * 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; + } + + public CredentialResponseEncryption setEnc(String enc) { + this.enc = enc; + return this; + } + + public JWK getJwk() { + return jwk; + } + + public CredentialResponseEncryption setJwk(JWK jwk) { + this.jwk = jwk; + return this; + } +} diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/model/CredentialResponseEncryptionMetadata.java b/services/src/main/java/org/keycloak/protocol/oid4vc/model/CredentialResponseEncryptionMetadata.java new file mode 100644 index 00000000000..a5cea605878 --- /dev/null +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/model/CredentialResponseEncryptionMetadata.java @@ -0,0 +1,66 @@ +/* + * 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.protocol.oid4vc.model; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; + +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-15.html#name-credential-issuer-metadata-p} + * + * @author Bertrand Ogen + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +public class CredentialResponseEncryptionMetadata { + + @JsonProperty("alg_values_supported") + private List algValuesSupported; + + @JsonProperty("enc_values_supported") + private List encValuesSupported; + + @JsonProperty("encryption_required") + private Boolean encryptionRequired; + + public List getAlgValuesSupported() { + return algValuesSupported; + } + + public void setAlgValuesSupported(List algValuesSupported) { + this.algValuesSupported = algValuesSupported; + } + + public List getEncValuesSupported() { + return encValuesSupported; + } + + public void setEncValuesSupported(List encValuesSupported) { + this.encValuesSupported = encValuesSupported; + } + + public Boolean getEncryptionRequired() { + return encryptionRequired; + } + + public void setEncryptionRequired(Boolean encryptionRequired) { + this.encryptionRequired = encryptionRequired; + } +} diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/model/ErrorType.java b/services/src/main/java/org/keycloak/protocol/oid4vc/model/ErrorType.java index 5241df30711..6e58e56ce3b 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/model/ErrorType.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/model/ErrorType.java @@ -31,7 +31,7 @@ public enum ErrorType { UNSUPPORTED_CREDENTIAL_TYPE("unsupported_credential_type"), UNSUPPORTED_CREDENTIAL_FORMAT("unsupported_credential_format"), INVALID_PROOF("invalid_proof"), - INVALID_ENCRYPTION_PARAMETER("invalid_encryption_parameters"), + INVALID_ENCRYPTION_PARAMETERS("invalid_encryption_parameters"), MISSING_CREDENTIAL_CONFIG("missing_credential_config"), MISSING_CREDENTIAL_IDENTIFIER_AND_CONFIGURATION_ID("missing_credential_identifier_and_configuration_id"); diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCIssuerEndpointTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCIssuerEndpointTest.java index caacc1c839f..0ab9fac8872 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCIssuerEndpointTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCIssuerEndpointTest.java @@ -38,10 +38,15 @@ import org.keycloak.admin.client.resource.RealmResource; import org.keycloak.admin.client.resource.UserResource; import org.keycloak.common.VerificationException; import org.keycloak.common.crypto.CryptoIntegration; +import org.keycloak.common.util.Base64Url; 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.jose.jwe.JWE; +import org.keycloak.jose.jwe.JWEException; +import org.keycloak.jose.jwk.JWK; +import org.keycloak.jose.jwk.RSAPublicJWK; import org.keycloak.models.AuthenticatedClientSessionModel; import org.keycloak.models.ClientScopeModel; import org.keycloak.models.oid4vci.CredentialScopeModel; @@ -60,7 +65,6 @@ import org.keycloak.protocol.oid4vc.model.DisplayObject; import org.keycloak.protocol.oid4vc.model.Format; import org.keycloak.protocol.oid4vc.model.SupportedCredentialConfiguration; import org.keycloak.protocol.oid4vc.model.VerifiableCredential; -import org.keycloak.protocol.oidc.OIDCLoginProtocol; import org.keycloak.protocol.oidc.utils.OAuth2Code; import org.keycloak.protocol.oidc.utils.OAuth2CodeParser; import org.keycloak.representations.JsonWebToken; @@ -86,6 +90,11 @@ import java.io.IOException; import java.io.InputStream; import java.net.URI; import java.nio.charset.StandardCharsets; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.NoSuchAlgorithmException; +import java.security.PrivateKey; +import java.security.interfaces.RSAPublicKey; import java.util.ArrayList; import java.util.HashMap; import java.util.List; @@ -99,6 +108,8 @@ 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.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.testsuite.forms.PassThroughClientAuthenticator.clientId; @@ -327,6 +338,59 @@ public abstract class OID4VCIssuerEndpointTest extends OID4VCTest { user.logout(); } + public static JWK generateRsaJwk() throws NoSuchAlgorithmException { + KeyPairGenerator keyGen = KeyPairGenerator.getInstance("RSA"); + keyGen.initialize(2048); + KeyPair keyPair = keyGen.generateKeyPair(); + RSAPublicKey publicKey = (RSAPublicKey) keyPair.getPublic(); + + String modulus = Base64Url.encode(publicKey.getModulus().toByteArray()); + String exponent = Base64Url.encode(publicKey.getPublicExponent().toByteArray()); + + RSAPublicJWK jwk = new RSAPublicJWK(); + jwk.setKeyType("RSA"); + jwk.setPublicKeyUse("enc"); + jwk.setAlgorithm("RSA-OAEP"); + jwk.setModulus(modulus); + jwk.setPublicExponent(exponent); + + return jwk; + } + + public static Map generateRsaJwkWithPrivateKey() throws NoSuchAlgorithmException { + KeyPairGenerator keyGen = KeyPairGenerator.getInstance("RSA"); + keyGen.initialize(2048); + KeyPair keyPair = keyGen.generateKeyPair(); + RSAPublicKey publicKey = (RSAPublicKey) keyPair.getPublic(); + PrivateKey privateKey = keyPair.getPrivate(); + + String modulus = Base64Url.encode(publicKey.getModulus().toByteArray()); + String exponent = Base64Url.encode(publicKey.getPublicExponent().toByteArray()); + + RSAPublicJWK jwk = new RSAPublicJWK(); + jwk.setKeyType("RSA"); + jwk.setPublicKeyUse("enc"); + jwk.setAlgorithm("RSA-OAEP"); + jwk.setModulus(modulus); + jwk.setPublicExponent(exponent); + + Map result = new HashMap<>(); + result.put("jwk", jwk); + result.put("privateKey", privateKey); + return result; + } + + protected 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); + + JWE jwe = new JWE(encryptedResponse); + jwe.getKeyStorage().setDecryptionKey(privateKey); + jwe.verifyAndDecodeJwe(); + byte[] decryptedContent = jwe.getContent(); + return JsonSerialization.readValue(decryptedContent, CredentialResponse.class); + } + 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()); @@ -456,6 +520,11 @@ public abstract class OID4VCIssuerEndpointTest extends OID4VCTest { testRealm.getComponents().add("org.keycloak.keys.KeyProvider", getKeyProvider()); + testRealm.getComponents().add("org.keycloak.keys.KeyProvider", + getRsaEncKeyProvider(RSA_OAEP_256, "enc-key-oaep256")); + testRealm.getComponents().add("org.keycloak.keys.KeyProvider", + getRsaEncKeyProvider(RSA_OAEP, "enc-key-oaep")); + // Find existing client representation ClientRepresentation existingClient = testRealm.getClients().stream() .filter(client -> client.getClientId().equals(clientId)) diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCIssuerWellKnownProviderTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCIssuerWellKnownProviderTest.java index bcc4c1f10c6..803c1a9f1ed 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCIssuerWellKnownProviderTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCIssuerWellKnownProviderTest.java @@ -19,12 +19,18 @@ package org.keycloak.testsuite.oid4vc.issuance.signing; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.type.TypeReference; +import jakarta.ws.rs.client.Client; +import jakarta.ws.rs.client.WebTarget; +import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.core.UriBuilder; import org.hamcrest.MatcherAssert; import org.hamcrest.Matchers; import org.junit.Assert; import org.junit.Test; import org.keycloak.common.util.MultivaluedHashMap; import org.keycloak.crypto.Algorithm; +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; @@ -32,11 +38,13 @@ import org.keycloak.protocol.ProtocolMapper; import org.keycloak.protocol.oid4vc.OID4VCLoginProtocolFactory; import org.keycloak.protocol.oid4vc.issuance.OID4VCIssuerEndpoint; import org.keycloak.protocol.oid4vc.issuance.OID4VCIssuerWellKnownProvider; +import org.keycloak.protocol.oid4vc.issuance.OID4VCIssuerWellKnownProviderFactory; import org.keycloak.protocol.oid4vc.issuance.mappers.OID4VCMapper; 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.DisplayObject; import org.keycloak.protocol.oid4vc.model.Format; import org.keycloak.protocol.oid4vc.model.ProofTypesSupported; @@ -45,12 +53,17 @@ import org.keycloak.representations.idm.ClientRepresentation; import org.keycloak.representations.idm.ClientScopeRepresentation; import org.keycloak.representations.idm.ProtocolMapperRepresentation; import org.keycloak.representations.idm.RealmRepresentation; +import org.keycloak.services.resources.RealmsResource; import org.keycloak.testsuite.arquillian.SuiteContext; import org.keycloak.testsuite.client.KeycloakTestingClient; +import org.keycloak.testsuite.util.AdminClientUtil; +import org.keycloak.testsuite.util.oauth.OAuthClient; import org.keycloak.util.JsonSerialization; import org.keycloak.utils.StringUtil; +import java.io.IOException; import java.io.Serializable; +import java.net.URI; import java.util.ArrayList; import java.util.Arrays; import java.util.HashMap; @@ -58,22 +71,38 @@ import java.util.List; import java.util.Map; import java.util.Optional; +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; + + public class OID4VCIssuerWellKnownProviderTest extends OID4VCIssuerEndpointTest { @Override public void configureTestRealm(RealmRepresentation testRealm) { Map attributes = Optional.ofNullable(testRealm.getAttributes()).orElseGet(HashMap::new); - attributes.put("credential_response_encryption.alg_values_supported", "[\"RSA-OAEP\"]"); - attributes.put("credential_response_encryption.enc_values_supported", "[\"A256GCM\"]"); attributes.put("credential_response_encryption.encryption_required", "true"); attributes.put("batch_credential_issuance.batch_size", "10"); attributes.put("signed_metadata", "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJmb28iOiJiYXIifQ.XYZ123abc"); + attributes.put(ATTR_ENCRYPTION_REQUIRED, "true"); testRealm.setAttributes(attributes); + + if (testRealm.getComponents() == null) { + testRealm.setComponents(new MultivaluedHashMap<>()); + } + + testRealm.getComponents().add("org.keycloak.keys.KeyProvider", + getRsaEncKeyProvider(RSA_OAEP_256, "enc-key-oaep256")); + testRealm.getComponents().add("org.keycloak.keys.KeyProvider", + getRsaEncKeyProvider(RSA_OAEP, "enc-key-oaep")); + super.configureTestRealm(testRealm); } + /** - * this test will use the configured scopes {@link #jwtTypeCredentialClientScope} and + * This test uses the configured scopes {@link #jwtTypeCredentialClientScope} and * {@link #sdJwtTypeCredentialClientScope} to verify that the metadata endpoint is presenting the expected data */ @Test @@ -82,50 +111,46 @@ public class OID4VCIssuerWellKnownProviderTest extends OID4VCIssuerEndpointTest Assert.assertEquals(getRealmPath(TEST_REALM_NAME), credentialIssuer.getCredentialIssuer()); Assert.assertEquals(getBasePath(TEST_REALM_NAME) + OID4VCIssuerEndpoint.CREDENTIAL_PATH, - credentialIssuer.getCredentialEndpoint()); + credentialIssuer.getCredentialEndpoint()); Assert.assertNull("Display was not configured", credentialIssuer.getDisplay()); Assert.assertEquals("Authorization Server should have the realm-address.", - 1, - credentialIssuer.getAuthorizationServers().size()); + 1, + credentialIssuer.getAuthorizationServers().size()); Assert.assertEquals("Authorization Server should point to the realm-address.", - getRealmPath(TEST_REALM_NAME), - credentialIssuer.getAuthorizationServers().get(0)); + getRealmPath(TEST_REALM_NAME), + credentialIssuer.getAuthorizationServers().get(0)); // Check credential_response_encryption - CredentialIssuer.CredentialResponseEncryption encryption = credentialIssuer.getCredentialResponseEncryption(); + CredentialResponseEncryptionMetadata encryption = credentialIssuer.getCredentialResponseEncryption(); Assert.assertNotNull("credential_response_encryption should be present", encryption); - Assert.assertEquals(List.of("RSA-OAEP"), encryption.getAlgValuesSupported()); - Assert.assertEquals(List.of("A256GCM"), encryption.getEncValuesSupported()); + Assert.assertEquals(List.of(RSA_OAEP, RSA_OAEP_256), encryption.getAlgValuesSupported()); + Assert.assertEquals(List.of(A256GCM), encryption.getEncValuesSupported()); Assert.assertTrue("encryption_required should be true", encryption.getEncryptionRequired()); - // Check batch_credential_issuance CredentialIssuer.BatchCredentialIssuance batch = credentialIssuer.getBatchCredentialIssuance(); Assert.assertNotNull("batch_credential_issuance should be present", batch); Assert.assertEquals(Integer.valueOf(10), batch.getBatchSize()); - - // Check signed_metadata Assert.assertEquals( "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJmb28iOiJiYXIifQ.XYZ123abc", credentialIssuer.getSignedMetadata() ); for (ClientScopeRepresentation clientScope : List.of(jwtTypeCredentialClientScope, - sdJwtTypeCredentialClientScope, - minimalJwtTypeCredentialClientScope)) { + sdJwtTypeCredentialClientScope, + minimalJwtTypeCredentialClientScope)) { compareMetadataToClientScope(credentialIssuer, clientScope); } } /** - * this test will make sure that the default values are correctly added into the metadata endpoint + * This test will make sure that the default values are correctly added into the metadata endpoint */ @Test - public void testMinimalJwtCredentialHardcodedTest() - { + public void testMinimalJwtCredentialHardcodedTest() { ClientScopeRepresentation clientScope = minimalJwtTypeCredentialClientScope; CredentialIssuer credentialIssuer = getCredentialIssuerMetadata(); SupportedCredentialConfiguration supportedConfig = credentialIssuer.getCredentialsSupported() - .get(clientScope.getName()); + .get(clientScope.getName()); Assert.assertNotNull(supportedConfig); Assert.assertEquals(Format.SD_JWT_VC, supportedConfig.getFormat()); Assert.assertEquals(clientScope.getName(), supportedConfig.getScope()); @@ -139,18 +164,77 @@ public class OID4VCIssuerWellKnownProviderTest extends OID4VCIssuerEndpointTest compareClaims(supportedConfig.getFormat(), supportedConfig.getClaims(), clientScope.getProtocolMappers()); } + + @Test + public void testCredentialIssuerMetadataFields() { + KeycloakTestingClient testingClient = this.testingClient; + + testingClient + .server(TEST_REALM_NAME) + .run(session -> { + CredentialIssuer issuer = getCredentialIssuer(session); + + CredentialResponseEncryptionMetadata encryption = issuer.getCredentialResponseEncryption(); + Assert.assertNotNull(encryption); + + Assert.assertTrue(encryption.getAlgValuesSupported().contains(RSA_OAEP)); + Assert.assertTrue("Supported encryption methods should include A256GCM", encryption.getEncValuesSupported().contains(A256GCM)); + Assert.assertTrue(encryption.getEncryptionRequired()); + Assert.assertEquals(Integer.valueOf(10), issuer.getBatchCredentialIssuance().getBatchSize()); + Assert.assertEquals("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJmb28iOiJiYXIifQ.XYZ123abc", + issuer.getSignedMetadata()); + }); + } + + private static CredentialIssuer getCredentialIssuer(KeycloakSession session) { + RealmModel realm = session.getContext().getRealm(); + + realm.setAttribute(ATTR_ENCRYPTION_REQUIRED, "true"); + realm.setAttribute("batch_credential_issuance.batch_size", "10"); + realm.setAttribute("signed_metadata", "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJmb28iOiJiYXIifQ.XYZ123abc"); + + OID4VCIssuerWellKnownProvider provider = new OID4VCIssuerWellKnownProvider(session); + return (CredentialIssuer) provider.getConfig(); + } + + @Test + public void testIssuerMetadataIncludesEncryptionSupport() throws IOException { + try (Client client = AdminClientUtil.createResteasyClient()) { + UriBuilder builder = UriBuilder.fromUri(OAuthClient.AUTH_SERVER_ROOT); + URI oid4vciDiscoveryUri = RealmsResource.wellKnownProviderUrl(builder) + .build(TEST_REALM_NAME, OID4VCIssuerWellKnownProviderFactory.PROVIDER_ID); + WebTarget oid4vciDiscoveryTarget = client.target(oid4vciDiscoveryUri); + + try (Response discoveryResponse = oid4vciDiscoveryTarget.request().get()) { + CredentialIssuer oid4vciIssuerConfig = JsonSerialization.readValue( + discoveryResponse.readEntity(String.class), CredentialIssuer.class); + + Assert.assertNotNull("Encryption support should be advertised in metadata", + oid4vciIssuerConfig.getCredentialResponseEncryption()); + Assert.assertFalse("Supported algorithms should not be empty", + oid4vciIssuerConfig.getCredentialResponseEncryption().getAlgValuesSupported().isEmpty()); + Assert.assertFalse("Supported encryption methods should not be empty", + oid4vciIssuerConfig.getCredentialResponseEncryption().getEncValuesSupported().isEmpty()); + Assert.assertTrue("Supported algorithms should include RSA-OAEP", + oid4vciIssuerConfig.getCredentialResponseEncryption().getAlgValuesSupported().contains("RSA-OAEP")); + Assert.assertTrue("Supported encryption methods should include A256GCM", + oid4vciIssuerConfig.getCredentialResponseEncryption().getEncValuesSupported().contains("A256GCM")); + } + } + } + private void compareMetadataToClientScope(CredentialIssuer credentialIssuer, ClientScopeRepresentation clientScope) { String credentialConfigurationId = Optional.ofNullable(clientScope.getAttributes() - .get(CredentialScopeModel.CONFIGURATION_ID)) - .orElse(clientScope.getName()); + .get(CredentialScopeModel.CONFIGURATION_ID)) + .orElse(clientScope.getName()); SupportedCredentialConfiguration supportedConfig = credentialIssuer.getCredentialsSupported() - .get(credentialConfigurationId); + .get(credentialConfigurationId); Assert.assertNotNull("Configuration of type '" + credentialConfigurationId + "' must be present", - supportedConfig); + supportedConfig); Assert.assertEquals(credentialConfigurationId, supportedConfig.getId()); String expectedFormat = Optional.ofNullable(clientScope.getAttributes().get(CredentialScopeModel.FORMAT)) - .orElse(Format.SD_JWT_VC); + .orElse(Format.SD_JWT_VC); Assert.assertEquals(expectedFormat, supportedConfig.getFormat()); Assert.assertEquals(clientScope.getName(), supportedConfig.getScope()); @@ -158,36 +242,36 @@ public class OID4VCIssuerWellKnownProviderTest extends OID4VCIssuerEndpointTest // TODO this is still hardcoded Assert.assertEquals(1, supportedConfig.getCryptographicBindingMethodsSupported().size()); Assert.assertEquals(CredentialScopeModel.CRYPTOGRAPHIC_BINDING_METHODS_DEFAULT, - supportedConfig.getCryptographicBindingMethodsSupported().get(0)); + supportedConfig.getCryptographicBindingMethodsSupported().get(0)); } compareDisplay(supportedConfig, clientScope); String expectedVct = Optional.ofNullable(clientScope.getAttributes().get(CredentialScopeModel.VCT)) - .orElse(clientScope.getName()); + .orElse(clientScope.getName()); Assert.assertEquals(expectedVct, supportedConfig.getVct()); Assert.assertNotNull(supportedConfig.getCredentialDefinition()); Assert.assertNotNull(supportedConfig.getCredentialDefinition().getType()); List credentialDefinitionTypes = Optional.ofNullable(clientScope.getAttributes() - .get(CredentialScopeModel.TYPES)) - .map(s -> s.split(",")) - .map(Arrays::asList) - .orElseGet(() -> List.of(clientScope.getName())); + .get(CredentialScopeModel.TYPES)) + .map(s -> s.split(",")) + .map(Arrays::asList) + .orElseGet(() -> List.of(clientScope.getName())); Assert.assertEquals(credentialDefinitionTypes.size(), - supportedConfig.getCredentialDefinition().getType().size()); + supportedConfig.getCredentialDefinition().getType().size()); MatcherAssert.assertThat(supportedConfig.getCredentialDefinition().getContext(), - Matchers.containsInAnyOrder(credentialDefinitionTypes.toArray())); + Matchers.containsInAnyOrder(credentialDefinitionTypes.toArray())); List credentialDefinitionContexts = Optional.ofNullable(clientScope.getAttributes() - .get(CredentialScopeModel.CONTEXTS)) - .map(s -> s.split(",")) - .map(Arrays::asList) - .orElseGet(() -> List.of(clientScope.getName())); + .get(CredentialScopeModel.CONTEXTS)) + .map(s -> s.split(",")) + .map(Arrays::asList) + .orElseGet(() -> List.of(clientScope.getName())); Assert.assertEquals(credentialDefinitionContexts.size(), - supportedConfig.getCredentialDefinition().getContext().size()); + supportedConfig.getCredentialDefinition().getContext().size()); MatcherAssert.assertThat(supportedConfig.getCredentialDefinition().getContext(), - Matchers.containsInAnyOrder(credentialDefinitionTypes.toArray())); + Matchers.containsInAnyOrder(credentialDefinitionTypes.toArray())); List signingAlgsSupported = new ArrayList<>(supportedConfig.getCredentialSigningAlgValuesSupported()); String proofTypesSupportedString = supportedConfig.getProofTypesSupported().toJsonString(); @@ -195,14 +279,13 @@ public class OID4VCIssuerWellKnownProviderTest extends OID4VCIssuerEndpointTest try { withCausePropagation(() -> testingClient.server(TEST_REALM_NAME).run((session -> { ProofTypesSupported expectedProofTypesSupported = ProofTypesSupported.parse(session, - List.of(Algorithm.RS256)); + List.of(Algorithm.RS256)); Assert.assertEquals(expectedProofTypesSupported, - ProofTypesSupported.fromJsonString(proofTypesSupportedString)); + ProofTypesSupported.fromJsonString(proofTypesSupportedString)); List expectedSigningAlgs = OID4VCIssuerWellKnownProvider.getSupportedSignatureAlgorithms(session); MatcherAssert.assertThat(signingAlgsSupported, - Matchers.containsInAnyOrder(expectedSigningAlgs.toArray())); - + Matchers.containsInAnyOrder(expectedSigningAlgs.toArray())); }))); } catch (Throwable e) { throw new RuntimeException(e); @@ -227,12 +310,12 @@ public class OID4VCIssuerWellKnownProviderTest extends OID4VCIssuerEndpointTest Assert.assertEquals(expectedDisplayObjectList.size(), supportedConfig.getDisplay().size()); MatcherAssert.assertThat("Must contain all expected display-objects", - supportedConfig.getDisplay(), - Matchers.containsInAnyOrder(expectedDisplayObjectList.toArray())); + supportedConfig.getDisplay(), + Matchers.containsInAnyOrder(expectedDisplayObjectList.toArray())); } /** - * each claim representation from the metadata is based on a protocol-mapper which we compare here + * Each claim representation from the metadata is based on a protocol-mapper which we compare here */ private void compareClaims(String credentialFormat, Claims originalClaims, @@ -245,7 +328,7 @@ public class OID4VCIssuerWellKnownProviderTest extends OID4VCIssuerEndpointTest withCausePropagation(() -> testingClient.server(TEST_REALM_NAME).run((session -> { Claims actualClaims = fromJsonString(claimsString, Claims.class); List protocolMappers = fromJsonString(protocolMappersString, - new SerializableProtocolMapperReference()); + new SerializableProtocolMapperReference()); // check only protocol-mappers of type oid4vc protocolMappers = protocolMappers.stream().filter(protocolMapper -> { return OID4VCLoginProtocolFactory.PROTOCOL_ID.equals(protocolMapper.getProtocol()); @@ -253,13 +336,13 @@ public class OID4VCIssuerWellKnownProviderTest extends OID4VCIssuerEndpointTest for (ProtocolMapperRepresentation protocolMapper : protocolMappers) { OID4VCMapper mapper = (OID4VCMapper) session.getProvider(ProtocolMapper.class, - protocolMapper.getProtocolMapper()); + protocolMapper.getProtocolMapper()); ProtocolMapperModel protocolMapperModel = new ProtocolMapperModel(); protocolMapperModel.setConfig(protocolMapper.getConfig()); mapper.setMapperModel(protocolMapperModel, credentialFormat); Claim claim = actualClaims.stream() - .filter(c -> c.getPath().equals(mapper.getMetadataAttributePath())) - .findFirst().orElse(null); + .filter(c -> c.getPath().equals(mapper.getMetadataAttributePath())) + .findFirst().orElse(null); if (mapper.includeInMetadata()) { Assert.assertNotNull("There should be a claim matching the protocol-mappers config!", claim); } @@ -269,13 +352,13 @@ public class OID4VCIssuerWellKnownProviderTest extends OID4VCIssuerEndpointTest continue; } Assert.assertEquals(claim.isMandatory(), - Optional.ofNullable(protocolMapper.getConfig() - .get(Oid4vcProtocolMapperModel.MANDATORY)) - .map(Boolean::parseBoolean) - .orElse(false)); + Optional.ofNullable(protocolMapper.getConfig() + .get(Oid4vcProtocolMapperModel.MANDATORY)) + .map(Boolean::parseBoolean) + .orElse(false)); String expectedDisplayString = protocolMapper.getConfig().get(Oid4vcProtocolMapperModel.DISPLAY); List expectedDisplayList = fromJsonString(expectedDisplayString, - new SerializableClaimDisplayReference()); + new SerializableClaimDisplayReference()); List actualDisplayList = claim.getDisplay(); if (expectedDisplayList == null) { Assert.assertNull(actualDisplayList); @@ -283,7 +366,7 @@ public class OID4VCIssuerWellKnownProviderTest extends OID4VCIssuerEndpointTest else { Assert.assertEquals(expectedDisplayList.size(), actualDisplayList.size()); MatcherAssert.assertThat(actualDisplayList, - Matchers.containsInAnyOrder(expectedDisplayList.toArray())); + Matchers.containsInAnyOrder(expectedDisplayList.toArray())); } } }))); @@ -293,14 +376,14 @@ public class OID4VCIssuerWellKnownProviderTest extends OID4VCIssuerEndpointTest } /** - * a jackson type-reference that can be used in the run-server-block + * A jackson type-reference that can be used in the run-server-block */ public static class SerializableProtocolMapperReference extends TypeReference> implements Serializable { } /** - * a jackson type-reference that can be used in the run-server-block + * A jackson type-reference that can be used in the run-server-block */ public static class SerializableClaimDisplayReference extends TypeReference> implements Serializable { @@ -338,7 +421,6 @@ public class OID4VCIssuerWellKnownProviderTest extends OID4VCIssuerEndpointTest testRealm.getComponents().add("org.keycloak.keys.KeyProvider", getRsaKeyProvider(RSA_KEY)); testRealm.getComponents().add("org.keycloak.protocol.oid4vc.issuance.credentialbuilder.CredentialBuilder", getCredentialBuilderProvider(Format.JWT_VC)); - if (testRealm.getClients() != null) { testRealm.getClients().add(clientRepresentation); } else { diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCJWTIssuerEndpointTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCJWTIssuerEndpointTest.java index bfd819a7552..b5d650d093b 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCJWTIssuerEndpointTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCJWTIssuerEndpointTest.java @@ -35,8 +35,12 @@ import org.junit.Test; import org.keycloak.OAuth2Constants; import org.keycloak.TokenVerifier; import org.keycloak.common.VerificationException; -import org.keycloak.models.oid4vci.CredentialScopeModel; 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; import org.keycloak.protocol.oid4vc.model.Claim; @@ -46,7 +50,10 @@ 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; import org.keycloak.protocol.oid4vc.model.Format; import org.keycloak.protocol.oid4vc.model.OfferUriType; import org.keycloak.protocol.oid4vc.model.PreAuthorizedCode; @@ -63,6 +70,8 @@ import org.keycloak.util.JsonSerialization; import java.io.IOException; import java.nio.charset.StandardCharsets; +import java.security.NoSuchAlgorithmException; +import java.security.PrivateKey; import java.util.LinkedList; import java.util.List; import java.util.Map; @@ -95,7 +104,6 @@ public class OID4VCJWTIssuerEndpointTest extends OID4VCIssuerEndpointTest { OID4VCIssuerEndpoint oid4VCIssuerEndpoint = prepareIssuerEndpoint(session, authenticator); oid4VCIssuerEndpoint.getCredentialOfferURI("inexistent-id", OfferUriType.URI, 0, 0); }))); - } @Test(expected = BadRequestException.class) @@ -126,7 +134,7 @@ public class OID4VCJWTIssuerEndpointTest extends OID4VCIssuerEndpointTest { public void testGetCredentialOfferURI() { final String scopeName = jwtTypeCredentialClientScope.getName(); final String credentialConfigurationId = jwtTypeCredentialClientScope.getAttributes() - .get(CredentialScopeModel.CONFIGURATION_ID); + .get(CredentialScopeModel.CONFIGURATION_ID); String token = getBearerToken(oauth, client, scopeName); testingClient.server(TEST_REALM_NAME).run((session) -> { @@ -137,13 +145,13 @@ public class OID4VCJWTIssuerEndpointTest extends OID4VCIssuerEndpointTest { OID4VCIssuerEndpoint oid4VCIssuerEndpoint = prepareIssuerEndpoint(session, authenticator); Response response = oid4VCIssuerEndpoint.getCredentialOfferURI(credentialConfigurationId, - OfferUriType.URI, - 0, - 0); + OfferUriType.URI, + 0, + 0); assertEquals("An offer uri should have been returned.", HttpStatus.SC_OK, response.getStatus()); CredentialOfferURI credentialOfferURI = JsonSerialization.mapper.convertValue(response.getEntity(), - CredentialOfferURI.class); + CredentialOfferURI.class); assertNotNull("A nonce should be included.", credentialOfferURI.getNonce()); assertNotNull("The issuer uri should be provided.", credentialOfferURI.getIssuer()); } catch (Exception e) { @@ -229,7 +237,7 @@ public class OID4VCJWTIssuerEndpointTest extends OID4VCIssuerEndpointTest { .setCredentialConfigurationIds(List.of("credential-configuration-id")); String sessionCode = prepareSessionCode(session, authenticator, JsonSerialization.writeValueAsString(credentialsOffer)); - // the cache transactions need to be commited explicitly in the test. Without that, the OAuth2Code will only be commited to + // The cache transactions need to be committed explicitly in the test. Without that, the OAuth2Code will only be committed to // the cache after .run((session)-> ...) session.getTransactionManager().commit(); OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator); @@ -279,27 +287,27 @@ public class OID4VCJWTIssuerEndpointTest extends OID4VCIssuerEndpointTest { @Test public void testRequestCredentialNoMatchingCredentialBuilder() throws Throwable { final String credentialConfigurationId = jwtTypeCredentialClientScope.getAttributes() - .get(CredentialScopeModel.CONFIGURATION_ID); + .get(CredentialScopeModel.CONFIGURATION_ID); final String scopeName = jwtTypeCredentialClientScope.getName(); String token = getBearerToken(oauth, client, scopeName); try { withCausePropagation(() -> { testingClient.server(TEST_REALM_NAME).run((session -> { - AppAuthManager.BearerTokenAuthenticator authenticator = // + AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session); authenticator.setTokenString(token); // Prepare the issue endpoint with no credential builders. OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator, Map.of()); - CredentialRequest credentialRequest = // + CredentialRequest credentialRequest = new CredentialRequest().setCredentialConfigurationId(credentialConfigurationId); issuerEndpoint.requestCredential(credentialRequest); })); }); Assert.fail("Should have thrown an exception"); - }catch (Exception e) { + } catch (Exception e) { Assert.assertTrue(e instanceof BadRequestException); Assert.assertEquals("No credential builder found for format jwt_vc", e.getMessage()); } @@ -335,28 +343,28 @@ public class OID4VCJWTIssuerEndpointTest extends OID4VCIssuerEndpointTest { .setCredentialIdentifier(scopeName); Response credentialResponse = issuerEndpoint.requestCredential(credentialRequest); assertEquals("The credential request should be answered successfully.", - HttpStatus.SC_OK, - credentialResponse.getStatus()); + HttpStatus.SC_OK, + credentialResponse.getStatus()); assertNotNull("A credential should be responded.", credentialResponse.getEntity()); CredentialResponse credentialResponseVO = JsonSerialization.mapper - .convertValue(credentialResponse.getEntity(), - CredentialResponse.class); + .convertValue(credentialResponse.getEntity(), + CredentialResponse.class); JsonWebToken jsonWebToken = TokenVerifier.create((String) credentialResponseVO.getCredentials().get(0).getCredential(), - JsonWebToken.class).getToken(); + JsonWebToken.class).getToken(); assertNotNull("A valid credential string should have been responded", jsonWebToken); assertNotNull("The credentials should be included at the vc-claim.", - jsonWebToken.getOtherClaims().get("vc")); + jsonWebToken.getOtherClaims().get("vc")); VerifiableCredential credential = JsonSerialization.mapper.convertValue(jsonWebToken.getOtherClaims().get("vc"), - VerifiableCredential.class); + VerifiableCredential.class); assertTrue("The static claim should be set.", - credential.getCredentialSubject().getClaims().containsKey("scope-name")); + credential.getCredentialSubject().getClaims().containsKey("scope-name")); assertEquals("The static claim should be set.", - scopeName, - credential.getCredentialSubject().getClaims().get("scope-name")); + scopeName, + credential.getCredentialSubject().getClaims().get("scope-name")); assertFalse("Only mappers supported for the requested type should have been evaluated.", - credential.getCredentialSubject().getClaims().containsKey("AnotherCredentialType")); + credential.getCredentialSubject().getClaims().containsKey("AnotherCredentialType")); })); } @@ -374,17 +382,304 @@ public class OID4VCJWTIssuerEndpointTest extends OID4VCIssuerEndpointTest { .setCredentialIdentifier(scopeName); Response credentialResponse = issuerEndpoint.requestCredential(credentialRequest); assertEquals("The credential request should be answered successfully.", - HttpStatus.SC_OK, - credentialResponse.getStatus()); + HttpStatus.SC_OK, + credentialResponse.getStatus()); assertNotNull("A credential should be responded.", credentialResponse.getEntity()); CredentialResponse credentialResponseVO = JsonSerialization.mapper - .convertValue(credentialResponse.getEntity(), - CredentialResponse.class); - SdJwtVP sdJwtVP = SdJwtVP.of((String)credentialResponseVO.getCredentials().get(0).getCredential()); + .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 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)); + + 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")); + } + }); + } + + @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 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"); + } + }); + } + // Tests the complete flow from // 1. Retrieving the credential-offer-uri // 2. Using the uri to get the actual credential offer @@ -394,21 +689,20 @@ public class OID4VCJWTIssuerEndpointTest extends OID4VCIssuerEndpointTest { // 6. Get the credential @Test public void testCredentialIssuance() throws Exception { - String token = getBearerToken(oauth, client, jwtTypeCredentialClientScope.getName()); // 1. Retrieving the credential-offer-uri final String credentialConfigurationId = jwtTypeCredentialClientScope.getAttributes() - .get(CredentialScopeModel.CONFIGURATION_ID); + .get(CredentialScopeModel.CONFIGURATION_ID); HttpGet getCredentialOfferURI = new HttpGet(getBasePath(TEST_REALM_NAME) - + "credential-offer-uri?credential_configuration_id=" - + credentialConfigurationId); + + "credential-offer-uri?credential_configuration_id=" + + credentialConfigurationId); getCredentialOfferURI.addHeader(HttpHeaders.AUTHORIZATION, "Bearer " + token); CloseableHttpResponse credentialOfferURIResponse = httpClient.execute(getCredentialOfferURI); assertEquals("A valid offer uri should be returned", - HttpStatus.SC_OK, - credentialOfferURIResponse.getStatusLine().getStatusCode()); + HttpStatus.SC_OK, + credentialOfferURIResponse.getStatusLine().getStatusCode()); String s = IOUtils.toString(credentialOfferURIResponse.getEntity().getContent(), StandardCharsets.UTF_8); CredentialOfferURI credentialOfferURI = JsonSerialization.readValue(s, CredentialOfferURI.class); @@ -456,10 +750,10 @@ public class OID4VCJWTIssuerEndpointTest extends OID4VCIssuerEndpointTest { .forEach(supportedCredential -> { try { requestCredential(theToken, - credentialIssuer.getCredentialEndpoint(), - supportedCredential, - new CredentialResponseHandler(), - jwtTypeCredentialClientScope); + credentialIssuer.getCredentialEndpoint(), + supportedCredential, + new CredentialResponseHandler(), + jwtTypeCredentialClientScope); } catch (IOException e) { fail("Was not able to get the credential."); } catch (VerificationException e) { @@ -479,27 +773,27 @@ public class OID4VCJWTIssuerEndpointTest extends OID4VCIssuerEndpointTest { WebTarget credentialTarget = (WebTarget) m.get("credentialTarget"); CredentialRequest credentialRequest = (CredentialRequest) m.get("credentialRequest"); assertEquals("Credential configuration id should match", - jwtTypeCredentialClientScope.getAttributes().get(CredentialScopeModel.CONFIGURATION_ID), - credentialRequest.getCredentialConfigurationId()); + jwtTypeCredentialClientScope.getAttributes().get(CredentialScopeModel.CONFIGURATION_ID), + credentialRequest.getCredentialConfigurationId()); try (Response response = credentialTarget.request() - .header(HttpHeaders.AUTHORIZATION, "bearer " + accessToken) - .post(Entity.json(credentialRequest))) { + .header(HttpHeaders.AUTHORIZATION, "bearer " + accessToken) + .post(Entity.json(credentialRequest))) { if (response.getStatus() != 200) { String errorBody = response.readEntity(String.class); System.out.println("Error Response: " + errorBody); } assertEquals(200, response.getStatus()); CredentialResponse credentialResponse = JsonSerialization.readValue(response.readEntity(String.class), - CredentialResponse.class); + CredentialResponse.class); JsonWebToken jsonWebToken = TokenVerifier.create((String) credentialResponse.getCredentials().get(0).getCredential(), - JsonWebToken.class).getToken(); + JsonWebToken.class).getToken(); assertEquals(TEST_DID.toString(), jsonWebToken.getIssuer()); VerifiableCredential credential = JsonSerialization.mapper.convertValue(jsonWebToken.getOtherClaims() - .get("vc"), - VerifiableCredential.class); + .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")); @@ -514,7 +808,7 @@ public class OID4VCJWTIssuerEndpointTest extends OID4VCIssuerEndpointTest { @Test public void testCredentialIssuanceWithAuthZCodeWithScopeUnmatched() throws Exception { testCredentialIssuanceWithAuthZCodeFlow(sdJwtTypeCredentialClientScope, (testClientId, testScope) -> - getBearerToken(oauth.clientId(testClientId).openid(false).scope("email")),// set registered different scope + getBearerToken(oauth.clientId(testClientId).openid(false).scope("email")),// set registered different scope m -> { String accessToken = (String) m.get("accessToken"); WebTarget credentialTarget = (WebTarget) m.get("credentialTarget"); @@ -529,7 +823,7 @@ public class OID4VCJWTIssuerEndpointTest extends OID4VCIssuerEndpointTest { @Test public void testCredentialIssuanceWithAuthZCodeSWithoutScope() throws Exception { testCredentialIssuanceWithAuthZCodeFlow(sdJwtTypeCredentialClientScope, - (testClientId, testScope) -> getBearerToken(oauth.clientId(testClientId).openid(false).scope(null)),// no scope + (testClientId, testScope) -> getBearerToken(oauth.clientId(testClientId).openid(false).scope(null)),// no scope m -> { String accessToken = (String) m.get("accessToken"); WebTarget credentialTarget = (WebTarget) m.get("credentialTarget"); @@ -557,13 +851,13 @@ public class OID4VCJWTIssuerEndpointTest extends OID4VCIssuerEndpointTest { CredentialRequest credentialRequest = (CredentialRequest) m.get("credentialRequest"); try (Response response = credentialTarget.request() - .header(HttpHeaders.AUTHORIZATION, "bearer " + accessToken) - .post(Entity.json(credentialRequest))) { + .header(HttpHeaders.AUTHORIZATION, "bearer " + accessToken) + .post(Entity.json(credentialRequest))) { assertEquals(400, response.getStatus()); String errorJson = response.readEntity(String.class); assertNotNull("Error response should not be null", errorJson); assertTrue("Error response should mention UNSUPPORTED_CREDENTIAL_TYPE or scope", - errorJson.contains("UNSUPPORTED_CREDENTIAL_TYPE") || errorJson.contains("scope")); + errorJson.contains("UNSUPPORTED_CREDENTIAL_TYPE") || errorJson.contains("scope")); } }; @@ -606,9 +900,9 @@ public class OID4VCJWTIssuerEndpointTest extends OID4VCIssuerEndpointTest { public void testGetJwtVcConfigFromMetadata() { final String scopeName = jwtTypeCredentialClientScope.getName(); final String credentialConfigurationId = jwtTypeCredentialClientScope.getAttributes() - .get(CredentialScopeModel.CONFIGURATION_ID); + .get(CredentialScopeModel.CONFIGURATION_ID); final String verifiableCredentialType = jwtTypeCredentialClientScope.getAttributes() - .get(CredentialScopeModel.VCT); + .get(CredentialScopeModel.VCT); String expectedIssuer = suiteContext.getAuthServerInfo().getContextRoot().toString() + "/auth/realms/" + TEST_REALM_NAME; String expectedCredentialsEndpoint = expectedIssuer + "/protocol/oid4vc/credential"; String expectedNonceEndpoint = expectedIssuer + "/protocol/oid4vc/" + OID4VCIssuerEndpoint.NONCE_PATH; @@ -623,41 +917,41 @@ public class OID4VCJWTIssuerEndpointTest extends OID4VCIssuerEndpointTest { assertEquals("The correct issuer should be included.", expectedIssuer, credentialIssuer.getCredentialIssuer()); assertEquals("The correct credentials endpoint should be included.", expectedCredentialsEndpoint, credentialIssuer.getCredentialEndpoint()); assertEquals("The correct nonce endpoint should be included.", - expectedNonceEndpoint, - credentialIssuer.getNonceEndpoint()); + expectedNonceEndpoint, + credentialIssuer.getNonceEndpoint()); assertEquals("Since the authorization server is equal to the issuer, just 1 should be returned.", 1, credentialIssuer.getAuthorizationServers().size()); assertEquals("The expected server should have been returned.", expectedAuthorizationServer, credentialIssuer.getAuthorizationServers().get(0)); assertTrue("The jwt_vc-credential should be supported.", - credentialIssuer.getCredentialsSupported() - .containsKey(credentialConfigurationId)); + credentialIssuer.getCredentialsSupported() + .containsKey(credentialConfigurationId)); SupportedCredentialConfiguration jwtVcConfig = credentialIssuer.getCredentialsSupported().get(credentialConfigurationId); assertEquals("The jwt_vc-credential should offer type test-credential", - scopeName, - jwtVcConfig.getScope()); + scopeName, + jwtVcConfig.getScope()); assertEquals("The jwt_vc-credential should be offered in the jwt_vc format.", - Format.JWT_VC, - jwtVcConfig.getFormat()); + Format.JWT_VC, + jwtVcConfig.getFormat()); Claims jwtVcClaims = jwtVcConfig.getClaims(); assertNotNull("The jwt_vc-credential can optionally provide a claims claim.", - jwtVcClaims); + jwtVcClaims); - assertEquals(5, jwtVcClaims.size()); + assertEquals(5, jwtVcClaims.size()); { Claim claim = jwtVcClaims.get(0); assertEquals("The jwt_vc-credential claim credentialSubject.given_name is present.", - Oid4VciConstants.CREDENTIAL_SUBJECT, - claim.getPath().get(0)); + Oid4VciConstants.CREDENTIAL_SUBJECT, + claim.getPath().get(0)); assertEquals("The jwt_vc-credential claim credentialSubject.given_name is present.", - "given_name", - claim.getPath().get(1)); + "given_name", + claim.getPath().get(1)); assertFalse("The jwt_vc-credential claim credentialSubject.given_name is not mandatory.", - claim.isMandatory()); + claim.isMandatory()); assertNotNull("The jwt_vc-credential claim credentialSubject.given_name has display configured", - claim.getDisplay()); + claim.getDisplay()); assertEquals(15, claim.getDisplay().size()); for (ClaimDisplay givenNameDisplay : claim.getDisplay()) { assertNotNull(givenNameDisplay.getName()); @@ -667,15 +961,15 @@ public class OID4VCJWTIssuerEndpointTest extends OID4VCIssuerEndpointTest { { Claim claim = jwtVcClaims.get(1); assertEquals("The jwt_vc-credential claim credentialSubject.family_name is present.", - Oid4VciConstants.CREDENTIAL_SUBJECT, - claim.getPath().get(0)); + Oid4VciConstants.CREDENTIAL_SUBJECT, + claim.getPath().get(0)); assertEquals("The jwt_vc-credential claim credentialSubject.family_name is present.", - "family_name", - claim.getPath().get(1)); + "family_name", + claim.getPath().get(1)); assertFalse("The jwt_vc-credential claim credentialSubject.family_name is not mandatory.", - claim.isMandatory()); + claim.isMandatory()); assertNotNull("The jwt_vc-credential claim credentialSubject.family_name has display configured", - claim.getDisplay()); + claim.getDisplay()); assertEquals(15, claim.getDisplay().size()); for (ClaimDisplay familyNameDisplay : claim.getDisplay()) { assertNotNull(familyNameDisplay.getName()); @@ -685,15 +979,15 @@ public class OID4VCJWTIssuerEndpointTest extends OID4VCIssuerEndpointTest { { Claim claim = jwtVcClaims.get(2); assertEquals("The jwt_vc-credential claim credentialSubject.birthdate is present.", - Oid4VciConstants.CREDENTIAL_SUBJECT, - claim.getPath().get(0)); + Oid4VciConstants.CREDENTIAL_SUBJECT, + claim.getPath().get(0)); assertEquals("The jwt_vc-credential claim credentialSubject.birthdate is present.", - "birthdate", - claim.getPath().get(1)); + "birthdate", + claim.getPath().get(1)); assertFalse("The jwt_vc-credential claim credentialSubject.birthdate is not mandatory.", - claim.isMandatory()); + claim.isMandatory()); assertNotNull("The jwt_vc-credential claim credentialSubject.birthdate has display configured", - claim.getDisplay()); + claim.getDisplay()); assertEquals(15, claim.getDisplay().size()); for (ClaimDisplay birthDateDisplay : claim.getDisplay()) { assertNotNull(birthDateDisplay.getName()); @@ -703,15 +997,15 @@ public class OID4VCJWTIssuerEndpointTest extends OID4VCIssuerEndpointTest { { Claim claim = jwtVcClaims.get(3); assertEquals("The jwt_vc-credential claim credentialSubject.email is present.", - Oid4VciConstants.CREDENTIAL_SUBJECT, - claim.getPath().get(0)); + Oid4VciConstants.CREDENTIAL_SUBJECT, + claim.getPath().get(0)); assertEquals("The jwt_vc-credential claim credentialSubject.email is present.", - "email", - claim.getPath().get(1)); + "email", + claim.getPath().get(1)); assertFalse("The jwt_vc-credential claim credentialSubject.email is not mandatory.", - claim.isMandatory()); + claim.isMandatory()); assertNotNull("The jwt_vc-credential claim credentialSubject.email has display configured", - claim.getDisplay()); + claim.getDisplay()); assertEquals(15, claim.getDisplay().size()); for (ClaimDisplay birthDateDisplay : claim.getDisplay()) { assertNotNull(birthDateDisplay.getName()); @@ -721,38 +1015,38 @@ public class OID4VCJWTIssuerEndpointTest extends OID4VCIssuerEndpointTest { { Claim claim = jwtVcClaims.get(4); assertEquals("The jwt_vc-credential claim credentialSubject.scope-name is present.", - Oid4VciConstants.CREDENTIAL_SUBJECT, - claim.getPath().get(0)); + Oid4VciConstants.CREDENTIAL_SUBJECT, + claim.getPath().get(0)); assertEquals("The jwt_vc-credential claim credentialSubject.scope-name is present.", - "scope-name", - claim.getPath().get(1)); + "scope-name", + claim.getPath().get(1)); assertFalse("The jwt_vc-credential claim credentialSubject.scope-name is not mandatory.", - claim.isMandatory()); + claim.isMandatory()); assertNull("The jwt_vc-credential claim credentialSubject.scope-name has no display configured", - claim.getDisplay()); + claim.getDisplay()); } assertEquals("The jwt_vc-credential should offer vct", - verifiableCredentialType, - jwtVcConfig.getVct()); + verifiableCredentialType, + jwtVcConfig.getVct()); // We are offering key binding only for identity credential assertTrue("The jwt_vc-credential should contain a cryptographic binding method supported named jwk", - jwtVcConfig.getCryptographicBindingMethodsSupported() - .contains(CredentialScopeModel.CRYPTOGRAPHIC_BINDING_METHODS_DEFAULT)); + jwtVcConfig.getCryptographicBindingMethodsSupported() + .contains(CredentialScopeModel.CRYPTOGRAPHIC_BINDING_METHODS_DEFAULT)); assertTrue("The jwt_vc-credential should contain a credential signing algorithm named RS256", - jwtVcConfig.getCredentialSigningAlgValuesSupported().contains("RS256")); + jwtVcConfig.getCredentialSigningAlgValuesSupported().contains("RS256")); assertTrue("The jwt_vc-credential should support a proof of type jwt with signing algorithm RS256", - credentialIssuer.getCredentialsSupported() - .get(credentialConfigurationId) - .getProofTypesSupported() - .getSupportedProofTypes() - .get("jwt") - .getSigningAlgorithmsSupported() - .contains("RS256")); + credentialIssuer.getCredentialsSupported() + .get(credentialConfigurationId) + .getProofTypesSupported() + .getSupportedProofTypes() + .get("jwt") + .getSigningAlgorithmsSupported() + .contains("RS256")); assertEquals("The jwt_vc-credential should display as Test Credential", - credentialConfigurationId, - jwtVcConfig.getDisplay().get(0).getName()); + credentialConfigurationId, + jwtVcConfig.getDisplay().get(0).getName()); })); } } diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCTest.java index 51b23a84af4..c3c6b08a6ae 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCTest.java @@ -177,7 +177,7 @@ public abstract class OID4VCTest extends AbstractTestRealmKeycloakTest { } - public static KeyWrapper getRsaKey() { + public static KeyWrapper getRsaKey(KeyUse keyUse, String algorithm, String keyName) { try { KeyPairGenerator kpg = KeyPairGenerator.getInstance("RSA"); kpg.initialize(2048); @@ -185,16 +185,20 @@ public abstract class OID4VCTest extends AbstractTestRealmKeycloakTest { KeyWrapper kw = new KeyWrapper(); kw.setPrivateKey(keyPair.getPrivate()); kw.setPublicKey(keyPair.getPublic()); - kw.setUse(KeyUse.SIG); - kw.setKid(KeyUtils.createKeyId(keyPair.getPublic())); + kw.setUse(keyUse); + kw.setKid(keyName != null ? keyName : KeyUtils.createKeyId(keyPair.getPublic())); kw.setType("RSA"); - kw.setAlgorithm("RS256"); + kw.setAlgorithm(algorithm); return kw; } catch (NoSuchAlgorithmException e) { throw new RuntimeException(e); } } + public static KeyWrapper getRsaKey() { + return getRsaKey(KeyUse.SIG, "RS256", null); + } + public static ComponentExportRepresentation getRsaKeyProvider(KeyWrapper keyWrapper) { ComponentExportRepresentation componentExportRepresentation = new ComponentExportRepresentation(); componentExportRepresentation.setName("rsa-key-provider"); @@ -211,12 +215,38 @@ public abstract class OID4VCTest extends AbstractTestRealmKeycloakTest { "active", List.of("true"), "priority", List.of("0"), "enabled", List.of("true"), - "algorithm", List.of("RS256") + "algorithm", List.of(keyWrapper.getAlgorithm()), + "keyUse", List.of(keyWrapper.getUse().name()) ) )); return componentExportRepresentation; } + public static ComponentExportRepresentation getRsaEncKeyProvider(String algorithm, String keyName) { + KeyWrapper keyWrapper = getRsaKey(KeyUse.ENC, algorithm, keyName); + ComponentExportRepresentation componentExportRepresentation = new ComponentExportRepresentation(); + componentExportRepresentation.setName(keyName); + componentExportRepresentation.setId(UUID.randomUUID().toString()); + componentExportRepresentation.setProviderId("rsa"); + + Certificate certificate = CertificateUtils.generateV1SelfSignedCertificate( + new KeyPair((PublicKey) keyWrapper.getPublicKey(), (PrivateKey) keyWrapper.getPrivateKey()), "TestKey"); + + componentExportRepresentation.setConfig(new MultivaluedHashMap<>( + Map.of( + "privateKey", List.of(PemUtils.encodeKey(keyWrapper.getPrivateKey())), + "certificate", List.of(PemUtils.encodeCertificate(certificate)), + "active", List.of("true"), + "priority", List.of("100"), + "enabled", List.of("true"), + "algorithm", List.of(algorithm), + "keyUse", List.of(KeyUse.ENC.name()) + ) + )); + return componentExportRepresentation; + } + + protected ClientRepresentation getTestClient(String clientId) { ClientRepresentation clientRepresentation = new ClientRepresentation(); clientRepresentation.setClientId(clientId); @@ -314,7 +344,7 @@ public abstract class OID4VCTest extends AbstractTestRealmKeycloakTest { protocolMapperRepresentation.setProtocolMapper("oid4vc-static-claim-mapper"); protocolMapperRepresentation.setConfig( Map.of("claim.name", "scope-name", - "staticValue", scopeName) + "staticValue", scopeName) ); return protocolMapperRepresentation; } @@ -467,7 +497,7 @@ public abstract class OID4VCTest extends AbstractTestRealmKeycloakTest { public static String getCNonce() { UriBuilder builder = UriBuilder.fromUri(OAuthClient.AUTH_SERVER_ROOT); URI oid4vcUri = RealmsResource.protocolUrl(builder).build(AbstractTestRealmKeycloakTest.TEST_REALM_NAME, - OID4VCLoginProtocolFactory.PROTOCOL_ID); + OID4VCLoginProtocolFactory.PROTOCOL_ID); String nonceUrl = String.format("%s/%s", oid4vcUri.toString(), OID4VCIssuerEndpoint.NONCE_PATH); String nonceResponseString; @@ -476,10 +506,10 @@ public abstract class OID4VCTest extends AbstractTestRealmKeycloakTest { WebTarget nonceTarget = client.target(nonceUrl); // the nonce endpoint must be unprotected, and therefore it must be accessible without any authentication Invocation.Builder nonceInvocationBuilder = nonceTarget.request() - // just making sure that no authentication is added - // by interceptors or similar - .header(HttpHeaders.AUTHORIZATION, null) - .header(HttpHeaders.COOKIE, null); + // just making sure that no authentication is added + // by interceptors or similar + .header(HttpHeaders.AUTHORIZATION, null) + .header(HttpHeaders.COOKIE, null); try (Response response = nonceInvocationBuilder.post(null)) { Assert.assertEquals(HttpStatus.SC_OK, response.getStatus());