8000 Add a client policy executor for the JWT authorization grant settings by mahdi-al-hakim · Pull Request #51720 · keycloak/keycloak · GitHub
[go: up one dir, main page]

Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,7 @@ One of several purposes for this executor is to realize the security requirement
* 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 that only configured protocol mapper types can be created or updated on clients through the dedicated Admin REST API protocol mapper endpoints using the `allowed-protocol-mappers` executor. Existing mapper types that are not configured can only be preserved with an unchanged type and configuration.
* Enforce that the JWT Authorization Grant settings of a client, its allowed identity providers and its custom audience mapping, are not set or changed by the request. Those settings decide which assertions the grant accepts for the client. Combined with the `client-updater-context` condition the restriction can be limited to requests the client makes itself, leaving administrators free to configure the client. A request that repeats the value the client already has is allowed, so existing configuration is preserved.
* Add default **Certificate Authority subject DN** for the client registration service when TLS client authentication (`tls_client_auth`) is configured (see *Using the client registration service* in the link:{securing_apps_link}[securing apps] section for more information). The `tls-client-auth-ca-subject-dn` executor can be used to provide the new compulsory attribute in client registration interface. It can also enforce the configured CA name to reject unwanted values.

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>>.
Expand Down
9 changes: 8 additions & 1 deletion docs/guides/securing-apps/jwt-authorization-grant.adoc
Diff line change
Original file line number Diff line number
Expand Up @@ -50,7 +50,7 @@ Only confidential clients can request a JWT authorization grant. In order to all
.Client configuration for JWT Authorization Grant
image::jwt-authorization-grant-client-config.png[Client configuration for JWT Authorization Grant]

NOTE: In the **Advanced** tab, section **OpenID Connect Compatibility Modes**, the configuration option **Custom audience mapping** allows to configure specific custom valid audiences for individual Identity Providers. The map key is the Identity Provider alias, and the value is the custom audience that will be accepted for that provider. Note this behavior is not covered by the standard and can have major security implications.
NOTE: In the **Advanced** tab, section **OpenID Connect Compatibility Modes**, the configuration option **Custom audience mapping** allows to configure specific custom valid audiences for individual Identity Providers. The map key is the Identity Provider alias, and the value is the custom audience that will be accepted for that provider. Note this behavior is not covered by the standard and can have major security implications. In deployments where client administrators should not be allowed to edit this option, see the **jwt-authorization-grant-settings-disabled** executor below.

The Identity Provider (both types commented in the introduction) needs to also be configured to establish the relationship that will validate the assertion. In **Identity providers** -> select your OIDC or JWT provider -> **Settings** tab -> **Authorization Grant Settings** section.

Expand Down Expand Up @@ -157,6 +157,13 @@ 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-settings-disabled**. This executor rejects any request that sets or changes the JWT Authorization Grant settings of a client, which are the **Allowed Identity Providers for JWT Authorization Grant** and the **Custom audience mapping** options. Those options decide which assertions the grant accepts for the client, so the executor is intended for deployments where client administrators should not be allowed to edit them. It applies to client creation and update, so it covers both the admin REST API and dynamic client registration.
+
Enabling the grant on a client that already exists is rejected as well, because the allow lists configured earlier are still on the client and the flag on its own would be enough to make the grant work again. Turning the grant off stays permitted.
+
Only a change is rejected. A request that carries the value a client already has is permitted, so applying the executor preserves the existing configuration of a client and leaves the rest of the client editable.
+
The executor applies to whoever the policy matches. To allow administrators to keep configuring these settings while refusing the clients themselves, combine it with the **client-updater-context** condition and select the `ByRegistrationAccessToken`, `ByInitialAccessToken` and `ByAnonymous` sources.

== JWT Authorization Grant for Google Identity Provider

Expand Down
8000 < 8000 td class="blob-code blob-code-addition js-file-line"> "Not permitted to set, change or remove the client attribute " + attribute);
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
/*
* 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.Map;
import java.util.Objects;
import java.util.Set;

import org.keycloak.events.Errors;
import org.keycloak.models.ClientModel;
import org.keycloak.protocol.oidc.OIDCConfigAttributes;
import org.keycloak.representations.idm.ClientPolicyExecutorConfigurationRepresentation;
import org.keycloak.representations.idm.ClientRepresentation;
import org.keycloak.services.clientpolicy.ClientPolicyContext;
import org.keycloak.services.clientpolicy.ClientPolicyException;
import org.keycloak.services.clientpolicy.context.ClientCRUDContext;

/**
* Check that the JWT authorization grant settings of a client are not set or changed by the request.
*
* <p>The identity provider allow list and the token audience allow list decide which assertions the
* JWT authorization grant accepts for a client, so they are trust decisions rather than ordinary
* client metadata. This executor lets a deployment keep those decisions away from whoever the policy
* applies to. Combined with the <code>client-updater-context</code> condition it can be limited to
* requests that the client itself makes, leaving administrators free to configure the client.</p>
*
* <p>Only a change is rejected. A request that carries the value the client already has is allowed,
* so an existing configuration is preserved and a read-modify-write of the whole client keeps
* working.</p>
*
* @author <a href="mailto:mahdi.a.alhakim@gmail.com">Mahdi Alhakim</a>
*/
public class JWTAuthorizationGrantSettingsDisabledExecutor implements ClientPolicyExecutorProvider<ClientPolicyExecutorConfigurationRepresentation> {

private static final Set<String> PROTECTED_ATTRIBUTES = Set.of(
OIDCConfigAttributes.JWT_AUTHORIZATION_GRANT_IDP,
OIDCConfigAttributes.JWT_AUTHORIZATION_GRANT_AUDIENCE);

@Override
public String getProviderId() {
return JWTAuthorizationGrantSettingsDisabledExecutorFactory.PROVIDER_ID;
}

@Override
public void executeOnEvent(ClientPolicyContext context) throws ClientPolicyException {
switch (context.getEvent()) {
case REGISTER:
case UPDATE:
ClientCRUDContext clientContext = (ClientCRUDContext) context;
validate(clientContext.getProposedClientRepresentation(), clientContext.getTargetClient());
break;
default:
return;
}
}

private void validate(ClientRepresentation proposedClient, ClientModel existingClient) throws ClientPolicyException {
if (proposedClient == null) {
return;
}

Map<String, String> attributes = proposedClient.getAttributes();
if (attributes == null) {
return;
}

for (String attribute : PROTECTED_ATTRIBUTES) {
if (!attributes.containsKey(attribute)) {
continue;
}

String proposed = attributes.get(attribute);
// On REGISTER there is no existing client, so any value is a new setting.
String current = existingClient == null ? null : existingClient.getAttribute(attribute);

// A value that storage treats as undefined and an absent attribute are the same thing, so
// clearing an attribute that is not set is not a change. The test has to match storage,
// which treats a value as undefined with String.trim (see ClientAdapter.setAttribute).
// Character.isWhitespace, used by ObjectUtil.isBlank, instead counts characters such as the
// em space U+2003 as blank, and trim keeps those, so reading such a value as a clear here
// would let storage keep it and set the attribute this executor is meant to lock.
if (isUndefined(proposed) && isUndefined(current)) {
continue;
}

// Deliberately an exact comparison of the stored form. Both attributes are serialized,
// the identity provider list with a separator and the audience map as JSON, so a
// re-serialization that means the same thing is still rejected. That errs towards
// refusing a request rather than letting a change through.
if (!Objects.equals(proposed, current)) {
throw new ClientPolicyException(Errors.INVALID_REGISTRATION,
}
}

validateGrantNotEnabled(attributes, existingClient);
}

/**
* The enabled flag is not one of the protected attributes, because {@code DescriptionConverter}
* writes it from the standard {@code grant_types} field of an OpenID Connect registration
* request, so rejecting every write of it would reject a spec compliant registration. Turning it
* on for a client that already exists is rejected all the same: the allow lists an administrator
* configured earlier are still on the client, so the flag on its own is enough to make the grant
* work again. Turning it off stays allowed.
*/
private void validateGrantNotEnabled(Map<String, String> attributes, ClientModel existingClient) throws ClientPolicyException {
if (existingClient == null) {
// A REGISTER that gets this far carries no allow list, the loop above rejects one,
// so the flag on its own grants nothing.
return;
}

String attribute = OIDCConfigAttributes.JWT_AUTHORIZATION_GRANT_ENABLED;
if (!Boolean.parseBoolean(attributes.get(attribute))) {
// Absent, or the grant is being turned off.
return;
}

if (Boolean.parseBoolean(existingClient.getAttribute(attribute))) {
// Already enabled, so this is not a change.
return;
}

throw new ClientPolicyException(Errors.INVALID_REGISTRATION,
"Not permitted to enable the JWT authorization grant on an existing client, attribute " + attribute);
}

/**
* Whether storage keeps the value, matching {@code ClientAdapter.setAttribute}. A value is kept
* unless it is null or empty once {@link String#trim()} has run, so this is deliberately not
* {@code ObjectUtil.isBlank}: that treats whitespace such as the em space U+2003 as blank while
* {@code trim} does not strip it, which would let such a value through the check and into storage.
*/
private static boolean isUndefined(String value) {
return value == null || value.trim().isEmpty();
}
}
CA6B
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
/*
* 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.Collections;
import java.util.List;

import org.keycloak.Config.Scope;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.KeycloakSessionFactory;
import org.keycloak.provider.ProviderConfigProperty;

/**
* Factory for the executor that keeps the JWT authorization grant settings of a client out of
* client requests, so only a realm administrator with the policy disabled can change them.
*
* @author <a href="mailto:mahdi.a.alhakim@gmail.com">Mahdi Alhakim</a>
*/
public class JWTAuthorizationGrantSettingsDisabledExecutorFactory implements ClientPolicyExecutorProviderFactory {

public static final String PROVIDER_ID = "jwt-authorization-grant-settings-disabled";

@Override
public ClientPolicyExecutorProvider create(KeycloakSession session) {
return new JWTAuthorizationGrantSettingsDisabledExecutor();
}

@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 """
It rejects a request that sets or changes the JWT authorization grant settings of a client, which are
the identity provider allow list and the token audience allow list. Enabling the grant on a client
that already exists is rejected as well, while turning it off stays allowed. A request that repeats
the value the client already has is permitted, so existing configuration is preserved. Combine it
with the client-updater-context condition to restrict it to requests that the client itself makes.
""";
}

@Override
public List<ProviderConfigProperty> getConfigProperties() {
return Collections.emptyList();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -39,3 +39,4 @@ org.keycloak.services.clientpolicy.executor.AllowedProtocolMappersExecutorFactor
org.keycloak.services.clientpolicy.executor.RejectMayActClaimExecutorFactory
org.keycloak.protocol.oauth2.cimd.clientpolicy.executor.ClientIdMetadataDocumentExecutorFactory
org.keycloak.services.clientpolicy.executor.SecureClientNodeExecutorFactory
org.keycloak.services.clientpolicy.executor.JWTAuthorizationGrantSettingsDisabledExecutorFactory
Loading
0