Client policy executor to allow extra audiences for JWT authorization grant

Closes #45180

Signed-off-by: rmartinc <rmartinc@redhat.com>
This commit is contained in:
rmartinc 2026-01-23 15:20:18 +01:00 committed by Marek Posolda
parent 072f547b71
commit c63f54ba3a
9 changed files with 364 additions and 3 deletions

View file

@ -153,6 +153,7 @@ One of several purposes for this executor is to realize the security requirement
* Enforce SAML Redirect binding cannot be used or SAML requests and assertions are signed * Enforce SAML Redirect binding cannot be used or SAML requests and assertions are signed
* Enforce scopes granted in link:{securing_apps_token_exchange_link}#_standard-token-exchange[Standard token exchange] or in JWT Authorization Grant are restricted to the ones present in the initial `subject_token` or `assertion` JWT. This executor only allows downscoping of the presented assertion. An error is returned if any extra scope, not originally granted to the JWT, is requested. * Enforce scopes granted in link:{securing_apps_token_exchange_link}#_standard-token-exchange[Standard token exchange] or in JWT Authorization Grant are restricted to the ones present in the initial `subject_token` or `assertion` JWT. This executor only allows downscoping of the presented assertion. An error is returned if any extra scope, not originally granted to the JWT, is requested.
* Enforce claims for assertion grants (`subject_token` in Token Exchange and `assertion` in JWT Authorization Grant). The executor enforces the presence and specific values of a claim in a JWT. It uses a Java regex so it is quite versatile. * Enforce claims for assertion grants (`subject_token` in Token Exchange and `assertion` in JWT Authorization Grant). The executor enforces the presence and specific values of a claim in a JWT. It uses a Java regex so it is quite versatile.
* Allow different valid audiences for the JWT Authorization Grant. This executor configures one or more valid audiences for the assertion instead of the ones defined in the standard (issuer or token endpoint URL) or the client ID. This behavior is not covered by the standard and can have major security implications.
Another available executor is the `auth-flow-enforce`, which can be used to enforce an authentication flow during an authentication request. For instance, it can be used to select a flow based on certain conditions, such as a specific scope or an ACR value. For more details, see the <<_client-policy-auth-flow, related documentation>>. Another available executor is the `auth-flow-enforce`, which can be used to enforce an authentication flow during an authentication request. For instance, it can be used to select a flow based on certain conditions, such as a specific scope or an ACR value. For more details, see the <<_client-policy-auth-flow, related documentation>>.

View file

@ -155,5 +155,9 @@ The enforcer can be used for any request that uses an assertion parameter. Curre
* Executor **jwt-claim-enforcer**. This executor allows to configure extra requirements for claims in the JWT assertion token. For example, if we want the assertion to contain an `iat` claim or a custom claim with a specific value. The configuration allows us to set any claim name and any claim value (using a java regex). If the claim in the JWT assertion does not match the regex, the request does not proceed and an error is returned. * Executor **jwt-claim-enforcer**. This executor allows to configure extra requirements for claims in the JWT assertion token. For example, if we want the assertion to contain an `iat` claim or a custom claim with a specific value. The configuration allows us to set any claim name and any claim value (using a java regex). If the claim in the JWT assertion does not match the regex, the request does not proceed and an error is returned.
+ +
As the previous executor, for the moment this enforcer can be used for JWT Authorization Grant and the Standard Token exchange. As the previous executor, for the moment this enforcer can be used for JWT Authorization Grant and the Standard Token exchange.
* Executor **jwt-authorization-grant-audience**. The executor allows to configure different and exclusive valid audiences for the assertion. The JWT Authorization Grant processing ensures, by specification, that the audience of the assertion is the {project_name} issuer or token endpoint URL. You can also use the Client ID as valid audience (option **Allows Client ID as audience for assertions**) for the OpenID Connect identity providers. Using this executor, you can configure one or more valid audiences instead of the default ones for the requests processed by the client policy.
+
WARNING: The **jwt-authorization-grant-audience** executor changes the validation of the assertion audience out of the standard compliance. This behavior can have major security implications.
</@tmpl.guide> </@tmpl.guide>

View file

@ -20,6 +20,10 @@ public interface JWTAuthorizationGrantValidationContext {
void setRestrictedScopes(Set<String> restrictedScopes); void setRestrictedScopes(Set<String> restrictedScopes);
void setAudienceAlreadyValidated();
boolean isAudienceAlreadyValidated();
default String getIssuer() { default String getIssuer() {
return getJWT().getIssuer(); return getJWT().getIssuer();
} }

View file

@ -92,9 +92,6 @@ public class JWTAuthorizationGrantType extends OAuth2GrantTypeBase {
// assign the signature alg and validate // assign the signature alg and validate
authorizationGrantContext.validateSignatureAlgorithm(jwtAuthorizationGrantProvider.getAssertionSignatureAlg()); authorizationGrantContext.validateSignatureAlgorithm(jwtAuthorizationGrantProvider.getAssertionSignatureAlg());
// Validate audience
authorizationGrantContext.validateTokenAudience(jwtAuthorizationGrantProvider.getAllowedAudienceForJWTGrant(), false);
//validate the JWT assertion and get the brokered identity from the idp //validate the JWT assertion and get the brokered identity from the idp
BrokeredIdentityContext brokeredIdentityContext = jwtAuthorizationGrantProvider.validateAuthorizationGrantAssertion(authorizationGrantContext); BrokeredIdentityContext brokeredIdentityContext = jwtAuthorizationGrantProvider.validateAuthorizationGrantAssertion(authorizationGrantContext);
if (brokeredIdentityContext == null) { if (brokeredIdentityContext == null) {
@ -122,6 +119,11 @@ public class JWTAuthorizationGrantType extends OAuth2GrantTypeBase {
throw new CorsErrorResponseException(cors, cpe.getError(), cpe.getErrorDetail(), cpe.getErrorStatus()); throw new CorsErrorResponseException(cors, cpe.getError(), cpe.getErrorDetail(), cpe.getErrorStatus());
} }
// Validate audience if not validated previously by client policies
if (!authorizationGrantContext.isAudienceAlreadyValidated()) {
authorizationGrantContext.validateTokenAudience(jwtAuthorizationGrantProvider.getAllowedAudienceForJWTGrant(), false);
}
RootAuthenticationSessionModel rootAuthSession = new AuthenticationSessionManager(session).createAuthenticationSession(realm, false); RootAuthenticationSessionModel rootAuthSession = new AuthenticationSessionManager(session).createAuthenticationSession(realm, false);
AuthenticationSessionModel authSession = createSessionModel(rootAuthSession, user, client, scopeParam); AuthenticationSessionModel authSession = createSessionModel(rootAuthSession, user, client, scopeParam);
UserSessionModel userSession = new UserSessionManager(session).createUserSession(authSession.getParentSession().getId(), realm, user, user.getUsername(), UserSessionModel userSession = new UserSessionManager(session).createUserSession(authSession.getParentSession().getId(), realm, user, user.getUsername(),

View file

@ -43,6 +43,7 @@ public class JWTAuthorizationGrantValidator extends AbstractBaseJWTValidator imp
private final String scope; private final String scope;
private Set<String> restrictedScopes; private Set<String> restrictedScopes;
private boolean audienceAlreadyValidated;
public static JWTAuthorizationGrantValidator createValidator(KeycloakSession session, ClientModel client, String assertion, String scope) { public static JWTAuthorizationGrantValidator createValidator(KeycloakSession session, ClientModel client, String assertion, String scope) {
if (assertion == null) { if (assertion == null) {
@ -62,6 +63,7 @@ public class JWTAuthorizationGrantValidator extends AbstractBaseJWTValidator imp
private JWTAuthorizationGrantValidator(KeycloakSession session, String scope, ClientAssertionState clientAssertionState) { private JWTAuthorizationGrantValidator(KeycloakSession session, String scope, ClientAssertionState clientAssertionState) {
super(session, clientAssertionState); super(session, clientAssertionState);
this.scope = scope; this.scope = scope;
this.audienceAlreadyValidated = false;
} }
public void validateClient() { public void validateClient() {
@ -102,14 +104,26 @@ public class JWTAuthorizationGrantValidator extends AbstractBaseJWTValidator imp
return scope; return scope;
} }
@Override
public Set<String> getRestrictedScopes() { public Set<String> getRestrictedScopes() {
return restrictedScopes; return restrictedScopes;
} }
@Override
public void setRestrictedScopes(Set<String> restrictedScopes) { public void setRestrictedScopes(Set<String> restrictedScopes) {
this.restrictedScopes = restrictedScopes; this.restrictedScopes = restrictedScopes;
} }
@Override
public void setAudienceAlreadyValidated() {
this.audienceAlreadyValidated = true;
}
@Override
public boolean isAudienceAlreadyValidated() {
return audienceAlreadyValidated;
}
@Override @Override
protected void failureCallback(String errorDescription) { protected void failureCallback(String errorDescription) {
throw new RuntimeException(errorDescription); throw new RuntimeException(errorDescription);

View file

@ -0,0 +1,108 @@
/*
* Copyright 2026 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.Set;
import org.keycloak.OAuthErrorException;
import org.keycloak.models.KeycloakSession;
import org.keycloak.representations.JsonWebToken;
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 com.fasterxml.jackson.annotation.JsonProperty;
import org.jboss.logging.Logger;
/**
*
* @author rmartinc
*/
public class JWTAuthorizationGrantAudienceExecutor implements ClientPolicyExecutorProvider<JWTAuthorizationGrantAudienceExecutor.Configuration> {
private static final Logger logger = Logger.getLogger(JWTAuthorizationGrantAudienceExecutor.class);
private final KeycloakSession session;
private Configuration configuration;
public static class Configuration extends ClientPolicyExecutorConfigurationRepresentation {
@JsonProperty(JWTAuthorizationGrantAudienceExecutorFactory.ALLOWED_AUDIENCE)
protected Set<String> allowedAudience;
public Set<String> getAllowedAudience() {
return allowedAudience;
}
public void setAllowedAudience(Set<String> allowedAudience) {
this.allowedAudience = allowedAudience;
}
}
public JWTAuthorizationGrantAudienceExecutor(KeycloakSession session) {
this.session = session;
}
@Override
public String getProviderId() {
return JWTAuthorizationGrantAudienceExecutorFactory.PROVIDER_ID;
}
@Override
public void setupConfiguration(Configuration config) {
this.configuration = config;
}
@Override
public Class<Configuration> getExecutorConfigurationClass() {
return Configuration.class;
}
public Configuration getConfiguration() {
return configuration;
}
@Override
public void executeOnEvent(ClientPolicyContext context) throws ClientPolicyException {
switch (context.getEvent()) {
case JWT_AUTHORIZATION_GRANT -> {
JWTAuthorizationGrantContext jwtAuthnGrantContext = ((JWTAuthorizationGrantContext) context);
validateAudience(jwtAuthnGrantContext);
}
}
}
private void validateAudience(JWTAuthorizationGrantContext jwtAuthnGrantContext) throws ClientPolicyException {
final JsonWebToken jwt = jwtAuthnGrantContext.getAuthorizationGrantContext().getJWT();
final String[] audience = jwt.getAudience();
if (audience == null || audience.length != 1 || configuration == null || configuration.getAllowedAudience() == null) {
// just continue with normal processing in this situations
return;
}
if (configuration.getAllowedAudience().contains(audience[0])) {
// set the audience as validated by this executor
logger.tracef("Allowing extra audience '%s' for the jwt authorization grant request.", audience[0]);
jwtAuthnGrantContext.getAuthorizationGrantContext().setAudienceAlreadyValidated();
return;
}
// the audience is not the ones defined in this executor, throw error
logger.tracef("Rejecting invalid audience '%s' for the jwt authorization grant request.", audience[0]);
throw new ClientPolicyException(OAuthErrorException.INVALID_GRANT, "Invalid token audience");
}
}

View file

@ -0,0 +1,80 @@
/*
* Copyright 2026 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;
import org.keycloak.provider.ProviderConfigurationBuilder;
/**
* <p>Factory that allows to configure different audiences as valid for the JWT Authorization Grant.
* This behavior breaks the specification and can have security implications.</p>
*
* @author rmartinc
*/
public class JWTAuthorizationGrantAudienceExecutorFactory implements ClientPolicyExecutorProviderFactory {
public static final String PROVIDER_ID = "jwt-authorization-grant-audience";
public static final String ALLOWED_AUDIENCE = "allowed-audience";
@Override
public ClientPolicyExecutorProvider create(KeycloakSession session) {
return new JWTAuthorizationGrantAudienceExecutor(session);
}
@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 """
Executor that configures new and exclusive valid audiences for the JWT Authorization Grant type.
The default audiences valid for the grant are not valid anymore.
Note this behavior breaks the standard and can have major security implications.
""";
}
@Override
public List<ProviderConfigProperty> getConfigProperties() {
return ProviderConfigurationBuilder.create()
.property()
.name(ALLOWED_AUDIENCE)
.type(ProviderConfigProperty.MULTIVALUED_STRING_TYPE)
.label("Allowed audience")
.helpText("List of new and exclusive valid audiences for the JWT Authhorization Grant")
.add()
.build();
}
}

View file

@ -32,3 +32,4 @@ org.keycloak.services.clientpolicy.executor.AuthenticationFlowSelectorExecutorFa
org.keycloak.services.clientpolicy.executor.SecureClientAuthenticationAssertionExecutorFactory org.keycloak.services.clientpolicy.executor.SecureClientAuthenticationAssertionExecutorFactory
org.keycloak.services.clientpolicy.executor.DownscopeAssertionGrantEnforcerExecutorFactory org.keycloak.services.clientpolicy.executor.DownscopeAssertionGrantEnforcerExecutorFactory
org.keycloak.services.clientpolicy.executor.JWTClaimEnforcerExecutorFactory org.keycloak.services.clientpolicy.executor.JWTClaimEnforcerExecutorFactory
org.keycloak.services.clientpolicy.executor.JWTAuthorizationGrantAudienceExecutorFactory

View file

@ -0,0 +1,147 @@
/*
* Copyright 2026 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.tests.oauth;
import java.util.Set;
import org.keycloak.broker.oidc.OIDCIdentityProviderConfig;
import org.keycloak.representations.JsonWebToken;
import org.keycloak.services.clientpolicy.condition.IdentityProviderConditionFactory;
import org.keycloak.services.clientpolicy.executor.JWTAuthorizationGrantAudienceExecutor;
import org.keycloak.services.clientpolicy.executor.JWTAuthorizationGrantAudienceExecutorFactory;
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;
/**
*
* @author rmartinc
*/
@KeycloakIntegrationTest(config = JWTAuthorizationGrantTest.JWTAuthorizationGrantServerConfig.class)
public class JWTAuthorizationGrantAudienceClientPoliciesTest extends BaseAbstractJWTAuthorizationGrantTest {
@InjectRealm(config = JWTAuthorizationGrantAudienceClientPoliciesTest.JWTAuthorizationGranthRealmConfig.class)
protected ManagedRealm realm;
@Test
public void testAudiences() {
// test normal issuer audience is not valid
String jwt = identityProvider.encodeToken(createDefaultAuthorizationGrantToken());
AccessTokenResponse response = oAuthClient.jwtAuthorizationGrantRequest(jwt).send();
assertFailurePolicy("invalid_grant", "Invalid token audience", response, events.poll());
// test allowed-aud1 is valid
jwt = identityProvider.encodeToken(createAuthorizationGrantToken("basic-user-id", "allowed-aud1", IDP_ISSUER));
response = oAuthClient.jwtAuthorizationGrantRequest(jwt).send();
assertSuccess("test-app", response);
// test allowed-aud2 is valid
jwt = identityProvider.encodeToken(createAuthorizationGrantToken("basic-user-id", "allowed-aud2", IDP_ISSUER));
response = oAuthClient.jwtAuthorizationGrantRequest(jwt).send();
assertSuccess("test-app", response);
// test any other audience is wrong
jwt = identityProvider.encodeToken(createAuthorizationGrantToken("basic-user-id", "other-aud", IDP_ISSUER));
response = oAuthClient.jwtAuthorizationGrantRequest(jwt).send();
assertFailurePolicy("invalid_grant", "Invalid token audience", response, events.poll());
// test client-id audience is wrong
jwt = getIdentityProvider().encodeToken(createAuthorizationGrantToken("basic-user-id", "test-client", IDP_ISSUER));
response = oAuthClient.jwtAuthorizationGrantRequest(jwt).send();
assertFailurePolicy("invalid_grant", "Invalid token audience", response, events.poll());
// test two audiences are always wrong
JsonWebToken jwtToken = createDefaultAuthorizationGrantToken();
jwtToken.addAudience("allowed-aud2");
jwt = getIdentityProvider().encodeToken(jwtToken);
response = oAuthClient.jwtAuthorizationGrantRequest(jwt).send();
assertFailure("Multiple audiences not allowed", response, events.poll());
}
@Test
public void testAudiencesWithClientId() {
// update to use client-id
realm.updateIdentityProviderWithCleanup(IDP_ALIAS, rep -> {
rep.getConfig().put(OIDCIdentityProviderConfig.ALLOW_CLIENT_ID_AS_AUDIENCE, Boolean.TRUE.toString());
});
// test normal client-id is not working anymore
String jwt = getIdentityProvider().encodeToken(createAuthorizationGrantToken("basic-user-id", "test-client", IDP_ISSUER));
AccessTokenResponse response = oAuthClient.jwtAuthorizationGrantRequest(jwt).send();
assertFailurePolicy("invalid_grant", "Invalid token audience", response, events.poll());
// test allowed-aud1 is valid
jwt = getIdentityProvider().encodeToken(createAuthorizationGrantToken("basic-user-id", "allowed-aud1", IDP_ISSUER));
response = oAuthClient.jwtAuthorizationGrantRequest(jwt).send();
assertSuccess("test-app", response);
// test allowed-aud2 is valid
jwt = identityProvider.encodeToken(createAuthorizationGrantToken("basic-user-id", "allowed-aud2", IDP_ISSUER));
response = oAuthClient.jwtAuthorizationGrantRequest(jwt).send();
assertSuccess("test-app", response);
// test any other audience is wrong
jwt = identityProvider.encodeToken(createAuthorizationGrantToken("basic-user-id", "other-aud", IDP_ISSUER));
response = oAuthClient.jwtAuthorizationGrantRequest(jwt).send();
assertFailurePolicy("invalid_grant", "Invalid token audience", response, events.poll());
// test issuer audience is wrong
jwt = getIdentityProvider().encodeToken(createDefaultAuthorizationGrantToken());
response = oAuthClient.jwtAuthorizationGrantRequest(jwt).send();
assertFailurePolicy("invalid_grant", "Invalid token audience", response, events.poll());
// test two audiences are always wrong
JsonWebToken jwtToken = createAuthorizationGrantToken("basic-user-id", "test-client", IDP_ISSUER);
jwtToken.addAudience("allowed-aud2");
jwt = getIdentityProvider().encodeToken(jwtToken);
response = oAuthClient.jwtAuthorizationGrantRequest(jwt).send();
assertFailure("Multiple audiences not allowed", response, events.poll());
}
public static class JWTAuthorizationGranthRealmConfig extends OIDCIdentityProviderJWTAuthorizationGrantTest.JWTAuthorizationGrantRealmConfig {
@Override
public RealmConfigBuilder configure(RealmConfigBuilder realm) {
super.configure(realm);
JWTAuthorizationGrantAudienceExecutor.Configuration config =
new JWTAuthorizationGrantAudienceExecutor.Configuration();
config.setAllowedAudience(Set.of("allowed-aud1", "allowed-aud2"));
realm.clientProfile(ClientProfileBuilder.create()
.name("executor")
.description("executor description")
.executor(JWTAuthorizationGrantAudienceExecutorFactory.PROVIDER_ID, config)
.build());
realm.clientPolicy(ClientPolicyBuilder.create()
.name("policy")
.description("description of policy")
.condition(IdentityProviderConditionFactory.PROVIDER_ID, ClientPolicyBuilder
.identityProviderConditionConfiguration(false, IDP_ALIAS))
.profile("executor")
.build());
return realm;
}
}
}