From 4c03ba916ff660957612a98a3a6785a6ad70e315 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 1/2] 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 fc3aa3549dd4..f0c6cc75ff20 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 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 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 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); } @@ -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); } @@ -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())) @@ -244,7 +246,7 @@ public T getAttribute(String name, Class clazz) { @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 000000000000..55eff79fb25b --- /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); + } + } +} From 3981b412b74eb4b779e0bf32ce4d6bb5faf032f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Barto=C5=A1?= Date: Mon, 18 May 2026 08:48:37 +0200 Subject: [PATCH 2/2] RAR scope parsing should explicitly accept client reference (#48981) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #48980 Closes #45716 Signed-off-by: Martin Bartoš --- .../keycloak/protocol/oidc/TokenManager.java | 3 +-- ...izationEndpointRequestParserProcessor.java | 2 +- .../oidc/grants/OAuth2GrantTypeBase.java | 2 +- .../ParEndpointRequestParserProcessor.java | 2 +- .../AuthorizationRequestParserProvider.java | 3 ++- ...ClientScopeAuthorizationRequestParser.java | 16 +++---------- ...orizationRequestParserProviderFactory.java | 2 +- .../StandardTokenExchangeProvider.java | 2 +- .../managers/AuthenticationManager.java | 14 +++++------ .../util/AuthorizationContextUtil.java | 23 +++++++++++-------- .../util/DefaultClientSessionContext.java | 7 +++--- .../oauth/DynamicScopesIsolationTest.java | 14 +++++------ .../oauth/rar/AbstractRARParserTest.java | 2 +- 13 files changed, 41 insertions(+), 51 deletions(-) diff --git a/services/src/main/java/org/keycloak/protocol/oidc/TokenManager.java b/services/src/main/java/org/keycloak/protocol/oidc/TokenManager.java index 14578065fdc6..e2c67881a974 100755 --- a/services/src/main/java/org/keycloak/protocol/oidc/TokenManager.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/TokenManager.java @@ -625,8 +625,7 @@ public static ClientSessionContext attachAuthenticationSession(KeycloakSession s Set clientScopes; if (Profile.isFeatureEnabled(Profile.Feature.DYNAMIC_SCOPES)) { - session.getContext().setClient(client); - clientScopes = AuthorizationContextUtil.getClientScopesStreamFromAuthorizationRequestContextWithClient(session, scopeParam) + clientScopes = AuthorizationContextUtil.getClientScopesStreamFromAuthorizationRequestContextWithClient(session, client, scopeParam) .collect(Collectors.toSet()); } else { clientScopes = getRequestedClientScopes(session, scopeParam, client, userSession.getUser()) diff --git a/services/src/main/java/org/keycloak/protocol/oidc/endpoints/request/AuthorizationEndpointRequestParserProcessor.java b/services/src/main/java/org/keycloak/protocol/oidc/endpoints/request/AuthorizationEndpointRequestParserProcessor.java index c476ca2a0ca1..5c4cfb6bf718 100644 --- a/services/src/main/java/org/keycloak/protocol/oidc/endpoints/request/AuthorizationEndpointRequestParserProcessor.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/endpoints/request/AuthorizationEndpointRequestParserProcessor.java @@ -105,7 +105,7 @@ public static AuthorizationEndpointRequest parseRequest(EventBuilder event, Keyc } if (Profile.isFeatureEnabled(Profile.Feature.DYNAMIC_SCOPES)) { - request.authorizationRequestContext = AuthorizationContextUtil.getAuthorizationRequestContextFromScopes(session, request.getScope()); + request.authorizationRequestContext = AuthorizationContextUtil.getAuthorizationRequestContextFromScopes(session, client, request.getScope()); } return request; diff --git a/services/src/main/java/org/keycloak/protocol/oidc/grants/OAuth2GrantTypeBase.java b/services/src/main/java/org/keycloak/protocol/oidc/grants/OAuth2GrantTypeBase.java index a51e878be46b..a09d26627c65 100644 --- a/services/src/main/java/org/keycloak/protocol/oidc/grants/OAuth2GrantTypeBase.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/grants/OAuth2GrantTypeBase.java @@ -266,7 +266,7 @@ protected String getRequestedScopes() { boolean validScopes; if (Profile.isFeatureEnabled(Profile.Feature.DYNAMIC_SCOPES)) { - AuthorizationRequestContext authorizationRequestContext = AuthorizationContextUtil.getAuthorizationRequestContextFromScopes(session, scope); + AuthorizationRequestContext authorizationRequestContext = AuthorizationContextUtil.getAuthorizationRequestContextFromScopes(session, client, scope); validScopes = TokenManager.isValidScope(session, scope, authorizationRequestContext, client, null); } else { validScopes = TokenManager.isValidScope(session, scope, client, null); diff --git a/services/src/main/java/org/keycloak/protocol/oidc/par/endpoints/request/ParEndpointRequestParserProcessor.java b/services/src/main/java/org/keycloak/protocol/oidc/par/endpoints/request/ParEndpointRequestParserProcessor.java index cf6478887415..617e56c8e11c 100644 --- a/services/src/main/java/org/keycloak/protocol/oidc/par/endpoints/request/ParEndpointRequestParserProcessor.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/par/endpoints/request/ParEndpointRequestParserProcessor.java @@ -92,7 +92,7 @@ public static AuthorizationEndpointRequest parseRequest(EventBuilder event, Keyc } if (Profile.isFeatureEnabled(Profile.Feature.DYNAMIC_SCOPES)) { - request.setAuthorizationRequestContext(AuthorizationContextUtil.getAuthorizationRequestContextFromScopes(session, request.getScope())); + request.setAuthorizationRequestContext(AuthorizationContextUtil.getAuthorizationRequestContextFromScopes(session, client, request.getScope())); } return request; diff --git a/services/src/main/java/org/keycloak/protocol/oidc/rar/AuthorizationRequestParserProvider.java b/services/src/main/java/org/keycloak/protocol/oidc/rar/AuthorizationRequestParserProvider.java index d738e331ac22..36864c322f28 100644 --- a/services/src/main/java/org/keycloak/protocol/oidc/rar/AuthorizationRequestParserProvider.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/rar/AuthorizationRequestParserProvider.java @@ -16,6 +16,7 @@ */ package org.keycloak.protocol.oidc.rar; +import org.keycloak.models.ClientModel; import org.keycloak.provider.Provider; import org.keycloak.rar.AuthorizationRequestContext; @@ -24,6 +25,6 @@ */ public interface AuthorizationRequestParserProvider extends Provider { - AuthorizationRequestContext parseScopes(String scopeParam); + AuthorizationRequestContext parseScopes(ClientModel client, String scopeParam); } diff --git a/services/src/main/java/org/keycloak/protocol/oidc/rar/parsers/ClientScopeAuthorizationRequestParser.java b/services/src/main/java/org/keycloak/protocol/oidc/rar/parsers/ClientScopeAuthorizationRequestParser.java index 60a6ce5fc94a..3bcef028279a 100644 --- a/services/src/main/java/org/keycloak/protocol/oidc/rar/parsers/ClientScopeAuthorizationRequestParser.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/rar/parsers/ClientScopeAuthorizationRequestParser.java @@ -48,16 +48,6 @@ public class ClientScopeAuthorizationRequestParser implements AuthorizationReque protected static final Logger logger = Logger.getLogger(ClientScopeAuthorizationRequestParser.class); - /** - * This parser will be created on a per-request basis. When the adapter is created, the request's client is passed - * as a parameter - */ - private final ClientModel client; - - public ClientScopeAuthorizationRequestParser(ClientModel client) { - this.client = client; - } - /** * Creates a {@link AuthorizationRequestContext} with a list of {@link AuthorizationDetails} that will be parsed from * the provided OAuth scopes that have been requested in a given Auth request, together with default client scopes. @@ -68,9 +58,9 @@ public ClientScopeAuthorizationRequestParser(ClientModel client) { * @return see description */ @Override - public AuthorizationRequestContext parseScopes(String scopeParam) { + public AuthorizationRequestContext parseScopes(ClientModel client, String scopeParam) { // Process all the default ClientScopeModels for the current client, and maps them to the DynamicScopeRepresentation to make use of a HashSet - Set clientScopeModelSet = this.client.getClientScopes(true).values().stream() + Set clientScopeModelSet = client.getClientScopes(true).values().stream() .filter(clientScopeModel -> !clientScopeModel.isDynamicScope()) // not strictly needed as Dynamic Scopes are going to be Optional scopes for now .map(IntermediaryScopeRepresentation::new) .collect(Collectors.toSet()); @@ -79,7 +69,7 @@ public AuthorizationRequestContext parseScopes(String scopeParam) { if (scopeParam != null) { // Go through the parsed requested scopes and attempt to match them against the optional scopes list intermediaryScopeRepresentations = TokenManager.parseScopeParameter(scopeParam).collect(Collectors.toSet()).stream() - .map((String requestScope) -> getMatchingClientScope(requestScope, this.client.getClientScopes(false).values())) + .map((String requestScope) -> getMatchingClientScope(requestScope, client.getClientScopes(false).values())) .filter(Optional::isPresent) .map(Optional::get) .collect(Collectors.toSet()); diff --git a/services/src/main/java/org/keycloak/protocol/oidc/rar/parsers/ClientScopeAuthorizationRequestParserProviderFactory.java b/services/src/main/java/org/keycloak/protocol/oidc/rar/parsers/ClientScopeAuthorizationRequestParserProviderFactory.java index 83c2f64efc02..f46f14a22b3c 100644 --- a/services/src/main/java/org/keycloak/protocol/oidc/rar/parsers/ClientScopeAuthorizationRequestParserProviderFactory.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/rar/parsers/ClientScopeAuthorizationRequestParserProviderFactory.java @@ -31,7 +31,7 @@ public class ClientScopeAuthorizationRequestParserProviderFactory implements Aut @Override public AuthorizationRequestParserProvider create(KeycloakSession session) { - return new ClientScopeAuthorizationRequestParser(session.getContext().getClient()); + return new ClientScopeAuthorizationRequestParser(); } @Override diff --git a/services/src/main/java/org/keycloak/protocol/oidc/tokenexchange/StandardTokenExchangeProvider.java b/services/src/main/java/org/keycloak/protocol/oidc/tokenexchange/StandardTokenExchangeProvider.java index e89884a44fbe..9f75a8a213cf 100644 --- a/services/src/main/java/org/keycloak/protocol/oidc/tokenexchange/StandardTokenExchangeProvider.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/tokenexchange/StandardTokenExchangeProvider.java @@ -196,7 +196,7 @@ protected String getRequestedScope(AccessToken token, List targetAu boolean validScopes; if (Profile.isFeatureEnabled(Profile.Feature.DYNAMIC_SCOPES)) { - AuthorizationRequestContext authorizationRequestContext = AuthorizationContextUtil.getAuthorizationRequestContextFromScopes(session, scope); + AuthorizationRequestContext authorizationRequestContext = AuthorizationContextUtil.getAuthorizationRequestContextFromScopes(session, client, scope); validScopes = TokenManager.isValidScope(session, scope, authorizationRequestContext, client, null); } else { validScopes = TokenManager.isValidScope(session, scope, client, null); diff --git a/services/src/main/java/org/keycloak/services/managers/AuthenticationManager.java b/services/src/main/java/org/keycloak/services/managers/AuthenticationManager.java index e58e74d87db3..008ef9e6a25f 100755 --- a/services/src/main/java/org/keycloak/services/managers/AuthenticationManager.java +++ b/services/src/main/java/org/keycloak/services/managers/AuthenticationManager.java @@ -1143,8 +1143,7 @@ public static String nextRequiredAction(final KeycloakSession session, final Aut UserConsentModel grantedConsent = getEffectiveGrantedConsent(session, authSession); // See if any clientScopes need to be approved on consent screen - List clientScopesToApprove = - getClientScopesToApproveOnConsentScreen(grantedConsent, session, authSession); + List clientScopesToApprove = getClientScopesToApproveOnConsentScreen(client, grantedConsent, session, authSession); if (!clientScopesToApprove.isEmpty()) { return Action.OAUTH_GRANT.name(); } @@ -1204,8 +1203,7 @@ private static Response actionRequired(final KeycloakSession session, final Auth UserConsentModel grantedConsent = getEffectiveGrantedConsent(session, authSession); - List clientScopesToApprove = - getClientScopesToApproveOnConsentScreen(grantedConsent, session, authSession); + List clientScopesToApprove = getClientScopesToApproveOnConsentScreen(client, grantedConsent, session, authSession); // Skip grant screen if everything was already approved by this user if (!clientScopesToApprove.isEmpty()) { @@ -1234,12 +1232,12 @@ private static Response actionRequired(final KeycloakSession session, final Auth } - private static List getClientScopesToApproveOnConsentScreen(UserConsentModel grantedConsent, KeycloakSession session, AuthenticationSessionModel authSession) { + private static List getClientScopesToApproveOnConsentScreen(ClientModel client, UserConsentModel grantedConsent, KeycloakSession session, AuthenticationSessionModel authSession) { // Client Scopes to be displayed on consent screen List clientScopesToDisplay = new LinkedList<>(); // AuthorizationDetails are going to be returned regardless of the Dynamic Scope feature state - for (AuthorizationDetails authDetails : getClientScopeModelStream(session).toList()) { + for (AuthorizationDetails authDetails : getClientScopeModelStream(session, client).toList()) { ClientScopeModel clientScope = authDetails.getClientScope(); if (clientScope == null || !clientScope.isDisplayOnConsentScreen()) { continue; @@ -1270,12 +1268,12 @@ private static boolean isDynamicScopeWithParam(AuthorizationDetails authorizatio } - private static Stream getClientScopeModelStream(KeycloakSession session) { + private static Stream getClientScopeModelStream(KeycloakSession session, ClientModel client) { AuthenticationSessionModel authSession = session.getContext().getAuthenticationSession(); //if Dynamic Scopes are enabled, get the scopes from the AuthorizationRequestContext, passing the session and scopes as parameters // then concat a Stream with the ClientModel, as it's discarded in the getAuthorizationRequestContext method if (Profile.isFeatureEnabled(Profile.Feature.DYNAMIC_SCOPES)) { - return AuthorizationContextUtil.getAuthorizationRequestsStreamFromScopesWithClient(session, authSession.getClientNote(OAuth2Constants.SCOPE)); + return AuthorizationContextUtil.getAuthorizationRequestsStreamFromScopesWithClient(session, client, authSession.getClientNote(OAuth2Constants.SCOPE)); } // if dynamic scopes are not enabled, we retain the old behaviour, but the ClientScopes will be wrapped in // AuthorizationRequest objects to standardize the code handling these. diff --git a/services/src/main/java/org/keycloak/services/util/AuthorizationContextUtil.java b/services/src/main/java/org/keycloak/services/util/AuthorizationContextUtil.java index 3822b30007fe..11d66fa24c39 100644 --- a/services/src/main/java/org/keycloak/services/util/AuthorizationContextUtil.java +++ b/services/src/main/java/org/keycloak/services/util/AuthorizationContextUtil.java @@ -19,6 +19,7 @@ import java.util.stream.Stream; import org.keycloak.common.Profile; +import org.keycloak.models.ClientModel; import org.keycloak.models.ClientScopeModel; import org.keycloak.models.KeycloakSession; import org.keycloak.protocol.oidc.rar.AuthorizationRequestParserProvider; @@ -39,10 +40,11 @@ public class AuthorizationContextUtil { /** * Base function to obtain a bare AuthorizationRequestContext with just OAuth2 Scopes * @param session + * @param client * @param scope * @return an {@link AuthorizationRequestContext} with scope entries */ - public static AuthorizationRequestContext getAuthorizationRequestContextFromScopes(KeycloakSession session, String scope) { + public static AuthorizationRequestContext getAuthorizationRequestContextFromScopes(KeycloakSession session, ClientModel client, String scope) { if (!Profile.isFeatureEnabled(Profile.Feature.DYNAMIC_SCOPES)) { throw new RuntimeException("The Dynamic Scopes feature is not enabled and the AuthorizationRequestContext hasn't been generated"); } @@ -54,18 +56,19 @@ public static AuthorizationRequestContext getAuthorizationRequestContextFromScop ClientScopeAuthorizationRequestParserProviderFactory.CLIENT_SCOPE_PARSER_ID)); } - return clientScopeParser.parseScopes(scope); + return clientScopeParser.parseScopes(client, scope); } /** - * An extension of {@link AuthorizationContextUtil#getAuthorizationRequestContextFromScopes} that appends the current context's client + * An extension of {@link AuthorizationContextUtil#getAuthorizationRequestContextFromScopes} that appends the client reference * @param session + * @param client * @param scope * @return an {@link AuthorizationRequestContext} with scope entries and a ClientModel */ - public static AuthorizationRequestContext getAuthorizationRequestContextFromScopesWithClient(KeycloakSession session, String scope) { - AuthorizationRequestContext authorizationRequestContext = getAuthorizationRequestContextFromScopes(session, scope); - authorizationRequestContext.getAuthorizationDetailEntries().add(new AuthorizationDetails(session.getContext().getClient())); + public static AuthorizationRequestContext getAuthorizationRequestContextFromScopesWithClient(KeycloakSession session, ClientModel client, String scope) { + AuthorizationRequestContext authorizationRequestContext = getAuthorizationRequestContextFromScopes(session, client, scope); + authorizationRequestContext.getAuthorizationDetailEntries().add(new AuthorizationDetails(client)); return authorizationRequestContext; } @@ -75,8 +78,8 @@ public static AuthorizationRequestContext getAuthorizationRequestContextFromScop * @param scope * @return a Stream of {@link AuthorizationDetails} containing a ClientModel */ - public static Stream getAuthorizationRequestsStreamFromScopesWithClient(KeycloakSession session, String scope) { - AuthorizationRequestContext authorizationRequestContext = getAuthorizationRequestContextFromScopesWithClient(session, scope); + public static Stream getAuthorizationRequestsStreamFromScopesWithClient(KeycloakSession session, ClientModel client, String scope) { + AuthorizationRequestContext authorizationRequestContext = getAuthorizationRequestContextFromScopesWithClient(session, client, scope); return authorizationRequestContext.getAuthorizationDetailEntries().stream(); } @@ -86,8 +89,8 @@ public static Stream getAuthorizationRequestsStreamFromSco * @param scope * @return see description */ - public static Stream getClientScopesStreamFromAuthorizationRequestContextWithClient(KeycloakSession session, String scope) { - return getAuthorizationRequestContextFromScopesWithClient(session, scope).getAuthorizationDetailEntries().stream() + public static Stream getClientScopesStreamFromAuthorizationRequestContextWithClient(KeycloakSession session, ClientModel client, String scope) { + return getAuthorizationRequestContextFromScopesWithClient(session, client, scope).getAuthorizationDetailEntries().stream() .filter(authorizationDetails -> authorizationDetails.getSource() == AuthorizationRequestSource.SCOPE) .map(AuthorizationDetails::getClientScope); } 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 f0c6cc75ff20..71d4d0161551 100644 --- a/services/src/main/java/org/keycloak/services/util/DefaultClientSessionContext.java +++ b/services/src/main/java/org/keycloak/services/util/DefaultClientSessionContext.java @@ -98,8 +98,7 @@ public static DefaultClientSessionContext fromClientSessionScopeParameter(Authen public static DefaultClientSessionContext fromClientSessionAndScopeParameter(AuthenticatedClientSessionModel clientSession, String scopeParam, KeycloakSession session) { Stream requestedScopes; if (Profile.isFeatureEnabled(Profile.Feature.DYNAMIC_SCOPES)) { - session.getContext().setClient(clientSession.getClient()); - requestedScopes = AuthorizationContextUtil.getClientScopesStreamFromAuthorizationRequestContextWithClient(session, scopeParam); + requestedScopes = AuthorizationContextUtil.getClientScopesStreamFromAuthorizationRequestContextWithClient(session, clientSession.getClient(), scopeParam); } else { requestedScopes = TokenManager.getRequestedClientScopes(session, scopeParam, clientSession.getClient(), clientSession.getUserSession().getUser()); } @@ -223,7 +222,7 @@ public String getScopeString(boolean ignoreIncludeInTokenScope) { * @return see description */ private String buildScopesStringFromAuthorizationRequest(boolean ignoreIncludeInTokenScope) { - return AuthorizationContextUtil.getAuthorizationRequestContextFromScopes(session, requestedScopeString).getAuthorizationDetailEntries().stream() + return AuthorizationContextUtil.getAuthorizationRequestContextFromScopes(session, clientSession.getClient(), requestedScopeString).getAuthorizationDetailEntries().stream() .filter(authorizationDetails -> authorizationDetails.getSource().equals(AuthorizationRequestSource.SCOPE)) .filter(authorizationDetails -> authorizationDetails.getClientScope().isIncludeInTokenScope() || ignoreIncludeInTokenScope) .filter(authorizationDetails -> isClientScopePermittedForUser(authorizationDetails.getClientScope())) @@ -246,7 +245,7 @@ public T getAttribute(String name, Class clazz) { @Override public AuthorizationRequestContext getAuthorizationRequestContext() { - return AuthorizationContextUtil.getAuthorizationRequestContextFromScopes(session, requestedScopeString); + return AuthorizationContextUtil.getAuthorizationRequestContextFromScopes(session, clientSession.getClient(), 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 index 55eff79fb25b..76f4730b5e55 100644 --- a/tests/base/src/test/java/org/keycloak/tests/oauth/DynamicScopesIsolationTest.java +++ b/tests/base/src/test/java/org/keycloak/tests/oauth/DynamicScopesIsolationTest.java @@ -17,11 +17,10 @@ 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.ClientConfigBuilder; 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.realm.RealmConfigBuilder; import org.keycloak.testframework.server.KeycloakServerConfig; import org.keycloak.testframework.server.KeycloakServerConfigBuilder; import org.keycloak.testframework.ui.annotations.InjectWebDriver; @@ -163,15 +162,16 @@ private static void assertScopeNotContains(String scopeString, String notExpecte 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")); + public RealmConfigBuilder configure(RealmConfigBuilder realm) { + realm.addUser(USERNAME).password(PASSWORD).email("test@localhost") + .firstName("Test").lastName("User"); + return realm; } } static class TestOAuthClientConfig extends DefaultOAuthClientConfiguration { @Override - public ClientBuilder configure(ClientBuilder client) { + public ClientConfigBuilder configure(ClientConfigBuilder client) { return super.configure(client).consentRequired(false); } } diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/rar/AbstractRARParserTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/rar/AbstractRARParserTest.java index 04d7198bddc2..4bfe357c50df 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/rar/AbstractRARParserTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/rar/AbstractRARParserTest.java @@ -101,7 +101,7 @@ protected AuthorizationRequestContextHolder fetchAuthorizationRequestContextHold String clientUUID = client.getId(); AuthenticatedClientSessionModel clientSession = userSession.getAuthenticatedClientSessionByClient(clientUUID); session.getContext().setClient(client); - List authorizationRequestHolders = AuthorizationContextUtil.getAuthorizationRequestContextFromScopes(session, clientSession.getNote(OAuth2Constants.SCOPE)) + List authorizationRequestHolders = AuthorizationContextUtil.getAuthorizationRequestContextFromScopes(session, client, clientSession.getNote(OAuth2Constants.SCOPE)) .getAuthorizationDetailEntries().stream() .map(AuthorizationRequestContextHolder.AuthorizationRequestHolder::new) .collect(Collectors.toList());