From 3528e7ba548f524fc1677a8a752e7f825a6df415 Mon Sep 17 00:00:00 2001 From: Daniel Gozalo Date: Wed, 19 Jan 2022 14:43:25 +0100 Subject: [PATCH] [fixes #9224] - Get consented scopes from AuthorizationContext Always show the consent screen when a dynamic scope is requested and show the requested parameter Improve the code that handles dynamic scopes consent and add some log traces Add a test to check how we show dynamic scope in the consent screen and added missing template file change Fix merge problem in comment and improve other comments Fix the Dynamic Scope test by assigning it to the client as optional instead of default Change how dynamic scopes are represented in the consent screen and adapt test --- ...uthorizationDetailsJSONRepresentation.java | 7 ++ .../forms/login/LoginFormsProvider.java | 4 +- .../keycloak/rar/AuthorizationDetails.java | 24 ++++++ .../FreeMarkerLoginFormsProvider.java | 6 +- .../freemarker/model/OAuthGrantBean.java | 16 +++- .../keycloak/protocol/oidc/TokenManager.java | 7 +- ...ClientScopeAuthorizationRequestParser.java | 3 + .../managers/AuthenticationManager.java | 60 ++++++++++---- .../util/DefaultClientSessionContext.java | 15 ++-- .../testsuite/oauth/OAuthGrantTest.java | 80 +++++++++++++++++-- .../theme/base/login/login-oauth-grant.ftl | 7 +- 11 files changed, 192 insertions(+), 37 deletions(-) diff --git a/core/src/main/java/org/keycloak/representations/AuthorizationDetailsJSONRepresentation.java b/core/src/main/java/org/keycloak/representations/AuthorizationDetailsJSONRepresentation.java index de8a96344a..4c4a3ad873 100644 --- a/core/src/main/java/org/keycloak/representations/AuthorizationDetailsJSONRepresentation.java +++ b/core/src/main/java/org/keycloak/representations/AuthorizationDetailsJSONRepresentation.java @@ -137,6 +137,13 @@ public class AuthorizationDetailsJSONRepresentation implements Serializable { return null; } + public String getDynamicScopeParamFromCustomData() { + if(this.getType().equalsIgnoreCase(DYNAMIC_SCOPE_RAR_TYPE)) { + return (String) this.customData.get("scope_parameter"); + } + return null; + } + @Override public boolean equals(Object o) { if (this == o) return true; diff --git a/server-spi-private/src/main/java/org/keycloak/forms/login/LoginFormsProvider.java b/server-spi-private/src/main/java/org/keycloak/forms/login/LoginFormsProvider.java index c30bec59a8..ca868c0dea 100755 --- a/server-spi-private/src/main/java/org/keycloak/forms/login/LoginFormsProvider.java +++ b/server-spi-private/src/main/java/org/keycloak/forms/login/LoginFormsProvider.java @@ -18,10 +18,10 @@ package org.keycloak.forms.login; import org.keycloak.authentication.AuthenticationFlowContext; -import org.keycloak.models.ClientScopeModel; import org.keycloak.models.UserModel; import org.keycloak.models.utils.FormMessage; import org.keycloak.provider.Provider; +import org.keycloak.rar.AuthorizationDetails; import org.keycloak.sessions.AuthenticationSessionModel; import javax.ws.rs.core.MultivaluedMap; @@ -104,7 +104,7 @@ public interface LoginFormsProvider extends Provider { LoginFormsProvider setClientSessionCode(String accessCode); - LoginFormsProvider setAccessRequest(List clientScopesRequested); + LoginFormsProvider setAccessRequest(List clientScopesRequested); /** * Set one global error message. diff --git a/server-spi/src/main/java/org/keycloak/rar/AuthorizationDetails.java b/server-spi/src/main/java/org/keycloak/rar/AuthorizationDetails.java index d085f4d034..3e63b62b43 100644 --- a/server-spi/src/main/java/org/keycloak/rar/AuthorizationDetails.java +++ b/server-spi/src/main/java/org/keycloak/rar/AuthorizationDetails.java @@ -46,6 +46,11 @@ public class AuthorizationDetails implements Serializable { this.authorizationDetails = authorizationDetails; } + public AuthorizationDetails(ClientScopeModel clientScope) { + this.clientScope = clientScope; + this.source = AuthorizationRequestSource.SCOPE; + } + public ClientScopeModel getClientScope() { return clientScope; } @@ -70,6 +75,25 @@ public class AuthorizationDetails implements Serializable { this.authorizationDetails = authorizationDetails; } + /** + * Returns whether the current {@link AuthorizationDetails} object is a dynamic scope + * @return see description + */ + public boolean isDynamicScope() { + return this.source.equals(AuthorizationRequestSource.SCOPE) && this.getClientScope().isDynamicScope(); + } + + /** + * Returns the Dynamic Scope parameter from the underlying {@link AuthorizationDetailsJSONRepresentation} representation + * @return see description + */ + public String getDynamicScopeParam() { + if(isDynamicScope()) { + return this.authorizationDetails.getDynamicScopeParamFromCustomData(); + } + return null; + } + @Override public boolean equals(Object o) { if (this == o) return true; diff --git a/services/src/main/java/org/keycloak/forms/login/freemarker/FreeMarkerLoginFormsProvider.java b/services/src/main/java/org/keycloak/forms/login/freemarker/FreeMarkerLoginFormsProvider.java index dba76ab436..3646df6798 100755 --- a/services/src/main/java/org/keycloak/forms/login/freemarker/FreeMarkerLoginFormsProvider.java +++ b/services/src/main/java/org/keycloak/forms/login/freemarker/FreeMarkerLoginFormsProvider.java @@ -46,7 +46,6 @@ import org.keycloak.forms.login.freemarker.model.UrlBean; import org.keycloak.forms.login.freemarker.model.VerifyProfileBean; import org.keycloak.forms.login.freemarker.model.X509ConfirmBean; import org.keycloak.models.ClientModel; -import org.keycloak.models.ClientScopeModel; import org.keycloak.models.Constants; import org.keycloak.models.IdentityProviderModel; import org.keycloak.models.KeycloakSession; @@ -54,6 +53,7 @@ import org.keycloak.models.RealmModel; import org.keycloak.models.UserModel; import org.keycloak.models.utils.FormMessage; import org.keycloak.protocol.oidc.OIDCLoginProtocol; +import org.keycloak.rar.AuthorizationDetails; import org.keycloak.services.Urls; import org.keycloak.services.messages.Messages; import org.keycloak.services.resources.LoginActionsService; @@ -100,7 +100,7 @@ public class FreeMarkerLoginFormsProvider implements LoginFormsProvider { protected String accessCode; protected Response.Status status; protected javax.ws.rs.core.MediaType contentType; - protected List clientScopesRequested; + protected List clientScopesRequested; protected Map httpResponseHeaders = new HashMap<>(); protected URI actionUri; protected String execution; @@ -767,7 +767,7 @@ public class FreeMarkerLoginFormsProvider implements LoginFormsProvider { } @Override - public LoginFormsProvider setAccessRequest(List clientScopesRequested) { + public LoginFormsProvider setAccessRequest(List clientScopesRequested) { this.clientScopesRequested = clientScopesRequested; return this; } diff --git a/services/src/main/java/org/keycloak/forms/login/freemarker/model/OAuthGrantBean.java b/services/src/main/java/org/keycloak/forms/login/freemarker/model/OAuthGrantBean.java index 8345bb6328..2ea10715de 100755 --- a/services/src/main/java/org/keycloak/forms/login/freemarker/model/OAuthGrantBean.java +++ b/services/src/main/java/org/keycloak/forms/login/freemarker/model/OAuthGrantBean.java @@ -19,6 +19,7 @@ package org.keycloak.forms.login.freemarker.model; import org.keycloak.models.ClientModel; import org.keycloak.models.ClientScopeModel; import org.keycloak.models.OrderedModel; +import org.keycloak.rar.AuthorizationDetails; import java.util.ArrayList; import java.util.List; @@ -34,12 +35,13 @@ public class OAuthGrantBean { private String code; private ClientModel client; - public OAuthGrantBean(String code, ClientModel client, List clientScopesRequested) { + public OAuthGrantBean(String code, ClientModel client, List clientScopesRequested) { this.code = code; this.client = client; - for (ClientScopeModel clientScope : clientScopesRequested) { - this.clientScopesRequested.add(new ClientScopeEntry(clientScope.getConsentScreenText(), clientScope.getGuiOrder())); + for (AuthorizationDetails authDetails : clientScopesRequested) { + ClientScopeModel clientScope = authDetails.getClientScope(); + this.clientScopesRequested.add(new ClientScopeEntry(clientScope.getConsentScreenText(), clientScope.getGuiOrder(), authDetails)); } this.clientScopesRequested.sort(COMPARATOR_INSTANCE); } @@ -64,10 +66,12 @@ public class OAuthGrantBean { private final String consentScreenText; private final String guiOrder; + private final String dynamicScopeParameter; - private ClientScopeEntry(String consentScreenText, String guiOrder) { + public ClientScopeEntry(String consentScreenText, String guiOrder, AuthorizationDetails authorizationDetails) { this.consentScreenText = consentScreenText; this.guiOrder = guiOrder; + this.dynamicScopeParameter = authorizationDetails.getDynamicScopeParam(); } public String getConsentScreenText() { @@ -78,5 +82,9 @@ public class OAuthGrantBean { public String getGuiOrder() { return guiOrder; } + + public String getDynamicScopeParameter() { + return dynamicScopeParameter; + } } } 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 2d9588dd03..84c05327ae 100755 --- a/services/src/main/java/org/keycloak/protocol/oidc/TokenManager.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/TokenManager.java @@ -659,6 +659,11 @@ public class TokenManager { requestedScopes.remove(OAuth2Constants.SCOPE_OPENID); } + if (logger.isTraceEnabled()) { + logger.tracef("Rar scopes to validate requested scopes against: %1s", String.join(" ", rarScopes)); + logger.tracef("Requested scopes: %1s", String.join(" ", requestedScopes)); + } + for (String requestedScope : requestedScopes) { // We keep the check to the getDynamicClientScope for the OpenshiftSAClientAdapter if (!rarScopes.contains(requestedScope) && client.getDynamicClientScope(requestedScope) == null) { @@ -667,7 +672,7 @@ public class TokenManager { } return true; } - + public static boolean isValidScope(String scopes, ClientModel client) { if (scopes == null) { return true; 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 0a007e45b4..ff04bc38c1 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 @@ -16,6 +16,7 @@ */ package org.keycloak.protocol.oidc.rar.parsers; +import org.jboss.logging.Logger; import org.keycloak.models.ClientModel; import org.keycloak.models.ClientScopeModel; import org.keycloak.protocol.oidc.TokenManager; @@ -46,6 +47,8 @@ import static org.keycloak.representations.AuthorizationDetailsJSONRepresentatio */ public class ClientScopeAuthorizationRequestParser implements AuthorizationRequestParserProvider { + 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 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 52ebefd1e1..63ebeb5e94 100755 --- a/services/src/main/java/org/keycloak/services/managers/AuthenticationManager.java +++ b/services/src/main/java/org/keycloak/services/managers/AuthenticationManager.java @@ -36,6 +36,7 @@ import org.keycloak.authentication.actiontoken.DefaultActionTokenKey; import org.keycloak.authentication.authenticators.browser.AbstractUsernameFormAuthenticator; import org.keycloak.broker.provider.IdentityProvider; import org.keycloak.common.ClientConnection; +import org.keycloak.common.Profile; import org.keycloak.common.VerificationException; import org.keycloak.common.util.Base64Url; import org.keycloak.common.util.SecretGenerator; @@ -69,6 +70,7 @@ import org.keycloak.protocol.oidc.BackchannelLogoutResponse; import org.keycloak.protocol.oidc.OIDCAdvancedConfigWrapper; import org.keycloak.protocol.oidc.OIDCLoginProtocol; import org.keycloak.protocol.oidc.TokenManager; +import org.keycloak.rar.AuthorizationDetails; import org.keycloak.representations.AccessToken; import org.keycloak.services.ErrorResponseException; import org.keycloak.services.ServicesLogger; @@ -80,6 +82,7 @@ import org.keycloak.services.resources.IdentityBrokerService; import org.keycloak.services.resources.LoginActionsService; import org.keycloak.services.resources.RealmsResource; import org.keycloak.services.util.CookieHelper; +import org.keycloak.services.util.DefaultClientSessionContext; import org.keycloak.services.util.P3PHelper; import org.keycloak.sessions.AuthenticationSessionModel; import org.keycloak.sessions.CommonClientSessionModel; @@ -361,7 +364,7 @@ public class AuthenticationManager { BackchannelLogoutResponse.DownStreamBackchannelLogoutResponse downStreamBackchannelLogoutResponse = new BackchannelLogoutResponse.DownStreamBackchannelLogoutResponse(); downStreamBackchannelLogoutResponse.setWithBackchannelLogoutUrl(backchannelLogoutUrl != null); - + if (clientSessionLogoutResponse != null) { downStreamBackchannelLogoutResponse.setResponseCode(clientSessionLogoutResponse.getStatus()); } else { @@ -425,7 +428,7 @@ public class AuthenticationManager { /** * Logs out the given client session and records the result into {@code logoutAuthSession} if set. - * + * * @param session * @param realm * @param clientSession @@ -687,7 +690,7 @@ public class AuthenticationManager { return response; } - + public static void finishUnconfirmedUserSession(KeycloakSession session, RealmModel realm, UserSessionModel userSessionModel) { if (userSessionModel.getAuthenticatedClientSessions().values().stream().anyMatch(cs -> !CommonClientSessionModel.Action.LOGGED_OUT.name().equals(cs.getAction()))) { logger.warnf("UserSession with id %s is removed while there are still some user sessions that are not logged out properly.", userSessionModel.getId()); @@ -1059,7 +1062,7 @@ public class AuthenticationManager { UserConsentModel grantedConsent = getEffectiveGrantedConsent(session, authSession); // See if any clientScopes need to be approved on consent screen - List clientScopesToApprove = getClientScopesToApproveOnConsentScreen(realm, grantedConsent, authSession); + List clientScopesToApprove = getClientScopesToApproveOnConsentScreen(grantedConsent, session); if (!clientScopesToApprove.isEmpty()) { return CommonClientSessionModel.Action.OAUTH_GRANT.name(); } @@ -1123,7 +1126,7 @@ public class AuthenticationManager { UserConsentModel grantedConsent = getEffectiveGrantedConsent(session, authSession); - List clientScopesToApprove = getClientScopesToApproveOnConsentScreen(realm, grantedConsent, authSession); + List clientScopesToApprove = getClientScopesToApproveOnConsentScreen(grantedConsent, session); // Skip grant screen if everything was already approved by this user if (clientScopesToApprove.size() > 0) { @@ -1150,27 +1153,54 @@ public class AuthenticationManager { } - private static List getClientScopesToApproveOnConsentScreen(RealmModel realm, UserConsentModel grantedConsent, - AuthenticationSessionModel authSession) { + private static List getClientScopesToApproveOnConsentScreen(UserConsentModel grantedConsent, KeycloakSession session) { // Client Scopes to be displayed on consent screen - List clientScopesToDisplay = new LinkedList<>(); - - for (String clientScopeId : authSession.getClientScopes()) { - ClientScopeModel clientScope = KeycloakModelUtils.findClientScopeById(realm, authSession.getClient(), clientScopeId); + List clientScopesToDisplay = new LinkedList<>(); + // AuthorizationDetails are going to be returned regardless of the Dynamic Scope feature state + for (AuthorizationDetails authDetails : getClientScopeModelStream(session).collect(Collectors.toList())) { + ClientScopeModel clientScope = authDetails.getClientScope(); if (clientScope == null || !clientScope.isDisplayOnConsentScreen()) { continue; } - // Check if consent already granted by user - if (grantedConsent == null || !grantedConsent.isClientScopeGranted(clientScope)) { - clientScopesToDisplay.add(clientScope); + // we need to add dynamic scopes with params to the scopes to consent every time for now + if (grantedConsent == null || !grantedConsent.isClientScopeGranted(clientScope) || isDynamicScopeWithParam(authDetails)) { + clientScopesToDisplay.add(authDetails); } } return clientScopesToDisplay; } + private static boolean isDynamicScopeWithParam(AuthorizationDetails authorizationDetails) { + boolean dynamicScopeWithParam = authorizationDetails.getClientScope().isDynamicScope() + && authorizationDetails.getAuthorizationDetails() != null; + if (dynamicScopeWithParam) { + logger.debugf("Scope %1s is a dynamic scope with param: %2s", + authorizationDetails.getAuthorizationDetails().getScopeNameFromCustomData(), + authorizationDetails.getDynamicScopeParam()); + } + return dynamicScopeWithParam; + } + + + private static Stream getClientScopeModelStream(KeycloakSession session) { + 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 Stream.concat(DefaultClientSessionContext.getAuthorizationRequestContext(session, authSession.getClientNote(OAuth2Constants.SCOPE)) + .getAuthorizationDetailEntries().stream(), + Collections.singletonList(new AuthorizationDetails(session.getContext().getClient())).stream()); + } + // 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. + return authSession.getClientScopes().stream() + .map(scopeId -> KeycloakModelUtils.findClientScopeById(authSession.getRealm(), authSession.getClient(), scopeId)) + .map(AuthorizationDetails::new); + } + public static void setClientScopesInSession(AuthenticationSessionModel authSession) { ClientModel client = authSession.getClient(); @@ -1301,7 +1331,7 @@ public class AuthenticationManager { .filter(RequiredActionProviderModel::isEnabled) .sorted(RequiredActionProviderModel.RequiredActionComparator.SINGLETON); } - + public static void evaluateRequiredActionTriggers(final KeycloakSession session, final AuthenticationSessionModel authSession, final HttpRequest request, final EventBuilder event, final RealmModel realm, final UserModel user) { 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 44df62e833..bede2249c6 100644 --- a/services/src/main/java/org/keycloak/services/util/DefaultClientSessionContext.java +++ b/services/src/main/java/org/keycloak/services/util/DefaultClientSessionContext.java @@ -159,8 +159,9 @@ public class DefaultClientSessionContext implements ClientSessionContext { @Override public String getScopeString() { - if (Profile.isFeatureEnabled(Profile.Feature.DYNAMIC_SCOPES)) { + if(Profile.isFeatureEnabled(Profile.Feature.DYNAMIC_SCOPES)) { String scopeParam = buildScopesStringFromAuthorizationRequest(); + logger.tracef("Generated scope param with Dynamic Scopes enabled: %1s", scopeParam); String scopeSent = clientSession.getNote(OAuth2Constants.SCOPE); if (TokenUtil.isOIDCRequest(scopeSent)) { scopeParam = TokenUtil.attachOIDCScope(scopeParam); @@ -214,18 +215,22 @@ public class DefaultClientSessionContext implements ClientSessionContext { @Override public AuthorizationRequestContext getAuthorizationRequestContext() { - if (!Profile.isFeatureEnabled(Profile.Feature.DYNAMIC_SCOPES)) { + return DefaultClientSessionContext.getAuthorizationRequestContext(this.session, clientSession.getNote(OAuth2Constants.SCOPE)); + } + + public static AuthorizationRequestContext getAuthorizationRequestContext(KeycloakSession keycloakSession, String scopes) { + if(!Profile.isFeatureEnabled(Profile.Feature.DYNAMIC_SCOPES)) { throw new RuntimeException("The Dynamic Scopes feature is not enabled and the AuthorizationRequestContext hasn't been generated"); } - AuthorizationRequestParserProvider clientScopeParser = session.getProvider(AuthorizationRequestParserProvider.class, + AuthorizationRequestParserProvider clientScopeParser = keycloakSession.getProvider(AuthorizationRequestParserProvider.class, ClientScopeAuthorizationRequestParserProviderFactory.CLIENT_SCOPE_PARSER_ID); - if (clientScopeParser == null) { + if(clientScopeParser == null) { throw new RuntimeException(String.format("No provider found for authorization requests parser %1s", ClientScopeAuthorizationRequestParserProviderFactory.CLIENT_SCOPE_PARSER_ID)); } - return clientScopeParser.parseScopes(clientSession.getNote(OAuth2Constants.SCOPE)); + return clientScopeParser.parseScopes(scopes); } // Loading data diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/OAuthGrantTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/OAuthGrantTest.java index 7ad39ed58f..f36a308052 100755 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/OAuthGrantTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/OAuthGrantTest.java @@ -27,7 +27,6 @@ import org.keycloak.admin.client.resource.ClientScopeResource; import org.keycloak.admin.client.resource.RealmResource; import org.keycloak.admin.client.resource.UserResource; import org.keycloak.common.Profile; -import org.keycloak.common.constants.KerberosConstants; import org.keycloak.events.Details; import org.keycloak.events.EventType; import org.keycloak.models.ClientScopeModel; @@ -43,18 +42,16 @@ import org.keycloak.testsuite.AbstractKeycloakTest; import org.keycloak.testsuite.AssertEvents; import org.keycloak.testsuite.admin.ApiUtil; import org.keycloak.testsuite.arquillian.annotation.DisableFeature; +import org.keycloak.testsuite.arquillian.annotation.EnableFeature; import org.keycloak.testsuite.pages.AccountApplicationsPage; import org.keycloak.testsuite.pages.AppPage; import org.keycloak.testsuite.pages.ErrorPage; import org.keycloak.testsuite.pages.OAuthGrantPage; -import org.keycloak.testsuite.util.ClientManager; import org.keycloak.testsuite.util.OAuthClient; import org.keycloak.testsuite.util.ProtocolMapperUtil; -import org.keycloak.testsuite.util.RoleBuilder; import org.openqa.selenium.By; -import java.util.Arrays; -import java.util.Collections; +import java.util.HashMap; import java.util.List; import java.util.Map; @@ -63,7 +60,6 @@ import javax.ws.rs.core.Response; import static org.junit.Assert.assertEquals; import static org.keycloak.testsuite.admin.AbstractAdminTest.loadJson; import static org.keycloak.testsuite.admin.ApiUtil.findClientByClientId; -import static org.keycloak.testsuite.admin.ApiUtil.findUserByUsernameId; /** * @author Viliam Rockai @@ -321,6 +317,78 @@ public class OAuthGrantTest extends AbstractKeycloakTest { thirdParty.removeOptionalClientScope(fooScopeId); } + @Test + @EnableFeature(value = Profile.Feature.DYNAMIC_SCOPES, skipRestart = true) + public void oauthGrantDynamicScopeParamRequired() { + RealmResource appRealm = adminClient.realm(REALM_NAME); + ClientResource thirdParty = findClientByClientId(appRealm, THIRD_PARTY_APP); + + // Create clientScope + ClientScopeRepresentation scope = new ClientScopeRepresentation(); + scope.setName("foo-dynamic-scope"); + scope.setProtocol(OIDCLoginProtocol.LOGIN_PROTOCOL); + scope.setAttributes(new HashMap() {{ + put(ClientScopeModel.IS_DYNAMIC_SCOPE, "true"); + put(ClientScopeModel.DYNAMIC_SCOPE_REGEXP, "foo-dynamic-scope:*"); + }}); + Response response = appRealm.clientScopes().create(scope); + String dynamicFooScopeId = ApiUtil.getCreatedId(response); + response.close(); + getCleanup().addClientScopeId(dynamicFooScopeId); + + // Add clientScope as optional to client + thirdParty.addOptionalClientScope(dynamicFooScopeId); + + // Assert clientScope not on grant screen when not requested + oauth.clientId(THIRD_PARTY_APP); + oauth.scope("foo-dynamic-scope:withparam"); + oauth.doLogin("test-user@localhost", "password"); + grantPage.assertCurrent(); + List grants = grantPage.getDisplayedGrants(); + Assert.assertTrue(grants.contains("foo-dynamic-scope: withparam")); + grantPage.accept(); + + EventRepresentation loginEvent = events.expectLogin() + .client(THIRD_PARTY_APP) + .detail(Details.CONSENT, Details.CONSENT_VALUE_CONSENT_GRANTED) + .assertEvent(); + + String code = new OAuthClient.AuthorizationEndpointResponse(oauth).getCode(); + OAuthClient.AccessTokenResponse res = oauth.doAccessTokenRequest(code, "password"); + + events.expectCodeToToken(loginEvent.getDetails().get(Details.CODE_ID), loginEvent.getSessionId()) + .client(THIRD_PARTY_APP) + .assertEvent(); + + oauth.openLogout(); + + events.expectLogout(loginEvent.getSessionId()).assertEvent(); + + // login again to check whether the Dynamic scope and only the dynamic scope is requested again + oauth.scope("foo-dynamic-scope:withparam"); + oauth.doLogin("test-user@localhost", "password"); + grantPage.assertCurrent(); + grants = grantPage.getDisplayedGrants(); + Assert.assertEquals(1, grants.size()); + Assert.assertTrue(grants.contains("foo-dynamic-scope: withparam")); + grantPage.accept(); + + events.expectLogin() + .client(THIRD_PARTY_APP) + .detail(Details.CONSENT, Details.CONSENT_VALUE_CONSENT_GRANTED) + .assertEvent(); + + // Revoke + accountAppsPage.open(); + accountAppsPage.revokeGrant(THIRD_PARTY_APP); + events.expect(EventType.REVOKE_GRANT) + .client("account").detail(Details.REVOKED_CLIENT, THIRD_PARTY_APP).assertEvent(); + + // cleanup + oauth.scope(null); + thirdParty.removeOptionalClientScope(dynamicFooScopeId); + } + // KEYCLOAK-4326 @Test diff --git a/themes/src/main/resources/theme/base/login/login-oauth-grant.ftl b/themes/src/main/resources/theme/base/login/login-oauth-grant.ftl index 0cb17bc613..d5cfc4aac7 100755 --- a/themes/src/main/resources/theme/base/login/login-oauth-grant.ftl +++ b/themes/src/main/resources/theme/base/login/login-oauth-grant.ftl @@ -18,7 +18,12 @@ <#if oauth.clientScopesRequested??> <#list oauth.clientScopesRequested as clientScope>
  • - ${advancedMsg(clientScope.consentScreenText)} + <#if !clientScope.dynamicScopeParameter??> + ${advancedMsg(clientScope.consentScreenText)} + <#else> + ${advancedMsg(clientScope.consentScreenText)}: ${clientScope.dynamicScopeParameter} + +