[OID4VCI] Make sure events are properly used in OID4VCI endpoints (#44946)

Closes: #44679


Signed-off-by: forkimenjeckayang <forkimenjeckayang@gmail.com>
This commit is contained in:
forkimenjeckayang 2026-01-07 11:06:45 +01:00 committed by GitHub
parent 695ee725a5
commit c76676ebef
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 472 additions and 26 deletions

View File

@ -119,4 +119,12 @@ public interface Details {
String USER_SESSION_EXPIRED_REASON = "user_session_expired";
String INVALID_USER_SESSION_REMEMBER_ME_REASON = "invalid_user_session_remember_me";
// OID4VCI (OpenID for Verifiable Credential Issuance) related details
String VERIFIABLE_CREDENTIAL_PRE_AUTHORIZED = "verifiable_credential_pre_authorized";
String VERIFIABLE_CREDENTIAL_TARGET_CLIENT_ID = "verifiable_credential_target_client_id";
String VERIFIABLE_CREDENTIAL_TARGET_USER_ID = "verifiable_credential_target_user_id";
String VERIFIABLE_CREDENTIAL_FORMAT = "verifiable_credential_format";
String VERIFIABLE_CREDENTIALS_ISSUED = "verifiable_credentials_issued";
String ERROR_TYPE = "error_type";
}

View File

@ -189,6 +189,13 @@ public enum EventType implements EnumWithStableIndex {
USER_SESSION_DELETED(61, false),
USER_SESSION_DELETED_ERROR(0x10000 + USER_SESSION_DELETED.getStableIndex(), false),
VERIFIABLE_CREDENTIAL_REQUEST(62, true),
VERIFIABLE_CREDENTIAL_REQUEST_ERROR(0x10000 + VERIFIABLE_CREDENTIAL_REQUEST.getStableIndex(), true),
VERIFIABLE_CREDENTIAL_OFFER_REQUEST(63, false),
VERIFIABLE_CREDENTIAL_OFFER_REQUEST_ERROR(0x10000 + VERIFIABLE_CREDENTIAL_OFFER_REQUEST.getStableIndex(), false),
VERIFIABLE_CREDENTIAL_NONCE_REQUEST(64, false),
VERIFIABLE_CREDENTIAL_NONCE_REQUEST_ERROR(0x10000 + VERIFIABLE_CREDENTIAL_NONCE_REQUEST.getStableIndex(), false),
;
private final int stableIndex;

View File

@ -56,6 +56,7 @@ import org.keycloak.crypto.KeyWrapper;
import org.keycloak.events.Details;
import org.keycloak.events.Errors;
import org.keycloak.events.EventBuilder;
import org.keycloak.events.EventType;
import org.keycloak.jose.JOSEHeader;
import org.keycloak.jose.jwe.JWE;
import org.keycloak.jose.jwe.JWEException;
@ -135,7 +136,6 @@ import org.jboss.logging.Logger;
import static org.keycloak.constants.OID4VCIConstants.CREDENTIAL_OFFER_CREATE;
import static org.keycloak.constants.OID4VCIConstants.OID4VC_PROTOCOL;
import static org.keycloak.events.EventType.INTROSPECT_TOKEN_ERROR;
import static org.keycloak.protocol.oid4vc.model.ErrorType.INVALID_CREDENTIAL_OFFER_REQUEST;
import static org.keycloak.protocol.oid4vc.model.ErrorType.INVALID_CREDENTIAL_REQUEST;
import static org.keycloak.protocol.oid4vc.model.ErrorType.UNKNOWN_CREDENTIAL_CONFIGURATION;
@ -286,6 +286,10 @@ public class OID4VCIssuerEndpoint {
@Produces({MediaType.APPLICATION_JSON})
@Path(NONCE_PATH)
public Response getCNonce() {
RealmModel realm = session.getContext().getRealm();
EventBuilder eventBuilder = new EventBuilder(realm, session, session.getContext().getConnection());
eventBuilder.event(EventType.VERIFIABLE_CREDENTIAL_NONCE_REQUEST);
CNonceHandler cNonceHandler = session.getProvider(CNonceHandler.class);
NonceResponse nonceResponse = new NonceResponse();
String sourceEndpoint = OID4VCIssuerWellKnownProvider.getNonceEndpoint(session.getContext());
@ -299,6 +303,8 @@ public class OID4VCIssuerEndpoint {
nonceResponse.setNonce(bodyCNonce);
eventBuilder.success();
Response.ResponseBuilder responseBuilder = Response.ok()
.header(HttpHeaders.CACHE_CONTROL, "no-store")
.entity(nonceResponse);
@ -385,6 +391,14 @@ public class OID4VCIssuerEndpoint {
ClientModel clientModel = clientSession.getClient();
RealmModel realmModel = clientModel.getRealm();
EventBuilder eventBuilder = new EventBuilder(realmModel, session, session.getContext().getConnection());
eventBuilder.event(EventType.VERIFIABLE_CREDENTIAL_OFFER_REQUEST)
.client(clientModel)
.user(userModel)
.session(clientSession.getUserSession().getId())
.detail(Details.USERNAME, userModel.getUsername())
.detail(Details.CREDENTIAL_TYPE, credConfigId);
cors.allowedOrigins(session, clientModel);
checkClientEnabled();
@ -394,6 +408,7 @@ public class OID4VCIssuerEndpoint {
.anyMatch(rm -> rm.getName().equals(CREDENTIAL_OFFER_CREATE.getName()));
if (!hasCredentialOfferRole) {
var errorMessage = "Credential offer creation requires role: " + CREDENTIAL_OFFER_CREATE.getName();
eventBuilder.detail(Details.REASON, errorMessage).error(Errors.NOT_ALLOWED);
throw new CorsErrorResponseException(cors,
INVALID_CREDENTIAL_OFFER_REQUEST.toString(), errorMessage, Response.Status.FORBIDDEN);
}
@ -404,6 +419,7 @@ public class OID4VCIssuerEndpoint {
//
if (appClientId != null && session.clients().getClientByClientId(realmModel, appClientId) == null) {
var errorMessage = "No such client id: " + appClientId;
eventBuilder.detail(Details.REASON, errorMessage).error(Errors.CLIENT_NOT_FOUND);
throw new CorsErrorResponseException(cors,
INVALID_CREDENTIAL_OFFER_REQUEST.toString(), errorMessage, Response.Status.BAD_REQUEST);
}
@ -413,11 +429,13 @@ public class OID4VCIssuerEndpoint {
UserModel user = session.users().getUserByUsername(realmModel, appUsername);
if (user == null) {
var errorMessage = "Not found user with username: " + appUsername;
eventBuilder.detail(Details.REASON, errorMessage).error(Errors.USER_NOT_FOUND);
throw new CorsErrorResponseException(cors,
INVALID_CREDENTIAL_OFFER_REQUEST.toString(), errorMessage, Response.Status.BAD_REQUEST);
}
if (!user.isEnabled()) {
var errorMessage = "User '" + appUsername + "' disabled";
eventBuilder.detail(Details.REASON, errorMessage).error(Errors.USER_DISABLED);
throw new CorsErrorResponseException(cors,
INVALID_CREDENTIAL_OFFER_REQUEST.toString(), errorMessage, Response.Status.BAD_REQUEST);
}
@ -431,6 +449,7 @@ public class OID4VCIssuerEndpoint {
}
if (appUsername == null) {
var errorMessage = "Pre-Authorized credential offer requires a target user";
eventBuilder.detail(Details.REASON, errorMessage).error(Errors.INVALID_REQUEST);
throw new CorsErrorResponseException(cors,
INVALID_CREDENTIAL_OFFER_REQUEST.toString(), errorMessage, Response.Status.BAD_REQUEST);
}
@ -445,6 +464,7 @@ public class OID4VCIssuerEndpoint {
if (!availableInClientScopes.contains(credConfigId)) {
var errorMessage = "Invalid credential configuration id: " + credConfigId;
LOGGER.debugf("%s not found in supported credential config ids: %s", credConfigId, availableInClientScopes);
eventBuilder.detail(Details.REASON, errorMessage).error(Errors.INVALID_REQUEST);
throw new CorsErrorResponseException(cors,
INVALID_CREDENTIAL_OFFER_REQUEST.toString(), errorMessage, Response.Status.BAD_REQUEST);
}
@ -475,6 +495,17 @@ public class OID4VCIssuerEndpoint {
clientSession.setNote(CREDENTIAL_CONFIGURATION_IDS_NOTE, credentialConfigIdsJson);
LOGGER.debugf("Stored credential configuration IDs for token processing: %s", credentialConfigIdsJson);
// Add event details
eventBuilder.detail(Details.VERIFIABLE_CREDENTIAL_PRE_AUTHORIZED, String.valueOf(preAuthorized))
.detail(Details.RESPONSE_TYPE, type.toString());
if (appClientId != null) {
eventBuilder.detail(Details.VERIFIABLE_CREDENTIAL_TARGET_CLIENT_ID, appClientId);
}
if (userId != null) {
eventBuilder.detail(Details.VERIFIABLE_CREDENTIAL_TARGET_USER_ID, userId);
}
eventBuilder.success();
return switch (type) {
case URI -> getOfferUriAsUri(offerState.getNonce());
case QR_CODE -> getOfferUriAsQr(offerState.getNonce(), width, height);
@ -546,6 +577,7 @@ public class OID4VCIssuerEndpoint {
RealmModel realm = session.getContext().getRealm();
EventBuilder eventBuilder = new EventBuilder(realm, session, session.getContext().getConnection());
eventBuilder.event(EventType.VERIFIABLE_CREDENTIAL_OFFER_REQUEST);
// Retrieve the associated credential offer state
//
@ -553,7 +585,7 @@ public class OID4VCIssuerEndpoint {
CredentialOfferState offerState = offerStorage.findOfferStateByNonce(session, nonce);
if (offerState == null) {
var errorMessage = "No credential offer state for nonce: " + nonce;
eventBuilder.event(INTROSPECT_TOKEN_ERROR).detail(Details.REASON, errorMessage).error(Errors.INVALID_REQUEST);
eventBuilder.detail(Details.REASON, errorMessage).error(Errors.INVALID_REQUEST);
throw new BadRequestException(getErrorResponse(INVALID_CREDENTIAL_OFFER_REQUEST, errorMessage));
}
@ -566,12 +598,24 @@ public class OID4VCIssuerEndpoint {
if (offerState.isExpired()) {
var errorMessage = "Credential offer already expired";
eventBuilder.event(INTROSPECT_TOKEN_ERROR).detail(Details.REASON, errorMessage).error(Errors.EXPIRED_CODE);
eventBuilder.detail(Details.REASON, errorMessage).error(Errors.EXPIRED_CODE);
throw new BadRequestException(getErrorResponse(INVALID_CREDENTIAL_OFFER_REQUEST, errorMessage));
}
// Add event details
if (offerState.getClientId() != null) {
eventBuilder.client(offerState.getClientId());
}
if (offerState.getUserId() != null) {
eventBuilder.user(offerState.getUserId());
}
if (credOffer.getCredentialConfigurationIds() != null && !credOffer.getCredentialConfigurationIds().isEmpty()) {
eventBuilder.detail(Details.CREDENTIAL_TYPE, String.join(",", credOffer.getCredentialConfigurationIds()));
}
LOGGER.debugf("Responding with offer: %s", JsonSerialization.valueAsString(credOffer));
eventBuilder.success();
return cors.add(Response.ok().entity(credOffer));
}
@ -612,9 +656,14 @@ public class OID4VCIssuerEndpoint {
public Response requestCredential(String requestPayload) {
LOGGER.debugf("Received credentials request with payload: %s", requestPayload);
RealmModel realm = session.getContext().getRealm();
EventBuilder eventBuilder = new EventBuilder(realm, session, session.getContext().getConnection());
eventBuilder.event(EventType.VERIFIABLE_CREDENTIAL_REQUEST);
if (requestPayload == null || requestPayload.trim().isEmpty()) {
String errorMessage = "Request payload is null or empty.";
LOGGER.debug(errorMessage);
eventBuilder.detail(Details.REASON, errorMessage).error(Errors.INVALID_REQUEST);
throw new BadRequestException(getErrorResponse(INVALID_CREDENTIAL_REQUEST, errorMessage));
}
@ -623,11 +672,26 @@ public class OID4VCIssuerEndpoint {
CredentialIssuer issuerMetadata = (CredentialIssuer) new OID4VCIssuerWellKnownProvider(session).getConfig();
// Validate request encryption
CredentialRequest credentialRequestVO = validateRequestEncryption(requestPayload, issuerMetadata);
CredentialRequest credentialRequestVO;
try {
credentialRequestVO = validateRequestEncryption(requestPayload, issuerMetadata, eventBuilder);
} catch (BadRequestException e) {
// Event tracking already handled in validateRequestEncryption
throw e;
}
// Authenticate first to fail fast on auth errors
AuthenticationManager.AuthResult authResult = getAuthResult();
// Set client and user info in event
ClientModel clientModel = session.getContext().getClient();
UserSessionModel userSession = authResult.session();
UserModel userModel = userSession.getUser();
eventBuilder.client(clientModel)
.user(userModel)
.session(userSession.getId())
.detail(Details.USERNAME, userModel.getUsername());
// Validate encryption parameters if present
CredentialResponseEncryption encryptionParams = credentialRequestVO.getCredentialResponseEncryption();
CredentialResponseEncryptionMetadata encryptionMetadata = OID4VCIssuerWellKnownProvider.getCredentialResponseEncryption(session);
@ -639,6 +703,9 @@ public class OID4VCIssuerEndpoint {
if (isEncryptionRequired && encryptionParams == null) {
String errorMessage = "Response encryption is required by the Credential Issuer, but no encryption parameters were provided.";
LOGGER.debug(errorMessage);
eventBuilder.detail(Details.REASON, errorMessage)
.detail(Details.ERROR_TYPE, ErrorType.INVALID_ENCRYPTION_PARAMETERS.getValue())
.error(Errors.INVALID_REQUEST);
throw new BadRequestException(getErrorResponse(ErrorType.INVALID_ENCRYPTION_PARAMETERS, errorMessage));
}
@ -652,6 +719,9 @@ public class OID4VCIssuerEndpoint {
String errorMessage = String.format("No supported key management algorithm (alg) for provided JWK (kty=%s)",
encryptionParams.getJwk().getKeyType());
LOGGER.debug(errorMessage);
eventBuilder.detail(Details.REASON, errorMessage)
.detail(Details.ERROR_TYPE, ErrorType.INVALID_ENCRYPTION_PARAMETERS.getValue())
.error(Errors.INVALID_REQUEST);
throw new BadRequestException(getErrorResponse(ErrorType.INVALID_ENCRYPTION_PARAMETERS, errorMessage));
}
@ -660,6 +730,9 @@ public class OID4VCIssuerEndpoint {
String errorMessage = String.format("Unsupported content encryption algorithm: enc=%s",
encryptionParams.getEnc());
LOGGER.debug(errorMessage);
eventBuilder.detail(Details.REASON, errorMessage)
.detail(Details.ERROR_TYPE, ErrorType.INVALID_ENCRYPTION_PARAMETERS.getValue())
.error(Errors.INVALID_REQUEST);
throw new BadRequestException(getErrorResponse(ErrorType.INVALID_ENCRYPTION_PARAMETERS, errorMessage));
}
@ -669,6 +742,9 @@ public class OID4VCIssuerEndpoint {
String errorMessage = String.format("Unsupported compression parameter: zip=%s",
encryptionParams.getZip());
LOGGER.debug(errorMessage);
eventBuilder.detail(Details.REASON, errorMessage)
.detail(Details.ERROR_TYPE, ErrorType.INVALID_ENCRYPTION_PARAMETERS.getValue())
.error(Errors.INVALID_REQUEST);
throw new BadRequestException(getErrorResponse(ErrorType.INVALID_ENCRYPTION_PARAMETERS, errorMessage));
}
}
@ -686,7 +762,9 @@ public class OID4VCIssuerEndpoint {
// Check if at least one of both is available.
if (credentialIdentifier == null && credentialConfigurationId == null) {
LOGGER.debugf("Missing both credential_configuration_id and credential_identifier. At least one must be specified.");
String errorMessage = "Missing both credential_configuration_id and credential_identifier. At least one must be specified.";
LOGGER.debugf(errorMessage);
eventBuilder.detail(Details.REASON, errorMessage).error(Errors.INVALID_REQUEST);
throw new BadRequestException(getErrorResponse(ErrorType.MISSING_CREDENTIAL_IDENTIFIER_AND_CONFIGURATION_ID));
}
@ -703,6 +781,7 @@ public class OID4VCIssuerEndpoint {
CredentialOfferState offerState = offerStorage.findOfferStateByCredentialId(session, credentialIdentifier);
if (offerState == null) {
var errorMessage = "No credential offer state for credential id: " + credentialIdentifier;
eventBuilder.detail(Details.REASON, errorMessage).error(Errors.INVALID_REQUEST);
throw new BadRequestException(getErrorResponse(UNKNOWN_CREDENTIAL_IDENTIFIER, errorMessage));
}
@ -712,6 +791,7 @@ public class OID4VCIssuerEndpoint {
String credConfigId = authDetails.getCredentialConfigurationId();
if (credConfigId == null) {
var errorMessage = "No credential_configuration_id in AuthorizationDetails";
eventBuilder.detail(Details.REASON, errorMessage).error(Errors.INVALID_REQUEST);
throw new BadRequestException(getErrorResponse(UNKNOWN_CREDENTIAL_CONFIGURATION, errorMessage));
}
@ -720,25 +800,25 @@ public class OID4VCIssuerEndpoint {
SupportedCredentialConfiguration credConfig = OID4VCIssuerWellKnownProvider.getSupportedCredentials(session).get(credConfigId);
if (credConfig == null) {
var errorMessage = "Mapped credential configuration not found: " + credConfigId;
eventBuilder.detail(Details.REASON, errorMessage).error(Errors.INVALID_REQUEST);
throw new BadRequestException(getErrorResponse(UNKNOWN_CREDENTIAL_CONFIGURATION, errorMessage));
}
// Verify the user login session
//
UserSessionModel userSession = authResult.session();
UserModel userModel = userSession.getUser();
if (!userModel.getId().equals(offerState.getUserId())) {
var errorMessage = "Unexpected login user: " + userModel.getUsername();
LOGGER.errorf(errorMessage + " != %s", offerState.getUserId());
eventBuilder.detail(Details.REASON, errorMessage).error(Errors.INVALID_USER);
throw new BadRequestException(getErrorResponse(ErrorType.INVALID_CREDENTIAL_REQUEST, errorMessage));
}
// Verify the login client
//
ClientModel clientModel = session.getContext().getClient();
if (offerState.getClientId() != null && !clientModel.getClientId().equals(offerState.getClientId())) {
var errorMessage = "Unexpected login client: " + clientModel.getClientId();
LOGGER.errorf(errorMessage + " != %s", offerState.getClientId());
eventBuilder.detail(Details.REASON, errorMessage).error(Errors.INVALID_CLIENT);
throw new BadRequestException(getErrorResponse(ErrorType.INVALID_CREDENTIAL_REQUEST, errorMessage));
}
@ -747,20 +827,26 @@ public class OID4VCIssuerEndpoint {
ClientScopeModel clientScope = clientModel.getClientScopes(false).get(credConfig.getScope());
if (clientScope == null) {
var errorMessage = String.format("Client scope not found: %s", credConfig.getScope());
eventBuilder.detail(Details.REASON, errorMessage).error(Errors.INVALID_REQUEST);
throw new BadRequestException(getErrorResponse(UNKNOWN_CREDENTIAL_CONFIGURATION, errorMessage));
}
requestedCredential = new CredentialScopeModel(clientScope);
LOGGER.debugf("Successfully mapped credential identifier %s to scope %s", credentialIdentifier, clientScope.getName());
eventBuilder.detail(Details.CREDENTIAL_TYPE, credConfigId);
} else if (credentialConfigurationId != null) {
// Use credential_configuration_id for direct lookup
requestedCredential = credentialRequestVO.findCredentialScope(session).orElseThrow(() -> {
var errorMessage = "Credential scope not found for configuration id: " + credentialConfigurationId;
eventBuilder.detail(Details.REASON, errorMessage).error(Errors.INVALID_REQUEST);
return new BadRequestException(getErrorResponse(ErrorType.UNKNOWN_CREDENTIAL_CONFIGURATION, errorMessage));
});
eventBuilder.detail(Details.CREDENTIAL_TYPE, credentialConfigurationId);
} else {
// Neither provided - this should not happen due to earlier validation
String errorMessage = "Missing both credential_configuration_id and credential_identifier";
eventBuilder.detail(Details.REASON, errorMessage).error(Errors.INVALID_REQUEST);
throw new BadRequestException(getErrorResponse(ErrorType.MISSING_CREDENTIAL_IDENTIFIER_AND_CONFIGURATION_ID));
}
@ -777,7 +863,7 @@ public class OID4VCIssuerEndpoint {
if (allProofs.isEmpty()) {
// Single issuance without proof
Object theCredential = getCredential(authResult, supportedCredential, credentialRequestVO);
Object theCredential = getCredential(authResult, supportedCredential, credentialRequestVO, eventBuilder);
responseVO.addCredential(theCredential);
} else {
// Issue credentials for each proof
@ -790,24 +876,34 @@ public class OID4VCIssuerEndpoint {
proofForIteration.setProofByType(proofType, currentProof);
// Creating credential with keybinding to the current proof
credentialRequestVO.setProofs(proofForIteration);
Object theCredential = getCredential(authResult, supportedCredential, credentialRequestVO);
Object theCredential = getCredential(authResult, supportedCredential, credentialRequestVO, eventBuilder);
responseVO.addCredential(theCredential);
}
credentialRequestVO.setProofs(originalProofs);
}
// Encrypt all responses if encryption parameters are provided, except for error credential responses
Response response;
if (encryptionParams != null && !responseVO.getCredentials().isEmpty()) {
String jwe = encryptCredentialResponse(responseVO, encryptionParams, encryptionMetadata);
return Response.ok()
response = Response.ok()
.type(MediaType.APPLICATION_JWT)
.entity(jwe)
.build();
} else {
response = Response.ok().entity(responseVO).build();
}
return Response.ok().entity(responseVO).build();
// Mark event as successful
eventBuilder.detail(Details.SCOPE, supportedCredential.getScope())
.detail(Details.VERIFIABLE_CREDENTIAL_FORMAT, supportedCredential.getFormat())
.detail(Details.VERIFIABLE_CREDENTIALS_ISSUED, String.valueOf(responseVO.getCredentials().size()));
eventBuilder.success();
return response;
}
private CredentialRequest validateRequestEncryption(String requestPayload, CredentialIssuer issuerMetadata) throws BadRequestException {
private CredentialRequest validateRequestEncryption(String requestPayload, CredentialIssuer issuerMetadata, EventBuilder eventBuilder) throws BadRequestException {
CredentialRequestEncryptionMetadata requestEncryptionMetadata = issuerMetadata.getCredentialRequestEncryption();
boolean isRequestEncryptionRequired = Optional.ofNullable(requestEncryptionMetadata)
.map(CredentialRequestEncryptionMetadata::isEncryptionRequired)
@ -825,6 +921,11 @@ public class OID4VCIssuerEndpoint {
if (requestEncryptionMetadata == null && contentTypeIsJwt) {
String errorMessage = "Received JWT content-type request, but credential_request_encryption is not supported.";
LOGGER.debug(errorMessage);
if (eventBuilder != null) {
eventBuilder.detail(Details.REASON, errorMessage)
.detail(Details.ERROR_TYPE, ErrorType.INVALID_ENCRYPTION_PARAMETERS.getValue())
.error(Errors.INVALID_REQUEST);
}
throw new BadRequestException(getErrorResponse(ErrorType.INVALID_ENCRYPTION_PARAMETERS, errorMessage));
}
@ -834,11 +935,21 @@ public class OID4VCIssuerEndpoint {
if (isRequestEncryptionRequired) {
String errorMessage = "Encryption is required but request is not a valid JWE: " + e.getMessage();
LOGGER.debug(errorMessage);
if (eventBuilder != null) {
eventBuilder.detail(Details.REASON, errorMessage)
.detail(Details.ERROR_TYPE, ErrorType.INVALID_ENCRYPTION_PARAMETERS.getValue())
.error(Errors.INVALID_REQUEST);
}
throw new BadRequestException(getErrorResponse(ErrorType.INVALID_ENCRYPTION_PARAMETERS, errorMessage));
}
if (contentTypeIsJwt) {
String errorMessage = "Request has JWT content-type but is not a valid JWE: " + e.getMessage();
LOGGER.debug(errorMessage);
if (eventBuilder != null) {
eventBuilder.detail(Details.REASON, errorMessage)
.detail(Details.ERROR_TYPE, ErrorType.INVALID_ENCRYPTION_PARAMETERS.getValue())
.error(Errors.INVALID_REQUEST);
}
throw new BadRequestException(getErrorResponse(ErrorType.INVALID_ENCRYPTION_PARAMETERS, errorMessage));
}
}
@ -852,6 +963,9 @@ public class OID4VCIssuerEndpoint {
String errorMessage = "Failed to parse JSON request: " + e.getMessage();
LOGGER.errorf(e, "JSON parsing failed. Request payload length: %d",
requestPayload != null ? requestPayload.length() : 0);
if (eventBuilder != null) {
eventBuilder.detail(Details.REASON, errorMessage).error(Errors.INVALID_REQUEST);
}
throw new BadRequestException(getErrorResponse(INVALID_CREDENTIAL_REQUEST, errorMessage));
}
}
@ -1267,11 +1381,13 @@ public class OID4VCIssuerEndpoint {
* @param authResult authResult containing the userSession to create the credential for
* @param credentialConfig the supported credential configuration
* @param credentialRequestVO the credential request
* @param eventBuilder the event builder for logging events
* @return the signed credential
*/
private Object getCredential(AuthenticationManager.AuthResult authResult,
SupportedCredentialConfiguration credentialConfig,
CredentialRequest credentialRequestVO
CredentialRequest credentialRequestVO,
EventBuilder eventBuilder
) {
// Get the client scope model from the credential configuration
@ -1293,7 +1409,7 @@ public class OID4VCIssuerEndpoint {
.filter(Objects::nonNull)
.toList();
VCIssuanceContext vcIssuanceContext = getVCToSign(protocolMappers, credentialConfig, authResult, credentialRequestVO, credentialScopeModel);
VCIssuanceContext vcIssuanceContext = getVCToSign(protocolMappers, credentialConfig, authResult, credentialRequestVO, credentialScopeModel, eventBuilder);
// Enforce key binding prior to signing if necessary
enforceKeyBindingIfProofProvided(vcIssuanceContext);
@ -1348,7 +1464,7 @@ public class OID4VCIssuerEndpoint {
// builds the unsigned credential by applying all protocol mappers.
private VCIssuanceContext getVCToSign(List<OID4VCMapper> protocolMappers, SupportedCredentialConfiguration credentialConfig,
AuthenticationManager.AuthResult authResult, CredentialRequest credentialRequestVO,
CredentialScopeModel credentialScopeModel) {
CredentialScopeModel credentialScopeModel, EventBuilder eventBuilder) {
// Compute issuance date and apply correlation-mitigation according to realm configuration
Instant issuance = Instant.ofEpochMilli(timeProvider.currentTimeMillis());
@ -1376,7 +1492,7 @@ public class OID4VCIssuerEndpoint {
// Validate that requested claims from authorization_details are present
String credentialConfigId = credentialConfig.getId();
validateRequestedClaimsArePresent(subjectClaimsWithMetadataPrefix, credentialConfig, authResult.session(), credentialConfigId);
validateRequestedClaimsArePresent(subjectClaimsWithMetadataPrefix, credentialConfig, authResult.session(), credentialConfigId, eventBuilder);
// Include all available claims
subjectClaims.forEach((key, value) -> vc.getCredentialSubject().setClaims(key, value));
@ -1452,14 +1568,15 @@ public class OID4VCIssuerEndpoint {
/**
* Validates that all requested claims from authorization_details are present in the available claims.
*
* @param allClaims all available claims. These are the claims including metadata prefix with the resolved path
* @param allClaims all available claims. These are the claims including metadata prefix with the resolved path
* @param credentialConfig Credential configuration
* @param userSession the user session
* @param scope the credential scope
* @param userSession the user session
* @param scope the credential scope
* @param eventBuilder the event builder for logging error events
* @throws BadRequestException if mandatory requested claims are missing
*/
private void validateRequestedClaimsArePresent(Map<String, Object> allClaims, SupportedCredentialConfiguration credentialConfig,
UserSessionModel userSession, String scope) {
UserSessionModel userSession, String scope, EventBuilder eventBuilder) {
// Protocol mappers from configuration
Map<List<Object>, ClaimsDescription> claimsConfig = credentialConfig.getCredentialMetadata().getClaims()
.stream()
@ -1473,7 +1590,7 @@ public class OID4VCIssuerEndpoint {
// Merge claims from both protocolMappers and authorizationDetails. If either source specifies "mandatory" as true, claim is considered mandatory
for (ClaimsDescription claimDescription : claimsFromAuthzDetails) {
List<Object> path = claimDescription.getPath();
List<Object> path = claimDescription.getPath();
ClaimsDescription existing = claimsConfig.get(path);
if (existing == null) {
claimsConfig.put(path, claimDescription);
@ -1494,10 +1611,13 @@ public class OID4VCIssuerEndpoint {
LOGGER.debugf("All requested claims are present for scope %s", scope);
} catch (IllegalArgumentException e) {
// If filtering fails, it means some requested claims are missing
String errorMessage = "Credential issuance failed: " + e.getMessage() +
". The requested claims are not available in the user profile.";
LOGGER.warnf("Requested claims validation failed for scope '%s', user '%s', client '%s': %s"
, scope, userSession.getUser().getUsername(), session.getContext().getClient().getClientId(), e.getMessage());
throw new BadRequestException("Credential issuance failed: " + e.getMessage() +
". The requested claims are not available in the user profile.");
// Add error event details with information about which mandatory claim is missing
eventBuilder.detail(Details.REASON, errorMessage).error(Errors.INVALID_REQUEST);
throw new BadRequestException(errorMessage);
}
}
@ -1514,8 +1634,8 @@ public class OID4VCIssuerEndpoint {
try {
// Parse the stored claims from JSON
return JsonSerialization.readValue(storedClaimsJson,
new TypeReference<>() {
});
new TypeReference<>() {
});
} catch (Exception e) {
LOGGER.warnf(e, "Failed to parse stored claims for scope '%s', user '%s', client '%s'", scope, username, clientId);
}

View File

@ -33,6 +33,7 @@ import jakarta.ws.rs.core.UriBuilder;
import org.keycloak.OAuth2Constants;
import org.keycloak.TokenVerifier;
import org.keycloak.crypto.Algorithm;
import org.keycloak.events.EventType;
import org.keycloak.jose.jws.JWSHeader;
import org.keycloak.models.KeycloakContext;
import org.keycloak.protocol.oid4vc.OID4VCLoginProtocolFactory;
@ -44,11 +45,13 @@ import org.keycloak.protocol.oid4vc.model.NonceResponse;
import org.keycloak.representations.JsonWebToken;
import org.keycloak.services.resources.RealmsResource;
import org.keycloak.testsuite.AbstractTestRealmKeycloakTest;
import org.keycloak.testsuite.AssertEvents;
import org.keycloak.testsuite.util.AdminClientUtil;
import org.keycloak.testsuite.util.oauth.OAuthClient;
import org.keycloak.util.JsonSerialization;
import org.junit.Assert;
import org.junit.Rule;
import org.junit.Test;
/**
@ -56,13 +59,26 @@ import org.junit.Test;
*/
public class NonceEndpointTest extends OID4VCIssuerEndpointTest {
@Rule
public AssertEvents events = new AssertEvents(this);
@Test
public void testGetCNonce() throws Exception {
// Clear events before nonce request
events.clear();
URI baseUri = RealmsResource.realmBaseUrl(UriBuilder.fromUri(OAuthClient.AUTH_SERVER_ROOT)).build(
AbstractTestRealmKeycloakTest.TEST_REALM_NAME,
OID4VCLoginProtocolFactory.PROTOCOL_ID);
String cNonce = getCNonce();
// Verify CREDENTIAL_NONCE_REQUEST event was fired (unauthenticated endpoint)
events.expect(EventType.VERIFIABLE_CREDENTIAL_NONCE_REQUEST)
.client((String) null)
.user((String) null)
.session((String) null)
.assertEvent();
URI oid4vcUri;
UriBuilder builder = UriBuilder.fromUri(OAuthClient.AUTH_SERVER_ROOT);
oid4vcUri = RealmsResource.protocolUrl(builder).build(AbstractTestRealmKeycloakTest.TEST_REALM_NAME,
@ -100,6 +116,9 @@ public class NonceEndpointTest extends OID4VCIssuerEndpointTest {
@Test
public void testDPoPNonceHeaderPresent() throws Exception {
// Clear events before nonce request
events.clear();
UriBuilder uriBuilder = UriBuilder.fromUri(OAuthClient.AUTH_SERVER_ROOT);
URI oid4vcBaseUri = RealmsResource.protocolUrl(uriBuilder).build(AbstractTestRealmKeycloakTest.TEST_REALM_NAME,
OID4VCLoginProtocolFactory.PROTOCOL_ID);
@ -111,6 +130,12 @@ public class NonceEndpointTest extends OID4VCIssuerEndpointTest {
Invocation.Builder requestBuilder = target.request(MediaType.APPLICATION_JSON_TYPE);
try (Response response = requestBuilder.post(null)) {
// Verify CREDENTIAL_NONCE_REQUEST event was fired (unauthenticated endpoint)
events.expect(EventType.VERIFIABLE_CREDENTIAL_NONCE_REQUEST)
.client((String) null)
.user((String) null)
.session((String) null)
.assertEvent();
// Verify successful response
Assert.assertEquals("Nonce endpoint should return 200 OK",
Response.Status.OK.getStatusCode(), response.getStatus());

View File

@ -30,6 +30,9 @@ import jakarta.ws.rs.core.HttpHeaders;
import org.keycloak.OAuth2Constants;
import org.keycloak.admin.client.resource.ClientScopeResource;
import org.keycloak.admin.client.resource.UserResource;
import org.keycloak.events.Details;
import org.keycloak.events.Errors;
import org.keycloak.events.EventType;
import org.keycloak.models.oid4vci.CredentialScopeModel;
import org.keycloak.models.oid4vci.Oid4vcProtocolMapperModel;
import org.keycloak.protocol.oid4vc.issuance.OID4VCAuthorizationDetailsResponse;
@ -44,6 +47,7 @@ import org.keycloak.representations.idm.ClientScopeRepresentation;
import org.keycloak.representations.idm.OAuth2ErrorRepresentation;
import org.keycloak.representations.idm.ProtocolMapperRepresentation;
import org.keycloak.representations.idm.UserRepresentation;
import org.keycloak.testsuite.AssertEvents;
import org.keycloak.testsuite.admin.ApiUtil;
import org.keycloak.util.JsonSerialization;
@ -57,6 +61,8 @@ import org.apache.http.client.methods.HttpGet;
import org.apache.http.client.methods.HttpPost;
import org.apache.http.entity.StringEntity;
import org.apache.http.message.BasicNameValuePair;
import org.hamcrest.Matchers;
import org.junit.Rule;
import org.junit.Test;
import static org.keycloak.OAuth2Constants.OPENID_CREDENTIAL;
@ -72,6 +78,9 @@ import static org.junit.Assert.assertNotNull;
*/
public abstract class OID4VCAuthorizationCodeFlowTestBase extends OID4VCIssuerEndpointTest {
@Rule
public AssertEvents events = new AssertEvents(this);
/**
* Test context for OID4VC tests
*/
@ -173,11 +182,23 @@ public abstract class OID4VCAuthorizationCodeFlowTestBase extends OID4VCIssuerEn
String credentialIdentifier = assertTokenResponse(tokenResponse);
String credentialConfigurationId = getCredentialClientScope().getAttributes().get(CredentialScopeModel.CONFIGURATION_ID);
// Clear events before credential request
events.clear();
// Request the actual credential using the identifier
HttpPost postCredential = getCredentialRequest(ctx, credRequestSupplier, tokenResponse, credentialConfigurationId, credentialIdentifier);
try (CloseableHttpResponse credentialResponse = httpClient.execute(postCredential)) {
assertErrorCredentialResponse(credentialResponse);
// Verify VERIFIABLE_CREDENTIAL_REQUEST_ERROR event was fired with details about missing mandatory claim
events.expect(EventType.VERIFIABLE_CREDENTIAL_REQUEST_ERROR)
.client(client.getClientId())
.user(AssertEvents.isUUID())
.session(AssertEvents.isSessionId())
.error(Errors.INVALID_REQUEST)
.detail(Details.REASON, Matchers.containsString("The requested claims are not available in the user profile"))
.assertEvent();
}
// 3 - Update user to add "lastName"
@ -229,11 +250,23 @@ public abstract class OID4VCAuthorizationCodeFlowTestBase extends OID4VCIssuerEn
String credentialIdentifier = assertTokenResponse(tokenResponse);
String credentialConfigurationId = getCredentialClientScope().getAttributes().get(CredentialScopeModel.CONFIGURATION_ID);
// Clear events before credential request
events.clear();
// Request the actual credential using the identifier
HttpPost postCredential = getCredentialRequest(ctx, credRequestSupplier, tokenResponse, credentialConfigurationId, credentialIdentifier);
try (CloseableHttpResponse credentialResponse = httpClient.execute(postCredential)) {
assertErrorCredentialResponse(credentialResponse);
// Verify VERIFIABLE_CREDENTIAL_REQUEST_ERROR event was fired with details about missing mandatory claim
events.expect(EventType.VERIFIABLE_CREDENTIAL_REQUEST_ERROR)
.client(client.getClientId())
.user(AssertEvents.isUUID())
.session(AssertEvents.isSessionId())
.error(Errors.INVALID_REQUEST)
.detail(Details.REASON, Matchers.containsString("The requested claims are not available in the user profile"))
.assertEvent();
}
// 3 - Update user to add "lastName", but keep "firstName" missing. Credential request should still fail
@ -241,8 +274,20 @@ public abstract class OID4VCAuthorizationCodeFlowTestBase extends OID4VCIssuerEn
userRep.setFirstName(null);
user.update(userRep);
// Clear events before credential request
events.clear();
try (CloseableHttpResponse credentialResponse = httpClient.execute(postCredential)) {
assertErrorCredentialResponse(credentialResponse);
// Verify VERIFIABLE_CREDENTIAL_REQUEST_ERROR event was fired
events.expect(EventType.VERIFIABLE_CREDENTIAL_REQUEST_ERROR)
.client(client.getClientId())
.user(AssertEvents.isUUID())
.session(AssertEvents.isSessionId())
.error(Errors.INVALID_REQUEST)
.detail(Details.REASON, Matchers.containsString("The requested claims are not available in the user profile"))
.assertEvent();
}
// 4 - Update user to add "firstName", but missing "lastName"
@ -250,8 +295,20 @@ public abstract class OID4VCAuthorizationCodeFlowTestBase extends OID4VCIssuerEn
userRep.setFirstName("John");
user.update(userRep);
// Clear events before credential request
events.clear();
try (CloseableHttpResponse credentialResponse = httpClient.execute(postCredential)) {
assertErrorCredentialResponse(credentialResponse);
// Verify VERIFIABLE_CREDENTIAL_REQUEST_ERROR event was fired
events.expect(EventType.VERIFIABLE_CREDENTIAL_REQUEST_ERROR)
.client(client.getClientId())
.user(AssertEvents.isUUID())
.session(AssertEvents.isSessionId())
.error(Errors.INVALID_REQUEST)
.detail(Details.REASON, Matchers.containsString("The requested claims are not available in the user profile"))
.assertEvent();
}
// 5 - Update user to both "firstName" and "lastName". Credential request should be successful

View File

@ -30,6 +30,9 @@ import java.util.stream.Collectors;
import jakarta.ws.rs.core.HttpHeaders;
import org.keycloak.OAuth2Constants;
import org.keycloak.events.Details;
import org.keycloak.events.Errors;
import org.keycloak.events.EventType;
import org.keycloak.models.oid4vci.CredentialScopeModel;
import org.keycloak.protocol.oid4vc.issuance.OID4VCAuthorizationDetailsResponse;
import org.keycloak.protocol.oid4vc.model.AuthorizationDetail;
@ -43,6 +46,7 @@ import org.keycloak.protocol.oid4vc.model.PreAuthorizedCode;
import org.keycloak.protocol.oidc.grants.PreAuthorizedCodeGrantTypeFactory;
import org.keycloak.protocol.oidc.representations.OIDCConfigurationRepresentation;
import org.keycloak.representations.idm.ClientScopeRepresentation;
import org.keycloak.testsuite.AssertEvents;
import org.keycloak.util.JsonSerialization;
import com.fasterxml.jackson.core.type.TypeReference;
@ -55,6 +59,7 @@ import org.apache.http.client.methods.HttpGet;
import org.apache.http.client.methods.HttpPost;
import org.apache.http.entity.StringEntity;
import org.apache.http.message.BasicNameValuePair;
import org.junit.Rule;
import org.junit.Test;
import static org.keycloak.OAuth2Constants.OPENID_CREDENTIAL;
@ -73,6 +78,9 @@ import static org.junit.Assert.fail;
*/
public abstract class OID4VCAuthorizationDetailsFlowTestBase extends OID4VCIssuerEndpointTest {
@Rule
public AssertEvents events = new AssertEvents(this);
protected static class Oid4vcTestContext {
CredentialsOffer credentialsOffer;
CredentialIssuer credentialIssuer;
@ -104,6 +112,10 @@ public abstract class OID4VCAuthorizationDetailsFlowTestBase extends OID4VCIssue
Oid4vcTestContext ctx = new Oid4vcTestContext();
String credentialConfigurationId = getCredentialClientScope().getAttributes().get(CredentialScopeModel.CONFIGURATION_ID);
// Clear events before credential offer URI request
events.clear();
HttpGet getCredentialOfferURI = new HttpGet(getCredentialOfferUriUrl(credentialConfigurationId));
getCredentialOfferURI.addHeader(HttpHeaders.AUTHORIZATION, "Bearer " + token);
@ -113,13 +125,33 @@ public abstract class OID4VCAuthorizationDetailsFlowTestBase extends OID4VCIssue
assertEquals(HttpStatus.SC_OK, status);
String s = IOUtils.toString(response.getEntity().getContent(), StandardCharsets.UTF_8);
credentialOfferURI = JsonSerialization.readValue(s, CredentialOfferURI.class);
// Verify CREDENTIAL_OFFER_REQUEST event was fired
events.expect(EventType.VERIFIABLE_CREDENTIAL_OFFER_REQUEST)
.client(client.getClientId())
.user(AssertEvents.isUUID())
.session(AssertEvents.isSessionId())
.detail(Details.USERNAME, "john")
.detail(Details.CREDENTIAL_TYPE, credentialConfigurationId)
.assertEvent();
}
// Clear events before credential offer request
events.clear();
HttpGet getCredentialOffer = new HttpGet(credentialOfferURI.getIssuer() + "/" + credentialOfferURI.getNonce());
try (CloseableHttpResponse response = httpClient.execute(getCredentialOffer)) {
assertEquals(HttpStatus.SC_OK, response.getStatusLine().getStatusCode());
String s = IOUtils.toString(response.getEntity().getContent(), StandardCharsets.UTF_8);
ctx.credentialsOffer = JsonSerialization.readValue(s, CredentialsOffer.class);
// Verify CREDENTIAL_OFFER_REQUEST event was fired (unauthenticated endpoint)
events.expect(EventType.VERIFIABLE_CREDENTIAL_OFFER_REQUEST)
.client(client.getClientId())
.user(AssertEvents.isUUID())
.session((String) null)
.detail(Details.CREDENTIAL_TYPE, credentialConfigurationId)
.assertEvent();
}
HttpGet getIssuerMetadata = new HttpGet(ctx.credentialsOffer.getIssuerMetadataUrl());
@ -550,6 +582,9 @@ public abstract class OID4VCAuthorizationDetailsFlowTestBase extends OID4VCIssue
// * It does not assume the caller already has an authenticated session.
// * It must guarantee isolation of state tied to the VC issuance flow.
{
// Clear events before credential request
events.clear();
HttpPost postCredential = new HttpPost(ctx.credentialIssuer.getCredentialEndpoint());
postCredential.addHeader(HttpHeaders.AUTHORIZATION, "Bearer " + token);
postCredential.addHeader(HttpHeaders.CONTENT_TYPE, "application/json");
@ -562,6 +597,15 @@ public abstract class OID4VCAuthorizationDetailsFlowTestBase extends OID4VCIssue
try (CloseableHttpResponse credentialResponse = httpClient.execute(postCredential)) {
assertEquals(HttpStatus.SC_OK, credentialResponse.getStatusLine().getStatusCode());
// Verify CREDENTIAL_REQUEST event was fired
events.expect(EventType.VERIFIABLE_CREDENTIAL_REQUEST)
.client(client.getClientId())
.user(AssertEvents.isUUID())
.session(AssertEvents.isSessionId())
.detail(Details.USERNAME, "john")
.detail(Details.CREDENTIAL_TYPE, credentialConfigurationId)
.assertEvent();
String responseBody = IOUtils.toString(credentialResponse.getEntity().getContent(), StandardCharsets.UTF_8);
// Parse the credential response
@ -586,6 +630,9 @@ public abstract class OID4VCAuthorizationDetailsFlowTestBase extends OID4VCIssue
// Step 3: Request a credential using the credentialConfigurationId
//
{
// Clear events before credential request
events.clear();
HttpPost postCredential = new HttpPost(ctx.credentialIssuer.getCredentialEndpoint());
postCredential.addHeader(HttpHeaders.AUTHORIZATION, "Bearer " + token);
postCredential.addHeader(HttpHeaders.CONTENT_TYPE, "application/json");
@ -598,6 +645,16 @@ public abstract class OID4VCAuthorizationDetailsFlowTestBase extends OID4VCIssue
try (CloseableHttpResponse credentialResponse = httpClient.execute(postCredential)) {
assertEquals(HttpStatus.SC_OK, credentialResponse.getStatusLine().getStatusCode());
// Verify CREDENTIAL_REQUEST event was fired
events.expect(EventType.VERIFIABLE_CREDENTIAL_REQUEST)
.client(client.getClientId())
.user(AssertEvents.isUUID())
.session(AssertEvents.isSessionId())
.detail(Details.USERNAME, "john")
.detail(Details.CREDENTIAL_TYPE, credentialConfigurationId)
.assertEvent();
assertEquals(HttpStatus.SC_OK, credentialResponse.getStatusLine().getStatusCode());
String responseBody = IOUtils.toString(credentialResponse.getEntity().getContent(), StandardCharsets.UTF_8);
// Parse the credential response
@ -742,6 +799,9 @@ public abstract class OID4VCAuthorizationDetailsFlowTestBase extends OID4VCIssue
}
// Step 2: Request the actual credential using the identifier and config id
// Clear events before credential request
events.clear();
HttpPost postCredential = new HttpPost(ctx.credentialIssuer.getCredentialEndpoint());
postCredential.addHeader(HttpHeaders.AUTHORIZATION, "Bearer " + token);
postCredential.addHeader(HttpHeaders.CONTENT_TYPE, "application/json");
@ -754,6 +814,15 @@ public abstract class OID4VCAuthorizationDetailsFlowTestBase extends OID4VCIssue
try (CloseableHttpResponse credentialResponse = httpClient.execute(postCredential)) {
assertEquals(HttpStatus.SC_OK, credentialResponse.getStatusLine().getStatusCode());
// Verify CREDENTIAL_REQUEST event was fired
events.expect(EventType.VERIFIABLE_CREDENTIAL_REQUEST)
.client(client.getClientId())
.user(AssertEvents.isUUID())
.session(AssertEvents.isSessionId())
.detail(Details.USERNAME, "john")
.detail(Details.CREDENTIAL_TYPE, credentialConfigurationId)
.assertEvent();
String responseBody = IOUtils.toString(credentialResponse.getEntity().getContent(), StandardCharsets.UTF_8);
// Parse the credential response
@ -775,6 +844,64 @@ public abstract class OID4VCAuthorizationDetailsFlowTestBase extends OID4VCIssue
}
}
@Test
public void testCredentialRequestWithEmptyPayload() throws Exception {
String token = getBearerToken(oauth, client, getCredentialClientScope().getName());
Oid4vcTestContext ctx = prepareOid4vcTestContext(token);
events.clear();
// Request credential with empty payload
HttpPost postCredential = new HttpPost(ctx.credentialIssuer.getCredentialEndpoint());
postCredential.addHeader(HttpHeaders.AUTHORIZATION, "Bearer " + token);
postCredential.addHeader(HttpHeaders.CONTENT_TYPE, "application/json");
postCredential.setEntity(new StringEntity("", StandardCharsets.UTF_8));
try (CloseableHttpResponse credentialResponse = httpClient.execute(postCredential)) {
assertEquals(HttpStatus.SC_BAD_REQUEST, credentialResponse.getStatusLine().getStatusCode());
// Verify VERIFIABLE_CREDENTIAL_REQUEST_ERROR event was fired
// Note: When payload is empty, error is thrown before authentication, so user/session are null
events.expect(EventType.VERIFIABLE_CREDENTIAL_REQUEST_ERROR)
.client((String) null)
.user((String) null)
.session((String) null)
.error(Errors.INVALID_REQUEST)
.detail(Details.REASON, "Request payload is null or empty.")
.assertEvent();
}
}
@Test
public void testCredentialRequestWithInvalidCredentialIdentifier() throws Exception {
String token = getBearerToken(oauth, client, getCredentialClientScope().getName());
Oid4vcTestContext ctx = prepareOid4vcTestContext(token);
events.clear();
// Request credential with invalid credential identifier
HttpPost postCredential = new HttpPost(ctx.credentialIssuer.getCredentialEndpoint());
postCredential.addHeader(HttpHeaders.AUTHORIZATION, "Bearer " + token);
postCredential.addHeader(HttpHeaders.CONTENT_TYPE, "application/json");
CredentialRequest credentialRequest = new CredentialRequest();
credentialRequest.setCredentialIdentifier("invalid-credential-identifier");
String requestBody = JsonSerialization.writeValueAsString(credentialRequest);
postCredential.setEntity(new StringEntity(requestBody, StandardCharsets.UTF_8));
try (CloseableHttpResponse credentialResponse = httpClient.execute(postCredential)) {
assertEquals(HttpStatus.SC_BAD_REQUEST, credentialResponse.getStatusLine().getStatusCode());
// Verify VERIFIABLE_CREDENTIAL_REQUEST_ERROR event was fired
events.expect(EventType.VERIFIABLE_CREDENTIAL_REQUEST_ERROR)
.client(client.getClientId())
.user(AssertEvents.isUUID())
.session(AssertEvents.isSessionId())
.error(Errors.INVALID_REQUEST)
.assertEvent();
}
}
/**
* Verify the credential structure based on the format.

View File

@ -19,14 +19,24 @@ package org.keycloak.testsuite.oid4vc.issuance.signing;
import java.io.IOException;
import java.util.Arrays;
import java.util.List;
import java.util.Set;
import java.util.concurrent.atomic.AtomicReference;
import java.util.stream.Collectors;
import jakarta.ws.rs.core.HttpHeaders;
import org.keycloak.common.Profile;
import org.keycloak.common.util.Time;
import org.keycloak.events.Details;
import org.keycloak.events.Errors;
import org.keycloak.events.EventType;
import org.keycloak.protocol.oid4vc.issuance.credentialoffer.CredentialOfferStorage;
import org.keycloak.protocol.oid4vc.issuance.credentialoffer.CredentialOfferStorage.CredentialOfferState;
import org.keycloak.protocol.oid4vc.model.CredentialOfferURI;
import org.keycloak.protocol.oid4vc.model.CredentialsOffer;
import org.keycloak.protocol.oid4vc.model.PreAuthorizedCode;
import org.keycloak.protocol.oid4vc.model.PreAuthorizedGrant;
import org.keycloak.representations.idm.RealmRepresentation;
import org.keycloak.services.cors.Cors;
import org.keycloak.testsuite.AssertEvents;
@ -40,6 +50,7 @@ import org.apache.http.HttpStatus;
import org.apache.http.client.methods.CloseableHttpResponse;
import org.apache.http.client.methods.HttpGet;
import org.apache.http.client.methods.HttpOptions;
import org.hamcrest.Matchers;
import org.junit.Rule;
import org.junit.Test;
@ -89,6 +100,8 @@ public class OID4VCCredentialOfferCorsTest extends OID4VCIssuerEndpointTest {
public void testCredentialOfferUriCorsValidOrigin() throws Exception {
AccessTokenResponse tokenResponse = getAccessToken();
// Clear events from token retrieval
events.clear();
// Test credential offer URI endpoint with valid origin
String offerUriUrl = getCredentialOfferUriUrl();
@ -102,6 +115,14 @@ public class OID4VCCredentialOfferCorsTest extends OID4VCIssuerEndpointTest {
CredentialOfferURI offerUri = JsonSerialization.readValue(responseBody, CredentialOfferURI.class);
assertNotNull("Credential offer URI should not be null", offerUri.getIssuer());
assertNotNull("Nonce should not be null", offerUri.getNonce());
// Verify CREDENTIAL_OFFER_REQUEST event was fired
events.expect(EventType.VERIFIABLE_CREDENTIAL_OFFER_REQUEST)
.client(clientId)
.user(AssertEvents.isUUID())
.session(AssertEvents.isSessionId())
.detail(Details.USERNAME, "john")
.assertEvent();
}
}
@ -149,6 +170,9 @@ public class OID4VCCredentialOfferCorsTest extends OID4VCIssuerEndpointTest {
AccessTokenResponse tokenResponse = getAccessToken();
String nonce = getNonceFromOfferUri(tokenResponse.getAccessToken());
// Clear events before credential offer request
events.clear();
// Test credential offer endpoint with valid origin
String offerUrl = getCredentialOfferUrl(nonce);
@ -161,6 +185,17 @@ public class OID4VCCredentialOfferCorsTest extends OID4VCIssuerEndpointTest {
CredentialsOffer offer = JsonSerialization.readValue(responseBody, CredentialsOffer.class);
assertNotNull("Credential offer should not be null", offer.getCredentialIssuer());
assertNotNull("Credential configuration IDs should not be null", offer.getCredentialConfigurationIds());
// The credential_type detail contains the credential configuration ID from the offer
// We already assert that credentialConfigurationIds is not null and not empty above
String expectedCredentialType = offer.getCredentialConfigurationIds().get(0);
events.expect(EventType.VERIFIABLE_CREDENTIAL_OFFER_REQUEST)
.client(clientId)
.user(AssertEvents.isUUID())
.session((String) null) // No session for unauthenticated endpoint
.detail(Details.CREDENTIAL_TYPE, expectedCredentialType)
.assertEvent();
}
}
@ -245,6 +280,73 @@ public class OID4VCCredentialOfferCorsTest extends OID4VCIssuerEndpointTest {
}
}
@Test
public void testCredentialOfferUriWithInvalidCredentialConfig() throws Exception {
AccessTokenResponse tokenResponse = getAccessToken();
events.clear();
// Test credential offer URI endpoint with invalid credential configuration ID
String offerUriUrl = getCredentialOfferUriUrl("invalid-credential-config-id");
try (CloseableHttpResponse response = makeCorsRequest(offerUriUrl, VALID_CORS_URL, tokenResponse.getAccessToken())) {
// Should return 400 Bad Request
assertEquals(HttpStatus.SC_BAD_REQUEST, response.getStatusLine().getStatusCode());
// Verify VERIFIABLE_CREDENTIAL_OFFER_REQUEST_ERROR event was fired
events.expect(EventType.VERIFIABLE_CREDENTIAL_OFFER_REQUEST_ERROR)
.client(clientId)
.user(AssertEvents.isUUID())
.session(AssertEvents.isSessionId())
.error(Errors.INVALID_REQUEST)
.assertEvent();
}
}
@Test
public void testCredentialOfferWithExpiredNonce() throws Exception {
events.clear();
// Create an expired credential offer using testing client
// Use AtomicReference to avoid serialization issues with lambda captures
AtomicReference<String> nonceHolder = new AtomicReference<>();
final String issuerPath = getRealmPath(TEST_REALM_NAME);
testingClient.server(TEST_REALM_NAME).run(session -> {
CredentialsOffer credOffer = new CredentialsOffer()
.setCredentialIssuer(issuerPath)
.setGrants(new PreAuthorizedGrant().setPreAuthorizedCode(new PreAuthorizedCode().setPreAuthorizedCode("test-code")))
.setCredentialConfigurationIds(List.of(jwtTypeCredentialConfigurationIdName));
CredentialOfferStorage offerStorage = session.getProvider(CredentialOfferStorage.class);
// Create offer with expiration time just 1 second in the past
// This ensures it's still findable in storage but marked as expired
CredentialOfferState offerState = new CredentialOfferState(credOffer, null, null, Time.currentTime() - 1);
offerStorage.putOfferState(session, offerState);
session.getTransactionManager().commit();
nonceHolder.set(offerState.getNonce());
});
String nonce = nonceHolder.get();
events.clear();
// Try to fetch the expired credential offer
String offerUrl = getCredentialOfferUrl(nonce);
try (CloseableHttpResponse response = makeCorsRequest(offerUrl, VALID_CORS_URL, null)) {
// Should return 400 Bad Request
assertEquals(HttpStatus.SC_BAD_REQUEST, response.getStatusLine().getStatusCode());
// Verify VERIFIABLE_CREDENTIAL_OFFER_REQUEST_ERROR event was fired
events.expect(EventType.VERIFIABLE_CREDENTIAL_OFFER_REQUEST_ERROR)
.client((String) null)
.user((String) null)
.session((String) null)
// Storage prunes expired single-use entries before lookup; lookup failure yields INVALID_REQUEST
.error(Errors.INVALID_REQUEST)
.detail(Details.REASON, Matchers.containsString("No credential offer state"))
.assertEvent();
}
}
// Helper methods
private AccessTokenResponse getAccessToken() throws Exception {