diff --git a/docs/documentation/server_admin/topics/clients/client-policies.adoc b/docs/documentation/server_admin/topics/clients/client-policies.adoc index 2ea0807aef1..ca6b6ec20b7 100644 --- a/docs/documentation/server_admin/topics/clients/client-policies.adoc +++ b/docs/documentation/server_admin/topics/clients/client-policies.adoc @@ -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 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. +* 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>>. diff --git a/docs/guides/securing-apps/jwt-authorization-grant.adoc b/docs/guides/securing-apps/jwt-authorization-grant.adoc index a0f370c350d..bf2a92ac0dc 100644 --- a/docs/guides/securing-apps/jwt-authorization-grant.adoc +++ b/docs/guides/securing-apps/jwt-authorization-grant.adoc @@ -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. + 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. + diff --git a/server-spi-private/src/main/java/org/keycloak/protocol/oidc/JWTAuthorizationGrantValidationContext.java b/server-spi-private/src/main/java/org/keycloak/protocol/oidc/JWTAuthorizationGrantValidationContext.java index de3e7ff45c8..e985f4594dd 100644 --- a/server-spi-private/src/main/java/org/keycloak/protocol/oidc/JWTAuthorizationGrantValidationContext.java +++ b/server-spi-private/src/main/java/org/keycloak/protocol/oidc/JWTAuthorizationGrantValidationContext.java @@ -20,6 +20,10 @@ public interface JWTAuthorizationGrantValidationContext { void setRestrictedScopes(Set restrictedScopes); + void setAudienceAlreadyValidated(); + + boolean isAudienceAlreadyValidated(); + default String getIssuer() { return getJWT().getIssuer(); } diff --git a/services/src/main/java/org/keycloak/protocol/oidc/grants/JWTAuthorizationGrantType.java b/services/src/main/java/org/keycloak/protocol/oidc/grants/JWTAuthorizationGrantType.java index 441e59460ae..b6fa5cd3e93 100644 --- a/services/src/main/java/org/keycloak/protocol/oidc/grants/JWTAuthorizationGrantType.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/grants/JWTAuthorizationGrantType.java @@ -92,9 +92,6 @@ public class JWTAuthorizationGrantType extends OAuth2GrantTypeBase { // assign the signature alg and validate authorizationGrantContext.validateSignatureAlgorithm(jwtAuthorizationGrantProvider.getAssertionSignatureAlg()); - // Validate audience - authorizationGrantContext.validateTokenAudience(jwtAuthorizationGrantProvider.getAllowedAudienceForJWTGrant(), false); - //validate the JWT assertion and get the brokered identity from the idp BrokeredIdentityContext brokeredIdentityContext = jwtAuthorizationGrantProvider.validateAuthorizationGrantAssertion(authorizationGrantContext); if (brokeredIdentityContext == null) { @@ -122,6 +119,11 @@ public class JWTAuthorizationGrantType extends OAuth2GrantTypeBase { 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); AuthenticationSessionModel authSession = createSessionModel(rootAuthSession, user, client, scopeParam); UserSessionModel userSession = new UserSessionManager(session).createUserSession(authSession.getParentSession().getId(), realm, user, user.getUsername(), diff --git a/services/src/main/java/org/keycloak/protocol/oidc/grants/JWTAuthorizationGrantValidator.java b/services/src/main/java/org/keycloak/protocol/oidc/grants/JWTAuthorizationGrantValidator.java index e040d03fb41..65c645eb89b 100644 --- a/services/src/main/java/org/keycloak/protocol/oidc/grants/JWTAuthorizationGrantValidator.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/grants/JWTAuthorizationGrantValidator.java @@ -43,6 +43,7 @@ public class JWTAuthorizationGrantValidator extends AbstractBaseJWTValidator imp private final String scope; private Set restrictedScopes; + private boolean audienceAlreadyValidated; public static JWTAuthorizationGrantValidator createValidator(KeycloakSession session, ClientModel client, String assertion, String scope) { if (assertion == null) { @@ -62,6 +63,7 @@ public class JWTAuthorizationGrantValidator extends AbstractBaseJWTValidator imp private JWTAuthorizationGrantValidator(KeycloakSession session, String scope, ClientAssertionState clientAssertionState) { super(session, clientAssertionState); this.scope = scope; + this.audienceAlreadyValidated = false; } public void validateClient() { @@ -102,14 +104,26 @@ public class JWTAuthorizationGrantValidator extends AbstractBaseJWTValidator imp return scope; } + @Override public Set getRestrictedScopes() { return restrictedScopes; } + @Override public void setRestrictedScopes(Set restrictedScopes) { this.restrictedScopes = restrictedScopes; } + @Override + public void setAudienceAlreadyValidated() { + this.audienceAlreadyValidated = true; + } + + @Override + public boolean isAudienceAlreadyValidated() { + return audienceAlreadyValidated; + } + @Override protected void failureCallback(String errorDescription) { throw new RuntimeException(errorDescription); diff --git a/services/src/main/java/org/keycloak/services/clientpolicy/executor/JWTAuthorizationGrantAudienceExecutor.java b/services/src/main/java/org/keycloak/services/clientpolicy/executor/JWTAuthorizationGrantAudienceExecutor.java new file mode 100644 index 00000000000..60676e9d78d --- /dev/null +++ b/services/src/main/java/org/keycloak/services/clientpolicy/executor/JWTAuthorizationGrantAudienceExecutor.java @@ -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 { + + 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 allowedAudience; + + public Set getAllowedAudience() { + return allowedAudience; + } + + public void setAllowedAudience(Set 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 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"); + } +} diff --git a/services/src/main/java/org/keycloak/services/clientpolicy/executor/JWTAuthorizationGrantAudienceExecutorFactory.java b/services/src/main/java/org/keycloak/services/clientpolicy/executor/JWTAuthorizationGrantAudienceExecutorFactory.java new file mode 100644 index 00000000000..fba457e7439 --- /dev/null +++ b/services/src/main/java/org/keycloak/services/clientpolicy/executor/JWTAuthorizationGrantAudienceExecutorFactory.java @@ -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; + +/** + *

Factory that allows to configure different audiences as valid for the JWT Authorization Grant. + * This behavior breaks the specification and can have security implications.

+ * + * @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 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(); + } +} 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 7cdcf69defb..3c324bf1906 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 @@ -32,3 +32,4 @@ org.keycloak.services.clientpolicy.executor.AuthenticationFlowSelectorExecutorFa org.keycloak.services.clientpolicy.executor.SecureClientAuthenticationAssertionExecutorFactory org.keycloak.services.clientpolicy.executor.DownscopeAssertionGrantEnforcerExecutorFactory org.keycloak.services.clientpolicy.executor.JWTClaimEnforcerExecutorFactory +org.keycloak.services.clientpolicy.executor.JWTAuthorizationGrantAudienceExecutorFactory diff --git a/tests/base/src/test/java/org/keycloak/tests/oauth/JWTAuthorizationGrantAudienceClientPoliciesTest.java b/tests/base/src/test/java/org/keycloak/tests/oauth/JWTAuthorizationGrantAudienceClientPoliciesTest.java new file mode 100644 index 00000000000..8e97507e098 --- /dev/null +++ b/tests/base/src/test/java/org/keycloak/tests/oauth/JWTAuthorizationGrantAudienceClientPoliciesTest.java @@ -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; + } + } +}