Filter scopes in token exchange v2 based on requested audience

Closes #37147

Signed-off-by: Giuseppe Graziano <g.graziano94@gmail.com>
This commit is contained in:
Giuseppe Graziano 2025-02-13 17:58:51 +01:00 committed by Marek Posolda
parent 477843cc31
commit b4f14b2690
5 changed files with 3467 additions and 2735 deletions

View File

@ -199,4 +199,7 @@ public final class Constants {
// Note used to store the acr values if it is matched by client policy condition
public static final String CLIENT_POLICY_REQUESTED_ACR = "client-policy-requested-acr";
//attribute name used to set client ids from requested audience in standard token exchange
public static final String REQUESTED_AUDIENCE_CLIENT_IDS = "audience-client-ids";
}

View File

@ -28,12 +28,14 @@ import jakarta.ws.rs.core.Response;
import org.keycloak.OAuth2Constants;
import org.keycloak.OAuthErrorException;
import org.keycloak.common.ClientConnection;
import org.keycloak.common.Profile;
import org.keycloak.common.constants.ServiceAccountConstants;
import org.keycloak.events.Details;
import org.keycloak.events.Errors;
import org.keycloak.events.EventBuilder;
import org.keycloak.models.ClientModel;
import org.keycloak.models.ClientSessionContext;
import org.keycloak.models.Constants;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel;
@ -41,6 +43,7 @@ import org.keycloak.models.UserSessionModel;
import org.keycloak.protocol.oidc.OIDCAdvancedConfigWrapper;
import org.keycloak.protocol.oidc.TokenExchangeContext;
import org.keycloak.protocol.oidc.TokenManager;
import org.keycloak.rar.AuthorizationRequestContext;
import org.keycloak.representations.AccessToken;
import org.keycloak.representations.AccessTokenResponse;
import org.keycloak.services.CorsErrorResponseException;
@ -48,6 +51,7 @@ import org.keycloak.services.cors.Cors;
import org.keycloak.services.managers.AuthenticationManager;
import org.keycloak.services.managers.AuthenticationSessionManager;
import org.keycloak.services.managers.UserSessionManager;
import org.keycloak.services.util.AuthorizationContextUtil;
import org.keycloak.sessions.AuthenticationSessionModel;
import org.keycloak.sessions.RootAuthenticationSessionModel;
import org.keycloak.util.TokenUtil;
@ -149,7 +153,24 @@ public class StandardTokenExchangeProvider extends AbstractTokenExchangeProvider
// For now, include "scope" parameter as is
@Override
protected String getRequestedScope(AccessToken token, List<ClientModel> targetAudienceClients) {
return params.getScope();
String scope = formParams.getFirst(OAuth2Constants.SCOPE);
boolean validScopes;
if (Profile.isFeatureEnabled(Profile.Feature.DYNAMIC_SCOPES)) {
AuthorizationRequestContext authorizationRequestContext = AuthorizationContextUtil.getAuthorizationRequestContextFromScopes(session, scope);
validScopes = TokenManager.isValidScope(session, scope, authorizationRequestContext, client, null);
} else {
validScopes = TokenManager.isValidScope(session, scope, client, null);
}
if (!validScopes) {
String errorMessage = "Invalid scopes: " + scope;
event.detail(Details.REASON, errorMessage);
event.error(Errors.INVALID_REQUEST);
throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_SCOPE, errorMessage, Response.Status.BAD_REQUEST);
}
return scope;
}
protected void setClientToContext(List<ClientModel> targetAudienceClients) {
@ -174,6 +195,10 @@ public class StandardTokenExchangeProvider extends AbstractTokenExchangeProvider
updateUserSessionFromClientAuth(targetUserSession);
if (params.getAudience() != null && !targetAudienceClients.isEmpty()) {
clientSessionCtx.setAttribute(Constants.REQUESTED_AUDIENCE_CLIENT_IDS, targetAudienceClients.stream().map(ClientModel::getId).toArray(String[]::new));
}
TokenManager.AccessTokenResponseBuilder responseBuilder = tokenManager.responseBuilder(realm, client, event, this.session, targetUserSession, clientSessionCtx)
.generateAccessToken();

View File

@ -17,8 +17,8 @@
package org.keycloak.services.util;
import java.util.Arrays;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
@ -33,6 +33,7 @@ import org.keycloak.models.AuthenticatedClientSessionModel;
import org.keycloak.models.ClientModel;
import org.keycloak.models.ClientScopeModel;
import org.keycloak.models.ClientSessionContext;
import org.keycloak.models.Constants;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.ProtocolMapperModel;
import org.keycloak.models.RoleModel;
@ -265,6 +266,12 @@ public class DefaultClientSessionContext implements ClientSessionContext {
// Expand (resolve composite roles)
clientScopeRoles = RoleUtils.expandCompositeRoles(clientScopeRoles);
//remove roles that are not contained in requested audience
if (attributes.get(Constants.REQUESTED_AUDIENCE_CLIENT_IDS) != null) {
Set<String> requestedClientIdsFromAudience = Arrays.stream(getAttribute(Constants.REQUESTED_AUDIENCE_CLIENT_IDS, String[].class)).collect(Collectors.toSet());
clientScopeRoles.removeIf(role-> role.isClientRole() && !requestedClientIdsFromAudience.contains(role.getContainerId()));
}
// Check if expanded roles of clientScope has any intersection with expanded roles of user. If not, it is not permitted
clientScopeRoles.retainAll(getUserRoles());
return !clientScopeRoles.isEmpty();

View File

@ -21,7 +21,7 @@ package org.keycloak.testsuite.oauth.tokenexchange;
import java.util.List;
import org.apache.http.HttpStatus;
import jakarta.ws.rs.core.Response;
import org.hamcrest.MatcherAssert;
import org.junit.Assert;
import org.junit.FixMethodOrder;
@ -36,9 +36,9 @@ import org.keycloak.testsuite.AbstractKeycloakTest;
import org.keycloak.testsuite.AssertEvents;
import org.keycloak.testsuite.arquillian.annotation.EnableFeature;
import org.keycloak.testsuite.util.oauth.AccessTokenResponse;
import org.keycloak.testsuite.util.oauth.OAuthClient;
import static org.hamcrest.Matchers.containsInAnyOrder;
import static org.junit.Assert.assertEquals;
import static org.keycloak.testsuite.admin.AbstractAdminTest.loadJson;
import static org.keycloak.testsuite.auth.page.AuthRealm.TEST;
@ -60,53 +60,89 @@ public class ClientTokenExchangeAudienceAndScopesTest extends AbstractKeycloakTe
}
@Test
public void test01_scopeParamIncludedWithoutAudience() throws Exception {
String accessToken = resourceOwnerLogin();
public void testOptionalScopeParamRequestedWithoutAudience() throws Exception {
String accessToken = resourceOwnerLogin("john", "password", List.of("target-client1"), List.of("default-scope1"));;
oauth.scope("optional-scope2");
AccessTokenResponse response = oauth.doTokenExchange(accessToken, (String) null, "requester-client", "secret", null);
assertAudiencesAndScopes(response, List.of("target-client1", "target-client2"), List.of("default-scope1", "optional-scope2"));
}
@Test
public void test02_scopeParamIncludedAudienceIncluded() throws Exception {
String accessToken = resourceOwnerLogin();
oauth.scope("optional-scope2");
public void testAudienceRequested() throws Exception {
String accessToken = resourceOwnerLogin("john", "password", List.of("target-client1"), List.of("default-scope1"));;
AccessTokenResponse response = oauth.doTokenExchange(accessToken, List.of("target-client1"), "requester-client", "secret", null);
assertAudiencesAndScopes(response, List.of("target-client1"), List.of("default-scope1", "optional-scope2"));
assertAudiencesAndScopes(response, List.of("target-client1"), List.of("default-scope1"));
}
@Test
public void test03_scopeParamIncludedAudienceIncluded_unavailableAudience() throws Exception {
String accessToken = resourceOwnerLogin();
oauth.scope("optional-scope2");
public void testUnavailableAudienceRequested() throws Exception {
String accessToken = resourceOwnerLogin("john", "password", List.of("target-client1"), List.of("default-scope1"));;
// The "target-client3" is valid client, but unavailable to the user. Request allowed, but "target-client3" audience will not be available
AccessTokenResponse response = oauth.doTokenExchange(accessToken, List.of("target-client1", "target-client3"), "requester-client", "secret", null);
assertAudiencesAndScopes(response, List.of("target-client1"), List.of("default-scope1", "optional-scope2"));
assertAudiencesAndScopes(response, List.of("target-client1"), List.of("default-scope1"));
}
@Test
public void testScopeNotAllowed() throws Exception {
String accessToken = resourceOwnerLogin("john", "password", List.of("target-client1"), List.of("default-scope1"));
private String resourceOwnerLogin() throws Exception {
//scope not allowed
oauth.scope("optional-scope3");
AccessTokenResponse response = oauth.doTokenExchange(accessToken, List.of("target-client1", "target-client3"), "requester-client", "secret", null);
assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), response.getStatusCode());
//scope that doesn't exist
oauth.scope("bad-scope");
response = oauth.doTokenExchange(accessToken, List.of("target-client1", "target-client3"), "requester-client", "secret", null);
assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), response.getStatusCode());
}
@Test
public void testScopeFilter() throws Exception {
String accessToken = resourceOwnerLogin("john", "password", List.of("target-client1"), List.of("default-scope1"));
AccessTokenResponse response = oauth.doTokenExchange(accessToken, List.of( "target-client2"), "requester-client", "secret", null);
assertAudiencesAndScopes(response, List.of(), List.of());
oauth.scope("optional-scope2");
response = oauth.doTokenExchange(accessToken, List.of( "target-client2"), "requester-client", "secret", null);
assertAudiencesAndScopes(response, List.of("target-client2"), List.of( "optional-scope2"));
oauth.scope("optional-scope2");
response = oauth.doTokenExchange(accessToken, List.of("target-client1", "target-client2"), "requester-client", "secret", null);
assertAudiencesAndScopes(response, List.of("target-client1", "target-client2"), List.of("default-scope1", "optional-scope2"));
//just check that the exchanged token contains the optional-scope2 mapped by the realm role
accessToken = resourceOwnerLogin("mike", "password", List.of("target-client1"), List.of("default-scope1"));
oauth.scope("optional-scope2");
response = oauth.doTokenExchange(accessToken, List.of(), "requester-client", "secret", null);
assertAudiencesAndScopes(response, List.of("target-client1"), List.of("default-scope1", "optional-scope2"));
accessToken = resourceOwnerLogin("mike", "password", List.of("target-client1"), List.of("default-scope1"));
oauth.scope("optional-scope2");
response = oauth.doTokenExchange(accessToken, List.of("target-client1"), "requester-client", "secret", null);
assertAudiencesAndScopes(response, List.of("target-client1"), List.of("default-scope1", "optional-scope2"));
}
private String resourceOwnerLogin(String username, String password, List<String> audience, List<String> scope) throws Exception {
oauth.realm(TEST);
oauth.clientId("requester-client");
oauth.scope(null);
oauth.openid(false);
AccessTokenResponse response = oauth.doGrantAccessTokenRequest("secret", "john", "password");
AccessTokenResponse response = oauth.doGrantAccessTokenRequest("secret", username, password);
TokenVerifier<AccessToken> accessTokenVerifier = TokenVerifier.create(response.getAccessToken(), AccessToken.class);
AccessToken token = accessTokenVerifier.parse().getToken();
assertAudiences(token, List.of("target-client1"));
assertScopes(token, List.of("default-scope1"));
assertAudiences(token, audience);
assertScopes(token, scope);
return response.getAccessToken();
}
private void assertAudiences(AccessToken token, List<String> expectedAudiences) {
MatcherAssert.assertThat("Incompatible audiences", List.of(token.getAudience()), containsInAnyOrder(expectedAudiences.toArray()));
MatcherAssert.assertThat("Incompatible audiences", token.getAudience() == null ? List.of() : List.of(token.getAudience()), containsInAnyOrder(expectedAudiences.toArray()));
MatcherAssert.assertThat("Incompatible resource access", token.getResourceAccess().keySet(), containsInAnyOrder(expectedAudiences.toArray()));
}
private void assertScopes(AccessToken token, List<String> expectedScopes) {
MatcherAssert.assertThat("Incompatible scopes", List.of(token.getScope().split(" ")), containsInAnyOrder(expectedScopes.toArray()));
MatcherAssert.assertThat("Incompatible scopes", token.getScope().isEmpty() ? List.of() : List.of(token.getScope().split(" ")), containsInAnyOrder(expectedScopes.toArray()));
}
private void assertAudiencesAndScopes(AccessTokenResponse tokenExchangeResponse, List<String> expectedAudiences, List<String> expectedScopes) throws Exception {