mirror of
https://github.com/keycloak/keycloak.git
synced 2026-01-09 15:02:05 -03:30
[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:
parent
695ee725a5
commit
c76676ebef
@ -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";
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -56,6 +56,7 @@ import org.keycloak.crypto.KeyWrapper;
|
||||
import org.keycloak.events.Details;
|
||||
import org.keycloak.events.Errors;
|
||||
import org.keycloak.events.EventBuilder;
|
||||
import org.keycloak.events.EventType;
|
||||
import org.keycloak.jose.JOSEHeader;
|
||||
import org.keycloak.jose.jwe.JWE;
|
||||
import org.keycloak.jose.jwe.JWEException;
|
||||
@ -135,7 +136,6 @@ import org.jboss.logging.Logger;
|
||||
|
||||
import static org.keycloak.constants.OID4VCIConstants.CREDENTIAL_OFFER_CREATE;
|
||||
import static org.keycloak.constants.OID4VCIConstants.OID4VC_PROTOCOL;
|
||||
import static org.keycloak.events.EventType.INTROSPECT_TOKEN_ERROR;
|
||||
import static org.keycloak.protocol.oid4vc.model.ErrorType.INVALID_CREDENTIAL_OFFER_REQUEST;
|
||||
import static org.keycloak.protocol.oid4vc.model.ErrorType.INVALID_CREDENTIAL_REQUEST;
|
||||
import static org.keycloak.protocol.oid4vc.model.ErrorType.UNKNOWN_CREDENTIAL_CONFIGURATION;
|
||||
@ -286,6 +286,10 @@ public class OID4VCIssuerEndpoint {
|
||||
@Produces({MediaType.APPLICATION_JSON})
|
||||
@Path(NONCE_PATH)
|
||||
public Response getCNonce() {
|
||||
RealmModel realm = session.getContext().getRealm();
|
||||
EventBuilder eventBuilder = new EventBuilder(realm, session, session.getContext().getConnection());
|
||||
eventBuilder.event(EventType.VERIFIABLE_CREDENTIAL_NONCE_REQUEST);
|
||||
|
||||
CNonceHandler cNonceHandler = session.getProvider(CNonceHandler.class);
|
||||
NonceResponse nonceResponse = new NonceResponse();
|
||||
String sourceEndpoint = OID4VCIssuerWellKnownProvider.getNonceEndpoint(session.getContext());
|
||||
@ -299,6 +303,8 @@ public class OID4VCIssuerEndpoint {
|
||||
|
||||
nonceResponse.setNonce(bodyCNonce);
|
||||
|
||||
eventBuilder.success();
|
||||
|
||||
Response.ResponseBuilder responseBuilder = Response.ok()
|
||||
.header(HttpHeaders.CACHE_CONTROL, "no-store")
|
||||
.entity(nonceResponse);
|
||||
@ -385,6 +391,14 @@ public class OID4VCIssuerEndpoint {
|
||||
ClientModel clientModel = clientSession.getClient();
|
||||
RealmModel realmModel = clientModel.getRealm();
|
||||
|
||||
EventBuilder eventBuilder = new EventBuilder(realmModel, session, session.getContext().getConnection());
|
||||
eventBuilder.event(EventType.VERIFIABLE_CREDENTIAL_OFFER_REQUEST)
|
||||
.client(clientModel)
|
||||
.user(userModel)
|
||||
.session(clientSession.getUserSession().getId())
|
||||
.detail(Details.USERNAME, userModel.getUsername())
|
||||
.detail(Details.CREDENTIAL_TYPE, credConfigId);
|
||||
|
||||
cors.allowedOrigins(session, clientModel);
|
||||
checkClientEnabled();
|
||||
|
||||
@ -394,6 +408,7 @@ public class OID4VCIssuerEndpoint {
|
||||
.anyMatch(rm -> rm.getName().equals(CREDENTIAL_OFFER_CREATE.getName()));
|
||||
if (!hasCredentialOfferRole) {
|
||||
var errorMessage = "Credential offer creation requires role: " + CREDENTIAL_OFFER_CREATE.getName();
|
||||
eventBuilder.detail(Details.REASON, errorMessage).error(Errors.NOT_ALLOWED);
|
||||
throw new CorsErrorResponseException(cors,
|
||||
INVALID_CREDENTIAL_OFFER_REQUEST.toString(), errorMessage, Response.Status.FORBIDDEN);
|
||||
}
|
||||
@ -404,6 +419,7 @@ public class OID4VCIssuerEndpoint {
|
||||
//
|
||||
if (appClientId != null && session.clients().getClientByClientId(realmModel, appClientId) == null) {
|
||||
var errorMessage = "No such client id: " + appClientId;
|
||||
eventBuilder.detail(Details.REASON, errorMessage).error(Errors.CLIENT_NOT_FOUND);
|
||||
throw new CorsErrorResponseException(cors,
|
||||
INVALID_CREDENTIAL_OFFER_REQUEST.toString(), errorMessage, Response.Status.BAD_REQUEST);
|
||||
}
|
||||
@ -413,11 +429,13 @@ public class OID4VCIssuerEndpoint {
|
||||
UserModel user = session.users().getUserByUsername(realmModel, appUsername);
|
||||
if (user == null) {
|
||||
var errorMessage = "Not found user with username: " + appUsername;
|
||||
eventBuilder.detail(Details.REASON, errorMessage).error(Errors.USER_NOT_FOUND);
|
||||
throw new CorsErrorResponseException(cors,
|
||||
INVALID_CREDENTIAL_OFFER_REQUEST.toString(), errorMessage, Response.Status.BAD_REQUEST);
|
||||
}
|
||||
if (!user.isEnabled()) {
|
||||
var errorMessage = "User '" + appUsername + "' disabled";
|
||||
eventBuilder.detail(Details.REASON, errorMessage).error(Errors.USER_DISABLED);
|
||||
throw new CorsErrorResponseException(cors,
|
||||
INVALID_CREDENTIAL_OFFER_REQUEST.toString(), errorMessage, Response.Status.BAD_REQUEST);
|
||||
}
|
||||
@ -431,6 +449,7 @@ public class OID4VCIssuerEndpoint {
|
||||
}
|
||||
if (appUsername == null) {
|
||||
var errorMessage = "Pre-Authorized credential offer requires a target user";
|
||||
eventBuilder.detail(Details.REASON, errorMessage).error(Errors.INVALID_REQUEST);
|
||||
throw new CorsErrorResponseException(cors,
|
||||
INVALID_CREDENTIAL_OFFER_REQUEST.toString(), errorMessage, Response.Status.BAD_REQUEST);
|
||||
}
|
||||
@ -445,6 +464,7 @@ public class OID4VCIssuerEndpoint {
|
||||
if (!availableInClientScopes.contains(credConfigId)) {
|
||||
var errorMessage = "Invalid credential configuration id: " + credConfigId;
|
||||
LOGGER.debugf("%s not found in supported credential config ids: %s", credConfigId, availableInClientScopes);
|
||||
eventBuilder.detail(Details.REASON, errorMessage).error(Errors.INVALID_REQUEST);
|
||||
throw new CorsErrorResponseException(cors,
|
||||
INVALID_CREDENTIAL_OFFER_REQUEST.toString(), errorMessage, Response.Status.BAD_REQUEST);
|
||||
}
|
||||
@ -475,6 +495,17 @@ public class OID4VCIssuerEndpoint {
|
||||
clientSession.setNote(CREDENTIAL_CONFIGURATION_IDS_NOTE, credentialConfigIdsJson);
|
||||
LOGGER.debugf("Stored credential configuration IDs for token processing: %s", credentialConfigIdsJson);
|
||||
|
||||
// Add event details
|
||||
eventBuilder.detail(Details.VERIFIABLE_CREDENTIAL_PRE_AUTHORIZED, String.valueOf(preAuthorized))
|
||||
.detail(Details.RESPONSE_TYPE, type.toString());
|
||||
if (appClientId != null) {
|
||||
eventBuilder.detail(Details.VERIFIABLE_CREDENTIAL_TARGET_CLIENT_ID, appClientId);
|
||||
}
|
||||
if (userId != null) {
|
||||
eventBuilder.detail(Details.VERIFIABLE_CREDENTIAL_TARGET_USER_ID, userId);
|
||||
}
|
||||
eventBuilder.success();
|
||||
|
||||
return switch (type) {
|
||||
case URI -> getOfferUriAsUri(offerState.getNonce());
|
||||
case QR_CODE -> getOfferUriAsQr(offerState.getNonce(), width, height);
|
||||
@ -546,6 +577,7 @@ public class OID4VCIssuerEndpoint {
|
||||
RealmModel realm = session.getContext().getRealm();
|
||||
|
||||
EventBuilder eventBuilder = new EventBuilder(realm, session, session.getContext().getConnection());
|
||||
eventBuilder.event(EventType.VERIFIABLE_CREDENTIAL_OFFER_REQUEST);
|
||||
|
||||
// Retrieve the associated credential offer state
|
||||
//
|
||||
@ -553,7 +585,7 @@ public class OID4VCIssuerEndpoint {
|
||||
CredentialOfferState offerState = offerStorage.findOfferStateByNonce(session, nonce);
|
||||
if (offerState == null) {
|
||||
var errorMessage = "No credential offer state for nonce: " + nonce;
|
||||
eventBuilder.event(INTROSPECT_TOKEN_ERROR).detail(Details.REASON, errorMessage).error(Errors.INVALID_REQUEST);
|
||||
eventBuilder.detail(Details.REASON, errorMessage).error(Errors.INVALID_REQUEST);
|
||||
throw new BadRequestException(getErrorResponse(INVALID_CREDENTIAL_OFFER_REQUEST, errorMessage));
|
||||
}
|
||||
|
||||
@ -566,12 +598,24 @@ public class OID4VCIssuerEndpoint {
|
||||
|
||||
if (offerState.isExpired()) {
|
||||
var errorMessage = "Credential offer already expired";
|
||||
eventBuilder.event(INTROSPECT_TOKEN_ERROR).detail(Details.REASON, errorMessage).error(Errors.EXPIRED_CODE);
|
||||
eventBuilder.detail(Details.REASON, errorMessage).error(Errors.EXPIRED_CODE);
|
||||
throw new BadRequestException(getErrorResponse(INVALID_CREDENTIAL_OFFER_REQUEST, errorMessage));
|
||||
}
|
||||
|
||||
// Add event details
|
||||
if (offerState.getClientId() != null) {
|
||||
eventBuilder.client(offerState.getClientId());
|
||||
}
|
||||
if (offerState.getUserId() != null) {
|
||||
eventBuilder.user(offerState.getUserId());
|
||||
}
|
||||
if (credOffer.getCredentialConfigurationIds() != null && !credOffer.getCredentialConfigurationIds().isEmpty()) {
|
||||
eventBuilder.detail(Details.CREDENTIAL_TYPE, String.join(",", credOffer.getCredentialConfigurationIds()));
|
||||
}
|
||||
|
||||
LOGGER.debugf("Responding with offer: %s", JsonSerialization.valueAsString(credOffer));
|
||||
|
||||
eventBuilder.success();
|
||||
return cors.add(Response.ok().entity(credOffer));
|
||||
}
|
||||
|
||||
@ -612,9 +656,14 @@ public class OID4VCIssuerEndpoint {
|
||||
public Response requestCredential(String requestPayload) {
|
||||
LOGGER.debugf("Received credentials request with payload: %s", requestPayload);
|
||||
|
||||
RealmModel realm = session.getContext().getRealm();
|
||||
EventBuilder eventBuilder = new EventBuilder(realm, session, session.getContext().getConnection());
|
||||
eventBuilder.event(EventType.VERIFIABLE_CREDENTIAL_REQUEST);
|
||||
|
||||
if (requestPayload == null || requestPayload.trim().isEmpty()) {
|
||||
String errorMessage = "Request payload is null or empty.";
|
||||
LOGGER.debug(errorMessage);
|
||||
eventBuilder.detail(Details.REASON, errorMessage).error(Errors.INVALID_REQUEST);
|
||||
throw new BadRequestException(getErrorResponse(INVALID_CREDENTIAL_REQUEST, errorMessage));
|
||||
}
|
||||
|
||||
@ -623,11 +672,26 @@ public class OID4VCIssuerEndpoint {
|
||||
CredentialIssuer issuerMetadata = (CredentialIssuer) new OID4VCIssuerWellKnownProvider(session).getConfig();
|
||||
|
||||
// Validate request encryption
|
||||
CredentialRequest credentialRequestVO = validateRequestEncryption(requestPayload, issuerMetadata);
|
||||
CredentialRequest credentialRequestVO;
|
||||
try {
|
||||
credentialRequestVO = validateRequestEncryption(requestPayload, issuerMetadata, eventBuilder);
|
||||
} catch (BadRequestException e) {
|
||||
// Event tracking already handled in validateRequestEncryption
|
||||
throw e;
|
||||
}
|
||||
|
||||
// Authenticate first to fail fast on auth errors
|
||||
AuthenticationManager.AuthResult authResult = getAuthResult();
|
||||
|
||||
// Set client and user info in event
|
||||
ClientModel clientModel = session.getContext().getClient();
|
||||
UserSessionModel userSession = authResult.session();
|
||||
UserModel userModel = userSession.getUser();
|
||||
eventBuilder.client(clientModel)
|
||||
.user(userModel)
|
||||
.session(userSession.getId())
|
||||
.detail(Details.USERNAME, userModel.getUsername());
|
||||
|
||||
// Validate encryption parameters if present
|
||||
CredentialResponseEncryption encryptionParams = credentialRequestVO.getCredentialResponseEncryption();
|
||||
CredentialResponseEncryptionMetadata encryptionMetadata = OID4VCIssuerWellKnownProvider.getCredentialResponseEncryption(session);
|
||||
@ -639,6 +703,9 @@ public class OID4VCIssuerEndpoint {
|
||||
if (isEncryptionRequired && encryptionParams == null) {
|
||||
String errorMessage = "Response encryption is required by the Credential Issuer, but no encryption parameters were provided.";
|
||||
LOGGER.debug(errorMessage);
|
||||
eventBuilder.detail(Details.REASON, errorMessage)
|
||||
.detail(Details.ERROR_TYPE, ErrorType.INVALID_ENCRYPTION_PARAMETERS.getValue())
|
||||
.error(Errors.INVALID_REQUEST);
|
||||
throw new BadRequestException(getErrorResponse(ErrorType.INVALID_ENCRYPTION_PARAMETERS, errorMessage));
|
||||
}
|
||||
|
||||
@ -652,6 +719,9 @@ public class OID4VCIssuerEndpoint {
|
||||
String errorMessage = String.format("No supported key management algorithm (alg) for provided JWK (kty=%s)",
|
||||
encryptionParams.getJwk().getKeyType());
|
||||
LOGGER.debug(errorMessage);
|
||||
eventBuilder.detail(Details.REASON, errorMessage)
|
||||
.detail(Details.ERROR_TYPE, ErrorType.INVALID_ENCRYPTION_PARAMETERS.getValue())
|
||||
.error(Errors.INVALID_REQUEST);
|
||||
throw new BadRequestException(getErrorResponse(ErrorType.INVALID_ENCRYPTION_PARAMETERS, errorMessage));
|
||||
}
|
||||
|
||||
@ -660,6 +730,9 @@ public class OID4VCIssuerEndpoint {
|
||||
String errorMessage = String.format("Unsupported content encryption algorithm: enc=%s",
|
||||
encryptionParams.getEnc());
|
||||
LOGGER.debug(errorMessage);
|
||||
eventBuilder.detail(Details.REASON, errorMessage)
|
||||
.detail(Details.ERROR_TYPE, ErrorType.INVALID_ENCRYPTION_PARAMETERS.getValue())
|
||||
.error(Errors.INVALID_REQUEST);
|
||||
throw new BadRequestException(getErrorResponse(ErrorType.INVALID_ENCRYPTION_PARAMETERS, errorMessage));
|
||||
}
|
||||
|
||||
@ -669,6 +742,9 @@ public class OID4VCIssuerEndpoint {
|
||||
String errorMessage = String.format("Unsupported compression parameter: zip=%s",
|
||||
encryptionParams.getZip());
|
||||
LOGGER.debug(errorMessage);
|
||||
eventBuilder.detail(Details.REASON, errorMessage)
|
||||
.detail(Details.ERROR_TYPE, ErrorType.INVALID_ENCRYPTION_PARAMETERS.getValue())
|
||||
.error(Errors.INVALID_REQUEST);
|
||||
throw new BadRequestException(getErrorResponse(ErrorType.INVALID_ENCRYPTION_PARAMETERS, errorMessage));
|
||||
}
|
||||
}
|
||||
@ -686,7 +762,9 @@ public class OID4VCIssuerEndpoint {
|
||||
|
||||
// Check if at least one of both is available.
|
||||
if (credentialIdentifier == null && credentialConfigurationId == null) {
|
||||
LOGGER.debugf("Missing both credential_configuration_id and credential_identifier. At least one must be specified.");
|
||||
String errorMessage = "Missing both credential_configuration_id and credential_identifier. At least one must be specified.";
|
||||
LOGGER.debugf(errorMessage);
|
||||
eventBuilder.detail(Details.REASON, errorMessage).error(Errors.INVALID_REQUEST);
|
||||
throw new BadRequestException(getErrorResponse(ErrorType.MISSING_CREDENTIAL_IDENTIFIER_AND_CONFIGURATION_ID));
|
||||
}
|
||||
|
||||
@ -703,6 +781,7 @@ public class OID4VCIssuerEndpoint {
|
||||
CredentialOfferState offerState = offerStorage.findOfferStateByCredentialId(session, credentialIdentifier);
|
||||
if (offerState == null) {
|
||||
var errorMessage = "No credential offer state for credential id: " + credentialIdentifier;
|
||||
eventBuilder.detail(Details.REASON, errorMessage).error(Errors.INVALID_REQUEST);
|
||||
throw new BadRequestException(getErrorResponse(UNKNOWN_CREDENTIAL_IDENTIFIER, errorMessage));
|
||||
}
|
||||
|
||||
@ -712,6 +791,7 @@ public class OID4VCIssuerEndpoint {
|
||||
String credConfigId = authDetails.getCredentialConfigurationId();
|
||||
if (credConfigId == null) {
|
||||
var errorMessage = "No credential_configuration_id in AuthorizationDetails";
|
||||
eventBuilder.detail(Details.REASON, errorMessage).error(Errors.INVALID_REQUEST);
|
||||
throw new BadRequestException(getErrorResponse(UNKNOWN_CREDENTIAL_CONFIGURATION, errorMessage));
|
||||
}
|
||||
|
||||
@ -720,25 +800,25 @@ public class OID4VCIssuerEndpoint {
|
||||
SupportedCredentialConfiguration credConfig = OID4VCIssuerWellKnownProvider.getSupportedCredentials(session).get(credConfigId);
|
||||
if (credConfig == null) {
|
||||
var errorMessage = "Mapped credential configuration not found: " + credConfigId;
|
||||
eventBuilder.detail(Details.REASON, errorMessage).error(Errors.INVALID_REQUEST);
|
||||
throw new BadRequestException(getErrorResponse(UNKNOWN_CREDENTIAL_CONFIGURATION, errorMessage));
|
||||
}
|
||||
|
||||
// Verify the user login session
|
||||
//
|
||||
UserSessionModel userSession = authResult.session();
|
||||
UserModel userModel = userSession.getUser();
|
||||
if (!userModel.getId().equals(offerState.getUserId())) {
|
||||
var errorMessage = "Unexpected login user: " + userModel.getUsername();
|
||||
LOGGER.errorf(errorMessage + " != %s", offerState.getUserId());
|
||||
eventBuilder.detail(Details.REASON, errorMessage).error(Errors.INVALID_USER);
|
||||
throw new BadRequestException(getErrorResponse(ErrorType.INVALID_CREDENTIAL_REQUEST, errorMessage));
|
||||
}
|
||||
|
||||
// Verify the login client
|
||||
//
|
||||
ClientModel clientModel = session.getContext().getClient();
|
||||
if (offerState.getClientId() != null && !clientModel.getClientId().equals(offerState.getClientId())) {
|
||||
var errorMessage = "Unexpected login client: " + clientModel.getClientId();
|
||||
LOGGER.errorf(errorMessage + " != %s", offerState.getClientId());
|
||||
eventBuilder.detail(Details.REASON, errorMessage).error(Errors.INVALID_CLIENT);
|
||||
throw new BadRequestException(getErrorResponse(ErrorType.INVALID_CREDENTIAL_REQUEST, errorMessage));
|
||||
}
|
||||
|
||||
@ -747,20 +827,26 @@ public class OID4VCIssuerEndpoint {
|
||||
ClientScopeModel clientScope = clientModel.getClientScopes(false).get(credConfig.getScope());
|
||||
if (clientScope == null) {
|
||||
var errorMessage = String.format("Client scope not found: %s", credConfig.getScope());
|
||||
eventBuilder.detail(Details.REASON, errorMessage).error(Errors.INVALID_REQUEST);
|
||||
throw new BadRequestException(getErrorResponse(UNKNOWN_CREDENTIAL_CONFIGURATION, errorMessage));
|
||||
}
|
||||
|
||||
requestedCredential = new CredentialScopeModel(clientScope);
|
||||
LOGGER.debugf("Successfully mapped credential identifier %s to scope %s", credentialIdentifier, clientScope.getName());
|
||||
eventBuilder.detail(Details.CREDENTIAL_TYPE, credConfigId);
|
||||
|
||||
} else if (credentialConfigurationId != null) {
|
||||
// Use credential_configuration_id for direct lookup
|
||||
requestedCredential = credentialRequestVO.findCredentialScope(session).orElseThrow(() -> {
|
||||
var errorMessage = "Credential scope not found for configuration id: " + credentialConfigurationId;
|
||||
eventBuilder.detail(Details.REASON, errorMessage).error(Errors.INVALID_REQUEST);
|
||||
return new BadRequestException(getErrorResponse(ErrorType.UNKNOWN_CREDENTIAL_CONFIGURATION, errorMessage));
|
||||
});
|
||||
eventBuilder.detail(Details.CREDENTIAL_TYPE, credentialConfigurationId);
|
||||
} else {
|
||||
// Neither provided - this should not happen due to earlier validation
|
||||
String errorMessage = "Missing both credential_configuration_id and credential_identifier";
|
||||
eventBuilder.detail(Details.REASON, errorMessage).error(Errors.INVALID_REQUEST);
|
||||
throw new BadRequestException(getErrorResponse(ErrorType.MISSING_CREDENTIAL_IDENTIFIER_AND_CONFIGURATION_ID));
|
||||
}
|
||||
|
||||
@ -777,7 +863,7 @@ public class OID4VCIssuerEndpoint {
|
||||
|
||||
if (allProofs.isEmpty()) {
|
||||
// Single issuance without proof
|
||||
Object theCredential = getCredential(authResult, supportedCredential, credentialRequestVO);
|
||||
Object theCredential = getCredential(authResult, supportedCredential, credentialRequestVO, eventBuilder);
|
||||
responseVO.addCredential(theCredential);
|
||||
} else {
|
||||
// Issue credentials for each proof
|
||||
@ -790,24 +876,34 @@ public class OID4VCIssuerEndpoint {
|
||||
proofForIteration.setProofByType(proofType, currentProof);
|
||||
// Creating credential with keybinding to the current proof
|
||||
credentialRequestVO.setProofs(proofForIteration);
|
||||
Object theCredential = getCredential(authResult, supportedCredential, credentialRequestVO);
|
||||
Object theCredential = getCredential(authResult, supportedCredential, credentialRequestVO, eventBuilder);
|
||||
responseVO.addCredential(theCredential);
|
||||
}
|
||||
credentialRequestVO.setProofs(originalProofs);
|
||||
}
|
||||
|
||||
// Encrypt all responses if encryption parameters are provided, except for error credential responses
|
||||
Response response;
|
||||
if (encryptionParams != null && !responseVO.getCredentials().isEmpty()) {
|
||||
String jwe = encryptCredentialResponse(responseVO, encryptionParams, encryptionMetadata);
|
||||
return Response.ok()
|
||||
response = Response.ok()
|
||||
.type(MediaType.APPLICATION_JWT)
|
||||
.entity(jwe)
|
||||
.build();
|
||||
} else {
|
||||
response = Response.ok().entity(responseVO).build();
|
||||
}
|
||||
return Response.ok().entity(responseVO).build();
|
||||
|
||||
// Mark event as successful
|
||||
eventBuilder.detail(Details.SCOPE, supportedCredential.getScope())
|
||||
.detail(Details.VERIFIABLE_CREDENTIAL_FORMAT, supportedCredential.getFormat())
|
||||
.detail(Details.VERIFIABLE_CREDENTIALS_ISSUED, String.valueOf(responseVO.getCredentials().size()));
|
||||
eventBuilder.success();
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
private CredentialRequest validateRequestEncryption(String requestPayload, CredentialIssuer issuerMetadata) throws BadRequestException {
|
||||
private CredentialRequest validateRequestEncryption(String requestPayload, CredentialIssuer issuerMetadata, EventBuilder eventBuilder) throws BadRequestException {
|
||||
CredentialRequestEncryptionMetadata requestEncryptionMetadata = issuerMetadata.getCredentialRequestEncryption();
|
||||
boolean isRequestEncryptionRequired = Optional.ofNullable(requestEncryptionMetadata)
|
||||
.map(CredentialRequestEncryptionMetadata::isEncryptionRequired)
|
||||
@ -825,6 +921,11 @@ public class OID4VCIssuerEndpoint {
|
||||
if (requestEncryptionMetadata == null && contentTypeIsJwt) {
|
||||
String errorMessage = "Received JWT content-type request, but credential_request_encryption is not supported.";
|
||||
LOGGER.debug(errorMessage);
|
||||
if (eventBuilder != null) {
|
||||
eventBuilder.detail(Details.REASON, errorMessage)
|
||||
.detail(Details.ERROR_TYPE, ErrorType.INVALID_ENCRYPTION_PARAMETERS.getValue())
|
||||
.error(Errors.INVALID_REQUEST);
|
||||
}
|
||||
throw new BadRequestException(getErrorResponse(ErrorType.INVALID_ENCRYPTION_PARAMETERS, errorMessage));
|
||||
}
|
||||
|
||||
@ -834,11 +935,21 @@ public class OID4VCIssuerEndpoint {
|
||||
if (isRequestEncryptionRequired) {
|
||||
String errorMessage = "Encryption is required but request is not a valid JWE: " + e.getMessage();
|
||||
LOGGER.debug(errorMessage);
|
||||
if (eventBuilder != null) {
|
||||
eventBuilder.detail(Details.REASON, errorMessage)
|
||||
.detail(Details.ERROR_TYPE, ErrorType.INVALID_ENCRYPTION_PARAMETERS.getValue())
|
||||
.error(Errors.INVALID_REQUEST);
|
||||
}
|
||||
throw new BadRequestException(getErrorResponse(ErrorType.INVALID_ENCRYPTION_PARAMETERS, errorMessage));
|
||||
}
|
||||
if (contentTypeIsJwt) {
|
||||
String errorMessage = "Request has JWT content-type but is not a valid JWE: " + e.getMessage();
|
||||
LOGGER.debug(errorMessage);
|
||||
if (eventBuilder != null) {
|
||||
eventBuilder.detail(Details.REASON, errorMessage)
|
||||
.detail(Details.ERROR_TYPE, ErrorType.INVALID_ENCRYPTION_PARAMETERS.getValue())
|
||||
.error(Errors.INVALID_REQUEST);
|
||||
}
|
||||
throw new BadRequestException(getErrorResponse(ErrorType.INVALID_ENCRYPTION_PARAMETERS, errorMessage));
|
||||
}
|
||||
}
|
||||
@ -852,6 +963,9 @@ public class OID4VCIssuerEndpoint {
|
||||
String errorMessage = "Failed to parse JSON request: " + e.getMessage();
|
||||
LOGGER.errorf(e, "JSON parsing failed. Request payload length: %d",
|
||||
requestPayload != null ? requestPayload.length() : 0);
|
||||
if (eventBuilder != null) {
|
||||
eventBuilder.detail(Details.REASON, errorMessage).error(Errors.INVALID_REQUEST);
|
||||
}
|
||||
throw new BadRequestException(getErrorResponse(INVALID_CREDENTIAL_REQUEST, errorMessage));
|
||||
}
|
||||
}
|
||||
@ -1267,11 +1381,13 @@ public class OID4VCIssuerEndpoint {
|
||||
* @param authResult authResult containing the userSession to create the credential for
|
||||
* @param credentialConfig the supported credential configuration
|
||||
* @param credentialRequestVO the credential request
|
||||
* @param eventBuilder the event builder for logging events
|
||||
* @return the signed credential
|
||||
*/
|
||||
private Object getCredential(AuthenticationManager.AuthResult authResult,
|
||||
SupportedCredentialConfiguration credentialConfig,
|
||||
CredentialRequest credentialRequestVO
|
||||
CredentialRequest credentialRequestVO,
|
||||
EventBuilder eventBuilder
|
||||
) {
|
||||
|
||||
// Get the client scope model from the credential configuration
|
||||
@ -1293,7 +1409,7 @@ public class OID4VCIssuerEndpoint {
|
||||
.filter(Objects::nonNull)
|
||||
.toList();
|
||||
|
||||
VCIssuanceContext vcIssuanceContext = getVCToSign(protocolMappers, credentialConfig, authResult, credentialRequestVO, credentialScopeModel);
|
||||
VCIssuanceContext vcIssuanceContext = getVCToSign(protocolMappers, credentialConfig, authResult, credentialRequestVO, credentialScopeModel, eventBuilder);
|
||||
|
||||
// Enforce key binding prior to signing if necessary
|
||||
enforceKeyBindingIfProofProvided(vcIssuanceContext);
|
||||
@ -1348,7 +1464,7 @@ public class OID4VCIssuerEndpoint {
|
||||
// builds the unsigned credential by applying all protocol mappers.
|
||||
private VCIssuanceContext getVCToSign(List<OID4VCMapper> protocolMappers, SupportedCredentialConfiguration credentialConfig,
|
||||
AuthenticationManager.AuthResult authResult, CredentialRequest credentialRequestVO,
|
||||
CredentialScopeModel credentialScopeModel) {
|
||||
CredentialScopeModel credentialScopeModel, EventBuilder eventBuilder) {
|
||||
|
||||
// Compute issuance date and apply correlation-mitigation according to realm configuration
|
||||
Instant issuance = Instant.ofEpochMilli(timeProvider.currentTimeMillis());
|
||||
@ -1376,7 +1492,7 @@ public class OID4VCIssuerEndpoint {
|
||||
|
||||
// Validate that requested claims from authorization_details are present
|
||||
String credentialConfigId = credentialConfig.getId();
|
||||
validateRequestedClaimsArePresent(subjectClaimsWithMetadataPrefix, credentialConfig, authResult.session(), credentialConfigId);
|
||||
validateRequestedClaimsArePresent(subjectClaimsWithMetadataPrefix, credentialConfig, authResult.session(), credentialConfigId, eventBuilder);
|
||||
|
||||
// Include all available claims
|
||||
subjectClaims.forEach((key, value) -> vc.getCredentialSubject().setClaims(key, value));
|
||||
@ -1452,14 +1568,15 @@ public class OID4VCIssuerEndpoint {
|
||||
/**
|
||||
* Validates that all requested claims from authorization_details are present in the available claims.
|
||||
*
|
||||
* @param allClaims all available claims. These are the claims including metadata prefix with the resolved path
|
||||
* @param allClaims all available claims. These are the claims including metadata prefix with the resolved path
|
||||
* @param credentialConfig Credential configuration
|
||||
* @param userSession the user session
|
||||
* @param scope the credential scope
|
||||
* @param userSession the user session
|
||||
* @param scope the credential scope
|
||||
* @param eventBuilder the event builder for logging error events
|
||||
* @throws BadRequestException if mandatory requested claims are missing
|
||||
*/
|
||||
private void validateRequestedClaimsArePresent(Map<String, Object> allClaims, SupportedCredentialConfiguration credentialConfig,
|
||||
UserSessionModel userSession, String scope) {
|
||||
UserSessionModel userSession, String scope, EventBuilder eventBuilder) {
|
||||
// Protocol mappers from configuration
|
||||
Map<List<Object>, ClaimsDescription> claimsConfig = credentialConfig.getCredentialMetadata().getClaims()
|
||||
.stream()
|
||||
@ -1473,7 +1590,7 @@ public class OID4VCIssuerEndpoint {
|
||||
|
||||
// Merge claims from both protocolMappers and authorizationDetails. If either source specifies "mandatory" as true, claim is considered mandatory
|
||||
for (ClaimsDescription claimDescription : claimsFromAuthzDetails) {
|
||||
List<Object> path = claimDescription.getPath();
|
||||
List<Object> path = claimDescription.getPath();
|
||||
ClaimsDescription existing = claimsConfig.get(path);
|
||||
if (existing == null) {
|
||||
claimsConfig.put(path, claimDescription);
|
||||
@ -1494,10 +1611,13 @@ public class OID4VCIssuerEndpoint {
|
||||
LOGGER.debugf("All requested claims are present for scope %s", scope);
|
||||
} catch (IllegalArgumentException e) {
|
||||
// If filtering fails, it means some requested claims are missing
|
||||
String errorMessage = "Credential issuance failed: " + e.getMessage() +
|
||||
". The requested claims are not available in the user profile.";
|
||||
LOGGER.warnf("Requested claims validation failed for scope '%s', user '%s', client '%s': %s"
|
||||
, scope, userSession.getUser().getUsername(), session.getContext().getClient().getClientId(), e.getMessage());
|
||||
throw new BadRequestException("Credential issuance failed: " + e.getMessage() +
|
||||
". The requested claims are not available in the user profile.");
|
||||
// Add error event details with information about which mandatory claim is missing
|
||||
eventBuilder.detail(Details.REASON, errorMessage).error(Errors.INVALID_REQUEST);
|
||||
throw new BadRequestException(errorMessage);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1514,8 +1634,8 @@ public class OID4VCIssuerEndpoint {
|
||||
try {
|
||||
// Parse the stored claims from JSON
|
||||
return JsonSerialization.readValue(storedClaimsJson,
|
||||
new TypeReference<>() {
|
||||
});
|
||||
new TypeReference<>() {
|
||||
});
|
||||
} catch (Exception e) {
|
||||
LOGGER.warnf(e, "Failed to parse stored claims for scope '%s', user '%s', client '%s'", scope, username, clientId);
|
||||
}
|
||||
|
||||
@ -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());
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -19,14 +19,24 @@ package org.keycloak.testsuite.oid4vc.issuance.signing;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import jakarta.ws.rs.core.HttpHeaders;
|
||||
|
||||
import org.keycloak.common.Profile;
|
||||
import org.keycloak.common.util.Time;
|
||||
import org.keycloak.events.Details;
|
||||
import org.keycloak.events.Errors;
|
||||
import org.keycloak.events.EventType;
|
||||
import org.keycloak.protocol.oid4vc.issuance.credentialoffer.CredentialOfferStorage;
|
||||
import org.keycloak.protocol.oid4vc.issuance.credentialoffer.CredentialOfferStorage.CredentialOfferState;
|
||||
import org.keycloak.protocol.oid4vc.model.CredentialOfferURI;
|
||||
import org.keycloak.protocol.oid4vc.model.CredentialsOffer;
|
||||
import org.keycloak.protocol.oid4vc.model.PreAuthorizedCode;
|
||||
import org.keycloak.protocol.oid4vc.model.PreAuthorizedGrant;
|
||||
import org.keycloak.representations.idm.RealmRepresentation;
|
||||
import org.keycloak.services.cors.Cors;
|
||||
import org.keycloak.testsuite.AssertEvents;
|
||||
@ -40,6 +50,7 @@ import org.apache.http.HttpStatus;
|
||||
import org.apache.http.client.methods.CloseableHttpResponse;
|
||||
import org.apache.http.client.methods.HttpGet;
|
||||
import org.apache.http.client.methods.HttpOptions;
|
||||
import org.hamcrest.Matchers;
|
||||
import org.junit.Rule;
|
||||
import org.junit.Test;
|
||||
|
||||
@ -89,6 +100,8 @@ public class OID4VCCredentialOfferCorsTest extends OID4VCIssuerEndpointTest {
|
||||
public void testCredentialOfferUriCorsValidOrigin() throws Exception {
|
||||
|
||||
AccessTokenResponse tokenResponse = getAccessToken();
|
||||
// Clear events from token retrieval
|
||||
events.clear();
|
||||
|
||||
// Test credential offer URI endpoint with valid origin
|
||||
String offerUriUrl = getCredentialOfferUriUrl();
|
||||
@ -102,6 +115,14 @@ public class OID4VCCredentialOfferCorsTest extends OID4VCIssuerEndpointTest {
|
||||
CredentialOfferURI offerUri = JsonSerialization.readValue(responseBody, CredentialOfferURI.class);
|
||||
assertNotNull("Credential offer URI should not be null", offerUri.getIssuer());
|
||||
assertNotNull("Nonce should not be null", offerUri.getNonce());
|
||||
|
||||
// Verify CREDENTIAL_OFFER_REQUEST event was fired
|
||||
events.expect(EventType.VERIFIABLE_CREDENTIAL_OFFER_REQUEST)
|
||||
.client(clientId)
|
||||
.user(AssertEvents.isUUID())
|
||||
.session(AssertEvents.isSessionId())
|
||||
.detail(Details.USERNAME, "john")
|
||||
.assertEvent();
|
||||
}
|
||||
}
|
||||
|
||||
@ -149,6 +170,9 @@ public class OID4VCCredentialOfferCorsTest extends OID4VCIssuerEndpointTest {
|
||||
AccessTokenResponse tokenResponse = getAccessToken();
|
||||
String nonce = getNonceFromOfferUri(tokenResponse.getAccessToken());
|
||||
|
||||
// Clear events before credential offer request
|
||||
events.clear();
|
||||
|
||||
// Test credential offer endpoint with valid origin
|
||||
String offerUrl = getCredentialOfferUrl(nonce);
|
||||
|
||||
@ -161,6 +185,17 @@ public class OID4VCCredentialOfferCorsTest extends OID4VCIssuerEndpointTest {
|
||||
CredentialsOffer offer = JsonSerialization.readValue(responseBody, CredentialsOffer.class);
|
||||
assertNotNull("Credential offer should not be null", offer.getCredentialIssuer());
|
||||
assertNotNull("Credential configuration IDs should not be null", offer.getCredentialConfigurationIds());
|
||||
|
||||
// The credential_type detail contains the credential configuration ID from the offer
|
||||
// We already assert that credentialConfigurationIds is not null and not empty above
|
||||
String expectedCredentialType = offer.getCredentialConfigurationIds().get(0);
|
||||
|
||||
events.expect(EventType.VERIFIABLE_CREDENTIAL_OFFER_REQUEST)
|
||||
.client(clientId)
|
||||
.user(AssertEvents.isUUID())
|
||||
.session((String) null) // No session for unauthenticated endpoint
|
||||
.detail(Details.CREDENTIAL_TYPE, expectedCredentialType)
|
||||
.assertEvent();
|
||||
}
|
||||
}
|
||||
|
||||
@ -245,6 +280,73 @@ public class OID4VCCredentialOfferCorsTest extends OID4VCIssuerEndpointTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testCredentialOfferUriWithInvalidCredentialConfig() throws Exception {
|
||||
AccessTokenResponse tokenResponse = getAccessToken();
|
||||
events.clear();
|
||||
|
||||
// Test credential offer URI endpoint with invalid credential configuration ID
|
||||
String offerUriUrl = getCredentialOfferUriUrl("invalid-credential-config-id");
|
||||
|
||||
try (CloseableHttpResponse response = makeCorsRequest(offerUriUrl, VALID_CORS_URL, tokenResponse.getAccessToken())) {
|
||||
// Should return 400 Bad Request
|
||||
assertEquals(HttpStatus.SC_BAD_REQUEST, response.getStatusLine().getStatusCode());
|
||||
|
||||
// Verify VERIFIABLE_CREDENTIAL_OFFER_REQUEST_ERROR event was fired
|
||||
events.expect(EventType.VERIFIABLE_CREDENTIAL_OFFER_REQUEST_ERROR)
|
||||
.client(clientId)
|
||||
.user(AssertEvents.isUUID())
|
||||
.session(AssertEvents.isSessionId())
|
||||
.error(Errors.INVALID_REQUEST)
|
||||
.assertEvent();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testCredentialOfferWithExpiredNonce() throws Exception {
|
||||
events.clear();
|
||||
|
||||
// Create an expired credential offer using testing client
|
||||
// Use AtomicReference to avoid serialization issues with lambda captures
|
||||
AtomicReference<String> nonceHolder = new AtomicReference<>();
|
||||
final String issuerPath = getRealmPath(TEST_REALM_NAME);
|
||||
testingClient.server(TEST_REALM_NAME).run(session -> {
|
||||
CredentialsOffer credOffer = new CredentialsOffer()
|
||||
.setCredentialIssuer(issuerPath)
|
||||
.setGrants(new PreAuthorizedGrant().setPreAuthorizedCode(new PreAuthorizedCode().setPreAuthorizedCode("test-code")))
|
||||
.setCredentialConfigurationIds(List.of(jwtTypeCredentialConfigurationIdName));
|
||||
|
||||
CredentialOfferStorage offerStorage = session.getProvider(CredentialOfferStorage.class);
|
||||
// Create offer with expiration time just 1 second in the past
|
||||
// This ensures it's still findable in storage but marked as expired
|
||||
CredentialOfferState offerState = new CredentialOfferState(credOffer, null, null, Time.currentTime() - 1);
|
||||
offerStorage.putOfferState(session, offerState);
|
||||
session.getTransactionManager().commit();
|
||||
nonceHolder.set(offerState.getNonce());
|
||||
});
|
||||
|
||||
String nonce = nonceHolder.get();
|
||||
|
||||
events.clear();
|
||||
|
||||
// Try to fetch the expired credential offer
|
||||
String offerUrl = getCredentialOfferUrl(nonce);
|
||||
try (CloseableHttpResponse response = makeCorsRequest(offerUrl, VALID_CORS_URL, null)) {
|
||||
// Should return 400 Bad Request
|
||||
assertEquals(HttpStatus.SC_BAD_REQUEST, response.getStatusLine().getStatusCode());
|
||||
|
||||
// Verify VERIFIABLE_CREDENTIAL_OFFER_REQUEST_ERROR event was fired
|
||||
events.expect(EventType.VERIFIABLE_CREDENTIAL_OFFER_REQUEST_ERROR)
|
||||
.client((String) null)
|
||||
.user((String) null)
|
||||
.session((String) null)
|
||||
// Storage prunes expired single-use entries before lookup; lookup failure yields INVALID_REQUEST
|
||||
.error(Errors.INVALID_REQUEST)
|
||||
.detail(Details.REASON, Matchers.containsString("No credential offer state"))
|
||||
.assertEvent();
|
||||
}
|
||||
}
|
||||
|
||||
// Helper methods
|
||||
|
||||
private AccessTokenResponse getAccessToken() throws Exception {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user