From b49a51bfd320e8ae8e548a450164bbc42e6d8380 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Barto=C5=A1?= Date: Wed, 13 May 2026 16:44:28 +0200 Subject: [PATCH] Dynamic scopes: requested scopes get mixed up between token requests (#48955) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #12223 Signed-off-by: Martin Bartoš --- .../util/DefaultClientSessionContext.java | 16 +- .../oauth/DynamicScopesIsolationTest.java | 185 ++++++++++++++++++ 2 files changed, 194 insertions(+), 7 deletions(-) create mode 100644 tests/base/src/test/java/org/keycloak/tests/oauth/DynamicScopesIsolationTest.java diff --git a/services/src/main/java/org/keycloak/services/util/DefaultClientSessionContext.java b/services/src/main/java/org/keycloak/services/util/DefaultClientSessionContext.java index fc3aa3549dd..f0c6cc75ff2 100644 --- a/services/src/main/java/org/keycloak/services/util/DefaultClientSessionContext.java +++ b/services/src/main/java/org/keycloak/services/util/DefaultClientSessionContext.java @@ -60,6 +60,7 @@ public class DefaultClientSessionContext implements ClientSessionContext { private final AuthenticatedClientSessionModel clientSession; private final Set requestedScopes; private final KeycloakSession session; + private final String requestedScopeString; private Set allowedClientScopes; @@ -76,10 +77,11 @@ public class DefaultClientSessionContext implements ClientSessionContext { private final Set restrictedScopes; - private DefaultClientSessionContext(AuthenticatedClientSessionModel clientSession, Set requestedScopes, Set restrictedScopes, KeycloakSession session) { + private DefaultClientSessionContext(AuthenticatedClientSessionModel clientSession, Set requestedScopes, Set restrictedScopes, String requestedScopeString, KeycloakSession session) { this.requestedScopes = requestedScopes; this.restrictedScopes = restrictedScopes; this.clientSession = clientSession; + this.requestedScopeString = requestedScopeString; this.session = session; this.session.setAttribute(ClientSessionContext.class.getName(), this); } @@ -101,13 +103,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()), null, session); + return new DefaultClientSessionContext(clientSession, requestedScopes.collect(Collectors.toSet()), null, scopeParam, session); } public static DefaultClientSessionContext fromClientSessionAndClientScopes(AuthenticatedClientSessionModel clientSession, Set requestedScopes, Set restrictedScopes, KeycloakSession session) { - return new DefaultClientSessionContext(clientSession, requestedScopes, restrictedScopes, session); + return new DefaultClientSessionContext(clientSession, requestedScopes, restrictedScopes, clientSession.getNote(OAuth2Constants.SCOPE), session); } @Override @@ -189,7 +191,7 @@ public class DefaultClientSessionContext implements ClientSessionContext { if (Profile.isFeatureEnabled(Profile.Feature.DYNAMIC_SCOPES)) { String scopeParam = buildScopesStringFromAuthorizationRequest(ignoreIncludeInTokenScope); logger.tracef("Generated scope param with Dynamic Scopes enabled: %1s", scopeParam); - String scopeSent = clientSession.getNote(OAuth2Constants.SCOPE); + String scopeSent = requestedScopeString; if (TokenUtil.isOIDCRequest(scopeSent)) { scopeParam = TokenUtil.attachOIDCScope(scopeParam); } @@ -203,7 +205,7 @@ public class DefaultClientSessionContext implements ClientSessionContext { .collect(Collectors.joining(" ")); // See if "openid" scope is requested - String scopeSent = clientSession.getNote(OAuth2Constants.SCOPE); + String scopeSent = requestedScopeString; if (TokenUtil.isOIDCRequest(scopeSent)) { scopeParam = TokenUtil.attachOIDCScope(scopeParam); } @@ -221,7 +223,7 @@ public class DefaultClientSessionContext implements ClientSessionContext { * @return see description */ private String buildScopesStringFromAuthorizationRequest(boolean ignoreIncludeInTokenScope) { - return AuthorizationContextUtil.getAuthorizationRequestContextFromScopes(session, clientSession.getNote(OAuth2Constants.SCOPE)).getAuthorizationDetailEntries().stream() + return AuthorizationContextUtil.getAuthorizationRequestContextFromScopes(session, requestedScopeString).getAuthorizationDetailEntries().stream() .filter(authorizationDetails -> authorizationDetails.getSource().equals(AuthorizationRequestSource.SCOPE)) .filter(authorizationDetails -> authorizationDetails.getClientScope().isIncludeInTokenScope() || ignoreIncludeInTokenScope) .filter(authorizationDetails -> isClientScopePermittedForUser(authorizationDetails.getClientScope())) @@ -244,7 +246,7 @@ public class DefaultClientSessionContext implements ClientSessionContext { @Override public AuthorizationRequestContext getAuthorizationRequestContext() { - return AuthorizationContextUtil.getAuthorizationRequestContextFromScopes(session, clientSession.getNote(OAuth2Constants.SCOPE)); + return AuthorizationContextUtil.getAuthorizationRequestContextFromScopes(session, requestedScopeString); } // Loading data diff --git a/tests/base/src/test/java/org/keycloak/tests/oauth/DynamicScopesIsolationTest.java b/tests/base/src/test/java/org/keycloak/tests/oauth/DynamicScopesIsolationTest.java new file mode 100644 index 00000000000..55eff79fb25 --- /dev/null +++ b/tests/base/src/test/java/org/keycloak/tests/oauth/DynamicScopesIsolationTest.java @@ -0,0 +1,185 @@ +package org.keycloak.tests.oauth; + +import java.util.HashMap; + +import jakarta.ws.rs.core.Response; + +import org.keycloak.common.Profile; +import org.keycloak.models.ClientScopeModel; +import org.keycloak.protocol.oidc.OIDCLoginProtocol; +import org.keycloak.representations.AccessToken; +import org.keycloak.representations.RefreshToken; +import org.keycloak.representations.idm.ClientRepresentation; +import org.keycloak.representations.idm.ClientScopeRepresentation; +import org.keycloak.testframework.annotations.InjectRealm; +import org.keycloak.testframework.annotations.KeycloakIntegrationTest; +import org.keycloak.testframework.injection.LifeCycle; +import org.keycloak.testframework.oauth.DefaultOAuthClientConfiguration; +import org.keycloak.testframework.oauth.OAuthClient; +import org.keycloak.testframework.oauth.annotations.InjectOAuthClient; +import org.keycloak.testframework.realm.ClientBuilder; +import org.keycloak.testframework.realm.ManagedRealm; +import org.keycloak.testframework.realm.RealmBuilder; +import org.keycloak.testframework.realm.RealmConfig; +import org.keycloak.testframework.realm.UserBuilder; +import org.keycloak.testframework.server.KeycloakServerConfig; +import org.keycloak.testframework.server.KeycloakServerConfigBuilder; +import org.keycloak.testframework.ui.annotations.InjectWebDriver; +import org.keycloak.testframework.ui.webdriver.ManagedWebDriver; +import org.keycloak.testframework.util.ApiUtil; +import org.keycloak.testsuite.util.oauth.AccessTokenResponse; +import org.keycloak.testsuite.util.oauth.AuthorizationEndpointResponse; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Reproducer for https://github.com/keycloak/keycloak/issues/12223 + * + * Verifies that sequential authorization code requests with different dynamic + * scope parameters produce tokens with the correct, non-mixed scopes — both + * at code exchange and after refresh. + */ +@KeycloakIntegrationTest(config = DynamicScopesIsolationTest.DynamicScopesServerConfig.class) +public class DynamicScopesIsolationTest { + + private static final String SCOPE_NAME = "dynamic"; + private static final String VALUE_A = "valueA"; + private static final String VALUE_B = "valueB"; + private static final String USERNAME = "test-user"; + private static final String PASSWORD = "password"; + + @InjectRealm(config = TestRealmConfig.class) + ManagedRealm realm; + + @InjectWebDriver(lifecycle = LifeCycle.METHOD) + ManagedWebDriver driver; + + @InjectOAuthClient(config = TestOAuthClientConfig.class) + OAuthClient oauth; + + private String dynamicScopeId; + + @AfterEach + public void cleanup() { + if (dynamicScopeId != null) { + ClientRepresentation client = realm.admin().clients().findByClientId(oauth.getClientId()).get(0); + realm.admin().clients().get(client.getId()).removeOptionalClientScope(dynamicScopeId); + realm.admin().clientScopes().get(dynamicScopeId).remove(); + dynamicScopeId = null; + } + } + + @Test + public void testDynamicScopeIsolationAcrossCodeExchangeAndRefresh() { + createAndAssignDynamicScope(); + + String scopeA = SCOPE_NAME + ":" + VALUE_A; + String scopeB = SCOPE_NAME + ":" + VALUE_B; + + // 1. First authz request with dynamic:valueA — user authenticates + AuthorizationEndpointResponse authResponse1 = oauth.loginForm() + .scope(scopeA) + .doLogin(USERNAME, PASSWORD); + assertTrue(authResponse1.isRedirected()); + String code1 = authResponse1.getCode(); + assertNotNull(code1); + + // 2. Second authz request with dynamic:valueB — SSO, no credentials needed + // This overwrites the client session notes with the new scope + AuthorizationEndpointResponse authResponse2 = oauth.loginForm() + .scope(scopeB) + .doLoginWithCookie(); + assertTrue(authResponse2.isRedirected()); + String code2 = authResponse2.getCode(); + assertNotNull(code2); + + // 3. Exchange code1 — token must have dynamic:valueA (not valueB) + AccessTokenResponse tokenResponse1 = oauth.doAccessTokenRequest(code1); + assertTrue(tokenResponse1.isSuccess()); + assertTokenScope(tokenResponse1, scopeA, scopeB); + + // 4. Exchange code2 — token must have dynamic:valueB (not valueA) + AccessTokenResponse tokenResponse2 = oauth.doAccessTokenRequest(code2); + assertTrue(tokenResponse2.isSuccess()); + assertTokenScope(tokenResponse2, scopeB, scopeA); + + // 5. Refresh token1 — must still carry dynamic:valueA + AccessTokenResponse refreshResponse1 = oauth.doRefreshTokenRequest(tokenResponse1.getRefreshToken()); + assertTrue(refreshResponse1.isSuccess()); + assertTokenScope(refreshResponse1, scopeA, scopeB); + + // Verify the new refresh token also preserves the original scope + RefreshToken newRefreshToken1 = oauth.parseToken(refreshResponse1.getRefreshToken(), RefreshToken.class); + assertScopeContains(newRefreshToken1.getScope(), scopeA); + assertScopeNotContains(newRefreshToken1.getScope(), scopeB); + + // 6. Refresh token2 — must still carry dynamic:valueB + AccessTokenResponse refreshResponse2 = oauth.doRefreshTokenRequest(tokenResponse2.getRefreshToken()); + assertTrue(refreshResponse2.isSuccess()); + assertTokenScope(refreshResponse2, scopeB, scopeA); + } + + private void assertTokenScope(AccessTokenResponse response, String expectedScope, String notExpectedScope) { + AccessToken token = oauth.parseToken(response.getAccessToken(), AccessToken.class); + assertScopeContains(token.getScope(), expectedScope); + assertScopeNotContains(token.getScope(), notExpectedScope); + } + + private void createAndAssignDynamicScope() { + ClientScopeRepresentation scopeRep = new ClientScopeRepresentation(); + scopeRep.setName(SCOPE_NAME); + scopeRep.setProtocol(OIDCLoginProtocol.LOGIN_PROTOCOL); + scopeRep.setAttributes(new HashMap<>() {{ + put(ClientScopeModel.IS_DYNAMIC_SCOPE, "true"); + put(ClientScopeModel.DYNAMIC_SCOPE_REGEXP, SCOPE_NAME + ":*"); + }}); + + try (Response response = realm.admin().clientScopes().create(scopeRep)) { + assertEquals(201, response.getStatus(), "Dynamic scope creation should succeed"); + dynamicScopeId = ApiUtil.getCreatedId(response); + } + + ClientRepresentation client = realm.admin().clients().findByClientId(oauth.getClientId()).get(0); + realm.admin().clients().get(client.getId()).addOptionalClientScope(dynamicScopeId); + } + + private static void assertScopeContains(String scopeString, String expectedScope) { + assertNotNull(scopeString, "Scope string should not be null"); + assertTrue(scopeString.contains(expectedScope), + "Scope '" + scopeString + "' should contain '" + expectedScope + "'"); + } + + private static void assertScopeNotContains(String scopeString, String notExpectedScope) { + assertNotNull(scopeString, "Scope string should not be null"); + assertFalse(scopeString.contains(notExpectedScope), + "Scope '" + scopeString + "' should NOT contain '" + notExpectedScope + "'"); + } + + static class TestRealmConfig implements RealmConfig { + @Override + public RealmBuilder configure(RealmBuilder realm) { + return realm.users(UserBuilder.create(USERNAME).password(PASSWORD) + .email("test@localhost").firstName("Test").lastName("User")); + } + } + + static class TestOAuthClientConfig extends DefaultOAuthClientConfiguration { + @Override + public ClientBuilder configure(ClientBuilder client) { + return super.configure(client).consentRequired(false); + } + } + + public static class DynamicScopesServerConfig implements KeycloakServerConfig { + @Override + public KeycloakServerConfigBuilder configure(KeycloakServerConfigBuilder config) { + return config.features(Profile.Feature.DYNAMIC_SCOPES); + } + } +}