From c76676ebef664e47b45bd3f4d9db5b5105f818d6 Mon Sep 17 00:00:00 2001 From: forkimenjeckayang <104195313+forkimenjeckayang@users.noreply.github.com> Date: Wed, 7 Jan 2026 11:06:45 +0100 Subject: [PATCH] [OID4VCI] Make sure events are properly used in OID4VCI endpoints (#44946) Closes: #44679 Signed-off-by: forkimenjeckayang --- .../java/org/keycloak/events/Details.java | 8 + .../java/org/keycloak/events/EventType.java | 7 + .../oid4vc/issuance/OID4VCIssuerEndpoint.java | 172 +++++++++++++++--- .../issuance/signing/NonceEndpointTest.java | 25 +++ .../OID4VCAuthorizationCodeFlowTestBase.java | 57 ++++++ ...ID4VCAuthorizationDetailsFlowTestBase.java | 127 +++++++++++++ .../OID4VCCredentialOfferCorsTest.java | 102 +++++++++++ 7 files changed, 472 insertions(+), 26 deletions(-) diff --git a/server-spi-private/src/main/java/org/keycloak/events/Details.java b/server-spi-private/src/main/java/org/keycloak/events/Details.java index 957b992ce2b..bfaf045aaaf 100755 --- a/server-spi-private/src/main/java/org/keycloak/events/Details.java +++ b/server-spi-private/src/main/java/org/keycloak/events/Details.java @@ -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"; } diff --git a/server-spi-private/src/main/java/org/keycloak/events/EventType.java b/server-spi-private/src/main/java/org/keycloak/events/EventType.java index b068bce1647..e6f67fbedab 100755 --- a/server-spi-private/src/main/java/org/keycloak/events/EventType.java +++ b/server-spi-private/src/main/java/org/keycloak/events/EventType.java @@ -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; diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/OID4VCIssuerEndpoint.java b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/OID4VCIssuerEndpoint.java index fc15917a974..4669aa723ad 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/OID4VCIssuerEndpoint.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/OID4VCIssuerEndpoint.java @@ -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 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 allClaims, SupportedCredentialConfiguration credentialConfig, - UserSessionModel userSession, String scope) { + UserSessionModel userSession, String scope, EventBuilder eventBuilder) { // Protocol mappers from configuration Map, 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 path = claimDescription.getPath(); + List 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); } diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/NonceEndpointTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/NonceEndpointTest.java index 24f4a8f6dca..66713e74fa2 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/NonceEndpointTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/NonceEndpointTest.java @@ -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()); diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCAuthorizationCodeFlowTestBase.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCAuthorizationCodeFlowTestBase.java index ff914672247..37c16ec0b86 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCAuthorizationCodeFlowTestBase.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCAuthorizationCodeFlowTestBase.java @@ -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 diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCAuthorizationDetailsFlowTestBase.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCAuthorizationDetailsFlowTestBase.java index 5630394ed49..b8eb402a08e 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCAuthorizationDetailsFlowTestBase.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCAuthorizationDetailsFlowTestBase.java @@ -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. diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCCredentialOfferCorsTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCCredentialOfferCorsTest.java index 0d4257ea42d..44980f997b2 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCCredentialOfferCorsTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCCredentialOfferCorsTest.java @@ -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 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 {