KEYCLOAK-17637 Client Scope Policy for authorization service

This commit is contained in:
Yoshiyuki Tabata 2021-04-01 17:27:33 +09:00 committed by Pedro Igor
parent 6d17117f42
commit 45202bd49a
20 changed files with 1074 additions and 10 deletions

View file

@ -188,7 +188,12 @@ public class AuthzClient {
* @return a {@link AuthorizationResource} * @return a {@link AuthorizationResource}
*/ */
public AuthorizationResource authorization(final String userName, final String password) { public AuthorizationResource authorization(final String userName, final String password) {
return new AuthorizationResource(configuration, serverConfiguration, this.http, createRefreshableAccessTokenSupplier(userName, password)); return authorization(userName, password, null);
}
public AuthorizationResource authorization(final String userName, final String password, final String scope) {
return new AuthorizationResource(configuration, serverConfiguration, this.http,
createRefreshableAccessTokenSupplier(userName, password, scope));
} }
/** /**
@ -276,6 +281,11 @@ public class AuthzClient {
} }
private TokenCallable createRefreshableAccessTokenSupplier(final String userName, final String password) { private TokenCallable createRefreshableAccessTokenSupplier(final String userName, final String password) {
return new TokenCallable(userName, password, http, configuration, serverConfiguration); return createRefreshableAccessTokenSupplier(userName, password, null);
}
private TokenCallable createRefreshableAccessTokenSupplier(final String userName, final String password,
final String scope) {
return new TokenCallable(userName, password, scope, http, configuration, serverConfiguration);
} }
} }

View file

@ -48,10 +48,16 @@ public class HttpMethodAuthenticator<R> {
} }
public HttpMethod<R> oauth2ResourceOwnerPassword(String userName, String password) { public HttpMethod<R> oauth2ResourceOwnerPassword(String userName, String password) {
return oauth2ResourceOwnerPassword(userName, password, null);
}
public HttpMethod<R> oauth2ResourceOwnerPassword(String userName, String password, String scope) {
client(); client();
this.method.params.put(OAuth2Constants.GRANT_TYPE, Arrays.asList(OAuth2Constants.PASSWORD)); this.method.params.put(OAuth2Constants.GRANT_TYPE, Arrays.asList(OAuth2Constants.PASSWORD));
this.method.params.put("username", Arrays.asList(userName)); this.method.params.put("username", Arrays.asList(userName));
this.method.params.put("password", Arrays.asList(password)); this.method.params.put("password", Arrays.asList(password));
if (scope != null)
this.method.params.put("scope", Arrays.asList(scope));
return this.method; return this.method;
} }

View file

@ -33,19 +33,27 @@ public class TokenCallable implements Callable<String> {
private static Logger log = Logger.getLogger(TokenCallable.class); private static Logger log = Logger.getLogger(TokenCallable.class);
private final String userName; private final String userName;
private final String password; private final String password;
private final String scope;
private final Http http; private final Http http;
private final Configuration configuration; private final Configuration configuration;
private final ServerConfiguration serverConfiguration; private final ServerConfiguration serverConfiguration;
private AccessTokenResponse tokenResponse; private AccessTokenResponse tokenResponse;
public TokenCallable(String userName, String password, Http http, Configuration configuration, ServerConfiguration serverConfiguration) { public TokenCallable(String userName, String password, String scope, Http http, Configuration configuration,
ServerConfiguration serverConfiguration) {
this.userName = userName; this.userName = userName;
this.password = password; this.password = password;
this.scope = scope;
this.http = http; this.http = http;
this.configuration = configuration; this.configuration = configuration;
this.serverConfiguration = serverConfiguration; this.serverConfiguration = serverConfiguration;
} }
public TokenCallable(String userName, String password, Http http, Configuration configuration,
ServerConfiguration serverConfiguration) {
this(userName, password, null, http, configuration, serverConfiguration);
}
public TokenCallable(Http http, Configuration configuration, ServerConfiguration serverConfiguration) { public TokenCallable(Http http, Configuration configuration, ServerConfiguration serverConfiguration) {
this(null, null, http, configuration, serverConfiguration); this(null, null, http, configuration, serverConfiguration);
} }
@ -121,12 +129,12 @@ public class TokenCallable implements Callable<String> {
* @return an {@link AccessTokenResponse} * @return an {@link AccessTokenResponse}
*/ */
AccessTokenResponse resourceOwnerPasswordGrant(String userName, String password) { AccessTokenResponse resourceOwnerPasswordGrant(String userName, String password) {
return this.http.<AccessTokenResponse>post(this.serverConfiguration.getTokenEndpoint()) return resourceOwnerPasswordGrant(userName, password, null);
.authentication() }
.oauth2ResourceOwnerPassword(userName, password)
.response() AccessTokenResponse resourceOwnerPasswordGrant(String userName, String password, String scope) {
.json(AccessTokenResponse.class) return this.http.<AccessTokenResponse>post(this.serverConfiguration.getTokenEndpoint()).authentication()
.execute(); .oauth2ResourceOwnerPassword(userName, password, scope).response().json(AccessTokenResponse.class).execute();
} }
private AccessTokenResponse refreshToken(String rawRefreshToken) { private AccessTokenResponse refreshToken(String rawRefreshToken) {
@ -144,6 +152,8 @@ public class TokenCallable implements Callable<String> {
private AccessTokenResponse obtainTokens() { private AccessTokenResponse obtainTokens() {
if (userName == null || password == null) { if (userName == null || password == null) {
return clientCredentialsGrant(); return clientCredentialsGrant();
} else if (scope != null) {
return resourceOwnerPasswordGrant(userName, password, scope);
} else { } else {
return resourceOwnerPasswordGrant(userName, password); return resourceOwnerPasswordGrant(userName, password);
} }

View file

@ -0,0 +1,83 @@
/*
* Copyright 2021 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package org.keycloak.authorization.policy.provider.clientscope;
import java.util.Set;
import java.util.function.BiFunction;
import org.keycloak.authorization.AuthorizationProvider;
import org.keycloak.authorization.identity.Identity;
import org.keycloak.authorization.model.Policy;
import org.keycloak.authorization.policy.evaluation.Evaluation;
import org.keycloak.authorization.policy.provider.PolicyProvider;
import org.keycloak.models.ClientScopeModel;
import org.keycloak.models.RealmModel;
import org.keycloak.representations.idm.authorization.ClientScopePolicyRepresentation;
/**
* @author <a href="mailto:yoshiyuki.tabata.jy@hitachi.com">Yoshiyuki Tabata</a>
*/
public class ClientScopePolicyProvider implements PolicyProvider {
private final BiFunction<Policy, AuthorizationProvider, ClientScopePolicyRepresentation> representationFunction;
public ClientScopePolicyProvider(
BiFunction<Policy, AuthorizationProvider, ClientScopePolicyRepresentation> representationFunction) {
this.representationFunction = representationFunction;
}
@Override
public void close() {
}
@Override
public void evaluate(Evaluation evaluation) {
Policy policy = evaluation.getPolicy();
Set<ClientScopePolicyRepresentation.ClientScopeDefinition> clientScopeIds = representationFunction
.apply(policy, evaluation.getAuthorizationProvider()).getClientScopes();
AuthorizationProvider authorizationProvider = evaluation.getAuthorizationProvider();
RealmModel realm = authorizationProvider.getKeycloakSession().getContext().getRealm();
Identity identity = evaluation.getContext().getIdentity();
for (ClientScopePolicyRepresentation.ClientScopeDefinition clientScopeDefinition : clientScopeIds) {
ClientScopeModel clientScope = realm.getClientScopeById(clientScopeDefinition.getId());
if (clientScope != null) {
boolean hasClientScope = hasClientScope(identity, clientScope);
if (!hasClientScope && clientScopeDefinition.isRequired()) {
evaluation.deny();
return;
} else if (hasClientScope) {
evaluation.grant();
}
}
}
}
private boolean hasClientScope(Identity identity, ClientScopeModel clientScope) {
String clientScopeName = clientScope.getName();
String[] clientScopes = identity.getAttributes().getValue("scope").asString(0).split(" ");
for (String scope : clientScopes) {
if (clientScopeName.equals(scope))
return true;
}
return false;
}
}

View file

@ -0,0 +1,247 @@
/*
* Copyright 2021 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package org.keycloak.authorization.policy.provider.clientscope;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.function.Consumer;
import org.keycloak.Config.Scope;
import org.keycloak.authorization.AuthorizationProvider;
import org.keycloak.authorization.model.Policy;
import org.keycloak.authorization.policy.provider.PolicyProvider;
import org.keycloak.authorization.policy.provider.PolicyProviderFactory;
import org.keycloak.authorization.store.PolicyStore;
import org.keycloak.authorization.store.StoreFactory;
import org.keycloak.models.ClientScopeModel;
import org.keycloak.models.ClientScopeModel.ClientScopeRemovedEvent;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.KeycloakSessionFactory;
import org.keycloak.models.RealmModel;
import org.keycloak.representations.idm.authorization.ClientScopePolicyRepresentation;
import org.keycloak.representations.idm.authorization.ClientScopePolicyRepresentation.ClientScopeDefinition;
import org.keycloak.representations.idm.authorization.PolicyRepresentation;
import org.keycloak.util.JsonSerialization;
/**
* @author <a href="mailto:yoshiyuki.tabata.jy@hitachi.com">Yoshiyuki Tabata</a>
*/
public class ClientScopePolicyProviderFactory implements PolicyProviderFactory<ClientScopePolicyRepresentation> {
private ClientScopePolicyProvider provider = new ClientScopePolicyProvider(this::toRepresentation);
@Override
public PolicyProvider create(KeycloakSession session) {
return provider;
}
@Override
public void init(Scope config) {
}
@Override
public void postInit(KeycloakSessionFactory factory) {
factory.register(event -> {
if (event instanceof ClientScopeRemovedEvent) {
KeycloakSession keycloakSession = ((ClientScopeRemovedEvent) event).getKeycloakSession();
AuthorizationProvider provider = keycloakSession.getProvider(AuthorizationProvider.class);
StoreFactory storeFactory = provider.getStoreFactory();
PolicyStore policyStore = storeFactory.getPolicyStore();
ClientScopeModel removedClientScope = ((ClientScopeRemovedEvent) event).getClientScope();
Map<Policy.FilterOption, String[]> filters = new HashMap<>();
filters.put(Policy.FilterOption.TYPE, new String[] { getId() });
policyStore.findByResourceServer(filters, null, -1, -1).forEach(new Consumer<Policy>() {
@Override
public void accept(Policy policy) {
List<Map<String, Object>> clientScopes = new ArrayList<>();
for (Map<String, Object> clientScope : getClientScopes(policy)) {
if (!clientScope.get("id").equals(removedClientScope.getId())) {
Map<String, Object> updated = new HashMap<>();
updated.put("id", clientScope.get("id"));
Object required = clientScope.get("required");
if (required != null) {
updated.put("required", required);
}
clientScopes.add(updated);
}
}
if (clientScopes.isEmpty()) {
policyStore.delete(policy.getId());
} else {
try {
policy.putConfig("clientScopes", JsonSerialization.writeValueAsString(clientScopes));
} catch (IOException e) {
throw new RuntimeException(
"Error while synchronizing client scopes with policy [" + policy.getName() + "].", e);
}
}
}
});
}
});
}
private Map<String, Object>[] getClientScopes(Policy policy) {
String clientScopes = policy.getConfig().get("clientScopes");
if (clientScopes != null) {
try {
return JsonSerialization.readValue(clientScopes.getBytes(), Map[].class);
} catch (IOException e) {
throw new RuntimeException(
"Could not parse client scopes [" + clientScopes + "] from policy config [" + policy.getName() + "].", e);
}
}
return new Map[] {};
}
@Override
public void close() {
}
@Override
public String getId() {
return "client-scope";
}
@Override
public String getName() {
return "Client Scope";
}
@Override
public String getGroup() {
return "Identity Based";
}
@Override
public PolicyProvider create(AuthorizationProvider authorization) {
return provider;
}
@Override
public ClientScopePolicyRepresentation toRepresentation(Policy policy, AuthorizationProvider authorization) {
ClientScopePolicyRepresentation representation = new ClientScopePolicyRepresentation();
try {
representation
.setClientScopes(new HashSet<>(Arrays.asList(JsonSerialization.readValue(policy.getConfig().get("clientScopes"),
ClientScopePolicyRepresentation.ClientScopeDefinition[].class))));
} catch (IOException e) {
throw new RuntimeException("Failed to deserialize client scopes", e);
}
return representation;
}
@Override
public Class<ClientScopePolicyRepresentation> getRepresentationType() {
return ClientScopePolicyRepresentation.class;
}
@Override
public void onCreate(Policy policy, ClientScopePolicyRepresentation representation, AuthorizationProvider authorization) {
updateClientScopes(policy, representation, authorization);
}
@Override
public void onUpdate(Policy policy, ClientScopePolicyRepresentation representation, AuthorizationProvider authorization) {
updateClientScopes(policy, representation, authorization);
}
@Override
public void onImport(Policy policy, PolicyRepresentation representation, AuthorizationProvider authorization) {
try {
updateClientScopes(policy, authorization,
new HashSet<>(Arrays.asList(JsonSerialization.readValue(representation.getConfig().get("clientScopes"),
ClientScopePolicyRepresentation.ClientScopeDefinition[].class))));
} catch (IOException e) {
throw new RuntimeException("Failed to deserialize client scopes during import", e);
}
}
@Override
public void onExport(Policy policy, PolicyRepresentation representation, AuthorizationProvider authorizationProvider) {
Map<String, String> config = new HashMap<>();
Set<ClientScopePolicyRepresentation.ClientScopeDefinition> clientScopes = toRepresentation(policy,
authorizationProvider).getClientScopes();
for (ClientScopePolicyRepresentation.ClientScopeDefinition clientScopeDefinition : clientScopes) {
ClientScopeModel clientScope = authorizationProvider.getRealm().getClientScopeById(clientScopeDefinition.getId());
clientScopeDefinition.setId(clientScope.getName());
}
try {
config.put("clientScopes", JsonSerialization.writeValueAsString(clientScopes));
} catch (IOException e) {
throw new RuntimeException("Failed to export client scope policy [" + policy.getName() + "]", e);
}
representation.setConfig(config);
}
private void updateClientScopes(Policy policy, ClientScopePolicyRepresentation representation,
AuthorizationProvider authorization) {
updateClientScopes(policy, authorization, representation.getClientScopes());
}
private void updateClientScopes(Policy policy, AuthorizationProvider authorization,
Set<ClientScopeDefinition> clientScopes) {
RealmModel realm = authorization.getRealm();
Set<ClientScopePolicyRepresentation.ClientScopeDefinition> updatedClientScopes = new HashSet<>();
if (clientScopes != null) {
for (ClientScopePolicyRepresentation.ClientScopeDefinition definition : clientScopes) {
String clientScopeName = definition.getId();
ClientScopeModel clientScope = realm.getClientScopesStream()
.filter(scope -> scope.getName().equals(clientScopeName)).findAny().orElse(null);
if (clientScope == null) {
clientScope = realm.getClientScopeById(clientScopeName);
}
if (clientScope == null) {
throw new RuntimeException(
"Error while updating policy [" + policy.getName() + "]. Client Scope [" + "] could not be found.");
}
definition.setId(clientScope.getId());
updatedClientScopes.add(definition);
}
}
try {
policy.putConfig("clientScopes", JsonSerialization.writeValueAsString(updatedClientScopes));
} catch (IOException e) {
throw new RuntimeException("Failed to serialize client scopes", e);
}
}
}

View file

@ -43,4 +43,5 @@ org.keycloak.authorization.policy.provider.time.TimePolicyProviderFactory
org.keycloak.authorization.policy.provider.user.UserPolicyProviderFactory org.keycloak.authorization.policy.provider.user.UserPolicyProviderFactory
org.keycloak.authorization.policy.provider.client.ClientPolicyProviderFactory org.keycloak.authorization.policy.provider.client.ClientPolicyProviderFactory
org.keycloak.authorization.policy.provider.group.GroupPolicyProviderFactory org.keycloak.authorization.policy.provider.group.GroupPolicyProviderFactory
org.keycloak.authorization.policy.provider.permission.UMAPolicyProviderFactory org.keycloak.authorization.policy.provider.permission.UMAPolicyProviderFactory
org.keycloak.authorization.policy.provider.clientscope.ClientScopePolicyProviderFactory

View file

@ -0,0 +1,83 @@
/*
* Copyright 2021 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.keycloak.representations.idm.authorization;
import java.util.HashSet;
import java.util.Set;
/**
* @author <a href="mailto:yoshiyuki.tabata.jy@hitachi.com">Yoshiyuki Tabata</a>
*/
public class ClientScopePolicyRepresentation extends AbstractPolicyRepresentation {
private Set<ClientScopeDefinition> clientScopes;
@Override
public String getType() {
return "client-scope";
}
public Set<ClientScopeDefinition> getClientScopes() {
return clientScopes;
}
public void setClientScopes(Set<ClientScopeDefinition> clientScopes) {
this.clientScopes = clientScopes;
}
public void addClientScope(String name, boolean required) {
if (clientScopes == null) {
clientScopes = new HashSet<>();
}
clientScopes.add(new ClientScopeDefinition(name, required));
}
public void addClientScope(String name) {
addClientScope(name, false);
}
public static class ClientScopeDefinition {
private String id;
private boolean required;
public ClientScopeDefinition() {
this(null, false);
}
public ClientScopeDefinition(String id, boolean required) {
this.id = id;
this.required = required;
}
public String getId() {
return id;
}
public void setId(String id) {
this.id = id;
}
public boolean isRequired() {
return required;
}
public void setRequired(boolean required) {
this.required = required;
}
}
}

View file

@ -0,0 +1,46 @@
/*
* Copyright 2021 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.keycloak.admin.client.resource;
import javax.ws.rs.Consumes;
import javax.ws.rs.GET;
import javax.ws.rs.POST;
import javax.ws.rs.Path;
import javax.ws.rs.Produces;
import javax.ws.rs.QueryParam;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.Response;
import org.jboss.resteasy.annotations.cache.NoCache;
import org.keycloak.representations.idm.authorization.ClientScopePolicyRepresentation;
/**
* @author <a href="mailto:yoshiyuki.tabata.jy@hitachi.com">Yoshiyuki Tabata</a>
*/
public interface ClientScopePoliciesResource {
@POST
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
Response create(ClientScopePolicyRepresentation representation);
@Path("/search")
@GET
@Produces(MediaType.APPLICATION_JSON)
@NoCache
ClientScopePolicyRepresentation findByName(@QueryParam("name") String name);
}

View file

@ -103,4 +103,7 @@ public interface PoliciesResource {
@Path("group") @Path("group")
GroupPoliciesResource group(); GroupPoliciesResource group();
@Path("client-scope")
ClientScopePoliciesResource clientScope();
} }

View file

@ -786,6 +786,20 @@ public class JpaRealmProvider implements RealmProvider, ClientProvider, ClientSc
em.createNamedQuery("deleteClientScopeRoleMappingByClientScope").setParameter("clientScope", clientScopeEntity).executeUpdate(); em.createNamedQuery("deleteClientScopeRoleMappingByClientScope").setParameter("clientScope", clientScopeEntity).executeUpdate();
em.remove(clientScopeEntity); em.remove(clientScopeEntity);
session.getKeycloakSessionFactory().publish(new ClientScopeModel.ClientScopeRemovedEvent() {
@Override
public KeycloakSession getKeycloakSession() {
return session;
}
@Override
public ClientScopeModel getClientScope() {
return clientScope;
}
});
em.flush(); em.flush();
return true; return true;
} }

View file

@ -124,6 +124,19 @@ public class MapClientScopeProvider implements ClientScopeProvider {
session.users().preRemove(clientScope); session.users().preRemove(clientScope);
realm.removeDefaultClientScope(clientScope); realm.removeDefaultClientScope(clientScope);
session.getKeycloakSessionFactory().publish(new ClientScopeModel.ClientScopeRemovedEvent() {
@Override
public KeycloakSession getKeycloakSession() {
return session;
}
@Override
public ClientScopeModel getClientScope() {
return clientScope;
}
});
tx.delete(UUID.fromString(id)); tx.delete(UUID.fromString(id));
return true; return true;
} }

View file

@ -20,6 +20,7 @@ package org.keycloak.models;
import java.util.Map; import java.util.Map;
import org.keycloak.common.util.ObjectUtil; import org.keycloak.common.util.ObjectUtil;
import org.keycloak.provider.ProviderEvent;
import org.keycloak.storage.SearchableModelField; import org.keycloak.storage.SearchableModelField;
/** /**
@ -34,6 +35,12 @@ public interface ClientScopeModel extends ProtocolMapperContainerModel, ScopeCon
public static final SearchableModelField<ClientScopeModel> NAME = new SearchableModelField<>("name", String.class); public static final SearchableModelField<ClientScopeModel> NAME = new SearchableModelField<>("name", String.class);
} }
interface ClientScopeRemovedEvent extends ProviderEvent {
ClientScopeModel getClientScope();
KeycloakSession getKeycloakSession();
}
String getId(); String getId();
String getName(); String getName();

View file

@ -0,0 +1,218 @@
/*
* Copyright 2021 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.keycloak.testsuite.authz;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.fail;
import java.util.List;
import org.junit.Before;
import org.junit.Test;
import org.keycloak.admin.client.resource.AuthorizationResource;
import org.keycloak.admin.client.resource.ClientResource;
import org.keycloak.admin.client.resource.ClientScopesResource;
import org.keycloak.admin.client.resource.ClientsResource;
import org.keycloak.admin.client.resource.RealmResource;
import org.keycloak.authorization.client.AuthorizationDeniedException;
import org.keycloak.authorization.client.AuthzClient;
import org.keycloak.representations.idm.ClientScopeRepresentation;
import org.keycloak.representations.idm.RealmRepresentation;
import org.keycloak.representations.idm.authorization.AuthorizationRequest;
import org.keycloak.representations.idm.authorization.AuthorizationResponse;
import org.keycloak.representations.idm.authorization.ClientScopePolicyRepresentation;
import org.keycloak.representations.idm.authorization.PermissionRequest;
import org.keycloak.representations.idm.authorization.ResourcePermissionRepresentation;
import org.keycloak.representations.idm.authorization.ResourceRepresentation;
import org.keycloak.testsuite.arquillian.annotation.AuthServerContainerExclude;
import org.keycloak.testsuite.arquillian.annotation.AuthServerContainerExclude.AuthServer;
import org.keycloak.testsuite.util.ClientBuilder;
import org.keycloak.testsuite.util.ClientScopeBuilder;
import org.keycloak.testsuite.util.RealmBuilder;
import org.keycloak.testsuite.util.UserBuilder;
/**
* @author <a href="mailto:yoshiyuki.tabata.jy@hitachi.com">Yoshiyuki Tabata</a>
*/
@AuthServerContainerExclude(AuthServer.REMOTE)
public class ClientScopePolicyTest extends AbstractAuthzTest {
@Override
public void addTestRealms(List<RealmRepresentation> testRealms) {
testRealms
.add(RealmBuilder.create().name("authz-test").user(UserBuilder.create().username("marta").password("password"))
.clientScope(ClientScopeBuilder.create().name("foo").protocol("openid-connect"))
.clientScope(ClientScopeBuilder.create().name("bar").protocol("openid-connect"))
.clientScope(ClientScopeBuilder.create().name("baz").protocol("openid-connect"))
.clientScope(ClientScopeBuilder.create().name("to-remove-a").protocol("openid-connect"))
.clientScope(ClientScopeBuilder.create().name("to-remove-b").protocol("openid-connect"))
.client(ClientBuilder.create().clientId("resource-server-test").secret("secret")
.authorizationServicesEnabled(true).redirectUris("http://localhost/resource-server-test")
.addOptionalClientScopes("foo", "bar", "baz").directAccessGrants())
.build());
}
@Before
public void configureAuthorization() throws Exception {
createResource("Resource A");
createResource("Resource B");
createClientScopePolicy("Client Scope foo Policy", "foo", "bar");
createClientScopePolicyAndLastOneRequired("Client Scope bar Policy", "foo", "bar");
createResourcePermission("Resource A Permission", "Resource A", "Client Scope foo Policy");
createResourcePermission("Resource B Permission", "Resource B", "Client Scope bar Policy");
}
private void createResource(String name) {
AuthorizationResource authorization = getClient().authorization();
ResourceRepresentation resource = new ResourceRepresentation(name);
authorization.resources().create(resource).close();
}
private void createClientScopePolicy(String name, String... clientScopes) {
ClientScopePolicyRepresentation policy = new ClientScopePolicyRepresentation();
policy.setName(name);
for (String clientScope : clientScopes) {
policy.addClientScope(clientScope);
}
getClient().authorization().policies().clientScope().create(policy).close();
}
private void createClientScopePolicyAndLastOneRequired(String name, String... clientScopes) {
ClientScopePolicyRepresentation policy = new ClientScopePolicyRepresentation();
policy.setName(name);
for (int i = 0; i < clientScopes.length - 1; i++) {
policy.addClientScope(clientScopes[i]);
}
policy.addClientScope(clientScopes[clientScopes.length - 1], true);
getClient().authorization().policies().clientScope().create(policy).close();
}
private void createResourcePermission(String name, String resource, String... policies) {
ResourcePermissionRepresentation permission = new ResourcePermissionRepresentation();
permission.setName(name);
permission.addResource(resource);
permission.addPolicy(policies);
getClient().authorization().permissions().resource().create(permission).close();
}
private ClientResource getClient() {
return getClient(getRealm());
}
private ClientResource getClient(RealmResource realm) {
ClientsResource clients = realm.clients();
return clients.findByClientId("resource-server-test").stream()
.map(representation -> clients.get(representation.getId())).findFirst()
.orElseThrow(() -> new RuntimeException("Expected client [resource-server-test]"));
}
private RealmResource getRealm() {
try {
return getAdminClient().realm("authz-test");
} catch (Exception e) {
throw new RuntimeException("Failed to create admin client");
}
}
@Test
public void testWithExpectedClientScope() {
// Access Resource A with client scope foo.
AuthzClient authzClient = getAuthzClient();
PermissionRequest request = new PermissionRequest("Resource A");
String ticket = authzClient.protection().permission().create(request).getTicket();
AuthorizationResponse response = authzClient.authorization("marta", "password", "foo")
.authorize(new AuthorizationRequest(ticket));
assertNotNull(response.getToken());
// Access Resource A with client scope bar.
request = new PermissionRequest("Resource A");
ticket = authzClient.protection().permission().create(request).getTicket();
response = authzClient.authorization("marta", "password", "bar").authorize(new AuthorizationRequest(ticket));
assertNotNull(response.getToken());
// Access Resource B with client scope bar.
request = new PermissionRequest("Resource B");
ticket = authzClient.protection().permission().create(request).getTicket();
response = authzClient.authorization("marta", "password", "bar").authorize(new AuthorizationRequest(ticket));
assertNotNull(response.getToken());
}
@Test
public void testWithoutExpectedClientScope() {
// Access Resource A with client scope baz.
AuthzClient authzClient = getAuthzClient();
PermissionRequest request = new PermissionRequest("Resource A");
String ticket = authzClient.protection().permission().create(request).getTicket();
try {
authzClient.authorization("marta", "password", "baz").authorize(new AuthorizationRequest(ticket));
fail("Should fail.");
} catch (AuthorizationDeniedException ignore) {
}
// Access Resource B with client scope foo.
request = new PermissionRequest("Resource B");
ticket = authzClient.protection().permission().create(request).getTicket();
try {
authzClient.authorization("marta", "password", "foo").authorize(new AuthorizationRequest(ticket));
fail("Should fail.");
} catch (AuthorizationDeniedException ignore) {
}
}
@Test
public void testRemovePolicyWhenRemovingScope() {
createClientScopePolicy("Client Scope To Remove Policy", "to-remove-a", "to-remove-b");
ClientScopesResource clientScopes = getRealm().clientScopes();
ClientScopeRepresentation scopeRep = clientScopes.findAll().stream().filter(r -> r.getName().equals("to-remove-a"))
.findAny().get();
getClient().removeDefaultClientScope(scopeRep.getId());
getRealm().clientScopes().get(scopeRep.getId()).remove();
ClientScopePolicyRepresentation policyRep = getClient().authorization().policies().clientScope()
.findByName("Client Scope To Remove Policy");
final String id = scopeRep.getId();
assertFalse(policyRep.getClientScopes().stream().anyMatch(def -> def.getId().equals(id)));
scopeRep = clientScopes.findAll().stream().filter(r -> r.getName().equals("to-remove-b")).findAny().get();
getClient().removeDefaultClientScope(scopeRep.getId());
getRealm().clientScopes().get(scopeRep.getId()).remove();
assertNull(getClient().authorization().policies().clientScope().findByName("Client Scope To Remove Policy"));
}
private AuthzClient getAuthzClient() {
return AuthzClient.create(getClass().getResourceAsStream("/authorization-test/default-keycloak.json"));
}
}

View file

@ -79,6 +79,14 @@ public class ClientBuilder {
return this; return this;
} }
public ClientBuilder addOptionalClientScopes(String... optionalClientScopes) {
if (rep.getOptionalClientScopes() == null) {
rep.setOptionalClientScopes(new ArrayList<>());
}
rep.getOptionalClientScopes().addAll(Arrays.asList(optionalClientScopes));
return this;
}
public ClientBuilder serviceAccount() { public ClientBuilder serviceAccount() {
rep.setServiceAccountsEnabled(true); rep.setServiceAccountsEnabled(true);
return this; return this;

View file

@ -0,0 +1,50 @@
/*
* Copyright 2021 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.keycloak.testsuite.util;
import org.keycloak.representations.idm.ClientScopeRepresentation;
/**
* @author <a href="mailto:yoshiyuki.tabata.jy@hitachi.com">Yoshiyuki Tabata</a>
*/
public class ClientScopeBuilder {
private ClientScopeRepresentation rep;
public static ClientScopeBuilder create() {
ClientScopeRepresentation rep = new ClientScopeRepresentation();
return new ClientScopeBuilder(rep);
}
private ClientScopeBuilder(ClientScopeRepresentation rep) {
this.rep = rep;
}
public ClientScopeRepresentation build() {
return rep;
}
public ClientScopeBuilder name(String name) {
rep.setName(name);
return this;
}
public ClientScopeBuilder protocol(String protocol) {
rep.setProtocol(protocol);
return this;
}
}

View file

@ -18,6 +18,7 @@
package org.keycloak.testsuite.util; package org.keycloak.testsuite.util;
import org.keycloak.representations.idm.ClientRepresentation; import org.keycloak.representations.idm.ClientRepresentation;
import org.keycloak.representations.idm.ClientScopeRepresentation;
import org.keycloak.representations.idm.GroupRepresentation; import org.keycloak.representations.idm.GroupRepresentation;
import org.keycloak.representations.idm.IdentityProviderRepresentation; import org.keycloak.representations.idm.IdentityProviderRepresentation;
import org.keycloak.representations.idm.RealmRepresentation; import org.keycloak.representations.idm.RealmRepresentation;
@ -132,6 +133,18 @@ public class RealmBuilder {
return this; return this;
} }
public RealmBuilder clientScope(ClientScopeBuilder clientScope) {
return clientScope(clientScope.build());
}
public RealmBuilder clientScope(ClientScopeRepresentation clientScope) {
if (rep.getClientScopes() == null) {
rep.setClientScopes(new LinkedList<>());
}
rep.getClientScopes().add(clientScope);
return this;
}
public RealmBuilder identityProvider(IdentityProviderBuilder identityProvider) { public RealmBuilder identityProvider(IdentityProviderBuilder identityProvider) {
return identityProvider(identityProvider.build()); return identityProvider(identityProvider.build());
} }

View file

@ -1628,6 +1628,11 @@ authz-no-groups-assigned=No groups assigned.
authz-policy-group-claim=Groups Claim authz-policy-group-claim=Groups Claim
authz-policy-group-claim.tooltip=If defined, the policy will fetch user's groups from the given claim within an access token or ID token representing the identity asking permissions. If not defined, user's groups are obtained from your realm configuration. authz-policy-group-claim.tooltip=If defined, the policy will fetch user's groups from the given claim within an access token or ID token representing the identity asking permissions. If not defined, user's groups are obtained from your realm configuration.
authz-policy-group-groups.tooltip=Specifies the groups allowed by this policy. authz-policy-group-groups.tooltip=Specifies the groups allowed by this policy.
# Authz Client Scope Policy Detail
authz-add-client-scope-policy=Add Client Scope Policy
authz-no-client-scopes-assigned=No client scopes assigned.
authz-policy-client-scope-client-scopes.tooltip=Specifies which client scope(s) are allowed by this policy.
select-a-client-scope=Select a client scope
# Authz Permission List # Authz Permission List
authz-no-permissions-available=No permissions available. authz-no-permissions-available=No permissions available.

View file

@ -396,6 +396,28 @@ module.config(['$routeProvider', function ($routeProvider) {
} }
}, },
controller: 'ResourceServerPolicyAggregateDetailCtrl' controller: 'ResourceServerPolicyAggregateDetailCtrl'
}).when('/realms/:realm/clients/:client/authz/resource-server/policy/client-scope/create', {
templateUrl: resourceUrl + '/partials/authz/policy/provider/resource-server-policy-client-scope-detail.html',
resolve: {
realm: function (RealmLoader) {
return RealmLoader();
},
client : function(ClientLoader) {
return ClientLoader();
}
},
controller: 'ResourceServerPolicyClientScopeDetailCtrl'
}).when('/realms/:realm/clients/:client/authz/resource-server/policy/client-scope/:id', {
templateUrl: resourceUrl + '/partials/authz/policy/provider/resource-server-policy-client-scope-detail.html',
resolve: {
realm: function (RealmLoader) {
return RealmLoader();
},
client : function(ClientLoader) {
return ClientLoader();
}
},
controller: 'ResourceServerPolicyClientScopeDetailCtrl'
}).when('/realms/:realm/roles/:role/permissions', { }).when('/realms/:realm/roles/:role/permissions', {
templateUrl : resourceUrl + '/partials/authz/mgmt/realm-role-permissions.html', templateUrl : resourceUrl + '/partials/authz/mgmt/realm-role-permissions.html',
resolve : { resolve : {

View file

@ -2093,6 +2093,105 @@ module.controller('ResourceServerPolicyAggregateDetailCtrl', function($scope, $r
}, realm, client, $scope); }, realm, client, $scope);
}); });
module.controller('ResourceServerPolicyClientScopeDetailCtrl', function($scope, $route, realm, client, ClientScope, PolicyController) {
PolicyController.onInit({
getPolicyType : function() {
return "client-scope";
},
onInit : function() {
ClientScope.query({realm: $route.current.params.realm}, function(data) {
$scope.clientScopes = data;
});
$scope.selectedClientScopes = [];
$scope.selectClientScope = function(clientScope) {
if (!clientScope || !clientScope.id) {
return;
}
$scope.selectedClientScope = null;
for (i = 0; i < $scope.selectedClientScopes.length; i++) {
if ($scope.selectedClientScopes[i].id == clientScope.id) {
return;
}
}
$scope.selectedClientScopes.push(clientScope);
}
$scope.removeFromList = function(clientScope) {
var index = $scope.selectedClientScopes.indexOf(clientScope);
if (index != -1) {
$scope.selectedClientScopes.splice(index, 1);
}
}
},
onInitUpdate : function(policy) {
var selectedClientScopes = [];
if (policy.clientScopes) {
var clientScopes = policy.clientScopes;
for (i = 0; i < clientScopes.length; i++) {
ClientScope.get({realm: $route.current.params.realm, clientScope: clientScopes[i].id}, function(data) {
for (i = 0; i < clientScopes.length; i++) {
if (clientScopes[i].id == data.id) {
data.required = clientScopes[i].required ? true : false;
}
}
selectedClientScopes.push(data);
$scope.selectedClientScopes = angular.copy(selectedClientScopes);
});
}
}
$scope.$watch('selectedClientScopes', function() {
if (!angular.equals($scope.selectedClientScopes, selectedClientScopes)) {
$scope.changed = true;
} else {
$scope.changed = false;
}
}, true);
},
onUpdate : function() {
var clientScopes = [];
for (i = 0; i < $scope.selectedClientScopes.length; i++) {
var clientScope = {};
clientScope.id = $scope.selectedClientScopes[i].id;
if ($scope.selectedClientScopes[i].required) {
clientScope.required = $scope.selectedClientScopes[i].required;
}
clientScopes.push(clientScope);
}
$scope.policy.clientScopes = clientScopes;
delete $scope.policy.config;
},
onCreate : function() {
var clientScopes = [];
for (i = 0; i < $scope.selectedClientScopes.length; i++) {
var clientScope = {};
clientScope.id = $scope.selectedClientScopes[i].id;
if ($scope.selectedClientScopes[i].required) {
clientScope.required = $scope.selectedClientScopes[i].required;
}
clientScopes.push(clientScope);
}
$scope.policy.clientScopes = clientScopes;
delete $scope.policy.config;
}
}, realm, client, $scope);
});
module.service("PolicyController", function($http, $route, $location, ResourceServer, ResourceServerPolicy, ResourceServerPermission, AuthzDialog, Notifications, policyViewState, PolicyProvider, viewState) { module.service("PolicyController", function($http, $route, $location, ResourceServer, ResourceServerPolicy, ResourceServerPermission, AuthzDialog, Notifications, policyViewState, PolicyProvider, viewState) {
var PolicyController = {}; var PolicyController = {};

View file

@ -0,0 +1,126 @@
<!--
~ JBoss, Home of Professional Open Source.
~ Copyright 2021 Red Hat, Inc., and individual contributors
~ as indicated by the @author tags.
~
~ Licensed under the Apache License, Version 2.0 (the "License");
~ you may not use this file except in compliance with the License.
~ You may obtain a copy of the License at
~
~ http://www.apache.org/licenses/LICENSE-2.0
~
~ Unless required by applicable law or agreed to in writing, software
~ distributed under the License is distributed on an "AS IS" BASIS,
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
~ See the License for the specific language governing permissions and
~ limitations under the License.
-->
<div class="col-sm-9 col-md-10 col-sm-push-3 col-md-push-2">
<ol class="breadcrumb">
<li><a href="#/realms/{{realm.realm}}/clients">{{:: 'clients' | translate}}</a></li>
<li><a href="#/realms/{{realm.realm}}/clients/{{client.id}}">{{client.clientId}}</a></li>
<li><a href="#/realms/{{realm.realm}}/clients/{{client.id}}/authz/resource-server">{{:: 'authz-authorization' |
translate}}</a></li>
<li><a href="#/realms/{{realm.realm}}/clients/{{client.id}}/authz/resource-server/policy">{{:: 'authz-policies'
| translate}}</a></li>
<li data-ng-show="policyState.state.policy.name != null && historyBackOnSaveOrCancel">{{policyState.state.policy.name}}</li>
<li data-ng-show="policyState.state.policy.name == null && historyBackOnSaveOrCancel">{{::
policyState.state.previousPage.name | translate}}</li>
<li data-ng-show="create">{{:: 'authz-add-client-scope-policy' | translate}}</li>
<li data-ng-hide="create">{{:: 'client-scopes' | translate}}</li>
<li data-ng-hide="create">{{originalPolicy.name}}</li>
</ol>
<h1 data-ng-show="create">{{:: 'authz-add-client-scope-policy' | translate}}</h1>
<h1 data-ng-hide="create">
{{originalPolicy.name|capitalize}}<i class="pficon pficon-delete clickable" data-ng-show="!create"
data-ng-click="remove()"></i>
</h1>
<form class="form-horizontal" name="clientForm" novalidate>
<fieldset class="border-top">
<div class="form-group">
<label class="col-md-2 control-label" for="name">{{:: 'name' | translate}} <span class="required">*</span></label>
<div class="col-sm-6">
<input class="form-control" type="text" id="name" name="name" data-ng-model="policy.name" autofocus required
data-ng-blur="checkNewNameAvailability()">
</div>
<kc-tooltip>{{:: 'authz-policy-name.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">
<input class="form-control" type="text" id="description" name="description"
data-ng-model="policy.description">
</div>
<kc-tooltip>{{:: 'authz-policy-description.tooltip' | translate}}</kc-tooltip>
</div>
<div class="form-group clearfix">
<label class="col-md-2 control-label" for="clientScopes">{{:: 'client-scopes' | translate}} <span
class="required">*</span></label>
<div class="col-md-4">
<select ui-select2="{ minimumInputLength: 1}" id="clientScopes" data-ng-model="selectedClientScope"
data-ng-change="selectClientScope(selectedClientScope);"
data-placeholder="{{:: 'select-a-client-scope' | translate}}..."
ng-options="clientScope as clientScope.name for clientScope in clientScopes"
data-ng-required="selectedClientScopes.length == 0">
<option></option>
</select>
</div>
<kc-tooltip>{{:: 'authz-policy-client-scope-client-scopes.tooltip' | translate}}</kc-tooltip>
</div>
<div class="form-group clearfix" style="margin-top: -15px;">
<label class="col-md-2 control-label"></label>
<div class="col-sm-4">
<table class="table table-striped table-bordered" id="selected-client-scopes">
<thead>
<tr>
<th class="col-sm-5">{{:: 'name' | translate}}</th>
<th>{{:: 'authz-required' | translate}}</th>
<th>{{:: 'actions' | translate}}</th>
</tr>
</thead>
<tbody>
<tr ng-repeat="clientScope in selectedClientScopes | orderBy:'name'">
<td>{{clientScope.name}}</td>
<td><input type="checkbox" ng-model="clientScope.required" id="{{clientScope.id}}"></td>
<td class="kc-action-cell">
<button class="btn btn-default btn-block btn-sm" ng-click="removeFromList(clientScope);">{{::
'remove' | translate}}</button>
</td>
</tr>
<tr data-ng-show="!selectedClientScopes.length">
<td class="text-muted" colspan="3">{{:: 'authz-no-client-scopes-assigned' | translate}}</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="form-group clearfix">
<label class="col-md-2 control-label" for="logic">{{:: 'authz-policy-logic' | translate}}</label>
<div class="col-sm-1">
<select class="form-control" id="logic" data-ng-model="policy.logic">
<option value="POSITIVE">{{:: 'authz-policy-logic-positive' | translate}}</option>
<option value="NEGATIVE">{{:: 'authz-policy-logic-negative' | translate}}</option>
</select>
</div>
<kc-tooltip>{{:: 'authz-policy-logic.tooltip' | translate}}</kc-tooltip>
</div>
<input type="hidden" data-ng-model="policy.type" />
</fieldset>
<div class="form-group" data-ng-show="access.manageAuthorization">
<div class="col-md-10 col-md-offset-2">
<button kc-save data-ng-disabled="!changed">{{:: 'save' | translate}}</button>
<button kc-reset data-ng-disabled="!changed && !historyBackOnSaveOrCancel">{{:: 'cancel' | translate}}</button>
</div>
</div>
</form>
</div>
<kc-menu></kc-menu>