[OID4VCI] Add a client policy to require a credential offer (#47286)

closes #44317


Signed-off-by: Thomas Diesler <tdiesler@proton.me>
This commit is contained in:
Thomas Diesler
2026-05-14 17:04:36 +02:00
committed by GitHub
parent 4cca6f7088
commit ce12c7184c
49 changed files with 836 additions and 99 deletions
@@ -21,7 +21,7 @@ public interface ClientPoliciesPoliciesResource {
/**
* Get client policies for the realm.
*
* @param includeGlobalPolicies Indicates if global server clioent policies should be included or not. Parameter available since Keycloak server 25. Will be ignored on older Keycloak versions with the default value false
* @param includeGlobalPolicies Indicates if global server client policies should be included or not. Parameter available since Keycloak server 25. Will be ignored on older Keycloak versions with the default value false
* @return client policies
*/
@GET
@@ -3973,6 +3973,8 @@ credentialConfigurationId=Credential Configuration ID
credentialConfigurationIdHelp=The unique identifier for this credential configuration. This ID is used in the credential issuer metadata and credential requests.
credentialIdentifier=Credential Identifier
credentialIdentifierHelp=A specific identifier for this credential type. This can be used to distinguish between different variants of the same credential type.
credentialOfferRequired=Credential Offer Required
credentialOfferRequiredHelp=The Issuer requires a credential offer for this credential configuration.
issuerDid=Issuer DID
issuerDidHelp=The Decentralized Identifier (DID) of the credential issuer. This identifies who is issuing the verifiable credentials.
credentialLifetime=Credential Lifetime (seconds)
@@ -394,6 +394,17 @@ export const ScopeForm = ({ clientScope, save }: ScopeFormProps) => {
label={t("credentialIdentifier")}
labelIcon={t("credentialIdentifierHelp")}
/>
<DefaultSwitchControl
name={convertAttributeNameToForm<ClientScopeDefaultOptionalType>(
"attributes.vc.policy.offer.required",
)}
defaultValue={
clientScope?.attributes?.["vc.policy.offer.required"] ?? "false"
}
label={t("credentialOfferRequired")}
labelIcon={t("credentialOfferRequiredHelp")}
stringify
/>
<TextControl
name={convertAttributeNameToForm<ClientScopeDefaultOptionalType>(
"attributes.vc.issuer_did",
@@ -27,9 +27,7 @@ import org.keycloak.services.clientpolicy.ClientPolicyVote;
/**
* This condition determines to which client a client policy is adopted.
* The condition can be evaluated on the events defined in {@link ClientPolicyEvent}.
* It is sufficient for the implementer of this condition to implement methods in which they are interested
* and {@link isEvaluatedOnEvent} method.
*
*
* @author <a href="mailto:takashi.norimatsu.ws@hitachi.com">Takashi Norimatsu</a>
*/
public interface ClientPolicyConditionProvider<CONFIG extends ClientPolicyConditionConfigurationRepresentation> extends Provider {
@@ -26,9 +26,7 @@ import org.keycloak.services.clientpolicy.ClientPolicyException;
/**
* This executor specifies what action is executed on the client to which a client policy is adopted.
* The executor can be executed on the events defined in {@link ClientPolicyEvent}.
* It is sufficient for the implementer of this executor to implement methods in which they are interested
* and {@link isEvaluatedOnEvent} method.
*
*
* @author <a href="mailto:takashi.norimatsu.ws@hitachi.com">Takashi Norimatsu</a>
*/
public interface ClientPolicyExecutorProvider<CONFIG extends ClientPolicyExecutorConfigurationRepresentation> extends Provider {
@@ -0,0 +1,26 @@
package org.keycloak.protocol.oid4vc.clientpolicy;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.representations.idm.ClientPolicyRepresentation;
import org.keycloak.services.clientpolicy.ClientPolicyException;
public abstract class CredentialClientPolicies {
/**
* Governs whether the given `credential_configuration_id` requires a Credential Offer
*/
public static PredicateCredentialClientPolicy VC_POLICY_CREDENTIAL_OFFER_REQUIRED = new PredicateCredentialClientPolicy(
"oid4vci-offer-required", "vc.policy.offer.required", true, false);
public static ClientPolicyRepresentation findClientPolicyByName(KeycloakSession session, String policyName) {
try {
RealmModel realm = session.getContext().getRealm();
return session.clientPolicy().getClientPolicies(realm, false).getPolicies().stream()
.filter(cp -> cp.getName().equals(policyName))
.findFirst().orElse(null);
} catch (ClientPolicyException ex) {
throw new RuntimeException("Cannot access client policies", ex);
}
}
}
@@ -0,0 +1,43 @@
package org.keycloak.protocol.oid4vc.clientpolicy;
import org.keycloak.protocol.oid4vc.model.CredentialScopeRepresentation;
public abstract class CredentialClientPolicy<T> {
private final String name;
private final String attrName;
private final Class<T> type;
private final T expectedValue;
private final T defaultValue;
public CredentialClientPolicy(String name, String attrName, Class<T> type, T expectedValue, T defaultValue) {
this.name = name;
this.attrName = attrName;
this.expectedValue = expectedValue;
this.defaultValue = defaultValue;
this.type = type;
}
public String getName() {
return name;
}
public String getAttrName() {
return attrName;
}
public Class<T> getType() {
return type;
}
public T getExpectedValue() {
return expectedValue;
}
public T getDefaultValue() {
return defaultValue;
}
public abstract T getCurrentValue(CredentialScopeRepresentation credScope);
}
@@ -0,0 +1,108 @@
package org.keycloak.protocol.oid4vc.clientpolicy;
import java.util.List;
import java.util.Optional;
import org.keycloak.OAuthErrorException;
import org.keycloak.events.Errors;
import org.keycloak.models.ClientModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.oid4vci.CredentialScopeModel;
import org.keycloak.protocol.oid4vc.issuance.credentialoffer.CredentialOfferState;
import org.keycloak.protocol.oid4vc.issuance.credentialoffer.CredentialOfferStorage;
import org.keycloak.protocol.oid4vc.model.CredentialsOffer;
import org.keycloak.protocol.oid4vc.model.IssuerState;
import org.keycloak.protocol.oid4vc.utils.CredentialScopeUtils;
import org.keycloak.protocol.oidc.endpoints.request.AuthorizationEndpointRequest;
import org.keycloak.representations.idm.ClientPolicyExecutorConfigurationRepresentation;
import org.keycloak.services.clientpolicy.ClientPolicyContext;
import org.keycloak.services.clientpolicy.ClientPolicyException;
import org.keycloak.services.clientpolicy.context.AuthorizationRequestContext;
import org.keycloak.services.clientpolicy.executor.ClientPolicyExecutorProvider;
import static org.keycloak.OAuth2Constants.ISSUER_STATE;
import static org.keycloak.protocol.oid4vc.clientpolicy.CredentialClientPolicies.VC_POLICY_CREDENTIAL_OFFER_REQUIRED;
import static org.keycloak.services.clientpolicy.ClientPolicyEvent.AUTHORIZATION_REQUEST;
/**
* This client policy executor can be reference in a client profile definition like this,
* which we currently don't add to the defaults client profile definitions.
*
* {
* "name": "oid4vci-client-profile",
* "description": "Client profile, which enforces various policies on oid4vci clients.",
* "executors": [
* {
* "executor": "oid4vci-policy-executor",
* "configuration": {}
* }
* ]
* }
*/
public class CredentialClientPolicyExecutor implements ClientPolicyExecutorProvider<ClientPolicyExecutorConfigurationRepresentation> {
protected final KeycloakSession session;
public CredentialClientPolicyExecutor(KeycloakSession session) {
this.session = session;
}
@Override
public String getProviderId() {
return CredentialClientPolicyExecutorFactory.PROVIDER_ID;
}
@Override
public void executeOnEvent(ClientPolicyContext context) throws ClientPolicyException {
if (AUTHORIZATION_REQUEST.equals(context.getEvent())) {
AuthorizationRequestContext authRequestContext = (AuthorizationRequestContext) context;
checkCredentialPolicies(authRequestContext);
}
}
private void checkCredentialPolicies(AuthorizationRequestContext context) throws ClientPolicyException {
ClientModel client = context.getClient();
if (client == null)
throw new ClientPolicyException(OAuthErrorException.INVALID_CLIENT, "No issuing client");
// Get the list of requested credential scopes that are associated with this client
//
AuthorizationEndpointRequest request = context.getAuthorizationEndpointRequest();
List<CredentialScopeModel> credScopes = CredentialScopeUtils.getCredentialScopesForAuthorization(client, request);
// Proceed when there are requested credential scopes
//
if (!credScopes.isEmpty()) {
PredicateCredentialClientPolicy offerRequiredPolicy = VC_POLICY_CREDENTIAL_OFFER_REQUIRED;
// Get the potential offer state derived from issuer_state
//
String issuerStateParam = request.getAdditionalReqParams().get(ISSUER_STATE);
CredentialOfferStorage offerStorage = session.getProvider(CredentialOfferStorage.class);
CredentialOfferState offerState = Optional.ofNullable(issuerStateParam)
.map(IssuerState::fromEncodedString)
.map(IssuerState::getCredentialsOfferId)
.map(offerStorage::getOfferStateById)
.orElse(null);
// Get the offered credential configuration ids
//
List<String> offeredConfigurationIds = Optional.ofNullable(offerState)
.map(CredentialOfferState::getCredentialsOffer)
.map(CredentialsOffer::getCredentialConfigurationIds)
.orElse(List.of());
// Check whether each requested credential_configuration_id has actually been offered
//
for (CredentialScopeModel credScope : credScopes) {
String credConfigId = credScope.getCredentialConfigurationId();
if (!offeredConfigurationIds.contains(credConfigId)) {
String errorDetail = "Authorization request rejected by policy " + offerRequiredPolicy.getName() + " for client: " + client.getClientId();
throw new ClientPolicyException(Errors.NOT_ALLOWED, errorDetail);
}
}
}
}
}
@@ -0,0 +1,54 @@
package org.keycloak.protocol.oid4vc.clientpolicy;
import java.util.Collections;
import java.util.List;
import org.keycloak.Config;
import org.keycloak.common.Profile;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.KeycloakSessionFactory;
import org.keycloak.provider.ProviderConfigProperty;
import org.keycloak.services.clientpolicy.executor.ClientPolicyExecutorProviderFactory;
public class CredentialClientPolicyExecutorFactory implements ClientPolicyExecutorProviderFactory {
public static final String PROVIDER_ID = "oid4vci-policy-executor";
@Override
public CredentialClientPolicyExecutor create(KeycloakSession session) {
return new CredentialClientPolicyExecutor(session);
}
@Override
public void init(Config.Scope config) {
}
@Override
public void postInit(KeycloakSessionFactory factory) {
}
@Override
public void close() {
}
@Override
public String getId() {
return PROVIDER_ID;
}
@Override
public String getHelpText() {
return "This executor checks client policies related to the credential offer process";
}
@Override
public List<ProviderConfigProperty> getConfigProperties() {
return Collections.emptyList();
}
@Override
public boolean isSupported(Config.Scope config) {
return Profile.isFeatureEnabled(Profile.Feature.CLIENT_POLICIES)
&& Profile.isFeatureEnabled(Profile.Feature.OID4VC_VCI);
}
}
@@ -0,0 +1,26 @@
package org.keycloak.protocol.oid4vc.clientpolicy;
import java.util.Objects;
import java.util.Optional;
import org.keycloak.protocol.oid4vc.model.CredentialScopeRepresentation;
public class PredicateCredentialClientPolicy extends CredentialClientPolicy<Boolean> {
public PredicateCredentialClientPolicy(String name, String key, Boolean exp, Boolean def) {
super(name, key, Boolean.class, exp, def);
}
public Boolean getCurrentValue(CredentialScopeRepresentation credScope) {
Boolean scopeValue = Optional.ofNullable(credScope.getAttribute(getAttrName()))
.map(Boolean::parseBoolean)
.orElse(getDefaultValue());
return scopeValue;
}
public Boolean validate(CredentialScopeRepresentation credScope) {
Boolean scopeValue = credScope.getCredentialPolicyValue(this);
return Objects.equals(scopeValue, getExpectedValue());
}
}
@@ -0,0 +1,82 @@
package org.keycloak.protocol.oid4vc.issuance;
import java.util.List;
import java.util.Optional;
import jakarta.ws.rs.core.Response;
import org.keycloak.OAuthErrorException;
import org.keycloak.models.ClientModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.oid4vci.CredentialScopeModel;
import org.keycloak.protocol.oid4vc.clientpolicy.PredicateCredentialClientPolicy;
import org.keycloak.protocol.oid4vc.issuance.credentialoffer.CredentialOfferState;
import org.keycloak.protocol.oid4vc.issuance.credentialoffer.CredentialOfferStorage;
import org.keycloak.protocol.oid4vc.model.CredentialScopeRepresentation;
import org.keycloak.protocol.oid4vc.model.CredentialsOffer;
import org.keycloak.protocol.oid4vc.model.IssuerState;
import org.keycloak.protocol.oid4vc.utils.CredentialScopeUtils;
import org.keycloak.protocol.oidc.endpoints.AuthorizationEndpointCheckProvider;
import org.keycloak.protocol.oidc.endpoints.AuthorizationEndpointChecker;
import org.keycloak.protocol.oidc.endpoints.AuthorizationEndpointChecker.AuthorizationCheckException;
import org.keycloak.protocol.oidc.endpoints.request.AuthorizationEndpointRequest;
import static org.keycloak.OAuth2Constants.ISSUER_STATE;
import static org.keycloak.protocol.oid4vc.clientpolicy.CredentialClientPolicies.VC_POLICY_CREDENTIAL_OFFER_REQUIRED;
public class OID4VCAuthorizationCheckProvider implements AuthorizationEndpointCheckProvider {
private final KeycloakSession session;
public OID4VCAuthorizationCheckProvider(KeycloakSession session) {
this.session = session;
}
@Override
public void check(AuthorizationEndpointChecker context) throws AuthorizationCheckException {
ClientModel client = context.getClient();
AuthorizationEndpointRequest request = context.getAuthorizationEndpointRequest();
// Get the list of requested credential scopes that are associated with this client
//
List<CredentialScopeModel> credScopes = CredentialScopeUtils.getCredentialScopesForAuthorization(client, request);
// Proceed when there are requested credential scopes
//
if (!credScopes.isEmpty()) {
PredicateCredentialClientPolicy offerRequiredPolicy = VC_POLICY_CREDENTIAL_OFFER_REQUIRED;
// Get the potential offer state derived from issuer_state
//
String issuerStateParam = request.getAdditionalReqParams().get(ISSUER_STATE);
CredentialOfferStorage offerStorage = session.getProvider(CredentialOfferStorage.class);
CredentialOfferState offerState = Optional.ofNullable(issuerStateParam)
.map(IssuerState::fromEncodedString)
.map(IssuerState::getCredentialsOfferId)
.map(offerStorage::getOfferStateById)
.orElse(null);
List<String> offeredConfigurationIds = Optional.ofNullable(offerState)
.map(CredentialOfferState::getCredentialsOffer)
.map(CredentialsOffer::getCredentialConfigurationIds)
.orElse(List.of());
// Check whether each requested credential_configuration_id has actually been offered
//
for (CredentialScopeModel credScope : credScopes) {
String credConfigId = credScope.getCredentialConfigurationId();
boolean requiredByScope = offerRequiredPolicy.validate(new CredentialScopeRepresentation(credScope));
if (requiredByScope && !offeredConfigurationIds.contains(credConfigId)) {
String errorDetail = "Authorization request rejected by policy " + offerRequiredPolicy.getName() + " for scope: " + credScope.getName();
throw new AuthorizationCheckException(Response.Status.BAD_REQUEST, OAuthErrorException.INVALID_REQUEST, errorDetail);
}
}
}
}
@Override
public void close() {
}
}
@@ -0,0 +1,38 @@
package org.keycloak.protocol.oid4vc.issuance;
import org.keycloak.Config;
import org.keycloak.common.Profile;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.KeycloakSessionFactory;
import org.keycloak.protocol.oidc.endpoints.AuthorizationEndpointCheckProvider;
import org.keycloak.protocol.oidc.endpoints.AuthorizationEndpointCheckProviderFactory;
public class OID4VCAuthorizationCheckProviderFactory implements AuthorizationEndpointCheckProviderFactory {
@Override
public AuthorizationEndpointCheckProvider create(KeycloakSession session) {
return new OID4VCAuthorizationCheckProvider(session);
}
@Override
public String getId() {
return "oid4vci-auth-checker";
}
@Override
public void init(Config.Scope config) {
}
@Override
public void postInit(KeycloakSessionFactory factory) {
}
@Override
public void close() {
}
@Override
public boolean isSupported(Config.Scope config) {
return Profile.isFeatureEnabled(Profile.Feature.OID4VC_VCI);
}
}
@@ -50,8 +50,8 @@ import static org.keycloak.OAuth2Constants.ISSUER_STATE;
import static org.keycloak.OID4VCConstants.OPENID_CREDENTIAL;
import static org.keycloak.models.oid4vci.CredentialScopeModel.VC_CONFIGURATION_ID;
import static org.keycloak.protocol.oid4vc.issuance.OID4VCIssuerEndpoint.CREDENTIALS_OFFER_ID_ATTR;
import static org.keycloak.protocol.oid4vc.utils.CredentialScopeModelUtils.findCredentialScopeModelByConfigurationId;
import static org.keycloak.protocol.oid4vc.utils.CredentialScopeModelUtils.findCredentialScopeModelByName;
import static org.keycloak.protocol.oid4vc.utils.CredentialScopeUtils.findCredentialScopeModelByConfigurationId;
import static org.keycloak.protocol.oid4vc.utils.CredentialScopeUtils.findCredentialScopeModelByName;
import static org.keycloak.protocol.oidc.endpoints.AuthorizationEndpoint.LOGIN_SESSION_NOTE_ADDITIONAL_REQ_PARAMS_PREFIX;
public class OID4VCAuthorizationDetailsProcessor implements AuthorizationDetailsProcessor<OID4VCAuthorizationDetail> {
@@ -108,7 +108,7 @@ import org.keycloak.protocol.oid4vc.model.Proofs;
import org.keycloak.protocol.oid4vc.model.SupportedCredentialConfiguration;
import org.keycloak.protocol.oid4vc.model.VerifiableCredential;
import org.keycloak.protocol.oid4vc.utils.ClaimsPathPointer;
import org.keycloak.protocol.oid4vc.utils.CredentialScopeModelUtils;
import org.keycloak.protocol.oid4vc.utils.CredentialScopeUtils;
import org.keycloak.protocol.oid4vc.utils.OID4VCUtil;
import org.keycloak.protocol.oidc.grants.PreAuthorizedCodeGrantType;
import org.keycloak.protocol.oidc.rar.AuthorizationDetailsProcessor;
@@ -944,7 +944,7 @@ public class OID4VCIssuerEndpoint {
// Find credential client scope by requested/authorized credential_configuration_id
//
CredentialScopeModel authorizedCredentialScope = CredentialScopeModelUtils.findCredentialScopeModelByConfigurationId(
CredentialScopeModel authorizedCredentialScope = CredentialScopeUtils.findCredentialScopeModelByConfigurationId(
realmModel, () -> clientModel.getClientScopes(false).values().stream(), authorizedCredentialConfigurationId);
if (authorizedCredentialScope == null) {
@@ -37,12 +37,12 @@ import org.keycloak.protocol.oid4vc.model.IssuerState;
import org.keycloak.protocol.oid4vc.model.OID4VCAuthorizationDetail;
import org.keycloak.protocol.oid4vc.model.PreAuthCodeCtx;
import org.keycloak.protocol.oid4vc.model.PreAuthorizedCodeGrant;
import org.keycloak.protocol.oid4vc.utils.CredentialScopeModelUtils;
import org.keycloak.util.Strings;
import static org.keycloak.OID4VCConstants.OID4VCI_ENABLED_ATTRIBUTE_KEY;
import static org.keycloak.constants.OID4VCIConstants.CREDENTIAL_OFFER_CREATE;
import static org.keycloak.protocol.oid4vc.model.PreAuthorizedCodeGrant.PRE_AUTH_GRANT_TYPE;
import static org.keycloak.protocol.oid4vc.utils.CredentialScopeUtils.findCredentialScopeModelByConfigurationId;
/**
* Default implementation of {@link CredentialOfferProvider}.
@@ -104,7 +104,7 @@ class DefaultCredentialOfferProvider implements CredentialOfferProvider {
CredentialOfferState offerState = new CredentialOfferState(credOffer, targetClientId, targetUserId, expireAt, credOffersId -> {
List<OID4VCAuthorizationDetail> authDetails = new ArrayList<>();
for (String credConfigId : credentialConfigurationIds) {
CredentialScopeModel credScope = CredentialScopeModelUtils.findCredentialScopeModelByConfigurationId(
CredentialScopeModel credScope = findCredentialScopeModelByConfigurationId(
realmModel, () -> session.clientScopes().getClientScopesStream(realmModel), credConfigId);
if (credScope == null) {
throw new CredentialOfferException(Errors.INVALID_REQUEST, "No credential scope model for: " + credConfigId);
@@ -29,7 +29,7 @@ import org.keycloak.protocol.oid4vc.issuance.TimeProvider;
import org.keycloak.protocol.oid4vc.issuance.credentialoffer.CredentialOfferProvider;
import org.keycloak.protocol.oid4vc.issuance.credentialoffer.CredentialOfferState;
import org.keycloak.protocol.oid4vc.issuance.credentialoffer.CredentialOfferStorage;
import org.keycloak.protocol.oid4vc.utils.CredentialScopeModelUtils;
import org.keycloak.protocol.oid4vc.utils.CredentialScopeUtils;
import org.keycloak.sessions.AuthenticationSessionModel;
import org.keycloak.util.JsonSerialization;
@@ -120,7 +120,7 @@ public class VerifiableCredentialOfferAction implements RequiredActionProvider,
}
String credentialConfigId = actionConfig.getCredentialConfigurationId();
CredentialScopeModel credScope = CredentialScopeModelUtils.findCredentialScopeModelByConfigurationId(
CredentialScopeModel credScope = CredentialScopeUtils.findCredentialScopeModelByConfigurationId(
realm, () -> session.clientScopes().getClientScopesStream(realm), credentialConfigId);
if (credScope == null) {
event.detail(Details.CREDENTIAL_TYPE, credentialConfigId);
@@ -147,7 +147,7 @@ public class VerifiableCredentialOfferAction implements RequiredActionProvider,
LoginFormsProvider form = context.form();
try {
String displayName = CredentialScopeModelUtils.getCredentialDisplayName(context.getSession(), context.getUser(), credScope);
String displayName = CredentialScopeUtils.getCredentialDisplayName(context.getSession(), context.getUser(), credScope);
form.setAttribute("credentialOffer", new CredentialOfferBean(context.getSession(), nonce));
form.setAttribute("credentialDisplayName", displayName);
} catch (WriterException | IOException ex) {
@@ -9,6 +9,7 @@ import java.util.Optional;
import org.keycloak.constants.OID4VCIConstants;
import org.keycloak.models.ClientScopeModel;
import org.keycloak.models.utils.ModelToRepresentation;
import org.keycloak.protocol.oid4vc.clientpolicy.CredentialClientPolicy;
import org.keycloak.representations.idm.ClientScopeRepresentation;
import static org.keycloak.models.ClientScopeModel.INCLUDE_IN_TOKEN_SCOPE;
@@ -279,6 +280,15 @@ public class CredentialScopeRepresentation extends ClientScopeRepresentation {
.map(list -> String.join(",")).orElse(null));
}
public <T> T getCredentialPolicyValue(CredentialClientPolicy<T> policy) {
T currentValue = policy.getCurrentValue(this);
return currentValue;
}
public <T> CredentialScopeRepresentation setCredentialPolicyValue(CredentialClientPolicy<T> policy, T value) {
return setAttribute(policy.getAttrName(), String.valueOf(value));
}
public String getAttribute(String key) {
return attributes != null ? attributes.get(key) : null;
}
@@ -91,10 +91,6 @@ public class CredentialsOffer {
return this;
}
public CredentialOfferGrant getGrant(String grantType) {
return grants.get(grantType);
}
public CredentialsOffer addGrant(CredentialOfferGrant grant) {
grants.put(grant.getGrantType(), grant);
return this;
@@ -1,32 +1,33 @@
package org.keycloak.protocol.oid4vc.utils;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.function.Supplier;
import java.util.stream.Stream;
import org.keycloak.models.ClientModel;
import org.keycloak.models.ClientScopeModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel;
import org.keycloak.models.oid4vci.CredentialScopeModel;
import org.keycloak.protocol.oid4vc.model.DisplayObject;
import org.keycloak.protocol.oid4vc.model.OID4VCAuthorizationDetail;
import org.keycloak.protocol.oidc.endpoints.request.AuthorizationEndpointRequest;
import org.keycloak.util.Strings;
import org.keycloak.utils.StringUtil;
import org.jboss.logging.Logger;
import static org.keycloak.OID4VCConstants.OPENID_CREDENTIAL;
import static org.keycloak.constants.OID4VCIConstants.OID4VC_PROTOCOL;
import static org.keycloak.models.oid4vci.CredentialScopeModel.VC_CONFIGURATION_ID;
public class CredentialScopeModelUtils {
public class CredentialScopeUtils {
private static final Logger log = Logger.getLogger(CredentialScopeModelUtils.class);
private static final Logger log = Logger.getLogger(CredentialScopeUtils.class);
// Hide ctor
private CredentialScopeModelUtils() {}
private CredentialScopeUtils() {}
public static CredentialScopeModel findCredentialScopeModelByConfigurationId(RealmModel realmModel, Supplier<Stream<ClientScopeModel>> supplier, String credConfigId) {
if (Strings.isEmpty(credConfigId)) {
@@ -69,23 +70,27 @@ public class CredentialScopeModelUtils {
return !credScopes.isEmpty() ? credScopes.get(0) : null;
}
public static OID4VCAuthorizationDetail buildOID4VCAuthorizationDetail(CredentialScopeModel credScope, String credOffersId) {
/**
* Get the list of credential scopes associated by the given and requested by the given authorization request
*/
public static List<CredentialScopeModel> getCredentialScopesForAuthorization(ClientModel client, AuthorizationEndpointRequest request) {
OID4VCAuthorizationDetail authDetail = new OID4VCAuthorizationDetail();
authDetail.setCredentialsOfferId(credOffersId);
authDetail.setType(OPENID_CREDENTIAL);
List<String> requestScopes = Optional.ofNullable(request.getScope())
.map(it -> it.split("\\s"))
.map(Arrays::asList)
.orElse(List.of());
String credConfigId = Optional.ofNullable(credScope.getCredentialConfigurationId())
.orElseThrow(() -> new IllegalStateException("No " + VC_CONFIGURATION_ID + " in client scope: " + credScope.getName()));
// Get the list of requested credential scopes that are associated with this client
//
Map<String, ClientScopeModel> clientScopes = client.getClientScopes(false);
List<CredentialScopeModel> credScopes = requestScopes.stream()
.filter(clientScopes::containsKey)
.map(clientScopes::get)
.filter(it -> OID4VC_PROTOCOL.equals(it.getProtocol()))
.map(CredentialScopeModel::new)
.toList();
authDetail.setCredentialConfigurationId(credConfigId);
String credIdentifier = credScope.getCredentialIdentifier();
if (!Strings.isEmpty(credIdentifier)) {
authDetail.setCredentialIdentifiers(List.of(credIdentifier));
}
return authDetail;
return credScopes;
}
/**
@@ -437,7 +437,7 @@ public class OIDCLoginProtocol implements LoginProtocol {
checker.checkResponseType();
checker.checkRedirectUri();
} catch (AuthorizationEndpointChecker.AuthorizationCheckException ex) {
ex.throwAsErrorPageException(null);
checker.throwAsErrorPageException(null, ex);
}
setupResponseTypeAndMode(clientData.getResponseType(), clientData.getResponseMode());
@@ -162,7 +162,7 @@ public class AuthorizationEndpoint extends AuthorizationEndpointBase {
checker.checkRedirectUri();
this.redirectUri = checker.getRedirectUri();
} catch (AuthorizationEndpointChecker.AuthorizationCheckException ex) {
ex.throwAsErrorPageException(authenticationSession);
checker.throwAsErrorPageException(authenticationSession, ex);
}
try {
@@ -191,6 +191,7 @@ public class AuthorizationEndpoint extends AuthorizationEndpointBase {
checker.checkValidResource();
checker.checkOIDCParams();
checker.checkPKCEParams();
checker.checkProviderAddOns();
} catch (AuthorizationEndpointChecker.AuthorizationCheckException ex) {
return redirectErrorToClient(parsedResponseMode, ex.getError(), ex.getErrorDescription());
}
@@ -338,7 +339,7 @@ public class AuthorizationEndpoint extends AuthorizationEndpointBase {
authenticationSession.setRedirectUri(redirectUri);
authenticationSession.setAction(AuthenticationSessionModel.Action.AUTHENTICATE.name());
authenticationSession.setClientNote(OIDCLoginProtocol.RESPONSE_TYPE_PARAM, request.getResponseType());
authenticationSession.setClientNote(OIDCLoginProtocol.REDIRECT_URI_PARAM, request.getRedirectUriParam());
authenticationSession.setClientNote(OIDCLoginProtocol.REDIRECT_URI_PARAM, request.getRedirectUri());
authenticationSession.setClientNote(OIDCLoginProtocol.ISSUER, Urls.realmIssuer(session.getContext().getUri().getBaseUri(), realm.getName()));
performActionOnParameters(request, (paramName, paramValue) -> {if (paramValue != null) authenticationSession.setClientNote(paramName, paramValue);});
@@ -0,0 +1,9 @@
package org.keycloak.protocol.oidc.endpoints;
import org.keycloak.protocol.oidc.endpoints.AuthorizationEndpointChecker.AuthorizationCheckException;
import org.keycloak.provider.Provider;
public interface AuthorizationEndpointCheckProvider extends Provider {
void check(AuthorizationEndpointChecker context) throws AuthorizationCheckException;
}
@@ -0,0 +1,7 @@
package org.keycloak.protocol.oidc.endpoints;
import org.keycloak.provider.EnvironmentDependentProviderFactory;
import org.keycloak.provider.ProviderFactory;
public interface AuthorizationEndpointCheckProviderFactory extends ProviderFactory<AuthorizationEndpointCheckProvider>, EnvironmentDependentProviderFactory {
}
@@ -0,0 +1,28 @@
package org.keycloak.protocol.oidc.endpoints;
import org.keycloak.provider.Provider;
import org.keycloak.provider.ProviderFactory;
import org.keycloak.provider.Spi;
public class AuthorizationEndpointCheckSpi implements Spi {
@Override
public boolean isInternal() {
return true;
}
@Override
public String getName() {
return "auth-endpoint-check";
}
@Override
public Class<? extends Provider> getProviderClass() {
return AuthorizationEndpointCheckProvider.class;
}
@Override
public Class<? extends ProviderFactory> getProviderFactoryClass() {
return AuthorizationEndpointCheckProviderFactory.class;
}
}
@@ -18,6 +18,9 @@
package org.keycloak.protocol.oidc.endpoints;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
@@ -33,6 +36,14 @@ import org.keycloak.events.EventBuilder;
import org.keycloak.models.ClientModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.models.oid4vci.CredentialScopeModel;
import org.keycloak.protocol.oid4vc.clientpolicy.PredicateCredentialClientPolicy;
import org.keycloak.protocol.oid4vc.issuance.credentialoffer.CredentialOfferState;
import org.keycloak.protocol.oid4vc.issuance.credentialoffer.CredentialOfferStorage;
import org.keycloak.protocol.oid4vc.model.CredentialScopeRepresentation;
import org.keycloak.protocol.oid4vc.model.CredentialsOffer;
import org.keycloak.protocol.oid4vc.model.IssuerState;
import org.keycloak.protocol.oid4vc.utils.CredentialScopeUtils;
import org.keycloak.protocol.oidc.OIDCAdvancedConfigWrapper;
import org.keycloak.protocol.oidc.OIDCConfigAttributes;
import org.keycloak.protocol.oidc.OIDCLoginProtocol;
@@ -60,6 +71,8 @@ import org.keycloak.utils.StringUtil;
import org.jboss.logging.Logger;
import static org.keycloak.OAuth2Constants.AUTHORIZATION_DETAILS;
import static org.keycloak.OAuth2Constants.ISSUER_STATE;
import static org.keycloak.protocol.oid4vc.clientpolicy.CredentialClientPolicies.VC_POLICY_CREDENTIAL_OFFER_REQUIRED;
/**
* Implements some checks typical for OIDC Authorization Endpoint. Useful to consolidate various checks on single place to avoid duplicated
@@ -115,6 +128,26 @@ public class AuthorizationEndpointChecker {
return this;
}
public AuthorizationEndpointRequest getAuthorizationEndpointRequest() {
return request;
}
public ClientModel getClient() {
return client;
}
public EventBuilder getEventBuilder() {
return event;
}
public RealmModel getRealm() {
return realm;
}
public MultivaluedMap<String, String> getQueryParams() {
return params;
}
public String getRedirectUri() {
return redirectUri;
}
@@ -128,7 +161,7 @@ public class AuthorizationEndpointChecker {
}
public void checkRedirectUri() throws AuthorizationCheckException {
String redirectUriParam = request.getRedirectUriParam();
String redirectUriParam = request.getRedirectUri();
boolean isOIDCRequest = TokenUtil.isOIDCRequest(request.getScope());
event.detail(Details.REDIRECT_URI, redirectUriParam);
@@ -348,6 +381,54 @@ public class AuthorizationEndpointChecker {
}
}
public void checkProviderAddOns() throws AuthorizationCheckException {
Set<AuthorizationEndpointCheckProvider> additionalChecks = session.getAllProviders(AuthorizationEndpointCheckProvider.class);
for (AuthorizationEndpointCheckProvider check : additionalChecks) {
check.check(this);
}
}
public void checkCredentialScope() throws AuthorizationCheckException {
// Get the list of requested credential scopes that are associated with this client
//
List<CredentialScopeModel> credScopes = CredentialScopeUtils.getCredentialScopesForAuthorization(client, request);
// Proceed when there are requested credential scopes
//
if (!credScopes.isEmpty()) {
PredicateCredentialClientPolicy offerRequiredPolicy = VC_POLICY_CREDENTIAL_OFFER_REQUIRED;
// Get the potential offer state derived from issuer_state
//
String issuerStateParam = request.getAdditionalReqParams().get(ISSUER_STATE);
CredentialOfferStorage offerStorage = session.getProvider(CredentialOfferStorage.class);
CredentialOfferState offerState = Optional.ofNullable(issuerStateParam)
.map(IssuerState::fromEncodedString)
.map(IssuerState::getCredentialsOfferId)
.map(offerStorage::getOfferStateById)
.orElse(null);
List<String> offeredConfigurationIds = Optional.ofNullable(offerState)
.map(CredentialOfferState::getCredentialsOffer)
.map(CredentialsOffer::getCredentialConfigurationIds)
.orElse(List.of());
// Check whether each requested credential_configuration_id has actually been offered
//
for (CredentialScopeModel credScope : credScopes) {
String credConfigId = credScope.getCredentialConfigurationId();
boolean requiredByScope = offerRequiredPolicy.validate(new CredentialScopeRepresentation(credScope));
if (requiredByScope && !offeredConfigurationIds.contains(credConfigId)) {
String errorDetail = "Authorization request rejected by policy " + offerRequiredPolicy.getName() + " for scope: " + credScope.getName();
throw new AuthorizationCheckException(Response.Status.BAD_REQUEST, OAuthErrorException.INVALID_REQUEST, errorDetail);
}
}
}
}
// https://tools.ietf.org/html/rfc7636#section-4
private boolean isValidPkceCodeChallenge(String codeChallenge) {
if (codeChallenge.length() < OIDCLoginProtocol.PKCE_CODE_CHALLENGE_MIN_LENGTH) {
@@ -437,9 +518,18 @@ public class AuthorizationEndpointChecker {
}
}
public void throwAsErrorPageException(AuthenticationSessionModel authenticationSession, AuthorizationCheckException ex) {
throw new ErrorPageException(session, authenticationSession, ex.status, ex.error, ex.errorDescription);
}
public void throwAsCorsErrorResponseException(Cors cors, AuthorizationCheckException ex) {
event.detail("detail", ex.errorDescription).error(ex.error);
throw new CorsErrorResponseException(cors, ex.error, ex.errorDescription, ex.status);
}
// Exception propagated to the caller, which will allow caller to send proper error response based on the context (Browser OIDC Authorization Endpoint, PAR etc)
public class AuthorizationCheckException extends Exception {
public static class AuthorizationCheckException extends Exception {
private final Response.Status status;
private final String error;
@@ -451,15 +541,6 @@ public class AuthorizationEndpointChecker {
this.errorDescription = errorDescription;
}
public void throwAsErrorPageException(AuthenticationSessionModel authenticationSession) {
throw new ErrorPageException(session, authenticationSession, status, error, errorDescription);
}
public void throwAsCorsErrorResponseException(Cors cors) {
AuthorizationEndpointChecker.this.event.detail("detail", errorDescription).error(error);
throw new CorsErrorResponseException(cors, error, errorDescription, status);
}
public String getError() {
return error;
}
@@ -31,7 +31,7 @@ public class AuthorizationEndpointRequest {
String invalidRequestMessage;
String clientId;
String redirectUriParam;
String redirectUri;
String responseType;
String responseMode;
String state;
@@ -66,15 +66,15 @@ public class AuthorizationEndpointRequest {
return clientId;
}
public String getRedirectUriParam() {
return redirectUriParam;
public String getRedirectUri() {
return redirectUri;
}
public static AuthorizationEndpointRequest fromClientData(ClientData cData) {
AuthorizationEndpointRequest request = new AuthorizationEndpointRequest();
request.responseType = cData.getResponseType();
request.responseMode = cData.getResponseMode();
request.redirectUriParam = cData.getRedirectUri();
request.redirectUri = cData.getRedirectUri();
return request;
}
@@ -130,7 +130,7 @@ public abstract class AuthzEndpointRequestParser {
}
request.responseMode = replaceIfNotNull(request.responseMode, getAndValidateParameter(OIDCLoginProtocol.RESPONSE_MODE_PARAM));
request.redirectUriParam = replaceIfNotNull(request.redirectUriParam, getAndValidateParameter(OIDCLoginProtocol.REDIRECT_URI_PARAM));
request.redirectUri = replaceIfNotNull(request.redirectUri, getAndValidateParameter(OIDCLoginProtocol.REDIRECT_URI_PARAM));
request.state = replaceIfNotNull(request.state, getAndValidateParameter(OIDCLoginProtocol.STATE_PARAM));
request.scope = replaceIfNotNull(request.scope, getAndValidateParameter(OIDCLoginProtocol.SCOPE_PARAM));
request.resource = replaceIfNotNull(request.resource, getAndValidateParameter(OIDCLoginProtocol.RESOURCE_PARAM));
@@ -49,7 +49,7 @@ import org.keycloak.protocol.oid4vc.model.CredentialsOffer;
import org.keycloak.protocol.oid4vc.model.OID4VCAuthorizationDetail;
import org.keycloak.protocol.oid4vc.model.PreAuthCodeCtx;
import org.keycloak.protocol.oid4vc.model.PreAuthorizedCodeGrant;
import org.keycloak.protocol.oid4vc.utils.CredentialScopeModelUtils;
import org.keycloak.protocol.oid4vc.utils.CredentialScopeUtils;
import org.keycloak.protocol.oidc.OIDCLoginProtocol;
import org.keycloak.protocol.oidc.TokenManager.AccessTokenResponseBuilder;
import org.keycloak.representations.AccessToken;
@@ -207,7 +207,7 @@ public class PreAuthorizedCodeGrantType extends OAuth2GrantTypeBase {
// Add the scope referenced by the credential from specified credential offer to the token scopes
String credConfigId = authDetails.getCredentialConfigurationId();
CredentialScopeModel credScope = CredentialScopeModelUtils.findCredentialScopeModelByConfigurationId(realm,
CredentialScopeModel credScope = CredentialScopeUtils.findCredentialScopeModelByConfigurationId(realm,
() -> session.clientScopes().getClientScopesStream(realm), credConfigId);
if (credScope == null) {
String errorMessage = "Credential client scope was not found for credential_configuration_id: " + credConfigId;
@@ -133,7 +133,7 @@ public class ParEndpoint extends AbstractParEndpoint {
if (ex.getError().equals(OAuthErrorException.UNSUPPORTED_RESPONSE_TYPE)) {
throw throwErrorResponseException(OAuthErrorException.INVALID_REQUEST, "Unsupported response type", Response.Status.BAD_REQUEST);
} else {
ex.throwAsCorsErrorResponseException(cors);
checker.throwAsCorsErrorResponseException(cors, ex);
}
}
@@ -151,7 +151,7 @@ public class ParEndpoint extends AbstractParEndpoint {
checker.checkPKCEParams();
checker.checkParDPoPParams();
} catch (AuthorizationEndpointChecker.AuthorizationCheckException ex) {
ex.throwAsCorsErrorResponseException(cors);
checker.throwAsCorsErrorResponseException(cors, ex);
}
try {
@@ -95,8 +95,8 @@ public class ClientAttributesCondition extends AbstractClientPolicyConditionProv
case JWT_AUTHORIZATION_GRANT:
case SAML_AUTHN_REQUEST:
case SAML_LOGOUT_REQUEST:
if (isAttributesMatched(session.getContext().getClient())) return ClientPolicyVote.YES;
return ClientPolicyVote.NO;
boolean attributesMatched = isAttributesMatched(session.getContext().getClient());
return attributesMatched ? ClientPolicyVote.YES : ClientPolicyVote.NO;
default:
return ClientPolicyVote.ABSTAIN;
}
@@ -56,7 +56,7 @@ public class AuthorizationRequestContext implements ClientPolicyContext, ClientM
return ClientPolicyEvent.AUTHORIZATION_REQUEST;
}
public OIDCResponseType getparsedResponseType() {
public OIDCResponseType getParsedResponseType() {
return parsedResponseType;
}
@@ -99,7 +99,7 @@ public class PKCEEnforcerExecutor implements ClientPolicyExecutorProvider<PKCEEn
break;
case AUTHORIZATION_REQUEST:
AuthorizationRequestContext authorizationRequestContext = (AuthorizationRequestContext)context;
executeOnAuthorizationRequest(authorizationRequestContext.getparsedResponseType(),
executeOnAuthorizationRequest(authorizationRequestContext.getParsedResponseType(),
authorizationRequestContext.getAuthorizationEndpointRequest(),
authorizationRequestContext.getRedirectUri());
return;
@@ -91,7 +91,7 @@ public class RejectImplicitGrantExecutor implements ClientPolicyExecutorProvider
break;
case AUTHORIZATION_REQUEST:
AuthorizationRequestContext authorizationRequestContext = (AuthorizationRequestContext)context;
executeOnAuthorizationRequest(authorizationRequestContext.getparsedResponseType(),
executeOnAuthorizationRequest(authorizationRequestContext.getParsedResponseType(),
authorizationRequestContext.getAuthorizationEndpointRequest(),
authorizationRequestContext.getRedirectUri());
return;
@@ -124,7 +124,7 @@ public class SecureParContentsExecutor implements ClientPolicyExecutorProvider<C
AuthorizationEndpoint.performActionOnParameters(request, (paramName, paramValue) -> {if (paramValue != null) parRetrievedRequest.add(paramName);});
if (request.getClientId() != null) parRetrievedRequest.add(OIDCLoginProtocol.CLIENT_ID_PARAM);
if (request.getResponseType() != null) parRetrievedRequest.add(OIDCLoginProtocol.RESPONSE_TYPE_PARAM);
if (request.getRedirectUriParam() != null) parRetrievedRequest.add(OIDCLoginProtocol.REDIRECT_URI_PARAM);
if (request.getRedirectUri() != null) parRetrievedRequest.add(OIDCLoginProtocol.REDIRECT_URI_PARAM);
if (request.getMaxAge() != null) parRetrievedRequest.add(OIDCLoginProtocol.MAX_AGE_PARAM);
if (request.getUiLocales() != null) parRetrievedRequest.add(OAuth2Constants.UI_LOCALES_PARAM);
for (String additionalParam : request.getAdditionalReqParams().keySet()) {
@@ -101,7 +101,7 @@ public class SecureResponseTypeExecutor implements ClientPolicyExecutorProvider<
break;
case AUTHORIZATION_REQUEST:
AuthorizationRequestContext authorizationRequestContext = (AuthorizationRequestContext)context;
executeOnAuthorizationRequest(authorizationRequestContext.getparsedResponseType(),
executeOnAuthorizationRequest(authorizationRequestContext.getParsedResponseType(),
authorizationRequestContext.getAuthorizationEndpointRequest(),
authorizationRequestContext.getRedirectUri());
break;
@@ -52,7 +52,7 @@ public class SecureSessionEnforceExecutor implements ClientPolicyExecutorProvide
switch (context.getEvent()) {
case AUTHORIZATION_REQUEST:
AuthorizationRequestContext authorizationRequestContext = (AuthorizationRequestContext)context;
executeOnAuthorizationRequest(authorizationRequestContext.getparsedResponseType(),
executeOnAuthorizationRequest(authorizationRequestContext.getParsedResponseType(),
authorizationRequestContext.getAuthorizationEndpointRequest(),
authorizationRequestContext.getRedirectUri());
return;
@@ -0,0 +1,20 @@
#
# 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.
#
#
org.keycloak.protocol.oid4vc.issuance.OID4VCAuthorizationCheckProviderFactory
@@ -38,6 +38,7 @@ org.keycloak.protocol.oid4vc.issuance.credentialoffer.preauth.PreAuthCodeHandler
org.keycloak.protocol.oid4vc.issuance.keybinding.ProofValidatorSpi
org.keycloak.protocol.oid4vc.issuance.signing.CredentialSignerSpi
org.keycloak.protocol.oid4vc.issuance.keybinding.CNonceHandlerSpi
org.keycloak.protocol.oidc.endpoints.AuthorizationEndpointCheckSpi
org.keycloak.protocol.oidc.rar.AuthorizationDetailsProcessorSpi
org.keycloak.protocol.oauth2.cimd.provider.ClientIdMetadataDocumentProviderSpi
org.keycloak.protocol.oidc.token.TokenInterceptorSpi
@@ -14,6 +14,7 @@ org.keycloak.services.clientpolicy.executor.SecureClientUrisPatternExecutorFacto
org.keycloak.protocol.oidc.grants.ciba.clientpolicy.executor.SecureCibaSessionEnforceExecutorFactory
org.keycloak.protocol.oidc.grants.ciba.clientpolicy.executor.SecureCibaSignedAuthenticationRequestExecutorFactory
org.keycloak.protocol.oidc.grants.ciba.clientpolicy.executor.SecureCibaAuthenticationRequestSigningAlgorithmExecutorFactory
org.keycloak.protocol.oid4vc.clientpolicy.CredentialClientPolicyExecutorFactory
org.keycloak.services.clientpolicy.executor.SecureLogoutExecutorFactory
org.keycloak.services.clientpolicy.executor.RejectResourceOwnerPasswordCredentialsGrantExecutorFactory
org.keycloak.services.clientpolicy.executor.ClientSecretRotationExecutorFactory
@@ -251,7 +251,7 @@ public class OID4VCActionTest extends OID4VCIssuerTestBase {
//
AccessTokenResponse tokenResponse = wallet.accessTokenRequest(ctx, authCode).send();
assertNull(tokenResponse.getAccessToken());
assertEquals("Credential offer target client 'oid4vci-test-pub' different from login client 'oid4vci-test'", tokenResponse.getErrorDescription());
assertEquals("Credential offer target client 'oid4vci-client-pub' different from login client 'oid4vci-client'", tokenResponse.getErrorDescription());
}
@@ -46,6 +46,7 @@ import org.keycloak.models.KeyManager;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.UserModel;
import org.keycloak.models.utils.KeycloakModelUtils;
import org.keycloak.protocol.oid4vc.clientpolicy.CredentialClientPolicyExecutorFactory;
import org.keycloak.protocol.oid4vc.issuance.OID4VCAuthorizationDetailsParser;
import org.keycloak.protocol.oid4vc.issuance.TimeProvider;
import org.keycloak.protocol.oid4vc.issuance.mappers.OID4VCGeneratedIdMapper;
@@ -55,6 +56,10 @@ import org.keycloak.protocol.oid4vc.model.CredentialSubject;
import org.keycloak.protocol.oid4vc.model.DisplayObject;
import org.keycloak.protocol.oid4vc.model.OID4VCAuthorizationDetail;
import org.keycloak.protocol.oid4vc.model.VerifiableCredential;
import org.keycloak.representations.idm.ClientPolicyConditionRepresentation;
import org.keycloak.representations.idm.ClientPolicyExecutorRepresentation;
import org.keycloak.representations.idm.ClientPolicyRepresentation;
import org.keycloak.representations.idm.ClientProfileRepresentation;
import org.keycloak.representations.idm.ClientRepresentation;
import org.keycloak.representations.idm.ClientScopeRepresentation;
import org.keycloak.representations.idm.ComponentRepresentation;
@@ -95,6 +100,8 @@ import org.keycloak.testsuite.util.oauth.AuthorizationEndpointResponse;
import org.keycloak.util.AuthorizationDetailsParser;
import org.keycloak.util.JsonSerialization;
import com.fasterxml.jackson.databind.node.JsonNodeFactory;
import com.fasterxml.jackson.databind.node.ObjectNode;
import org.jboss.logging.Logger;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
@@ -109,6 +116,7 @@ import static org.keycloak.models.oid4vci.CredentialScopeModel.VC_BINDING_REQUIR
import static org.keycloak.models.oid4vci.CredentialScopeModel.VC_BINDING_REQUIRED_PROOF_TYPES;
import static org.keycloak.models.oid4vci.CredentialScopeModel.VC_CRYPTOGRAPHIC_BINDING_METHODS;
import static org.keycloak.models.oid4vci.CredentialScopeModel.VC_FORMAT_DEFAULT;
import static org.keycloak.protocol.oid4vc.clientpolicy.CredentialClientPolicies.VC_POLICY_CREDENTIAL_OFFER_REQUIRED;
/**
* Abstract base class for OID4VCI Testing
@@ -119,9 +127,10 @@ public abstract class OID4VCIssuerTestBase {
protected final Logger log = Logger.getLogger(getClass());
public static final String OID4VCI_CLIENT_ID = "oid4vci-test";
public static final String OID4VCI_PUBLIC_CLIENT_ID = "oid4vci-test-pub";
public static final URI ISSUER_DID = URI.create("did:web:test.org");
public static final String OID4VCI_CLIENT_ID = "oid4vci-client";
public static final String OID4VCI_PUBLIC_CLIENT_ID = "oid4vci-client-pub";
public static final String TEST_ISSUER_DID = "did:web:test.org";
public static final String TEST_CREDENTIAL_MAPPERS_FILE = "/oid4vc/test-credential-mappers.json";
public static final String TEST_USER = "john";
public static final String TEST_PASSWORD = "password";
@@ -139,7 +148,6 @@ public abstract class OID4VCIssuerTestBase {
public static final String minimalJwtTypeCredentialConfigurationIdName = "vc-with-minimal-config-id";
public static final String CONTEXT_URL = "https://www.w3.org/2018/credentials/v1";
protected static final URI TEST_DID = ISSUER_DID;
protected static final List<String> TEST_TYPES = List.of("VerifiableCredential");
protected static final Instant TEST_EXPIRATION_DATE = Instant.ofEpochMilli(Time.currentTimeMillis())
.plus(365, ChronoUnit.DAYS)
@@ -149,7 +157,7 @@ public abstract class OID4VCIssuerTestBase {
@InjectRealm(config = VCTestRealmConfig.class)
protected ManagedRealm testRealm;
@InjectClient(ref = "oid4vci-client", config = PrivateOID4VCIClient.class)
@InjectClient(ref = OID4VCI_CLIENT_ID, config = ConfidentialOID4VCIClient.class)
protected ManagedClient managedClient;
@InjectClient(ref = OID4VCI_PUBLIC_CLIENT_ID, config = PublicOID4VCIClient.class)
@@ -188,6 +196,7 @@ public abstract class OID4VCIssuerTestBase {
@TestSetup
public void configureTestRealm() {
RealmResource realmResource = testRealm.admin();
UPConfig upConfig = realmResource.users().userProfile().getConfiguration();
upConfig.setUnmanagedAttributePolicy(UPConfig.UnmanagedAttributePolicy.ADMIN_EDIT);
@@ -261,7 +270,7 @@ public abstract class OID4VCIssuerTestBase {
testCredential.setId(URI.create(String.format("uri:uuid:%s", UUID.randomUUID())));
testCredential.setContext(List.of(CONTEXT_URL));
testCredential.setType(TEST_TYPES);
testCredential.setIssuer(TEST_DID);
testCredential.setIssuer(TEST_ISSUER_DID);
testCredential.setExpirationDate(TEST_EXPIRATION_DATE);
if (claims.containsKey("issuanceDate")) {
testCredential.setIssuanceDate((Instant) claims.get("issuanceDate"));
@@ -487,7 +496,7 @@ public abstract class OID4VCIssuerTestBase {
public static class VCTestServerWithPreAuthCodeEnabled implements KeycloakServerConfig {
@Override
public KeycloakServerConfigBuilder configure(KeycloakServerConfigBuilder config) {
return config.features(Profile.Feature.OID4VC_VCI, Profile.Feature.OID4VC_VCI_PREAUTH_CODE, Profile.Feature.OID4VC_VCI_REST_CREDENTIAL_OFFER);
return config.features(Profile.Feature.OID4VC_VCI, Profile.Feature.OID4VC_VCI_REST_CREDENTIAL_OFFER, Profile.Feature.OID4VC_VCI_PREAUTH_CODE);
}
}
@@ -536,7 +545,7 @@ public abstract class OID4VCIssuerTestBase {
CredentialScopeRepresentation jwtVcScope = createCredentialScope(
jwtTypeCredentialScopeName,
ISSUER_DID.toString(),
TEST_ISSUER_DID,
jwtTypeCredentialConfigurationIdName,
jwtTypeCredentialScopeName,
null,
@@ -565,9 +574,50 @@ public abstract class OID4VCIssuerTestBase {
realm.users(getUserRepresentation("John Doe", Map.of("did", "did:key:1234"), List.of(), Collections.emptyMap()));
realm.users(getUserRepresentation("Alice Wonderland", Map.of("did", "did:key:5678"), List.of(), Map.of()));
// Add Client Policies
//
ClientProfileRepresentation profile = createClientPolicyProfile();
realm.clientPolicy(createClientPolicyOfferRequired(profile));
realm.clientProfile(profile);
return realm;
}
private ClientProfileRepresentation createClientPolicyProfile() {
ClientProfileRepresentation profile = new ClientProfileRepresentation();
profile.setName("oid4vci-client-profile");
ClientPolicyExecutorRepresentation executor = new ClientPolicyExecutorRepresentation();
executor.setExecutorProviderId(CredentialClientPolicyExecutorFactory.PROVIDER_ID);
executor.setConfiguration(JsonNodeFactory.instance.objectNode());
profile.setExecutors(List.of(executor));
return profile;
}
private ClientPolicyRepresentation createClientPolicyOfferRequired(ClientProfileRepresentation profile) {
ClientPolicyRepresentation policy = new ClientPolicyRepresentation();
policy.setName(VC_POLICY_CREDENTIAL_OFFER_REQUIRED.getName());
policy.setDescription("Client policy to determine whether a credential offers is required");
policy.setEnabled(false);
ClientPolicyConditionRepresentation condition = new ClientPolicyConditionRepresentation();
condition.setConditionProviderId("client-attributes");
ObjectNode config = JsonNodeFactory.instance.objectNode();
config.put("attributes", JsonSerialization.valueAsString(List.of(Map.of(
"key", OID4VCI_ENABLED_ATTRIBUTE_KEY,
"value", String.valueOf(true)
))));
condition.setConfiguration(config);
policy.setConditions(List.of(condition));
policy.setProfiles(List.of(profile.getName()));
return policy;
}
private CredentialScopeRepresentation createCredentialScope(
String scopeName,
String issuerDid,
@@ -666,7 +716,7 @@ public abstract class OID4VCIssuerTestBase {
}
}
public static class PrivateOID4VCIClient implements ClientConfig {
public static class ConfidentialOID4VCIClient implements ClientConfig {
@Override
public ClientBuilder configure(ClientBuilder client) {
@@ -1436,7 +1436,7 @@ public class OID4VCJWTIssuerEndpointTest extends OID4VCIssuerEndpointTest {
public void testCredentialRequestWithOptionalClientScope() {
ClientScopeRepresentation optionalScope = createOptionalClientScope(
"optional-jwt-credential",
ISSUER_DID.toString(),
TEST_ISSUER_DID,
"optional-jwt-credential-config-id",
null, null,
VCFormat.JWT_VC,
@@ -1484,7 +1484,7 @@ public class OID4VCJWTIssuerEndpointTest extends OID4VCIssuerEndpointTest {
public void testCannotAssignOid4vciScopeAsDefaultToClient() {
ClientScopeRepresentation oid4vciScope = createOptionalClientScope(
"test-oid4vci-scope",
ISSUER_DID.toString(),
TEST_ISSUER_DID,
"test-oid4vci-config-id",
null, null,
VCFormat.JWT_VC,
@@ -1509,7 +1509,7 @@ public class OID4VCJWTIssuerEndpointTest extends OID4VCIssuerEndpointTest {
public void testCannotAssignOid4vciScopeAsDefaultToRealm() {
ClientScopeRepresentation oid4vciScope = createOptionalClientScope(
"test-oid4vci-realm-scope",
ISSUER_DID.toString(),
TEST_ISSUER_DID,
"test-oid4vci-realm-config-id",
null, null,
VCFormat.JWT_VC,
@@ -1532,7 +1532,7 @@ public class OID4VCJWTIssuerEndpointTest extends OID4VCIssuerEndpointTest {
public void testCannotAssignOid4vciScopeWhenRealmDisabled() {
ClientScopeRepresentation oid4vciScope = createOptionalClientScope(
"test-oid4vci-disabled-scope",
ISSUER_DID.toString(),
TEST_ISSUER_DID,
"test-oid4vci-disabled-config-id",
null, null,
VCFormat.JWT_VC,
@@ -2,29 +2,49 @@ package org.keycloak.tests.oid4vc;
import java.net.URI;
import java.util.List;
import java.util.function.Function;
import org.keycloak.TokenVerifier;
import org.keycloak.admin.client.resource.ClientPoliciesPoliciesResource;
import org.keycloak.admin.client.resource.RealmResource;
import org.keycloak.protocol.oid4vc.clientpolicy.PredicateCredentialClientPolicy;
import org.keycloak.protocol.oid4vc.model.CredentialIssuer;
import org.keycloak.protocol.oid4vc.model.CredentialResponse;
import org.keycloak.protocol.oid4vc.model.CredentialsOffer;
import org.keycloak.protocol.oid4vc.model.OID4VCAuthorizationDetail;
import org.keycloak.protocol.oid4vc.model.VerifiableCredential;
import org.keycloak.representations.JsonWebToken;
import org.keycloak.representations.idm.ClientPoliciesRepresentation;
import org.keycloak.representations.idm.ClientPolicyRepresentation;
import org.keycloak.testframework.annotations.KeycloakIntegrationTest;
import org.keycloak.tests.oid4vc.OID4VCIssuerTestBase.VCTestServerConfig;
import org.keycloak.testframework.annotations.TestSetup;
import org.keycloak.tests.oid4vc.OID4VCBasicWallet.AuthorizationEndpointRequest;
import org.keycloak.testsuite.util.oauth.AccessTokenResponse;
import org.keycloak.testsuite.util.oauth.AuthorizationEndpointResponse;
import org.keycloak.util.JsonSerialization;
import org.junit.jupiter.api.Test;
import org.opentest4j.AssertionFailedError;
import static org.keycloak.OID4VCConstants.OPENID_CREDENTIAL;
import static org.keycloak.protocol.oid4vc.clientpolicy.CredentialClientPolicies.VC_POLICY_CREDENTIAL_OFFER_REQUIRED;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
@KeycloakIntegrationTest(config = VCTestServerConfig.class)
@KeycloakIntegrationTest(config = OID4VCIssuerTestBase.VCTestServerConfigRestCredentialOffer.class)
public class OID4VCredentialByScopeTest extends OID4VCIssuerTestBase {
@TestSetup
public void configure() {
RealmResource realmResource = testRealm.admin();
realmResource.clientPoliciesPoliciesResource().getPolicies().getPolicies().stream()
.filter(cpr -> "oid4vci-offer-required".equals(cpr.getName()))
.findFirst().orElseThrow(() -> new AssertionFailedError("Client policy not installed"));
}
@Test
public void testNoOffer_Scope() throws Exception {
@@ -77,6 +97,110 @@ public class OID4VCredentialByScopeTest extends OID4VCIssuerTestBase {
verifyCredentialResponse(ctx, ctx.getHolder(), credResponse);
}
@Test
public void testNoOffer_Scope_RequireOfferPolicy() {
var ctx = new OID4VCTestContext(client, jwtTypeCredentialScope);
PredicateCredentialClientPolicy offerRequiredPolicy = VC_POLICY_CREDENTIAL_OFFER_REQUIRED;
Function<List<Boolean>, Boolean> runner = (params) -> {
Boolean createOffer = params.get(0);
Boolean policyEnabled = params.get(1);
Boolean scopeEnabled = params.get(2);
// Set client policy 'oid4vci-offer-required'
//
ClientPoliciesPoliciesResource clientPoliciesResource = testRealm.admin().clientPoliciesPoliciesResource();
ClientPoliciesRepresentation policies = clientPoliciesResource.getPolicies();
ClientPolicyRepresentation clientPolicy = policies.getPolicies().stream()
.filter(cp -> cp.getName().equals(offerRequiredPolicy.getName()))
.findFirst().orElseThrow();
Boolean wasPolicyEnabled = clientPolicy.isEnabled();
clientPolicy.setEnabled(policyEnabled);
clientPoliciesResource.updatePolicies(policies);
// Set client scope attribute 'vc.policy.offer.required'
//
var credScope = ctx.getCredentialScope();
Boolean wasScopeEnabled = credScope.getCredentialPolicyValue(offerRequiredPolicy);
credScope.setCredentialPolicyValue(offerRequiredPolicy, scopeEnabled);
updateCredentialScope(credScope);
try {
// Build AuthorizationRequest
//
AuthorizationEndpointRequest authRequest = wallet
.authorizationRequest()
.scope(ctx.getScope());
if (createOffer) {
CredentialsOffer credOffer = wallet.createCredentialOffer(ctx, req -> {
req.credentialConfigurationId(ctx.getCredentialConfigurationId());
req.preAuthorized(false);
});
assertNotNull(credOffer, "No credOffer");
String issuerState = credOffer.getIssuerState();
assertNotNull(issuerState, "No issuerState");
authRequest.issuerState(issuerState);
}
// Send AuthorizationRequest
//
if (authRequest.openLoginForm()) {
authRequest.send(ctx.getHolder(), TEST_PASSWORD);
} else {
AuthorizationEndpointResponse authResponse = authRequest.parseLoginResponse();
String errorDescription = authResponse.getErrorDescription();
assertTrue(errorDescription.contains("rejected by policy oid4vci-offer-required"), errorDescription);
return false;
}
AuthorizationEndpointResponse authResponse = authRequest.parseLoginResponse();
String authCode = authResponse.getCode();
assertNotNull(authCode, "No authCode");
// Build and send AccessTokenRequest
//
AccessTokenResponse tokenResponse = wallet.accessTokenRequest(ctx, authCode).send();
String accessToken = wallet.validateHolderAccessToken(ctx, tokenResponse);
assertNotNull(accessToken, "No accessToken");
String authorizedIdentifier = ctx.getAuthorizedCredentialIdentifier();
assertNotNull(authorizedIdentifier,"No authorized credential identifier");
CredentialResponse credentialResponse = wallet.credentialRequest(ctx, accessToken)
.credentialIdentifier(authorizedIdentifier)
.proofs(wallet.generateJwtProof(ctx))
.send()
.getCredentialResponse();
assertFalse(credentialResponse.getCredentials().isEmpty(), "Credentials expected");
return true;
} finally {
clientPolicy.setEnabled(wasPolicyEnabled);
clientPoliciesResource.updatePolicies(policies);
credScope.setCredentialPolicyValue(offerRequiredPolicy, wasScopeEnabled);
updateCredentialScope(credScope);
wallet.logout(ctx.getHolder());
}
};
// Verification matrix (createOffer, policyEnabled, scopeEnabled)
//
assertTrue(runner.apply(List.of(false, false, false)), "Offer not required");
assertFalse(runner.apply(List.of(false, false, true)), "Offer required");
assertFalse(runner.apply(List.of(false, true, false)), "Offer required");
assertFalse(runner.apply(List.of(false, true, true)), "Offer required");
assertTrue(runner.apply(List.of(true, false, false)), "Offer not required");
assertTrue(runner.apply(List.of(true, false, true)), "Offer required");
assertTrue(runner.apply(List.of(true, true, false)), "Offer required");
assertTrue(runner.apply(List.of(true, true, true)), "Offer required");
}
// Private ---------------------------------------------------------------------------------------------------------
private void verifyCredentialResponse(OID4VCTestContext ctx, String expUser, CredentialResponse credResponse) throws Exception {
@@ -95,9 +95,8 @@ public class SdJwtCredentialBuilderTest extends CredentialBuilderTest {
public void testSignSDJwtCredential(Map<String, Object> claims, int decoys, List<String> visibleClaims)
throws VerificationException {
String issuerDid = TEST_DID.toString();
CredentialBuildConfig credentialBuildConfig = new CredentialBuildConfig()
.setCredentialIssuer(issuerDid)
.setCredentialIssuer(TEST_ISSUER_DID)
.setCredentialType("https://credentials.example.com/test-credential")
.setTokenJwsType("example+sd-jwt")
.setHashAlgorithm(OID4VCConstants.SD_HASH_DEFAULT_ALGORITHM)
@@ -113,7 +112,7 @@ public class SdJwtCredentialBuilderTest extends CredentialBuilderTest {
IssuerSignedJWT jwt = sdJwt.getIssuerSignedJWT();
assertEquals(issuerDid,
assertEquals(TEST_ISSUER_DID,
jwt.getPayload().get(CLAIM_NAME_ISSUER).asText(),
"The issuer should be set in the token.");
@@ -181,7 +181,7 @@ public class JwtCredentialSignerTest extends OID4VCIssuerTestBase {
public static void testSignJwtCredential(
KeycloakSession session, String signingKeyId, String algorithm, Map<String, Object> claims) {
CredentialBuildConfig credentialBuildConfig = new CredentialBuildConfig()
.setCredentialIssuer(TEST_DID.toString())
.setCredentialIssuer(TEST_ISSUER_DID)
.setTokenJwsType("JWT")
.setSigningKeyId(signingKeyId)
.setSigningAlgorithm(algorithm);
@@ -237,14 +237,14 @@ public class JwtCredentialSignerTest extends OID4VCIssuerTestBase {
// if not specific date is set, check against "currentTime"
assertEquals(TEST_ISSUANCE_DATE.getEpochSecond(), theToken.getNbf().longValue(), "VC Data Model v1.1 specifies that “issuanceDate” property MUST be represented as an nbf JWT claim, and not iat JWT claim.");
}
assertEquals(TEST_DID.toString(), theToken.getIssuer(), "The issuer should be set in the token.");
assertEquals(TEST_ISSUER_DID, theToken.getIssuer(), "The issuer should be set in the token.");
assertEquals(testCredential.getId().toString(), theToken.getId(), "The credential ID should be set as the token ID.");
Optional.ofNullable(testCredential.getCredentialSubject().getClaims().get("id")).ifPresent(id -> assertEquals(id.toString(), theToken.getSubject(), "If the credentials subject id is set, it should be set as the token subject."));
assertNotNull(theToken.getOtherClaims().get("vc"), "The credentials should be included at the vc-claim.");
VerifiableCredential credential = JsonSerialization.mapper.convertValue(theToken.getOtherClaims().get("vc"), VerifiableCredential.class);
assertEquals(TEST_TYPES, credential.getType(), "The types should be included");
assertEquals(TEST_DID, credential.getIssuer(), "The issuer should be included");
assertEquals(TEST_ISSUER_DID, String.valueOf(credential.getIssuer()), "The issuer should be included");
assertEquals(TEST_EXPIRATION_DATE, credential.getExpirationDate(), "The expiration date should be included");
if (claims.containsKey("issuanceDate")) {
assertEquals(claims.get("issuanceDate"), credential.getIssuanceDate(), "The issuance date should be included");
@@ -220,7 +220,7 @@ public class SdJwtCredentialSignerTest extends OID4VCIssuerTestBase {
runOnServer.run(session -> {
String signingKeyId = getKeyIdFromSession(session);
CredentialBuildConfig credentialBuildConfig = new CredentialBuildConfig()
.setCredentialIssuer(TEST_DID.toString())
.setCredentialIssuer(TEST_ISSUER_DID)
.setCredentialType("https://credentials.example.com/test-credential")
.setTokenJwsType("example+sd-jwt")
.setHashAlgorithm(OID4VCConstants.SD_HASH_DEFAULT_ALGORITHM)
@@ -278,7 +278,7 @@ public class SdJwtCredentialSignerTest extends OID4VCIssuerTestBase {
public static void testSignSDJwtCredential(KeycloakSession session, String signingKeyId, String overrideKeyId, String
algorithm, Map<String, Object> claims, int decoys, List<String> visibleClaims) {
CredentialBuildConfig credentialBuildConfig = new CredentialBuildConfig()
.setCredentialIssuer(TEST_DID.toString())
.setCredentialIssuer(TEST_ISSUER_DID)
.setCredentialType("https://credentials.example.com/test-credential")
.setTokenJwsType("example+sd-jwt")
.setHashAlgorithm(OID4VCConstants.SD_HASH_DEFAULT_ALGORITHM)
@@ -337,7 +337,7 @@ public class SdJwtCredentialSignerTest extends OID4VCIssuerTestBase {
try {
JsonWebToken theToken = verifier.getToken();
assertEquals(TEST_DID.toString(), theToken.getIssuer(), "The issuer should be set in the token.");
assertEquals(TEST_ISSUER_DID, theToken.getIssuer(), "The issuer should be set in the token.");
assertEquals("https://credentials.example.com/test-credential", theToken.getOtherClaims().get("vct"), "The type should be included");
List<String> sds = (List<String>) theToken.getOtherClaims().get(CLAIM_NAME_SD);
if (sds != null && !sds.isEmpty()) {
@@ -13,7 +13,7 @@ import org.apache.http.client.methods.CloseableHttpResponse;
public class CredentialOfferUriRequest extends AbstractHttpGetRequest<CredentialOfferUriRequest, CredentialOfferUriResponse> {
private final String credConfigId;
private String credConfigId;
private Boolean preAuthorized;
private String targetUser;
private Integer expireAt;
@@ -25,6 +25,11 @@ public class CredentialOfferUriRequest extends AbstractHttpGetRequest<Credential
this.credConfigId = credConfigId;
}
public CredentialOfferUriRequest credentialConfigurationId(String credConfigId) {
this.credConfigId = credConfigId;
return this;
}
public CredentialOfferUriRequest preAuthorized(Boolean preAuthorized) {
this.preAuthorized = preAuthorized;
return this;
@@ -355,7 +355,14 @@ public abstract class AbstractClientPoliciesTest extends AbstractKeycloakTest {
ClientProfilesRepresentation actualProfilesRep = getProfilesWithGlobals();
// same profiles
assertExpectedProfiles(actualProfilesRep, Arrays.asList(FAPI1_BASELINE_PROFILE_NAME, FAPI1_ADVANCED_PROFILE_NAME, FAPI_CIBA_PROFILE_NAME, FAPI2_SECURITY_PROFILE_NAME, FAPI2_MESSAGE_SIGNING_PROFILE_NAME, OAUTH2_1_CONFIDENTIAL_CLIENT_PROFILE_NAME, OAUTH2_1_PUBLIC_CLIENT_PROFILE_NAME, SAML_SECURITY_PROFILE_NAME, FAPI2_DPOP_SECURITY_PROFILE_NAME, FAPI2_DPOP_MESSAGE_SIGNING_PROFILE_NAME), Arrays.asList("ordinal-test-profile", "lack-of-builtin-field-test-profile"));
assertExpectedProfiles(actualProfilesRep,
Arrays.asList(FAPI1_BASELINE_PROFILE_NAME, FAPI1_ADVANCED_PROFILE_NAME, FAPI_CIBA_PROFILE_NAME,
FAPI2_SECURITY_PROFILE_NAME, FAPI2_MESSAGE_SIGNING_PROFILE_NAME,
OAUTH2_1_CONFIDENTIAL_CLIENT_PROFILE_NAME, OAUTH2_1_PUBLIC_CLIENT_PROFILE_NAME,
SAML_SECURITY_PROFILE_NAME,
FAPI2_DPOP_SECURITY_PROFILE_NAME, FAPI2_DPOP_MESSAGE_SIGNING_PROFILE_NAME),
Arrays.asList("ordinal-test-profile", "lack-of-builtin-field-test-profile")
);
// each profile - fapi-1-baseline
ClientProfileRepresentation actualProfileRep = getProfileRepresentation(actualProfilesRep, FAPI1_BASELINE_PROFILE_NAME, true);
@@ -86,7 +86,14 @@ public class ClientPoliciesLoadUpdateTest extends AbstractClientPoliciesTest {
ClientProfilesRepresentation actualProfilesRep = getProfilesWithGlobals();
// same profiles
assertExpectedProfiles(actualProfilesRep, Arrays.asList(FAPI1_BASELINE_PROFILE_NAME, FAPI1_ADVANCED_PROFILE_NAME, FAPI_CIBA_PROFILE_NAME, FAPI2_SECURITY_PROFILE_NAME, FAPI2_MESSAGE_SIGNING_PROFILE_NAME, OAUTH2_1_CONFIDENTIAL_CLIENT_PROFILE_NAME, OAUTH2_1_PUBLIC_CLIENT_PROFILE_NAME, SAML_SECURITY_PROFILE_NAME, FAPI2_DPOP_SECURITY_PROFILE_NAME, FAPI2_DPOP_MESSAGE_SIGNING_PROFILE_NAME), Collections.emptyList());
assertExpectedProfiles(actualProfilesRep,
Arrays.asList(FAPI1_BASELINE_PROFILE_NAME, FAPI1_ADVANCED_PROFILE_NAME, FAPI_CIBA_PROFILE_NAME,
FAPI2_SECURITY_PROFILE_NAME, FAPI2_MESSAGE_SIGNING_PROFILE_NAME,
OAUTH2_1_CONFIDENTIAL_CLIENT_PROFILE_NAME, OAUTH2_1_PUBLIC_CLIENT_PROFILE_NAME,
SAML_SECURITY_PROFILE_NAME,
FAPI2_DPOP_SECURITY_PROFILE_NAME, FAPI2_DPOP_MESSAGE_SIGNING_PROFILE_NAME),
Collections.emptyList());
// each profile - fapi-1-baseline
ClientProfileRepresentation actualProfileRep = getProfileRepresentation(actualProfilesRep, FAPI1_BASELINE_PROFILE_NAME, true);