Dynamic scopes: requested scopes get mixed up between token requests (#48955)

Closes #12223

Signed-off-by: Martin Bartoš <mabartos@redhat.com>
This commit is contained in:
Martin Bartoš
2026-05-13 16:44:28 +02:00
committed by GitHub
parent 1f0ff55b71
commit b49a51bfd3
2 changed files with 194 additions and 7 deletions
@@ -60,6 +60,7 @@ public class DefaultClientSessionContext implements ClientSessionContext {
private final AuthenticatedClientSessionModel clientSession;
private final Set<ClientScopeModel> requestedScopes;
private final KeycloakSession session;
private final String requestedScopeString;
private Set<ClientScopeModel> allowedClientScopes;
@@ -76,10 +77,11 @@ public class DefaultClientSessionContext implements ClientSessionContext {
private final Set<String> restrictedScopes;
private DefaultClientSessionContext(AuthenticatedClientSessionModel clientSession, Set<ClientScopeModel> requestedScopes, Set<String> restrictedScopes, KeycloakSession session) {
private DefaultClientSessionContext(AuthenticatedClientSessionModel clientSession, Set<ClientScopeModel> requestedScopes, Set<String> 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<ClientScopeModel> requestedScopes, Set<String> 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
@@ -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);
}
}
}