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 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";
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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());
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user