Client policy to enforce only downscoping in Token Exchange (#44030)

Closes #43931

Signed-off-by: rmartinc <rmartinc@redhat.com>
This commit is contained in:
Ricardo Martin 2025-11-12 08:48:42 +01:00 committed by GitHub
parent 281ced0ca8
commit de49500393
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 288 additions and 18 deletions

View File

@ -149,6 +149,7 @@ One of several purposes for this executor is to realize the security requirement
* Enforce that <<_refresh_token_rotation,refresh token rotation>> is skipped and there is no refresh token returned from the refresh token response
* Enforce a valid redirect URI that the OAuth 2.1 specification requires
* 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] are restricted to the ones present in the initial `subject_token`. 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.
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

@ -123,6 +123,13 @@ If a user is not permitted to use the client scope, no protocol mappers or role
== Realm default client scopes
include::proc-updating-default-scopes.adoc[]
[[_downscoping]]
== Downscoping
In OAuth/OIDC, *downscoping* is the process of exchanging an existing JWT access token for a new one with a more restricted set of permissions (scopes) and/or a narrower audience. In the OAuth 2.0 refresh token grant type, the https://datatracker.ietf.org/doc/html/rfc6749#section-6[RFC 6749] itself restricts the requested scope, saying that it must not include any scope not originally granted by the resource owner. So, this *downscoping* concept is very common and very recommended for security reasons.
{project_name} provides a <<_client_policies,client policy>> executor that ensures this *downscoping* idea for other grant types. The client executor is called `downscope-assertion-grant-enforcer` and, for the moment, applies for the link:{securing_apps_token_exchange_link}#_standard-token-exchange[Standard token exchange]. When this client executor is enforced, the token exchange is only allowed for the scopes that are already present in the initial JWT (`subject_token` parameter). An error is returned if any other extra scope is requested, no matter if the client configuration permits this scope as optional or default. Default scopes that are configured as *include in token scope* set to *false* (for example `basic` or `acr` in the default configuration) are the only exception. Those scopes are invisible for the requester and are considered compulsory for any grant type. Once this executor is applied for the client, *downscoping* is the only option when exchanging an access token, no additional scopes will ever be granted.
== Scopes explained
The term _scope_ has multiple meanings within {project_name} and across the OAuth/OIDC specifications. Below is a clarification of the different _scopes_ used in {project_name}:

View File

@ -169,6 +169,8 @@ NOTE: The `audience` parameter can be used to filter the audiences that are comi
no filtering occurs. As a result, the `audience` parameter is effectively used for "downscoping" the token to make sure that it contains only the requested audiences. However, the `scope` parameter is used
to add optional client scopes and hence it can be used for "upscoping" and adding more scopes.
NOTE: By default, token exchange can be used to request extra scopes and audiences that are not present in the initial `subject_token`. If, for security reasons, you want to ensure that scopes are limited to the ones already granted to the `subject_token`, the `downscope-assertion-grant-enforcer` policy executor can be applied to the client. This executor enforces that only downscoping is allowed for token exchange. See link:{adminguide_link}#_downscoping[Downscoping] and link:{adminguide_link}#_client_policies[Client Policies] chapters in the {adminguide_name} for more information.
==== Examples
Here are some examples to better illustrate the behavior for scopes and audiences.

View File

@ -30,6 +30,7 @@ import jakarta.ws.rs.core.MultivaluedMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* Token exchange context
@ -53,6 +54,7 @@ public class TokenExchangeContext {
private final Map<String, String> clientAuthAttributes;
private final Params params = new Params();
private Set<String> restrictedScopes;
// Reason why the particular tokenExchange provider cannot be supported
private String unsupportedReason;
@ -123,6 +125,14 @@ public class TokenExchangeContext {
return params;
}
public Set<String> getRestrictedScopes() {
return restrictedScopes;
}
public void setRestrictedScopes(Set<String> restrictedScopes) {
this.restrictedScopes = restrictedScopes;
}
public String getUnsupportedReason() {
return unsupportedReason;
}

View File

@ -529,11 +529,16 @@ public class TokenManager {
}
public static ClientSessionContext attachAuthenticationSession(KeycloakSession session, UserSessionModel userSession, AuthenticationSessionModel authSession) {
return attachAuthenticationSession(session, userSession, authSession, false);
return attachAuthenticationSession(session, userSession, authSession, null, false);
}
public static ClientSessionContext attachAuthenticationSession(KeycloakSession session, UserSessionModel userSession,
AuthenticationSessionModel authSession, boolean createTransientIfMissing) {
return attachAuthenticationSession(session, userSession, authSession, null, createTransientIfMissing);
}
public static ClientSessionContext attachAuthenticationSession(KeycloakSession session, UserSessionModel userSession,
AuthenticationSessionModel authSession, Set<String> restrictedScopes, boolean createTransientIfMissing) {
ClientModel client = authSession.getClient();
AuthenticatedClientSessionModel clientSession = userSession.getAuthenticatedClientSessionByClient(client.getId());
@ -579,7 +584,7 @@ public class TokenManager {
// Remove authentication session now (just current tab, not whole "rootAuthenticationSession" in case we have more browser tabs with "authentications in progress")
new AuthenticationSessionManager(session).updateAuthenticationSessionAfterSuccessfulAuthentication(realm, authSession);
return DefaultClientSessionContext.fromClientSessionAndClientScopes(clientSession, clientScopes, session);
return DefaultClientSessionContext.fromClientSessionAndClientScopes(clientSession, clientScopes, restrictedScopes, session);
}

View File

@ -229,7 +229,7 @@ public class StandardTokenExchangeProvider extends AbstractTokenExchangeProvider
try {
ClientSessionContext clientSessionCtx = TokenManager.attachAuthenticationSession(this.session, targetUserSession, authSession,
!OAuth2Constants.REFRESH_TOKEN_TYPE.equals(requestedTokenType)); // create transient session if needed except for refresh
context.getRestrictedScopes(), !OAuth2Constants.REFRESH_TOKEN_TYPE.equals(requestedTokenType)); // create transient session if needed except for refresh
if (requestedTokenType.equals(OAuth2Constants.REFRESH_TOKEN_TYPE)
&& clientSessionCtx.getClientScopesStream().filter(s -> OAuth2Constants.OFFLINE_ACCESS.equals(s.getName())).findAny().isPresent()) {

View File

@ -17,7 +17,7 @@
package org.keycloak.services.clientpolicy.condition;
import com.fasterxml.jackson.annotation.JsonAlias;
import com.fasterxml.jackson.annotation.JsonProperty;
import org.jboss.logging.Logger;
import org.keycloak.OAuth2Constants;
import org.keycloak.models.KeycloakSession;
@ -48,7 +48,7 @@ public class GrantTypeCondition extends AbstractClientPolicyConditionProvider<Gr
public static class Configuration extends ClientPolicyConditionConfigurationRepresentation {
@JsonAlias("grant_types")
@JsonProperty("grant_types")
protected List<String> grantTypes;
public List<String> getGrantTypes() {

View File

@ -0,0 +1,103 @@
/*
* 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.Collections;
import java.util.Set;
import java.util.function.Predicate;
import java.util.stream.Collectors;
import org.keycloak.OAuth2Constants;
import org.keycloak.OAuthErrorException;
import org.keycloak.jose.jws.JWSInput;
import org.keycloak.jose.jws.JWSInputException;
import org.keycloak.models.ClientModel;
import org.keycloak.models.ClientScopeModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.protocol.oidc.TokenExchangeContext;
import org.keycloak.protocol.oidc.TokenManager;
import org.keycloak.representations.AccessToken;
import org.keycloak.services.clientpolicy.ClientPolicyContext;
import org.keycloak.services.clientpolicy.ClientPolicyException;
import org.keycloak.services.clientpolicy.context.TokenExchangeRequestContext;
/**
*
* @author rmartinc
*/
public class DownscopeAssertionGrantEnforcerExecutor implements ClientPolicyExecutorProvider {
private final KeycloakSession session;
public DownscopeAssertionGrantEnforcerExecutor(KeycloakSession session) {
this.session = session;
}
@Override
public String getProviderId() {
return DownscopeAssertionGrantEnforcerExecutorFactory.PROVIDER_ID;
}
@Override
public void executeOnEvent(ClientPolicyContext context) throws ClientPolicyException {
switch (context.getEvent()) {
case TOKEN_EXCHANGE_REQUEST -> {
TokenExchangeContext tokenExchangeContext = ((TokenExchangeRequestContext) context).getTokenExchangeContext();
Set<String> restrictedScopes = checkDownscope(tokenExchangeContext.getClient(),
getAccessTokenFromSubjectToken(tokenExchangeContext),
tokenExchangeContext.getParams().getScope());
tokenExchangeContext.setRestrictedScopes(restrictedScopes);
}
}
}
private AccessToken getAccessTokenFromSubjectToken(TokenExchangeContext context) throws ClientPolicyException {
if (!OAuth2Constants.ACCESS_TOKEN_TYPE.equals(context.getParams().getSubjectTokenType())) {
throw new ClientPolicyException(OAuthErrorException.INVALID_REQUEST, "Parameter 'subject_token' should be access_token for the executor");
}
try {
return new JWSInput(context.getParams().getSubjectToken())
.readJsonContent(AccessToken.class);
} catch (JWSInputException e) {
throw new ClientPolicyException(OAuthErrorException.INVALID_REQUEST, "Parameter 'subject_token' contains an invalid access token");
}
}
private Set<String> checkDownscope(ClientModel client, AccessToken token, String scopeParam) throws ClientPolicyException {
Set<String> tokenScopes = token.getScope() != null
? TokenManager.parseScopeParameter(token.getScope()).collect(Collectors.toSet())
: Collections.emptySet();
if (scopeParam != null) {
// the user requested specific scopes, check they are allowed
Set<String> requestedScopes = TokenManager.parseScopeParameter(scopeParam).collect(Collectors.toSet());
// check all requested scopes are inside the token
requestedScopes.removeAll(tokenScopes);
if (!requestedScopes.isEmpty()) {
throw new ClientPolicyException(OAuthErrorException.INVALID_SCOPE,
String.format("Scopes %s not present in the initial access token %s", requestedScopes, tokenScopes));
}
}
// always add as allowed restricted scopes the ones that are default and not included in token
Set<String> restrictedScopes = client.getClientScopes(true).values().stream()
.filter(Predicate.not(ClientScopeModel::isIncludeInTokenScope))
.map(ClientScopeModel::getName)
.collect(Collectors.toSet());
restrictedScopes.addAll(tokenScopes);
return restrictedScopes;
}
}

View File

@ -0,0 +1,73 @@
/*
* 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.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;
/**
* <p>Factory that enforces the grant to only use scopes that are already present in
* the initial assertion. For the moment it can only be used in the Token Exchange
* context.</p>
*
* @author rmartinc
*/
public class DownscopeAssertionGrantEnforcerExecutorFactory implements ClientPolicyExecutorProviderFactory {
public static final String PROVIDER_ID = "downscope-assertion-grant-enforcer";
@Override
public ClientPolicyExecutorProvider create(KeycloakSession session) {
return new DownscopeAssertionGrantEnforcerExecutor(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 """
It ensures that the scopes in the final access token are limited to the ones already present in the JWT assertion passed.
For the moment, the executor applies to certain grants where some initial token/assertion is passed (for example
subject_token in case of Standard Token Exchange grant). The limitation is done over the scopes that are
present in the initial token/assertion, returning an error if any extra scope is requested.
""";
}
@Override
public List<ProviderConfigProperty> getConfigProperties() {
return Collections.emptyList();
}
}

View File

@ -73,8 +73,11 @@ public class DefaultClientSessionContext implements ClientSessionContext {
private Set<String> clientScopeIds;
private String scopeString;
private DefaultClientSessionContext(AuthenticatedClientSessionModel clientSession, Set<ClientScopeModel> requestedScopes, KeycloakSession session) {
private final Set<String> restrictedScopes;
private DefaultClientSessionContext(AuthenticatedClientSessionModel clientSession, Set<ClientScopeModel> requestedScopes, Set<String> restrictedScopes, KeycloakSession session) {
this.requestedScopes = requestedScopes;
this.restrictedScopes = restrictedScopes;
this.clientSession = clientSession;
this.session = session;
this.session.setAttribute(ClientSessionContext.class.getName(), this);
@ -97,12 +100,13 @@ public class DefaultClientSessionContext implements ClientSessionContext {
} else {
requestedScopes = TokenManager.getRequestedClientScopes(session, scopeParam, clientSession.getClient(), clientSession.getUserSession().getUser());
}
return new DefaultClientSessionContext(clientSession, requestedScopes.collect(Collectors.toSet()), session);
return new DefaultClientSessionContext(clientSession, requestedScopes.collect(Collectors.toSet()), null, session);
}
public static DefaultClientSessionContext fromClientSessionAndClientScopes(AuthenticatedClientSessionModel clientSession, Set<ClientScopeModel> requestedScopes, KeycloakSession session) {
return new DefaultClientSessionContext(clientSession, requestedScopes, session);
public static DefaultClientSessionContext fromClientSessionAndClientScopes(AuthenticatedClientSessionModel clientSession,
Set<ClientScopeModel> requestedScopes, Set<String> restrictedScopes, KeycloakSession session) {
return new DefaultClientSessionContext(clientSession, requestedScopes, restrictedScopes, session);
}
@Override
@ -245,16 +249,20 @@ public class DefaultClientSessionContext implements ClientSessionContext {
// Loading data
private boolean isAllowed(ClientScopeModel clientScope) {
if (isClientScopePermittedForUser(clientScope)) {
return true;
if (restrictedScopes != null && !restrictedScopes.contains(clientScope.getName())) {
logger.tracef("Client scope '%s' is not among the restricted scopes list and will not be processed", clientScope.getName());
return false;
}
if (logger.isTraceEnabled()) {
logger.tracef("User '%s' not permitted to have client scope '%s'",
clientSession.getUserSession().getUser().getUsername(), clientScope.getName());
if (!isClientScopePermittedForUser(clientScope)) {
if (logger.isTraceEnabled()) {
logger.tracef("User '%s' not permitted to have client scope '%s'",
clientSession.getUserSession().getUser().getUsername(), clientScope.getName());
}
return false;
}
return false;
return true;
}
// Return true if clientScope can be used by the user.

View File

@ -30,3 +30,4 @@ org.keycloak.services.clientpolicy.executor.SamlAvoidRedirectBindingExecutorFact
org.keycloak.services.clientpolicy.executor.SamlSignatureEnforcerExecutorFactory
org.keycloak.services.clientpolicy.executor.AuthenticationFlowSelectorExecutorFactory
org.keycloak.services.clientpolicy.executor.SecureClientAuthenticationAssertionExecutorFactory
org.keycloak.services.clientpolicy.executor.DownscopeAssertionGrantEnforcerExecutorFactory

View File

@ -58,6 +58,7 @@ import org.keycloak.representations.oidc.TokenMetadataRepresentation;
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.testsuite.AssertEvents;
import org.keycloak.testsuite.admin.ApiUtil;
import org.keycloak.testsuite.arquillian.annotation.UncaughtServerErrorExpected;
@ -1046,6 +1047,65 @@ public class StandardTokenExchangeV2Test extends AbstractClientPoliciesTest {
assertEquals("Exception thrown intentionally", response.getErrorDescription());
}
@Test
public void testDownscopeClientPolicies() throws Exception {
String json = (new ClientPoliciesUtil.ClientProfilesBuilder()).addProfile((new ClientPoliciesUtil.ClientProfileBuilder()).createProfile(PROFILE_NAME, "Profile")
.addExecutor(DownscopeAssertionGrantEnforcerExecutorFactory.PROVIDER_ID, null)
.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);
// request initial token with optional scope optional-scope2
final UserRepresentation john = ApiUtil.findUserByUsername(adminClient.realm(TEST), "john");
String accessToken = resourceOwnerLogin("john", "password", "subject-client", "secret", "optional-scope2").getAccessToken();
AccessToken token = TokenVerifier.create(accessToken, AccessToken.class).parse().getToken();
assertScopes(token, List.of("email", "profile", "optional-scope2"));
// request with the all the scopes allowed in the initial token, all are optional in requester-client
// only those should be there, even default-scope1 is supressed
oauth.scope("email profile optional-scope2");
AccessTokenResponse response = tokenExchange(accessToken, "requester-client", "secret", null, null);
assertAudiencesAndScopes(response, john, List.of("target-client2"), List.of("email", "profile", "optional-scope2"));
// exchange with downscope to only optional-scope2
oauth.scope("optional-scope2");
response = tokenExchange(accessToken, "requester-client", "secret", null, null);
assertAudiencesAndScopes(response, john, List.of("target-client2"), List.of("optional-scope2"));
// exchange for a invisible scope returns error although it is added by default
oauth.scope("basic optional-scope2");
response = tokenExchange(accessToken, "requester-client", "secret", null, null);
assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), response.getStatusCode());
assertEquals(OAuthErrorException.INVALID_SCOPE, response.getError());
assertEquals("Scopes [basic] not present in the initial access token [optional-scope2, profile, email]",
response.getErrorDescription());
// exchange for another optional that is not in the token
oauth.scope("optional-requester-scope");
response = tokenExchange(accessToken, "requester-client", "secret", null, null);
assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), response.getStatusCode());
assertEquals(OAuthErrorException.INVALID_SCOPE, response.getError());
assertEquals("Scopes [optional-requester-scope] not present in the initial access token [optional-scope2, profile, email]",
response.getErrorDescription());
// exchange for a optional that is not in initial token
oauth.scope("default-scope1");
response = tokenExchange(accessToken, "requester-client", "secret", null, null);
assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), response.getStatusCode());
assertEquals(OAuthErrorException.INVALID_SCOPE, response.getError());
assertEquals("Scopes [default-scope1] not present in the initial access token [optional-scope2, profile, email]",
response.getErrorDescription());
}
@Test
@UncaughtServerErrorExpected
public void testTokenRevocation() throws Exception {

View File

@ -891,7 +891,7 @@
"fullScopeAllowed" : false,
"nodeReRegistrationTimeout" : -1,
"defaultClientScopes" : [ "service_account", "acr", "default-scope1", "roles", "basic" ],
"optionalClientScopes" : [ "optional-scope2", "optional-requester-scope", "offline_access" ]
"optionalClientScopes" : [ "optional-scope2", "optional-requester-scope", "offline_access", "profile", "email" ]
}, {
"id" : "952643a3-2943-4734-9b51-8fa5956ebf55",
"clientId" : "requester-client-2",
@ -1091,8 +1091,8 @@
"userinfo.token.claim" : "false"
}
} ],
"defaultClientScopes" : [ "service_account", "acr", "roles", "profile", "basic" ],
"optionalClientScopes" : [ "optional-requester-scope" ]
"defaultClientScopes" : [ "service_account", "acr", "roles", "profile", "basic", "email" ],
"optionalClientScopes" : [ "optional-scope2", "optional-requester-scope" ]
}, {
"id" : "192692cd-c5e4-42ff-a7ec-d5cb6228c0c7",
"clientId" : "target-client1",