diff --git a/docs/documentation/server_admin/topics/clients/client-policies.adoc b/docs/documentation/server_admin/topics/clients/client-policies.adoc index c40923e340..ee93466386 100644 --- a/docs/documentation/server_admin/topics/clients/client-policies.adoc +++ b/docs/documentation/server_admin/topics/clients/client-policies.adoc @@ -87,6 +87,10 @@ for marking that particular client policy for particular clients. Client Domain Name, Host or IP Address:: Applied for specific domain names of client. Or for the cases when the administrator registers/updates client from particular Host or IP Address. +Client Attribute:: + Applies to clients with the client attribute of the specified name and value. If you specify multiple client attributes, they will be evaluated using AND conditions. + If you want to evaluate using OR conditions, set this condition multiple times. + Any Client:: This condition always evaluates to true. It can be used for example to ensure that all clients in the particular realm are FAPI compliant. diff --git a/js/apps/admin-ui/maven-resources/theme/keycloak.v2/admin/messages/messages_en.properties b/js/apps/admin-ui/maven-resources/theme/keycloak.v2/admin/messages/messages_en.properties index 3c5a530ca9..36e9be7599 100644 --- a/js/apps/admin-ui/maven-resources/theme/keycloak.v2/admin/messages/messages_en.properties +++ b/js/apps/admin-ui/maven-resources/theme/keycloak.v2/admin/messages/messages_en.properties @@ -2281,6 +2281,7 @@ search=Search validateEditMode=You must select an edit mode copyFlowSuccess=Flow successfully duplicated cacheSettings=Cache settings +client-attributes.label=Client Attributes searchForClient=Search for client permissionDeletedError=Could not delete permission due to {{error}} eventTypes.UPDATE_PROFILE.name=Update profile @@ -2810,6 +2811,7 @@ clientSessionIdle=Client Session Idle push=Push targetClaimHelp=Specifies the target claim which the policy will fetch. periodicFullSyncHelp=Whether periodic full synchronization of LDAP users to Keycloak should be enabled or not +client-attributes-condition.tooltip=Client attributes, that will be checked during this condition evaluation. Condition evaluates to true if the client has all client attributes with the name and value as the client attributes specified in the configuration. scopePermissions.users.user-impersonated-description=Policies that decide which users can be impersonated. These policies are applied to the user being impersonated. forceNameIdFormat=Force name ID format noMappersInstructions=There are currently no mappers for this identity provider. diff --git a/services/src/main/java/org/keycloak/services/clientpolicy/condition/ClientAttributesCondition.java b/services/src/main/java/org/keycloak/services/clientpolicy/condition/ClientAttributesCondition.java new file mode 100644 index 0000000000..7ed8229348 --- /dev/null +++ b/services/src/main/java/org/keycloak/services/clientpolicy/condition/ClientAttributesCondition.java @@ -0,0 +1,122 @@ +/* + * Copyright 2024 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.services.clientpolicy.condition; + +import org.jboss.logging.Logger; +import org.keycloak.models.ClientModel; +import org.keycloak.models.KeycloakSession; +import org.keycloak.representations.idm.ClientPolicyConditionConfigurationRepresentation; +import org.keycloak.services.clientpolicy.ClientPolicyContext; +import org.keycloak.services.clientpolicy.ClientPolicyException; +import org.keycloak.services.clientpolicy.ClientPolicyVote; +import org.keycloak.services.clientpolicy.context.PreAuthorizationRequestContext; + +import java.util.Map; + +/** + * @author Yoshiyuki Tabata + */ +public class ClientAttributesCondition extends AbstractClientPolicyConditionProvider { + + private static final Logger logger = Logger.getLogger(ClientAttributesCondition.class); + + public ClientAttributesCondition(KeycloakSession session) { + super(session); + } + + @Override + public Class getConditionConfigurationClass() { + return Configuration.class; + } + + public static class Configuration extends ClientPolicyConditionConfigurationRepresentation { + + protected Map attributes; + + public Map getAttributes() { + return attributes; + } + + public void setAttributes(Map attributes) { + this.attributes = attributes; + } + } + + @Override + public String getProviderId() { + return ClientAttributesConditionFactory.PROVIDER_ID; + } + + @Override + public ClientPolicyVote applyPolicy(ClientPolicyContext context) throws ClientPolicyException { + switch (context.getEvent()) { + case PRE_AUTHORIZATION_REQUEST: + PreAuthorizationRequestContext paContext = (PreAuthorizationRequestContext) context; + ClientModel client = session.getContext().getRealm().getClientByClientId(paContext.getClientId()); + if (isAttributesMatched(client)) return ClientPolicyVote.YES; + return ClientPolicyVote.NO; + case AUTHORIZATION_REQUEST: + case TOKEN_REQUEST: + case TOKEN_RESPONSE: + case SERVICE_ACCOUNT_TOKEN_REQUEST: + case SERVICE_ACCOUNT_TOKEN_RESPONSE: + case TOKEN_REFRESH: + case TOKEN_REFRESH_RESPONSE: + case TOKEN_REVOKE: + case TOKEN_INTROSPECT: + case USERINFO_REQUEST: + case LOGOUT_REQUEST: + case BACKCHANNEL_AUTHENTICATION_REQUEST: + case BACKCHANNEL_TOKEN_REQUEST: + case BACKCHANNEL_TOKEN_RESPONSE: + case PUSHED_AUTHORIZATION_REQUEST: + case REGISTERED: + case UPDATE: + case UPDATED: + case SAML_AUTHN_REQUEST: + case SAML_LOGOUT_REQUEST: + if (isAttributesMatched(session.getContext().getClient())) return ClientPolicyVote.YES; + return ClientPolicyVote.NO; + default: + return ClientPolicyVote.ABSTAIN; + } + } + + private boolean isAttributesMatched(ClientModel client) { + if (client == null) return false; + + Map attributesForMatching = getAttributesForMatching(); + if (attributesForMatching == null) return false; + + Map clientAttributes = client.getAttributes(); + + if (logger.isTraceEnabled()) { + clientAttributes.forEach((i, j) -> logger.tracev("client attribute assigned = {0}: {1}", i, j)); + attributesForMatching.forEach((i, j) -> logger.tracev("client attribute for matching = {0}: {1}", i, j)); + } + + return attributesForMatching.entrySet().stream() + .allMatch(entry -> clientAttributes.containsKey(entry.getKey()) && clientAttributes.get(entry.getKey()).equals(entry.getValue())); + } + + private Map getAttributesForMatching() { + if (configuration.getAttributes() == null) return null; + return configuration.getAttributes(); + } + +} diff --git a/services/src/main/java/org/keycloak/services/clientpolicy/condition/ClientAttributesConditionFactory.java b/services/src/main/java/org/keycloak/services/clientpolicy/condition/ClientAttributesConditionFactory.java new file mode 100644 index 0000000000..3e98999bfd --- /dev/null +++ b/services/src/main/java/org/keycloak/services/clientpolicy/condition/ClientAttributesConditionFactory.java @@ -0,0 +1,64 @@ +/* + * Copyright 2024 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.services.clientpolicy.condition; + +import org.keycloak.models.KeycloakSession; +import org.keycloak.provider.ProviderConfigProperty; + +import java.util.ArrayList; +import java.util.List; + +/** + * @author Yoshiyuki Tabata + */ +public class ClientAttributesConditionFactory extends AbstractClientPolicyConditionProviderFactory { + + public static final String PROVIDER_ID = "client-attributes"; + + public static final String ATTRIBUTES = "attributes"; + + private static final List configProperties = new ArrayList<>(); + + static { + addCommonConfigProperties(configProperties); + + ProviderConfigProperty property; + property = new ProviderConfigProperty(ATTRIBUTES, PROVIDER_ID + ".label", PROVIDER_ID + "-condition.tooltip", ProviderConfigProperty.MAP_TYPE, null); + configProperties.add(property); + } + + @Override + public ClientPolicyConditionProvider create(KeycloakSession session) { + return new ClientAttributesCondition(session); + } + + @Override + public String getId() { + return PROVIDER_ID; + } + + @Override + public String getHelpText() { + return "The condition checks whether all of the specified client attributes exists on the client to determine whether the policy is applied. This effectively allows client administrator to create client attribute of specified name on the client to make sure that particular client policy will be applied on requests of this client. Condition is checked during most of OpenID Connect requests (Authorization request, token requests, introspection endpoint request etc.)."; + } + + @Override + public List getConfigProperties() { + return configProperties; + } +} diff --git a/services/src/main/resources/META-INF/services/org.keycloak.services.clientpolicy.condition.ClientPolicyConditionProviderFactory b/services/src/main/resources/META-INF/services/org.keycloak.services.clientpolicy.condition.ClientPolicyConditionProviderFactory index 888c84ba0b..cd21301195 100644 --- a/services/src/main/resources/META-INF/services/org.keycloak.services.clientpolicy.condition.ClientPolicyConditionProviderFactory +++ b/services/src/main/resources/META-INF/services/org.keycloak.services.clientpolicy.condition.ClientPolicyConditionProviderFactory @@ -7,3 +7,4 @@ org.keycloak.services.clientpolicy.condition.ClientUpdaterSourceGroupsConditionF org.keycloak.services.clientpolicy.condition.ClientUpdaterSourceRolesConditionFactory org.keycloak.services.clientpolicy.condition.AnyClientConditionFactory org.keycloak.services.clientpolicy.condition.ClientProtocolConditionFactory +org.keycloak.services.clientpolicy.condition.ClientAttributesConditionFactory diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/client/policies/ClientPoliciesConditionTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/client/policies/ClientPoliciesConditionTest.java index 3120171e63..fcc6bb5597 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/client/policies/ClientPoliciesConditionTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/client/policies/ClientPoliciesConditionTest.java @@ -22,6 +22,7 @@ import static org.junit.Assert.fail; import static org.keycloak.testsuite.admin.AbstractAdminTest.loadJson; import static org.keycloak.testsuite.util.ClientPoliciesUtil.createAnyClientConditionConfig; import static org.keycloak.testsuite.util.ClientPoliciesUtil.createClientAccessTypeConditionConfig; +import static org.keycloak.testsuite.util.ClientPoliciesUtil.createClientAttributesConditionConfig; import static org.keycloak.testsuite.util.ClientPoliciesUtil.createClientScopesConditionConfig; import static org.keycloak.testsuite.util.ClientPoliciesUtil.createClientUpdateSourceGroupsConditionConfig; import static org.keycloak.testsuite.util.ClientPoliciesUtil.createClientUpdateSourceHostsConditionConfig; @@ -60,6 +61,7 @@ import org.keycloak.services.clientpolicy.ClientPolicyEvent; import org.keycloak.services.clientpolicy.ClientPolicyException; import org.keycloak.services.clientpolicy.condition.AnyClientConditionFactory; import org.keycloak.services.clientpolicy.condition.ClientAccessTypeConditionFactory; +import org.keycloak.services.clientpolicy.condition.ClientAttributesConditionFactory; import org.keycloak.services.clientpolicy.condition.ClientScopesConditionFactory; import org.keycloak.services.clientpolicy.condition.ClientUpdaterSourceGroupsConditionFactory; import org.keycloak.services.clientpolicy.condition.ClientUpdaterSourceHostsConditionFactory; @@ -83,7 +85,7 @@ import org.keycloak.testsuite.util.UserBuilder; /** * This test class is for testing a condition of client policies. - * + * * @author Takashi Norimatsu */ @EnableFeature(value = Profile.Feature.CLIENT_SECRET_ROTATION) @@ -382,6 +384,71 @@ public class ClientPoliciesConditionTest extends AbstractClientPoliciesTest { } } + @Test + public void testClientAttributesCondition() throws Exception { + // register profiles + String json = (new ClientProfilesBuilder()).addProfile( + (new ClientProfileBuilder()).createProfile(PROFILE_NAME, "Het Eerste Profiel") + .addExecutor(PKCEEnforcerExecutorFactory.PROVIDER_ID, + createPKCEEnforceExecutorConfig(Boolean.TRUE)) + .toRepresentation() + ).toString(); + updateProfiles(json); + + // register policies + json = (new ClientPoliciesBuilder()).addPolicy( + (new ClientPolicyBuilder()).createPolicy(POLICY_NAME, "Het Eerste Beleid", Boolean.TRUE) + .addCondition(ClientAttributesConditionFactory.PROVIDER_ID, + createClientAttributesConditionConfig(new HashMap() { + { + put("attr1", "Apple"); + put("attr2", "Orange"); + } + })) + .addProfile(PROFILE_NAME) + .toRepresentation() + ).toString(); + updatePolicies(json); + + String clientAlphaId = generateSuffixedName("Alpha-App"); + String clientSecret = "secret"; + createClientByAdmin(clientAlphaId, (ClientRepresentation clientRep) -> { + clientRep.setSecret(clientSecret); + clientRep.setAttributes(new HashMap() { + { + put("attr1", "Apple"); + put("attr2", "Orange"); + put("attr3", "Banana"); + } + }); + }); + + String clientBetaId = generateSuffixedName("Beta-App"); + createClientByAdmin(clientBetaId, (ClientRepresentation clientRep) -> { + clientRep.setSecret(clientSecret); + clientRep.setAttributes(new HashMap() { + { + put("attr1", "Apple"); + put("attr2", "Peach"); // attr2 is not "Orange" + put("attr3", "Banana"); + } + }); + }); + + try { + successfulLoginAndLogout(clientBetaId, clientSecret); + } catch (Exception e) { + fail(); + } + + try { + failLoginByNotFollowingPKCE(clientAlphaId); + successfulLoginAndLogoutWithPKCE(clientAlphaId, clientSecret, TEST_USER_NAME, TEST_USER_PASSWORD); + } catch (Exception e) { + fail(); + } + } + @Test public void testClientAccessTypeCondition() throws Exception { // register profiles diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/util/ClientPoliciesUtil.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/util/ClientPoliciesUtil.java index 24a2012c24..2b3948800a 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/util/ClientPoliciesUtil.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/util/ClientPoliciesUtil.java @@ -45,6 +45,7 @@ import org.keycloak.representations.idm.ClientProfileRepresentation; import org.keycloak.representations.idm.ClientProfilesRepresentation; import org.keycloak.services.clientpolicy.ClientPolicyEvent; import org.keycloak.services.clientpolicy.condition.ClientAccessTypeCondition; +import org.keycloak.services.clientpolicy.condition.ClientAttributesCondition; import org.keycloak.services.clientpolicy.condition.ClientRolesCondition; import org.keycloak.services.clientpolicy.condition.ClientScopesCondition; import org.keycloak.services.clientpolicy.condition.ClientUpdaterContextCondition; @@ -82,6 +83,7 @@ import java.security.interfaces.RSAPublicKey; import java.security.spec.ECGenParameterSpec; import java.util.ArrayList; import java.util.List; +import java.util.Map; import java.util.function.Consumer; import static org.junit.Assert.fail; @@ -404,6 +406,12 @@ public final class ClientPoliciesUtil { return config; } + public static ClientAttributesCondition.Configuration createClientAttributesConditionConfig(Map attributes) { + ClientAttributesCondition.Configuration config = new ClientAttributesCondition.Configuration(); + config.setAttributes(attributes); + return config; + } + public static ClientUpdaterContextCondition.Configuration createClientUpdateContextConditionConfig(List updateClientSource) { ClientUpdaterContextCondition.Configuration config = new ClientUpdaterContextCondition.Configuration(); config.setUpdateClientSource(updateClientSource);