[OID4VCI] Credential Offer must be created by Issuer not Holder (#44255)

closes #44116


Signed-off-by: Thomas Diesler <tdiesler@ibm.com>
This commit is contained in:
Thomas Diesler
2025-11-27 16:07:10 +01:00
committed by GitHub
parent bf23259c0f
commit 54bf9206b2
59 changed files with 1689 additions and 587 deletions

1
.gitignore vendored
View File

@@ -5,6 +5,7 @@
# Intellij
###################
.idea
.run
*.iml
!.idea/icon.png

View File

@@ -164,7 +164,8 @@ public interface OAuth2Constants {
String CNF = "cnf";
// RAR - https://datatracker.ietf.org/doc/html/rfc9396
String AUTHORIZATION_DETAILS_PARAM = "authorization_details";
// Used as url parameter in AuthorizationRequest and as claim in TokenResponse
String AUTHORIZATION_DETAILS = "authorization_details";
// DPoP - https://datatracker.ietf.org/doc/html/rfc9449
String DPOP_HTTP_HEADER = "DPoP";
@@ -173,4 +174,7 @@ public interface OAuth2Constants {
String DPOP_JWT_HEADER_TYPE = "dpop+jwt";
String ALGS_ATTRIBUTE = "algs";
// OID4VCI - https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html
String OPENID_CREDENTIAL = "openid_credential";
String CREDENTIAL_IDENTIFIERS = "credential_identifiers";
}

View File

@@ -51,6 +51,22 @@ public class JsonSerialization {
prettyMapper.setSerializationInclusion(JsonInclude.Include.NON_NULL);
}
public static String valueAsString(Object obj) {
try {
return mapper.writeValueAsString(obj);
} catch (IOException e) {
throw new IllegalArgumentException(e);
}
}
public static <T> T valueFromString(String string, Class<T> type) {
try {
return mapper.readValue(string, type);
} catch (IOException e) {
throw new IllegalArgumentException(e);
}
}
public static void writeValueToStream(OutputStream os, Object obj) throws IOException {
mapper.writeValue(os, obj);
}

View File

@@ -18,13 +18,15 @@
package org.keycloak.constants;
import org.keycloak.representations.idm.RoleRepresentation;
/**
* Keycloak specific constants related to OID4VC and related functionality. Useful for example for internal constants (EG. name of Keycloak realm attributes).
* For protocol constants defined in the specification, see {@link org.keycloak.OID4VCConstants}
*
* @author Pascal Knüppel
*/
public final class Oid4VciConstants {
public final class OID4VCIConstants {
public static final String OID4VC_PROTOCOL = "oid4vc";
@@ -38,6 +40,9 @@ public final class Oid4VciConstants {
public static final String SOURCE_ENDPOINT = "source_endpoint";
public static final String BATCH_CREDENTIAL_ISSUANCE_BATCH_SIZE = "batch_credential_issuance.batch_size";
private Oid4VciConstants() {
public static final RoleRepresentation CREDENTIAL_OFFER_CREATE =
new RoleRepresentation("credential-offer-create", "Allow credential offer creation", false);
private OID4VCIConstants() {
}
}

View File

@@ -25,14 +25,14 @@ import java.util.Map;
import java.util.Optional;
import java.util.stream.Stream;
import org.keycloak.constants.Oid4VciConstants;
import org.keycloak.constants.OID4VCIConstants;
import org.keycloak.models.ClientScopeModel;
import org.keycloak.models.ProtocolMapperModel;
import org.keycloak.models.RealmModel;
import org.keycloak.models.RoleModel;
import static org.keycloak.OID4VCConstants.SD_JWT_VC_FORMAT;
import static org.keycloak.constants.Oid4VciConstants.OID4VC_PROTOCOL;
import static org.keycloak.constants.OID4VCIConstants.OID4VC_PROTOCOL;
/**
* This class acts as delegate for a {@link ClientScopeModel} implementation and adds additional functionality for
@@ -424,7 +424,7 @@ public class CredentialScopeModel implements ClientScopeModel {
public Stream<Oid4vcProtocolMapperModel> getOid4vcProtocolMappersStream() {
return clientScope.getProtocolMappersStream().filter(pm -> {
return Oid4VciConstants.OID4VC_PROTOCOL.equals(pm.getProtocol());
return OID4VCIConstants.OID4VC_PROTOCOL.equals(pm.getProtocol());
}).map(Oid4vcProtocolMapperModel::new);
}

View File

@@ -55,7 +55,7 @@ import org.keycloak.common.util.PemUtils;
import org.keycloak.common.util.SecretGenerator;
import org.keycloak.common.util.Time;
import org.keycloak.component.ComponentModel;
import org.keycloak.constants.Oid4VciConstants;
import org.keycloak.constants.OID4VCIConstants;
import org.keycloak.crypto.Algorithm;
import org.keycloak.deployment.DeployedConfigurationsManager;
import org.keycloak.models.AccountRoles;
@@ -1241,7 +1241,7 @@ public final class KeycloakModelUtils {
public static List<String> getAcceptedClientScopeProtocols(ClientModel client) {
List<String> acceptedClientProtocols;
if (client.getProtocol() == null || "openid-connect".equals(client.getProtocol())) {
acceptedClientProtocols = List.of("openid-connect", Oid4VciConstants.OID4VC_PROTOCOL);
acceptedClientProtocols = List.of("openid-connect", OID4VCIConstants.OID4VC_PROTOCOL);
}else {
acceptedClientProtocols = List.of(client.getProtocol());
}

View File

@@ -21,7 +21,7 @@ import java.util.HashMap;
import java.util.Map;
import org.keycloak.Config;
import org.keycloak.constants.Oid4VciConstants;
import org.keycloak.constants.OID4VCIConstants;
import org.keycloak.events.EventBuilder;
import org.keycloak.models.ClientModel;
import org.keycloak.models.ClientScopeModel;
@@ -43,7 +43,7 @@ import org.keycloak.representations.idm.ClientScopeRepresentation;
import org.jboss.logging.Logger;
import static org.keycloak.constants.Oid4VciConstants.OID4VC_PROTOCOL;
import static org.keycloak.constants.OID4VCIConstants.OID4VC_PROTOCOL;
import static org.keycloak.models.ClientScopeModel.INCLUDE_IN_TOKEN_SCOPE;
import static org.keycloak.models.oid4vci.CredentialScopeModel.CONFIGURATION_ID;
import static org.keycloak.models.oid4vci.CredentialScopeModel.CONTEXTS;
@@ -82,7 +82,7 @@ public class OID4VCLoginProtocolFactory implements LoginProtocolFactory, OID4VCE
private static final String LAST_NAME_MAPPER = "last-name";
private static final String FIRST_NAME_MAPPER = "first-name";
public static final String PROTOCOL_ID = Oid4VciConstants.OID4VC_PROTOCOL;
public static final String PROTOCOL_ID = OID4VCIConstants.OID4VC_PROTOCOL;
private final Map<String, ProtocolMapperModel> builtins = new HashMap<>();
@@ -181,6 +181,8 @@ public class OID4VCLoginProtocolFactory implements LoginProtocolFactory, OID4VCE
return OIDCLoginProtocolFactory.UI_ORDER - 20;
}
// Private ---------------------------------------------------------------------------------------------------------
private void addClientScopeDefaults(ClientScopeModel clientScope) {
ClientScopeRepresentation clientScopeRep = ModelToRepresentation.toRepresentation(clientScope);
addClientScopeDefaults(clientScopeRep);

View File

@@ -41,14 +41,13 @@ import org.keycloak.util.JsonSerialization;
import com.fasterxml.jackson.core.type.TypeReference;
import org.jboss.logging.Logger;
import static org.keycloak.OAuth2Constants.OPENID_CREDENTIAL;
import static org.keycloak.models.Constants.AUTHORIZATION_DETAILS_RESPONSE;
public class OID4VCAuthorizationDetailsProcessor implements AuthorizationDetailsProcessor {
private static final Logger logger = Logger.getLogger(OID4VCAuthorizationDetailsProcessor.class);
private final KeycloakSession session;
public static final String OPENID_CREDENTIAL_TYPE = "openid_credential";
public OID4VCAuthorizationDetailsProcessor(KeycloakSession session) {
this.session = session;
}
@@ -114,9 +113,9 @@ public class OID4VCAuthorizationDetailsProcessor implements AuthorizationDetails
List<ClaimsDescription> claims = detail.getClaims();
// Validate type first
if (!OPENID_CREDENTIAL_TYPE.equals(type)) {
if (!OPENID_CREDENTIAL.equals(type)) {
logger.warnf("Invalid authorization_details type: %s", type);
throw getInvalidRequestException("type: " + type + ", expected=" + OPENID_CREDENTIAL_TYPE);
throw getInvalidRequestException("type: " + type + ", expected=" + OPENID_CREDENTIAL);
}
// If authorization_servers is present, locations must be set to issuer identifier
@@ -222,19 +221,10 @@ public class OID4VCAuthorizationDetailsProcessor implements AuthorizationDetails
}
OID4VCAuthorizationDetailsResponse responseDetail = new OID4VCAuthorizationDetailsResponse();
responseDetail.setType(OPENID_CREDENTIAL_TYPE);
responseDetail.setType(OPENID_CREDENTIAL);
responseDetail.setCredentialConfigurationId(credentialConfigurationId);
responseDetail.setCredentialIdentifiers(credentialIdentifiers);
// Store credential identifier mapping in client session for later use during credential issuance
AuthenticatedClientSessionModel clientSession = clientSessionCtx.getClientSession();
for (String credentialIdentifier : credentialIdentifiers) {
// Store the mapping between credential identifier and configuration ID in client session
String mappingKey = OID4VCIssuerEndpoint.CREDENTIAL_IDENTIFIER_PREFIX + credentialIdentifier;
clientSession.setNote(mappingKey, credentialConfigurationId);
logger.debugf("Stored credential identifier mapping: %s -> %s", credentialIdentifier, credentialConfigurationId);
}
// Store claims in user session for later use during credential issuance
if (detail.getClaims() != null) {
// Store claims with a unique key based on credential configuration ID
@@ -289,17 +279,11 @@ public class OID4VCAuthorizationDetailsProcessor implements AuthorizationDetails
}
String credentialIdentifier = UUID.randomUUID().toString();
// Store the mapping between credential identifier and configuration ID in client session
// This will be used later when processing credential requests
String mappingKey = OID4VCIssuerEndpoint.CREDENTIAL_IDENTIFIER_PREFIX + credentialIdentifier;
clientSession.setNote(mappingKey, credentialConfigurationId);
logger.debugf("Generated credential identifier '%s' for configuration '%s'",
credentialIdentifier, credentialConfigurationId);
OID4VCAuthorizationDetailsResponse authDetail = new OID4VCAuthorizationDetailsResponse();
authDetail.setType(OPENID_CREDENTIAL_TYPE);
authDetail.setType(OPENID_CREDENTIAL);
authDetail.setCredentialConfigurationId(credentialConfigurationId);
authDetail.setCredentialIdentifiers(List.of(credentialIdentifier));

View File

@@ -23,6 +23,8 @@ import org.keycloak.protocol.oid4vc.OID4VCEnvironmentProviderFactory;
import org.keycloak.protocol.oidc.rar.AuthorizationDetailsProcessor;
import org.keycloak.protocol.oidc.rar.AuthorizationDetailsProcessorFactory;
import static org.keycloak.OAuth2Constants.OPENID_CREDENTIAL;
/**
* Factory for creating OID4VCI-specific authorization details processors.
* This factory is only enabled when the OID4VCI feature is available.
@@ -31,7 +33,7 @@ import org.keycloak.protocol.oidc.rar.AuthorizationDetailsProcessorFactory;
*/
public class OID4VCAuthorizationDetailsProcessorFactory implements AuthorizationDetailsProcessorFactory, OID4VCEnvironmentProviderFactory {
public static final String PROVIDER_ID = OID4VCAuthorizationDetailsProcessor.OPENID_CREDENTIAL_TYPE;
public static final String PROVIDER_ID = OPENID_CREDENTIAL;
@Override
public AuthorizationDetailsProcessor create(KeycloakSession session) {

View File

@@ -52,6 +52,7 @@ import org.keycloak.common.VerificationException;
import org.keycloak.common.util.SecretGenerator;
import org.keycloak.crypto.KeyUse;
import org.keycloak.crypto.KeyWrapper;
import org.keycloak.events.Details;
import org.keycloak.events.Errors;
import org.keycloak.events.EventBuilder;
import org.keycloak.jose.JOSEHeader;
@@ -67,6 +68,7 @@ import org.keycloak.models.KeyManager;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.KeycloakSessionFactory;
import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel;
import org.keycloak.models.UserSessionModel;
import org.keycloak.models.oid4vci.CredentialScopeModel;
import org.keycloak.protocol.ProtocolMapper;
@@ -74,6 +76,8 @@ import org.keycloak.protocol.oid4vc.OID4VCLoginProtocolFactory;
import org.keycloak.protocol.oid4vc.issuance.credentialbuilder.CredentialBody;
import org.keycloak.protocol.oid4vc.issuance.credentialbuilder.CredentialBuilder;
import org.keycloak.protocol.oid4vc.issuance.credentialbuilder.CredentialBuilderFactory;
import org.keycloak.protocol.oid4vc.issuance.credentialoffer.CredentialOfferStorage;
import org.keycloak.protocol.oid4vc.issuance.credentialoffer.CredentialOfferStorage.CredentialOfferState;
import org.keycloak.protocol.oid4vc.issuance.keybinding.CNonceHandler;
import org.keycloak.protocol.oid4vc.issuance.keybinding.JwtCNonceHandler;
import org.keycloak.protocol.oid4vc.issuance.keybinding.ProofValidator;
@@ -103,8 +107,6 @@ import org.keycloak.protocol.oid4vc.model.VerifiableCredential;
import org.keycloak.protocol.oid4vc.utils.ClaimsPathPointer;
import org.keycloak.protocol.oidc.grants.PreAuthorizedCodeGrantType;
import org.keycloak.protocol.oidc.grants.PreAuthorizedCodeGrantTypeFactory;
import org.keycloak.protocol.oidc.utils.OAuth2Code;
import org.keycloak.protocol.oidc.utils.OAuth2CodeParser;
import org.keycloak.representations.AccessToken;
import org.keycloak.representations.dpop.DPoP;
import org.keycloak.saml.processing.api.util.DeflateUtil;
@@ -130,6 +132,14 @@ import org.apache.http.client.methods.HttpOptions;
import org.apache.http.client.methods.HttpPost;
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;
import static org.keycloak.protocol.oid4vc.model.ErrorType.UNKNOWN_CREDENTIAL_IDENTIFIER;
/**
* Provides the (REST-)endpoints required for the OID4VCI protocol.
* <p>
@@ -148,12 +158,6 @@ public class OID4VCIssuerEndpoint {
*/
public static final String CREDENTIAL_CONFIGURATION_IDS_NOTE = "CREDENTIAL_CONFIGURATION_IDS";
/**
* Prefix for session note keys that store the mapping between credential identifiers and configuration IDs.
* This is used to store mappings generated during authorization details processing.
*/
public static final String CREDENTIAL_IDENTIFIER_PREFIX = "credential_identifier_";
/**
* Prefix for session note keys that store authorization details claims.
* This is used to store claims from authorization details for later use during credential issuance.
@@ -318,82 +322,165 @@ public class OID4VCIssuerEndpoint {
}
/**
* Provides the URI to the OID4VCI compliant credentials offer
* Creates a Credential Offer Uri that is bound to the calling user.
*/
public Response getCredentialOfferURI(String credConfigId) {
UserSessionModel userSession = getAuthenticatedClientSession().getUserSession();
return getCredentialOfferURI(credConfigId, true, userSession.getLoginUsername());
}
/**
* Creates a Credential Offer Uri that is bound to a specific user.
*/
public Response getCredentialOfferURI(String credConfigId, boolean preAuthorized, String targetUser) {
return getCredentialOfferURI(credConfigId, preAuthorized, null, targetUser, OfferUriType.URI, 0, 0);
}
/**
* Creates a Credential Offer Uri that can be pre-authorized and hence bound to a specific client/user id.
* <p>
* Credential Offer Validity Matrix for the supported request parameters "pre_authorized", "client_id", "user_id" combinations.
* </p>
* +----------+-----------+---------+---------+-----------------------------------------------------+
* | pre-auth | clientId | userId | Valid | Notes |
* +----------+-----------+---------+---------+-----------------------------------------------------+
* | no | no | no | yes | Generic offer; any logged-in user may redeem. |
* | no | no | yes | yes | Offer restricted to a specific user. |
* | no | yes | no | yes | Bound to client; user determined at login. |
* | no | yes | yes | yes | Bound to both client and user. |
* +----------+-----------+---------+---------+-----------------------------------------------------+
* | yes | no | no | no | Pre-auth requires a user subject; missing userId. |
* | yes | yes | no | no | Same as above; userId required. |
* | yes | no | yes | yes | Pre-auth for a specific user; client unconstrained. |
* | yes | yes | yes | yes | Fully constrained: user + client. |
* +----------+-----------+---------+---------+-----------------------------------------------------+
*
* @param credConfigId A valid credential configuration id
* @param preAuthorized A flag whether the offer should be pre-authorized (requires targetUser)
* @param appClientId The client id that the offer is authorized for
* @param appUserId The user id that the offer is authorized for
* @param type The response type, which can be 'uri' or 'qr-code'
* @param width The width of the QR code image
* @param height The height of the QR code image
* @see https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#name-credential-offer-endpoint
*/
@GET
@Produces({MediaType.APPLICATION_JSON, RESPONSE_TYPE_IMG_PNG})
@Path("credential-offer-uri")
public Response getCredentialOfferURI(@QueryParam("credential_configuration_id") String vcId, @QueryParam("type") @DefaultValue("uri") OfferUriType type, @QueryParam("width") @DefaultValue("200") int width, @QueryParam("height") @DefaultValue("200") int height) {
public Response getCredentialOfferURI(
@QueryParam("credential_configuration_id") String credConfigId,
@QueryParam("pre_authorized") @DefaultValue("true") boolean preAuthorized,
@QueryParam("client_id") String appClientId,
@QueryParam("user_id") String appUserId,
@QueryParam("type") @DefaultValue("uri") OfferUriType type,
@QueryParam("width") @DefaultValue("200") int width,
@QueryParam("height") @DefaultValue("200") int height
) {
configureCors(true);
AuthenticatedClientSessionModel clientSession = getAuthenticatedClientSession();
cors.allowedOrigins(session, clientSession.getClient());
UserModel userModel = clientSession.getUserSession().getUser();
ClientModel clientModel = clientSession.getClient();
RealmModel realmModel = clientModel.getRealm();
cors.allowedOrigins(session, clientModel);
checkClientEnabled();
Map<String, SupportedCredentialConfiguration> credentialsMap = OID4VCIssuerWellKnownProvider.getSupportedCredentials(session);
LOGGER.debugf("Get an offer for %s", vcId);
if (!credentialsMap.containsKey(vcId)) {
LOGGER.debugf("No credential with id %s exists.", vcId);
LOGGER.debugf("Supported credentials are %s.", credentialsMap);
throw new CorsErrorResponseException(
cors,
ErrorType.INVALID_CREDENTIAL_REQUEST.toString(),
"Invalid credential configuration ID",
Response.Status.BAD_REQUEST);
// Check required role to create a credential offer
//
boolean hasCredentialOfferRole = userModel.getRoleMappingsStream()
.anyMatch(rm -> rm.getName().equals(CREDENTIAL_OFFER_CREATE.getName()));
if (!hasCredentialOfferRole) {
var errorMessage = "Credential offer creation requires role: " + CREDENTIAL_OFFER_CREATE.getName();
throw new CorsErrorResponseException(cors,
INVALID_CREDENTIAL_OFFER_REQUEST.toString(), errorMessage, Response.Status.FORBIDDEN);
}
SupportedCredentialConfiguration supportedCredentialConfiguration = credentialsMap.get(vcId);
// calculate the expiration of the preAuthorizedCode. The sessionCode will also expire at that time.
int expiration = timeProvider.currentTimeSeconds() + preAuthorizedCodeLifeSpan;
String preAuthorizedCode = generateAuthorizationCodeForClientSession(expiration, clientSession);
LOGGER.debugf("Get an offer for %s", credConfigId);
CredentialsOffer theOffer = new CredentialsOffer()
// Check whether given client/user ids actually exist
//
if (appClientId != null && session.clients().getClientByClientId(realmModel, appClientId) == null) {
var errorMessage = "No such client id: " + appClientId;
throw new CorsErrorResponseException(cors,
INVALID_CREDENTIAL_OFFER_REQUEST.toString(), errorMessage, Response.Status.BAD_REQUEST);
}
if (appUserId != null && session.users().getUserByUsername(realmModel, appUserId) == null) {
var errorMessage = "No such user id: " + appUserId;
throw new CorsErrorResponseException(cors,
INVALID_CREDENTIAL_OFFER_REQUEST.toString(), errorMessage, Response.Status.BAD_REQUEST);
}
if (preAuthorized) {
if (appClientId == null) {
appClientId = clientModel.getClientId();
LOGGER.warnf("Using fallback client id for credential offer: %s", appClientId);
}
if (appUserId == null) {
var errorMessage = "Pre-Authorized credential offer requires a target user";
throw new CorsErrorResponseException(cors,
INVALID_CREDENTIAL_OFFER_REQUEST.toString(), errorMessage, Response.Status.BAD_REQUEST);
}
}
// Check whether the credential configuration exists in available client scopes
//
List<String> availableInClientScopes = session.clientScopes()
.getClientScopesByProtocol(realmModel, OID4VC_PROTOCOL)
.map(it -> it.getAttribute(CredentialScopeModel.CONFIGURATION_ID))
.toList();
if (!availableInClientScopes.contains(credConfigId)) {
var errorMessage = "Invalid credential configuration id: " + credConfigId;
LOGGER.debugf("%s not found in supported credential config ids: %s", credConfigId, availableInClientScopes);
throw new CorsErrorResponseException(cors,
INVALID_CREDENTIAL_OFFER_REQUEST.toString(), errorMessage, Response.Status.BAD_REQUEST);
}
CredentialsOffer credOffer = new CredentialsOffer()
.setCredentialIssuer(OID4VCIssuerWellKnownProvider.getIssuer(session.getContext()))
.setCredentialConfigurationIds(List.of(supportedCredentialConfiguration.getId()))
.setGrants(
new PreAuthorizedGrant()
.setPreAuthorizedCode(
new PreAuthorizedCode()
.setPreAuthorizedCode(preAuthorizedCode)));
.setCredentialConfigurationIds(List.of(credConfigId));
String sessionCode = generateCodeForSession(expiration, clientSession);
try {
clientSession.setNote(sessionCode, JsonSerialization.mapper.writeValueAsString(theOffer));
int expiration = timeProvider.currentTimeSeconds() + preAuthorizedCodeLifeSpan;
CredentialOfferState offerState = new CredentialOfferState(credOffer, appClientId, appUserId, expiration);
// Store the credential configuration IDs in a predictable location for token processing
// This allows the authorization details processor to easily retrieve the configuration IDs
// without having to search through all session notes or parse the full credential offer
String credentialConfigIdsJson = JsonSerialization.mapper.writeValueAsString(theOffer.getCredentialConfigurationIds());
clientSession.setNote(CREDENTIAL_CONFIGURATION_IDS_NOTE, credentialConfigIdsJson);
LOGGER.debugf("Stored credential configuration IDs for token processing: %s", credentialConfigIdsJson);
} catch (JsonProcessingException e) {
LOGGER.errorf("Could not convert the offer POJO to JSON: %s", e.getMessage());
throw new CorsErrorResponseException(
cors,
ErrorType.INVALID_CREDENTIAL_REQUEST.toString(),
"Failed to process credential offer",
Response.Status.BAD_REQUEST);
if (preAuthorized) {
String code = "urn:oid4vci:code:" + SecretGenerator.getInstance().randomString(64);
credOffer.setGrants(new PreAuthorizedGrant().setPreAuthorizedCode(
new PreAuthorizedCode().setPreAuthorizedCode(code)));
}
CredentialOfferStorage offerStorage = session.getProvider(CredentialOfferStorage.class);
offerStorage.putOfferState(session, offerState);
LOGGER.debugf("Stored credential offer state: [ids=%s, cid=%s, uid=%s, nonce=%s]",
credOffer.getCredentialConfigurationIds(), offerState.getClientId(), offerState.getUserId(), offerState.getNonce());
// Store the credential configuration IDs in a predictable location for token processing
// This allows the authorization details processor to easily retrieve the configuration IDs
// without having to search through all session notes or parse the full credential offer
String credentialConfigIdsJson = JsonSerialization.valueAsString(credOffer.getCredentialConfigurationIds());
clientSession.setNote(CREDENTIAL_CONFIGURATION_IDS_NOTE, credentialConfigIdsJson);
LOGGER.debugf("Stored credential configuration IDs for token processing: %s", credentialConfigIdsJson);
return switch (type) {
case URI -> getOfferUriAsUri(sessionCode);
case QR_CODE -> getOfferUriAsQr(sessionCode, width, height);
case URI -> getOfferUriAsUri(offerState.getNonce());
case QR_CODE -> getOfferUriAsQr(offerState.getNonce(), width, height);
};
}
private Response getOfferUriAsUri(String sessionCode) {
private Response getOfferUriAsUri(String nonce) {
CredentialOfferURI credentialOfferURI = new CredentialOfferURI()
.setIssuer(OID4VCIssuerWellKnownProvider.getIssuer(session.getContext()) + "/protocol/" + OID4VCLoginProtocolFactory.PROTOCOL_ID + "/" + CREDENTIAL_OFFER_PATH)
.setNonce(sessionCode);
.setNonce(nonce);
return cors.add(Response.ok()
.type(MediaType.APPLICATION_JSON)
.entity(credentialOfferURI));
}
private Response getOfferUriAsQr(String sessionCode, int width, int height) {
private Response getOfferUriAsQr(String nonce, int width, int height) {
QRCodeWriter qrCodeWriter = new QRCodeWriter();
String encodedOfferUri = URLEncoder.encode(OID4VCIssuerWellKnownProvider.getIssuer(session.getContext()) + "/protocol/" + OID4VCLoginProtocolFactory.PROTOCOL_ID + "/" + CREDENTIAL_OFFER_PATH + sessionCode, StandardCharsets.UTF_8);
String encodedOfferUri = URLEncoder.encode(OID4VCIssuerWellKnownProvider.getIssuer(session.getContext()) + "/protocol/" + OID4VCLoginProtocolFactory.PROTOCOL_ID + "/" + CREDENTIAL_OFFER_PATH + nonce, StandardCharsets.UTF_8);
try {
BitMatrix bitMatrix = qrCodeWriter.encode("openid-credential-offer://?credential_offer_uri=" + encodedOfferUri, BarcodeFormat.QR_CODE, width, height);
ByteArrayOutputStream bos = new ByteArrayOutputStream();
@@ -434,19 +521,45 @@ public class OID4VCIssuerEndpoint {
*/
@GET
@Produces(MediaType.APPLICATION_JSON)
@Path(CREDENTIAL_OFFER_PATH + "{sessionCode}")
public Response getCredentialOffer(@PathParam("sessionCode") String sessionCode) {
@Path(CREDENTIAL_OFFER_PATH + "{nonce}")
public Response getCredentialOffer(@PathParam("nonce") String nonce) {
configureCors(false);
if (sessionCode == null) {
throw new BadRequestException(getErrorResponse(ErrorType.INVALID_CREDENTIAL_REQUEST));
if (nonce == null) {
var errorMessage = "No credential offer nonce";
throw new BadRequestException(getErrorResponse(INVALID_CREDENTIAL_OFFER_REQUEST, errorMessage));
}
CredentialsOffer credentialsOffer = getOfferFromSessionCode(sessionCode);
LOGGER.debugf("Responding with offer: %s", credentialsOffer);
RealmModel realm = session.getContext().getRealm();
return cors.add(Response.ok()
.entity(credentialsOffer));
EventBuilder eventBuilder = new EventBuilder(realm, session, session.getContext().getConnection());
// Retrieve the associated credential offer state
//
CredentialOfferStorage offerStorage = session.getProvider(CredentialOfferStorage.class);
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);
throw new BadRequestException(getErrorResponse(INVALID_CREDENTIAL_OFFER_REQUEST, errorMessage));
}
// We treat the credential offer URI as an unprotected capability URL and rely solely on the later authorization step
// i.e. an authenticated client/user session is not required nor checked against the offer state
CredentialsOffer credOffer = offerState.getCredentialsOffer();
LOGGER.debugf("Found credential offer state: [ids=%s, cid=%s, uid=%s, nonce=%s]",
credOffer.getCredentialConfigurationIds(), offerState.getClientId(), offerState.getUserId(), offerState.getNonce());
if (offerState.isExpired()) {
var errorMessage = "Credential offer already expired";
eventBuilder.event(INTROSPECT_TOKEN_ERROR).detail(Details.REASON, errorMessage).error(Errors.EXPIRED_CODE);
throw new BadRequestException(getErrorResponse(INVALID_CREDENTIAL_OFFER_REQUEST, errorMessage));
}
LOGGER.debugf("Responding with offer: %s", JsonSerialization.valueAsString(credOffer));
return cors.add(Response.ok().entity(credOffer));
}
private void checkScope(CredentialScopeModel requestedCredential) {
@@ -487,7 +600,7 @@ public class OID4VCIssuerEndpoint {
if (requestPayload == null || requestPayload.trim().isEmpty()) {
String errorMessage = "Request payload is null or empty.";
LOGGER.debug(errorMessage);
throw new BadRequestException(getErrorResponse(ErrorType.INVALID_CREDENTIAL_REQUEST, errorMessage));
throw new BadRequestException(getErrorResponse(INVALID_CREDENTIAL_REQUEST, errorMessage));
}
cors = Cors.builder().auth().allowedMethods(HttpPost.METHOD_NAME).auth().exposedHeaders(Cors.ACCESS_CONTROL_ALLOW_METHODS);
@@ -551,75 +664,85 @@ public class OID4VCIssuerEndpoint {
// Both credential_configuration_id and credential_identifier are optional.
// If the credential_configuration_id is present, credential_identifier can't be present.
// But this implementation will tolerate the presence of both, waiting for clarity in specifications.
// This implementation will privilege the presence of the credential_configuration_id.
String requestedCredentialConfigurationId = credentialRequestVO.getCredentialConfigurationId();
String requestedCredentialIdentifier = credentialRequestVO.getCredentialIdentifier();
// This implementation will privilege the presence of credential_identifier.
String credentialIdentifier = credentialRequestVO.getCredentialIdentifier();
String credentialConfigurationId = credentialRequestVO.getCredentialConfigurationId();
// Check if at least one of both is available.
if (requestedCredentialConfigurationId == null && requestedCredentialIdentifier == null) {
if (credentialIdentifier == null && credentialConfigurationId == null) {
LOGGER.debugf("Missing both credential_configuration_id and credential_identifier. At least one must be specified.");
throw new BadRequestException(getErrorResponse(ErrorType.MISSING_CREDENTIAL_IDENTIFIER_AND_CONFIGURATION_ID));
}
CredentialScopeModel requestedCredential;
// If credential_identifier is provided, retrieve the mapping from client session
if (credentialRequestVO.getCredentialIdentifier() != null) {
String mappingKey = CREDENTIAL_IDENTIFIER_PREFIX + credentialRequestVO.getCredentialIdentifier();
// When the CredentialRequest contains a credential identifier the caller must have gone through the
// CredentialOffer process or otherwise have set up a valid CredentialOfferState
// First try to get the client session and look for the mapping there
if (credentialIdentifier != null) {
// Retrieve the associated credential offer state
//
CredentialOfferStorage offerStorage = session.getProvider(CredentialOfferStorage.class);
CredentialOfferState offerState = offerStorage.findOfferStateByCredentialId(session, credentialIdentifier);
if (offerState == null) {
var errorMessage = "No credential offer state for credential id: " + credentialIdentifier;
throw new BadRequestException(getErrorResponse(UNKNOWN_CREDENTIAL_IDENTIFIER, errorMessage));
}
// Get the credential_configuration_id from AuthorizationDetails
//
OID4VCAuthorizationDetailsResponse authDetails = offerState.getAuthorizationDetails();
String credConfigId = authDetails.getCredentialConfigurationId();
if (credConfigId == null) {
var errorMessage = "No credential_configuration_id in AuthorizationDetails";
throw new BadRequestException(getErrorResponse(UNKNOWN_CREDENTIAL_CONFIGURATION, errorMessage));
}
// Find the credential configuration in the Issuer's metadata
//
SupportedCredentialConfiguration credConfig = OID4VCIssuerWellKnownProvider.getSupportedCredentials(session).get(credConfigId);
if (credConfig == null) {
var errorMessage = "Mapped credential configuration not found: " + credConfigId;
throw new BadRequestException(getErrorResponse(UNKNOWN_CREDENTIAL_CONFIGURATION, errorMessage));
}
// Verify the user login session
//
UserSessionModel userSession = authResult.session();
AuthenticatedClientSessionModel clientSession = userSession.getAuthenticatedClientSessionByClient(authResult.client().getId());
String mappedCredentialConfigurationId = null;
if (clientSession != null) {
mappedCredentialConfigurationId = clientSession.getNote(mappingKey);
if (mappedCredentialConfigurationId != null) {
LOGGER.debugf("Found credential configuration ID mapping in client session for identifier %s: %s",
credentialRequestVO.getCredentialIdentifier(), mappedCredentialConfigurationId);
}
UserModel userModel = userSession.getUser();
if (!userModel.getUsername().equals(offerState.getUserId())) {
var errorMessage = "Unexpected login user: " + userModel.getUsername();
LOGGER.errorf(errorMessage + " != %s", offerState.getUserId());
throw new BadRequestException(getErrorResponse(ErrorType.INVALID_CREDENTIAL_REQUEST, errorMessage));
}
if (mappedCredentialConfigurationId != null) {
// Use the mapped credential configuration ID to find the credential scope
Map<String, SupportedCredentialConfiguration> supportedCredentials = OID4VCIssuerWellKnownProvider.getSupportedCredentials(session);
if (supportedCredentials.containsKey(mappedCredentialConfigurationId)) {
SupportedCredentialConfiguration config = supportedCredentials.get(mappedCredentialConfigurationId);
ClientModel client = session.getContext().getClient();
Map<String, ClientScopeModel> clientScopes = client.getClientScopes(false);
ClientScopeModel clientScope = clientScopes.get(config.getScope());
if (clientScope != null) {
requestedCredential = new CredentialScopeModel(clientScope);
LOGGER.debugf("Successfully mapped credential identifier %s to configuration %s",
credentialRequestVO.getCredentialIdentifier(), mappedCredentialConfigurationId);
} else {
LOGGER.errorf("Client scope not found for mapped credential configuration: %s", config.getScope());
throw new BadRequestException(getErrorResponse(ErrorType.UNKNOWN_CREDENTIAL_CONFIGURATION));
}
} else {
LOGGER.errorf("Mapped credential configuration not found: %s", mappedCredentialConfigurationId);
throw new BadRequestException(getErrorResponse(ErrorType.UNKNOWN_CREDENTIAL_CONFIGURATION));
}
} else {
// No mapping found, try to use credential_identifier as a direct scope name
LOGGER.debugf("No mapping found for credential identifier %s, trying direct scope lookup",
credentialRequestVO.getCredentialIdentifier());
try {
requestedCredential = credentialRequestVO.findCredentialScope(session).orElseThrow(() -> {
LOGGER.errorf("Credential scope not found for identifier: %s", credentialRequestVO.getCredentialIdentifier());
return new BadRequestException(getErrorResponse(ErrorType.UNKNOWN_CREDENTIAL_IDENTIFIER));
});
} catch (Exception e) {
LOGGER.errorf(e, "Failed to find credential scope for identifier: %s", credentialRequestVO.getCredentialIdentifier());
throw new BadRequestException(getErrorResponse(ErrorType.UNKNOWN_CREDENTIAL_IDENTIFIER));
}
// 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());
throw new BadRequestException(getErrorResponse(ErrorType.INVALID_CREDENTIAL_REQUEST, errorMessage));
}
} else if (credentialRequestVO.getCredentialConfigurationId() != null) {
// Find the configured scope in the login client
//
ClientScopeModel clientScope = clientModel.getClientScopes(false).get(credConfig.getScope());
if (clientScope == null) {
var errorMessage = String.format("Client scope not found: %s", credConfig.getScope());
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());
} else if (credentialConfigurationId != null) {
// Use credential_configuration_id for direct lookup
requestedCredential = credentialRequestVO.findCredentialScope(session).orElseThrow(() -> {
LOGGER.errorf("Credential scope not found for configuration ID: %s", credentialRequestVO.getCredentialConfigurationId());
return new BadRequestException(getErrorResponse(ErrorType.UNKNOWN_CREDENTIAL_CONFIGURATION));
var errorMessage = "Credential scope not found for configuration id: " + credentialConfigurationId;
return new BadRequestException(getErrorResponse(ErrorType.UNKNOWN_CREDENTIAL_CONFIGURATION, errorMessage));
});
} else {
// Neither provided - this should not happen due to earlier validation
@@ -708,7 +831,7 @@ public class OID4VCIssuerEndpoint {
} catch (JsonProcessingException e) {
String errorMessage = "Failed to parse JSON request: " + e.getMessage();
LOGGER.debug(errorMessage);
throw new BadRequestException(getErrorResponse(ErrorType.INVALID_CREDENTIAL_REQUEST, errorMessage));
throw new BadRequestException(getErrorResponse(INVALID_CREDENTIAL_REQUEST, errorMessage));
}
}
@@ -828,7 +951,7 @@ public class OID4VCIssuerEndpoint {
if (credentialRequest.getProof() != null && credentialRequest.getProofs() != null) {
String message = "Both 'proof' and 'proofs' must not be present at the same time";
LOGGER.debug(message);
throw new BadRequestException(getErrorResponse(ErrorType.INVALID_CREDENTIAL_REQUEST, message));
throw new BadRequestException(getErrorResponse(INVALID_CREDENTIAL_REQUEST, message));
}
if (credentialRequest.getProof() != null) {
@@ -1160,39 +1283,6 @@ public class OID4VCIssuerEndpoint {
return new CredentialScopeModel(clientScopeModel);
}
private String generateCodeForSession(int expiration, AuthenticatedClientSessionModel clientSession) {
String codeId = SecretGenerator.getInstance().randomString();
String nonce = SecretGenerator.getInstance().randomString();
OAuth2Code oAuth2Code = new OAuth2Code(codeId, expiration, nonce, CREDENTIAL_OFFER_URI_CODE_SCOPE, null, null, null, null,
clientSession.getUserSession().getId());
return OAuth2CodeParser.persistCode(session, clientSession, oAuth2Code);
}
private CredentialsOffer getOfferFromSessionCode(String sessionCode) {
EventBuilder eventBuilder = new EventBuilder(session.getContext().getRealm(), session,
session.getContext().getConnection());
OAuth2CodeParser.ParseResult result = OAuth2CodeParser.parseCode(session, sessionCode,
session.getContext().getRealm(),
eventBuilder);
if (result.isExpiredCode() || result.isIllegalCode() || !result.getCodeData().getScope().equals(CREDENTIAL_OFFER_URI_CODE_SCOPE)) {
throw new BadRequestException(getErrorResponse(ErrorType.INVALID_TOKEN));
}
try {
String offer = result.getClientSession().getNote(sessionCode);
return JsonSerialization.mapper.readValue(offer, CredentialsOffer.class);
} catch (JsonProcessingException e) {
LOGGER.errorf("Could not convert JSON to POJO: %s", e);
throw new BadRequestException(getErrorResponse(ErrorType.INVALID_TOKEN));
} finally {
result.getClientSession().removeNote(sessionCode);
}
}
private String generateAuthorizationCodeForClientSession(int expiration, AuthenticatedClientSessionModel clientSessionModel) {
return PreAuthorizedCodeGrantType.getPreAuthorizedCode(session, clientSessionModel, expiration);
}
private Response getErrorResponse(ErrorType errorType) {
return getErrorResponse(errorType, null);
}
@@ -1358,12 +1448,12 @@ public class OID4VCIssuerEndpoint {
String errorMessage = e.getMessage();
if (errorMessage.contains("Mandatory claim not found:")) {
LOGGER.errorf("Mandatory claim missing during claims filtering for scope %s: %s", scope, errorMessage);
throw new BadRequestException(getErrorResponse(ErrorType.INVALID_CREDENTIAL_REQUEST,
throw new BadRequestException(getErrorResponse(INVALID_CREDENTIAL_REQUEST,
"Credential issuance failed: " + errorMessage +
". The requested mandatory claim is not available in the user profile."));
} else {
LOGGER.errorf("Claims filtering error for scope %s: %s", scope, errorMessage);
throw new BadRequestException(getErrorResponse(ErrorType.INVALID_CREDENTIAL_REQUEST,
throw new BadRequestException(getErrorResponse(INVALID_CREDENTIAL_REQUEST,
"Credential issuance failed: " + errorMessage));
}
} catch (BadRequestException e) {

View File

@@ -28,7 +28,7 @@ import jakarta.ws.rs.core.UriBuilder;
import jakarta.ws.rs.core.UriInfo;
import org.keycloak.common.util.Time;
import org.keycloak.constants.Oid4VciConstants;
import org.keycloak.constants.OID4VCIConstants;
import org.keycloak.crypto.CryptoUtils;
import org.keycloak.crypto.KeyUse;
import org.keycloak.crypto.KeyWrapper;
@@ -64,7 +64,7 @@ import org.jboss.logging.Logger;
import static org.keycloak.OID4VCConstants.SIGNED_METADATA_JWT_TYPE;
import static org.keycloak.OID4VCConstants.WELL_KNOWN_OPENID_CREDENTIAL_ISSUER;
import static org.keycloak.constants.Oid4VciConstants.BATCH_CREDENTIAL_ISSUANCE_BATCH_SIZE;
import static org.keycloak.constants.OID4VCIConstants.BATCH_CREDENTIAL_ISSUANCE_BATCH_SIZE;
import static org.keycloak.crypto.KeyType.RSA;
import static org.keycloak.jose.jwk.RSAPublicJWK.RS256;
@@ -456,7 +456,7 @@ public class OID4VCIssuerWellKnownProvider implements WellKnownProvider {
RealmModel realm = keycloakSession.getContext().getRealm();
Map<String, SupportedCredentialConfiguration> supportedCredentialConfigurations =
keycloakSession.clientScopes()
.getClientScopesByProtocol(realm, Oid4VciConstants.OID4VC_PROTOCOL)
.getClientScopesByProtocol(realm, OID4VCIConstants.OID4VC_PROTOCOL)
.map(CredentialScopeModel::new)
.map(clientScope -> {
return SupportedCredentialConfiguration.parse(keycloakSession,

View File

@@ -23,7 +23,7 @@ import java.time.ZonedDateTime;
import java.time.temporal.ChronoUnit;
import java.util.Locale;
import org.keycloak.constants.Oid4VciConstants;
import org.keycloak.constants.OID4VCIConstants;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.utils.StringUtil;
@@ -71,9 +71,9 @@ public class TimeClaimNormalizer {
}
public TimeClaimNormalizer(RealmModel realm) {
this.strategy = parseStrategy(realm.getAttribute(Oid4VciConstants.TIME_CLAIMS_STRATEGY));
this.randomizeWindowSeconds = parseRandomizeWindow(realm.getAttribute(Oid4VciConstants.TIME_RANDOMIZE_WINDOW_SECONDS));
this.roundUnit = parseRoundUnit(realm.getAttribute(Oid4VciConstants.TIME_ROUND_UNIT));
this.strategy = parseStrategy(realm.getAttribute(OID4VCIConstants.TIME_CLAIMS_STRATEGY));
this.randomizeWindowSeconds = parseRandomizeWindow(realm.getAttribute(OID4VCIConstants.TIME_RANDOMIZE_WINDOW_SECONDS));
this.roundUnit = parseRoundUnit(realm.getAttribute(OID4VCIConstants.TIME_ROUND_UNIT));
}
TimeClaimNormalizer(Strategy strategy, Long randomizeWindowSeconds, RoundUnit roundUnit) {

View File

@@ -0,0 +1,135 @@
/*
* Copyright 2025 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.keycloak.protocol.oid4vc.issuance.credentialoffer;
import java.beans.Transient;
import java.util.Optional;
import org.keycloak.common.util.Base64Url;
import org.keycloak.common.util.Time;
import org.keycloak.models.KeycloakSession;
import org.keycloak.protocol.oid4vc.issuance.OID4VCAuthorizationDetailsResponse;
import org.keycloak.protocol.oid4vc.model.CredentialsOffer;
import org.keycloak.protocol.oid4vc.model.PreAuthorizedCode;
import org.keycloak.protocol.oid4vc.model.PreAuthorizedGrant;
import org.keycloak.provider.Provider;
import org.keycloak.saml.RandomSecret;
import com.fasterxml.jackson.annotation.JsonInclude;
public interface CredentialOfferStorage extends Provider {
@JsonInclude(JsonInclude.Include.NON_NULL)
class CredentialOfferState {
private CredentialsOffer credentialsOffer;
private String clientId;
private String userId;
private String nonce;
private int expiration;
private OID4VCAuthorizationDetailsResponse authorizationDetails;
public CredentialOfferState(CredentialsOffer credOffer, String clientId, String userId, int expiration) {
this.credentialsOffer = credOffer;
this.clientId = clientId;
this.userId = userId;
this.expiration = expiration;
this.nonce = Base64Url.encode(RandomSecret.createRandomSecret(64));
}
// For json serialization
CredentialOfferState() {
}
@Transient
public Optional<String> getPreAuthorizedCode() {
return Optional.ofNullable(credentialsOffer.getGrants())
.map(PreAuthorizedGrant::getPreAuthorizedCode)
.map(PreAuthorizedCode::getPreAuthorizedCode);
}
@Transient
public boolean isExpired() {
int currentTime = Time.currentTime();
return currentTime > expiration;
}
public CredentialsOffer getCredentialsOffer() {
return credentialsOffer;
}
public String getClientId() {
return clientId;
}
public String getUserId() {
return userId;
}
public String getNonce() {
return nonce;
}
public int getExpiration() {
return expiration;
}
public OID4VCAuthorizationDetailsResponse getAuthorizationDetails() {
return authorizationDetails;
}
public void setAuthorizationDetails(OID4VCAuthorizationDetailsResponse authorizationDetails) {
this.authorizationDetails = authorizationDetails;
}
void setCredentialsOffer(CredentialsOffer credentialsOffer) {
this.credentialsOffer = credentialsOffer;
}
void setClientId(String clientId) {
this.clientId = clientId;
}
void setUserId(String userId) {
this.userId = userId;
}
void setNonce(String nonce) {
this.nonce = nonce;
}
void setExpiration(int expiration) {
this.expiration = expiration;
}
}
void putOfferState(KeycloakSession session, CredentialOfferState entry);
CredentialOfferState findOfferStateByNonce(KeycloakSession session, String nonce);
CredentialOfferState findOfferStateByCode(KeycloakSession session, String code);
CredentialOfferState findOfferStateByCredentialId(KeycloakSession session, String credId);
void replaceOfferState(KeycloakSession session, CredentialOfferState entry);
void removeOfferState(KeycloakSession session, CredentialOfferState entry);
@Override
default void close() {
}
}

View File

@@ -0,0 +1,26 @@
/*
* Copyright 2025 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.keycloak.protocol.oid4vc.issuance.credentialoffer;
import org.keycloak.provider.ProviderFactory;
public interface CredentialOfferStorageFactory extends ProviderFactory<CredentialOfferStorage> {
@Override
default void close() { }
}

View File

@@ -0,0 +1,26 @@
/*
* Copyright 2025 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.keycloak.protocol.oid4vc.issuance.credentialoffer;
import org.keycloak.provider.Spi;
public class CredentialOfferStorageSpi implements Spi {
@Override public String getName() { return "credential-offer-storage"; }
@Override public Class<CredentialOfferStorage> getProviderClass() { return CredentialOfferStorage.class; }
@Override public Class<CredentialOfferStorageFactory> getProviderFactoryClass() { return CredentialOfferStorageFactory.class; }
@Override public boolean isInternal() { return false; }
}

View File

@@ -0,0 +1,99 @@
/*
* Copyright 2025 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.keycloak.protocol.oid4vc.issuance.credentialoffer;
import java.util.Map;
import java.util.Optional;
import org.keycloak.models.KeycloakSession;
import org.keycloak.util.JsonSerialization;
class InMemoryCredentialOfferStorage implements CredentialOfferStorage {
private static final String ENTRY_KEY = "json";
@Override
public void putOfferState(KeycloakSession session, CredentialOfferState entry) {
String entryJson = JsonSerialization.valueAsString(entry);
session.singleUseObjects().put(entry.getNonce(), entry.getExpiration(), Map.of(ENTRY_KEY, entryJson));
entry.getPreAuthorizedCode().ifPresent(it -> {
session.singleUseObjects().put(it, entry.getExpiration(), Map.of(ENTRY_KEY, entryJson));
});
Optional.ofNullable(entry.getAuthorizationDetails()).ifPresent(it -> {
it.getCredentialIdentifiers().forEach( cid -> {
session.singleUseObjects().put(cid, entry.getExpiration(), Map.of(ENTRY_KEY, entryJson));
});
});
}
@Override
public CredentialOfferState findOfferStateByNonce(KeycloakSession session, String nonce) {
if (session.singleUseObjects().contains(nonce)) {
String entryJson = session.singleUseObjects().get(nonce).get(ENTRY_KEY);
return JsonSerialization.valueFromString(entryJson, CredentialOfferState.class);
}
return null;
}
@Override
public CredentialOfferState findOfferStateByCode(KeycloakSession session, String code) {
if (session.singleUseObjects().contains(code)) {
String entryJson = session.singleUseObjects().get(code).get(ENTRY_KEY);
return JsonSerialization.valueFromString(entryJson, CredentialOfferState.class);
}
return null;
}
@Override
public CredentialOfferState findOfferStateByCredentialId(KeycloakSession session, String credId) {
if (session.singleUseObjects().contains(credId)) {
String entryJson = session.singleUseObjects().get(credId).get(ENTRY_KEY);
return JsonSerialization.valueFromString(entryJson, CredentialOfferState.class);
}
return null;
}
public void replaceOfferState(KeycloakSession session, CredentialOfferState entry) {
String entryJson = JsonSerialization.valueAsString(entry);
session.singleUseObjects().replace(entry.getNonce(), Map.of(ENTRY_KEY, entryJson));
entry.getPreAuthorizedCode().ifPresent(it -> {
session.singleUseObjects().replace(it, Map.of(ENTRY_KEY, entryJson));
});
Optional.ofNullable(entry.getAuthorizationDetails()).ifPresent(it -> {
it.getCredentialIdentifiers().forEach( cid -> {
if (session.singleUseObjects().contains(cid)) {
session.singleUseObjects().replace(cid, Map.of(ENTRY_KEY, entryJson));
} else {
session.singleUseObjects().put(cid, entry.getExpiration(), Map.of(ENTRY_KEY, entryJson));
}
});
});
}
@Override
public void removeOfferState(KeycloakSession session, CredentialOfferState entry) {
session.singleUseObjects().remove(entry.getNonce());
entry.getPreAuthorizedCode().ifPresent(it -> {
session.singleUseObjects().remove(it);
});
Optional.ofNullable(entry.getAuthorizationDetails()).ifPresent(it -> {
it.getCredentialIdentifiers().forEach( cid -> {
session.singleUseObjects().remove(cid);
});
});
}
}

View File

@@ -0,0 +1,47 @@
/*
* Copyright 2025 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.keycloak.protocol.oid4vc.issuance.credentialoffer;
import org.keycloak.Config;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.KeycloakSessionFactory;
public class InMemoryCredentialOfferStorageFactory implements CredentialOfferStorageFactory {
private static CredentialOfferStorage INSTANCE;
@Override
public CredentialOfferStorage create(KeycloakSession session) {
if (INSTANCE == null) {
INSTANCE = new InMemoryCredentialOfferStorage();
}
return INSTANCE;
}
@Override
public void init(Config.Scope config) {
}
@Override
public void postInit(KeycloakSessionFactory factory) {
}
@Override
public String getId() {
return "in_memory";
}
}

View File

@@ -34,7 +34,7 @@ import jakarta.annotation.Nullable;
import org.keycloak.TokenVerifier;
import org.keycloak.common.VerificationException;
import org.keycloak.constants.Oid4VciConstants;
import org.keycloak.constants.OID4VCIConstants;
import org.keycloak.crypto.Algorithm;
import org.keycloak.crypto.KeyUse;
import org.keycloak.crypto.KeyWrapper;
@@ -58,7 +58,7 @@ import org.slf4j.LoggerFactory;
*/
public class JwtCNonceHandler implements CNonceHandler {
public static final String SOURCE_ENDPOINT = Oid4VciConstants.SOURCE_ENDPOINT;
public static final String SOURCE_ENDPOINT = OID4VCIConstants.SOURCE_ENDPOINT;
public static final int NONCE_DEFAULT_LENGTH = 50;
@@ -80,7 +80,7 @@ public class JwtCNonceHandler implements CNonceHandler {
RealmModel realm = keycloakSession.getContext().getRealm();
final String issuer = OID4VCIssuerWellKnownProvider.getIssuer(keycloakSession.getContext());
// TODO discussion about the attribute name to use
final Integer nonceLifetimeMillis = realm.getAttribute(Oid4VciConstants.C_NONCE_LIFETIME_IN_SECONDS, 60);
final Integer nonceLifetimeMillis = realm.getAttribute(OID4VCIConstants.C_NONCE_LIFETIME_IN_SECONDS, 60);
audiences = Optional.ofNullable(audiences).orElseGet(Collections::emptyList);
final Instant now = Instant.now();
final long expiresAt = now.plus(nonceLifetimeMillis, ChronoUnit.SECONDS).getEpochSecond();

View File

@@ -26,6 +26,7 @@ package org.keycloak.protocol.oid4vc.model;
*/
public enum ErrorType {
INVALID_CREDENTIAL_OFFER_REQUEST("invalid_credential_offer_request"),
INVALID_CREDENTIAL_REQUEST("invalid_credential_request"),
INVALID_TOKEN("invalid_token"),
UNKNOWN_CREDENTIAL_CONFIGURATION("unknown_credential_configuration"),

View File

@@ -160,7 +160,7 @@ public class SupportedCredentialConfiguration {
return null;
}
public CredentialConfigId deriveConfiId() {
public CredentialConfigId deriveConfigId() {
return CredentialConfigId.from(id);
}

View File

@@ -386,9 +386,9 @@ public class AuthorizationEndpoint extends AuthorizationEndpointBase {
}
// Store authorization_details from authorization/PAR request for later processing
String authorizationDetails = request.getAdditionalReqParams().get(OAuth2Constants.AUTHORIZATION_DETAILS_PARAM);
String authorizationDetails = request.getAdditionalReqParams().get(OAuth2Constants.AUTHORIZATION_DETAILS);
if (authorizationDetails != null) {
authenticationSession.setClientNote(OAuth2Constants.AUTHORIZATION_DETAILS_PARAM, authorizationDetails);
authenticationSession.setClientNote(OAuth2Constants.AUTHORIZATION_DETAILS, authorizationDetails);
}
}
}

View File

@@ -51,7 +51,7 @@ import org.keycloak.services.util.DefaultClientSessionContext;
import org.jboss.logging.Logger;
import static org.keycloak.OAuth2Constants.AUTHORIZATION_DETAILS_PARAM;
import static org.keycloak.OAuth2Constants.AUTHORIZATION_DETAILS;
import static org.keycloak.models.Constants.AUTHORIZATION_DETAILS_RESPONSE;
/**
@@ -217,12 +217,12 @@ public class AuthorizationCodeGrantType extends OAuth2GrantTypeBase {
// Process authorization_details using provider discovery (if present in request)
List<AuthorizationDetailsResponse> authorizationDetailsResponse = null;
if (formParams.getFirst(AUTHORIZATION_DETAILS_PARAM) != null) {
if (formParams.getFirst(AUTHORIZATION_DETAILS) != null) {
authorizationDetailsResponse = processAuthorizationDetails(userSession, clientSessionCtx);
if (authorizationDetailsResponse != null && !authorizationDetailsResponse.isEmpty()) {
clientSessionCtx.setAttribute(AUTHORIZATION_DETAILS_RESPONSE, authorizationDetailsResponse);
} else {
logger.debugf("No available AuthorizationDetailsProcessor being able to process authorization_details '%s'", formParams.getFirst(AUTHORIZATION_DETAILS_PARAM));
logger.debugf("No available AuthorizationDetailsProcessor being able to process authorization_details '%s'", formParams.getFirst(AUTHORIZATION_DETAILS));
}
}
@@ -251,7 +251,7 @@ public class AuthorizationCodeGrantType extends OAuth2GrantTypeBase {
protected void addCustomTokenResponseClaims(AccessTokenResponse res, ClientSessionContext clientSessionCtx) {
List<AuthorizationDetailsResponse> authDetailsResponse = clientSessionCtx.getAttribute(AUTHORIZATION_DETAILS_RESPONSE, List.class);
if (authDetailsResponse != null && !authDetailsResponse.isEmpty()) {
res.setOtherClaims(AUTHORIZATION_DETAILS_PARAM, authDetailsResponse);
res.setOtherClaims(AUTHORIZATION_DETAILS, authDetailsResponse);
}
}

View File

@@ -44,7 +44,7 @@ import org.keycloak.sessions.RootAuthenticationSessionModel;
import org.jboss.logging.Logger;
import static org.keycloak.OAuth2Constants.AUTHORIZATION_DETAILS_PARAM;
import static org.keycloak.OAuth2Constants.AUTHORIZATION_DETAILS;
/**
* OAuth 2.0 Client Credentials Grant
@@ -160,9 +160,9 @@ public class ClientCredentialsGrantType extends OAuth2GrantTypeBase {
* until RAR is fully implemented.
*/
private void setAuthorizationDetailsNoteIfIncluded(AuthenticationSessionModel authSession) {
String authorizationDetails = formParams.getFirst(AUTHORIZATION_DETAILS_PARAM);
String authorizationDetails = formParams.getFirst(AUTHORIZATION_DETAILS);
if (authorizationDetails != null) {
authSession.setClientNote(AUTHORIZATION_DETAILS_PARAM, authorizationDetails);
authSession.setClientNote(AUTHORIZATION_DETAILS, authorizationDetails);
}
}
}

View File

@@ -67,7 +67,7 @@ import org.keycloak.util.TokenUtil;
import org.jboss.logging.Logger;
import static org.keycloak.OAuth2Constants.AUTHORIZATION_DETAILS_PARAM;
import static org.keycloak.OAuth2Constants.AUTHORIZATION_DETAILS;
/**
* Base class for OAuth 2.0 grant types
@@ -278,7 +278,7 @@ public abstract class OAuth2GrantTypeBase implements OAuth2GrantType {
* @return the authorization details response if processing was successful, null otherwise
*/
protected List<AuthorizationDetailsResponse> processAuthorizationDetails(UserSessionModel userSession, ClientSessionContext clientSessionCtx) {
String authorizationDetailsParam = formParams.getFirst(AUTHORIZATION_DETAILS_PARAM);
String authorizationDetailsParam = formParams.getFirst(AUTHORIZATION_DETAILS);
if (authorizationDetailsParam != null) {
try {
return session.getKeycloakSessionFactory()
@@ -312,7 +312,7 @@ public abstract class OAuth2GrantTypeBase implements OAuth2GrantType {
*/
protected List<AuthorizationDetailsResponse> handleMissingAuthorizationDetails(UserSessionModel userSession, ClientSessionContext clientSessionCtx) {
try {
return session.getKeycloakSessionFactory()
var result = session.getKeycloakSessionFactory()
.getProviderFactoriesStream(AuthorizationDetailsProcessor.class)
.sorted((f1, f2) -> f2.order() - f1.order())
.map(f -> session.getProvider(AuthorizationDetailsProcessor.class, f.getId()))
@@ -320,6 +320,7 @@ public abstract class OAuth2GrantTypeBase implements OAuth2GrantType {
.filter(authzDetailsResponse -> authzDetailsResponse != null)
.findFirst()
.orElse(null);
return result;
} catch (RuntimeException e) {
logger.warnf(e, "Error when handling missing authorization_details");
return null;
@@ -337,7 +338,7 @@ public abstract class OAuth2GrantTypeBase implements OAuth2GrantType {
*/
protected List<AuthorizationDetailsResponse> processStoredAuthorizationDetails(UserSessionModel userSession, ClientSessionContext clientSessionCtx) throws CorsErrorResponseException {
// Check if authorization_details was stored during authorization request (e.g., from PAR)
String storedAuthDetails = clientSessionCtx.getClientSession().getNote(AUTHORIZATION_DETAILS_PARAM);
String storedAuthDetails = clientSessionCtx.getClientSession().getNote(AUTHORIZATION_DETAILS);
if (storedAuthDetails != null) {
logger.debugf("Found authorization_details in client session, processing it");
try {

View File

@@ -18,33 +18,36 @@
package org.keycloak.protocol.oidc.grants;
import java.util.List;
import java.util.UUID;
import java.util.Optional;
import jakarta.ws.rs.core.Response;
import org.keycloak.OAuth2Constants;
import org.keycloak.OAuthErrorException;
import org.keycloak.common.util.SecretGenerator;
import org.keycloak.events.Details;
import org.keycloak.events.Errors;
import org.keycloak.events.EventType;
import org.keycloak.models.AuthenticatedClientSessionModel;
import org.keycloak.models.ClientModel;
import org.keycloak.models.ClientSessionContext;
import org.keycloak.models.Constants;
import org.keycloak.models.KeycloakSession;
import org.keycloak.protocol.oidc.TokenManager;
import org.keycloak.models.UserSessionModel;
import org.keycloak.protocol.oid4vc.issuance.OID4VCAuthorizationDetailsResponse;
import org.keycloak.protocol.oid4vc.issuance.OID4VCIssuerEndpoint;
import org.keycloak.protocol.oid4vc.issuance.credentialoffer.CredentialOfferStorage;
import org.keycloak.protocol.oidc.OIDCLoginProtocol;
import org.keycloak.protocol.oidc.TokenManager.AccessTokenResponseBuilder;
import org.keycloak.protocol.oidc.rar.AuthorizationDetailsResponse;
import org.keycloak.protocol.oidc.utils.OAuth2Code;
import org.keycloak.protocol.oidc.utils.OAuth2CodeParser;
import org.keycloak.representations.AccessToken;
import org.keycloak.representations.AccessTokenResponse;
import org.keycloak.services.CorsErrorResponseException;
import org.keycloak.services.util.DefaultClientSessionContext;
import org.keycloak.util.JsonSerialization;
import org.keycloak.utils.MediaType;
import org.jboss.logging.Logger;
import static org.keycloak.OAuth2Constants.AUTHORIZATION_DETAILS_PARAM;
import static org.keycloak.OAuth2Constants.AUTHORIZATION_DETAILS;
import static org.keycloak.services.util.DefaultClientSessionContext.fromClientSessionAndScopeParameter;
public class PreAuthorizedCodeGrantType extends OAuth2GrantTypeBase {
@@ -63,72 +66,114 @@ public class PreAuthorizedCodeGrantType extends OAuth2GrantTypeBase {
if (code == null) {
// See: https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#name-token-request
String errorMessage = "Missing parameter: " + PreAuthorizedCodeGrantTypeFactory.CODE_REQUEST_PARAM;
event.detail(Details.REASON, errorMessage);
event.error(Errors.INVALID_CODE);
event.detail(Details.REASON, errorMessage).error(Errors.INVALID_CODE);
throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_REQUEST,
errorMessage, Response.Status.BAD_REQUEST);
}
OAuth2CodeParser.ParseResult result = OAuth2CodeParser.parseCode(session, code, realm, event);
if (result.isIllegalCode()) {
event.error(Errors.INVALID_CODE);
throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_GRANT, "Code not valid",
Response.Status.BAD_REQUEST);
var offerStorage = session.getProvider(CredentialOfferStorage.class);
var offerState = offerStorage.findOfferStateByCode(session, code);
if (offerState == null) {
var errorMessage = "No credential offer state for code: " + code;
event.detail(Details.REASON, errorMessage).error(Errors.INVALID_CODE);
throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_REQUEST,
errorMessage, Response.Status.BAD_REQUEST);
}
if (result.isExpiredCode()) {
if (offerState.isExpired()) {
event.error(Errors.EXPIRED_CODE);
throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_GRANT, "Code is expired",
Response.Status.BAD_REQUEST);
throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_GRANT,
"Code is expired", Response.Status.BAD_REQUEST);
}
AuthenticatedClientSessionModel clientSession = result.getClientSession();
ClientSessionContext sessionContext = DefaultClientSessionContext.fromClientSessionAndScopeParameter(clientSession,
OAuth2Constants.SCOPE_OPENID, session);
var credOffer = offerState.getCredentialsOffer();
var appUserId = offerState.getUserId();
var userModel = session.users().getUserByUsername(realm, appUserId);
if (userModel == null) {
var errorMessage = "No user model for: " + appUserId;
event.detail(Details.REASON, errorMessage).error(Errors.INVALID_CODE);
throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_REQUEST,
errorMessage, Response.Status.BAD_REQUEST);
}
var appClientId = offerState.getClientId();
ClientModel clientModel = realm.getClientByClientId(appClientId);
if (clientModel == null) {
var errorMessage = "No client model for: " + appClientId;
event.detail(Details.REASON, errorMessage).error(Errors.INVALID_CODE);
throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_REQUEST,
errorMessage, Response.Status.BAD_REQUEST);
}
UserSessionModel userSession = session.sessions().createUserSession(null, realm, userModel, userModel.getUsername(),
null, "pre-authorized-code", false, null,
null, UserSessionModel.SessionPersistenceState.PERSISTENT);
AuthenticatedClientSessionModel clientSession = session.sessions().createClientSession(realm, clientModel, userSession);
String credentialConfigurationIds = JsonSerialization.valueAsString(credOffer.getCredentialConfigurationIds());
clientSession.setNote(OID4VCIssuerEndpoint.CREDENTIAL_CONFIGURATION_IDS_NOTE, credentialConfigurationIds);
clientSession.setNote(OIDCLoginProtocol.ISSUER, credOffer.getCredentialIssuer());
clientSession.setNote(VC_ISSUANCE_FLOW, PreAuthorizedCodeGrantTypeFactory.GRANT_TYPE);
ClientSessionContext sessionContext = fromClientSessionAndScopeParameter(clientSession, OAuth2Constants.SCOPE_OPENID, session);
sessionContext.setAttribute(Constants.GRANT_TYPE, PreAuthorizedCodeGrantTypeFactory.GRANT_TYPE);
// set the client as retrieved from the pre-authorized session
session.getContext().setClient(result.getClientSession().getClient());
session.getContext().setClient(clientModel);
// Process authorization_details using provider discovery
List<AuthorizationDetailsResponse> authorizationDetailsResponses = processAuthorizationDetails(userSession, sessionContext);
LOGGER.infof("Initial authorization_details processing result: %s", authorizationDetailsResponses);
// If no authorization_details were processed from the request, try to generate them from credential offer
if (authorizationDetailsResponses == null || authorizationDetailsResponses.isEmpty()) {
authorizationDetailsResponses = handleMissingAuthorizationDetails(userSession, sessionContext);
}
authorizationDetailsResponses = Optional.ofNullable(authorizationDetailsResponses).orElse(List.of());
if (authorizationDetailsResponses.size() != 1) {
boolean emptyAuthDetails = authorizationDetailsResponses.isEmpty();
String errorMessage = (emptyAuthDetails ? "No" : "Multiple") + " authorization details";
event.detail(Details.REASON, errorMessage).error(Errors.INVALID_CODE);
throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_REQUEST,
errorMessage, Response.Status.BAD_REQUEST);
}
// Add authorization_details to the OfferState and otherClaims
var authDetails = (OID4VCAuthorizationDetailsResponse) authorizationDetailsResponses.get(0);
offerState.setAuthorizationDetails(authDetails);
offerStorage.replaceOfferState(session, offerState);
AccessToken accessToken = tokenManager.createClientAccessToken(session,
clientSession.getRealm(),
clientSession.getClient(),
clientSession.getUserSession().getUser(),
clientSession.getUserSession(),
userSession.getUser(),
userSession,
sessionContext);
TokenManager.AccessTokenResponseBuilder responseBuilder = tokenManager.responseBuilder(
accessToken.setOtherClaims(AUTHORIZATION_DETAILS, authorizationDetailsResponses);
AccessTokenResponseBuilder responseBuilder = tokenManager.responseBuilder(
clientSession.getRealm(),
clientSession.getClient(),
event,
session,
clientSession.getUserSession(),
userSession,
sessionContext).accessToken(accessToken);
// Process authorization_details using provider discovery
List<AuthorizationDetailsResponse> authorizationDetailsResponse = processAuthorizationDetails(clientSession.getUserSession(), sessionContext);
LOGGER.infof("Initial authorization_details processing result: %s", authorizationDetailsResponse);
// If no authorization_details were processed from the request, try to generate them from credential offer
if (authorizationDetailsResponse == null || authorizationDetailsResponse.isEmpty()) {
authorizationDetailsResponse = handleMissingAuthorizationDetails(clientSession.getUserSession(), sessionContext);
}
AccessTokenResponse tokenResponse;
try {
tokenResponse = responseBuilder.build();
tokenResponse.setOtherClaims(AUTHORIZATION_DETAILS, authorizationDetailsResponses);
} catch (RuntimeException re) {
if ("cannot get encryption KEK".equals(re.getMessage())) {
throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_REQUEST,
"cannot get encryption KEK", Response.Status.BAD_REQUEST);
String errorMessage = "Cannot get encryption KEK";
if (errorMessage.equals(re.getMessage())) {
throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_REQUEST, errorMessage, Response.Status.BAD_REQUEST);
} else {
throw re;
}
}
// If authorization_details is present, add it to otherClaims
if (authorizationDetailsResponse != null && !authorizationDetailsResponse.isEmpty()) {
tokenResponse.setOtherClaims(AUTHORIZATION_DETAILS_PARAM, authorizationDetailsResponse);
}
event.success();
return cors.allowAllOrigins().add(Response.ok(tokenResponse).type(MediaType.APPLICATION_JSON_TYPE));
}
@@ -137,20 +182,4 @@ public class PreAuthorizedCodeGrantType extends OAuth2GrantTypeBase {
public EventType getEventType() {
return EventType.CODE_TO_TOKEN;
}
/**
* Create a pre-authorized Code for the given client session.
*
* @param session - keycloak session to be used
* @param authenticatedClientSession - client session to be persisted
* @param expirationTime - expiration time of the code, the code should be short-lived
* @return the pre-authorized code
*/
public static String getPreAuthorizedCode(KeycloakSession session, AuthenticatedClientSessionModel authenticatedClientSession, int expirationTime) {
String codeId = UUID.randomUUID().toString();
String nonce = SecretGenerator.getInstance().randomString();
OAuth2Code oAuth2Code = new OAuth2Code(codeId, expirationTime, nonce, null, null, null, null, null,
authenticatedClientSession.getUserSession().getId());
return OAuth2CodeParser.persistCode(session, authenticatedClientSession, oAuth2Code);
}
}

View File

@@ -22,9 +22,9 @@ import java.util.Map;
/**
* Data associated with the oauth2 code.
*
* <p>
* Those data are typically valid just for the very short time - they're created at the point before we redirect to the application
* after successful and they're removed when application sends requests to the token endpoint (code-to-token endpoint) to exchange the
* and removed when application sends requests to the token endpoint (code-to-token endpoint) to exchange the
* single-use OAuth2 code parameter for those data.
*
* @author <a href="mailto:mposolda@redhat.com">Marek Posolda</a>
@@ -59,6 +59,18 @@ public class OAuth2Code {
private final String userSessionId;
public OAuth2Code(String id, int expiration, String nonce, String scope, String userSessionId) {
this.id = id;
this.expiration = expiration;
this.nonce = nonce;
this.scope = scope;
this.redirectUriParam = null;
this.codeChallenge = null;
this.codeChallengeMethod = null;
this.dpopJkt = null;
this.userSessionId = userSessionId;
}
public OAuth2Code(String id, int expiration, String nonce, String scope, String redirectUriParam,
String codeChallenge, String codeChallengeMethod, String dpopJkt, String userSessionId) {
this.id = id;
@@ -85,7 +97,7 @@ public class OAuth2Code {
}
public static final OAuth2Code deserializeCode(Map<String, String> data) {
public static OAuth2Code deserializeCode(Map<String, String> data) {
return new OAuth2Code(data);
}
@@ -93,7 +105,7 @@ public class OAuth2Code {
public Map<String, String> serializeCode() {
Map<String, String> result = new HashMap<>();
result.put(ID_NOTE, id.toString());
result.put(ID_NOTE, id);
result.put(EXPIRATION_NOTE, String.valueOf(expiration));
result.put(NONCE_NOTE, nonce);
result.put(SCOPE_NOTE, scope);
@@ -106,7 +118,6 @@ public class OAuth2Code {
return result;
}
public String getId() {
return id;
}

View File

@@ -40,13 +40,9 @@ public class OAuth2CodeParser {
private static final Pattern DOT = Pattern.compile("\\.");
/**
* Will persist the code to the cache and return the object with the codeData and code correctly set
*
* @param session
* @param clientSession
* @param codeData
* @return code parameter to be used in OAuth2 handshake
*/
public static String persistCode(KeycloakSession session, AuthenticatedClientSessionModel clientSession, OAuth2Code codeData) {
@@ -67,12 +63,6 @@ public class OAuth2CodeParser {
* Will parse the code and retrieve the corresponding OAuth2Code and AuthenticatedClientSessionModel. Will also check if code wasn't already
* used and if it wasn't expired. If it was already used (or other error happened during parsing), then returned parser will have "isIllegalCode"
* set to true. If it was expired, the parser will have "isExpired" set to true
*
* @param session
* @param code
* @param realm
* @param event
* @return
*/
public static ParseResult parseCode(KeycloakSession session, String code, RealmModel realm, EventBuilder event) {
ParseResult result = new ParseResult(code);
@@ -180,5 +170,4 @@ public class OAuth2CodeParser {
return this;
}
}
}

View File

@@ -77,6 +77,8 @@ import org.keycloak.utils.ReservedCharValidator;
import org.keycloak.utils.SMTPUtil;
import org.keycloak.utils.StringUtil;
import static org.keycloak.constants.OID4VCIConstants.CREDENTIAL_OFFER_CREATE;
/**
* Per request object
*
@@ -97,7 +99,7 @@ public class RealmManager {
return session;
}
public RealmModel getKeycloakAdminstrationRealm() {
public RealmModel getKeycloakAdministrationRealm() {
return getRealmByName(Config.getAdminRealm());
}
@@ -246,7 +248,6 @@ public class RealmManager {
viewUsers.addCompositeRole(queryGroups);
}
public String getRealmAdminClientId(RealmModel realm) {
return Constants.REALM_MANAGEMENT_CLIENT_ID;
}
@@ -255,8 +256,6 @@ public class RealmManager {
return Constants.REALM_MANAGEMENT_CLIENT_ID;
}
protected void setupRealmDefaults(RealmModel realm) {
realm.setBrowserSecurityHeaders(BrowserSecurityHeaders.realmDefaultHeaders);
@@ -275,6 +274,13 @@ public class RealmManager {
realm.setOTPPolicy(OTPPolicy.DEFAULT_POLICY);
realm.setLoginWithEmailAllowed(true);
if (Profile.isFeatureEnabled(Profile.Feature.OID4VC_VCI)) {
if (realm.getRole(CREDENTIAL_OFFER_CREATE.getName()) == null) {
RoleModel roleModel = realm.addRole(CREDENTIAL_OFFER_CREATE.getName());
roleModel.setDescription(CREDENTIAL_OFFER_CREATE.getDescription());
}
}
realm.setEventsListeners(Collections.singleton("jboss-logging"));
}
@@ -284,7 +290,7 @@ public class RealmManager {
boolean removed = model.removeRealm(realm.getId());
if (removed) {
if (masterAdminClient != null) {
session.clients().removeClient(getKeycloakAdminstrationRealm(), masterAdminClient.getId());
session.clients().removeClient(getKeycloakAdministrationRealm(), masterAdminClient.getId());
}
UserSessionProvider sessions = session.sessions();

View File

@@ -326,7 +326,7 @@ public class AdminConsole {
}
protected RealmModel getAdminstrationRealm(RealmManager realmManager) {
return realmManager.getKeycloakAdminstrationRealm();
return realmManager.getKeycloakAdministrationRealm();
}
/**

View File

@@ -97,7 +97,7 @@ public class AdminRoot {
public Response masterRealmAdminConsoleRedirect() {
KeycloakUriInfo adminUriInfo = session.getContext().getUri(UrlType.ADMIN);
if (shouldRedirect(adminUriInfo)) {
RealmModel master = new RealmManager(session).getKeycloakAdminstrationRealm();
RealmModel master = new RealmManager(session).getKeycloakAdministrationRealm();
return Response.status(302).location(
adminUriInfo.getBaseUriBuilder().path(AdminRoot.class).path(AdminRoot.class, "getAdminConsole").path("/").build(master.getName())
).build();

View File

@@ -0,0 +1 @@
org.keycloak.protocol.oid4vc.issuance.credentialoffer.InMemoryCredentialOfferStorageFactory

View File

@@ -32,6 +32,7 @@ org.keycloak.protocol.oidc.rar.AuthorizationRequestParserSpi
org.keycloak.services.resources.admin.ext.AdminRealmResourceSpi
org.keycloak.theme.freemarker.FreeMarkerSPI
org.keycloak.protocol.oid4vc.issuance.credentialbuilder.CredentialBuilderSpi
org.keycloak.protocol.oid4vc.issuance.credentialoffer.CredentialOfferStorageSpi
org.keycloak.protocol.oid4vc.issuance.keybinding.ProofValidatorSpi
org.keycloak.protocol.oid4vc.issuance.signing.CredentialSignerSpi
org.keycloak.protocol.oid4vc.issuance.keybinding.CNonceHandlerSpi

View File

@@ -24,6 +24,9 @@ import org.keycloak.protocol.oid4vc.model.ClaimsDescription;
import org.junit.Test;
import static org.keycloak.OAuth2Constants.CREDENTIAL_IDENTIFIERS;
import static org.keycloak.OAuth2Constants.OPENID_CREDENTIAL;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotEquals;
@@ -48,7 +51,7 @@ public class OID4VCAuthorizationDetailsProcessorTest {
*/
private AuthorizationDetail createValidAuthorizationDetail() {
AuthorizationDetail authDetail = new AuthorizationDetail();
authDetail.setType("openid_credential");
authDetail.setType(OPENID_CREDENTIAL);
authDetail.setCredentialConfigurationId("test-config-id");
authDetail.setLocations(List.of("https://test-issuer.com"));
return authDetail;
@@ -87,7 +90,7 @@ public class OID4VCAuthorizationDetailsProcessorTest {
*/
private AuthorizationDetail createMissingCredentialIdAuthorizationDetail() {
AuthorizationDetail authDetail = new AuthorizationDetail();
authDetail.setType("openid_credential");
authDetail.setType(OPENID_CREDENTIAL);
return authDetail;
}
@@ -116,7 +119,7 @@ public class OID4VCAuthorizationDetailsProcessorTest {
* Asserts that an AuthorizationDetail has valid structure
*/
private void assertValidAuthorizationDetail(AuthorizationDetail authDetail) {
assertEquals("Type should be openid_credential", "openid_credential", authDetail.getType());
assertEquals("Type should be openid_credential", OPENID_CREDENTIAL, authDetail.getType());
assertEquals("Credential configuration ID should be set", "test-config-id", authDetail.getCredentialConfigurationId());
assertNotNull("Locations should not be null", authDetail.getLocations());
assertEquals("Should have exactly one location", 1, authDetail.getLocations().size());
@@ -127,7 +130,7 @@ public class OID4VCAuthorizationDetailsProcessorTest {
* Asserts that an AuthorizationDetail has invalid type
*/
private void assertInvalidTypeAuthorizationDetail(AuthorizationDetail authDetail) {
assertNotEquals("Type should not be openid_credential", "openid_credential", authDetail.getType());
assertNotEquals("Type should not be openid_credential", OPENID_CREDENTIAL, authDetail.getType());
assertEquals("Invalid type should be preserved", "invalid_type", authDetail.getType());
}
@@ -135,7 +138,7 @@ public class OID4VCAuthorizationDetailsProcessorTest {
* Asserts that an AuthorizationDetail has missing credential configuration ID
*/
private void assertMissingCredentialIdAuthorizationDetail(AuthorizationDetail authDetail) {
assertEquals("Type should be openid_credential", "openid_credential", authDetail.getType());
assertEquals("Type should be openid_credential", OPENID_CREDENTIAL, authDetail.getType());
assertNull("Credential configuration ID should be null", authDetail.getCredentialConfigurationId());
}
@@ -317,7 +320,7 @@ public class OID4VCAuthorizationDetailsProcessorTest {
assertEquals("Should have exactly one authorization detail", 1, authDetails.size());
AuthorizationDetail parsedDetail = authDetails.get(0);
assertEquals("Type should be preserved", "openid_credential", parsedDetail.getType());
assertEquals("Type should be preserved", OPENID_CREDENTIAL, parsedDetail.getType());
assertEquals("Credential configuration ID should be preserved", "test-config-id", parsedDetail.getCredentialConfigurationId());
assertNotNull("Claims should be preserved", parsedDetail.getClaims());
assertEquals("Should have exactly one claim", 1, parsedDetail.getClaims().size());
@@ -365,29 +368,34 @@ public class OID4VCAuthorizationDetailsProcessorTest {
@Test
public void testBuildAuthorizationDetailResponseLogic() {
// Test the response structure that would be built
String expectedCredentialConfigurationId = "test-config-id";
List<String> expectedCredentialIdentifiers = List.of("test-identifier-123");
ClaimsDescription claim = createValidClaimsDescription();
List<ClaimsDescription> expectedClaims = List.of(claim);
// Test authorization detail that would be used to build response
AuthorizationDetail authDetail = createValidAuthorizationDetail();
ClaimsDescription claim = createValidClaimsDescription();
authDetail.setClaims(List.of(claim));
authDetail.setAdditionalField(CREDENTIAL_IDENTIFIERS, expectedCredentialIdentifiers);
authDetail.setClaims(expectedClaims);
// Verify the data structure that buildAuthorizationDetailResponse() would process
assertValidAuthorizationDetail(authDetail);
assertNotNull("Claims should not be null", authDetail.getClaims());
assertEquals("Should have exactly one claim", 1, authDetail.getClaims().size());
// Test the response structure that would be built
String expectedType = "openid_credential";
String expectedCredentialConfigurationId = "test-config-id";
List<String> expectedCredentialIdentifiers = List.of("test-identifier-123");
List<ClaimsDescription> expectedClaims = List.of(claim);
@SuppressWarnings("unchecked")
List<String> actualCredentialIdentifiers = (List<String>) authDetail.getAdditionalFields().get(CREDENTIAL_IDENTIFIERS);
// Verify the response data that would be created
assertEquals("Response type should match", expectedType, "openid_credential");
assertEquals("Response credential configuration ID should match", expectedCredentialConfigurationId, "test-config-id");
assertNotNull("Response credential identifiers should not be null", expectedCredentialIdentifiers);
assertEquals("Response should have exactly one credential identifier", 1, expectedCredentialIdentifiers.size());
assertNotNull("Response claims should not be null", expectedClaims);
assertEquals("Response should have exactly one claim", 1, expectedClaims.size());
assertEquals("Response type should match", OPENID_CREDENTIAL, authDetail.getType());
assertEquals("Response credential configuration ID should match", expectedCredentialConfigurationId, authDetail.getCredentialConfigurationId());
assertNotNull("Response credential identifiers should not be null", actualCredentialIdentifiers);
assertEquals("Response should have exactly one credential identifier", 1, actualCredentialIdentifiers.size());
assertEquals("Response credential identifiers should match", expectedCredentialIdentifiers, actualCredentialIdentifiers);
assertNotNull("Response claims should not be null", authDetail.getClaims());
assertEquals("Response should have exactly one claim", 1, authDetail.getClaims().size());
assertEquals("Response claims should match", expectedClaims, authDetail.getClaims());
}
@Test

View File

@@ -25,7 +25,7 @@ import jakarta.ws.rs.core.HttpHeaders;
import jakarta.ws.rs.core.Response;
import org.keycloak.common.Profile;
import org.keycloak.constants.Oid4VciConstants;
import org.keycloak.constants.OID4VCIConstants;
import org.keycloak.models.oid4vci.CredentialScopeModel;
import org.keycloak.representations.idm.ClientScopeRepresentation;
import org.keycloak.testframework.annotations.KeycloakIntegrationTest;
@@ -48,7 +48,7 @@ public class ClientScopeTestOid4Vci extends AbstractClientScopeTest {
ClientScopeRepresentation clientScope = new ClientScopeRepresentation();
clientScope.setName("test-client-scope");
clientScope.setDescription("test-client-scope-description");
clientScope.setProtocol(Oid4VciConstants.OID4VC_PROTOCOL);
clientScope.setProtocol(OID4VCIConstants.OID4VC_PROTOCOL);
clientScope.setAttributes(Map.of("test-attribute", "test-value"));
String clientScopeId = null;

View File

@@ -1,8 +1,11 @@
package org.keycloak.testsuite.util.oauth;
import java.io.IOException;
import java.util.List;
import org.keycloak.OAuth2Constants;
import org.keycloak.protocol.oid4vc.model.AuthorizationDetail;
import org.keycloak.util.JsonSerialization;
import org.keycloak.util.TokenUtil;
import org.apache.http.client.methods.CloseableHttpResponse;
@@ -40,6 +43,11 @@ public class AccessTokenRequest extends AbstractHttpPostRequest<AccessTokenReque
return this;
}
public AccessTokenRequest authorizationDetails(List<AuthorizationDetail> authDetails) {
parameter(OAuth2Constants.AUTHORIZATION_DETAILS, JsonSerialization.valueAsString(authDetails));
return this;
}
public AccessTokenRequest dpopProof(String dpopProof) {
header(TokenUtil.TOKEN_TYPE_DPOP, dpopProof);
return this;

View File

@@ -1,10 +1,14 @@
package org.keycloak.testsuite.util.oauth;
import java.io.IOException;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.keycloak.OAuth2Constants;
import org.keycloak.protocol.oid4vc.model.AuthorizationDetail;
import org.keycloak.util.JsonSerialization;
import org.apache.http.client.methods.CloseableHttpResponse;
@@ -19,6 +23,7 @@ public class AccessTokenResponse extends AbstractHttpResponse {
private String refreshToken;
private String scope;
private String sessionState;
private List<AuthorizationDetail> authorizationDetails;
private Map<String, Object> otherClaims;
@@ -61,6 +66,11 @@ public class AccessTokenResponse extends AbstractHttpResponse {
case OAuth2Constants.REFRESH_TOKEN:
refreshToken = (String) entry.getValue();
break;
case OAuth2Constants.AUTHORIZATION_DETAILS:
var valJson = JsonSerialization.valueAsString(entry.getValue());
var arr = JsonSerialization.valueFromString(valJson, AuthorizationDetail[].class);
authorizationDetails = Arrays.asList(arr);
break;
default:
otherClaims.put(entry.getKey(), entry.getValue());
break;
@@ -108,4 +118,7 @@ public class AccessTokenResponse extends AbstractHttpResponse {
return otherClaims;
}
public List<AuthorizationDetail> getAuthorizationDetails() {
return authorizationDetails;
}
}

View File

@@ -38,7 +38,9 @@ import org.keycloak.provider.ProviderConfigProperty;
public class PassThroughClientAuthenticator extends AbstractClientAuthenticator {
public static final String PROVIDER_ID = "testsuite-client-passthrough";
public static String clientId = "test-app";
public static String namedClientId = "named-test-app";
// If this parameter is present in the HTTP request, the error will be thrown during authentication
public static final String TEST_ERROR_PARAM = "test_error_param";

View File

@@ -72,7 +72,6 @@ import org.keycloak.events.admin.AuthDetails;
import org.keycloak.events.admin.OperationType;
import org.keycloak.events.email.EmailEventListenerProviderFactory;
import org.keycloak.http.HttpRequest;
import org.keycloak.models.AuthenticatedClientSessionModel;
import org.keycloak.models.AuthenticationFlowModel;
import org.keycloak.models.ClientModel;
import org.keycloak.models.FederatedIdentityModel;
@@ -85,9 +84,14 @@ import org.keycloak.models.UserProvider;
import org.keycloak.models.UserSessionModel;
import org.keycloak.models.utils.ModelToRepresentation;
import org.keycloak.models.utils.ResetTimeOffsetEvent;
import org.keycloak.protocol.oid4vc.issuance.OID4VCIssuerWellKnownProvider;
import org.keycloak.protocol.oid4vc.issuance.credentialoffer.CredentialOfferStorage;
import org.keycloak.protocol.oid4vc.issuance.credentialoffer.CredentialOfferStorage.CredentialOfferState;
import org.keycloak.protocol.oid4vc.model.CredentialsOffer;
import org.keycloak.protocol.oid4vc.model.PreAuthorizedCode;
import org.keycloak.protocol.oid4vc.model.PreAuthorizedGrant;
import org.keycloak.protocol.oidc.encode.AccessTokenContext;
import org.keycloak.protocol.oidc.encode.TokenContextEncoderProvider;
import org.keycloak.protocol.oidc.grants.PreAuthorizedCodeGrantType;
import org.keycloak.provider.Provider;
import org.keycloak.provider.ProviderFactory;
import org.keycloak.representations.idm.AdminEventRepresentation;
@@ -1121,18 +1125,24 @@ public class TestingResourceProvider implements RealmResourceProvider {
@NoCache
public String getPreAuthorizedCode(@QueryParam("realm") final String realmName, @QueryParam("userSessionId") final String userSessionId, @QueryParam("clientId") final String clientId, @QueryParam("expiration") final int expiration) {
RealmModel realm = getRealmByName(realmName);
AuthenticatedClientSessionModel ascm = session.sessions()
.getUserSession(realm, userSessionId)
.getAuthenticatedClientSessions()
.values()
.stream().filter(acsm -> acsm.getClient().getClientId().equals(clientId))
.findFirst()
.orElseThrow(() -> new RuntimeException("No authenticatedClientSession found."));
return PreAuthorizedCodeGrantType.getPreAuthorizedCode(session, ascm, expiration);
UserSessionModel userSession = session.sessions().getUserSession(realm, userSessionId);
String code = "urn:oid4vci:code:" + UUID.randomUUID();
CredentialsOffer credOffer = new CredentialsOffer()
.setCredentialIssuer(OID4VCIssuerWellKnownProvider.getIssuer(session.getContext()))
.setCredentialConfigurationIds(List.of("oid4vc_natural_person"))
.setGrants(new PreAuthorizedGrant().setPreAuthorizedCode(
new PreAuthorizedCode().setPreAuthorizedCode(code)));
String userId = userSession.getUser().getUsername();
var offerStorage = session.getProvider(CredentialOfferStorage.class);
offerStorage.putOfferState(session, new CredentialOfferState(credOffer, clientId, userId, expiration));
return code;
}
@POST
@Path("/email-event-litener-provide/add-events")
@Path("/email-event-listener-provide/add-events")
@Consumes(MediaType.APPLICATION_JSON)
public void addEventsToEmailEventListenerProvider(List<EventType> events) {
if (events != null && !events.isEmpty()) {
@@ -1143,7 +1153,7 @@ public class TestingResourceProvider implements RealmResourceProvider {
}
@POST
@Path("/email-event-litener-provide/remove-events")
@Path("/email-event-listener-provide/remove-events")
@Consumes(MediaType.APPLICATION_JSON)
public void removeEventsToEmailEventListenerProvider(List<EventType> events) {
if (events != null && !events.isEmpty()) {

View File

@@ -457,7 +457,7 @@ public interface TestingResource {
* @param events The events to be included
*/
@POST
@Path("/email-event-litener-provide/add-events")
@Path("/email-event-listener-provide/add-events")
@Consumes(MediaType.APPLICATION_JSON)
public void addEventsToEmailEventListenerProvider(List<EventType> events);
@@ -466,7 +466,7 @@ public interface TestingResource {
* @param events The events to be removed
*/
@POST
@Path("/email-event-litener-provide/remove-events")
@Path("/email-event-listener-provide/remove-events")
@Consumes(MediaType.APPLICATION_JSON)
public void removeEventsToEmailEventListenerProvider(List<EventType> events);

View File

@@ -65,7 +65,6 @@ public class PreAuthorizedGrantTest extends AbstractTestRealmKeycloakTest {
AccessTokenResponse accessTokenResponse = postCode(preAuthorizedCode);
assertEquals("An access token should have successfully been returned.", HttpStatus.SC_OK, accessTokenResponse.getStatusCode());
assertEquals("The correct session should have been used for the pre-authorized code.", userSessionId, accessTokenResponse.getSessionState());
}
@Test

View File

@@ -27,7 +27,7 @@ import org.keycloak.protocol.oidc.par.endpoints.ParEndpoint;
import org.junit.Assert;
import org.junit.Test;
import static org.keycloak.OAuth2Constants.AUTHORIZATION_DETAILS_PARAM;
import static org.keycloak.OAuth2Constants.AUTHORIZATION_DETAILS;
public class ParEndpointTest {
@@ -35,12 +35,12 @@ public class ParEndpointTest {
public void testFlattenDecodedFormParametersRetainAuthorizationDetails() {
var decodedFormParameters = new MultivaluedHashMap<String, String>();
String authorizationDetails = "[{\"type\": \"urn:openfinanceuae:account-access-consent:v1.0\",\"foo\":\"bar\"},{\"type\": \"urn:openfinanceuae:account-access-consent:v1.0\",\"gugu\":\"gaga\"}]";
decodedFormParameters.put(AUTHORIZATION_DETAILS_PARAM, List.of(authorizationDetails));
decodedFormParameters.put(AUTHORIZATION_DETAILS, List.of(authorizationDetails));
var params = new HashMap<String, String>();
ParEndpoint.flattenDecodedFormParametersToParamsMap(decodedFormParameters, params);
Assert.assertEquals(authorizationDetails, params.get(AUTHORIZATION_DETAILS_PARAM));
Assert.assertEquals(authorizationDetails, params.get(AUTHORIZATION_DETAILS));
}
@Test

View File

@@ -0,0 +1,460 @@
/*
* Copyright 2025 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.keycloak.testsuite.oid4vc.issuance;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import jakarta.ws.rs.core.HttpHeaders;
import org.keycloak.OAuth2Constants;
import org.keycloak.TokenVerifier;
import org.keycloak.jose.jws.JWSInput;
import org.keycloak.protocol.oid4vc.model.AuthorizationDetail;
import org.keycloak.protocol.oid4vc.model.CredentialIssuer;
import org.keycloak.protocol.oid4vc.model.CredentialOfferURI;
import org.keycloak.protocol.oid4vc.model.CredentialRequest;
import org.keycloak.protocol.oid4vc.model.CredentialResponse;
import org.keycloak.protocol.oid4vc.model.CredentialsOffer;
import org.keycloak.protocol.oid4vc.model.PreAuthorizedCode;
import org.keycloak.protocol.oid4vc.model.SupportedCredentialConfiguration;
import org.keycloak.protocol.oid4vc.model.VerifiableCredential;
import org.keycloak.protocol.oidc.grants.PreAuthorizedCodeGrantTypeFactory;
import org.keycloak.protocol.oidc.representations.OIDCConfigurationRepresentation;
import org.keycloak.representations.JsonWebToken;
import org.keycloak.representations.idm.ClientRepresentation;
import org.keycloak.testsuite.oid4vc.issuance.signing.OID4VCIssuerEndpointTest;
import org.keycloak.testsuite.util.oauth.AccessTokenResponse;
import org.keycloak.util.JsonSerialization;
import org.apache.commons.io.IOUtils;
import org.apache.directory.api.util.Strings;
import org.apache.http.HttpEntity;
import org.apache.http.HttpStatus;
import org.apache.http.NameValuePair;
import org.apache.http.client.entity.UrlEncodedFormEntity;
import org.apache.http.client.methods.CloseableHttpResponse;
import org.apache.http.client.methods.HttpGet;
import org.apache.http.client.methods.HttpPost;
import org.apache.http.entity.ContentType;
import org.apache.http.entity.StringEntity;
import org.apache.http.message.BasicNameValuePair;
import org.apache.http.util.EntityUtils;
import org.junit.Test;
import static org.keycloak.OAuth2Constants.CREDENTIAL_IDENTIFIERS;
import static org.keycloak.OAuth2Constants.OPENID_CREDENTIAL;
import static org.keycloak.constants.OID4VCIConstants.CREDENTIAL_OFFER_CREATE;
import static org.keycloak.protocol.oid4vc.model.ErrorType.INVALID_CREDENTIAL_OFFER_REQUEST;
import static org.keycloak.testsuite.admin.ApiUtil.findUserByUsernameId;
import static org.keycloak.testsuite.forms.PassThroughClientAuthenticator.clientId;
import static org.keycloak.testsuite.forms.PassThroughClientAuthenticator.namedClientId;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertTrue;
import static org.junit.Assert.fail;
/**
* Credential Offer Validity Matrix
* <p>
* +----------+-----------+---------+---------+------------------------------------------------------+
* | pre-auth | clientId | userId | Valid | Notes |
* +----------+-----------+---------+---------+------------------------------------------------------+
* | no | no | no | yes | Generic offer; any logged-in user may redeem. |
* | no | no | yes | yes | Offer restricted to a specific user. |
* | no | yes | no | yes | Bound to client; user determined at login. |
* | no | yes | yes | yes | Bound to both client and user. |
* +----------+-----------+---------+---------+------------------------------------------------------+
* | yes | no | no | no | Pre-auth requires a user subject; missing userId. |
* | yes | no | yes | yes | Pre-auth for a specific user; client issuer defined. |
* | yes | yes | no | no | Same as above; userId required. |
* | yes | yes | yes | yes | Fully constrained: user + client. |
* +----------+-----------+---------+---------+------------------------------------------------------+
*/
public class OID4VCICredentialOfferMatrixTest extends OID4VCIssuerEndpointTest {
String issUserId = "john";
String issClientId = clientId;
String namedUserId = "alice";
String credScopeName = jwtTypeCredentialScopeName;
String credConfigId = jwtTypeCredentialConfigurationIdName;
static class OfferTestContext {
boolean preAuthorized;
String issUser;
String issClient;
String appUser;
String appClient;
CredentialIssuer issuerMetadata;
OIDCConfigurationRepresentation authorizationMetadata;
SupportedCredentialConfiguration supportedCredentialConfiguration;
}
OfferTestContext newTestContext(boolean preAuth, String appClient, String appUser) {
var ctx = new OfferTestContext();
ctx.preAuthorized = preAuth;
ctx.issUser = issUserId;
ctx.issClient = issClientId;
ctx.appUser = appUser;
ctx.appClient = appClient;
ctx.issuerMetadata = getCredentialIssuerMetadata();
ctx.authorizationMetadata = getAuthorizationMetadata(ctx.issuerMetadata.getAuthorizationServers().get(0));
ctx.supportedCredentialConfiguration = ctx.issuerMetadata.getCredentialsSupported().get(credConfigId);
return ctx;
}
@Test
public void testVariousLogins() {
assertNotNull(getBearerTokenAndLogout(issClientId, issUserId, "openid"));
assertNotNull(getBearerTokenAndLogout(issClientId, namedUserId, "openid"));
assertNotNull(getBearerTokenAndLogout(namedClientId, issUserId, "openid"));
assertNotNull(getBearerTokenAndLogout(namedClientId, namedUserId, "openid"));
}
@Test
public void testCredentialWithoutOffer() throws Exception {
var ctx = newTestContext(false, null, namedUserId);
AuthorizationDetail authDetail = new AuthorizationDetail();
authDetail.setType(OPENID_CREDENTIAL);
authDetail.setCredentialConfigurationId(credConfigId);
authDetail.setLocations(List.of(ctx.issuerMetadata.getCredentialIssuer()));
// [TODO] Requires Credential scope in AuthorizationRequest although already given in AuthorizationDetails
// https://github.com/keycloak/keycloak/issues/44320
String accessToken = getBearerToken(issClientId, ctx.appUser, credScopeName, authDetail);
CredentialResponse credResponse = getCredentialByAuthDetail(ctx, accessToken, authDetail);
verifyCredentialResponse(ctx, credResponse);
}
@Test
public void testCredentialOffer_noPreAuth_noClientId_noUserId() throws Exception {
runCredentialOfferTest(newTestContext(false, null, null));
}
@Test
public void testCredentialOffer_noPreAuth_noClientId_UserId() throws Exception {
runCredentialOfferTest(newTestContext(false, null, namedUserId));
}
@Test
public void testCredentialOffer_noPreAuth_ClientId_noUserId() throws Exception {
runCredentialOfferTest(newTestContext(false, namedClientId, null));
}
@Test
public void testCredentialOffer_noPreAuth_ClientId_UserId() throws Exception {
runCredentialOfferTest(newTestContext(false, namedClientId, namedUserId));
}
// Pre Authorized --------------------------------------------------------------------------------------------------
@Test
public void testCredentialOffer_PreAuth_noClientId_noUserId() throws Exception {
try {
runCredentialOfferTest(newTestContext(true, null, null));
fail("Expected " + INVALID_CREDENTIAL_OFFER_REQUEST.name());
} catch (RuntimeException ex) {
List.of(INVALID_CREDENTIAL_OFFER_REQUEST.name(), "Pre-Authorized credential offer requires a target user")
.forEach(it -> assertTrue(ex.getMessage() + " does not contain " + it, ex.getMessage().contains(it)));
}
}
@Test
public void testCredentialOffer_PreAuth_noClientId_UserId() throws Exception {
runCredentialOfferTest(newTestContext(true, null, namedUserId));
}
@Test
public void testCredentialOffer_PreAuth_ClientId_noUserId() throws Exception {
try {
runCredentialOfferTest(newTestContext(true, namedClientId, null));
fail("Expected " + INVALID_CREDENTIAL_OFFER_REQUEST.name());
} catch (RuntimeException ex) {
List.of(INVALID_CREDENTIAL_OFFER_REQUEST.name(), "Pre-Authorized credential offer requires a target user")
.forEach(it -> assertTrue(ex.getMessage() + " does not contain " + it, ex.getMessage().contains(it)));
}
}
@Test
public void testCredentialOffer_PreAuth_ClientId_UserId() throws Exception {
runCredentialOfferTest(newTestContext(true, namedClientId, namedUserId));
}
void runCredentialOfferTest(OfferTestContext ctx) throws Exception {
// Issuer login
//
String issToken = getBearerToken(ctx.issClient, ctx.issUser, "openid");
// Exclude scope: <credScope>
// Require role: credential-offer-create
verifyTokenJwt(ctx, issToken,
List.of(), List.of(ctx.supportedCredentialConfiguration.getScope()),
List.of(CREDENTIAL_OFFER_CREATE.getName()), List.of());
// Retrieving the credential-offer-uri
//
String offerUri = getCredentialOfferUriUrl(ctx, issToken);
// Issuer logout in order to remove unwanted session state
//
logout(ctx.issUser);
try {
// Using the uri to get the actual credential offer
//
CredentialsOffer credOffer = getCredentialsOffer(ctx, offerUri);
if (credOffer.getCredentialConfigurationIds().size() > 1)
throw new IllegalStateException("Multiple credential configuration ids not supported in: " + JsonSerialization.valueAsString(credOffer));
if (ctx.preAuthorized) {
// Get an access token for the pre-authorized code (PAC)
//
// For a PAC access token, we treat all scopes and all roles as non-meaningful.
// The access token:
// 1. has no authenticated user, and therefore cannot carry any user roles
// 2. does not perform authorization-based scope filtering
// 3. does not derive scopes from the client configuration
// 4. does not reflect anything from the credential offer
//
AccessTokenResponse accessToken = getPreAuthorizedAccessTokenResponse(ctx, credOffer);
List<AuthorizationDetail> authDetails = accessToken.getAuthorizationDetails();
if (authDetails == null)
throw new IllegalStateException("No authorization_details in token response");
if (authDetails.size() > 1)
throw new IllegalStateException("Multiple authorization_details in token response");
// Get the credential and verify
//
CredentialResponse credResponse = getCredentialByAuthDetail(ctx, accessToken.getAccessToken(), authDetails.get(0));
verifyCredentialResponse(ctx, credResponse);
} else {
String clientId = ctx.appClient != null ? ctx.appClient : namedClientId;
String userId = ctx.appUser != null ? ctx.appUser : namedUserId;
String credConfigId = credOffer.getCredentialConfigurationIds().get(0);
SupportedCredentialConfiguration credConfig = ctx.issuerMetadata.getCredentialsSupported().get(credConfigId);
String scope = credConfig.getScope();
String accessToken = getBearerToken(clientId, userId, scope);
// Get the credential and verify
//
CredentialResponse credResponse = getCredentialByOffer(ctx, accessToken, credOffer);
verifyCredentialResponse(ctx, credResponse);
}
} finally {
if (ctx.appUser != null) {
logout(ctx.appUser);
}
}
}
// Private ---------------------------------------------------------------------------------------------------------
private String getBearerToken(String clientId, String username, String scope) {
ClientRepresentation client = testRealm().clients().findByClientId(clientId).get(0);
if (client.isDirectAccessGrantsEnabled()) {
return getBearerTokenDirectAccess(oauth, client, username, scope).getAccessToken();
} else {
return getBearerTokenCodeFlow(oauth, client, username, scope).getAccessToken();
}
}
private String getBearerToken(String clientId, String username, String scope, AuthorizationDetail... authDetail) {
ClientRepresentation client = testRealm().clients().findByClientId(clientId).get(0);
String authCode = getAuthorizationCode(oauth, client, username, scope);
return getBearerToken(oauth, authCode, authDetail).getAccessToken();
}
private String getBearerTokenAndLogout(String clientId, String userId, String scope) {
String token = getBearerToken(clientId, userId, scope);
logout(userId);
return token;
}
private void logout(String userId) {
findUserByUsernameId(testRealm(), userId).logout();
}
private String getCredentialOfferUriUrl(OfferTestContext ctx, String token) throws Exception {
CredentialOfferURI offerURI = getCredentialOfferUri(ctx, token);
return offerURI.getIssuer() + offerURI.getNonce();
}
private CredentialOfferURI getCredentialOfferUri(OfferTestContext ctx, String token) throws Exception {
String credConfigId = ctx.supportedCredentialConfiguration.getId();
String credOfferUriUrl = getCredentialOfferUriUrl(credConfigId, ctx.preAuthorized, ctx.appUser, ctx.appClient);
HttpGet getCredentialOfferURI = new HttpGet(credOfferUriUrl);
getCredentialOfferURI.addHeader(HttpHeaders.AUTHORIZATION, "Bearer " + token);
CloseableHttpResponse credentialOfferURIResponse = httpClient.execute(getCredentialOfferURI);
int statusCode = credentialOfferURIResponse.getStatusLine().getStatusCode();
if (HttpStatus.SC_OK != statusCode) {
HttpEntity entity = credentialOfferURIResponse.getEntity();
throw new IllegalStateException(EntityUtils.toString(entity));
}
String s = IOUtils.toString(credentialOfferURIResponse.getEntity().getContent(), StandardCharsets.UTF_8);
CredentialOfferURI credentialOfferURI = JsonSerialization.valueFromString(s, CredentialOfferURI.class);
assertTrue(credentialOfferURI.getIssuer().startsWith(ctx.issuerMetadata.getCredentialIssuer()));
assertTrue(Strings.isNotEmpty(credentialOfferURI.getNonce()));
return credentialOfferURI;
}
private CredentialsOffer getCredentialsOffer(OfferTestContext ctx, String offerUri) throws Exception {
HttpGet getCredentialOffer = new HttpGet(offerUri);
CloseableHttpResponse credentialOfferResponse = httpClient.execute(getCredentialOffer);
int statusCode = credentialOfferResponse.getStatusLine().getStatusCode();
if (HttpStatus.SC_OK != statusCode) {
HttpEntity entity = credentialOfferResponse.getEntity();
throw new IllegalStateException(EntityUtils.toString(entity));
}
String s = IOUtils.toString(credentialOfferResponse.getEntity().getContent(), StandardCharsets.UTF_8);
CredentialsOffer credOffer = JsonSerialization.valueFromString(s, CredentialsOffer.class);
assertEquals(List.of(ctx.supportedCredentialConfiguration.getId()), credOffer.getCredentialConfigurationIds());
return credOffer;
}
private AccessTokenResponse getPreAuthorizedAccessTokenResponse(OID4VCICredentialOfferMatrixTest.OfferTestContext ctx, CredentialsOffer credOffer) throws Exception {
PreAuthorizedCode preAuthorizedCode = credOffer.getGrants().getPreAuthorizedCode();
HttpPost postPreAuthorizedCode = new HttpPost(ctx.authorizationMetadata.getTokenEndpoint());
List<NameValuePair> parameters = new LinkedList<>();
parameters.add(new BasicNameValuePair(OAuth2Constants.GRANT_TYPE, PreAuthorizedCodeGrantTypeFactory.GRANT_TYPE));
parameters.add(new BasicNameValuePair(PreAuthorizedCodeGrantTypeFactory.CODE_REQUEST_PARAM, preAuthorizedCode.getPreAuthorizedCode()));
UrlEncodedFormEntity formEntity = new UrlEncodedFormEntity(parameters, StandardCharsets.UTF_8);
postPreAuthorizedCode.setEntity(formEntity);
CloseableHttpResponse accessTokenResponse = httpClient.execute(postPreAuthorizedCode);
int statusCode = accessTokenResponse.getStatusLine().getStatusCode();
if (HttpStatus.SC_OK != statusCode) {
HttpEntity entity = accessTokenResponse.getEntity();
throw new IllegalStateException(EntityUtils.toString(entity));
}
return new AccessTokenResponse(accessTokenResponse);
}
private CredentialResponse getCredentialByAuthDetail(OfferTestContext ctx, String accessToken, AuthorizationDetail authDetail) throws Exception {
@SuppressWarnings("unchecked")
List<String> credIdentifiers = (List<String>) authDetail.getAdditionalFields().get(CREDENTIAL_IDENTIFIERS);
var credentialRequest = new CredentialRequest();
if (credIdentifiers != null) {
if (credIdentifiers.size() > 1)
throw new IllegalStateException("Multiple credential ids not supported");
credentialRequest.setCredentialIdentifier(credIdentifiers.get(0));
} else {
if (authDetail.getCredentialConfigurationId() == null)
throw new IllegalStateException("No credential_configuration_id in: " + JsonSerialization.valueAsString(authDetail));
credentialRequest.setCredentialConfigurationId(authDetail.getCredentialConfigurationId());
}
return sendCredentialRequest(ctx, accessToken, credentialRequest);
}
private CredentialResponse getCredentialByOffer(OfferTestContext ctx, String accessToken, CredentialsOffer credOffer) throws Exception {
List<String> credConfigIds = credOffer.getCredentialConfigurationIds();
if (credConfigIds.size() > 1)
throw new IllegalStateException("Multiple credential configuration ids not supported in: " + JsonSerialization.valueAsString(credOffer));
var credentialRequest = new CredentialRequest();
credentialRequest.setCredentialConfigurationId(credConfigIds.get(0));
return sendCredentialRequest(ctx, accessToken, credentialRequest);
}
private CredentialResponse sendCredentialRequest(OfferTestContext ctx, String accessToken, CredentialRequest credentialRequest) throws Exception {
HttpPost postCredential = new HttpPost(ctx.issuerMetadata.getCredentialEndpoint());
postCredential.addHeader(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken);
StringEntity stringEntity = new StringEntity(JsonSerialization.valueAsString(credentialRequest), ContentType.APPLICATION_JSON);
postCredential.setEntity(stringEntity);
CloseableHttpResponse credentialRequestResponse = httpClient.execute(postCredential);
int statusCode = credentialRequestResponse.getStatusLine().getStatusCode();
if (HttpStatus.SC_OK != statusCode) {
HttpEntity entity = credentialRequestResponse.getEntity();
throw new IllegalStateException(EntityUtils.toString(entity));
}
String s = IOUtils.toString(credentialRequestResponse.getEntity().getContent(), StandardCharsets.UTF_8);
CredentialResponse credentialResponse = JsonSerialization.valueFromString(s, CredentialResponse.class);
assertNotNull("The credentials array should be present in the response", credentialResponse.getCredentials());
assertFalse("The credentials array should not be empty", credentialResponse.getCredentials().isEmpty());
return credentialResponse;
}
private void verifyCredentialResponse(OfferTestContext ctx, CredentialResponse credResponse) throws Exception {
String scope = ctx.supportedCredentialConfiguration.getScope();
CredentialResponse.Credential credentialObj = credResponse.getCredentials().get(0);
assertNotNull("The first credential in the array should not be null", credentialObj);
String expUsername = ctx.appUser != null ? ctx.appUser : namedUserId;
JsonWebToken jsonWebToken = TokenVerifier.create((String) credentialObj.getCredential(), JsonWebToken.class).getToken();
assertEquals("did:web:test.org", jsonWebToken.getIssuer());
Object vc = jsonWebToken.getOtherClaims().get("vc");
VerifiableCredential credential = JsonSerialization.mapper.convertValue(vc, VerifiableCredential.class);
assertEquals(List.of(scope), credential.getType());
assertEquals(URI.create("did:web:test.org"), credential.getIssuer());
assertEquals(expUsername + "@email.cz", credential.getCredentialSubject().getClaims().get("email"));
}
private void verifyTokenJwt(
OfferTestContext ctx,
String token,
List<String> includeScopes,
List<String> excludeScopes,
List<String> includeRoles,
List<String> excludeRoles
) throws Exception {
JsonWebToken jwt = JsonSerialization.readValue(new JWSInput(token).getContent(), JsonWebToken.class);
List<String> wasScopes = Arrays.stream(((String) jwt.getOtherClaims().get("scope")).split("\\s")).toList();
includeScopes.forEach(it -> assertTrue("Missing scope: " + it, wasScopes.contains(it)));
excludeScopes.forEach(it -> assertFalse("Invalid scope: " + it, wasScopes.contains(it)));
List<String> allRoles = new ArrayList<>();
Object realmAccess = jwt.getOtherClaims().get("realm_access");
if (realmAccess != null) {
@SuppressWarnings("unchecked")
var realmRoles = ((Map<String, List<String>>) realmAccess).get("roles");
allRoles.addAll(realmRoles);
}
Object resourceAccess = jwt.getOtherClaims().get("resource_access");
if (resourceAccess != null) {
@SuppressWarnings("unchecked")
var resourceAccessMapping = (Map<String, Map<String, List<String>>>) resourceAccess;
resourceAccessMapping.forEach((k, v) -> {
allRoles.addAll(v.get("roles"));
});
}
includeRoles.forEach(it -> assertTrue("Missing role: " + it, allRoles.contains(it)));
excludeRoles.forEach(it -> assertFalse("Invalid role: " + it, allRoles.contains(it)));
}
}

View File

@@ -115,15 +115,12 @@ public class OID4VCTargetRoleMapperTest extends OID4VCTest {
return mergedRoles;
}
);
} else {
testRealm.getRoles()
.setClient(Map.of(existingClient.getClientId(),
List.of(getRoleRepresentation("testRole", existingClient.getClientId()))));
}
List<UserRepresentation> realmUsers = Optional.ofNullable(testRealm.getUsers()).map(ArrayList::new)
.orElse(new ArrayList<>());
realmUsers.add(getUserRepresentation(Map.of(existingClient.getClientId(), List.of("testRole"), "newClient", List.of("newRole"))));
realmUsers.add(getUserRepresentation("John Doe", List.of(),
Map.of(clientId, List.of("testRole"), "newClient", List.of("newRole"))));
testRealm.setUsers(realmUsers);
}
}

View File

@@ -52,6 +52,8 @@ import org.apache.http.entity.StringEntity;
import org.apache.http.message.BasicNameValuePair;
import org.junit.Test;
import static org.keycloak.OAuth2Constants.OPENID_CREDENTIAL;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull;
@@ -63,8 +65,6 @@ import static org.junit.Assert.assertNotNull;
*/
public abstract class OID4VCAuthorizationCodeFlowTestBase extends OID4VCIssuerEndpointTest {
public static final String OPENID_CREDENTIAL_TYPE = "openid_credential";
/**
* Test context for OID4VC tests
*/
@@ -140,7 +140,7 @@ public abstract class OID4VCAuthorizationCodeFlowTestBase extends OID4VCIssuerEn
claim.setMandatory(true);
AuthorizationDetail authDetail = new AuthorizationDetail();
authDetail.setType(OPENID_CREDENTIAL_TYPE);
authDetail.setType(OPENID_CREDENTIAL);
authDetail.setCredentialConfigurationId(getCredentialClientScope().getAttributes().get(CredentialScopeModel.CONFIGURATION_ID));
authDetail.setClaims(Arrays.asList(claim));
authDetail.setLocations(Collections.singletonList(ctx.credentialIssuer.getCredentialIssuer()));
@@ -176,8 +176,8 @@ public abstract class OID4VCAuthorizationCodeFlowTestBase extends OID4VCIssuerEn
assertNotNull("Credential identifiers should be present", authDetailResponse.getCredentialIdentifiers());
assertEquals(1, authDetailResponse.getCredentialIdentifiers().size());
String credentialIdentifier = authDetailResponse.getCredentialIdentifiers().get(0);
assertNotNull("Credential identifier should not be null", credentialIdentifier);
String credentialConfigurationId = authDetailResponse.getCredentialConfigurationId();
assertNotNull("Credential configuration id should not be null", credentialConfigurationId);
// Request the actual credential using the identifier
HttpPost postCredential = new HttpPost(ctx.credentialIssuer.getCredentialEndpoint());
@@ -185,7 +185,7 @@ public abstract class OID4VCAuthorizationCodeFlowTestBase extends OID4VCIssuerEn
postCredential.addHeader(HttpHeaders.CONTENT_TYPE, "application/json");
CredentialRequest credentialRequest = new CredentialRequest();
credentialRequest.setCredentialIdentifier(credentialIdentifier);
credentialRequest.setCredentialConfigurationId(credentialConfigurationId);
String requestBody = JsonSerialization.writeValueAsString(credentialRequest);
postCredential.setEntity(new StringEntity(requestBody, StandardCharsets.UTF_8));

View File

@@ -53,7 +53,7 @@ import org.apache.http.entity.StringEntity;
import org.apache.http.message.BasicNameValuePair;
import org.junit.Test;
import static org.keycloak.protocol.oid4vc.issuance.OID4VCAuthorizationDetailsProcessor.OPENID_CREDENTIAL_TYPE;
import static org.keycloak.OAuth2Constants.OPENID_CREDENTIAL;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
@@ -131,7 +131,7 @@ public class OID4VCAuthorizationCodeFlowWithPARTest extends OID4VCIssuerEndpoint
claim.setMandatory(true);
AuthorizationDetail authDetail = new AuthorizationDetail();
authDetail.setType(OPENID_CREDENTIAL_TYPE);
authDetail.setType(OPENID_CREDENTIAL);
authDetail.setCredentialConfigurationId(credentialConfigurationId);
authDetail.setClaims(List.of(claim));
authDetail.setLocations(Collections.singletonList(ctx.credentialIssuer.getCredentialIssuer()));
@@ -198,7 +198,7 @@ public class OID4VCAuthorizationCodeFlowWithPARTest extends OID4VCIssuerEndpoint
assertEquals("Should have exactly one authorization detail", 1, authDetailsResponse.size());
OID4VCAuthorizationDetailsResponse authDetailResponse = authDetailsResponse.get(0);
assertEquals("Type should be openid_credential", OPENID_CREDENTIAL_TYPE, authDetailResponse.getType());
assertEquals("Type should be openid_credential", OPENID_CREDENTIAL, authDetailResponse.getType());
assertEquals("Credential configuration ID should match", credentialConfigurationId, authDetailResponse.getCredentialConfigurationId());
// Verify claims are preserved
@@ -229,7 +229,7 @@ public class OID4VCAuthorizationCodeFlowWithPARTest extends OID4VCIssuerEndpoint
postCredential.addHeader(HttpHeaders.CONTENT_TYPE, "application/json");
CredentialRequest credentialRequest = new CredentialRequest();
credentialRequest.setCredentialIdentifier(credentialIdentifier);
credentialRequest.setCredentialConfigurationId(credentialConfigurationId);
String requestBody = JsonSerialization.writeValueAsString(credentialRequest);
postCredential.setEntity(new StringEntity(requestBody, StandardCharsets.UTF_8));
@@ -263,7 +263,7 @@ public class OID4VCAuthorizationCodeFlowWithPARTest extends OID4VCIssuerEndpoint
// Step 1: Create PAR request with INVALID authorization_details
// Create authorization details with INVALID credential configuration ID
AuthorizationDetail authDetail = new AuthorizationDetail();
authDetail.setType(OPENID_CREDENTIAL_TYPE);
authDetail.setType(OPENID_CREDENTIAL);
authDetail.setCredentialConfigurationId("INVALID_CONFIG_ID"); // This should cause failure
authDetail.setLocations(Collections.singletonList(ctx.credentialIssuer.getCredentialIssuer()));

View File

@@ -39,6 +39,7 @@ import org.keycloak.protocol.oid4vc.model.CredentialOfferURI;
import org.keycloak.protocol.oid4vc.model.CredentialRequest;
import org.keycloak.protocol.oid4vc.model.CredentialResponse;
import org.keycloak.protocol.oid4vc.model.CredentialsOffer;
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;
@@ -56,7 +57,7 @@ import org.apache.http.entity.StringEntity;
import org.apache.http.message.BasicNameValuePair;
import org.junit.Test;
import static org.keycloak.protocol.oid4vc.issuance.OID4VCAuthorizationDetailsProcessor.OPENID_CREDENTIAL_TYPE;
import static org.keycloak.OAuth2Constants.OPENID_CREDENTIAL;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
@@ -103,7 +104,7 @@ public abstract class OID4VCAuthorizationDetailsFlowTestBase extends OID4VCIssue
Oid4vcTestContext ctx = new Oid4vcTestContext();
String credentialConfigurationId = getCredentialClientScope().getAttributes().get(CredentialScopeModel.CONFIGURATION_ID);
HttpGet getCredentialOfferURI = new HttpGet(getBasePath(TEST_REALM_NAME) + "credential-offer-uri?credential_configuration_id=" + credentialConfigurationId);
HttpGet getCredentialOfferURI = new HttpGet(getCredentialOfferUriUrl(credentialConfigurationId));
getCredentialOfferURI.addHeader(HttpHeaders.AUTHORIZATION, "Bearer " + token);
CredentialOfferURI credentialOfferURI;
@@ -144,7 +145,7 @@ public abstract class OID4VCAuthorizationDetailsFlowTestBase extends OID4VCIssue
Oid4vcTestContext ctx = prepareOid4vcTestContext(token);
AuthorizationDetail authDetail = new AuthorizationDetail();
authDetail.setType(OPENID_CREDENTIAL_TYPE);
authDetail.setType(OPENID_CREDENTIAL);
authDetail.setCredentialConfigurationId(getCredentialClientScope().getAttributes().get(CredentialScopeModel.CONFIGURATION_ID));
authDetail.setLocations(Collections.singletonList(ctx.credentialIssuer.getCredentialIssuer()));
@@ -166,7 +167,7 @@ public abstract class OID4VCAuthorizationDetailsFlowTestBase extends OID4VCIssue
assertNotNull("authorization_details should be present in the response", authDetailsResponse);
assertEquals(1, authDetailsResponse.size());
OID4VCAuthorizationDetailsResponse authDetailResponse = authDetailsResponse.get(0);
assertEquals(OPENID_CREDENTIAL_TYPE, authDetailResponse.getType());
assertEquals(OPENID_CREDENTIAL, authDetailResponse.getType());
assertEquals(getCredentialClientScope().getAttributes().get(CredentialScopeModel.CONFIGURATION_ID), authDetailResponse.getCredentialConfigurationId());
assertNotNull(authDetailResponse.getCredentialIdentifiers());
assertEquals(1, authDetailResponse.getCredentialIdentifiers().size());
@@ -202,7 +203,7 @@ public abstract class OID4VCAuthorizationDetailsFlowTestBase extends OID4VCIssue
claim.setMandatory(true);
AuthorizationDetail authDetail = new AuthorizationDetail();
authDetail.setType(OPENID_CREDENTIAL_TYPE);
authDetail.setType(OPENID_CREDENTIAL);
authDetail.setCredentialConfigurationId(getCredentialClientScope().getAttributes().get(CredentialScopeModel.CONFIGURATION_ID));
authDetail.setClaims(Arrays.asList(claim));
authDetail.setLocations(Collections.singletonList(ctx.credentialIssuer.getCredentialIssuer()));
@@ -225,7 +226,7 @@ public abstract class OID4VCAuthorizationDetailsFlowTestBase extends OID4VCIssue
assertNotNull("authorization_details should be present in the response", authDetailsResponse);
assertEquals(1, authDetailsResponse.size());
OID4VCAuthorizationDetailsResponse authDetailResponse = authDetailsResponse.get(0);
assertEquals(OPENID_CREDENTIAL_TYPE, authDetailResponse.getType());
assertEquals(OPENID_CREDENTIAL, authDetailResponse.getType());
assertEquals(getCredentialClientScope().getAttributes().get(CredentialScopeModel.CONFIGURATION_ID), authDetailResponse.getCredentialConfigurationId());
assertNotNull(authDetailResponse.getClaims());
assertEquals(1, authDetailResponse.getClaims().size());
@@ -257,7 +258,7 @@ public abstract class OID4VCAuthorizationDetailsFlowTestBase extends OID4VCIssue
claim.setMandatory(false);
AuthorizationDetail authDetail = new AuthorizationDetail();
authDetail.setType(OPENID_CREDENTIAL_TYPE);
authDetail.setType(OPENID_CREDENTIAL);
authDetail.setCredentialConfigurationId(getCredentialClientScope().getAttributes().get(CredentialScopeModel.CONFIGURATION_ID));
authDetail.setClaims(Arrays.asList(claim));
authDetail.setLocations(Collections.singletonList(ctx.credentialIssuer.getCredentialIssuer()));
@@ -293,7 +294,7 @@ public abstract class OID4VCAuthorizationDetailsFlowTestBase extends OID4VCIssue
claim.setMandatory(true);
AuthorizationDetail authDetail = new AuthorizationDetail();
authDetail.setType(OPENID_CREDENTIAL_TYPE);
authDetail.setType(OPENID_CREDENTIAL);
authDetail.setCredentialConfigurationId(getCredentialClientScope().getAttributes().get(CredentialScopeModel.CONFIGURATION_ID));
authDetail.setClaims(Arrays.asList(claim));
authDetail.setLocations(Collections.singletonList(ctx.credentialIssuer.getCredentialIssuer()));
@@ -329,7 +330,7 @@ public abstract class OID4VCAuthorizationDetailsFlowTestBase extends OID4VCIssue
claim.setMandatory(false);
AuthorizationDetail authDetail = new AuthorizationDetail();
authDetail.setType(OPENID_CREDENTIAL_TYPE);
authDetail.setType(OPENID_CREDENTIAL);
authDetail.setCredentialConfigurationId(getCredentialClientScope().getAttributes().get(CredentialScopeModel.CONFIGURATION_ID));
authDetail.setClaims(Arrays.asList(claim));
authDetail.setLocations(Collections.singletonList(ctx.credentialIssuer.getCredentialIssuer()));
@@ -369,7 +370,7 @@ public abstract class OID4VCAuthorizationDetailsFlowTestBase extends OID4VCIssue
Oid4vcTestContext ctx = prepareOid4vcTestContext(token);
AuthorizationDetail authDetail = new AuthorizationDetail();
authDetail.setType(OPENID_CREDENTIAL_TYPE);
authDetail.setType(OPENID_CREDENTIAL);
// Missing credential_configuration_id - should fail
authDetail.setLocations(Collections.singletonList(ctx.credentialIssuer.getCredentialIssuer()));
@@ -403,7 +404,7 @@ public abstract class OID4VCAuthorizationDetailsFlowTestBase extends OID4VCIssue
claim.setMandatory(false);
AuthorizationDetail authDetail = new AuthorizationDetail();
authDetail.setType(OPENID_CREDENTIAL_TYPE);
authDetail.setType(OPENID_CREDENTIAL);
authDetail.setCredentialConfigurationId(getCredentialClientScope().getAttributes().get(CredentialScopeModel.CONFIGURATION_ID));
authDetail.setClaims(Arrays.asList(claim));
authDetail.setLocations(Collections.singletonList(ctx.credentialIssuer.getCredentialIssuer()));
@@ -481,7 +482,7 @@ public abstract class OID4VCAuthorizationDetailsFlowTestBase extends OID4VCIssue
String expectedConfigId = ctx.credentialsOffer.getCredentialConfigurationIds().get(i);
OID4VCAuthorizationDetailsResponse authDetailResponse = authDetailsResponse.get(i);
assertEquals(OPENID_CREDENTIAL_TYPE, authDetailResponse.getType());
assertEquals(OPENID_CREDENTIAL, authDetailResponse.getType());
assertEquals("Credential configuration ID should match the one from the offer",
expectedConfigId, authDetailResponse.getCredentialConfigurationId());
assertNotNull("Credential identifiers should be present", authDetailResponse.getCredentialIdentifiers());
@@ -502,18 +503,22 @@ public abstract class OID4VCAuthorizationDetailsFlowTestBase extends OID4VCIssue
@Test
public void testCompleteFlowWithCredentialOfferBasedAuthorizationDetails() throws Exception {
String token = getBearerToken(oauth, client, getCredentialClientScope().getName());
Oid4vcTestContext ctx = prepareOid4vcTestContext(token);
PreAuthorizedCode preAuthorizedCode = ctx.credentialsOffer.getGrants().getPreAuthorizedCode();
// Step 1: Request token without authorization_details parameter (no scope needed)
HttpPost postPreAuthorizedCode = new HttpPost(ctx.openidConfig.getTokenEndpoint());
List<NameValuePair> parameters = new LinkedList<>();
parameters.add(new BasicNameValuePair(OAuth2Constants.GRANT_TYPE, PreAuthorizedCodeGrantTypeFactory.GRANT_TYPE));
parameters.add(new BasicNameValuePair(PreAuthorizedCodeGrantTypeFactory.CODE_REQUEST_PARAM, ctx.credentialsOffer.getGrants().getPreAuthorizedCode().getPreAuthorizedCode()));
parameters.add(new BasicNameValuePair(PreAuthorizedCodeGrantTypeFactory.CODE_REQUEST_PARAM, preAuthorizedCode.getPreAuthorizedCode()));
UrlEncodedFormEntity formEntity = new UrlEncodedFormEntity(parameters, StandardCharsets.UTF_8);
postPreAuthorizedCode.setEntity(formEntity);
String credentialIdentifier = null;
String credentialIdentifier;
String credentialConfigurationId;
OID4VCAuthorizationDetailsResponse authDetailResponse;
try (CloseableHttpResponse tokenResponse = httpClient.execute(postPreAuthorizedCode)) {
assertEquals(HttpStatus.SC_OK, tokenResponse.getStatusLine().getStatusCode());
String responseBody = IOUtils.toString(tokenResponse.getEntity().getContent(), StandardCharsets.UTF_8);
@@ -524,46 +529,94 @@ public abstract class OID4VCAuthorizationDetailsFlowTestBase extends OID4VCIssue
ctx.credentialsOffer.getCredentialConfigurationIds().size(), authDetailsResponse.size());
// Use the first authorization detail for credential request
OID4VCAuthorizationDetailsResponse authDetailResponse = authDetailsResponse.get(0);
authDetailResponse = authDetailsResponse.get(0);
assertNotNull("Credential identifiers should be present", authDetailResponse.getCredentialIdentifiers());
assertEquals(1, authDetailResponse.getCredentialIdentifiers().size());
credentialIdentifier = authDetailResponse.getCredentialIdentifiers().get(0);
assertNotNull("Credential identifier should not be null", credentialIdentifier);
credentialConfigurationId = authDetailResponse.getCredentialConfigurationId();
assertNotNull("Credential configuration id should not be null", credentialConfigurationId);
}
// Step 2: Request the actual credential using ONLY the identifier (no credential_configuration_id)
// This tests that the mapping from credential identifier to credential configuration ID works correctly
HttpPost postCredential = new HttpPost(ctx.credentialIssuer.getCredentialEndpoint());
postCredential.addHeader(HttpHeaders.AUTHORIZATION, "Bearer " + token);
postCredential.addHeader(HttpHeaders.CONTENT_TYPE, "application/json");
// This tests that the mapping from credential identifier to credential configuration ID works as expected.
//
// The Pre-Authorized code flow is treated as a separate authentication event.
// Even if the underlying user and client match an existing session.
// A new user session is created because:
// * The pre-auth code is defined as a standalone authentication mechanism.
// * It does not assume the caller already has an authenticated session.
// * It must guarantee isolation of state tied to the VC issuance flow.
{
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(credentialIdentifier);
CredentialRequest credentialRequest = new CredentialRequest();
credentialRequest.setCredentialIdentifier(credentialIdentifier);
String requestBody = JsonSerialization.writeValueAsString(credentialRequest);
postCredential.setEntity(new StringEntity(requestBody, StandardCharsets.UTF_8));
String requestBody = JsonSerialization.writeValueAsString(credentialRequest);
postCredential.setEntity(new StringEntity(requestBody, StandardCharsets.UTF_8));
try (CloseableHttpResponse credentialResponse = httpClient.execute(postCredential)) {
assertEquals(HttpStatus.SC_OK, credentialResponse.getStatusLine().getStatusCode());
String responseBody = IOUtils.toString(credentialResponse.getEntity().getContent(), StandardCharsets.UTF_8);
try (CloseableHttpResponse credentialResponse = httpClient.execute(postCredential)) {
assertEquals(HttpStatus.SC_OK, credentialResponse.getStatusLine().getStatusCode());
String responseBody = IOUtils.toString(credentialResponse.getEntity().getContent(), StandardCharsets.UTF_8);
// Parse the credential response
CredentialResponse parsedResponse = JsonSerialization.readValue(responseBody, CredentialResponse.class);
assertNotNull("Credential response should not be null", parsedResponse);
assertNotNull("Credentials should be present", parsedResponse.getCredentials());
assertEquals("Should have exactly one credential", 1, parsedResponse.getCredentials().size());
// Parse the credential response
CredentialResponse parsedResponse = JsonSerialization.readValue(responseBody, CredentialResponse.class);
assertNotNull("Credential response should not be null", parsedResponse);
assertNotNull("Credentials should be present", parsedResponse.getCredentials());
assertEquals("Should have exactly one credential", 1, parsedResponse.getCredentials().size());
// Step 3: Verify that the issued credential structure is valid
CredentialResponse.Credential credentialWrapper = parsedResponse.getCredentials().get(0);
assertNotNull("Credential wrapper should not be null", credentialWrapper);
// Step 3: Verify that the issued credential structure is valid
CredentialResponse.Credential credentialWrapper = parsedResponse.getCredentials().get(0);
assertNotNull("Credential wrapper should not be null", credentialWrapper);
// The credential is stored as Object, so we need to cast it
Object credentialObj = credentialWrapper.getCredential();
assertNotNull("Credential object should not be null", credentialObj);
// The credential is stored as Object, so we need to cast it
Object credentialObj = credentialWrapper.getCredential();
assertNotNull("Credential object should not be null", credentialObj);
// Verify the credential structure based on format
verifyCredentialStructure(credentialObj);
// Verify the credential structure based on format
verifyCredentialStructure(credentialObj);
}
}
// Step 3: Request a credential using the credentialConfigurationId
//
{
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.setCredentialConfigurationId(credentialConfigurationId);
String requestBody = JsonSerialization.writeValueAsString(credentialRequest);
postCredential.setEntity(new StringEntity(requestBody, StandardCharsets.UTF_8));
try (CloseableHttpResponse credentialResponse = httpClient.execute(postCredential)) {
assertEquals(HttpStatus.SC_OK, credentialResponse.getStatusLine().getStatusCode());
String responseBody = IOUtils.toString(credentialResponse.getEntity().getContent(), StandardCharsets.UTF_8);
// Parse the credential response
CredentialResponse parsedResponse = JsonSerialization.readValue(responseBody, CredentialResponse.class);
assertNotNull("Credential response should not be null", parsedResponse);
assertNotNull("Credentials should be present", parsedResponse.getCredentials());
assertEquals("Should have exactly one credential", 1, parsedResponse.getCredentials().size());
// Step 3: Verify that the issued credential structure is valid
CredentialResponse.Credential credentialWrapper = parsedResponse.getCredentials().get(0);
assertNotNull("Credential wrapper should not be null", credentialWrapper);
// The credential is stored as Object, so we need to cast it
Object credentialObj = credentialWrapper.getCredential();
assertNotNull("Credential object should not be null", credentialObj);
// Verify the credential structure based on format
verifyCredentialStructure(credentialObj);
}
}
}
@@ -603,7 +656,7 @@ public abstract class OID4VCAuthorizationDetailsFlowTestBase extends OID4VCIssue
String expectedConfigId = ctx.credentialsOffer.getCredentialConfigurationIds().get(i);
// Verify structure
assertEquals("Type should be openid_credential", OPENID_CREDENTIAL_TYPE, authDetail.getType());
assertEquals("Type should be openid_credential", OPENID_CREDENTIAL, authDetail.getType());
assertEquals("Credential configuration ID should match the one from the offer",
expectedConfigId, authDetail.getCredentialConfigurationId());
assertNotNull("Credential identifiers should be present", authDetail.getCredentialIdentifiers());
@@ -633,6 +686,7 @@ public abstract class OID4VCAuthorizationDetailsFlowTestBase extends OID4VCIssue
@Test
public void testCompleteFlowWithClaimsValidation() throws Exception {
String token = getBearerToken(oauth, client, getCredentialClientScope().getName());
String credConfigId = getCredentialClientScope().getAttributes().get(CredentialScopeModel.CONFIGURATION_ID);
Oid4vcTestContext ctx = prepareOid4vcTestContext(token);
// Step 1: Request token with authorization details containing specific claims
@@ -650,13 +704,13 @@ public abstract class OID4VCAuthorizationDetailsFlowTestBase extends OID4VCIssue
claim.setMandatory(true);
AuthorizationDetail authDetail = new AuthorizationDetail();
authDetail.setType(OPENID_CREDENTIAL_TYPE);
authDetail.setCredentialConfigurationId(getCredentialClientScope().getAttributes().get(CredentialScopeModel.CONFIGURATION_ID));
authDetail.setClaims(Arrays.asList(claim));
authDetail.setType(OPENID_CREDENTIAL);
authDetail.setCredentialConfigurationId(credConfigId);
authDetail.setLocations(Collections.singletonList(ctx.credentialIssuer.getCredentialIssuer()));
authDetail.setClaims(List.of(claim));
List<AuthorizationDetail> authDetails = List.of(authDetail);
String authDetailsJson = JsonSerialization.writeValueAsString(authDetails);
String authDetailsJson = JsonSerialization.valueAsString(authDetails);
HttpPost postPreAuthorizedCode = new HttpPost(ctx.openidConfig.getTokenEndpoint());
List<NameValuePair> parameters = new LinkedList<>();
@@ -667,6 +721,8 @@ public abstract class OID4VCAuthorizationDetailsFlowTestBase extends OID4VCIssue
postPreAuthorizedCode.setEntity(formEntity);
String credentialIdentifier;
String credentialConfigurationId;
OID4VCAuthorizationDetailsResponse authDetailResponse;
try (CloseableHttpResponse tokenResponse = httpClient.execute(postPreAuthorizedCode)) {
assertEquals(HttpStatus.SC_OK, tokenResponse.getStatusLine().getStatusCode());
String responseBody = IOUtils.toString(tokenResponse.getEntity().getContent(), StandardCharsets.UTF_8);
@@ -674,15 +730,18 @@ public abstract class OID4VCAuthorizationDetailsFlowTestBase extends OID4VCIssue
assertNotNull("authorization_details should be present in the response", authDetailsResponse);
assertEquals(1, authDetailsResponse.size());
OID4VCAuthorizationDetailsResponse authDetailResponse = authDetailsResponse.get(0);
authDetailResponse = authDetailsResponse.get(0);
assertNotNull("Credential identifiers should be present", authDetailResponse.getCredentialIdentifiers());
assertEquals(1, authDetailResponse.getCredentialIdentifiers().size());
credentialIdentifier = authDetailResponse.getCredentialIdentifiers().get(0);
assertNotNull("Credential identifier should not be null", credentialIdentifier);
credentialConfigurationId = authDetailResponse.getCredentialConfigurationId();
assertNotNull("Credential configuration id should not be null", credentialConfigurationId);
}
// Step 2: Request the actual credential using the identifier
// Step 2: Request the actual credential using the identifier and config id
HttpPost postCredential = new HttpPost(ctx.credentialIssuer.getCredentialEndpoint());
postCredential.addHeader(HttpHeaders.AUTHORIZATION, "Bearer " + token);
postCredential.addHeader(HttpHeaders.CONTENT_TYPE, "application/json");

View File

@@ -23,7 +23,6 @@ import jakarta.ws.rs.client.Client;
import jakarta.ws.rs.core.Response;
import org.keycloak.common.Profile;
import org.keycloak.protocol.oid4vc.issuance.OID4VCAuthorizationDetailsProcessor;
import org.keycloak.protocol.oid4vc.model.CredentialIssuer;
import org.keycloak.protocol.oidc.representations.OIDCConfigurationRepresentation;
import org.keycloak.representations.idm.RealmRepresentation;
@@ -34,7 +33,7 @@ import org.keycloak.testsuite.util.oauth.OAuthClient;
import org.junit.Before;
import org.junit.Test;
import static org.keycloak.protocol.oid4vc.issuance.OID4VCAuthorizationDetailsProcessor.OPENID_CREDENTIAL_TYPE;
import static org.keycloak.OAuth2Constants.OPENID_CREDENTIAL;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
@@ -74,7 +73,7 @@ public class OID4VCAuthorizationDetailsTypesSupportedTest extends OID4VCIssuerEn
// Verify that it contains openid_credential
List<String> supportedTypes = oauthConfig.getAuthorizationDetailsTypesSupported();
assertTrue("authorization_details_types_supported should contain openid_credential",
supportedTypes.contains(OID4VCAuthorizationDetailsProcessor.OPENID_CREDENTIAL_TYPE));
supportedTypes.contains(OPENID_CREDENTIAL));
}
}
@@ -94,7 +93,7 @@ public class OID4VCAuthorizationDetailsTypesSupportedTest extends OID4VCIssuerEn
assertNotNull("Authorization server should support authorization_details_types_supported",
authServerConfig.getAuthorizationDetailsTypesSupported());
assertTrue("Authorization server should support openid_credential",
authServerConfig.getAuthorizationDetailsTypesSupported().contains(OPENID_CREDENTIAL_TYPE));
authServerConfig.getAuthorizationDetailsTypesSupported().contains(OPENID_CREDENTIAL));
}
}

View File

@@ -255,11 +255,7 @@ public class OID4VCCredentialOfferCorsTest extends OID4VCIssuerEndpointTest {
}
private String getCredentialOfferUriUrl() {
return getBasePath("test") + "credential-offer-uri?credential_configuration_id=" + jwtTypeCredentialConfigurationIdName;
}
private String getCredentialOfferUrl(String sessionCode) {
return getBasePath("test") + "credential-offer/" + sessionCode;
return getCredentialOfferUriUrl(jwtTypeCredentialConfigurationIdName);
}
private String getSessionCodeFromOfferUri(String accessToken) throws Exception {

View File

@@ -38,6 +38,7 @@ import org.keycloak.jose.jwk.JWK;
import org.keycloak.jose.jwk.JWKParser;
import org.keycloak.models.KeyManager;
import org.keycloak.models.RealmModel;
import org.keycloak.models.oid4vci.CredentialScopeModel;
import org.keycloak.protocol.oid4vc.issuance.OID4VCIssuerEndpoint;
import org.keycloak.protocol.oid4vc.model.CredentialRequest;
import org.keycloak.protocol.oid4vc.model.CredentialResponse;
@@ -75,6 +76,7 @@ public class OID4VCIssuerEndpointEncryptionTest extends OID4VCIssuerEndpointTest
@Test
public void testRequestCredentialWithEncryption() {
final String scopeName = jwtTypeCredentialClientScope.getName();
String credConfigId = jwtTypeCredentialClientScope.getAttributes().get(CredentialScopeModel.CONFIGURATION_ID);
String token = getBearerToken(oauth, client, scopeName);
testingClient
.server(TEST_REALM_NAME)
@@ -93,7 +95,7 @@ public class OID4VCIssuerEndpointEncryptionTest extends OID4VCIssuerEndpointTest
PrivateKey privateKey = (PrivateKey) jwkPair.get("privateKey");
CredentialRequest credentialRequest = new CredentialRequest()
.setCredentialIdentifier(scopeName)
.setCredentialConfigurationId(credConfigId)
.setCredentialResponseEncryption(
new CredentialResponseEncryption()
.setEnc(A256GCM)
@@ -169,6 +171,7 @@ public class OID4VCIssuerEndpointEncryptionTest extends OID4VCIssuerEndpointTest
@Test
public void testEncryptedCredentialRequest() {
final String scopeName = jwtTypeCredentialClientScope.getName();
String credConfigId = jwtTypeCredentialClientScope.getAttributes().get(CredentialScopeModel.CONFIGURATION_ID);
String token = getBearerToken(oauth, client, scopeName);
testingClient.server(TEST_REALM_NAME).run(session -> {
try {
@@ -194,7 +197,7 @@ public class OID4VCIssuerEndpointEncryptionTest extends OID4VCIssuerEndpointTest
PrivateKey responsePrivateKey = (PrivateKey) jwkPair.get("privateKey");
CredentialRequest credentialRequest = new CredentialRequest()
.setCredentialIdentifier(scopeName)
.setCredentialConfigurationId(credConfigId)
.setCredentialResponseEncryption(
new CredentialResponseEncryption()
.setEnc(A256GCM)
@@ -234,6 +237,7 @@ public class OID4VCIssuerEndpointEncryptionTest extends OID4VCIssuerEndpointTest
@Test
public void testEncryptedCredentialRequestWithCompression() {
final String scopeName = jwtTypeCredentialClientScope.getName();
String credConfigId = jwtTypeCredentialClientScope.getAttributes().get(CredentialScopeModel.CONFIGURATION_ID);
String token = getBearerToken(oauth, client, scopeName);
testingClient.server(TEST_REALM_NAME).run(session -> {
try {
@@ -262,7 +266,7 @@ public class OID4VCIssuerEndpointEncryptionTest extends OID4VCIssuerEndpointTest
// Create credential request with response encryption parameters
CredentialRequest credentialRequest = new CredentialRequest()
.setCredentialIdentifier(scopeName)
.setCredentialConfigurationId(credConfigId)
.setCredentialResponseEncryption(
new CredentialResponseEncryption()
.setEnc(A256GCM)
@@ -474,6 +478,7 @@ public class OID4VCIssuerEndpointEncryptionTest extends OID4VCIssuerEndpointTest
@Test
public void testRequestCredentialWithInvalidJWK() throws Throwable {
final String scopeName = jwtTypeCredentialClientScope.getName();
String credConfigId = jwtTypeCredentialClientScope.getAttributes().get(CredentialScopeModel.CONFIGURATION_ID);
String token = getBearerToken(oauth, client, scopeName);
testingClient.server(TEST_REALM_NAME).run(session -> {
AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session);
@@ -483,7 +488,7 @@ public class OID4VCIssuerEndpointEncryptionTest extends OID4VCIssuerEndpointTest
// Invalid JWK (missing modulus but WITH alg parameter)
JWK jwk = JWKParser.create().parse("{\"kty\":\"RSA\",\"alg\":\"RSA-OAEP-256\",\"e\":\"AQAB\"}").getJwk();
CredentialRequest credentialRequest = new CredentialRequest()
.setCredentialIdentifier(scopeName)
.setCredentialConfigurationId(credConfigId)
.setCredentialResponseEncryption(
new CredentialResponseEncryption()
.setEnc("A256GCM")

View File

@@ -35,6 +35,8 @@ import java.util.Optional;
import java.util.function.BiConsumer;
import java.util.function.BiFunction;
import java.util.function.Consumer;
import java.util.function.Function;
import java.util.stream.Collectors;
import java.util.zip.Deflater;
import java.util.zip.DeflaterOutputStream;
@@ -54,7 +56,7 @@ import org.keycloak.common.util.Base64Url;
import org.keycloak.common.util.MultivaluedHashMap;
import org.keycloak.common.util.SecretGenerator;
import org.keycloak.common.util.Time;
import org.keycloak.constants.Oid4VciConstants;
import org.keycloak.constants.OID4VCIConstants;
import org.keycloak.crypto.KeyWrapper;
import org.keycloak.jose.jwe.JWE;
import org.keycloak.jose.jwe.JWEException;
@@ -79,6 +81,7 @@ import org.keycloak.protocol.oid4vc.model.DisplayObject;
import org.keycloak.protocol.oid4vc.model.Format;
import org.keycloak.protocol.oid4vc.model.SupportedCredentialConfiguration;
import org.keycloak.protocol.oid4vc.model.VerifiableCredential;
import org.keycloak.protocol.oidc.representations.OIDCConfigurationRepresentation;
import org.keycloak.protocol.oidc.utils.OAuth2Code;
import org.keycloak.protocol.oidc.utils.OAuth2CodeParser;
import org.keycloak.representations.JsonWebToken;
@@ -88,6 +91,7 @@ import org.keycloak.representations.idm.ComponentExportRepresentation;
import org.keycloak.representations.idm.ProtocolMapperRepresentation;
import org.keycloak.representations.idm.RealmRepresentation;
import org.keycloak.representations.idm.RoleRepresentation;
import org.keycloak.representations.idm.RolesRepresentation;
import org.keycloak.representations.idm.UserRepresentation;
import org.keycloak.services.managers.AppAuthManager;
import org.keycloak.services.managers.AuthenticationManager;
@@ -110,12 +114,14 @@ import org.apache.http.impl.client.HttpClientBuilder;
import org.jboss.logging.Logger;
import org.junit.Before;
import static org.keycloak.constants.OID4VCIConstants.CREDENTIAL_OFFER_CREATE;
import static org.keycloak.jose.jwe.JWEConstants.A256GCM;
import static org.keycloak.jose.jwe.JWEConstants.RSA_OAEP;
import static org.keycloak.jose.jwe.JWEConstants.RSA_OAEP_256;
import static org.keycloak.protocol.oid4vc.issuance.OID4VCIssuerEndpoint.CREDENTIAL_OFFER_URI_CODE_SCOPE;
import static org.keycloak.protocol.oid4vc.model.ProofType.JWT;
import static org.keycloak.testsuite.forms.PassThroughClientAuthenticator.clientId;
import static org.keycloak.testsuite.forms.PassThroughClientAuthenticator.namedClientId;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
@@ -139,33 +145,31 @@ public abstract class OID4VCIssuerEndpointTest extends OID4VCTest {
protected static ClientScopeRepresentation minimalJwtTypeCredentialClientScope;
protected CloseableHttpClient httpClient;
protected ClientRepresentation client;
protected ClientRepresentation namedClient;
record OAuth2CodeEntry(String key, OAuth2Code code) {}
protected boolean shouldEnableOid4vci() {
return true;
}
protected static String prepareSessionCode(KeycloakSession session, AppAuthManager.BearerTokenAuthenticator authenticator, String note) {
protected static OAuth2CodeEntry prepareSessionCode(KeycloakSession session, AppAuthManager.BearerTokenAuthenticator authenticator, String note) {
AuthenticationManager.AuthResult authResult = authenticator.authenticate();
UserSessionModel userSessionModel = authResult.session();
AuthenticatedClientSessionModel authenticatedClientSessionModel = userSessionModel.getAuthenticatedClientSessionByClient(
authResult.client().getId());
String codeId = SecretGenerator.getInstance().randomString();
String nonce = SecretGenerator.getInstance().randomString();
OAuth2Code oAuth2Code = new OAuth2Code(codeId,
OAuth2Code oauth2Code = new OAuth2Code(
SecretGenerator.getInstance().randomString(),
Time.currentTime() + 6000,
nonce,
SecretGenerator.getInstance().randomString(),
CREDENTIAL_OFFER_URI_CODE_SCOPE,
null,
null,
null,
null,
authenticatedClientSessionModel.getUserSession().getId());
String oauthCode = OAuth2CodeParser.persistCode(session, authenticatedClientSessionModel, oAuth2Code);
authenticatedClientSessionModel.setNote(oauthCode, note);
return oauthCode;
String nonce = OAuth2CodeParser.persistCode(session, authenticatedClientSessionModel, oauth2Code);
authenticatedClientSessionModel.setNote(nonce, note);
return new OAuth2CodeEntry(nonce, oauth2Code);
}
protected static OID4VCIssuerEndpoint prepareIssuerEndpoint(KeycloakSession session,
@@ -201,6 +205,7 @@ public abstract class OID4VCIssuerEndpointTest extends OID4VCTest {
CryptoIntegration.init(this.getClass().getClassLoader());
httpClient = HttpClientBuilder.create().build();
client = testRealm().clients().findByClientId(clientId).get(0);
namedClient = testRealm().clients().findByClientId(namedClientId).get(0);
// Lookup the pre-installed oid4vc_natural_person client scope
sdJwtTypeNaturalPersonClientScope = requireExistingClientScope(sdJwtTypeNaturalPersonScopeName);
@@ -228,13 +233,23 @@ public abstract class OID4VCIssuerEndpointTest extends OID4VCTest {
null,
null);
// Assign the registered optional client scopes to the client
assignOptionalClientScopeToClient(sdJwtTypeCredentialClientScope.getId(), client.getClientId());
assignOptionalClientScopeToClient(jwtTypeCredentialClientScope.getId(), client.getClientId());
assignOptionalClientScopeToClient(minimalJwtTypeCredentialClientScope.getId(), client.getClientId());
List.of(client, namedClient).forEach(client -> {
String clientId = client.getClientId();
// Enable OID4VCI for the client by default, but allow tests to override
setClientOid4vciEnabled(clientId, shouldEnableOid4vci());
// Assign the registered optional client scopes to the client
assignOptionalClientScopeToClient(sdJwtTypeNaturalPersonClientScope.getId(), clientId);
assignOptionalClientScopeToClient(sdJwtTypeCredentialClientScope.getId(), clientId);
assignOptionalClientScopeToClient(jwtTypeCredentialClientScope.getId(), clientId);
assignOptionalClientScopeToClient(minimalJwtTypeCredentialClientScope.getId(), clientId);
assignOptionalClientScopeToClient(sdJwtTypeNaturalPersonClientScope.getId(), clientId);
assignOptionalClientScopeToClient(sdJwtTypeCredentialClientScope.getId(), clientId);
assignOptionalClientScopeToClient(jwtTypeCredentialClientScope.getId(), clientId);
assignOptionalClientScopeToClient(minimalJwtTypeCredentialClientScope.getId(), clientId);
// Enable OID4VCI for the client by default, but allow tests to override
setClientOid4vciEnabled(clientId, shouldEnableOid4vci());
});
}
private ClientResource findClientByClientId(RealmResource realm, String clientId) {
@@ -264,7 +279,7 @@ public abstract class OID4VCIssuerEndpointTest extends OID4VCTest {
// Create a new ClientScope if not found
ClientScopeRepresentation clientScope = new ClientScopeRepresentation();
clientScope.setName(scopeName);
clientScope.setProtocol(Oid4VciConstants.OID4VC_PROTOCOL);
clientScope.setProtocol(OID4VCIConstants.OID4VC_PROTOCOL);
Map<String, String> attributes =
new HashMap<>(Map.of(ClientScopeModel.INCLUDE_IN_TOKEN_SCOPE, "true",
CredentialScopeModel.EXPIRY_IN_SECONDS, "15"));
@@ -531,6 +546,29 @@ public abstract class OID4VCIssuerEndpointTest extends OID4VCTest {
return contextRoot + "/auth/.well-known/openid-credential-issuer/realms/" + realm;
}
protected String getCredentialOfferUriUrl(String configId) {
return getCredentialOfferUriUrl(configId, true, "john");
}
protected String getCredentialOfferUriUrl(String configId, boolean preAuthorized, String targetUser) {
return getCredentialOfferUriUrl(configId, preAuthorized, targetUser, null);
}
protected String getCredentialOfferUriUrl(String configId, Boolean preAuthorized, String appUserId, String appClientId) {
String res = getBasePath("test") + "credential-offer-uri?credential_configuration_id=" + configId;
if (preAuthorized != null)
res += "&pre_authorized=" + preAuthorized;
if (appClientId != null)
res += "&client_id=" + appClientId;
if (appUserId != null)
res += "&user_id=" + appUserId;
return res;
}
protected String getCredentialOfferUrl(String nonce) {
return getBasePath("test") + "credential-offer/" + nonce;
}
protected void requestCredential(String token,
String credentialEndpoint,
SupportedCredentialConfiguration offeredCredential,
@@ -569,6 +607,24 @@ public abstract class OID4VCIssuerEndpointTest extends OID4VCTest {
}
}
public OIDCConfigurationRepresentation getAuthorizationMetadata(String authServerUrl) {
HttpGet getOpenidConfiguration = new HttpGet(authServerUrl + "/.well-known/openid-configuration");
try (CloseableHttpResponse response = httpClient.execute(getOpenidConfiguration)) {
assertEquals(HttpStatus.SC_OK, response.getStatusLine().getStatusCode());
String s = IOUtils.toString(response.getEntity().getContent(), StandardCharsets.UTF_8);
return JsonSerialization.readValue(s, OIDCConfigurationRepresentation.class);
} catch (Exception ex) {
throw new RuntimeException(ex);
}
}
public SupportedCredentialConfiguration getSupportedCredentialConfigurationByScope(CredentialIssuer metadata, String scope) {
SupportedCredentialConfiguration result = metadata.getCredentialsSupported().values().stream()
.filter(it -> it.getScope().equals(scope))
.findFirst().orElse(null);
return result;
}
@Override
public void configureTestRealm(RealmRepresentation testRealm) {
if (testRealm.getComponents() == null) {
@@ -583,15 +639,15 @@ public abstract class OID4VCIssuerEndpointTest extends OID4VCTest {
getRsaEncKeyProvider(RSA_OAEP, "enc-key-oaep", 101));
// Find existing client representation
ClientRepresentation existingClient = testRealm.getClients().stream()
.filter(client -> client.getClientId().equals(clientId))
.findFirst()
Map<String, ClientRepresentation> realmClients = testRealm.getClients().stream()
.collect(Collectors.toMap(ClientRepresentation::getClientId, Function.identity()));
ClientRepresentation existingClient = Optional.ofNullable(realmClients.get(clientId))
.orElseThrow(() -> new IllegalStateException("Client with ID " + clientId + " not found in realm"));
// Add a role to an existing client
if (testRealm.getRoles() != null) {
Map<String, List<RoleRepresentation>> clientRoles = testRealm.getRoles().getClient();
clientRoles.merge(
RolesRepresentation realmRoles = testRealm.getRoles();
if (realmRoles != null) {
realmRoles.getClient().merge(
existingClient.getClientId(),
List.of(getRoleRepresentation("testRole", existingClient.getClientId())),
(existingRoles, newRoles) -> {
@@ -600,15 +656,12 @@ public abstract class OID4VCIssuerEndpointTest extends OID4VCTest {
return mergedRoles;
}
);
} else {
testRealm.getRoles()
.setClient(Map.of(existingClient.getClientId(),
List.of(getRoleRepresentation("testRole", existingClient.getClientId()))));
}
List<UserRepresentation> realmUsers = Optional.ofNullable(testRealm.getUsers()).map(ArrayList::new)
.orElse(new ArrayList<>());
realmUsers.add(getUserRepresentation(Map.of(existingClient.getClientId(), List.of("testRole"))));
Map<String, List<String>> clientRoles = Map.of(clientId, List.of("testRole"));
List<UserRepresentation> realmUsers = Optional.ofNullable(testRealm.getUsers()).map(ArrayList::new).orElse(new ArrayList<>());
realmUsers.add(getUserRepresentation("John Doe", List.of(CREDENTIAL_OFFER_CREATE.getName()), clientRoles));
realmUsers.add(getUserRepresentation("Alice Wonderland", List.of(), Map.of()));
testRealm.setUsers(realmUsers);
}

View File

@@ -61,7 +61,6 @@ import org.keycloak.protocol.oid4vc.model.DisplayObject;
import org.keycloak.protocol.oid4vc.model.Format;
import org.keycloak.protocol.oid4vc.model.ProofTypesSupported;
import org.keycloak.protocol.oid4vc.model.SupportedCredentialConfiguration;
import org.keycloak.representations.idm.ClientRepresentation;
import org.keycloak.representations.idm.ClientScopeRepresentation;
import org.keycloak.representations.idm.ProtocolMapperRepresentation;
import org.keycloak.representations.idm.RealmRepresentation;
@@ -88,7 +87,7 @@ import org.hamcrest.Matchers;
import org.junit.Test;
import static org.keycloak.OID4VCConstants.SIGNED_METADATA_JWT_TYPE;
import static org.keycloak.constants.Oid4VciConstants.BATCH_CREDENTIAL_ISSUANCE_BATCH_SIZE;
import static org.keycloak.constants.OID4VCIConstants.BATCH_CREDENTIAL_ISSUANCE_BATCH_SIZE;
import static org.keycloak.jose.jwe.JWEConstants.A256GCM;
import static org.keycloak.jose.jwe.JWEConstants.RSA_OAEP;
import static org.keycloak.jose.jwe.JWEConstants.RSA_OAEP_256;
@@ -783,31 +782,4 @@ public class OID4VCIssuerWellKnownProviderTest extends OID4VCIssuerEndpointTest
}
});
}
public static void extendConfigureTestRealm(RealmRepresentation testRealm, ClientRepresentation clientRepresentation) {
if (testRealm.getComponents() == null) {
testRealm.setComponents(new MultivaluedHashMap<>());
}
testRealm.getComponents().add("org.keycloak.keys.KeyProvider", getRsaKeyProvider(RSA_KEY));
testRealm.getComponents().add("org.keycloak.protocol.oid4vc.issuance.credentialbuilder.CredentialBuilder", getCredentialBuilderProvider(Format.JWT_VC));
if (testRealm.getClients() != null) {
testRealm.getClients().add(clientRepresentation);
} else {
testRealm.setClients(new ArrayList<>(List.of(clientRepresentation)));
}
if (testRealm.getUsers() != null) {
testRealm.getUsers().add(OID4VCTest.getUserRepresentation(Map.of(clientRepresentation.getClientId(), List.of("testRole"))));
} else {
testRealm.setUsers(new ArrayList<>(List.of(OID4VCTest.getUserRepresentation(Map.of(clientRepresentation.getClientId(), List.of("testRole"))))));
}
if (testRealm.getAttributes() != null) {
testRealm.getAttributes().put("issuerDid", TEST_DID.toString());
} else {
testRealm.setAttributes(new HashMap<>(Map.of("issuerDid", TEST_DID.toString())));
}
}
}

View File

@@ -6,7 +6,6 @@ import jakarta.ws.rs.core.Response;
import org.keycloak.protocol.oid4vc.issuance.OID4VCIssuerEndpoint;
import org.keycloak.protocol.oid4vc.model.CredentialRequest;
import org.keycloak.protocol.oid4vc.model.OfferUriType;
import org.keycloak.services.CorsErrorResponseException;
import org.keycloak.services.managers.AppAuthManager;
import org.keycloak.testsuite.Assert;
@@ -36,7 +35,7 @@ public class OID4VCJWTIssuerEndpointDisabledTest extends OID4VCIssuerEndpointTes
// Test getCredentialOfferURI
CorsErrorResponseException offerUriException = Assert.assertThrows(CorsErrorResponseException.class, () ->
issuerEndpoint.getCredentialOfferURI("test-credential", OfferUriType.URI, 0, 0)
issuerEndpoint.getCredentialOfferURI("test-credential")
);
assertEquals("Should fail with 403 Forbidden when client is not OID4VCI-enabled",
Response.Status.FORBIDDEN.getStatusCode(), offerUriException.getResponse().getStatus());

View File

@@ -35,9 +35,12 @@ import jakarta.ws.rs.core.Response;
import org.keycloak.OAuth2Constants;
import org.keycloak.TokenVerifier;
import org.keycloak.common.VerificationException;
import org.keycloak.common.util.Time;
import org.keycloak.models.oid4vci.CredentialScopeModel;
import org.keycloak.protocol.oid4vc.issuance.OID4VCIssuerEndpoint;
import org.keycloak.protocol.oid4vc.issuance.OID4VCIssuerWellKnownProvider;
import org.keycloak.protocol.oid4vc.issuance.credentialoffer.CredentialOfferStorage;
import org.keycloak.protocol.oid4vc.issuance.credentialoffer.CredentialOfferStorage.CredentialOfferState;
import org.keycloak.protocol.oid4vc.model.Claim;
import org.keycloak.protocol.oid4vc.model.ClaimDisplay;
import org.keycloak.protocol.oid4vc.model.Claims;
@@ -50,7 +53,6 @@ import org.keycloak.protocol.oid4vc.model.ErrorResponse;
import org.keycloak.protocol.oid4vc.model.ErrorType;
import org.keycloak.protocol.oid4vc.model.Format;
import org.keycloak.protocol.oid4vc.model.JwtProof;
import org.keycloak.protocol.oid4vc.model.OfferUriType;
import org.keycloak.protocol.oid4vc.model.PreAuthorizedCode;
import org.keycloak.protocol.oid4vc.model.PreAuthorizedGrant;
import org.keycloak.protocol.oid4vc.model.Proofs;
@@ -59,9 +61,8 @@ import org.keycloak.protocol.oid4vc.model.VerifiableCredential;
import org.keycloak.protocol.oidc.grants.PreAuthorizedCodeGrantTypeFactory;
import org.keycloak.protocol.oidc.representations.OIDCConfigurationRepresentation;
import org.keycloak.representations.JsonWebToken;
import org.keycloak.sdjwt.vp.SdJwtVP;
import org.keycloak.services.CorsErrorResponseException;
import org.keycloak.services.managers.AppAuthManager;
import org.keycloak.services.managers.AppAuthManager.BearerTokenAuthenticator;
import org.keycloak.testsuite.util.oauth.AccessTokenResponse;
import org.keycloak.util.JsonSerialization;
@@ -90,19 +91,20 @@ import static org.junit.Assert.fail;
* Test from org.keycloak.testsuite.oid4vc.issuance.signing.OID4VCIssuerEndpointTest
*/
public class OID4VCJWTIssuerEndpointTest extends OID4VCIssuerEndpointTest {
// ----- getCredentialOfferUri
@Test
public void testGetCredentialOfferUriUnsupportedCredential() {
String token = getBearerToken(oauth);
testingClient.server(TEST_REALM_NAME).run((session -> {
AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session);
BearerTokenAuthenticator authenticator = new BearerTokenAuthenticator(session);
authenticator.setTokenString(token);
OID4VCIssuerEndpoint oid4VCIssuerEndpoint = prepareIssuerEndpoint(session, authenticator);
CorsErrorResponseException exception = Assert.assertThrows(CorsErrorResponseException.class, () ->
oid4VCIssuerEndpoint.getCredentialOfferURI("inexistent-id", OfferUriType.URI, 0, 0)
oid4VCIssuerEndpoint.getCredentialOfferURI("inexistent-id")
);
assertEquals("Should return BAD_REQUEST", Response.Status.BAD_REQUEST.getStatusCode(),
exception.getResponse().getStatus());
@@ -112,12 +114,12 @@ public class OID4VCJWTIssuerEndpointTest extends OID4VCIssuerEndpointTest {
@Test
public void testGetCredentialOfferUriUnauthorized() {
testingClient.server(TEST_REALM_NAME).run((session -> {
AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session);
BearerTokenAuthenticator authenticator = new BearerTokenAuthenticator(session);
authenticator.setTokenString(null);
OID4VCIssuerEndpoint oid4VCIssuerEndpoint = prepareIssuerEndpoint(session, authenticator);
CorsErrorResponseException exception = Assert.assertThrows(CorsErrorResponseException.class, () ->
oid4VCIssuerEndpoint.getCredentialOfferURI("test-credential", OfferUriType.URI, 0, 0)
oid4VCIssuerEndpoint.getCredentialOfferURI("test-credential", true, "john")
);
assertEquals("Should return BAD_REQUEST", Response.Status.BAD_REQUEST.getStatusCode(),
exception.getResponse().getStatus());
@@ -127,12 +129,12 @@ public class OID4VCJWTIssuerEndpointTest extends OID4VCIssuerEndpointTest {
@Test
public void testGetCredentialOfferUriInvalidToken() {
testingClient.server(TEST_REALM_NAME).run((session -> {
AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session);
BearerTokenAuthenticator authenticator = new BearerTokenAuthenticator(session);
authenticator.setTokenString("invalid-token");
OID4VCIssuerEndpoint oid4VCIssuerEndpoint = prepareIssuerEndpoint(session, authenticator);
CorsErrorResponseException exception = Assert.assertThrows(CorsErrorResponseException.class, () ->
oid4VCIssuerEndpoint.getCredentialOfferURI("test-credential", OfferUriType.URI, 0, 0)
oid4VCIssuerEndpoint.getCredentialOfferURI("test-credential", true, "john")
);
assertEquals("Should return BAD_REQUEST", Response.Status.BAD_REQUEST.getStatusCode(),
exception.getResponse().getStatus());
@@ -148,15 +150,11 @@ public class OID4VCJWTIssuerEndpointTest extends OID4VCIssuerEndpointTest {
testingClient.server(TEST_REALM_NAME).run((session) -> {
try {
AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(
session);
BearerTokenAuthenticator authenticator = new BearerTokenAuthenticator(session);
authenticator.setTokenString(token);
OID4VCIssuerEndpoint oid4VCIssuerEndpoint = prepareIssuerEndpoint(session, authenticator);
Response response = oid4VCIssuerEndpoint.getCredentialOfferURI(credentialConfigurationId,
OfferUriType.URI,
0,
0);
Response response = oid4VCIssuerEndpoint.getCredentialOfferURI(credentialConfigurationId);
assertEquals("An offer uri should have been returned.", HttpStatus.SC_OK, response.getStatus());
CredentialOfferURI credentialOfferURI = JsonSerialization.mapper.convertValue(response.getEntity(),
@@ -177,7 +175,7 @@ public class OID4VCJWTIssuerEndpointTest extends OID4VCIssuerEndpointTest {
testingClient
.server(TEST_REALM_NAME)
.run((session) -> {
AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session);
BearerTokenAuthenticator authenticator = new BearerTokenAuthenticator(session);
authenticator.setTokenString(null);
OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator);
Response response = issuerEndpoint.getCredentialOffer("nonce");
@@ -193,7 +191,7 @@ public class OID4VCJWTIssuerEndpointTest extends OID4VCIssuerEndpointTest {
testingClient
.server(TEST_REALM_NAME)
.run((session -> {
AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session);
BearerTokenAuthenticator authenticator = new BearerTokenAuthenticator(session);
authenticator.setTokenString(token);
OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator);
issuerEndpoint.getCredentialOffer(null);
@@ -208,7 +206,7 @@ public class OID4VCJWTIssuerEndpointTest extends OID4VCIssuerEndpointTest {
testingClient
.server(TEST_REALM_NAME)
.run((session -> {
AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session);
BearerTokenAuthenticator authenticator = new BearerTokenAuthenticator(session);
authenticator.setTokenString(token);
OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator);
issuerEndpoint.getCredentialOffer("unpreparedNonce");
@@ -223,11 +221,11 @@ public class OID4VCJWTIssuerEndpointTest extends OID4VCIssuerEndpointTest {
testingClient
.server(TEST_REALM_NAME)
.run((session -> {
AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session);
BearerTokenAuthenticator authenticator = new BearerTokenAuthenticator(session);
authenticator.setTokenString(token);
String sessionCode = prepareSessionCode(session, authenticator, "invalidNote");
String nonce = prepareSessionCode(session, authenticator, "invalidNote").key();
OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator);
issuerEndpoint.getCredentialOffer(sessionCode);
issuerEndpoint.getCredentialOffer(nonce);
}));
});
}
@@ -238,25 +236,28 @@ public class OID4VCJWTIssuerEndpointTest extends OID4VCIssuerEndpointTest {
testingClient
.server(TEST_REALM_NAME)
.run((session) -> {
AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session);
BearerTokenAuthenticator authenticator = new BearerTokenAuthenticator(session);
authenticator.setTokenString(token);
CredentialsOffer credentialsOffer = new CredentialsOffer()
CredentialsOffer credOffer = new CredentialsOffer()
.setCredentialIssuer("the-issuer")
.setGrants(new PreAuthorizedGrant().setPreAuthorizedCode(new PreAuthorizedCode().setPreAuthorizedCode("the-code")))
.setCredentialConfigurationIds(List.of("credential-configuration-id"));
String sessionCode = prepareSessionCode(session, authenticator, JsonSerialization.writeValueAsString(credentialsOffer));
CredentialOfferStorage offerStorage = session.getProvider(CredentialOfferStorage.class);
CredentialOfferState offerState = new CredentialOfferState(credOffer, null, null, Time.currentTime() + 60);
offerStorage.putOfferState(session, offerState);
// The cache transactions need to be committed explicitly in the test. Without that, the OAuth2Code will only be committed to
// the cache after .run((session)-> ...)
session.getTransactionManager().commit();
OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator);
Response credentialOfferResponse = issuerEndpoint.getCredentialOffer(sessionCode);
Response credentialOfferResponse = issuerEndpoint.getCredentialOffer(offerState.getNonce());
assertEquals("The offer should have been returned.", HttpStatus.SC_OK, credentialOfferResponse.getStatus());
Object credentialOfferEntity = credentialOfferResponse.getEntity();
assertNotNull("An actual offer should be in the response.", credentialOfferEntity);
CredentialsOffer retrievedCredentialsOffer = JsonSerialization.mapper.convertValue(credentialOfferEntity, CredentialsOffer.class);
assertEquals("The offer should be the one prepared with for the session.", credentialsOffer, retrievedCredentialsOffer);
assertEquals("The offer should be the one prepared with for the session.", credOffer, retrievedCredentialsOffer);
});
}
@@ -265,7 +266,7 @@ public class OID4VCJWTIssuerEndpointTest extends OID4VCIssuerEndpointTest {
@Test
public void testRequestCredentialUnauthorized() {
testingClient.server(TEST_REALM_NAME).run(session -> {
AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session);
BearerTokenAuthenticator authenticator = new BearerTokenAuthenticator(session);
authenticator.setTokenString(null);
OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator);
CredentialRequest credentialRequest = new CredentialRequest()
@@ -285,7 +286,7 @@ public class OID4VCJWTIssuerEndpointTest extends OID4VCIssuerEndpointTest {
@Test
public void testRequestCredentialInvalidToken() {
testingClient.server(TEST_REALM_NAME).run(session -> {
AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session);
BearerTokenAuthenticator authenticator = new BearerTokenAuthenticator(session);
authenticator.setTokenString("token");
OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator);
CredentialRequest credentialRequest = new CredentialRequest()
@@ -311,8 +312,8 @@ public class OID4VCJWTIssuerEndpointTest extends OID4VCIssuerEndpointTest {
try {
withCausePropagation(() -> {
testingClient.server(TEST_REALM_NAME).run((session -> {
AppAuthManager.BearerTokenAuthenticator authenticator =
new AppAuthManager.BearerTokenAuthenticator(session);
BearerTokenAuthenticator authenticator =
new BearerTokenAuthenticator(session);
authenticator.setTokenString(token);
// Prepare the issue endpoint with no credential builders.
@@ -337,7 +338,7 @@ public class OID4VCJWTIssuerEndpointTest extends OID4VCIssuerEndpointTest {
String token = getBearerToken(oauth);
withCausePropagation(() -> {
testingClient.server(TEST_REALM_NAME).run(session -> {
AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session);
BearerTokenAuthenticator authenticator = new BearerTokenAuthenticator(session);
authenticator.setTokenString(token);
OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator);
CredentialRequest credentialRequest = new CredentialRequest()
@@ -352,14 +353,15 @@ public class OID4VCJWTIssuerEndpointTest extends OID4VCIssuerEndpointTest {
@Test
public void testRequestCredential() {
final String scopeName = jwtTypeCredentialClientScope.getName();
String scopeName = jwtTypeCredentialClientScope.getName();
String credConfigId = jwtTypeCredentialClientScope.getAttributes().get(CredentialScopeModel.CONFIGURATION_ID);
String token = getBearerToken(oauth, client, scopeName);
testingClient.server(TEST_REALM_NAME).run(session -> {
AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session);
BearerTokenAuthenticator authenticator = new BearerTokenAuthenticator(session);
authenticator.setTokenString(token);
OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator);
CredentialRequest credentialRequest = new CredentialRequest()
.setCredentialIdentifier(scopeName);
.setCredentialConfigurationId(credConfigId);
String requestPayload = JsonSerialization.writeValueAsString(credentialRequest);
@@ -392,27 +394,22 @@ public class OID4VCJWTIssuerEndpointTest extends OID4VCIssuerEndpointTest {
}
@Test
public void testRequestCredentialWithConfigurationIdNotSet() {
public void testRequestCredentialWithNeitherIdSet() {
final String scopeName = minimalJwtTypeCredentialClientScope.getName();
String token = getBearerToken(oauth, client, scopeName);
testingClient.server(TEST_REALM_NAME).run(session -> {
AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session);
BearerTokenAuthenticator authenticator = new BearerTokenAuthenticator(session);
authenticator.setTokenString(token);
OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator);
CredentialRequest credentialRequest = new CredentialRequest()
.setCredentialIdentifier(scopeName);
String requestPayload = JsonSerialization.writeValueAsString(credentialRequest);
Response credentialResponse = issuerEndpoint.requestCredential(requestPayload);
assertEquals("The credential request should be answered successfully.",
HttpStatus.SC_OK, credentialResponse.getStatus());
assertNotNull("A credential should be responded.", credentialResponse.getEntity());
CredentialResponse credentialResponseVO = JsonSerialization.mapper
.convertValue(credentialResponse.getEntity(), CredentialResponse.class);
String credentialString = (String) credentialResponseVO.getCredentials().get(0).getCredential();
SdJwtVP sdJwtVP = SdJwtVP.of(credentialString);
assertNotNull("A valid credential string should have been responded", sdJwtVP);
CredentialRequest credentialRequest = new CredentialRequest();
try {
String requestPayload = JsonSerialization.writeValueAsString(credentialRequest);
issuerEndpoint.requestCredential(requestPayload);
Assert.fail("Expected BadRequestException due to unknown credential identifier");
} catch (BadRequestException e) {
ErrorResponse error = (ErrorResponse) e.getResponse().getEntity();
assertEquals(ErrorType.MISSING_CREDENTIAL_IDENTIFIER_AND_CONFIGURATION_ID, error.getError());
}
});
}
@@ -430,9 +427,7 @@ public class OID4VCJWTIssuerEndpointTest extends OID4VCIssuerEndpointTest {
// 1. Retrieving the credential-offer-uri
final String credentialConfigurationId = jwtTypeCredentialClientScope.getAttributes()
.get(CredentialScopeModel.CONFIGURATION_ID);
HttpGet getCredentialOfferURI = new HttpGet(getBasePath(TEST_REALM_NAME)
+ "credential-offer-uri?credential_configuration_id="
+ credentialConfigurationId);
HttpGet getCredentialOfferURI = new HttpGet(getCredentialOfferUriUrl(credentialConfigurationId));
getCredentialOfferURI.addHeader(HttpHeaders.AUTHORIZATION, "Bearer " + token);
CloseableHttpResponse credentialOfferURIResponse = httpClient.execute(getCredentialOfferURI);
@@ -602,15 +597,16 @@ public class OID4VCJWTIssuerEndpointTest extends OID4VCIssuerEndpointTest {
@Test
public void testRequestCredentialWithNotificationId() {
String token = getBearerToken(oauth, client, jwtTypeCredentialClientScope.getName());
final String scopeName = jwtTypeCredentialClientScope.getName();
String credConfigId = jwtTypeCredentialClientScope.getAttributes().get(CredentialScopeModel.CONFIGURATION_ID);
String token = getBearerToken(oauth, client, scopeName);
testingClient.server(TEST_REALM_NAME).run((session) -> {
AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session);
BearerTokenAuthenticator authenticator = new BearerTokenAuthenticator(session);
authenticator.setTokenString(token);
OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator);
CredentialRequest credentialRequest = new CredentialRequest()
.setCredentialIdentifier(scopeName);
.setCredentialConfigurationId(credConfigId);
String requestPayload = JsonSerialization.writeValueAsString(credentialRequest);
@@ -639,12 +635,13 @@ public class OID4VCJWTIssuerEndpointTest extends OID4VCIssuerEndpointTest {
@Test
public void testRequestMultipleCredentialsWithProofs() {
final String scopeName = jwtTypeCredentialClientScope.getName();
String credConfigId = jwtTypeCredentialClientScope.getAttributes().get(CredentialScopeModel.CONFIGURATION_ID);
String token = getBearerToken(oauth, client, scopeName);
String cNonce = getCNonce();
testingClient.server(TEST_REALM_NAME).run(session -> {
try {
AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session);
BearerTokenAuthenticator authenticator = new BearerTokenAuthenticator(session);
authenticator.setTokenString(token);
String issuer = OID4VCIssuerWellKnownProvider.getIssuer(session.getContext());
@@ -654,7 +651,7 @@ public class OID4VCJWTIssuerEndpointTest extends OID4VCIssuerEndpointTest {
CredentialRequest request = new CredentialRequest()
.setCredentialIdentifier(scopeName)
.setCredentialConfigurationId(credConfigId)
.setProofs(proofs);
OID4VCIssuerEndpoint endpoint = prepareIssuerEndpoint(session, authenticator);
@@ -874,7 +871,7 @@ public class OID4VCJWTIssuerEndpointTest extends OID4VCIssuerEndpointTest {
String token = getBearerToken(oauth, client, jwtTypeCredentialClientScope.getName());
testingClient.server(TEST_REALM_NAME).run(session -> {
AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session);
BearerTokenAuthenticator authenticator = new BearerTokenAuthenticator(session);
authenticator.setTokenString(token);
OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator);
@@ -901,7 +898,7 @@ public class OID4VCJWTIssuerEndpointTest extends OID4VCIssuerEndpointTest {
String token = getBearerToken(oauth, client, jwtTypeCredentialClientScope.getName());
testingClient.server(TEST_REALM_NAME).run(session -> {
AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session);
BearerTokenAuthenticator authenticator = new BearerTokenAuthenticator(session);
authenticator.setTokenString(token);
OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator);
@@ -931,7 +928,7 @@ public class OID4VCJWTIssuerEndpointTest extends OID4VCIssuerEndpointTest {
String token = getBearerToken(oauth, client, jwtTypeCredentialClientScope.getName());
testingClient.server(TEST_REALM_NAME).run(session -> {
AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session);
BearerTokenAuthenticator authenticator = new BearerTokenAuthenticator(session);
authenticator.setTokenString(token);
// Prepare endpoint with no credential builders to simulate missing builder for the configured format
OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator, Map.of());
@@ -963,7 +960,7 @@ public class OID4VCJWTIssuerEndpointTest extends OID4VCIssuerEndpointTest {
.get(CredentialScopeModel.CONFIGURATION_ID);
testingClient.server(TEST_REALM_NAME).run(session -> {
AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session);
BearerTokenAuthenticator authenticator = new BearerTokenAuthenticator(session);
authenticator.setTokenString(token);
OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator);

View File

@@ -6,7 +6,6 @@ import jakarta.ws.rs.core.Response;
import org.keycloak.protocol.oid4vc.issuance.OID4VCIssuerEndpoint;
import org.keycloak.protocol.oid4vc.model.CredentialRequest;
import org.keycloak.protocol.oid4vc.model.OfferUriType;
import org.keycloak.services.CorsErrorResponseException;
import org.keycloak.services.managers.AppAuthManager;
import org.keycloak.testsuite.Assert;
@@ -36,7 +35,7 @@ public class OID4VCSdJwtIssuingEndpointDisabledTest extends OID4VCIssuerEndpoint
// Test getCredentialOfferURI
CorsErrorResponseException offerUriException = Assert.assertThrows(CorsErrorResponseException.class, () ->
issuerEndpoint.getCredentialOfferURI("test-credential", OfferUriType.URI, 0, 0)
issuerEndpoint.getCredentialOfferURI("test-credential")
);
assertEquals("Should fail with 403 Forbidden when client is not OID4VCI-enabled",
Response.Status.FORBIDDEN.getStatusCode(), offerUriException.getResponse().getStatus());

View File

@@ -32,7 +32,7 @@ import org.keycloak.OAuth2Constants;
import org.keycloak.TokenVerifier;
import org.keycloak.common.VerificationException;
import org.keycloak.common.util.Base64Url;
import org.keycloak.constants.Oid4VciConstants;
import org.keycloak.constants.OID4VCIConstants;
import org.keycloak.models.ClientScopeModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
@@ -231,7 +231,7 @@ public class OID4VCSdJwtIssuingEndpointTest extends OID4VCIssuerEndpointTest {
final String nonceEndpoint = OID4VCIssuerWellKnownProvider.getNonceEndpoint(session.getContext());
try {
// make the exp-value negative to set the exp-time in the past
session.getContext().getRealm().setAttribute(Oid4VciConstants.C_NONCE_LIFETIME_IN_SECONDS, -1);
session.getContext().getRealm().setAttribute(OID4VCIConstants.C_NONCE_LIFETIME_IN_SECONDS, -1);
String cNonce = cNonceHandler.buildCNonce(List.of(credentialsEndpoint),
Map.of(JwtCNonceHandler.SOURCE_ENDPOINT, nonceEndpoint));
Proofs proof = new Proofs().setJwt(List.of(generateJwtProof(getCredentialIssuer(session), cNonce)));
@@ -241,7 +241,7 @@ public class OID4VCSdJwtIssuingEndpointTest extends OID4VCIssuerEndpointTest {
testRequestTestCredential(session, clientScope, token, proof);
} finally {
// make sure other tests are not affected by the changed realm-attribute
session.getContext().getRealm().removeAttribute(Oid4VciConstants.C_NONCE_LIFETIME_IN_SECONDS);
session.getContext().getRealm().removeAttribute(OID4VCIConstants.C_NONCE_LIFETIME_IN_SECONDS);
}
})));
Assert.fail("Should have thrown an exception");
@@ -303,9 +303,7 @@ public class OID4VCSdJwtIssuingEndpointTest extends OID4VCIssuerEndpointTest {
// 1. Retrieving the credential-offer-uri
final String credentialConfigurationId = clientScope.getAttributes().get(CredentialScopeModel.CONFIGURATION_ID);
HttpGet getCredentialOfferURI = new HttpGet(getBasePath(TEST_REALM_NAME) +
"credential-offer-uri?credential_configuration_id=" +
credentialConfigurationId);
HttpGet getCredentialOfferURI = new HttpGet(getCredentialOfferUriUrl(credentialConfigurationId));
getCredentialOfferURI.addHeader(HttpHeaders.AUTHORIZATION, "Bearer " + token);
CloseableHttpResponse credentialOfferURIResponse = httpClient.execute(getCredentialOfferURI);
@@ -515,7 +513,7 @@ public class OID4VCSdJwtIssuingEndpointTest extends OID4VCIssuerEndpointTest {
public static ProtocolMapperRepresentation getJtiGeneratedIdMapper() {
ProtocolMapperRepresentation protocolMapperRepresentation = new ProtocolMapperRepresentation();
protocolMapperRepresentation.setName("generated-id-mapper");
protocolMapperRepresentation.setProtocol(Oid4VciConstants.OID4VC_PROTOCOL);
protocolMapperRepresentation.setProtocol(OID4VCIConstants.OID4VC_PROTOCOL);
protocolMapperRepresentation.setId(UUID.randomUUID().toString());
protocolMapperRepresentation.setProtocolMapper("oid4vc-generated-id-mapper");
protocolMapperRepresentation.setConfig(Map.of(
@@ -530,7 +528,7 @@ public class OID4VCSdJwtIssuingEndpointTest extends OID4VCIssuerEndpointTest {
.addClientScope(realmModel, jwtTypeCredentialScopeName);
credentialScope.setAttribute(CredentialScopeModel.CREDENTIAL_IDENTIFIER,
jwtTypeCredentialScopeName);
credentialScope.setProtocol(Oid4VciConstants.OID4VC_PROTOCOL);
credentialScope.setProtocol(OID4VCIConstants.OID4VC_PROTOCOL);
return credentialScope;
}

View File

@@ -29,6 +29,7 @@ import java.security.Security;
import java.security.cert.Certificate;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.Arrays;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
@@ -53,7 +54,7 @@ import org.keycloak.common.util.CertificateUtils;
import org.keycloak.common.util.KeyUtils;
import org.keycloak.common.util.MultivaluedHashMap;
import org.keycloak.common.util.PemUtils;
import org.keycloak.constants.Oid4VciConstants;
import org.keycloak.constants.OID4VCIConstants;
import org.keycloak.crypto.ECDSASignatureSignerContext;
import org.keycloak.crypto.KeyUse;
import org.keycloak.crypto.KeyWrapper;
@@ -70,6 +71,7 @@ import org.keycloak.protocol.oid4vc.issuance.VCIssuanceContext;
import org.keycloak.protocol.oid4vc.issuance.keybinding.AttestationValidatorUtil;
import org.keycloak.protocol.oid4vc.issuance.keybinding.JwtProofValidator;
import org.keycloak.protocol.oid4vc.issuance.mappers.OID4VCIssuedAtTimeClaimMapper;
import org.keycloak.protocol.oid4vc.model.AuthorizationDetail;
import org.keycloak.protocol.oid4vc.model.CredentialRequest;
import org.keycloak.protocol.oid4vc.model.CredentialSubject;
import org.keycloak.protocol.oid4vc.model.Format;
@@ -91,7 +93,8 @@ import org.keycloak.testsuite.AbstractTestRealmKeycloakTest;
import org.keycloak.testsuite.arquillian.annotation.EnableFeature;
import org.keycloak.testsuite.util.AdminClientUtil;
import org.keycloak.testsuite.util.UserBuilder;
import org.keycloak.testsuite.util.oauth.AuthorizationEndpointResponse;
import org.keycloak.testsuite.util.oauth.AccessTokenRequest;
import org.keycloak.testsuite.util.oauth.AccessTokenResponse;
import org.keycloak.testsuite.util.oauth.OAuthClient;
import org.keycloak.util.JsonSerialization;
@@ -336,7 +339,7 @@ public abstract class OID4VCTest extends AbstractTestRealmKeycloakTest {
ProtocolMapperRepresentation protocolMapperRepresentation = new ProtocolMapperRepresentation();
protocolMapperRepresentation.setName("role-mapper");
protocolMapperRepresentation.setId(UUID.randomUUID().toString());
protocolMapperRepresentation.setProtocol(Oid4VciConstants.OID4VC_PROTOCOL);
protocolMapperRepresentation.setProtocol(OID4VCIConstants.OID4VC_PROTOCOL);
protocolMapperRepresentation.setProtocolMapper("oid4vc-target-role-mapper");
protocolMapperRepresentation.setConfig(
Map.of("claim.name", "roles", "clientId", clientId)
@@ -347,7 +350,7 @@ public abstract class OID4VCTest extends AbstractTestRealmKeycloakTest {
public static ProtocolMapperRepresentation getIdMapper() {
ProtocolMapperRepresentation protocolMapperRepresentation = new ProtocolMapperRepresentation();
protocolMapperRepresentation.setName("id-mapper");
protocolMapperRepresentation.setProtocol(Oid4VciConstants.OID4VC_PROTOCOL);
protocolMapperRepresentation.setProtocol(OID4VCIConstants.OID4VC_PROTOCOL);
protocolMapperRepresentation.setId(UUID.randomUUID().toString());
protocolMapperRepresentation.setProtocolMapper("oid4vc-subject-id-mapper");
protocolMapperRepresentation.setConfig(Map.of());
@@ -357,7 +360,7 @@ public abstract class OID4VCTest extends AbstractTestRealmKeycloakTest {
public static ProtocolMapperRepresentation getStaticClaimMapper(String scopeName) {
ProtocolMapperRepresentation protocolMapperRepresentation = new ProtocolMapperRepresentation();
protocolMapperRepresentation.setName(UUID.randomUUID().toString());
protocolMapperRepresentation.setProtocol(Oid4VciConstants.OID4VC_PROTOCOL);
protocolMapperRepresentation.setProtocol(OID4VCIConstants.OID4VC_PROTOCOL);
protocolMapperRepresentation.setId(UUID.randomUUID().toString());
protocolMapperRepresentation.setProtocolMapper("oid4vc-static-claim-mapper");
protocolMapperRepresentation.setConfig(
@@ -394,25 +397,36 @@ public abstract class OID4VCTest extends AbstractTestRealmKeycloakTest {
return componentExportRepresentation;
}
public static UserRepresentation getUserRepresentation(Map<String, List<String>> clientRoles) {
UserBuilder userBuilder = UserBuilder.create()
.id(KeycloakModelUtils.generateId())
.username("john")
.enabled(true)
.email("john@email.cz")
.emailVerified(true)
.firstName("John")
.lastName("Doe")
.password("password")
.role("account", "manage-account")
.role("account", "view-profile");
public static UserRepresentation getUserRepresentation(
String fullName,
List<String> realmRoles,
Map<String, List<String>> clientRoles
) {
String[] nameToks = fullName.split("\\s");
String firstName = nameToks[0];
String lastName = nameToks[1];
String username = firstName.toLowerCase();
UserBuilder userBuilder = UserBuilder.create()
.id(KeycloakModelUtils.generateId())
.username(username)
.enabled(true)
.email(username + "@email.cz")
.emailVerified(true)
.firstName(firstName)
.lastName(lastName)
.password("password")
.role("account", "manage-account")
.role("account", "view-profile");
clientRoles.entrySet().forEach(entry -> {
entry.getValue().forEach(role -> userBuilder.role(entry.getKey(), role));
});
return userBuilder.build();
}
// When Keycloak issues a token for a user and client:
//
// 1. It looks up all effective realm roles and all effective client roles assigned to the user.
// 2. The token includes only those roles that the user actually has.
//
realmRoles.forEach(userBuilder::addRoles);
clientRoles.forEach((cid, roles) -> roles.forEach(role -> userBuilder.role(cid, role)));
return userBuilder.build();
}
public static RoleRepresentation getRoleRepresentation(String roleName, String clientId) {
@@ -423,27 +437,62 @@ public abstract class OID4VCTest extends AbstractTestRealmKeycloakTest {
return role;
}
protected String getBearerToken(OAuthClient oAuthClient) {
return getBearerToken(oAuthClient, null);
protected String getAuthorizationCode(OAuthClient oAuthClient, ClientRepresentation client, String username, String scope) {
if (client != null) {
oAuthClient.client(client.getClientId(), client.getSecret());
}
if (scope != null) {
oAuthClient.scope(scope);
}
var authorizationEndpointResponse = oAuthClient.doLogin(username,"password");
return authorizationEndpointResponse.getCode();
}
protected String getBearerToken(OAuthClient oauthClient) {
return getBearerToken(oauthClient, null);
}
protected String getBearerToken(OAuthClient oAuthClient, ClientRepresentation client) {
return getBearerToken(oAuthClient, client, null);
protected String getBearerToken(OAuthClient oauthClient, ClientRepresentation client) {
return getBearerToken(oauthClient, client, null);
}
protected String getBearerToken(OAuthClient oAuthClient, ClientRepresentation client, String credentialScopeName) {
if (client != null) {
oAuthClient.client(client.getClientId(), client.getSecret());
}
if (credentialScopeName != null) {
oAuthClient.scope(credentialScopeName);
}
AuthorizationEndpointResponse authorizationEndpointResponse = oAuthClient.doLogin("john",
"password");
return oAuthClient.doAccessTokenRequest(authorizationEndpointResponse.getCode()).getAccessToken();
protected String getBearerToken(OAuthClient oauthClient, ClientRepresentation client, String scope) {
return getBearerToken(oauthClient, client, "john", scope);
}
public static class StaticTimeProvider implements TimeProvider {
protected String getBearerToken(OAuthClient oauthClient, ClientRepresentation client, String username, String scope) {
return getBearerTokenCodeFlow(oauthClient, client, username, scope).getAccessToken();
}
protected AccessTokenResponse getBearerToken(OAuthClient oauthClient, String authCode, AuthorizationDetail... authDetail) {
AccessTokenRequest accessTokenRequest = oauthClient.accessTokenRequest(authCode);
if (authDetail != null) {
accessTokenRequest.authorizationDetails(Arrays.asList(authDetail));
}
AccessTokenResponse tokenResponse = accessTokenRequest.send();
if (!tokenResponse.isSuccess()) {
throw new IllegalStateException(tokenResponse.getErrorDescription());
}
return tokenResponse;
}
protected AccessTokenResponse getBearerTokenCodeFlow(OAuthClient oauthClient, ClientRepresentation client, String username, String scope) {
var authCode = getAuthorizationCode(oauthClient, client, username, scope);
return oauthClient.accessTokenRequest(authCode).send();
}
protected AccessTokenResponse getBearerTokenDirectAccess(OAuthClient oauthClient, ClientRepresentation client, String username, String scope) {
if (client != null) {
oauthClient.client(client.getClientId(), client.getSecret());
}
if (scope != null) {
oauthClient.scope(scope);
}
var accessTokenResponse = oauthClient.doPasswordGrantRequest(username, "password");
return accessTokenResponse;
}
public static class StaticTimeProvider implements TimeProvider {
private final int currentTimeInS;
public StaticTimeProvider(int currentTimeInS) {
@@ -464,7 +513,7 @@ public abstract class OID4VCTest extends AbstractTestRealmKeycloakTest {
protected ProtocolMapperRepresentation getUserAttributeMapper(String subjectProperty, String attributeName) {
ProtocolMapperRepresentation protocolMapperRepresentation = new ProtocolMapperRepresentation();
protocolMapperRepresentation.setName(attributeName + "-mapper");
protocolMapperRepresentation.setProtocol(Oid4VciConstants.OID4VC_PROTOCOL);
protocolMapperRepresentation.setProtocol(OID4VCIConstants.OID4VC_PROTOCOL);
protocolMapperRepresentation.setId(UUID.randomUUID().toString());
protocolMapperRepresentation.setProtocolMapper("oid4vc-user-attribute-mapper");
protocolMapperRepresentation.setConfig(
@@ -478,7 +527,7 @@ public abstract class OID4VCTest extends AbstractTestRealmKeycloakTest {
protected ProtocolMapperRepresentation getIssuedAtTimeMapper(String subjectProperty, String truncateToTimeUnit, String valueSource) {
ProtocolMapperRepresentation protocolMapperRepresentation = new ProtocolMapperRepresentation();
protocolMapperRepresentation.setName(subjectProperty + "-oid4vc-issued-at-time-claim-mapper");
protocolMapperRepresentation.setProtocol(Oid4VciConstants.OID4VC_PROTOCOL);
protocolMapperRepresentation.setProtocol(OID4VCIConstants.OID4VC_PROTOCOL);
protocolMapperRepresentation.setId(UUID.randomUUID().toString());
protocolMapperRepresentation.setProtocolMapper("oid4vc-issued-at-time-claim-mapper");

View File

@@ -24,7 +24,7 @@ import jakarta.ws.rs.core.Response;
import org.keycloak.TokenVerifier;
import org.keycloak.common.VerificationException;
import org.keycloak.constants.Oid4VciConstants;
import org.keycloak.constants.OID4VCIConstants;
import org.keycloak.models.oid4vci.CredentialScopeModel;
import org.keycloak.protocol.oid4vc.issuance.OID4VCIssuerEndpoint;
import org.keycloak.protocol.oid4vc.issuance.mappers.OID4VCIssuedAtTimeClaimMapper;
@@ -68,7 +68,7 @@ public class OID4VCTimeNormalizationSdJwtTest extends OID4VCSdJwtIssuingEndpoint
ClientScopeRepresentation clientScope = fromJsonString(clientScopeString, ClientScopeRepresentation.class);
ProtocolMapperRepresentation pr = new ProtocolMapperRepresentation();
pr.setName("iat-from-vc");
pr.setProtocol(Oid4VciConstants.OID4VC_PROTOCOL);
pr.setProtocol(OID4VCIConstants.OID4VC_PROTOCOL);
pr.setProtocolMapper(OID4VCIssuedAtTimeClaimMapper.MAPPER_ID);
pr.setConfig(Map.of(
OID4VCIssuedAtTimeClaimMapper.CLAIM_NAME, "iat",

View File

@@ -24,6 +24,7 @@ import jakarta.ws.rs.core.Response;
import org.keycloak.TokenVerifier;
import org.keycloak.common.VerificationException;
import org.keycloak.models.oid4vci.CredentialScopeModel;
import org.keycloak.protocol.oid4vc.issuance.OID4VCIssuerEndpoint;
import org.keycloak.protocol.oid4vc.model.CredentialRequest;
import org.keycloak.protocol.oid4vc.model.CredentialResponse;
@@ -55,6 +56,7 @@ public class OID4VCTimeNormalizationTest extends OID4VCJWTIssuerEndpointTest {
});
final String scopeName = jwtTypeCredentialClientScope.getName();
String credConfigId = jwtTypeCredentialClientScope.getAttributes().get(CredentialScopeModel.CONFIGURATION_ID);
String token = getBearerToken(oauth, client, scopeName);
testingClient.server(TEST_REALM_NAME).run(session -> {
@@ -64,7 +66,7 @@ public class OID4VCTimeNormalizationTest extends OID4VCJWTIssuerEndpointTest {
OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator);
CredentialRequest credentialRequest = new CredentialRequest()
.setCredentialIdentifier(scopeName);
.setCredentialConfigurationId(credConfigId);
String requestPayload = JsonSerialization.writeValueAsString(credentialRequest);
Response response = issuerEndpoint.requestCredential(requestPayload);

View File

@@ -405,6 +405,7 @@
"clientId": "named-test-app",
"name": "My Named Test App",
"enabled": true,
"directAccessGrantsEnabled": true,
"baseUrl": "http://localhost:8180/namedapp/base",
"redirectUris": [
"http://localhost:8180/namedapp/base/*",