Skip to content

Commit

Permalink
[fixes keycloak#9222] - Let users configure Dynamic Client Scopes (ke…
Browse files Browse the repository at this point in the history
  • Loading branch information
dgozalo authored Jan 12, 2022
1 parent 93419a1 commit 8ea09d3
Show file tree
Hide file tree
Showing 13 changed files with 232 additions and 5 deletions.
3 changes: 2 additions & 1 deletion common/src/main/java/org/keycloak/common/Profile.java
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,8 @@ public enum Feature {
CIBA(Type.DEFAULT),
MAP_STORAGE(Type.EXPERIMENTAL),
PAR(Type.DEFAULT),
DECLARATIVE_USER_PROFILE(Type.PREVIEW);
DECLARATIVE_USER_PROFILE(Type.PREVIEW),
DYNAMIC_SCOPES(Type.EXPERIMENTAL);

private final Type typeProject;
private final Type typeProduct;
Expand Down
4 changes: 2 additions & 2 deletions common/src/test/java/org/keycloak/common/ProfileTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ public class ProfileTest {
@Test
public void checkDefaultsKeycloak() {
Assert.assertEquals("community", Profile.getName());
assertEquals(Profile.getDisabledFeatures(), Profile.Feature.ADMIN_FINE_GRAINED_AUTHZ, Profile.Feature.ADMIN2, Profile.Feature.DOCKER, Profile.Feature.SCRIPTS, Profile.Feature.TOKEN_EXCHANGE, Profile.Feature.OPENSHIFT_INTEGRATION, Profile.Feature.UPLOAD_SCRIPTS, Profile.Feature.MAP_STORAGE, Profile.Feature.DECLARATIVE_USER_PROFILE);
assertEquals(Profile.getDisabledFeatures(), Profile.Feature.ADMIN_FINE_GRAINED_AUTHZ, Profile.Feature.DYNAMIC_SCOPES, Profile.Feature.ADMIN2, Profile.Feature.DOCKER, Profile.Feature.SCRIPTS, Profile.Feature.TOKEN_EXCHANGE, Profile.Feature.OPENSHIFT_INTEGRATION, Profile.Feature.UPLOAD_SCRIPTS, Profile.Feature.MAP_STORAGE, Profile.Feature.DECLARATIVE_USER_PROFILE);
assertEquals(Profile.getPreviewFeatures(), Profile.Feature.ADMIN_FINE_GRAINED_AUTHZ, Profile.Feature.SCRIPTS, Profile.Feature.TOKEN_EXCHANGE, Profile.Feature.OPENSHIFT_INTEGRATION, Profile.Feature.DECLARATIVE_USER_PROFILE);
assertEquals(Profile.getDeprecatedFeatures(), Profile.Feature.UPLOAD_SCRIPTS);

Expand All @@ -37,7 +37,7 @@ public void checkDefaultsRH_SSO() {
Profile.init();

Assert.assertEquals("product", Profile.getName());
assertEquals(Profile.getDisabledFeatures(), Profile.Feature.ADMIN_FINE_GRAINED_AUTHZ, Profile.Feature.ADMIN2, Profile.Feature.DOCKER, Profile.Feature.SCRIPTS, Profile.Feature.TOKEN_EXCHANGE, Profile.Feature.OPENSHIFT_INTEGRATION, Profile.Feature.UPLOAD_SCRIPTS, Profile.Feature.WEB_AUTHN, Profile.Feature.MAP_STORAGE, Profile.Feature.DECLARATIVE_USER_PROFILE);
assertEquals(Profile.getDisabledFeatures(), Profile.Feature.ADMIN_FINE_GRAINED_AUTHZ, Profile.Feature.DYNAMIC_SCOPES, Profile.Feature.ADMIN2, Profile.Feature.DOCKER, Profile.Feature.SCRIPTS, Profile.Feature.TOKEN_EXCHANGE, Profile.Feature.OPENSHIFT_INTEGRATION, Profile.Feature.UPLOAD_SCRIPTS, Profile.Feature.WEB_AUTHN, Profile.Feature.MAP_STORAGE, Profile.Feature.DECLARATIVE_USER_PROFILE);
assertEquals(Profile.getPreviewFeatures(), Profile.Feature.ADMIN_FINE_GRAINED_AUTHZ, Profile.Feature.SCRIPTS, Profile.Feature.TOKEN_EXCHANGE, Profile.Feature.OPENSHIFT_INTEGRATION, Profile.Feature.WEB_AUTHN, Profile.Feature.DECLARATIVE_USER_PROFILE);
assertEquals(Profile.getDeprecatedFeatures(), Profile.Feature.UPLOAD_SCRIPTS);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,8 @@ Feature:
Enables the DECLARATIVE_USER_PROFILE feature.
--features-docker <enabled|disabled>
Enables the DOCKER feature.
--features-dynamic_scopes <enabled|disabled>
Enables the DYNAMIC_SCOPES feature.
--features-impersonation <enabled|disabled>
Enables the IMPERSONATION feature.
--features-map_storage <enabled|disabled>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,8 @@ Feature:
Enables the DECLARATIVE_USER_PROFILE feature.
--features-docker <enabled|disabled>
Enables the DOCKER feature.
--features-dynamic_scopes <enabled|disabled>
Enables the DYNAMIC_SCOPES feature.
--features-impersonation <enabled|disabled>
Enables the IMPERSONATION feature.
--features-map_storage <enabled|disabled>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,8 @@ Feature:
Enables the DECLARATIVE_USER_PROFILE feature.
--features-docker <enabled|disabled>
Enables the DOCKER feature.
--features-dynamic_scopes <enabled|disabled>
Enables the DYNAMIC_SCOPES feature.
--features-impersonation <enabled|disabled>
Enables the IMPERSONATION feature.
--features-map_storage <enabled|disabled>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -508,6 +508,21 @@ public void setIncludeInTokenScope(boolean includeInTokenScope) {
getDelegate().setIncludeInTokenScope(includeInTokenScope);
}

@Override
public boolean isDynamicScope() {
return getDelegate().isDynamicScope();
}

@Override
public void setIsDynamicScope(boolean isDynamicScope) {
getDelegate().setIsDynamicScope(isDynamicScope);
}

@Override
public String getDynamicScopeRegexp() {
return getDelegate().getDynamicScopeRegexp();
}

@Override
public Set<RoleModel> getScopeMappings() {
return getDelegate().getScopeMappings();
Expand Down
15 changes: 15 additions & 0 deletions server-spi/src/main/java/org/keycloak/models/ClientScopeModel.java
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
package org.keycloak.models;

import java.util.Map;
import java.util.Optional;

import org.keycloak.common.util.ObjectUtil;
import org.keycloak.provider.ProviderEvent;
Expand Down Expand Up @@ -67,6 +68,8 @@ interface ClientScopeRemovedEvent extends ProviderEvent {
String CONSENT_SCREEN_TEXT = "consent.screen.text";
String GUI_ORDER = "gui.order";
String INCLUDE_IN_TOKEN_SCOPE = "include.in.token.scope";
String IS_DYNAMIC_SCOPE = "is.dynamic.scope";
String DYNAMIC_SCOPE_REGEXP = "dynamic.scope.regexp";

default boolean isDisplayOnConsentScreen() {
String displayVal = getAttribute(DISPLAY_ON_CONSENT_SCREEN);
Expand Down Expand Up @@ -107,4 +110,16 @@ default boolean isIncludeInTokenScope() {
default void setIncludeInTokenScope(boolean includeInTokenScope) {
setAttribute(INCLUDE_IN_TOKEN_SCOPE, String.valueOf(includeInTokenScope));
}

default boolean isDynamicScope() {
return Optional.ofNullable(getAttribute(IS_DYNAMIC_SCOPE)).isPresent();
}

default void setIsDynamicScope(boolean isDynamicScope) {
setAttribute(IS_DYNAMIC_SCOPE, String.valueOf(isDynamicScope));
}

default String getDynamicScopeRegexp() {
return getAttribute(DYNAMIC_SCOPE_REGEXP);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@
import org.jboss.logging.Logger;
import org.jboss.resteasy.annotations.cache.NoCache;
import org.jboss.resteasy.spi.ResteasyProviderFactory;
import org.keycloak.common.Profile;
import org.keycloak.events.Errors;
import org.keycloak.events.admin.OperationType;
import org.keycloak.events.admin.ResourceType;
import org.keycloak.models.ClientScopeModel;
Expand All @@ -29,7 +31,9 @@
import org.keycloak.models.utils.ModelToRepresentation;
import org.keycloak.models.utils.RepresentationToModel;
import org.keycloak.representations.idm.ClientScopeRepresentation;
import org.keycloak.saml.common.util.StringUtil;
import org.keycloak.services.ErrorResponse;
import org.keycloak.services.ErrorResponseException;
import org.keycloak.services.resources.admin.permissions.AdminPermissionEvaluator;

import javax.ws.rs.Consumes;
Expand All @@ -41,6 +45,10 @@
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.Response;

import java.util.Map;
import java.util.Optional;
import java.util.regex.Pattern;


/**
* Base resource class for managing one particular client of a realm.
Expand All @@ -56,6 +64,7 @@ public class ClientScopeResource {
private AdminEventBuilder adminEvent;
protected ClientScopeModel clientScope;
protected KeycloakSession session;
protected static Pattern dynamicScreenPattern = Pattern.compile("[^\\s\\*]*\\*{1}[^\\s\\*]*");

public ClientScopeResource(RealmModel realm, AdminPermissionEvaluator auth, ClientScopeModel clientScope, KeycloakSession session, AdminEventBuilder adminEvent) {
this.realm = realm;
Expand Down Expand Up @@ -96,7 +105,7 @@ public ScopeMappedResource getScopeMappedResource() {
@Consumes(MediaType.APPLICATION_JSON)
public Response update(final ClientScopeRepresentation rep) {
auth.clients().requireManageClientScopes();

validateDynamicClientScope(rep);
try {
RepresentationToModel.updateClientScope(rep, clientScope);
adminEvent.operation(OperationType.UPDATE).resourcePath(session.getContext().getUri()).representation(rep).success();
Expand Down Expand Up @@ -143,4 +152,41 @@ public Response deleteClientScope() {
return ErrorResponse.error(me.getMessage(), Response.Status.BAD_REQUEST);
}
}

/**
* Performs some validation based on attributes combinations and format.
* Validations differ based on whether the DYNAMIC_SCOPES feature is enabled or not
* @param clientScope
* @throws ErrorResponseException
*/
public static void validateDynamicClientScope(ClientScopeRepresentation clientScope) throws ErrorResponseException {
if(clientScope.getAttributes() == null) {
return;
}
boolean isDynamic = Boolean.parseBoolean(clientScope.getAttributes().get(ClientScopeModel.IS_DYNAMIC_SCOPE));
String regexp = clientScope.getAttributes().get(ClientScopeModel.DYNAMIC_SCOPE_REGEXP);
if(Profile.isFeatureEnabled(Profile.Feature.DYNAMIC_SCOPES)) {
// if the scope is dynamic but the regexp is empty, it's not considered valid
if(isDynamic && StringUtil.isNullOrEmpty(regexp)) {
throw new ErrorResponseException(ErrorResponse.error("Dynamic scope regexp must not be null or empty", Response.Status.BAD_REQUEST));
}
// Always validate the dynamic scope regexp to avoid inserting a wrong value even when the feature is disabled
if(!StringUtil.isNullOrEmpty(regexp) && !dynamicScreenPattern.matcher(regexp).matches()) {
throw new ErrorResponseException(ErrorResponse.error(String.format("Invalid format for the Dynamic Scope regexp %1s", regexp), Response.Status.BAD_REQUEST));
}
} else {
// if the value is not null or empty we won't accept the request as the feature is disabled
Optional.ofNullable(regexp).ifPresent(s -> {
if(!s.isEmpty()) {
throw new ErrorResponseException(ErrorResponse.error(String.format("Unexpected value \"%1s\" for attribute %2s in ClientScope",
regexp, ClientScopeModel.DYNAMIC_SCOPE_REGEXP), Response.Status.BAD_REQUEST));
}
});
// If isDynamic is true, we won't accept the request as the feature is disabled
if(isDynamic) {
throw new ErrorResponseException(ErrorResponse.error(String.format("Unexpected value \"%1s\" for attribute %2s in ClientScope",
isDynamic, ClientScopeModel.IS_DYNAMIC_SCOPE), Response.Status.BAD_REQUEST));
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ public Stream<ClientScopeRepresentation> getClientScopes() {
@NoCache
public Response createClientScope(ClientScopeRepresentation rep) {
auth.clients().requireManageClientScopes();

ClientScopeResource.validateDynamicClientScope(rep);
try {
ClientScopeModel clientModel = RepresentationToModel.createClientScope(session, realm, rep);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,12 @@
import org.keycloak.admin.client.resource.ProtocolMappersResource;
import org.keycloak.admin.client.resource.RealmResource;
import org.keycloak.admin.client.resource.RoleMappingResource;
import org.keycloak.common.Profile;
import org.keycloak.common.util.ObjectUtil;
import org.keycloak.events.admin.OperationType;
import org.keycloak.events.admin.ResourceType;
import org.keycloak.models.AccountRoles;
import org.keycloak.models.ClientScopeModel;
import org.keycloak.models.Constants;
import org.keycloak.protocol.oidc.OIDCLoginProtocol;
import org.keycloak.protocol.saml.SamlProtocol;
Expand All @@ -38,15 +40,19 @@
import org.keycloak.representations.idm.ProtocolMapperRepresentation;
import org.keycloak.representations.idm.RoleRepresentation;
import org.keycloak.testsuite.admin.ApiUtil;
import org.keycloak.testsuite.arquillian.annotation.DisableFeature;
import org.keycloak.testsuite.arquillian.annotation.EnableFeature;
import org.keycloak.testsuite.util.AdminEventPaths;
import org.keycloak.testsuite.util.ClientBuilder;
import org.keycloak.testsuite.util.Matchers;
import org.keycloak.testsuite.util.RoleBuilder;
import org.keycloak.util.JsonSerialization;

import javax.ws.rs.ClientErrorException;
import javax.ws.rs.NotFoundException;
import javax.ws.rs.core.Response;
import javax.ws.rs.core.Response.Status;
import java.io.IOException;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
Expand All @@ -58,6 +64,7 @@
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertThat;
import static org.junit.Assert.assertTrue;
import static org.junit.Assert.fail;
import static org.keycloak.testsuite.Assert.assertNames;

/**
Expand Down Expand Up @@ -653,6 +660,99 @@ public void updateClientWithDefaultScopeAssignedAsOptionalAndOpposite() {
testRealmResource().clients().get(clientUuid).update(clientRep);
}

@Test
@EnableFeature(value = Profile.Feature.DYNAMIC_SCOPES, skipRestart = true)
public void testCreateValidDynamicScope() {
ClientScopeRepresentation scopeRep = new ClientScopeRepresentation();
scopeRep.setName("dynamic-scope-def");
scopeRep.setProtocol("openid-connect");
scopeRep.setAttributes(new HashMap<String, String>(){{
put(ClientScopeModel.IS_DYNAMIC_SCOPE, "true");
put(ClientScopeModel.DYNAMIC_SCOPE_REGEXP, "dynamic-scope-def:*");
}});
String scopeDefId = createClientScope(scopeRep);
getCleanup().addClientScopeId(scopeDefId);

// Assert updated attributes
scopeRep = clientScopes().get(scopeDefId).toRepresentation();
assertEquals("dynamic-scope-def", scopeRep.getName());
assertEquals("true", scopeRep.getAttributes().get(ClientScopeModel.IS_DYNAMIC_SCOPE));
assertEquals("dynamic-scope-def:*", scopeRep.getAttributes().get(ClientScopeModel.DYNAMIC_SCOPE_REGEXP));
}

@Test
@EnableFeature(value = Profile.Feature.DYNAMIC_SCOPES, skipRestart = true)
public void testCreateNonDynamicScopeWithFeatureEnabled() {
ClientScopeRepresentation scopeRep = new ClientScopeRepresentation();
scopeRep.setName("non-dynamic-scope-def");
scopeRep.setProtocol("openid-connect");
scopeRep.setAttributes(new HashMap<String, String>(){{
put(ClientScopeModel.IS_DYNAMIC_SCOPE, "false");
put(ClientScopeModel.DYNAMIC_SCOPE_REGEXP, "");
}});
String scopeDefId = createClientScope(scopeRep);
getCleanup().addClientScopeId(scopeDefId);

// Assert updated attributes
scopeRep = clientScopes().get(scopeDefId).toRepresentation();
assertEquals("non-dynamic-scope-def", scopeRep.getName());
assertEquals("false", scopeRep.getAttributes().get(ClientScopeModel.IS_DYNAMIC_SCOPE));
assertEquals("", scopeRep.getAttributes().get(ClientScopeModel.DYNAMIC_SCOPE_REGEXP));
}

@Test
@DisableFeature(value = Profile.Feature.DYNAMIC_SCOPES, skipRestart = true)
public void testCreateDynamicScopeWithFeatureDisabledAndIsDynamicScopeTrue() {
ClientScopeRepresentation scopeRep = new ClientScopeRepresentation();
scopeRep.setName("non-dynamic-scope-def2");
scopeRep.setProtocol("openid-connect");
scopeRep.setAttributes(new HashMap<String, String>(){{
put(ClientScopeModel.IS_DYNAMIC_SCOPE, "true");
put(ClientScopeModel.DYNAMIC_SCOPE_REGEXP, "");
}});
handleExpectedCreateFailure(scopeRep, 400, "Unexpected value \"true\" for attribute is.dynamic.scope in ClientScope");
}

@Test
@DisableFeature(value = Profile.Feature.DYNAMIC_SCOPES, skipRestart = true)
public void testCreateDynamicScopeWithFeatureDisabledAndNonEmptyDynamicScopeRegexp() {
ClientScopeRepresentation scopeRep = new ClientScopeRepresentation();
scopeRep.setName("non-dynamic-scope-def3");
scopeRep.setProtocol("openid-connect");
scopeRep.setAttributes(new HashMap<String, String>(){{
put(ClientScopeModel.IS_DYNAMIC_SCOPE, "false");
put(ClientScopeModel.DYNAMIC_SCOPE_REGEXP, "not-empty");
}});
handleExpectedCreateFailure(scopeRep, 400, "Unexpected value \"not-empty\" for attribute dynamic.scope.regexp in ClientScope");
}

@Test
@EnableFeature(value = Profile.Feature.DYNAMIC_SCOPES, skipRestart = true)
public void testCreateInvalidRegexpDynamicScope() {
ClientScopeRepresentation scopeRep = new ClientScopeRepresentation();
scopeRep.setName("dynamic-scope-def4");
scopeRep.setProtocol("openid-connect");
scopeRep.setAttributes(new HashMap<String, String>(){{
put(ClientScopeModel.IS_DYNAMIC_SCOPE, "true");
put(ClientScopeModel.DYNAMIC_SCOPE_REGEXP, "dynamic-scope-def:*:*");
}});
handleExpectedCreateFailure(scopeRep, 400, "Invalid format for the Dynamic Scope regexp dynamic-scope-def:*:*");
}

private void handleExpectedCreateFailure(ClientScopeRepresentation scopeRep, int expectedErrorCode, String expectedErrorMessage) {
try(Response resp = clientScopes().create(scopeRep)) {
Assert.assertEquals(expectedErrorCode, resp.getStatus());
String respBody = resp.readEntity(String.class);
Map<String, String> responseJson = null;
try {
responseJson = JsonSerialization.readValue(respBody, Map.class);
Assert.assertEquals(expectedErrorMessage, responseJson.get("errorMessage"));
} catch (IOException e) {
fail("Failed to extract the errorMessage from a CreateScope Response");
}
}
}

private ClientScopesResource clientScopes() {
return testRealmResource().clientScopes();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1163,6 +1163,10 @@ client-scope.gui-order=GUI order
client-scope.gui-order.tooltip=Specify order of the provider in GUI (such as in Consent page) as integer
client-scope.include-in-token-scope=Include In Token Scope
client-scope.include-in-token-scope.tooltip=If on, the name of this client scope will be added to the access token property 'scope' as well as to the Token Introspection Endpoint response. If off, this client scope will be omitted from the token and from the Token Introspection Endpoint response.
client-scope.is-dynamic-scope=Dynamic Scope
client-scope.is-dynamic-scope.tooltip=If on, this scope will be considered a Dynamic Scope, which will be comprised of a static and a variable portion.
client-scope.dynamic-scope-regexp=Dynamic Scope Format
client-scope.dynamic-scope-regexp.tooltip=This is the regular expression that the system will use to extract the scope name and variable.

add-user-federation-provider=Add user federation provider
add-user-storage-provider=Add user storage provider
Expand Down
Loading

0 comments on commit 8ea09d3

Please sign in to comment.