diff --git a/authz/client/src/main/java/org/keycloak/authorization/client/AuthzClient.java b/authz/client/src/main/java/org/keycloak/authorization/client/AuthzClient.java index 64c9632a42..5082adc038 100644 --- a/authz/client/src/main/java/org/keycloak/authorization/client/AuthzClient.java +++ b/authz/client/src/main/java/org/keycloak/authorization/client/AuthzClient.java @@ -188,7 +188,12 @@ public class AuthzClient { * @return a {@link AuthorizationResource} */ 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) { - 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); } } \ No newline at end of file diff --git a/authz/client/src/main/java/org/keycloak/authorization/client/util/HttpMethodAuthenticator.java b/authz/client/src/main/java/org/keycloak/authorization/client/util/HttpMethodAuthenticator.java index 64f0f6f0be..3d3764b85e 100644 --- a/authz/client/src/main/java/org/keycloak/authorization/client/util/HttpMethodAuthenticator.java +++ b/authz/client/src/main/java/org/keycloak/authorization/client/util/HttpMethodAuthenticator.java @@ -48,10 +48,16 @@ public class HttpMethodAuthenticator { } public HttpMethod oauth2ResourceOwnerPassword(String userName, String password) { + return oauth2ResourceOwnerPassword(userName, password, null); + } + + public HttpMethod oauth2ResourceOwnerPassword(String userName, String password, String scope) { client(); this.method.params.put(OAuth2Constants.GRANT_TYPE, Arrays.asList(OAuth2Constants.PASSWORD)); this.method.params.put("username", Arrays.asList(userName)); this.method.params.put("password", Arrays.asList(password)); + if (scope != null) + this.method.params.put("scope", Arrays.asList(scope)); return this.method; } diff --git a/authz/client/src/main/java/org/keycloak/authorization/client/util/TokenCallable.java b/authz/client/src/main/java/org/keycloak/authorization/client/util/TokenCallable.java index 342b9b3114..383db674b4 100644 --- a/authz/client/src/main/java/org/keycloak/authorization/client/util/TokenCallable.java +++ b/authz/client/src/main/java/org/keycloak/authorization/client/util/TokenCallable.java @@ -33,19 +33,27 @@ public class TokenCallable implements Callable { private static Logger log = Logger.getLogger(TokenCallable.class); private final String userName; private final String password; + private final String scope; private final Http http; private final Configuration configuration; private final ServerConfiguration serverConfiguration; 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.password = password; + this.scope = scope; this.http = http; this.configuration = configuration; 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) { this(null, null, http, configuration, serverConfiguration); } @@ -121,12 +129,12 @@ public class TokenCallable implements Callable { * @return an {@link AccessTokenResponse} */ AccessTokenResponse resourceOwnerPasswordGrant(String userName, String password) { - return this.http.post(this.serverConfiguration.getTokenEndpoint()) - .authentication() - .oauth2ResourceOwnerPassword(userName, password) - .response() - .json(AccessTokenResponse.class) - .execute(); + return resourceOwnerPasswordGrant(userName, password, null); + } + + AccessTokenResponse resourceOwnerPasswordGrant(String userName, String password, String scope) { + return this.http.post(this.serverConfiguration.getTokenEndpoint()).authentication() + .oauth2ResourceOwnerPassword(userName, password, scope).response().json(AccessTokenResponse.class).execute(); } private AccessTokenResponse refreshToken(String rawRefreshToken) { @@ -144,6 +152,8 @@ public class TokenCallable implements Callable { private AccessTokenResponse obtainTokens() { if (userName == null || password == null) { return clientCredentialsGrant(); + } else if (scope != null) { + return resourceOwnerPasswordGrant(userName, password, scope); } else { return resourceOwnerPasswordGrant(userName, password); } diff --git a/authz/policy/common/src/main/java/org/keycloak/authorization/policy/provider/clientscope/ClientScopePolicyProvider.java b/authz/policy/common/src/main/java/org/keycloak/authorization/policy/provider/clientscope/ClientScopePolicyProvider.java new file mode 100644 index 0000000000..13b4806221 --- /dev/null +++ b/authz/policy/common/src/main/java/org/keycloak/authorization/policy/provider/clientscope/ClientScopePolicyProvider.java @@ -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 Yoshiyuki Tabata + */ +public class ClientScopePolicyProvider implements PolicyProvider { + + private final BiFunction representationFunction; + + public ClientScopePolicyProvider( + BiFunction representationFunction) { + this.representationFunction = representationFunction; + } + + @Override + public void close() { + } + + @Override + public void evaluate(Evaluation evaluation) { + Policy policy = evaluation.getPolicy(); + Set 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; + } + +} diff --git a/authz/policy/common/src/main/java/org/keycloak/authorization/policy/provider/clientscope/ClientScopePolicyProviderFactory.java b/authz/policy/common/src/main/java/org/keycloak/authorization/policy/provider/clientscope/ClientScopePolicyProviderFactory.java new file mode 100644 index 0000000000..a787603f0a --- /dev/null +++ b/authz/policy/common/src/main/java/org/keycloak/authorization/policy/provider/clientscope/ClientScopePolicyProviderFactory.java @@ -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 Yoshiyuki Tabata + */ +public class ClientScopePolicyProviderFactory implements PolicyProviderFactory { + + 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 filters = new HashMap<>(); + + filters.put(Policy.FilterOption.TYPE, new String[] { getId() }); + + policyStore.findByResourceServer(filters, null, -1, -1).forEach(new Consumer() { + + @Override + public void accept(Policy policy) { + List> clientScopes = new ArrayList<>(); + + for (Map clientScope : getClientScopes(policy)) { + if (!clientScope.get("id").equals(removedClientScope.getId())) { + Map 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[] 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 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 config = new HashMap<>(); + Set 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 clientScopes) { + RealmModel realm = authorization.getRealm(); + Set 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); + } + } +} diff --git a/authz/policy/common/src/main/resources/META-INF/services/org.keycloak.authorization.policy.provider.PolicyProviderFactory b/authz/policy/common/src/main/resources/META-INF/services/org.keycloak.authorization.policy.provider.PolicyProviderFactory index 2d8c4f3f9d..d5f00400e4 100644 --- a/authz/policy/common/src/main/resources/META-INF/services/org.keycloak.authorization.policy.provider.PolicyProviderFactory +++ b/authz/policy/common/src/main/resources/META-INF/services/org.keycloak.authorization.policy.provider.PolicyProviderFactory @@ -43,4 +43,5 @@ org.keycloak.authorization.policy.provider.time.TimePolicyProviderFactory org.keycloak.authorization.policy.provider.user.UserPolicyProviderFactory org.keycloak.authorization.policy.provider.client.ClientPolicyProviderFactory org.keycloak.authorization.policy.provider.group.GroupPolicyProviderFactory -org.keycloak.authorization.policy.provider.permission.UMAPolicyProviderFactory \ No newline at end of file +org.keycloak.authorization.policy.provider.permission.UMAPolicyProviderFactory +org.keycloak.authorization.policy.provider.clientscope.ClientScopePolicyProviderFactory \ No newline at end of file diff --git a/core/src/main/java/org/keycloak/representations/idm/authorization/ClientScopePolicyRepresentation.java b/core/src/main/java/org/keycloak/representations/idm/authorization/ClientScopePolicyRepresentation.java new file mode 100644 index 0000000000..624c415434 --- /dev/null +++ b/core/src/main/java/org/keycloak/representations/idm/authorization/ClientScopePolicyRepresentation.java @@ -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 Yoshiyuki Tabata + */ +public class ClientScopePolicyRepresentation extends AbstractPolicyRepresentation { + + private Set clientScopes; + + @Override + public String getType() { + return "client-scope"; + } + + public Set getClientScopes() { + return clientScopes; + } + + public void setClientScopes(Set 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; + } + + } +} diff --git a/integration/admin-client/src/main/java/org/keycloak/admin/client/resource/ClientScopePoliciesResource.java b/integration/admin-client/src/main/java/org/keycloak/admin/client/resource/ClientScopePoliciesResource.java new file mode 100644 index 0000000000..4cbc3762f0 --- /dev/null +++ b/integration/admin-client/src/main/java/org/keycloak/admin/client/resource/ClientScopePoliciesResource.java @@ -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 Yoshiyuki Tabata + */ +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); +} diff --git a/integration/admin-client/src/main/java/org/keycloak/admin/client/resource/PoliciesResource.java b/integration/admin-client/src/main/java/org/keycloak/admin/client/resource/PoliciesResource.java index 411dba91f4..5a6f7c39d0 100644 --- a/integration/admin-client/src/main/java/org/keycloak/admin/client/resource/PoliciesResource.java +++ b/integration/admin-client/src/main/java/org/keycloak/admin/client/resource/PoliciesResource.java @@ -103,4 +103,7 @@ public interface PoliciesResource { @Path("group") GroupPoliciesResource group(); + + @Path("client-scope") + ClientScopePoliciesResource clientScope(); } diff --git a/model/jpa/src/main/java/org/keycloak/models/jpa/JpaRealmProvider.java b/model/jpa/src/main/java/org/keycloak/models/jpa/JpaRealmProvider.java index 9e8a2bbcc1..9bbed5d148 100644 --- a/model/jpa/src/main/java/org/keycloak/models/jpa/JpaRealmProvider.java +++ b/model/jpa/src/main/java/org/keycloak/models/jpa/JpaRealmProvider.java @@ -786,6 +786,20 @@ public class JpaRealmProvider implements RealmProvider, ClientProvider, ClientSc em.createNamedQuery("deleteClientScopeRoleMappingByClientScope").setParameter("clientScope", clientScopeEntity).executeUpdate(); em.remove(clientScopeEntity); + + session.getKeycloakSessionFactory().publish(new ClientScopeModel.ClientScopeRemovedEvent() { + + @Override + public KeycloakSession getKeycloakSession() { + return session; + } + + @Override + public ClientScopeModel getClientScope() { + return clientScope; + } + }); + em.flush(); return true; } diff --git a/model/map/src/main/java/org/keycloak/models/map/clientscope/MapClientScopeProvider.java b/model/map/src/main/java/org/keycloak/models/map/clientscope/MapClientScopeProvider.java index 7b996043f6..d9647dbdad 100644 --- a/model/map/src/main/java/org/keycloak/models/map/clientscope/MapClientScopeProvider.java +++ b/model/map/src/main/java/org/keycloak/models/map/clientscope/MapClientScopeProvider.java @@ -124,6 +124,19 @@ public class MapClientScopeProvider implements ClientScopeProvider { session.users().preRemove(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)); return true; } diff --git a/server-spi/src/main/java/org/keycloak/models/ClientScopeModel.java b/server-spi/src/main/java/org/keycloak/models/ClientScopeModel.java index 4ecc9e5b64..d2f9c56fe4 100755 --- a/server-spi/src/main/java/org/keycloak/models/ClientScopeModel.java +++ b/server-spi/src/main/java/org/keycloak/models/ClientScopeModel.java @@ -20,6 +20,7 @@ package org.keycloak.models; import java.util.Map; import org.keycloak.common.util.ObjectUtil; +import org.keycloak.provider.ProviderEvent; import org.keycloak.storage.SearchableModelField; /** @@ -34,6 +35,12 @@ public interface ClientScopeModel extends ProtocolMapperContainerModel, ScopeCon public static final SearchableModelField NAME = new SearchableModelField<>("name", String.class); } + interface ClientScopeRemovedEvent extends ProviderEvent { + ClientScopeModel getClientScope(); + + KeycloakSession getKeycloakSession(); + } + String getId(); String getName(); diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/authz/ClientScopePolicyTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/authz/ClientScopePolicyTest.java new file mode 100644 index 0000000000..52ee41c866 --- /dev/null +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/authz/ClientScopePolicyTest.java @@ -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 Yoshiyuki Tabata + */ +@AuthServerContainerExclude(AuthServer.REMOTE) +public class ClientScopePolicyTest extends AbstractAuthzTest { + + @Override + public void addTestRealms(List 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")); + } +} diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/util/ClientBuilder.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/util/ClientBuilder.java index 2a3523840f..42cd2db345 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/util/ClientBuilder.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/util/ClientBuilder.java @@ -79,6 +79,14 @@ public class ClientBuilder { 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() { rep.setServiceAccountsEnabled(true); return this; diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/util/ClientScopeBuilder.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/util/ClientScopeBuilder.java new file mode 100644 index 0000000000..f408529b22 --- /dev/null +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/util/ClientScopeBuilder.java @@ -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 Yoshiyuki Tabata + */ +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; + } +} diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/util/RealmBuilder.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/util/RealmBuilder.java index af18b514c6..58a02f2b02 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/util/RealmBuilder.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/util/RealmBuilder.java @@ -18,6 +18,7 @@ package org.keycloak.testsuite.util; import org.keycloak.representations.idm.ClientRepresentation; +import org.keycloak.representations.idm.ClientScopeRepresentation; import org.keycloak.representations.idm.GroupRepresentation; import org.keycloak.representations.idm.IdentityProviderRepresentation; import org.keycloak.representations.idm.RealmRepresentation; @@ -132,6 +133,18 @@ public class RealmBuilder { 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) { return identityProvider(identityProvider.build()); } diff --git a/themes/src/main/resources/theme/base/admin/messages/admin-messages_en.properties b/themes/src/main/resources/theme/base/admin/messages/admin-messages_en.properties index cae64c1220..ce9a857cbf 100644 --- a/themes/src/main/resources/theme/base/admin/messages/admin-messages_en.properties +++ b/themes/src/main/resources/theme/base/admin/messages/admin-messages_en.properties @@ -1628,6 +1628,11 @@ authz-no-groups-assigned=No groups assigned. 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-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-no-permissions-available=No permissions available. diff --git a/themes/src/main/resources/theme/base/admin/resources/js/authz/authz-app.js b/themes/src/main/resources/theme/base/admin/resources/js/authz/authz-app.js index 338cde37cd..5c4e29b2e0 100644 --- a/themes/src/main/resources/theme/base/admin/resources/js/authz/authz-app.js +++ b/themes/src/main/resources/theme/base/admin/resources/js/authz/authz-app.js @@ -396,6 +396,28 @@ module.config(['$routeProvider', function ($routeProvider) { } }, 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', { templateUrl : resourceUrl + '/partials/authz/mgmt/realm-role-permissions.html', resolve : { diff --git a/themes/src/main/resources/theme/base/admin/resources/js/authz/authz-controller.js b/themes/src/main/resources/theme/base/admin/resources/js/authz/authz-controller.js index ab041c655f..f4c533d273 100644 --- a/themes/src/main/resources/theme/base/admin/resources/js/authz/authz-controller.js +++ b/themes/src/main/resources/theme/base/admin/resources/js/authz/authz-controller.js @@ -2093,6 +2093,105 @@ module.controller('ResourceServerPolicyAggregateDetailCtrl', function($scope, $r }, 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) { var PolicyController = {}; diff --git a/themes/src/main/resources/theme/base/admin/resources/partials/authz/policy/provider/resource-server-policy-client-scope-detail.html b/themes/src/main/resources/theme/base/admin/resources/partials/authz/policy/provider/resource-server-policy-client-scope-detail.html new file mode 100644 index 0000000000..e48f550a07 --- /dev/null +++ b/themes/src/main/resources/theme/base/admin/resources/partials/authz/policy/provider/resource-server-policy-client-scope-detail.html @@ -0,0 +1,126 @@ + + +
+ + + +

{{:: 'authz-add-client-scope-policy' | translate}}

+

+ {{originalPolicy.name|capitalize}} +

+ +
+
+
+ +
+ +
+ {{:: 'authz-policy-name.tooltip' | translate}} +
+
+ +
+ +
+ {{:: 'authz-policy-description.tooltip' | translate}} +
+
+ + +
+ +
+ + {{:: 'authz-policy-client-scope-client-scopes.tooltip' | translate}} +
+
+ +
+ + + + + + + + + + + + + + + + + + +
{{:: 'name' | translate}}{{:: 'authz-required' | translate}}{{:: 'actions' | translate}}
{{clientScope.name}} + +
{{:: 'authz-no-client-scopes-assigned' | translate}}
+
+
+
+ + +
+ +
+ + {{:: 'authz-policy-logic.tooltip' | translate}} +
+ +
+
+
+ + +
+
+
+
+ + \ No newline at end of file