diff --git a/core/src/main/java/org/keycloak/jose/jws/JWSInput.java b/core/src/main/java/org/keycloak/jose/jws/JWSInput.java
index ab806b685d8..5cf56d03a36 100755
--- a/core/src/main/java/org/keycloak/jose/jws/JWSInput.java
+++ b/core/src/main/java/org/keycloak/jose/jws/JWSInput.java
@@ -24,6 +24,8 @@ import org.keycloak.common.util.Base64Url;
import org.keycloak.jose.JOSE;
import org.keycloak.util.JsonSerialization;
+import com.fasterxml.jackson.core.type.TypeReference;
+
/**
* @author Bill Burke
* @version $Revision: 1 $
@@ -99,6 +101,14 @@ public class JWSInput implements JOSE {
}
}
+ public T readJsonContent(TypeReference type) throws JWSInputException {
+ try {
+ return JsonSerialization.readValue(content, type);
+ } catch (IOException e) {
+ throw new JWSInputException(e);
+ }
+ }
+
public String readContentAsString() {
return new String(content, StandardCharsets.UTF_8);
}
diff --git a/core/src/main/java/org/keycloak/util/JsonSerialization.java b/core/src/main/java/org/keycloak/util/JsonSerialization.java
index e6d93a138d5..d96f5fc7f48 100755
--- a/core/src/main/java/org/keycloak/util/JsonSerialization.java
+++ b/core/src/main/java/org/keycloak/util/JsonSerialization.java
@@ -102,6 +102,10 @@ public class JsonSerialization {
return mapper.readValue(bytes, type);
}
+ public static T readValue(byte[] bytes, TypeReference type) throws IOException {
+ return mapper.readValue(bytes, type);
+ }
+
public static T readValue(String string, TypeReference type) throws IOException {
return mapper.readValue(string, type);
}
diff --git a/services/src/main/java/org/keycloak/services/clientpolicy/executor/JWTClaimEnforcerExecutor.java b/services/src/main/java/org/keycloak/services/clientpolicy/executor/JWTClaimEnforcerExecutor.java
new file mode 100644
index 00000000000..820136f31e3
--- /dev/null
+++ b/services/src/main/java/org/keycloak/services/clientpolicy/executor/JWTClaimEnforcerExecutor.java
@@ -0,0 +1,148 @@
+/*
+ * 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.services.clientpolicy.executor;
+
+import java.util.Map;
+
+import org.keycloak.OAuth2Constants;
+import org.keycloak.OAuthErrorException;
+import org.keycloak.jose.jws.JWSInput;
+import org.keycloak.jose.jws.JWSInputException;
+import org.keycloak.models.KeycloakSession;
+import org.keycloak.protocol.oidc.JWTAuthorizationGrantValidationContext;
+import org.keycloak.protocol.oidc.TokenExchangeContext;
+import org.keycloak.representations.idm.ClientPolicyExecutorConfigurationRepresentation;
+import org.keycloak.services.clientpolicy.ClientPolicyContext;
+import org.keycloak.services.clientpolicy.ClientPolicyException;
+import org.keycloak.services.clientpolicy.context.JWTAuthorizationGrantContext;
+import org.keycloak.services.clientpolicy.context.TokenExchangeRequestContext;
+import org.keycloak.utils.StringUtil;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+import com.fasterxml.jackson.core.type.TypeReference;
+
+public class JWTClaimEnforcerExecutor implements ClientPolicyExecutorProvider {
+
+ private final KeycloakSession session;
+ private Configuration configuration;
+
+ public JWTClaimEnforcerExecutor(KeycloakSession session) {
+ this.session = session;
+ }
+
+ @Override
+ public void setupConfiguration(JWTClaimEnforcerExecutor.Configuration config) {
+ this.configuration = config;
+ }
+
+ @Override
+ public String getProviderId() {
+ return JWTClaimEnforcerExecutorFactory.PROVIDER_ID;
+ }
+
+ @Override
+ public Class getExecutorConfigurationClass() {
+ return Configuration.class;
+ }
+
+ public static class Configuration extends ClientPolicyExecutorConfigurationRepresentation {
+
+ @JsonProperty("claim-name")
+ protected String claimName;
+
+ @JsonProperty("allowed-value")
+ protected String allowedValue;
+
+ public String getClaimName() {
+ return claimName;
+ }
+
+ public void setClaimName(String claimName) {
+ this.claimName = claimName;
+ }
+
+ public String getAllowedValue() {
+ return allowedValue;
+ }
+
+ public void setAllowedValue(String allowedValue) {
+ this.allowedValue = allowedValue;
+ }
+ }
+
+ @Override
+ public void executeOnEvent(ClientPolicyContext context) throws ClientPolicyException {
+ switch (context.getEvent()) {
+ case JWT_AUTHORIZATION_GRANT -> {
+ JWTAuthorizationGrantContext jwtAuthnGrantContext = ((JWTAuthorizationGrantContext) context);
+ JWTAuthorizationGrantValidationContext jwtContext = jwtAuthnGrantContext.getAuthorizationGrantContext();
+ checkClaims(getAccessTokenMapFromJWTString(jwtContext.getAssertion()));
+ }
+ case TOKEN_EXCHANGE_REQUEST -> {
+ TokenExchangeContext tokenExchangeContext = ((TokenExchangeRequestContext) context).getTokenExchangeContext();
+ if (!OAuth2Constants.ACCESS_TOKEN_TYPE.equals(tokenExchangeContext.getParams().getSubjectTokenType())) {
+ throw new ClientPolicyException(OAuthErrorException.INVALID_REQUEST, "Parameter 'subject_token' should be access_token for the executor");
+ }
+ checkClaims(getAccessTokenMapFromJWTString(tokenExchangeContext.getParams().getSubjectToken()));
+ }
+ }
+ }
+
+ private Map getAccessTokenMapFromJWTString(String jwt) throws ClientPolicyException {
+ try {
+ return new JWSInput(jwt).readJsonContent(new TypeReference<>() {});
+ } catch (JWSInputException e) {
+ throw new ClientPolicyException(OAuthErrorException.INVALID_REQUEST, "JWT is not valid");
+ }
+ }
+
+ private void checkClaims(Map tokenMap) throws ClientPolicyException {
+ String claimName = configuration.getClaimName();
+ // Validate configuration
+ if (claimName == null) {
+ throw new ClientPolicyException(OAuthErrorException.INVALID_REQUEST, "Invalid configuration");
+ }
+
+ String allowedValue = configuration.getAllowedValue();
+
+ // Extract claim value
+ Object claimValue = tokenMap.get(claimName);
+ if (claimValue == null) {
+ throw new ClientPolicyException(OAuthErrorException.INVALID_REQUEST, "Required claim '" + claimName + "' is missing from the token");
+ }
+
+ // If allowedValue is empty validate only if the claim exists
+ if (StringUtil.isBlank(allowedValue)) {
+ return;
+ }
+
+ //allow only numbers or strings
+ if (!isAllowedClaimType(claimValue)) {
+ throw new ClientPolicyException(OAuthErrorException.INVALID_REQUEST, "Value type for claim '" + claimName + "' not allowed");
+ }
+
+ String stringValue = String.valueOf(claimValue);
+
+ if (!stringValue.matches(allowedValue)) {
+ throw new ClientPolicyException(OAuthErrorException.INVALID_REQUEST, "Value for claim '" + claimName + "' not allowed");
+ }
+ }
+
+ private boolean isAllowedClaimType(Object claimValue) {
+ return claimValue instanceof String || claimValue instanceof Number;
+ }
+}
diff --git a/services/src/main/java/org/keycloak/services/clientpolicy/executor/JWTClaimEnforcerExecutorFactory.java b/services/src/main/java/org/keycloak/services/clientpolicy/executor/JWTClaimEnforcerExecutorFactory.java
new file mode 100644
index 00000000000..577c6e60e75
--- /dev/null
+++ b/services/src/main/java/org/keycloak/services/clientpolicy/executor/JWTClaimEnforcerExecutorFactory.java
@@ -0,0 +1,88 @@
+/*
+ * 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.services.clientpolicy.executor;
+
+import java.util.List;
+
+import org.keycloak.Config.Scope;
+import org.keycloak.models.KeycloakSession;
+import org.keycloak.models.KeycloakSessionFactory;
+import org.keycloak.provider.ProviderConfigProperty;
+
+public class JWTClaimEnforcerExecutorFactory implements ClientPolicyExecutorProviderFactory {
+
+ public static final String PROVIDER_ID = "jwt-claim-enforcer";
+
+ public static final String CLAIM_NAME = "claim-name";
+
+ public static final String ALLOWED_VALUE = "allowed-value";
+
+ @Override
+ public ClientPolicyExecutorProvider create(KeycloakSession session) {
+ return new JWTClaimEnforcerExecutor(session);
+ }
+
+ private static final ProviderConfigProperty CLAIM_NAME_PROPERTY = new ProviderConfigProperty(
+ CLAIM_NAME,
+ "Claim Name",
+ "The name of the JWT claim to enforce. This claim must be present in the token for validation.",
+ ProviderConfigProperty.STRING_TYPE,
+ null
+ );
+
+ private static final ProviderConfigProperty ALLOWED_VALUE_PROPERTY = new ProviderConfigProperty(
+ ALLOWED_VALUE,
+ "Allowed Value",
+ "Value that the JWT claim must match. Regular expressions are supported. If left empty, only the presence of the claim is enforced.",
+ ProviderConfigProperty.STRING_TYPE,
+ null
+ );
+
+ @Override
+ public void init(Scope config) {
+ }
+
+ @Override
+ public void postInit(KeycloakSessionFactory factory) {
+ }
+
+ @Override
+ public void close() {
+ }
+
+ @Override
+ public String getId() {
+ return PROVIDER_ID;
+ }
+
+ @Override
+ public String getHelpText() {
+ return """
+ The executor enforces the presence and specific values of a claim in a JWT.
+ It is applied in client requests where an existing JWT token is passed, such as a JWT Authorization Grant request (RFC 7523) or Standard Token Exchange request.
+ The configured claim must be present in the received JWT.
+ If allowed value is empty, only the presence of the claim is enforced.
+ If allowed value is set, the claim's value must match the configured regular expression.
+ Only claims of type string or number are allowed; multi-valued, arrays, maps, or other JSON objects are not supported.
+ """;
+ }
+
+ @Override
+ public List getConfigProperties() {
+ return List.of(CLAIM_NAME_PROPERTY, ALLOWED_VALUE_PROPERTY);
+ }
+}
diff --git a/services/src/main/resources/META-INF/services/org.keycloak.services.clientpolicy.executor.ClientPolicyExecutorProviderFactory b/services/src/main/resources/META-INF/services/org.keycloak.services.clientpolicy.executor.ClientPolicyExecutorProviderFactory
index 8d86134f450..7cdcf69defb 100644
--- a/services/src/main/resources/META-INF/services/org.keycloak.services.clientpolicy.executor.ClientPolicyExecutorProviderFactory
+++ b/services/src/main/resources/META-INF/services/org.keycloak.services.clientpolicy.executor.ClientPolicyExecutorProviderFactory
@@ -31,3 +31,4 @@ org.keycloak.services.clientpolicy.executor.SamlSignatureEnforcerExecutorFactory
org.keycloak.services.clientpolicy.executor.AuthenticationFlowSelectorExecutorFactory
org.keycloak.services.clientpolicy.executor.SecureClientAuthenticationAssertionExecutorFactory
org.keycloak.services.clientpolicy.executor.DownscopeAssertionGrantEnforcerExecutorFactory
+org.keycloak.services.clientpolicy.executor.JWTClaimEnforcerExecutorFactory
diff --git a/test-framework/core/src/main/java/org/keycloak/testframework/realm/RealmConfigBuilder.java b/test-framework/core/src/main/java/org/keycloak/testframework/realm/RealmConfigBuilder.java
index 13d8205e847..30241d8d956 100644
--- a/test-framework/core/src/main/java/org/keycloak/testframework/realm/RealmConfigBuilder.java
+++ b/test-framework/core/src/main/java/org/keycloak/testframework/realm/RealmConfigBuilder.java
@@ -256,13 +256,13 @@ public class RealmConfigBuilder {
return this;
}
- public RealmConfigBuilder clientProfile(ClientProfileRepresentation clienProfileRep) {
+ public RealmConfigBuilder clientProfile(ClientProfileRepresentation clientProfileRep) {
ClientProfilesRepresentation clientProfiles = rep.getParsedClientProfiles();
if (clientProfiles == null) {
clientProfiles = new ClientProfilesRepresentation();
}
List profiles = clientProfiles.getProfiles();
- profiles.add(clienProfileRep);
+ profiles.add(clientProfileRep);
rep.setParsedClientProfiles(clientProfiles);
return this;
}
diff --git a/tests/base/src/test/java/org/keycloak/tests/oauth/JWTAuthorizationGrantJWTClaimsClientPoliciesTest.java b/tests/base/src/test/java/org/keycloak/tests/oauth/JWTAuthorizationGrantJWTClaimsClientPoliciesTest.java
new file mode 100644
index 00000000000..cff44424832
--- /dev/null
+++ b/tests/base/src/test/java/org/keycloak/tests/oauth/JWTAuthorizationGrantJWTClaimsClientPoliciesTest.java
@@ -0,0 +1,174 @@
+package org.keycloak.tests.oauth;
+
+import java.util.Map;
+
+import org.keycloak.OAuth2Constants;
+import org.keycloak.representations.JsonWebToken;
+import org.keycloak.services.clientpolicy.condition.GrantTypeConditionFactory;
+import org.keycloak.services.clientpolicy.executor.JWTClaimEnforcerExecutor;
+import org.keycloak.services.clientpolicy.executor.JWTClaimEnforcerExecutorFactory;
+import org.keycloak.testframework.annotations.InjectRealm;
+import org.keycloak.testframework.annotations.KeycloakIntegrationTest;
+import org.keycloak.testframework.realm.ClientPolicyBuilder;
+import org.keycloak.testframework.realm.ClientProfileBuilder;
+import org.keycloak.testframework.realm.ManagedRealm;
+import org.keycloak.testframework.realm.RealmConfigBuilder;
+import org.keycloak.testsuite.util.oauth.AccessTokenResponse;
+
+import org.junit.jupiter.api.Test;
+
+@KeycloakIntegrationTest(config = JWTAuthorizationGrantTest.JWTAuthorizationGrantServerConfig.class)
+public class JWTAuthorizationGrantJWTClaimsClientPoliciesTest extends BaseAbstractJWTAuthorizationGrantTest {
+
+ @InjectRealm(config = JWTAuthorizationGrantRealmConfig.class)
+ protected ManagedRealm realm;
+
+ @Test
+ public void testClaimPresenceOnly() {
+ updateExecutorConfig("username", null);
+
+ JsonWebToken assertionJwt = createDefaultAuthorizationGrantToken();
+
+ AccessTokenResponse response = oAuthClient.jwtAuthorizationGrantRequest(identityProvider.encodeToken(assertionJwt)).send();
+ assertFailurePolicy("invalid_request", "Required claim 'username' is missing from the token", response, events.poll());
+
+ assertionJwt = createDefaultAuthorizationGrantToken();
+ assertionJwt.getOtherClaims().put("username", "anyvalue");
+ response = oAuthClient.jwtAuthorizationGrantRequest(identityProvider.encodeToken(assertionJwt)).send();
+ assertSuccess("test-app", response);
+ }
+
+ @Test
+ public void testSubjectClaimExactMatch() {
+ updateExecutorConfig("sub", "basic-user-id-1");
+
+ JsonWebToken assertionJwt = createDefaultAuthorizationGrantToken();
+ AccessTokenResponse response = oAuthClient.jwtAuthorizationGrantRequest(identityProvider.encodeToken(assertionJwt)).send();
+ assertFailurePolicy("invalid_request", "Value for claim 'sub' not allowed", response, events.poll());
+ }
+
+ @Test
+ public void testClaimExactMatch() {
+ updateExecutorConfig("username", "test-username");
+
+ JsonWebToken assertionJwt = createDefaultAuthorizationGrantToken();
+ assertionJwt.getOtherClaims().put("username", "test-username");
+
+ AccessTokenResponse response = oAuthClient.jwtAuthorizationGrantRequest(identityProvider.encodeToken(assertionJwt)).send();
+ assertSuccess("test-app", response);
+
+ assertionJwt = createDefaultAuthorizationGrantToken();
+ assertionJwt.getOtherClaims().put("username", "wronguser");
+ response = oAuthClient.jwtAuthorizationGrantRequest(identityProvider.encodeToken(assertionJwt)).send();
+ assertFailurePolicy("invalid_request", "Value for claim 'username' not allowed", response, events.poll());
+ }
+
+ @Test
+ public void testClaimWildcardMatch() {
+ updateExecutorConfig("username", "test-username.*");
+
+ JsonWebToken assertionJwt = createDefaultAuthorizationGrantToken();
+ assertionJwt.getOtherClaims().put("username", "test-username123");
+
+ AccessTokenResponse response = oAuthClient.jwtAuthorizationGrantRequest(identityProvider.encodeToken(assertionJwt)).send();
+ assertSuccess("test-app", response);
+
+ assertionJwt = createDefaultAuthorizationGrantToken();
+ assertionJwt.getOtherClaims().put("username", "wronguser");
+ response = oAuthClient.jwtAuthorizationGrantRequest(identityProvider.encodeToken(assertionJwt)).send();
+ assertFailurePolicy("invalid_request", "Value for claim 'username' not allowed", response, events.poll());
+ }
+
+ @Test
+ public void testClaimNumberMatch() {
+ updateExecutorConfig("level", "^(3|5)$");
+
+ JsonWebToken assertionJwt = createDefaultAuthorizationGrantToken();
+ assertionJwt.getOtherClaims().put("level", 3);
+
+ AccessTokenResponse response = oAuthClient.jwtAuthorizationGrantRequest(identityProvider.encodeToken(assertionJwt)).send();
+ assertSuccess("test-app", response);
+
+ assertionJwt = createDefaultAuthorizationGrantToken();
+ assertionJwt.getOtherClaims().put("level", 2);
+ response = oAuthClient.jwtAuthorizationGrantRequest(identityProvider.encodeToken(assertionJwt)).send();
+ assertFailurePolicy("invalid_request", "Value for claim 'level' not allowed", response, events.poll());
+ }
+
+ @Test
+ public void testClaimMultiValueRegexMatch() {
+ updateExecutorConfig("username", "^(admin|service|test-[0-9]+)$");
+
+ JsonWebToken assertionJwt = createDefaultAuthorizationGrantToken();
+ assertionJwt.getOtherClaims().put("username", "admin");
+
+ AccessTokenResponse response =
+ oAuthClient.jwtAuthorizationGrantRequest(identityProvider.encodeToken(assertionJwt)).send();
+ assertSuccess("test-app", response);
+
+ assertionJwt = createDefaultAuthorizationGrantToken();
+ assertionJwt.getOtherClaims().put("username", "service");
+
+ response = oAuthClient.jwtAuthorizationGrantRequest(identityProvider.encodeToken(assertionJwt)).send();
+ assertSuccess("test-app", response);
+
+ assertionJwt = createDefaultAuthorizationGrantToken();
+ assertionJwt.getOtherClaims().put("username", "test-12345");
+
+ response = oAuthClient.jwtAuthorizationGrantRequest(identityProvider.encodeToken(assertionJwt)).send();
+ assertSuccess("test-app", response);
+
+ assertionJwt = createDefaultAuthorizationGrantToken();
+ assertionJwt.getOtherClaims().put("username", "unknown-username");
+
+ response = oAuthClient.jwtAuthorizationGrantRequest(identityProvider.encodeToken(assertionJwt)).send();
+ assertFailurePolicy("invalid_request", "Value for claim 'username' not allowed", response, events.poll());
+ }
+
+ @Test
+ public void testClaimNowAllowedType() {
+ updateExecutorConfig("username", "test-username");
+
+ JsonWebToken assertionJwt = createDefaultAuthorizationGrantToken();
+ assertionJwt.getOtherClaims().put("username", Map.of("test-username","tet-username-value"));
+
+ AccessTokenResponse response = oAuthClient.jwtAuthorizationGrantRequest(identityProvider.encodeToken(assertionJwt)).send();
+ assertFailurePolicy("invalid_request", "Value type for claim 'username' not allowed", response, events.poll());
+ }
+
+ protected void updateExecutorConfig(String claimName, String allowedValue) {
+ JWTClaimEnforcerExecutor.Configuration claimsConfig = new JWTClaimEnforcerExecutor.Configuration();
+ claimsConfig.setClaimName(claimName);
+ claimsConfig.setAllowedValue(allowedValue);
+ updateExecutorConfig(claimsConfig);
+ }
+ protected void updateExecutorConfig(JWTClaimEnforcerExecutor.Configuration newConfig) {
+
+ realm.updateWithCleanup(r -> {
+ r.clientProfile(ClientProfileBuilder.create()
+ .name("executor")
+ .description("executor description")
+ .executor(JWTClaimEnforcerExecutorFactory.PROVIDER_ID, newConfig)
+ .build());
+
+ r.clientPolicy(ClientPolicyBuilder.create()
+ .name("policy")
+ .description("description of policy")
+ .condition(GrantTypeConditionFactory.PROVIDER_ID, ClientPolicyBuilder.grantTypeConditionConfiguration(
+ OAuth2Constants.JWT_AUTHORIZATION_GRANT))
+ .profile("executor")
+ .build());
+
+ return r;
+ });
+ }
+
+ public static class JWTAuthorizationGrantRealmConfig extends OIDCIdentityProviderJWTAuthorizationGrantTest.JWTAuthorizationGrantRealmConfig {
+
+ @Override
+ public RealmConfigBuilder configure(RealmConfigBuilder realm) {
+ super.configure(realm);
+ return realm;
+ }
+ }
+}
diff --git a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/updaters/ProtocolMappersUpdater.java b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/updaters/ProtocolMappersUpdater.java
index d4e6961907a..c54223e77ed 100644
--- a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/updaters/ProtocolMappersUpdater.java
+++ b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/updaters/ProtocolMappersUpdater.java
@@ -63,6 +63,17 @@ public class ProtocolMappersUpdater extends ServerResourceUpdater it = rep.iterator(); it.hasNext();) {
+ ProtocolMapperRepresentation mapper = it.next();
+ if (name.equals(mapper.getName())) {
+ it.remove();
+ break;
+ }
+ }
+ return this;
+ }
+
private void update(List expectedMappers) {
List currentMappers = resource.getMappers();
diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/tokenexchange/StandardTokenExchangeV2Test.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/tokenexchange/StandardTokenExchangeV2Test.java
index a523ec05e05..ab7772ce8c1 100644
--- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/tokenexchange/StandardTokenExchangeV2Test.java
+++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/tokenexchange/StandardTokenExchangeV2Test.java
@@ -47,6 +47,7 @@ import org.keycloak.protocol.oidc.OIDCConfigAttributes;
import org.keycloak.protocol.oidc.OIDCLoginProtocol;
import org.keycloak.protocol.oidc.encode.AccessTokenContext;
import org.keycloak.protocol.oidc.mappers.AudienceProtocolMapper;
+import org.keycloak.protocol.oidc.mappers.HardcodedClaim;
import org.keycloak.representations.AccessToken;
import org.keycloak.representations.IDToken;
import org.keycloak.representations.idm.ClientScopeRepresentation;
@@ -59,6 +60,8 @@ import org.keycloak.services.clientpolicy.ClientPolicyEvent;
import org.keycloak.services.clientpolicy.condition.ClientScopesConditionFactory;
import org.keycloak.services.clientpolicy.condition.GrantTypeConditionFactory;
import org.keycloak.services.clientpolicy.executor.DownscopeAssertionGrantEnforcerExecutorFactory;
+import org.keycloak.services.clientpolicy.executor.JWTClaimEnforcerExecutor;
+import org.keycloak.services.clientpolicy.executor.JWTClaimEnforcerExecutorFactory;
import org.keycloak.testsuite.AssertEvents;
import org.keycloak.testsuite.admin.ApiUtil;
import org.keycloak.testsuite.arquillian.annotation.UncaughtServerErrorExpected;
@@ -1109,6 +1112,58 @@ public class StandardTokenExchangeV2Test extends AbstractClientPoliciesTest {
response.getErrorDescription());
}
+ @Test
+ public void testJWTClaimClientPolicies() throws Exception {
+ testJWTClaimClientPolicies("username", "testuser", "testuser", true, null);
+ testJWTClaimClientPolicies("username", "puppa", "testuser", false, "Value for claim 'username' not allowed");
+ testJWTClaimClientPolicies("username", "admin", "^(admin|service|test-[0-9]+)$", true, null);
+ testJWTClaimClientPolicies("username", "test-12345", "^(admin|service|test-[0-9]+)$", true, null);
+ testJWTClaimClientPolicies("username", "unknown-username", "^(admin|service|test-[0-9]+)$", false, "Value for claim 'username' not allowed");
+ testJWTClaimClientPolicies("username", "testuser", null, true, "Value for claim 'username' not allowed");
+ testJWTClaimClientPolicies("username", null, null, false, "Required claim 'username' is missing from the token");
+ }
+
+ public void testJWTClaimClientPolicies(String claimName, String claimValue, String executorRegex, boolean success, String errorMessage) throws Exception {
+ ClientAttributeUpdater.forClient(adminClient, TEST, "subject-client")
+ .protocolMappers()
+ .add(ModelToRepresentation.toRepresentation(HardcodedClaim.create(claimName, claimName, claimValue, "String", true, true, true)))
+ .update();
+
+ JWTClaimEnforcerExecutor.Configuration claimsConfig = new JWTClaimEnforcerExecutor.Configuration();
+ claimsConfig.setClaimName(claimName);
+ claimsConfig.setAllowedValue(executorRegex);
+
+ String json = (new ClientPoliciesUtil.ClientProfilesBuilder()).addProfile((new ClientPoliciesUtil.ClientProfileBuilder()).createProfile(PROFILE_NAME, "Profile")
+ .addExecutor(JWTClaimEnforcerExecutorFactory.PROVIDER_ID, claimsConfig)
+ .toRepresentation()).toString();
+ updateProfiles(json);
+
+ // register policy with condition on token exchange grant
+ json = (new ClientPoliciesUtil.ClientPoliciesBuilder()).addPolicy(
+ (new ClientPoliciesUtil.ClientPolicyBuilder()).createPolicy(POLICY_NAME, "Client Scope Policy", Boolean.TRUE)
+ .addCondition(GrantTypeConditionFactory.PROVIDER_ID,
+ createGrantTypeConditionConfig(List.of(OAuth2Constants.TOKEN_EXCHANGE_GRANT_TYPE)))
+ .addProfile(PROFILE_NAME)
+ .toRepresentation()).toString();
+ updatePolicies(json);
+
+ String accessToken = resourceOwnerLogin("john", "password", "subject-client", "secret").getAccessToken();
+ AccessTokenResponse response = tokenExchange(accessToken, "requester-client", "secret", null, null);
+
+ if (success) {
+ assertEquals(Response.Status.OK.getStatusCode(), response.getStatusCode());
+ }
+ else {
+ assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), response.getStatusCode());
+ assertEquals(OAuthErrorException.INVALID_REQUEST, response.getError());
+ assertEquals(errorMessage, response.getErrorDescription());
+ }
+
+ ClientAttributeUpdater.forClient(adminClient, TEST, "subject-client").protocolMappers().removeByName(claimName).update();
+ revertToBuiltinProfiles();
+ revertToBuiltinPolicies();
+ }
+
@Test
@UncaughtServerErrorExpected
public void testTokenRevocation() throws Exception {