mirror of
https://github.com/keycloak/keycloak.git
synced 2026-01-10 15:32:05 -03:30
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:
parent
477843cc31
commit
b4f14b2690
@ -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";
|
||||
|
||||
}
|
||||
|
||||
@ -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();
|
||||
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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 {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Loading…
x
Reference in New Issue
Block a user