[OID4VCI] Add support for credential_response_encryption in credential request (#41001)

Closes #39310
Closes #41031


Signed-off-by: Ogenbertrand <ogenbertrand@gmail.com>
This commit is contained in:
Ogen Bertrand
2025-08-04 11:44:41 +01:00
committed by GitHub
parent 3cc8808465
commit db01ff742b
11 changed files with 1116 additions and 292 deletions

View File

@@ -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<String, CredentialBuilder> 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<String> missingParams = new ArrayList<>();
if (encryptionParams.getAlg() == null) missingParams.add("alg");
if (encryptionParams.getEnc() == null) missingParams.add("enc");
if (encryptionParams.getJwk() == null) missingParams.add("jwk");
if (!missingParams.isEmpty()) {
String errorMessage = String.format("Missing required encryption parameters: %s", String.join(", ", missingParams));
LOGGER.debug(errorMessage);
throw new BadRequestException(getErrorResponse(ErrorType.INVALID_ENCRYPTION_PARAMETERS, errorMessage));
}
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)

View File

@@ -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<List<String>>() {
}))
.setEncValuesSupported(mapper.readValue(encs, new TypeReference<List<String>>() {
}))
.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<String> getSupportedEncryptionAlgorithms(KeycloakSession session) {
RealmModel realm = session.getContext().getRealm();
KeyManager keyManager = session.keys();
List<String> 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<String> 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<String, SupportedCredentialConfiguration> getSupportedCredentials(KeycloakSession keycloakSession) {
@@ -136,17 +182,16 @@ public class OID4VCIssuerWellKnownProvider implements WellKnownProvider {
RealmModel realm = keycloakSession.getContext().getRealm();
Map<String, SupportedCredentialConfiguration> 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<String> 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());
}
/**

View File

@@ -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<DisplayObject> 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<String> algValuesSupported;
public CredentialResponseEncryptionMetadata getCredentialResponseEncryption() {
return credentialResponseEncryption;
}
@JsonProperty("enc_values_supported")
private List<String> encValuesSupported;
@JsonProperty("encryption_required")
private Boolean encryptionRequired;
public List<String> getAlgValuesSupported() {
return algValuesSupported;
}
public CredentialResponseEncryption setAlgValuesSupported(List<String> algValuesSupported) {
this.algValuesSupported = algValuesSupported;
return this;
}
public List<String> getEncValuesSupported() {
return encValuesSupported;
}
public CredentialResponseEncryption setEncValuesSupported(List<String> 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;
}
/**

View File

@@ -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 <a href="https://github.com/wistefan">Stefan Wiedemann</a>
@@ -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<CredentialScopeModel> findCredentialScope(KeycloakSession keycloakSession) {
Map<String, String> 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

View File

@@ -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 <a href="mailto:Bertrand.Ogen@adorsys.com">Bertrand Ogen</a>
*/
@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;
}
}

View File

@@ -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 <a href="mailto:Bertrand.Ogen@adorsys.com">Bertrand Ogen</a>
*/
@JsonInclude(JsonInclude.Include.NON_NULL)
public class CredentialResponseEncryptionMetadata {
@JsonProperty("alg_values_supported")
private List<String> algValuesSupported;
@JsonProperty("enc_values_supported")
private List<String> encValuesSupported;
@JsonProperty("encryption_required")
private Boolean encryptionRequired;
public List<String> getAlgValuesSupported() {
return algValuesSupported;
}
public void setAlgValuesSupported(List<String> algValuesSupported) {
this.algValuesSupported = algValuesSupported;
}
public List<String> getEncValuesSupported() {
return encValuesSupported;
}
public void setEncValuesSupported(List<String> encValuesSupported) {
this.encValuesSupported = encValuesSupported;
}
public Boolean getEncryptionRequired() {
return encryptionRequired;
}
public void setEncryptionRequired(Boolean encryptionRequired) {
this.encryptionRequired = encryptionRequired;
}
}

View File

@@ -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");

View File

@@ -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<String, Object> 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<String, Object> 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))

View File

@@ -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<String, String> 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<String> 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<String> 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<String> 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<String> 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<ProtocolMapperRepresentation> 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<ClaimDisplay> expectedDisplayList = fromJsonString(expectedDisplayString,
new SerializableClaimDisplayReference());
new SerializableClaimDisplayReference());
List<ClaimDisplay> 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<List<ProtocolMapperRepresentation>>
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<List<ClaimDisplay>>
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 {

View File

@@ -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<String, Object> jwkPair;
try {
jwkPair = generateRsaJwkWithPrivateKey();
} catch (NoSuchAlgorithmException e) {
throw new RuntimeException("Failed to generate JWK", e);
}
JWK jwk = (JWK) jwkPair.get("jwk");
PrivateKey privateKey = (PrivateKey) jwkPair.get("privateKey");
CredentialRequest credentialRequest = new CredentialRequest()
.setFormat(Format.JWT_VC)
.setCredentialIdentifier(scopeName)
.setCredentialResponseEncryption(
new CredentialResponseEncryption()
.setAlg("RSA-OAEP")
.setEnc("A256GCM")
.setJwk(jwk));
Response credentialResponse = issuerEndpoint.requestCredential(credentialRequest);
assertEquals("The credential request should be answered successfully.",
HttpStatus.SC_OK, credentialResponse.getStatus());
assertEquals("Response should be JWT type for encrypted responses",
org.keycloak.utils.MediaType.APPLICATION_JWT, credentialResponse.getMediaType().toString());
String encryptedResponse = (String) credentialResponse.getEntity();
CredentialResponse decryptedResponse;
try {
decryptedResponse = decryptJweResponse(encryptedResponse, privateKey);
} catch (IOException | JWEException e) {
Assert.fail("Failed to decrypt JWE response: " + e.getMessage());
return;
}
// Verify the decrypted payload
assertNotNull("Decrypted response should contain a credential", decryptedResponse.getCredentials());
JsonWebToken jsonWebToken;
try {
jsonWebToken = TokenVerifier.create((String) decryptedResponse.getCredentials().get(0).getCredential(), JsonWebToken.class).getToken();
} catch (VerificationException e) {
Assert.fail("Failed to verify JWT: " + e.getMessage());
return;
}
assertNotNull("A valid credential string should have been responded", jsonWebToken);
VerifiableCredential credential = JsonSerialization.mapper.convertValue(
jsonWebToken.getOtherClaims().get("vc"), VerifiableCredential.class);
assertTrue("The static claim should be set.", credential.getCredentialSubject().getClaims().containsKey("scope-name"));
}));
}
@Test
public void testRequestCredentialWithIncompleteEncryptionParams() throws Throwable {
String token = getBearerToken(oauth, client, jwtTypeCredentialClientScope.getName());
testingClient.server(TEST_REALM_NAME).run(session -> {
AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session);
authenticator.setTokenString(token);
OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator);
// Missing enc parameter
JWK jwk = JWKParser.create().parse("{\"kty\":\"RSA\",\"n\":\"test-n\",\"e\":\"AQAB\"}").getJwk();
CredentialRequest credentialRequest = new CredentialRequest()
.setFormat(Format.JWT_VC)
.setCredentialIdentifier("test-credential")
.setCredentialResponseEncryption(
new CredentialResponseEncryption()
.setAlg("RSA-OAEP")
.setJwk(jwk));
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<String, Object> jwkPair;
try {
jwkPair = generateRsaJwkWithPrivateKey();
} catch (NoSuchAlgorithmException e) {
throw new RuntimeException("Failed to generate JWK", e);
}
JWK jwk = (JWK) jwkPair.get("jwk");
PrivateKey privateKey = (PrivateKey) jwkPair.get("privateKey");
credentialRequest.setCredentialResponseEncryption(
new CredentialResponseEncryption()
.setAlg("RSA-OAEP")
.setEnc("A256GCM")
.setJwk(jwk));
try (Response response = credentialTarget.request()
.header(HttpHeaders.AUTHORIZATION, "bearer " + accessToken)
.post(Entity.json(credentialRequest))) {
assertEquals(200, response.getStatus());
assertEquals("application/jwt", response.getMediaType().toString());
String encryptedResponse = response.readEntity(String.class);
CredentialResponse decryptedResponse;
try {
decryptedResponse = decryptJweResponse(encryptedResponse, privateKey);
} catch (IOException | JWEException e) {
Assert.fail("Failed to decrypt JWE response: " + e.getMessage());
return;
}
// Verify the decrypted payload
JsonWebToken jsonWebToken;
try {
jsonWebToken = TokenVerifier.create(
(String) decryptedResponse.getCredentials().get(0).getCredential(),
JsonWebToken.class
).getToken();
} catch (VerificationException e) {
Assert.fail("Failed to verify JWT: " + e.getMessage());
return;
}
assertEquals("did:web:test.org", jsonWebToken.getIssuer());
VerifiableCredential credential = JsonSerialization.mapper.convertValue(
jsonWebToken.getOtherClaims().get("vc"),
VerifiableCredential.class
);
assertEquals(List.of(jwtTypeCredentialClientScope.getName()), credential.getType());
assertEquals(TEST_DID, credential.getIssuer());
assertEquals("john@email.cz", credential.getCredentialSubject().getClaims().get("email"));
}
});
}
@Test
public void testRequestCredentialWithUnsupportedAlgorithms() throws Throwable {
String token = getBearerToken(oauth, client, jwtTypeCredentialClientScope.getName());
testingClient.server(TEST_REALM_NAME).run(session -> {
AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session);
authenticator.setTokenString(token);
OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator);
JWK jwk;
try {
jwk = generateRsaJwk();
} catch (NoSuchAlgorithmException e) {
throw new RuntimeException("Failed to generate JWK", e);
}
CredentialRequest credentialRequest = new CredentialRequest()
.setFormat(Format.JWT_VC)
.setCredentialIdentifier("test-credential")
.setCredentialResponseEncryption(
new CredentialResponseEncryption()
.setAlg("UNSUPPORTED-ALG")
.setEnc("A256GCM")
.setJwk(jwk));
try {
issuerEndpoint.requestCredential(credentialRequest);
Assert.fail("Expected BadRequestException due to unsupported algorithm");
} catch (BadRequestException e) {
ErrorResponse error = (ErrorResponse) e.getResponse().getEntity();
assertEquals(ErrorType.INVALID_ENCRYPTION_PARAMETERS, error.getError());
assertTrue(error.getErrorDescription().contains("UNSUPPORTED-ALG"));
}
});
}
@Test
public void testRequestCredentialWithInvalidJWK() throws Throwable {
String token = getBearerToken(oauth, client, jwtTypeCredentialClientScope.getName());
testingClient.server(TEST_REALM_NAME).run(session -> {
AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session);
authenticator.setTokenString(token);
OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator);
// Invalid JWK (missing modulus)
JWK jwk = JWKParser.create().parse("{\"kty\":\"RSA\",\"e\":\"AQAB\"}").getJwk();
CredentialRequest credentialRequest = new CredentialRequest()
.setFormat(Format.JWT_VC)
.setCredentialIdentifier("test-credential")
.setCredentialResponseEncryption(
new CredentialResponseEncryption()
.setAlg("RSA-OAEP")
.setEnc("A256GCM")
.setJwk(jwk));
try {
issuerEndpoint.requestCredential(credentialRequest);
Assert.fail("Expected BadRequestException due to invalid JWK missing modulus");
} catch (BadRequestException e) {
ErrorResponse error = (ErrorResponse) e.getResponse().getEntity();
assertEquals(ErrorType.INVALID_ENCRYPTION_PARAMETERS, error.getError());
assertTrue(error.getErrorDescription().contains("JWK"));
}
});
}
@Test
public void testRequestCredentialWithWrongKeyTypeJWK() throws Throwable {
String token = getBearerToken(oauth, client, jwtTypeCredentialClientScope.getName());
testingClient.server(TEST_REALM_NAME).run(session -> {
AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session);
authenticator.setTokenString(token);
OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator);
JWK jwk = JWKParser.create().parse("{\"kty\":\"EC\",\"crv\":\"P-256\",\"x\":\"test-x\",\"y\":\"test-y\"}").getJwk();
CredentialRequest credentialRequest = new CredentialRequest()
.setFormat(Format.JWT_VC)
.setCredentialIdentifier("test-credential")
.setCredentialResponseEncryption(
new CredentialResponseEncryption()
.setAlg("RSA-OAEP")
.setEnc("A256GCM")
.setJwk(jwk));
try {
issuerEndpoint.requestCredential(credentialRequest);
Assert.fail("Expected BadRequestException due to wrong JWK key type");
} catch (BadRequestException e) {
ErrorResponse error = (ErrorResponse) e.getResponse().getEntity();
assertEquals(ErrorType.INVALID_ENCRYPTION_PARAMETERS, error.getError());
assertTrue(error.getErrorDescription().contains("JWK"));
}
});
}
@Test
public void testRequestCredentialEncryptionRequiredButMissing() {
String token = getBearerToken(oauth, client, jwtTypeCredentialClientScope.getName());
testingClient.server(TEST_REALM_NAME).run(session -> {
RealmModel realm = session.getContext().getRealm();
realm.setAttribute("oid4vci.encryption.required", "true");
realm.setAttribute("oid4vci.encryption.algs", "RSA-OAEP");
realm.setAttribute("oid4vci.encryption.encs", "A256GCM");
try {
AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session);
authenticator.setTokenString(token);
OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator);
CredentialRequest credentialRequest = new CredentialRequest()
.setFormat(Format.JWT_VC)
.setCredentialIdentifier("test-credential");
issuerEndpoint.requestCredential(credentialRequest);
Assert.fail("Expected BadRequestException due to missing encryption parameters when required");
} catch (BadRequestException e) {
ErrorResponse error = (ErrorResponse) e.getResponse().getEntity();
assertEquals(ErrorType.INVALID_ENCRYPTION_PARAMETERS, error.getError());
assertEquals("Encryption is required by the Credential Issuer, but no encryption parameters were provided.", error.getErrorDescription());
} finally {
// Clean up realm attributes
realm.removeAttribute("oid4vci.encryption.required");
realm.removeAttribute("oid4vci.encryption.algs");
realm.removeAttribute("oid4vci.encryption.encs");
}
});
}
// 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());
}));
}
}

View File

@@ -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());