[fixes #9222] - Let users configure Dynamic Client Scopes (#9327)

This commit is contained in:
Daniel Gozalo 2022-01-12 14:27:24 +01:00 committed by GitHub
parent 93419a1797
commit 8ea09d3816
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 232 additions and 5 deletions

View file

@ -64,7 +64,8 @@ public class Profile {
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;

View file

@ -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);
@ -37,7 +37,7 @@ public class ProfileTest {
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);

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -508,6 +508,21 @@ public class ClientModelLazyDelegate implements ClientModel {
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();

View file

@ -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;
@ -67,6 +68,8 @@ public interface ClientScopeModel extends ProtocolMapperContainerModel, ScopeCon
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);
@ -107,4 +110,16 @@ public interface ClientScopeModel extends ProtocolMapperContainerModel, ScopeCon
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);
}
}

View file

@ -19,6 +19,8 @@ package org.keycloak.services.resources.admin;
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;
@ -29,7 +31,9 @@ import org.keycloak.models.RealmModel;
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;
@ -41,6 +45,10 @@ import javax.ws.rs.Produces;
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.
@ -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;
@ -96,7 +105,7 @@ public class ClientScopeResource {
@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();
@ -143,4 +152,41 @@ public class ClientScopeResource {
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));
}
}
}
}

View file

@ -94,7 +94,7 @@ public class ClientScopesResource {
@NoCache
public Response createClientScope(ClientScopeRepresentation rep) {
auth.clients().requireManageClientScopes();
ClientScopeResource.validateDynamicClientScope(rep);
try {
ClientScopeModel clientModel = RepresentationToModel.createClientScope(session, realm, rep);

View file

@ -24,10 +24,12 @@ import org.keycloak.admin.client.resource.ClientScopesResource;
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;
@ -38,15 +40,19 @@ import org.keycloak.representations.idm.MappingsRepresentation;
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;
@ -58,6 +64,7 @@ import static org.junit.Assert.assertEquals;
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;
/**
@ -653,6 +660,99 @@ public class ClientScopeTest extends AbstractClientTest {
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();
}

View file

@ -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

View file

@ -3262,6 +3262,20 @@ module.controller('ClientScopeDetailCtrl', function($scope, realm, clientScope,
$scope.displayOnConsentScreen = true;
}
if(serverInfo.featureEnabled("DYNAMIC_SCOPES")) {
if ($scope.clientScope.attributes["is.dynamic.scope"]) {
if ($scope.clientScope.attributes["is.dynamic.scope"] === "true") {
$scope.isDynamicScope = true;
} else {
$scope.isDynamicScope = false;
}
} else {
$scope.isDynamicScope = false;
}
$scope.clientScope.attributes["dynamic.scope.regexp"] = $scope.clientScope.name + ":*";
}
if ($scope.clientScope.attributes["include.in.token.scope"]) {
if ($scope.clientScope.attributes["include.in.token.scope"] == "true") {
$scope.includeInTokenScope = true;
@ -3320,6 +3334,14 @@ module.controller('ClientScopeDetailCtrl', function($scope, realm, clientScope,
$scope.clientScope.attributes["display.on.consent.screen"] = "false";
}
if(serverInfo.featureEnabled("DYNAMIC_SCOPES")) {
if ($scope.isDynamicScope === true) {
$scope.clientScope.attributes["is.dynamic.scope"] = "true";
} else {
$scope.clientScope.attributes["is.dynamic.scope"] = "false";
}
}
if ($scope.includeInTokenScope == true) {
$scope.clientScope.attributes["include.in.token.scope"] = "true";
} else {

View file

@ -17,6 +17,24 @@
</div>
<kc-tooltip>{{:: 'client-scope.name.tooltip' | translate}}</kc-tooltip>
</div>
<div class="form-group clearfix block" data-ng-show="serverInfo.featureEnabled('DYNAMIC_SCOPES') && protocol === 'openid-connect'">
<label class="col-md-2 control-label" for="isDynamicScope">{{:: 'client-scope.is-dynamic-scope' | translate}}</label>
<div class="col-sm-6">
<input ng-model="isDynamicScope" ng-click="switchChange()" name="isDynamicScope" id="isDynamicScope" onoffswitch on-text="{{:: 'onText' | translate}}" off-text="{{:: 'offText' | translate}}"/>
</div>
<kc-tooltip>{{:: 'client-scope.is-dynamic-scope.tooltip' | translate}}</kc-tooltip>
</div>
<div class="form-group" data-ng-show="protocol === 'openid-connect' && isDynamicScope && serverInfo.featureEnabled('DYNAMIC_SCOPES')">
<label class="col-md-2 control-label" for="dynamicScopeRegExp">{{:: 'client-scope.dynamic-scope-regexp' | translate}} <span class="required">*</span></label>
<div class="col-sm-6">
<input class="form-control" ng-readonly="true" type="text"
ng-value="(isDynamicScope && serverInfo.featureEnabled('DYNAMIC_SCOPES')) ? clientScope.attributes['dynamic.scope.regexp'] = clientScope.name+':*' : ''"
id="dynamicScopeRegExp" name="dynamicScopeRegExp"
data-ng-model="clientScope.attributes['dynamic.scope.regexp']" autofocus ng-disabled="!isDynamicScope"
ng-required="isDynamicScope">
</div>
<kc-tooltip>{{:: 'client-scope.dynamic-scope-regexp.tooltip' | translate}}</kc-tooltip>
</div>
<div class="form-group">
<label class="col-md-2 control-label" for="description">{{:: 'description' | translate}} </label>
<div class="col-sm-6">