8000 Dynamic scopes: requested scopes get mixed up between token requests by mabartos · Pull Request #48955 · keycloak/keycloak · GitHub
[go: up one dir, main page]

Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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);
}
Expand All @@ -101,13 +103,13 @@ public static DefaultClientSessionContext fromClientSessionAndScopeParameter(Aut
} 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
Expand Down Expand Up @@ -189,7 +191,7 @@ public String getScopeString(boolean ignoreIncludeInTokenScope) {
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);
}
Expand All @@ -203,7 +205,7 @@ public String getScopeString(boolean ignoreIncludeInTokenScope) {
.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);
}
Expand All @@ -221,7 +223,7 @@ public String getScopeString(boolean ignoreIncludeInTokenScope) {
* @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()))
Expand All @@ -244,7 +246,7 @@ public <T> T getAttribute(String name, Class<T> clazz) {

@Override
public AuthorizationRequestContext getAuthorizationRequestContext() {
return AuthorizationContextUtil.getAuthorizationRequestContextFromScopes(session, clientSession.getNote(OAuth2Constants.SCOPE));
return AuthorizationContextUtil.getAuthorizationRequestContextFromScopes(session, requestedScopeString);
}

// Loading data
Expand Down
Original file line number Diff line number Diff line change
@@ -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;

8000 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);
}
}
}
Loading
0