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 {