[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 USER_SESSION_EXPIRED_REASON = "user_session_expired";
String INVALID_USER_SESSION_REMEMBER_ME_REASON = "invalid_user_session_remember_me"; 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(61, false),
USER_SESSION_DELETED_ERROR(0x10000 + USER_SESSION_DELETED.getStableIndex(), 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; private final int stableIndex;

View File

@ -56,6 +56,7 @@ import org.keycloak.crypto.KeyWrapper;
import org.keycloak.events.Details; import org.keycloak.events.Details;
import org.keycloak.events.Errors; import org.keycloak.events.Errors;
import org.keycloak.events.EventBuilder; import org.keycloak.events.EventBuilder;
import org.keycloak.events.EventType;
import org.keycloak.jose.JOSEHeader; import org.keycloak.jose.JOSEHeader;
import org.keycloak.jose.jwe.JWE; import org.keycloak.jose.jwe.JWE;
import org.keycloak.jose.jwe.JWEException; 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.CREDENTIAL_OFFER_CREATE;
import static org.keycloak.constants.OID4VCIConstants.OID4VC_PROTOCOL; 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_OFFER_REQUEST;
import static org.keycloak.protocol.oid4vc.model.ErrorType.INVALID_CREDENTIAL_REQUEST; import static org.keycloak.protocol.oid4vc.model.ErrorType.INVALID_CREDENTIAL_REQUEST;
import static org.keycloak.protocol.oid4vc.model.ErrorType.UNKNOWN_CREDENTIAL_CONFIGURATION; import static org.keycloak.protocol.oid4vc.model.ErrorType.UNKNOWN_CREDENTIAL_CONFIGURATION;
@ -286,6 +286,10 @@ public class OID4VCIssuerEndpoint {
@Produces({MediaType.APPLICATION_JSON}) @Produces({MediaType.APPLICATION_JSON})
@Path(NONCE_PATH) @Path(NONCE_PATH)
public Response getCNonce() { 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); CNonceHandler cNonceHandler = session.getProvider(CNonceHandler.class);
NonceResponse nonceResponse = new NonceResponse(); NonceResponse nonceResponse = new NonceResponse();
String sourceEndpoint = OID4VCIssuerWellKnownProvider.getNonceEndpoint(session.getContext()); String sourceEndpoint = OID4VCIssuerWellKnownProvider.getNonceEndpoint(session.getContext());
@ -299,6 +303,8 @@ public class OID4VCIssuerEndpoint {
nonceResponse.setNonce(bodyCNonce); nonceResponse.setNonce(bodyCNonce);
eventBuilder.success();
Response.ResponseBuilder responseBuilder = Response.ok() Response.ResponseBuilder responseBuilder = Response.ok()
.header(HttpHeaders.CACHE_CONTROL, "no-store") .header(HttpHeaders.CACHE_CONTROL, "no-store")
.entity(nonceResponse); .entity(nonceResponse);
@ -385,6 +391,14 @@ public class OID4VCIssuerEndpoint {
ClientModel clientModel = clientSession.getClient(); ClientModel clientModel = clientSession.getClient();
RealmModel realmModel = clientModel.getRealm(); 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); cors.allowedOrigins(session, clientModel);
checkClientEnabled(); checkClientEnabled();
@ -394,6 +408,7 @@ public class OID4VCIssuerEndpoint {
.anyMatch(rm -> rm.getName().equals(CREDENTIAL_OFFER_CREATE.getName())); .anyMatch(rm -> rm.getName().equals(CREDENTIAL_OFFER_CREATE.getName()));
if (!hasCredentialOfferRole) { if (!hasCredentialOfferRole) {
var errorMessage = "Credential offer creation requires role: " + CREDENTIAL_OFFER_CREATE.getName(); var errorMessage = "Credential offer creation requires role: " + CREDENTIAL_OFFER_CREATE.getName();
eventBuilder.detail(Details.REASON, errorMessage).error(Errors.NOT_ALLOWED);
throw new CorsErrorResponseException(cors, throw new CorsErrorResponseException(cors,
INVALID_CREDENTIAL_OFFER_REQUEST.toString(), errorMessage, Response.Status.FORBIDDEN); 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) { if (appClientId != null && session.clients().getClientByClientId(realmModel, appClientId) == null) {
var errorMessage = "No such client id: " + appClientId; var errorMessage = "No such client id: " + appClientId;
eventBuilder.detail(Details.REASON, errorMessage).error(Errors.CLIENT_NOT_FOUND);
throw new CorsErrorResponseException(cors, throw new CorsErrorResponseException(cors,
INVALID_CREDENTIAL_OFFER_REQUEST.toString(), errorMessage, Response.Status.BAD_REQUEST); INVALID_CREDENTIAL_OFFER_REQUEST.toString(), errorMessage, Response.Status.BAD_REQUEST);
} }
@ -413,11 +429,13 @@ public class OID4VCIssuerEndpoint {
UserModel user = session.users().getUserByUsername(realmModel, appUsername); UserModel user = session.users().getUserByUsername(realmModel, appUsername);
if (user == null) { if (user == null) {
var errorMessage = "Not found user with username: " + appUsername; var errorMessage = "Not found user with username: " + appUsername;
eventBuilder.detail(Details.REASON, errorMessage).error(Errors.USER_NOT_FOUND);
throw new CorsErrorResponseException(cors, throw new CorsErrorResponseException(cors,
INVALID_CREDENTIAL_OFFER_REQUEST.toString(), errorMessage, Response.Status.BAD_REQUEST); INVALID_CREDENTIAL_OFFER_REQUEST.toString(), errorMessage, Response.Status.BAD_REQUEST);
} }
if (!user.isEnabled()) { if (!user.isEnabled()) {
var errorMessage = "User '" + appUsername + "' disabled"; var errorMessage = "User '" + appUsername + "' disabled";
eventBuilder.detail(Details.REASON, errorMessage).error(Errors.USER_DISABLED);
throw new CorsErrorResponseException(cors, throw new CorsErrorResponseException(cors,
INVALID_CREDENTIAL_OFFER_REQUEST.toString(), errorMessage, Response.Status.BAD_REQUEST); INVALID_CREDENTIAL_OFFER_REQUEST.toString(), errorMessage, Response.Status.BAD_REQUEST);
} }
@ -431,6 +449,7 @@ public class OID4VCIssuerEndpoint {
} }
if (appUsername == null) { if (appUsername == null) {
var errorMessage = "Pre-Authorized credential offer requires a target user"; var errorMessage = "Pre-Authorized credential offer requires a target user";
eventBuilder.detail(Details.REASON, errorMessage).error(Errors.INVALID_REQUEST);
throw new CorsErrorResponseException(cors, throw new CorsErrorResponseException(cors,
INVALID_CREDENTIAL_OFFER_REQUEST.toString(), errorMessage, Response.Status.BAD_REQUEST); INVALID_CREDENTIAL_OFFER_REQUEST.toString(), errorMessage, Response.Status.BAD_REQUEST);
} }
@ -445,6 +464,7 @@ public class OID4VCIssuerEndpoint {
if (!availableInClientScopes.contains(credConfigId)) { if (!availableInClientScopes.contains(credConfigId)) {
var errorMessage = "Invalid credential configuration id: " + credConfigId; var errorMessage = "Invalid credential configuration id: " + credConfigId;
LOGGER.debugf("%s not found in supported credential config ids: %s", credConfigId, availableInClientScopes); 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, throw new CorsErrorResponseException(cors,
INVALID_CREDENTIAL_OFFER_REQUEST.toString(), errorMessage, Response.Status.BAD_REQUEST); INVALID_CREDENTIAL_OFFER_REQUEST.toString(), errorMessage, Response.Status.BAD_REQUEST);
} }
@ -475,6 +495,17 @@ public class OID4VCIssuerEndpoint {
clientSession.setNote(CREDENTIAL_CONFIGURATION_IDS_NOTE, credentialConfigIdsJson); clientSession.setNote(CREDENTIAL_CONFIGURATION_IDS_NOTE, credentialConfigIdsJson);
LOGGER.debugf("Stored credential configuration IDs for token processing: %s", 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) { return switch (type) {
case URI -> getOfferUriAsUri(offerState.getNonce()); case URI -> getOfferUriAsUri(offerState.getNonce());
case QR_CODE -> getOfferUriAsQr(offerState.getNonce(), width, height); case QR_CODE -> getOfferUriAsQr(offerState.getNonce(), width, height);
@ -546,6 +577,7 @@ public class OID4VCIssuerEndpoint {
RealmModel realm = session.getContext().getRealm(); RealmModel realm = session.getContext().getRealm();
EventBuilder eventBuilder = new EventBuilder(realm, session, session.getContext().getConnection()); EventBuilder eventBuilder = new EventBuilder(realm, session, session.getContext().getConnection());
eventBuilder.event(EventType.VERIFIABLE_CREDENTIAL_OFFER_REQUEST);
// Retrieve the associated credential offer state // Retrieve the associated credential offer state
// //
@ -553,7 +585,7 @@ public class OID4VCIssuerEndpoint {
CredentialOfferState offerState = offerStorage.findOfferStateByNonce(session, nonce); CredentialOfferState offerState = offerStorage.findOfferStateByNonce(session, nonce);
if (offerState == null) { if (offerState == null) {
var errorMessage = "No credential offer state for nonce: " + nonce; 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)); throw new BadRequestException(getErrorResponse(INVALID_CREDENTIAL_OFFER_REQUEST, errorMessage));
} }
@ -566,12 +598,24 @@ public class OID4VCIssuerEndpoint {
if (offerState.isExpired()) { if (offerState.isExpired()) {
var errorMessage = "Credential offer already expired"; 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)); 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)); LOGGER.debugf("Responding with offer: %s", JsonSerialization.valueAsString(credOffer));
eventBuilder.success();
return cors.add(Response.ok().entity(credOffer)); return cors.add(Response.ok().entity(credOffer));
} }
@ -612,9 +656,14 @@ public class OID4VCIssuerEndpoint {
public Response requestCredential(String requestPayload) { public Response requestCredential(String requestPayload) {
LOGGER.debugf("Received credentials request with payload: %s", 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()) { if (requestPayload == null || requestPayload.trim().isEmpty()) {
String errorMessage = "Request payload is null or empty."; String errorMessage = "Request payload is null or empty.";
LOGGER.debug(errorMessage); LOGGER.debug(errorMessage);
eventBuilder.detail(Details.REASON, errorMessage).error(Errors.INVALID_REQUEST);
throw new BadRequestException(getErrorResponse(INVALID_CREDENTIAL_REQUEST, errorMessage)); throw new BadRequestException(getErrorResponse(INVALID_CREDENTIAL_REQUEST, errorMessage));
} }
@ -623,11 +672,26 @@ public class OID4VCIssuerEndpoint {
CredentialIssuer issuerMetadata = (CredentialIssuer) new OID4VCIssuerWellKnownProvider(session).getConfig(); CredentialIssuer issuerMetadata = (CredentialIssuer) new OID4VCIssuerWellKnownProvider(session).getConfig();
// Validate request encryption // 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 // Authenticate first to fail fast on auth errors
AuthenticationManager.AuthResult authResult = getAuthResult(); 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 // Validate encryption parameters if present
CredentialResponseEncryption encryptionParams = credentialRequestVO.getCredentialResponseEncryption(); CredentialResponseEncryption encryptionParams = credentialRequestVO.getCredentialResponseEncryption();
CredentialResponseEncryptionMetadata encryptionMetadata = OID4VCIssuerWellKnownProvider.getCredentialResponseEncryption(session); CredentialResponseEncryptionMetadata encryptionMetadata = OID4VCIssuerWellKnownProvider.getCredentialResponseEncryption(session);
@ -639,6 +703,9 @@ public class OID4VCIssuerEndpoint {
if (isEncryptionRequired && encryptionParams == null) { if (isEncryptionRequired && encryptionParams == null) {
String errorMessage = "Response encryption is required by the Credential Issuer, but no encryption parameters were provided."; String errorMessage = "Response encryption is required by the Credential Issuer, but no encryption parameters were provided.";
LOGGER.debug(errorMessage); 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)); 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)", String errorMessage = String.format("No supported key management algorithm (alg) for provided JWK (kty=%s)",
encryptionParams.getJwk().getKeyType()); encryptionParams.getJwk().getKeyType());
LOGGER.debug(errorMessage); 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)); 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", String errorMessage = String.format("Unsupported content encryption algorithm: enc=%s",
encryptionParams.getEnc()); encryptionParams.getEnc());
LOGGER.debug(errorMessage); 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)); 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", String errorMessage = String.format("Unsupported compression parameter: zip=%s",
encryptionParams.getZip()); encryptionParams.getZip());
LOGGER.debug(errorMessage); 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)); 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. // Check if at least one of both is available.
if (credentialIdentifier == null && credentialConfigurationId == null) { 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)); throw new BadRequestException(getErrorResponse(ErrorType.MISSING_CREDENTIAL_IDENTIFIER_AND_CONFIGURATION_ID));
} }
@ -703,6 +781,7 @@ public class OID4VCIssuerEndpoint {
CredentialOfferState offerState = offerStorage.findOfferStateByCredentialId(session, credentialIdentifier); CredentialOfferState offerState = offerStorage.findOfferStateByCredentialId(session, credentialIdentifier);
if (offerState == null) { if (offerState == null) {
var errorMessage = "No credential offer state for credential id: " + credentialIdentifier; 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)); throw new BadRequestException(getErrorResponse(UNKNOWN_CREDENTIAL_IDENTIFIER, errorMessage));
} }
@ -712,6 +791,7 @@ public class OID4VCIssuerEndpoint {
String credConfigId = authDetails.getCredentialConfigurationId(); String credConfigId = authDetails.getCredentialConfigurationId();
if (credConfigId == null) { if (credConfigId == null) {
var errorMessage = "No credential_configuration_id in AuthorizationDetails"; 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)); throw new BadRequestException(getErrorResponse(UNKNOWN_CREDENTIAL_CONFIGURATION, errorMessage));
} }
@ -720,25 +800,25 @@ public class OID4VCIssuerEndpoint {
SupportedCredentialConfiguration credConfig = OID4VCIssuerWellKnownProvider.getSupportedCredentials(session).get(credConfigId); SupportedCredentialConfiguration credConfig = OID4VCIssuerWellKnownProvider.getSupportedCredentials(session).get(credConfigId);
if (credConfig == null) { if (credConfig == null) {
var errorMessage = "Mapped credential configuration not found: " + credConfigId; 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)); throw new BadRequestException(getErrorResponse(UNKNOWN_CREDENTIAL_CONFIGURATION, errorMessage));
} }
// Verify the user login session // Verify the user login session
// //
UserSessionModel userSession = authResult.session();
UserModel userModel = userSession.getUser();
if (!userModel.getId().equals(offerState.getUserId())) { if (!userModel.getId().equals(offerState.getUserId())) {
var errorMessage = "Unexpected login user: " + userModel.getUsername(); var errorMessage = "Unexpected login user: " + userModel.getUsername();
LOGGER.errorf(errorMessage + " != %s", offerState.getUserId()); LOGGER.errorf(errorMessage + " != %s", offerState.getUserId());
eventBuilder.detail(Details.REASON, errorMessage).error(Errors.INVALID_USER);
throw new BadRequestException(getErrorResponse(ErrorType.INVALID_CREDENTIAL_REQUEST, errorMessage)); throw new BadRequestException(getErrorResponse(ErrorType.INVALID_CREDENTIAL_REQUEST, errorMessage));
} }
// Verify the login client // Verify the login client
// //
ClientModel clientModel = session.getContext().getClient();
if (offerState.getClientId() != null && !clientModel.getClientId().equals(offerState.getClientId())) { if (offerState.getClientId() != null && !clientModel.getClientId().equals(offerState.getClientId())) {
var errorMessage = "Unexpected login client: " + clientModel.getClientId(); var errorMessage = "Unexpected login client: " + clientModel.getClientId();
LOGGER.errorf(errorMessage + " != %s", offerState.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)); throw new BadRequestException(getErrorResponse(ErrorType.INVALID_CREDENTIAL_REQUEST, errorMessage));
} }
@ -747,20 +827,26 @@ public class OID4VCIssuerEndpoint {
ClientScopeModel clientScope = clientModel.getClientScopes(false).get(credConfig.getScope()); ClientScopeModel clientScope = clientModel.getClientScopes(false).get(credConfig.getScope());
if (clientScope == null) { if (clientScope == null) {
var errorMessage = String.format("Client scope not found: %s", credConfig.getScope()); 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)); throw new BadRequestException(getErrorResponse(UNKNOWN_CREDENTIAL_CONFIGURATION, errorMessage));
} }
requestedCredential = new CredentialScopeModel(clientScope); requestedCredential = new CredentialScopeModel(clientScope);
LOGGER.debugf("Successfully mapped credential identifier %s to scope %s", credentialIdentifier, clientScope.getName()); LOGGER.debugf("Successfully mapped credential identifier %s to scope %s", credentialIdentifier, clientScope.getName());
eventBuilder.detail(Details.CREDENTIAL_TYPE, credConfigId);
} else if (credentialConfigurationId != null) { } else if (credentialConfigurationId != null) {
// Use credential_configuration_id for direct lookup // Use credential_configuration_id for direct lookup
requestedCredential = credentialRequestVO.findCredentialScope(session).orElseThrow(() -> { requestedCredential = credentialRequestVO.findCredentialScope(session).orElseThrow(() -> {
var errorMessage = "Credential scope not found for configuration id: " + credentialConfigurationId; 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)); return new BadRequestException(getErrorResponse(ErrorType.UNKNOWN_CREDENTIAL_CONFIGURATION, errorMessage));
}); });
eventBuilder.detail(Details.CREDENTIAL_TYPE, credentialConfigurationId);
} else { } else {
// Neither provided - this should not happen due to earlier validation // 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)); throw new BadRequestException(getErrorResponse(ErrorType.MISSING_CREDENTIAL_IDENTIFIER_AND_CONFIGURATION_ID));
} }
@ -777,7 +863,7 @@ public class OID4VCIssuerEndpoint {
if (allProofs.isEmpty()) { if (allProofs.isEmpty()) {
// Single issuance without proof // Single issuance without proof
Object theCredential = getCredential(authResult, supportedCredential, credentialRequestVO); Object theCredential = getCredential(authResult, supportedCredential, credentialRequestVO, eventBuilder);
responseVO.addCredential(theCredential); responseVO.addCredential(theCredential);
} else { } else {
// Issue credentials for each proof // Issue credentials for each proof
@ -790,24 +876,34 @@ public class OID4VCIssuerEndpoint {
proofForIteration.setProofByType(proofType, currentProof); proofForIteration.setProofByType(proofType, currentProof);
// Creating credential with keybinding to the current proof // Creating credential with keybinding to the current proof
credentialRequestVO.setProofs(proofForIteration); credentialRequestVO.setProofs(proofForIteration);
Object theCredential = getCredential(authResult, supportedCredential, credentialRequestVO); Object theCredential = getCredential(authResult, supportedCredential, credentialRequestVO, eventBuilder);
responseVO.addCredential(theCredential); responseVO.addCredential(theCredential);
} }
credentialRequestVO.setProofs(originalProofs); credentialRequestVO.setProofs(originalProofs);
} }
// Encrypt all responses if encryption parameters are provided, except for error credential responses // Encrypt all responses if encryption parameters are provided, except for error credential responses
Response response;
if (encryptionParams != null && !responseVO.getCredentials().isEmpty()) { if (encryptionParams != null && !responseVO.getCredentials().isEmpty()) {
String jwe = encryptCredentialResponse(responseVO, encryptionParams, encryptionMetadata); String jwe = encryptCredentialResponse(responseVO, encryptionParams, encryptionMetadata);
return Response.ok() response = Response.ok()
.type(MediaType.APPLICATION_JWT) .type(MediaType.APPLICATION_JWT)
.entity(jwe) .entity(jwe)
.build(); .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(); CredentialRequestEncryptionMetadata requestEncryptionMetadata = issuerMetadata.getCredentialRequestEncryption();
boolean isRequestEncryptionRequired = Optional.ofNullable(requestEncryptionMetadata) boolean isRequestEncryptionRequired = Optional.ofNullable(requestEncryptionMetadata)
.map(CredentialRequestEncryptionMetadata::isEncryptionRequired) .map(CredentialRequestEncryptionMetadata::isEncryptionRequired)
@ -825,6 +921,11 @@ public class OID4VCIssuerEndpoint {
if (requestEncryptionMetadata == null && contentTypeIsJwt) { if (requestEncryptionMetadata == null && contentTypeIsJwt) {
String errorMessage = "Received JWT content-type request, but credential_request_encryption is not supported."; String errorMessage = "Received JWT content-type request, but credential_request_encryption is not supported.";
LOGGER.debug(errorMessage); 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)); throw new BadRequestException(getErrorResponse(ErrorType.INVALID_ENCRYPTION_PARAMETERS, errorMessage));
} }
@ -834,11 +935,21 @@ public class OID4VCIssuerEndpoint {
if (isRequestEncryptionRequired) { if (isRequestEncryptionRequired) {
String errorMessage = "Encryption is required but request is not a valid JWE: " + e.getMessage(); String errorMessage = "Encryption is required but request is not a valid JWE: " + e.getMessage();
LOGGER.debug(errorMessage); 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)); throw new BadRequestException(getErrorResponse(ErrorType.INVALID_ENCRYPTION_PARAMETERS, errorMessage));
} }
if (contentTypeIsJwt) { if (contentTypeIsJwt) {
String errorMessage = "Request has JWT content-type but is not a valid JWE: " + e.getMessage(); String errorMessage = "Request has JWT content-type but is not a valid JWE: " + e.getMessage();
LOGGER.debug(errorMessage); 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)); 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(); String errorMessage = "Failed to parse JSON request: " + e.getMessage();
LOGGER.errorf(e, "JSON parsing failed. Request payload length: %d", LOGGER.errorf(e, "JSON parsing failed. Request payload length: %d",
requestPayload != null ? requestPayload.length() : 0); 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)); 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 authResult authResult containing the userSession to create the credential for
* @param credentialConfig the supported credential configuration * @param credentialConfig the supported credential configuration
* @param credentialRequestVO the credential request * @param credentialRequestVO the credential request
* @param eventBuilder the event builder for logging events
* @return the signed credential * @return the signed credential
*/ */
private Object getCredential(AuthenticationManager.AuthResult authResult, private Object getCredential(AuthenticationManager.AuthResult authResult,
SupportedCredentialConfiguration credentialConfig, SupportedCredentialConfiguration credentialConfig,
CredentialRequest credentialRequestVO CredentialRequest credentialRequestVO,
EventBuilder eventBuilder
) { ) {
// Get the client scope model from the credential configuration // Get the client scope model from the credential configuration
@ -1293,7 +1409,7 @@ public class OID4VCIssuerEndpoint {
.filter(Objects::nonNull) .filter(Objects::nonNull)
.toList(); .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 // Enforce key binding prior to signing if necessary
enforceKeyBindingIfProofProvided(vcIssuanceContext); enforceKeyBindingIfProofProvided(vcIssuanceContext);
@ -1348,7 +1464,7 @@ public class OID4VCIssuerEndpoint {
// builds the unsigned credential by applying all protocol mappers. // builds the unsigned credential by applying all protocol mappers.
private VCIssuanceContext getVCToSign(List<OID4VCMapper> protocolMappers, SupportedCredentialConfiguration credentialConfig, private VCIssuanceContext getVCToSign(List<OID4VCMapper> protocolMappers, SupportedCredentialConfiguration credentialConfig,
AuthenticationManager.AuthResult authResult, CredentialRequest credentialRequestVO, AuthenticationManager.AuthResult authResult, CredentialRequest credentialRequestVO,
CredentialScopeModel credentialScopeModel) { CredentialScopeModel credentialScopeModel, EventBuilder eventBuilder) {
// Compute issuance date and apply correlation-mitigation according to realm configuration // Compute issuance date and apply correlation-mitigation according to realm configuration
Instant issuance = Instant.ofEpochMilli(timeProvider.currentTimeMillis()); Instant issuance = Instant.ofEpochMilli(timeProvider.currentTimeMillis());
@ -1376,7 +1492,7 @@ public class OID4VCIssuerEndpoint {
// Validate that requested claims from authorization_details are present // Validate that requested claims from authorization_details are present
String credentialConfigId = credentialConfig.getId(); String credentialConfigId = credentialConfig.getId();
validateRequestedClaimsArePresent(subjectClaimsWithMetadataPrefix, credentialConfig, authResult.session(), credentialConfigId); validateRequestedClaimsArePresent(subjectClaimsWithMetadataPrefix, credentialConfig, authResult.session(), credentialConfigId, eventBuilder);
// Include all available claims // Include all available claims
subjectClaims.forEach((key, value) -> vc.getCredentialSubject().setClaims(key, value)); 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. * 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 credentialConfig Credential configuration
* @param userSession the user session * @param userSession the user session
* @param scope the credential scope * @param scope the credential scope
* @param eventBuilder the event builder for logging error events
* @throws BadRequestException if mandatory requested claims are missing * @throws BadRequestException if mandatory requested claims are missing
*/ */
private void validateRequestedClaimsArePresent(Map<String, Object> allClaims, SupportedCredentialConfiguration credentialConfig, private void validateRequestedClaimsArePresent(Map<String, Object> allClaims, SupportedCredentialConfiguration credentialConfig,
UserSessionModel userSession, String scope) { UserSessionModel userSession, String scope, EventBuilder eventBuilder) {
// Protocol mappers from configuration // Protocol mappers from configuration
Map<List<Object>, ClaimsDescription> claimsConfig = credentialConfig.getCredentialMetadata().getClaims() Map<List<Object>, ClaimsDescription> claimsConfig = credentialConfig.getCredentialMetadata().getClaims()
.stream() .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 // Merge claims from both protocolMappers and authorizationDetails. If either source specifies "mandatory" as true, claim is considered mandatory
for (ClaimsDescription claimDescription : claimsFromAuthzDetails) { for (ClaimsDescription claimDescription : claimsFromAuthzDetails) {
List<Object> path = claimDescription.getPath(); List<Object> path = claimDescription.getPath();
ClaimsDescription existing = claimsConfig.get(path); ClaimsDescription existing = claimsConfig.get(path);
if (existing == null) { if (existing == null) {
claimsConfig.put(path, claimDescription); claimsConfig.put(path, claimDescription);
@ -1494,10 +1611,13 @@ public class OID4VCIssuerEndpoint {
LOGGER.debugf("All requested claims are present for scope %s", scope); LOGGER.debugf("All requested claims are present for scope %s", scope);
} catch (IllegalArgumentException e) { } catch (IllegalArgumentException e) {
// If filtering fails, it means some requested claims are missing // 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" LOGGER.warnf("Requested claims validation failed for scope '%s', user '%s', client '%s': %s"
, scope, userSession.getUser().getUsername(), session.getContext().getClient().getClientId(), e.getMessage()); , scope, userSession.getUser().getUsername(), session.getContext().getClient().getClientId(), e.getMessage());
throw new BadRequestException("Credential issuance failed: " + e.getMessage() + // Add error event details with information about which mandatory claim is missing
". The requested claims are not available in the user profile."); eventBuilder.detail(Details.REASON, errorMessage).error(Errors.INVALID_REQUEST);
throw new BadRequestException(errorMessage);
} }
} }
@ -1514,8 +1634,8 @@ public class OID4VCIssuerEndpoint {
try { try {
// Parse the stored claims from JSON // Parse the stored claims from JSON
return JsonSerialization.readValue(storedClaimsJson, return JsonSerialization.readValue(storedClaimsJson,
new TypeReference<>() { new TypeReference<>() {
}); });
} catch (Exception e) { } catch (Exception e) {
LOGGER.warnf(e, "Failed to parse stored claims for scope '%s', user '%s', client '%s'", scope, username, clientId); 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.OAuth2Constants;
import org.keycloak.TokenVerifier; import org.keycloak.TokenVerifier;
import org.keycloak.crypto.Algorithm; import org.keycloak.crypto.Algorithm;
import org.keycloak.events.EventType;
import org.keycloak.jose.jws.JWSHeader; import org.keycloak.jose.jws.JWSHeader;
import org.keycloak.models.KeycloakContext; import org.keycloak.models.KeycloakContext;
import org.keycloak.protocol.oid4vc.OID4VCLoginProtocolFactory; 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.representations.JsonWebToken;
import org.keycloak.services.resources.RealmsResource; import org.keycloak.services.resources.RealmsResource;
import org.keycloak.testsuite.AbstractTestRealmKeycloakTest; import org.keycloak.testsuite.AbstractTestRealmKeycloakTest;
import org.keycloak.testsuite.AssertEvents;
import org.keycloak.testsuite.util.AdminClientUtil; import org.keycloak.testsuite.util.AdminClientUtil;
import org.keycloak.testsuite.util.oauth.OAuthClient; import org.keycloak.testsuite.util.oauth.OAuthClient;
import org.keycloak.util.JsonSerialization; import org.keycloak.util.JsonSerialization;
import org.junit.Assert; import org.junit.Assert;
import org.junit.Rule;
import org.junit.Test; import org.junit.Test;
/** /**
@ -56,13 +59,26 @@ import org.junit.Test;
*/ */
public class NonceEndpointTest extends OID4VCIssuerEndpointTest { public class NonceEndpointTest extends OID4VCIssuerEndpointTest {
@Rule
public AssertEvents events = new AssertEvents(this);
@Test @Test
public void testGetCNonce() throws Exception { public void testGetCNonce() throws Exception {
// Clear events before nonce request
events.clear();
URI baseUri = RealmsResource.realmBaseUrl(UriBuilder.fromUri(OAuthClient.AUTH_SERVER_ROOT)).build( URI baseUri = RealmsResource.realmBaseUrl(UriBuilder.fromUri(OAuthClient.AUTH_SERVER_ROOT)).build(
AbstractTestRealmKeycloakTest.TEST_REALM_NAME, AbstractTestRealmKeycloakTest.TEST_REALM_NAME,
OID4VCLoginProtocolFactory.PROTOCOL_ID); OID4VCLoginProtocolFactory.PROTOCOL_ID);
String cNonce = getCNonce(); 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; URI oid4vcUri;
UriBuilder builder = UriBuilder.fromUri(OAuthClient.AUTH_SERVER_ROOT); UriBuilder builder = UriBuilder.fromUri(OAuthClient.AUTH_SERVER_ROOT);
oid4vcUri = RealmsResource.protocolUrl(builder).build(AbstractTestRealmKeycloakTest.TEST_REALM_NAME, oid4vcUri = RealmsResource.protocolUrl(builder).build(AbstractTestRealmKeycloakTest.TEST_REALM_NAME,
@ -100,6 +116,9 @@ public class NonceEndpointTest extends OID4VCIssuerEndpointTest {
@Test @Test
public void testDPoPNonceHeaderPresent() throws Exception { public void testDPoPNonceHeaderPresent() throws Exception {
// Clear events before nonce request
events.clear();
UriBuilder uriBuilder = UriBuilder.fromUri(OAuthClient.AUTH_SERVER_ROOT); UriBuilder uriBuilder = UriBuilder.fromUri(OAuthClient.AUTH_SERVER_ROOT);
URI oid4vcBaseUri = RealmsResource.protocolUrl(uriBuilder).build(AbstractTestRealmKeycloakTest.TEST_REALM_NAME, URI oid4vcBaseUri = RealmsResource.protocolUrl(uriBuilder).build(AbstractTestRealmKeycloakTest.TEST_REALM_NAME,
OID4VCLoginProtocolFactory.PROTOCOL_ID); OID4VCLoginProtocolFactory.PROTOCOL_ID);
@ -111,6 +130,12 @@ public class NonceEndpointTest extends OID4VCIssuerEndpointTest {
Invocation.Builder requestBuilder = target.request(MediaType.APPLICATION_JSON_TYPE); Invocation.Builder requestBuilder = target.request(MediaType.APPLICATION_JSON_TYPE);
try (Response response = requestBuilder.post(null)) { 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 // Verify successful response
Assert.assertEquals("Nonce endpoint should return 200 OK", Assert.assertEquals("Nonce endpoint should return 200 OK",
Response.Status.OK.getStatusCode(), response.getStatus()); 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.OAuth2Constants;
import org.keycloak.admin.client.resource.ClientScopeResource; import org.keycloak.admin.client.resource.ClientScopeResource;
import org.keycloak.admin.client.resource.UserResource; 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.CredentialScopeModel;
import org.keycloak.models.oid4vci.Oid4vcProtocolMapperModel; import org.keycloak.models.oid4vci.Oid4vcProtocolMapperModel;
import org.keycloak.protocol.oid4vc.issuance.OID4VCAuthorizationDetailsResponse; 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.OAuth2ErrorRepresentation;
import org.keycloak.representations.idm.ProtocolMapperRepresentation; import org.keycloak.representations.idm.ProtocolMapperRepresentation;
import org.keycloak.representations.idm.UserRepresentation; import org.keycloak.representations.idm.UserRepresentation;
import org.keycloak.testsuite.AssertEvents;
import org.keycloak.testsuite.admin.ApiUtil; import org.keycloak.testsuite.admin.ApiUtil;
import org.keycloak.util.JsonSerialization; 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.client.methods.HttpPost;
import org.apache.http.entity.StringEntity; import org.apache.http.entity.StringEntity;
import org.apache.http.message.BasicNameValuePair; import org.apache.http.message.BasicNameValuePair;
import org.hamcrest.Matchers;
import org.junit.Rule;
import org.junit.Test; import org.junit.Test;
import static org.keycloak.OAuth2Constants.OPENID_CREDENTIAL; import static org.keycloak.OAuth2Constants.OPENID_CREDENTIAL;
@ -72,6 +78,9 @@ import static org.junit.Assert.assertNotNull;
*/ */
public abstract class OID4VCAuthorizationCodeFlowTestBase extends OID4VCIssuerEndpointTest { public abstract class OID4VCAuthorizationCodeFlowTestBase extends OID4VCIssuerEndpointTest {
@Rule
public AssertEvents events = new AssertEvents(this);
/** /**
* Test context for OID4VC tests * Test context for OID4VC tests
*/ */
@ -173,11 +182,23 @@ public abstract class OID4VCAuthorizationCodeFlowTestBase extends OID4VCIssuerEn
String credentialIdentifier = assertTokenResponse(tokenResponse); String credentialIdentifier = assertTokenResponse(tokenResponse);
String credentialConfigurationId = getCredentialClientScope().getAttributes().get(CredentialScopeModel.CONFIGURATION_ID); String credentialConfigurationId = getCredentialClientScope().getAttributes().get(CredentialScopeModel.CONFIGURATION_ID);
// Clear events before credential request
events.clear();
// Request the actual credential using the identifier // Request the actual credential using the identifier
HttpPost postCredential = getCredentialRequest(ctx, credRequestSupplier, tokenResponse, credentialConfigurationId, credentialIdentifier); HttpPost postCredential = getCredentialRequest(ctx, credRequestSupplier, tokenResponse, credentialConfigurationId, credentialIdentifier);
try (CloseableHttpResponse credentialResponse = httpClient.execute(postCredential)) { try (CloseableHttpResponse credentialResponse = httpClient.execute(postCredential)) {
assertErrorCredentialResponse(credentialResponse); 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" // 3 - Update user to add "lastName"
@ -229,11 +250,23 @@ public abstract class OID4VCAuthorizationCodeFlowTestBase extends OID4VCIssuerEn
String credentialIdentifier = assertTokenResponse(tokenResponse); String credentialIdentifier = assertTokenResponse(tokenResponse);
String credentialConfigurationId = getCredentialClientScope().getAttributes().get(CredentialScopeModel.CONFIGURATION_ID); String credentialConfigurationId = getCredentialClientScope().getAttributes().get(CredentialScopeModel.CONFIGURATION_ID);
// Clear events before credential request
events.clear();
// Request the actual credential using the identifier // Request the actual credential using the identifier
HttpPost postCredential = getCredentialRequest(ctx, credRequestSupplier, tokenResponse, credentialConfigurationId, credentialIdentifier); HttpPost postCredential = getCredentialRequest(ctx, credRequestSupplier, tokenResponse, credentialConfigurationId, credentialIdentifier);
try (CloseableHttpResponse credentialResponse = httpClient.execute(postCredential)) { try (CloseableHttpResponse credentialResponse = httpClient.execute(postCredential)) {
assertErrorCredentialResponse(credentialResponse); 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 // 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); userRep.setFirstName(null);
user.update(userRep); user.update(userRep);
// Clear events before credential request
events.clear();
try (CloseableHttpResponse credentialResponse = httpClient.execute(postCredential)) { try (CloseableHttpResponse credentialResponse = httpClient.execute(postCredential)) {
assertErrorCredentialResponse(credentialResponse); 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" // 4 - Update user to add "firstName", but missing "lastName"
@ -250,8 +295,20 @@ public abstract class OID4VCAuthorizationCodeFlowTestBase extends OID4VCIssuerEn
userRep.setFirstName("John"); userRep.setFirstName("John");
user.update(userRep); user.update(userRep);
// Clear events before credential request
events.clear();
try (CloseableHttpResponse credentialResponse = httpClient.execute(postCredential)) { try (CloseableHttpResponse credentialResponse = httpClient.execute(postCredential)) {
assertErrorCredentialResponse(credentialResponse); 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 // 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 jakarta.ws.rs.core.HttpHeaders;
import org.keycloak.OAuth2Constants; 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.models.oid4vci.CredentialScopeModel;
import org.keycloak.protocol.oid4vc.issuance.OID4VCAuthorizationDetailsResponse; import org.keycloak.protocol.oid4vc.issuance.OID4VCAuthorizationDetailsResponse;
import org.keycloak.protocol.oid4vc.model.AuthorizationDetail; 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.grants.PreAuthorizedCodeGrantTypeFactory;
import org.keycloak.protocol.oidc.representations.OIDCConfigurationRepresentation; import org.keycloak.protocol.oidc.representations.OIDCConfigurationRepresentation;
import org.keycloak.representations.idm.ClientScopeRepresentation; import org.keycloak.representations.idm.ClientScopeRepresentation;
import org.keycloak.testsuite.AssertEvents;
import org.keycloak.util.JsonSerialization; import org.keycloak.util.JsonSerialization;
import com.fasterxml.jackson.core.type.TypeReference; 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.client.methods.HttpPost;
import org.apache.http.entity.StringEntity; import org.apache.http.entity.StringEntity;
import org.apache.http.message.BasicNameValuePair; import org.apache.http.message.BasicNameValuePair;
import org.junit.Rule;
import org.junit.Test; import org.junit.Test;
import static org.keycloak.OAuth2Constants.OPENID_CREDENTIAL; import static org.keycloak.OAuth2Constants.OPENID_CREDENTIAL;
@ -73,6 +78,9 @@ import static org.junit.Assert.fail;
*/ */
public abstract class OID4VCAuthorizationDetailsFlowTestBase extends OID4VCIssuerEndpointTest { public abstract class OID4VCAuthorizationDetailsFlowTestBase extends OID4VCIssuerEndpointTest {
@Rule
public AssertEvents events = new AssertEvents(this);
protected static class Oid4vcTestContext { protected static class Oid4vcTestContext {
CredentialsOffer credentialsOffer; CredentialsOffer credentialsOffer;
CredentialIssuer credentialIssuer; CredentialIssuer credentialIssuer;
@ -104,6 +112,10 @@ public abstract class OID4VCAuthorizationDetailsFlowTestBase extends OID4VCIssue
Oid4vcTestContext ctx = new Oid4vcTestContext(); Oid4vcTestContext ctx = new Oid4vcTestContext();
String credentialConfigurationId = getCredentialClientScope().getAttributes().get(CredentialScopeModel.CONFIGURATION_ID); String credentialConfigurationId = getCredentialClientScope().getAttributes().get(CredentialScopeModel.CONFIGURATION_ID);
// Clear events before credential offer URI request
events.clear();
HttpGet getCredentialOfferURI = new HttpGet(getCredentialOfferUriUrl(credentialConfigurationId)); HttpGet getCredentialOfferURI = new HttpGet(getCredentialOfferUriUrl(credentialConfigurationId));
getCredentialOfferURI.addHeader(HttpHeaders.AUTHORIZATION, "Bearer " + token); getCredentialOfferURI.addHeader(HttpHeaders.AUTHORIZATION, "Bearer " + token);
@ -113,13 +125,33 @@ public abstract class OID4VCAuthorizationDetailsFlowTestBase extends OID4VCIssue
assertEquals(HttpStatus.SC_OK, status); assertEquals(HttpStatus.SC_OK, status);
String s = IOUtils.toString(response.getEntity().getContent(), StandardCharsets.UTF_8); String s = IOUtils.toString(response.getEntity().getContent(), StandardCharsets.UTF_8);
credentialOfferURI = JsonSerialization.readValue(s, CredentialOfferURI.class); 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()); HttpGet getCredentialOffer = new HttpGet(credentialOfferURI.getIssuer() + "/" + credentialOfferURI.getNonce());
try (CloseableHttpResponse response = httpClient.execute(getCredentialOffer)) { try (CloseableHttpResponse response = httpClient.execute(getCredentialOffer)) {
assertEquals(HttpStatus.SC_OK, response.getStatusLine().getStatusCode()); assertEquals(HttpStatus.SC_OK, response.getStatusLine().getStatusCode());
String s = IOUtils.toString(response.getEntity().getContent(), StandardCharsets.UTF_8); String s = IOUtils.toString(response.getEntity().getContent(), StandardCharsets.UTF_8);
ctx.credentialsOffer = JsonSerialization.readValue(s, CredentialsOffer.class); 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()); 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 does not assume the caller already has an authenticated session.
// * It must guarantee isolation of state tied to the VC issuance flow. // * 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()); HttpPost postCredential = new HttpPost(ctx.credentialIssuer.getCredentialEndpoint());
postCredential.addHeader(HttpHeaders.AUTHORIZATION, "Bearer " + token); postCredential.addHeader(HttpHeaders.AUTHORIZATION, "Bearer " + token);
postCredential.addHeader(HttpHeaders.CONTENT_TYPE, "application/json"); postCredential.addHeader(HttpHeaders.CONTENT_TYPE, "application/json");
@ -562,6 +597,15 @@ public abstract class OID4VCAuthorizationDetailsFlowTestBase extends OID4VCIssue
try (CloseableHttpResponse credentialResponse = httpClient.execute(postCredential)) { try (CloseableHttpResponse credentialResponse = httpClient.execute(postCredential)) {
assertEquals(HttpStatus.SC_OK, credentialResponse.getStatusLine().getStatusCode()); 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); String responseBody = IOUtils.toString(credentialResponse.getEntity().getContent(), StandardCharsets.UTF_8);
// Parse the credential response // Parse the credential response
@ -586,6 +630,9 @@ public abstract class OID4VCAuthorizationDetailsFlowTestBase extends OID4VCIssue
// Step 3: Request a credential using the credentialConfigurationId // Step 3: Request a credential using the credentialConfigurationId
// //
{ {
// Clear events before credential request
events.clear();
HttpPost postCredential = new HttpPost(ctx.credentialIssuer.getCredentialEndpoint()); HttpPost postCredential = new HttpPost(ctx.credentialIssuer.getCredentialEndpoint());
postCredential.addHeader(HttpHeaders.AUTHORIZATION, "Bearer " + token); postCredential.addHeader(HttpHeaders.AUTHORIZATION, "Bearer " + token);
postCredential.addHeader(HttpHeaders.CONTENT_TYPE, "application/json"); postCredential.addHeader(HttpHeaders.CONTENT_TYPE, "application/json");
@ -598,6 +645,16 @@ public abstract class OID4VCAuthorizationDetailsFlowTestBase extends OID4VCIssue
try (CloseableHttpResponse credentialResponse = httpClient.execute(postCredential)) { try (CloseableHttpResponse credentialResponse = httpClient.execute(postCredential)) {
assertEquals(HttpStatus.SC_OK, credentialResponse.getStatusLine().getStatusCode()); 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); String responseBody = IOUtils.toString(credentialResponse.getEntity().getContent(), StandardCharsets.UTF_8);
// Parse the credential response // 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 // 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()); HttpPost postCredential = new HttpPost(ctx.credentialIssuer.getCredentialEndpoint());
postCredential.addHeader(HttpHeaders.AUTHORIZATION, "Bearer " + token); postCredential.addHeader(HttpHeaders.AUTHORIZATION, "Bearer " + token);
postCredential.addHeader(HttpHeaders.CONTENT_TYPE, "application/json"); postCredential.addHeader(HttpHeaders.CONTENT_TYPE, "application/json");
@ -754,6 +814,15 @@ public abstract class OID4VCAuthorizationDetailsFlowTestBase extends OID4VCIssue
try (CloseableHttpResponse credentialResponse = httpClient.execute(postCredential)) { try (CloseableHttpResponse credentialResponse = httpClient.execute(postCredential)) {
assertEquals(HttpStatus.SC_OK, credentialResponse.getStatusLine().getStatusCode()); 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); String responseBody = IOUtils.toString(credentialResponse.getEntity().getContent(), StandardCharsets.UTF_8);
// Parse the credential response // 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. * 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.io.IOException;
import java.util.Arrays; import java.util.Arrays;
import java.util.List;
import java.util.Set; import java.util.Set;
import java.util.concurrent.atomic.AtomicReference;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import jakarta.ws.rs.core.HttpHeaders; import jakarta.ws.rs.core.HttpHeaders;
import org.keycloak.common.Profile; 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.CredentialOfferURI;
import org.keycloak.protocol.oid4vc.model.CredentialsOffer; 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.representations.idm.RealmRepresentation;
import org.keycloak.services.cors.Cors; import org.keycloak.services.cors.Cors;
import org.keycloak.testsuite.AssertEvents; 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.CloseableHttpResponse;
import org.apache.http.client.methods.HttpGet; import org.apache.http.client.methods.HttpGet;
import org.apache.http.client.methods.HttpOptions; import org.apache.http.client.methods.HttpOptions;
import org.hamcrest.Matchers;
import org.junit.Rule; import org.junit.Rule;
import org.junit.Test; import org.junit.Test;
@ -89,6 +100,8 @@ public class OID4VCCredentialOfferCorsTest extends OID4VCIssuerEndpointTest {
public void testCredentialOfferUriCorsValidOrigin() throws Exception { public void testCredentialOfferUriCorsValidOrigin() throws Exception {
AccessTokenResponse tokenResponse = getAccessToken(); AccessTokenResponse tokenResponse = getAccessToken();
// Clear events from token retrieval
events.clear();
// Test credential offer URI endpoint with valid origin // Test credential offer URI endpoint with valid origin
String offerUriUrl = getCredentialOfferUriUrl(); String offerUriUrl = getCredentialOfferUriUrl();
@ -102,6 +115,14 @@ public class OID4VCCredentialOfferCorsTest extends OID4VCIssuerEndpointTest {
CredentialOfferURI offerUri = JsonSerialization.readValue(responseBody, CredentialOfferURI.class); CredentialOfferURI offerUri = JsonSerialization.readValue(responseBody, CredentialOfferURI.class);
assertNotNull("Credential offer URI should not be null", offerUri.getIssuer()); assertNotNull("Credential offer URI should not be null", offerUri.getIssuer());
assertNotNull("Nonce should not be null", offerUri.getNonce()); 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(); AccessTokenResponse tokenResponse = getAccessToken();
String nonce = getNonceFromOfferUri(tokenResponse.getAccessToken()); String nonce = getNonceFromOfferUri(tokenResponse.getAccessToken());
// Clear events before credential offer request
events.clear();
// Test credential offer endpoint with valid origin // Test credential offer endpoint with valid origin
String offerUrl = getCredentialOfferUrl(nonce); String offerUrl = getCredentialOfferUrl(nonce);
@ -161,6 +185,17 @@ public class OID4VCCredentialOfferCorsTest extends OID4VCIssuerEndpointTest {
CredentialsOffer offer = JsonSerialization.readValue(responseBody, CredentialsOffer.class); CredentialsOffer offer = JsonSerialization.readValue(responseBody, CredentialsOffer.class);
assertNotNull("Credential offer should not be null", offer.getCredentialIssuer()); assertNotNull("Credential offer should not be null", offer.getCredentialIssuer());
assertNotNull("Credential configuration IDs should not be null", offer.getCredentialConfigurationIds()); 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 // Helper methods
private AccessTokenResponse getAccessToken() throws Exception { private AccessTokenResponse getAccessToken() throws Exception {