mirror of
https://github.com/keycloak/keycloak.git
synced 2025-12-16 12:05:49 -06:00
[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:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -5,6 +5,7 @@
|
||||
# Intellij
|
||||
###################
|
||||
.idea
|
||||
.run
|
||||
*.iml
|
||||
!.idea/icon.png
|
||||
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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));
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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() {
|
||||
}
|
||||
}
|
||||
@@ -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() { }
|
||||
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -160,7 +160,7 @@ public class SupportedCredentialConfiguration {
|
||||
return null;
|
||||
}
|
||||
|
||||
public CredentialConfigId deriveConfiId() {
|
||||
public CredentialConfigId deriveConfigId() {
|
||||
return CredentialConfigId.from(id);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -326,7 +326,7 @@ public class AdminConsole {
|
||||
}
|
||||
|
||||
protected RealmModel getAdminstrationRealm(RealmManager realmManager) {
|
||||
return realmManager.getKeycloakAdminstrationRealm();
|
||||
return realmManager.getKeycloakAdministrationRealm();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
org.keycloak.protocol.oid4vc.issuance.credentialoffer.InMemoryCredentialOfferStorageFactory
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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()));
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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())));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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/*",
|
||||
|
||||
Reference in New Issue
Block a user