From 035ebc881abfe78544861f394c30b1dd9623f879 Mon Sep 17 00:00:00 2001 From: pedroigor Date: Fri, 6 Apr 2018 10:50:55 -0300 Subject: [PATCH] [KEYCLOAK-4903] - Claim Information point Provider SPI and configuration --- .../authorization/AbstractPolicyEnforcer.java | 41 +- .../BearerTokenPolicyEnforcer.java | 81 --- .../ClaimInformationPointProvider.java | 30 + .../ClaimInformationPointProviderFactory.java | 33 ++ .../KeycloakAdapterPolicyEnforcer.java | 145 ++++- .../authorization/PolicyEnforcer.java | 44 +- .../cip/ClaimsInformationPointProvider.java | 76 +++ ...ClaimsInformationPointProviderFactory.java | 43 ++ .../HttpClaimInformationPointProvider.java | 210 +++++++ ...pClaimInformationPointProviderFactory.java | 45 ++ .../authorization/util/JsonUtils.java | 58 ++ ...oakSecurityContextPlaceHolderResolver.java | 67 +++ .../util/PlaceHolderResolver.java | 30 + .../authorization/util/PlaceHolders.java | 107 ++++ .../util/RequestPlaceHolderResolver.java | 175 ++++++ ...ation.ClaimInformationPointProviderFactory | 19 + .../adapters/elytron/ElytronHttpFacade.java | 2 +- .../keycloak/representations/AccessToken.java | 11 + .../adapters/config/PolicyEnforcerConfig.java | 12 + .../authorization/AuthorizationRequest.java | 6 +- .../idm/authorization/Permission.java | 22 +- .../authorization/PermissionTicketToken.java | 4 + .../AuthorizationTokenService.java | 46 +- .../common/DefaultEvaluationContext.java | 17 +- .../common/KeycloakEvaluationContext.java | 3 +- .../common/KeycloakIdentity.java | 3 +- .../permission/AbstractPermissionService.java | 15 +- .../oidc/endpoints/TokenEndpoint.java | 28 +- ...claim-information-point-authz-service.json | 25 + .../servlet-authz-app-authz-service.json | 24 + .../main/webapp/protected/context/context.jsp | 14 + .../AbstractServletAuthzCIPAdapterTest.java | 59 ++ .../ClaimInformationPointProviderTest.java | 457 ++++++++++++++++ .../authorization/EnforcerConfigTest.java | 28 +- .../authorization/PolicyEnforcerTest.java | 512 ++++++++++++++++++ .../testsuite/authz/EntitlementAPITest.java | 71 +++ .../authz/PermissionManagementTest.java | 2 +- .../default-keycloak-public-client.json | 5 + .../enforcer-config-claims-provider.json | 89 +++ .../enforcer-config-path-cip.json | 33 ++ .../enforcer-entitlement-claims-test.json | 29 + .../enforcer-uma-claims-test.json | 31 ++ .../WildflyServletAuthzCIPAdapterTest.java | 31 ++ 43 files changed, 2622 insertions(+), 161 deletions(-) delete mode 100644 adapters/oidc/adapter-core/src/main/java/org/keycloak/adapters/authorization/BearerTokenPolicyEnforcer.java create mode 100644 adapters/oidc/adapter-core/src/main/java/org/keycloak/adapters/authorization/ClaimInformationPointProvider.java create mode 100644 adapters/oidc/adapter-core/src/main/java/org/keycloak/adapters/authorization/ClaimInformationPointProviderFactory.java create mode 100644 adapters/oidc/adapter-core/src/main/java/org/keycloak/adapters/authorization/cip/ClaimsInformationPointProvider.java create mode 100644 adapters/oidc/adapter-core/src/main/java/org/keycloak/adapters/authorization/cip/ClaimsInformationPointProviderFactory.java create mode 100644 adapters/oidc/adapter-core/src/main/java/org/keycloak/adapters/authorization/cip/HttpClaimInformationPointProvider.java create mode 100644 adapters/oidc/adapter-core/src/main/java/org/keycloak/adapters/authorization/cip/HttpClaimInformationPointProviderFactory.java create mode 100644 adapters/oidc/adapter-core/src/main/java/org/keycloak/adapters/authorization/util/JsonUtils.java create mode 100644 adapters/oidc/adapter-core/src/main/java/org/keycloak/adapters/authorization/util/KeycloakSecurityContextPlaceHolderResolver.java create mode 100644 adapters/oidc/adapter-core/src/main/java/org/keycloak/adapters/authorization/util/PlaceHolderResolver.java create mode 100644 adapters/oidc/adapter-core/src/main/java/org/keycloak/adapters/authorization/util/PlaceHolders.java create mode 100644 adapters/oidc/adapter-core/src/main/java/org/keycloak/adapters/authorization/util/RequestPlaceHolderResolver.java create mode 100644 adapters/oidc/adapter-core/src/main/resources/META-INF/services/org.keycloak.adapters.authorization.ClaimInformationPointProviderFactory create mode 100644 testsuite/integration-arquillian/test-apps/servlet-authz/keycloak-claim-information-point-authz-service.json create mode 100644 testsuite/integration-arquillian/test-apps/servlet-authz/src/main/webapp/protected/context/context.jsp create mode 100644 testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/adapter/example/authorization/AbstractServletAuthzCIPAdapterTest.java create mode 100644 testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/client/authorization/ClaimInformationPointProviderTest.java create mode 100644 testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/client/authorization/PolicyEnforcerTest.java create mode 100644 testsuite/integration-arquillian/tests/base/src/test/resources/authorization-test/default-keycloak-public-client.json create mode 100644 testsuite/integration-arquillian/tests/base/src/test/resources/authorization-test/enforcer-config-claims-provider.json create mode 100644 testsuite/integration-arquillian/tests/base/src/test/resources/authorization-test/enforcer-config-path-cip.json create mode 100644 testsuite/integration-arquillian/tests/base/src/test/resources/authorization-test/enforcer-entitlement-claims-test.json create mode 100644 testsuite/integration-arquillian/tests/base/src/test/resources/authorization-test/enforcer-uma-claims-test.json create mode 100644 testsuite/integration-arquillian/tests/other/adapters/jboss/wildfly/src/test/java/org/keycloak/testsuite/adapter/example/authorization/WildflyServletAuthzCIPAdapterTest.java diff --git a/adapters/oidc/adapter-core/src/main/java/org/keycloak/adapters/authorization/AbstractPolicyEnforcer.java b/adapters/oidc/adapter-core/src/main/java/org/keycloak/adapters/authorization/AbstractPolicyEnforcer.java index 402ed48d1e..c54191f931 100644 --- a/adapters/oidc/adapter-core/src/main/java/org/keycloak/adapters/authorization/AbstractPolicyEnforcer.java +++ b/adapters/oidc/adapter-core/src/main/java/org/keycloak/adapters/authorization/AbstractPolicyEnforcer.java @@ -18,7 +18,10 @@ package org.keycloak.adapters.authorization; import java.util.Collections; +import java.util.HashMap; import java.util.List; +import java.util.Map; +import java.util.Map.Entry; import java.util.Set; import org.jboss.logging.Logger; @@ -161,7 +164,8 @@ public abstract class AbstractPolicyEnforcer { if (HTTP_METHOD_DELETE.equalsIgnoreCase(request.getMethod()) && actualPathConfig.isInstance()) { policyEnforcer.getPathMatcher().removeFromCache(getPath(request)); } - return true; + + return hasValidClaims(actualPathConfig, httpFacade, authorization); } } } else { @@ -183,6 +187,41 @@ public abstract class AbstractPolicyEnforcer { return false; } + private boolean hasValidClaims(PathConfig actualPathConfig, OIDCHttpFacade httpFacade, Authorization authorization) { + Map> claimInformationPointConfig = actualPathConfig.getClaimInformationPointConfig(); + + if (claimInformationPointConfig != null) { + Map> claims = new HashMap<>(); + + for (Entry> entry : claimInformationPointConfig.entrySet()) { + ClaimInformationPointProviderFactory factory = policyEnforcer.getClaimInformationPointProviderFactories().get(entry.getKey()); + + if (factory == null) { + throw new RuntimeException("Could not find claim information provider with name [" + entry.getKey() + "]"); + } + + claims.putAll(factory.create(entry.getValue()).resolve(httpFacade)); + } + + Map> grantedClaims = authorization.getClaims(); + + if (grantedClaims != null) { + if (claims.isEmpty()) { + return false; + } + for (Entry> entry : grantedClaims.entrySet()) { + List requestClaims = claims.get(entry.getKey()); + + if (requestClaims == null || requestClaims.isEmpty() || !entry.getValue().containsAll(requestClaims)) { + return false; + } + } + } + } + + return true; + } + protected void handleAccessDenied(OIDCHttpFacade httpFacade) { httpFacade.getResponse().sendError(403); } diff --git a/adapters/oidc/adapter-core/src/main/java/org/keycloak/adapters/authorization/BearerTokenPolicyEnforcer.java b/adapters/oidc/adapter-core/src/main/java/org/keycloak/adapters/authorization/BearerTokenPolicyEnforcer.java deleted file mode 100644 index 9e29735239..0000000000 --- a/adapters/oidc/adapter-core/src/main/java/org/keycloak/adapters/authorization/BearerTokenPolicyEnforcer.java +++ /dev/null @@ -1,81 +0,0 @@ -/* - * Copyright 2016 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.adapters.authorization; - -import java.util.HashSet; - -import org.jboss.logging.Logger; -import org.keycloak.adapters.OIDCHttpFacade; -import org.keycloak.adapters.spi.HttpFacade; -import org.keycloak.authorization.client.AuthzClient; -import org.keycloak.authorization.client.resource.PermissionResource; -import org.keycloak.authorization.client.resource.ProtectionResource; -import org.keycloak.representations.adapters.config.PolicyEnforcerConfig; -import org.keycloak.representations.adapters.config.PolicyEnforcerConfig.PathConfig; -import org.keycloak.representations.idm.authorization.PermissionRequest; - -/** - * @author Pedro Igor - */ -public class BearerTokenPolicyEnforcer extends AbstractPolicyEnforcer { - - private static Logger LOGGER = Logger.getLogger(BearerTokenPolicyEnforcer.class); - - public BearerTokenPolicyEnforcer(PolicyEnforcer enforcer) { - super(enforcer); - } - - @Override - protected boolean challenge(PathConfig pathConfig, PolicyEnforcerConfig.MethodConfig methodConfig, OIDCHttpFacade facade) { - HttpFacade.Response response = facade.getResponse(); - AuthzClient authzClient = getAuthzClient(); - String ticket = getPermissionTicket(pathConfig, methodConfig, authzClient); - - if (ticket == null) { - response.setStatus(403); - return true; - } - - String realm = authzClient.getConfiguration().getRealm(); - String authorizationServerUri = authzClient.getServerConfiguration().getIssuer().toString(); - response.setStatus(401); - StringBuilder wwwAuthenticate = new StringBuilder("UMA realm=\"").append(realm).append("\"").append(",as_uri=\"").append(authorizationServerUri).append("\""); - - if (ticket != null) { - wwwAuthenticate.append(",ticket=\"").append(ticket).append("\""); - } - - response.setHeader("WWW-Authenticate", wwwAuthenticate.toString()); - if (LOGGER.isDebugEnabled()) { - LOGGER.debug("Sending UMA challenge"); - } - return true; - } - - private String getPermissionTicket(PathConfig pathConfig, PolicyEnforcerConfig.MethodConfig methodConfig, AuthzClient authzClient) { - if (getEnforcerConfig().getUserManagedAccess() != null) { - ProtectionResource protection = authzClient.protection(); - PermissionResource permission = protection.permission(); - PermissionRequest permissionRequest = new PermissionRequest(); - permissionRequest.setResourceId(pathConfig.getId()); - permissionRequest.setScopes(new HashSet<>(methodConfig.getScopes())); - return permission.create(permissionRequest).getTicket(); - } - return null; - } -} \ No newline at end of file diff --git a/adapters/oidc/adapter-core/src/main/java/org/keycloak/adapters/authorization/ClaimInformationPointProvider.java b/adapters/oidc/adapter-core/src/main/java/org/keycloak/adapters/authorization/ClaimInformationPointProvider.java new file mode 100644 index 0000000000..fb594ef177 --- /dev/null +++ b/adapters/oidc/adapter-core/src/main/java/org/keycloak/adapters/authorization/ClaimInformationPointProvider.java @@ -0,0 +1,30 @@ +/* + * Copyright 2018 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.adapters.authorization; + +import java.util.List; +import java.util.Map; + +import org.keycloak.adapters.spi.HttpFacade; + +/** + * @author Pedro Igor + */ +public interface ClaimInformationPointProvider { + + Map> resolve(HttpFacade httpFacade); +} diff --git a/adapters/oidc/adapter-core/src/main/java/org/keycloak/adapters/authorization/ClaimInformationPointProviderFactory.java b/adapters/oidc/adapter-core/src/main/java/org/keycloak/adapters/authorization/ClaimInformationPointProviderFactory.java new file mode 100644 index 0000000000..894debc76a --- /dev/null +++ b/adapters/oidc/adapter-core/src/main/java/org/keycloak/adapters/authorization/ClaimInformationPointProviderFactory.java @@ -0,0 +1,33 @@ +/* + * Copyright 2018 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.adapters.authorization; + +import java.util.Map; + +import org.keycloak.adapters.spi.HttpFacade; + +/** + * @author Pedro Igor + */ +public interface ClaimInformationPointProviderFactory { + + String getName(); + + void init(PolicyEnforcer policyEnforcer); + + C create(Map config); +} diff --git a/adapters/oidc/adapter-core/src/main/java/org/keycloak/adapters/authorization/KeycloakAdapterPolicyEnforcer.java b/adapters/oidc/adapter-core/src/main/java/org/keycloak/adapters/authorization/KeycloakAdapterPolicyEnforcer.java index 0732ee9c3b..4e87c907b1 100644 --- a/adapters/oidc/adapter-core/src/main/java/org/keycloak/adapters/authorization/KeycloakAdapterPolicyEnforcer.java +++ b/adapters/oidc/adapter-core/src/main/java/org/keycloak/adapters/authorization/KeycloakAdapterPolicyEnforcer.java @@ -18,7 +18,11 @@ package org.keycloak.adapters.authorization; import java.util.ArrayList; +import java.util.HashMap; import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Map.Entry; import org.jboss.logging.Logger; import org.keycloak.KeycloakSecurityContext; @@ -28,6 +32,9 @@ import org.keycloak.adapters.rotation.AdapterRSATokenVerifier; import org.keycloak.adapters.spi.HttpFacade; import org.keycloak.authorization.client.AuthorizationDeniedException; import org.keycloak.authorization.client.AuthzClient; +import org.keycloak.authorization.client.resource.PermissionResource; +import org.keycloak.authorization.client.resource.ProtectionResource; +import org.keycloak.common.util.Base64; import org.keycloak.representations.AccessToken; import org.keycloak.representations.adapters.config.PolicyEnforcerConfig; import org.keycloak.representations.adapters.config.PolicyEnforcerConfig.PathConfig; @@ -35,7 +42,7 @@ import org.keycloak.representations.idm.authorization.AuthorizationRequest; import org.keycloak.representations.idm.authorization.AuthorizationResponse; import org.keycloak.representations.idm.authorization.Permission; import org.keycloak.representations.idm.authorization.PermissionRequest; -import org.keycloak.representations.idm.authorization.PermissionResponse; +import org.keycloak.util.JsonSerialization; /** * @author Pedro Igor @@ -72,7 +79,27 @@ public class KeycloakAdapterPolicyEnforcer extends AbstractPolicyEnforcer { AccessToken.Authorization newAuthorization = accessToken.getAuthorization(); if (newAuthorization != null) { - authorization.getPermissions().addAll(newAuthorization.getPermissions()); + List grantedPermissions = authorization.getPermissions(); + List newPermissions = newAuthorization.getPermissions(); + + for (Permission newPermission : newPermissions) { + if (!grantedPermissions.contains(newPermission)) { + grantedPermissions.add(newPermission); + } + } + + Map> newClaims = newAuthorization.getClaims(); + + if (newClaims != null) { + Map> claims = authorization.getClaims(); + + if (claims == null) { + claims = new HashMap<>(); + authorization.setClaims(claims); + } + + claims.putAll(newClaims); + } } original.setAuthorization(authorization); @@ -81,8 +108,29 @@ public class KeycloakAdapterPolicyEnforcer extends AbstractPolicyEnforcer { } @Override - protected boolean challenge(PathConfig pathConfig, PolicyEnforcerConfig.MethodConfig methodConfig, OIDCHttpFacade facade) { - handleAccessDenied(facade); + protected boolean challenge(PathConfig pathConfig, PolicyEnforcerConfig.MethodConfig methodConfig, OIDCHttpFacade httpFacade) { + if (isBearerAuthorization(httpFacade)) { + HttpFacade.Response response = httpFacade.getResponse(); + AuthzClient authzClient = getAuthzClient(); + String ticket = getPermissionTicket(pathConfig, methodConfig, authzClient, httpFacade); + + if (ticket != null) { + response.setStatus(401); + response.setHeader("WWW-Authenticate", new StringBuilder("UMA realm=\"").append(authzClient.getConfiguration().getRealm()).append("\"").append(",as_uri=\"") + .append(authzClient.getServerConfiguration().getIssuer()).append("\"").append(",ticket=\"").append(ticket).append("\"").toString()); + } else { + response.setStatus(403); + } + + if (LOGGER.isDebugEnabled()) { + LOGGER.debug("Sending challenge"); + } + + return true; + } + + handleAccessDenied(httpFacade); + return true; } @@ -106,28 +154,31 @@ public class KeycloakAdapterPolicyEnforcer extends AbstractPolicyEnforcer { } private AccessToken requestAuthorizationToken(PathConfig pathConfig, PolicyEnforcerConfig.MethodConfig methodConfig, OIDCHttpFacade httpFacade) { + if (getPolicyEnforcer().getDeployment().isBearerOnly() || (isBearerAuthorization(httpFacade) && getEnforcerConfig().getUserManagedAccess() != null)) { + return null; + } + try { KeycloakSecurityContext securityContext = httpFacade.getSecurityContext(); String accessTokenString = securityContext.getTokenString(); - AuthzClient authzClient = getAuthzClient(); KeycloakDeployment deployment = getPolicyEnforcer().getDeployment(); - PermissionRequest permissionRequest = new PermissionRequest(); - - permissionRequest.setResourceId(pathConfig.getId()); - permissionRequest.setScopes(new HashSet<>(methodConfig.getScopes())); - AccessToken accessToken = securityContext.getToken(); - AuthorizationRequest authzRequest; + AuthorizationRequest authzRequest = new AuthorizationRequest(); if (getEnforcerConfig().getUserManagedAccess() != null) { - PermissionResponse permissionResponse = authzClient.protection().permission().create(permissionRequest); - authzRequest = new AuthorizationRequest(); - authzRequest.setTicket(permissionResponse.getTicket()); + String ticket = getPermissionTicket(pathConfig, methodConfig, getAuthzClient(), httpFacade); + authzRequest.setTicket(ticket); } else { - authzRequest = new AuthorizationRequest(); if (accessToken.getAuthorization() != null) { authzRequest.addPermission(pathConfig.getId(), methodConfig.getScopes()); } + + Map> claims = getClaims(pathConfig, httpFacade); + + if (!claims.isEmpty()) { + authzRequest.setClaimTokenFormat("urn:ietf:params:oauth:token-type:jwt"); + authzRequest.setClaimToken(Base64.encodeBytes(JsonSerialization.writeValueAsBytes(claims))); + } } if (accessToken.getAuthorization() != null) { @@ -135,18 +186,70 @@ public class KeycloakAdapterPolicyEnforcer extends AbstractPolicyEnforcer { } LOGGER.debug("Obtaining authorization for authenticated user."); - AuthorizationResponse authzResponse = authzClient.authorization(accessTokenString).authorize(authzRequest); + AuthorizationResponse authzResponse = getAuthzClient().authorization(accessTokenString).authorize(authzRequest); if (authzResponse != null) { return AdapterRSATokenVerifier.verifyToken(authzResponse.getToken(), deployment); } - - return null; - } catch (AuthorizationDeniedException e) { - LOGGER.debug("Authorization denied", e); - return null; + } catch (AuthorizationDeniedException ignore) { + LOGGER.debug("Authorization denied", ignore); } catch (Exception e) { throw new RuntimeException("Unexpected error during authorization request.", e); } + + return null; + } + + private String getPermissionTicket(PathConfig pathConfig, PolicyEnforcerConfig.MethodConfig methodConfig, AuthzClient authzClient, HttpFacade httpFacade) { + if (getEnforcerConfig().getUserManagedAccess() != null) { + ProtectionResource protection = authzClient.protection(); + PermissionResource permission = protection.permission(); + PermissionRequest permissionRequest = new PermissionRequest(); + + permissionRequest.setResourceId(pathConfig.getId()); + permissionRequest.setScopes(new HashSet<>(methodConfig.getScopes())); + + Map> claims = getClaims(pathConfig, httpFacade); + + if (!claims.isEmpty()) { + permissionRequest.setClaims(claims); + } + + return permission.create(permissionRequest).getTicket(); + } + + return null; + } + + private Map> getClaims(PathConfig pathConfig, HttpFacade httpFacade) { + Map> claims = new HashMap<>(); + Map> claimInformationPointConfig = pathConfig.getClaimInformationPointConfig(); + + if (claimInformationPointConfig != null) { + for (Entry> claimDef : claimInformationPointConfig.entrySet()) { + ClaimInformationPointProviderFactory factory = getPolicyEnforcer().getClaimInformationPointProviderFactories().get(claimDef.getKey()); + + if (factory != null) { + claims.putAll(factory.create(claimDef.getValue()).resolve(httpFacade)); + } + } + } + return claims; + } + + private boolean isBearerAuthorization(OIDCHttpFacade httpFacade) { + List authHeaders = httpFacade.getRequest().getHeaders("Authorization"); + if (authHeaders == null || authHeaders.size() == 0) { + return false; + } + + for (String authHeader : authHeaders) { + String[] split = authHeader.trim().split("\\s+"); + if (split == null || split.length != 2) continue; + if (!split[0].equalsIgnoreCase("Bearer")) continue; + return true; + } + + return false; } } diff --git a/adapters/oidc/adapter-core/src/main/java/org/keycloak/adapters/authorization/PolicyEnforcer.java b/adapters/oidc/adapter-core/src/main/java/org/keycloak/adapters/authorization/PolicyEnforcer.java index 2ccdf28a91..50278b94e2 100644 --- a/adapters/oidc/adapter-core/src/main/java/org/keycloak/adapters/authorization/PolicyEnforcer.java +++ b/adapters/oidc/adapter-core/src/main/java/org/keycloak/adapters/authorization/PolicyEnforcer.java @@ -21,10 +21,12 @@ import java.util.Arrays; import java.util.Collection; import java.util.Collections; import java.util.HashMap; +import java.util.Iterator; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Map.Entry; +import java.util.ServiceLoader; import org.jboss.logging.Logger; import org.keycloak.AuthorizationContext; @@ -55,6 +57,7 @@ public class PolicyEnforcer { private final PolicyEnforcerConfig enforcerConfig; private final PathConfigMatcher pathMatcher; private final Map paths; + private final Map claimInformationPointProviderFactories = new HashMap<>(); public PolicyEnforcer(KeycloakDeployment deployment, AdapterConfig adapterConfig) { this.deployment = deployment; @@ -80,20 +83,17 @@ public class PolicyEnforcer { LOGGER.debug(pathConfig); } } + + loadClaimInformationPointProviders(ServiceLoader.load(ClaimInformationPointProviderFactory.class, ClaimInformationPointProviderFactory.class.getClassLoader())); + loadClaimInformationPointProviders(ServiceLoader.load(ClaimInformationPointProviderFactory.class, Thread.currentThread().getContextClassLoader())); } public AuthorizationContext enforce(OIDCHttpFacade facade) { if (LOGGER.isDebugEnabled()) { - LOGGER.debugv("Policy enforcement is enable. Enforcing policy decisions for path [{0}].", facade.getRequest().getURI()); + LOGGER.debugv("Policy enforcement is enabled. Enforcing policy decisions for path [{0}].", facade.getRequest().getURI()); } - AuthorizationContext context; - - if (deployment.isBearerOnly()) { - context = new BearerTokenPolicyEnforcer(this).authorize(facade); - } else { - context = new KeycloakAdapterPolicyEnforcer(this).authorize(facade); - } + AuthorizationContext context = new KeycloakAdapterPolicyEnforcer(this).authorize(facade); if (LOGGER.isDebugEnabled()) { LOGGER.debugv("Policy enforcement result for path [{0}] is : {1}", facade.getRequest().getURI(), context.isGranted() ? "GRANTED" : "DENIED"); @@ -126,6 +126,22 @@ public class PolicyEnforcer { return deployment; } + public Map getClaimInformationPointProviderFactories() { + return claimInformationPointProviderFactories; + } + + private void loadClaimInformationPointProviders(ServiceLoader loader) { + Iterator iterator = loader.iterator(); + + while (iterator.hasNext()) { + ClaimInformationPointProviderFactory factory = iterator.next(); + + factory.init(this); + + claimInformationPointProviderFactories.put(factory.getName(), factory); + } + } + private Map configurePaths(ProtectedResource protectedResource, PolicyEnforcerConfig enforcerConfig) { boolean loadPathsFromServer = true; @@ -164,6 +180,10 @@ public class PolicyEnforcer { LOGGER.debugf("Trying to find resource with uri [%s] for path [%s].", path, path); List resources = protectedResource.findByUri(path); + if (resources.isEmpty()) { + resources = protectedResource.findByMatchingUri(path); + } + if (resources.size() == 1) { resource = resources.get(0); } else if (resources.size() > 1) { @@ -173,16 +193,14 @@ public class PolicyEnforcer { } } - if (resource == null) { - throw new RuntimeException("Could not find matching resource on server with uri [" + path + "] or name [" + resourceName + "]. Make sure you have created a resource on the server that matches with the path configuration."); + if (resource != null) { + pathConfig.setId(resource.getId()); } - pathConfig.setId(resource.getId()); - PathConfig existingPath = null; for (PathConfig current : paths.values()) { - if (current.getId().equals(pathConfig.getId()) && current.getPath().equals(pathConfig.getPath())) { + if (current.getPath().equals(pathConfig.getPath())) { existingPath = current; break; } diff --git a/adapters/oidc/adapter-core/src/main/java/org/keycloak/adapters/authorization/cip/ClaimsInformationPointProvider.java b/adapters/oidc/adapter-core/src/main/java/org/keycloak/adapters/authorization/cip/ClaimsInformationPointProvider.java new file mode 100644 index 0000000000..0221c8dd6a --- /dev/null +++ b/adapters/oidc/adapter-core/src/main/java/org/keycloak/adapters/authorization/cip/ClaimsInformationPointProvider.java @@ -0,0 +1,76 @@ +/* + * Copyright 2018 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.adapters.authorization.cip; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashMap; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.Map.Entry; + +import org.keycloak.adapters.authorization.ClaimInformationPointProvider; +import org.keycloak.adapters.authorization.util.PlaceHolders; +import org.keycloak.adapters.spi.HttpFacade; + +/** + * @author Pedro Igor + */ +public class ClaimsInformationPointProvider implements ClaimInformationPointProvider { + + private final Map config; + + public ClaimsInformationPointProvider(Map config) { + this.config = config; + } + + @Override + public Map> resolve(HttpFacade httpFacade) { + Map> claims = new HashMap<>(); + + for (Entry configEntry : config.entrySet()) { + String claimName = configEntry.getKey(); + Object claimValue = configEntry.getValue(); + List values = new ArrayList<>(); + + if (claimValue instanceof String) { + values = getValues(claimValue.toString(), httpFacade); + } else if (claimValue instanceof Collection) { + Iterator iterator = Collection.class.cast(claimValue).iterator(); + + while (iterator.hasNext()) { + List resolvedValues = getValues(iterator.next().toString(), httpFacade); + + if (!resolvedValues.isEmpty()) { + values.addAll(resolvedValues); + } + } + } + + if (!values.isEmpty()) { + claims.put(claimName, values); + } + } + + return claims; + } + + private List getValues(String value, HttpFacade httpFacade) { + return PlaceHolders.resolve(value, httpFacade); + } +} diff --git a/adapters/oidc/adapter-core/src/main/java/org/keycloak/adapters/authorization/cip/ClaimsInformationPointProviderFactory.java b/adapters/oidc/adapter-core/src/main/java/org/keycloak/adapters/authorization/cip/ClaimsInformationPointProviderFactory.java new file mode 100644 index 0000000000..c86c2013c8 --- /dev/null +++ b/adapters/oidc/adapter-core/src/main/java/org/keycloak/adapters/authorization/cip/ClaimsInformationPointProviderFactory.java @@ -0,0 +1,43 @@ +/* + * Copyright 2018 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.adapters.authorization.cip; + +import java.util.Map; + +import org.keycloak.adapters.authorization.ClaimInformationPointProviderFactory; +import org.keycloak.adapters.authorization.PolicyEnforcer; + +/** + * @author Pedro Igor + */ +public class ClaimsInformationPointProviderFactory implements ClaimInformationPointProviderFactory { + + @Override + public String getName() { + return "claims"; + } + + @Override + public void init(PolicyEnforcer policyEnforcer) { + + } + + @Override + public ClaimsInformationPointProvider create(Map config) { + return new ClaimsInformationPointProvider(config); + } +} diff --git a/adapters/oidc/adapter-core/src/main/java/org/keycloak/adapters/authorization/cip/HttpClaimInformationPointProvider.java b/adapters/oidc/adapter-core/src/main/java/org/keycloak/adapters/authorization/cip/HttpClaimInformationPointProvider.java new file mode 100644 index 0000000000..51efb90e64 --- /dev/null +++ b/adapters/oidc/adapter-core/src/main/java/org/keycloak/adapters/authorization/cip/HttpClaimInformationPointProvider.java @@ -0,0 +1,210 @@ +/* + * Copyright 2018 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.adapters.authorization.cip; + +import java.io.BufferedInputStream; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.Charset; +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashMap; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.Map.Entry; + +import com.fasterxml.jackson.databind.JsonNode; +import org.apache.http.HttpEntity; +import org.apache.http.HttpResponse; +import org.apache.http.StatusLine; +import org.apache.http.client.HttpClient; +import org.apache.http.client.methods.RequestBuilder; +import org.apache.http.util.EntityUtils; +import org.keycloak.adapters.authorization.ClaimInformationPointProvider; +import org.keycloak.adapters.authorization.PolicyEnforcer; +import org.keycloak.adapters.authorization.util.JsonUtils; +import org.keycloak.adapters.authorization.util.PlaceHolders; +import org.keycloak.adapters.spi.HttpFacade; +import org.keycloak.authorization.client.util.HttpResponseException; +import org.keycloak.common.util.StreamUtil; +import org.keycloak.util.JsonSerialization; + +/** + * @author Pedro Igor + */ +public class HttpClaimInformationPointProvider implements ClaimInformationPointProvider { + + private final Map config; + private final HttpClient httpClient; + + public HttpClaimInformationPointProvider(Map config, PolicyEnforcer policyEnforcer) { + this.config = config; + this.httpClient = policyEnforcer.getDeployment().getClient(); + } + + @Override + public Map> resolve(HttpFacade httpFacade) { + try { + InputStream responseStream = executeRequest(httpFacade); + + try (InputStream inputStream = new BufferedInputStream(responseStream)) { + JsonNode jsonNode = JsonSerialization.mapper.readTree(inputStream); + Map> claims = new HashMap<>(); + Map claimsDef = (Map) config.get("claims"); + + if (claimsDef == null) { + Iterator nodeNames = jsonNode.fieldNames(); + + while (nodeNames.hasNext()) { + String nodeName = nodeNames.next(); + claims.put(nodeName, JsonUtils.getValues(jsonNode.get(nodeName))); + } + } else { + for (Entry claimDef : claimsDef.entrySet()) { + List jsonPaths = new ArrayList<>(); + + if (claimDef.getValue() instanceof Collection) { + jsonPaths.addAll(Collection.class.cast(claimDef.getValue())); + } else { + jsonPaths.add(claimDef.getValue().toString()); + } + + List claimValues = new ArrayList<>(); + + for (String path : jsonPaths) { + claimValues.addAll(JsonUtils.getValues(jsonNode, path)); + } + + claims.put(claimDef.getKey(), claimValues); + } + } + + return claims; + } + } catch (IOException cause) { + throw new RuntimeException("Could not obtain claims from http claim information point [" + config.get("url") + "] response", cause); + } + } + + private InputStream executeRequest(HttpFacade httpFacade) { + String method = config.get("method").toString(); + + if (method == null) { + method = "GET"; + } + + RequestBuilder builder = null; + + if ("GET".equalsIgnoreCase(method)) { + builder = RequestBuilder.get(); + } else { + builder = RequestBuilder.post(); + } + + builder.setUri(config.get("url").toString()); + + byte[] bytes = new byte[0]; + + try { + setParameters(builder, httpFacade); + + if (config.containsKey("headers")) { + setHeaders(builder, httpFacade); + } + + HttpResponse response = httpClient.execute(builder.build()); + HttpEntity entity = response.getEntity(); + + if (entity != null) { + bytes = EntityUtils.toByteArray(entity); + } + + StatusLine statusLine = response.getStatusLine(); + int statusCode = statusLine.getStatusCode(); + + if (statusCode < 200 || statusCode >= 300) { + throw new HttpResponseException("Unexpected response from server: " + statusCode + " / " + statusLine.getReasonPhrase(), statusCode, statusLine.getReasonPhrase(), bytes); + } + + return new ByteArrayInputStream(bytes); + } catch (Exception cause) { + try { + throw new RuntimeException("Error executing http method [" + builder + "]. Response : " + StreamUtil.readString(new ByteArrayInputStream(bytes), Charset.forName("UTF-8")), cause); + } catch (Exception e) { + throw new RuntimeException("Error executing http method [" + builder + "]", cause); + } + } + } + + private void setHeaders(RequestBuilder builder, HttpFacade httpFacade) { + Object headersDef = config.get("headers"); + + if (headersDef != null) { + Map headers = Map.class.cast(headersDef); + + for (Entry header : headers.entrySet()) { + Object value = header.getValue(); + List headerValues = new ArrayList<>(); + + if (value instanceof Collection) { + Collection values = Collection.class.cast(value); + Iterator iterator = values.iterator(); + + while (iterator.hasNext()) { + headerValues.addAll(PlaceHolders.resolve(iterator.next().toString(), httpFacade)); + } + } else { + headerValues.addAll(PlaceHolders.resolve(value.toString(), httpFacade)); + } + + for (String headerValue : headerValues) { + builder.addHeader(header.getKey(), headerValue); + } + } + } + } + + private void setParameters(RequestBuilder builder, HttpFacade httpFacade) { + Object config = this.config.get("parameters"); + + if (config != null) { + Map paramsDef = Map.class.cast(config); + + for (Entry paramDef : paramsDef.entrySet()) { + Object value = paramDef.getValue(); + List paramValues = new ArrayList<>(); + + if (value instanceof Collection) { + Collection values = Collection.class.cast(value); + Iterator iterator = values.iterator(); + + while (iterator.hasNext()) { + paramValues.addAll(PlaceHolders.resolve(iterator.next().toString(), httpFacade)); + } + } else { + paramValues.addAll(PlaceHolders.resolve(value.toString(), httpFacade)); + } + + for (String paramValue : paramValues) { + builder.addParameter(paramDef.getKey(), paramValue); + } + } + } + } +} diff --git a/adapters/oidc/adapter-core/src/main/java/org/keycloak/adapters/authorization/cip/HttpClaimInformationPointProviderFactory.java b/adapters/oidc/adapter-core/src/main/java/org/keycloak/adapters/authorization/cip/HttpClaimInformationPointProviderFactory.java new file mode 100644 index 0000000000..6e3656203e --- /dev/null +++ b/adapters/oidc/adapter-core/src/main/java/org/keycloak/adapters/authorization/cip/HttpClaimInformationPointProviderFactory.java @@ -0,0 +1,45 @@ +/* + * Copyright 2018 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.adapters.authorization.cip; + +import java.util.Map; + +import org.keycloak.adapters.authorization.ClaimInformationPointProviderFactory; +import org.keycloak.adapters.authorization.PolicyEnforcer; + +/** + * @author Pedro Igor + */ +public class HttpClaimInformationPointProviderFactory implements ClaimInformationPointProviderFactory { + + private PolicyEnforcer policyEnforcer; + + @Override + public String getName() { + return "http"; + } + + @Override + public void init(PolicyEnforcer policyEnforcer) { + this.policyEnforcer = policyEnforcer; + } + + @Override + public HttpClaimInformationPointProvider create(Map config) { + return new HttpClaimInformationPointProvider(config, policyEnforcer); + } +} diff --git a/adapters/oidc/adapter-core/src/main/java/org/keycloak/adapters/authorization/util/JsonUtils.java b/adapters/oidc/adapter-core/src/main/java/org/keycloak/adapters/authorization/util/JsonUtils.java new file mode 100644 index 0000000000..36c6d9b1f9 --- /dev/null +++ b/adapters/oidc/adapter-core/src/main/java/org/keycloak/adapters/authorization/util/JsonUtils.java @@ -0,0 +1,58 @@ +/* + * Copyright 2018 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.adapters.authorization.util; + +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; + +import com.fasterxml.jackson.databind.JsonNode; + +/** + * @author Pedro Igor + */ +public class JsonUtils { + + public static List getValues(JsonNode jsonNode, String path) { + return getValues(jsonNode.at(path)); + } + + public static List getValues(JsonNode jsonNode) { + List values = new ArrayList<>(); + + if (jsonNode.isArray()) { + Iterator iterator = jsonNode.iterator(); + + while (iterator.hasNext()) { + String value = iterator.next().textValue(); + + if (value != null) { + values.add(value); + } + } + } else { + String value = jsonNode.textValue(); + + if (value != null) { + values.add(value); + } + } + + return values; + } + +} diff --git a/adapters/oidc/adapter-core/src/main/java/org/keycloak/adapters/authorization/util/KeycloakSecurityContextPlaceHolderResolver.java b/adapters/oidc/adapter-core/src/main/java/org/keycloak/adapters/authorization/util/KeycloakSecurityContextPlaceHolderResolver.java new file mode 100644 index 0000000000..d59cfd7861 --- /dev/null +++ b/adapters/oidc/adapter-core/src/main/java/org/keycloak/adapters/authorization/util/KeycloakSecurityContextPlaceHolderResolver.java @@ -0,0 +1,67 @@ +/* + * Copyright 2018 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.adapters.authorization.util; + +import static org.keycloak.adapters.authorization.util.PlaceHolders.getParameter; + +import java.util.Arrays; +import java.util.List; + +import com.fasterxml.jackson.databind.JsonNode; +import org.keycloak.KeycloakSecurityContext; +import org.keycloak.adapters.OIDCHttpFacade; +import org.keycloak.adapters.spi.HttpFacade; +import org.keycloak.util.JsonSerialization; + +/** + * @author Pedro Igor + */ +public class KeycloakSecurityContextPlaceHolderResolver implements PlaceHolderResolver { + + public static final String NAME = "keycloak"; + + @Override + public List resolve(String placeHolder, HttpFacade httpFacade) { + String source = placeHolder.substring(placeHolder.indexOf('.') + 1); + OIDCHttpFacade oidcHttpFacade = OIDCHttpFacade.class.cast(httpFacade); + KeycloakSecurityContext securityContext = oidcHttpFacade.getSecurityContext(); + + if (securityContext == null) { + return null; + } + + if (source.endsWith("access_token")) { + return Arrays.asList(securityContext.getTokenString()); + } + + if (source.endsWith("id_token")) { + return Arrays.asList(securityContext.getIdTokenString()); + } + + JsonNode jsonNode; + + if (source.startsWith("access_token[")) { + jsonNode = JsonSerialization.mapper.valueToTree(securityContext.getToken()); + } else if (source.startsWith("id_token[")) { + jsonNode = JsonSerialization.mapper.valueToTree(securityContext.getIdToken()); + } else { + throw new RuntimeException("Invalid placeholder [" + placeHolder + "]"); + } + + return JsonUtils.getValues(jsonNode, getParameter(source, "Invalid placeholder [" + placeHolder + "]")); + } +} diff --git a/adapters/oidc/adapter-core/src/main/java/org/keycloak/adapters/authorization/util/PlaceHolderResolver.java b/adapters/oidc/adapter-core/src/main/java/org/keycloak/adapters/authorization/util/PlaceHolderResolver.java new file mode 100644 index 0000000000..62d6e1e51a --- /dev/null +++ b/adapters/oidc/adapter-core/src/main/java/org/keycloak/adapters/authorization/util/PlaceHolderResolver.java @@ -0,0 +1,30 @@ +/* + * Copyright 2018 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.adapters.authorization.util; + +import java.util.List; + +import org.keycloak.adapters.spi.HttpFacade; + +/** + * @author Pedro Igor + */ +public interface PlaceHolderResolver { + + List resolve(String placeHolder, HttpFacade httpFacade); + +} diff --git a/adapters/oidc/adapter-core/src/main/java/org/keycloak/adapters/authorization/util/PlaceHolders.java b/adapters/oidc/adapter-core/src/main/java/org/keycloak/adapters/authorization/util/PlaceHolders.java new file mode 100644 index 0000000000..7a39dbf094 --- /dev/null +++ b/adapters/oidc/adapter-core/src/main/java/org/keycloak/adapters/authorization/util/PlaceHolders.java @@ -0,0 +1,107 @@ +/* + * Copyright 2018 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.adapters.authorization.util; + +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Map.Entry; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.keycloak.adapters.spi.HttpFacade; + +/** + * @author Pedro Igor + */ +public class PlaceHolders { + + private static Map resolvers = new HashMap<>(); + + static { + resolvers.put(RequestPlaceHolderResolver.NAME, new RequestPlaceHolderResolver()); + resolvers.put(KeycloakSecurityContextPlaceHolderResolver.NAME, new KeycloakSecurityContextPlaceHolderResolver()); + } + + private static Pattern PLACEHOLDER_PATTERN = Pattern.compile("\\{(.+?)\\}"); + private static Pattern PLACEHOLDER_PARAM_PATTERN = Pattern.compile("\\[(.+?)\\]"); + + public static List resolve(String value, HttpFacade httpFacade) { + Map> placeHolders = parsePlaceHolders(value, httpFacade); + + if (!placeHolders.isEmpty()) { + value = formatPlaceHolder(value); + + for (Entry> entry : placeHolders.entrySet()) { + List values = entry.getValue(); + + if (values.isEmpty() || values.size() > 1) { + return values; + } + + value = value.replaceAll(entry.getKey(), values.get(0)).trim(); + } + } + + return Arrays.asList(value); + } + + static String getParameter(String source, String messageIfNotFound) { + Matcher matcher = PLACEHOLDER_PARAM_PATTERN.matcher(source); + + while (matcher.find()) { + return matcher.group(1).replaceAll("'", ""); + } + + if (messageIfNotFound != null) { + throw new RuntimeException(messageIfNotFound); + } + + return null; + } + + private static Map> parsePlaceHolders(String value, HttpFacade httpFacade) { + Map> placeHolders = new HashMap<>(); + Matcher matcher = PLACEHOLDER_PATTERN.matcher(value); + + while (matcher.find()) { + String placeHolder = matcher.group(1); + int resolverNameIdx = placeHolder.indexOf('.'); + + if (resolverNameIdx == -1) { + throw new RuntimeException("Invalid placeholder [" + value + "]. Could not find resolver name."); + } + + PlaceHolderResolver resolver = resolvers.get(placeHolder.substring(0, resolverNameIdx)); + + if (resolver != null) { + List resolved = resolver.resolve(placeHolder, httpFacade); + + if (resolved != null) { + placeHolders.put(formatPlaceHolder(placeHolder), resolved); + } + } + } + + return placeHolders; + } + + private static String formatPlaceHolder(String placeHolder) { + return placeHolder.replaceAll("\\{", "").replace("}", "").replace("[", "").replace("]", "").replace("[", "").replace("]", ""); + } +} diff --git a/adapters/oidc/adapter-core/src/main/java/org/keycloak/adapters/authorization/util/RequestPlaceHolderResolver.java b/adapters/oidc/adapter-core/src/main/java/org/keycloak/adapters/authorization/util/RequestPlaceHolderResolver.java new file mode 100644 index 0000000000..cdd362d032 --- /dev/null +++ b/adapters/oidc/adapter-core/src/main/java/org/keycloak/adapters/authorization/util/RequestPlaceHolderResolver.java @@ -0,0 +1,175 @@ +/* + * Copyright 2018 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.adapters.authorization.util; + +import static org.keycloak.adapters.authorization.util.PlaceHolders.getParameter; + +import java.io.BufferedInputStream; +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import com.fasterxml.jackson.databind.JsonNode; +import org.keycloak.adapters.spi.HttpFacade; +import org.keycloak.adapters.spi.HttpFacade.Cookie; +import org.keycloak.adapters.spi.HttpFacade.Request; +import org.keycloak.util.JsonSerialization; + +/** + * @author Pedro Igor + */ +public class RequestPlaceHolderResolver implements PlaceHolderResolver { + + static String NAME = "request"; + + @Override + public List resolve(String placeHolder, HttpFacade httpFacade) { + String source = placeHolder.substring(placeHolder.indexOf('.') + 1); + Request request = httpFacade.getRequest(); + + if (source.startsWith("parameter")) { + String parameterName = getParameter(source, "Could not obtain parameter name from placeholder [" + source + "]"); + String parameterValue = request.getQueryParamValue(parameterName); + + if (parameterValue == null) { + parameterValue = request.getFirstParam(parameterName); + } + + if (parameterValue != null) { + return Arrays.asList(parameterValue); + } + } else if (source.startsWith("header")) { + String headerName = getParameter(source, "Could not obtain header name from placeholder [" + source + "]"); + List headerValue = request.getHeaders(headerName); + + if (headerValue != null) { + return headerValue; + } + } else if (source.startsWith("cookie")) { + String cookieName = getParameter(source, "Could not obtain cookie name from placeholder [" + source + "]"); + Cookie cookieValue = request.getCookie(cookieName); + + if (cookieValue != null) { + return Arrays.asList(cookieValue.getValue()); + } + } else if (source.startsWith("remoteAddr")) { + String value = request.getRemoteAddr(); + + if (value != null) { + return Arrays.asList(value); + } + } else if (source.startsWith("method")) { + String value = request.getMethod(); + + if (value != null) { + return Arrays.asList(value); + } + } else if (source.startsWith("uri")) { + String value = request.getURI(); + + if (value != null) { + return Arrays.asList(value); + } + } else if (source.startsWith("relativePath")) { + String value = request.getRelativePath(); + + if (value != null) { + return Arrays.asList(value); + } + } else if (source.startsWith("secure")) { + return Arrays.asList(String.valueOf(request.isSecure())); + } else if (source.startsWith("body")) { + String contentType = request.getHeader("Content-Type"); + + if (contentType == null) { + contentType = ""; + } + + InputStream body = request.getInputStream(true); + + try { + if (body == null || body.available() == 0) { + return Collections.emptyList(); + } + } catch (IOException cause) { + throw new RuntimeException("Failed to check available bytes in request input stream", cause); + } + + if (body.markSupported()) { + body.mark(0); + } + + List values = new ArrayList<>(); + + try { + switch (contentType) { + case "application/json": + try { + JsonNode jsonNode = JsonSerialization.mapper.readTree(new BufferedInputStream(body) { + @Override + public void close() { + // we can't close the stream because it may be used later by the application + } + }); + String path = getParameter(source, null); + + if (path == null) { + values.addAll(JsonUtils.getValues(jsonNode)); + } else { + values.addAll(JsonUtils.getValues(jsonNode, path)); + } + } catch (IOException cause) { + throw new RuntimeException("Could not extract claim from request JSON body", cause); + } + break; + default: + StringBuilder value = new StringBuilder(); + BufferedReader reader = new BufferedReader(new InputStreamReader(body)); + + try { + int ch; + + while ((ch = reader.read()) != -1) { + value.append((char) ch); + } + } catch (IOException cause) { + throw new RuntimeException("Could not extract claim from request body", cause); + } + + values.add(value.toString()); + } + } finally { + if (body.markSupported()) { + try { + body.reset(); + } catch (IOException cause) { + throw new RuntimeException("Failed to reset request input stream", cause); + } + } + } + + return values; + } + + return Collections.emptyList(); + } +} diff --git a/adapters/oidc/adapter-core/src/main/resources/META-INF/services/org.keycloak.adapters.authorization.ClaimInformationPointProviderFactory b/adapters/oidc/adapter-core/src/main/resources/META-INF/services/org.keycloak.adapters.authorization.ClaimInformationPointProviderFactory new file mode 100644 index 0000000000..f40afedf26 --- /dev/null +++ b/adapters/oidc/adapter-core/src/main/resources/META-INF/services/org.keycloak.adapters.authorization.ClaimInformationPointProviderFactory @@ -0,0 +1,19 @@ +# +# * Copyright 2018 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. +# + +org.keycloak.adapters.authorization.cip.ClaimsInformationPointProviderFactory +org.keycloak.adapters.authorization.cip.HttpClaimInformationPointProviderFactory \ No newline at end of file diff --git a/adapters/oidc/wildfly-elytron/src/main/java/org/keycloak/adapters/elytron/ElytronHttpFacade.java b/adapters/oidc/wildfly-elytron/src/main/java/org/keycloak/adapters/elytron/ElytronHttpFacade.java index 543e848d47..1d27810c0e 100644 --- a/adapters/oidc/wildfly-elytron/src/main/java/org/keycloak/adapters/elytron/ElytronHttpFacade.java +++ b/adapters/oidc/wildfly-elytron/src/main/java/org/keycloak/adapters/elytron/ElytronHttpFacade.java @@ -184,7 +184,7 @@ class ElytronHttpFacade implements OIDCHttpFacade { @Override public String getFirstParam(String param) { - throw new RuntimeException("Not implemented."); + return request.getFirstParameterValue(param); } @Override diff --git a/core/src/main/java/org/keycloak/representations/AccessToken.java b/core/src/main/java/org/keycloak/representations/AccessToken.java index 36778e102d..542712d830 100755 --- a/core/src/main/java/org/keycloak/representations/AccessToken.java +++ b/core/src/main/java/org/keycloak/representations/AccessToken.java @@ -88,6 +88,9 @@ public class AccessToken extends IDToken { @JsonProperty("permissions") private List permissions; + @JsonProperty("claims") + private Map> claims; + public List getPermissions() { return permissions; } @@ -95,6 +98,14 @@ public class AccessToken extends IDToken { public void setPermissions(List permissions) { this.permissions = permissions; } + + public void setClaims(Map> claims) { + this.claims = claims; + } + + public Map> getClaims() { + return claims; + } } @JsonProperty("trusted-certs") diff --git a/core/src/main/java/org/keycloak/representations/adapters/config/PolicyEnforcerConfig.java b/core/src/main/java/org/keycloak/representations/adapters/config/PolicyEnforcerConfig.java index d01d7c5d15..81dc5063e0 100644 --- a/core/src/main/java/org/keycloak/representations/adapters/config/PolicyEnforcerConfig.java +++ b/core/src/main/java/org/keycloak/representations/adapters/config/PolicyEnforcerConfig.java @@ -19,6 +19,7 @@ package org.keycloak.representations.adapters.config; import java.util.ArrayList; import java.util.List; +import java.util.Map; import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonInclude; @@ -139,6 +140,9 @@ public class PolicyEnforcerConfig { @JsonProperty("enforcement-mode") private EnforcementMode enforcementMode = EnforcementMode.ENFORCING; + @JsonProperty("claim-information-point") + private Map> claimInformationPointConfig; + @JsonIgnore private PathConfig parentConfig; @@ -198,6 +202,14 @@ public class PolicyEnforcerConfig { this.enforcementMode = enforcementMode; } + public Map> getClaimInformationPointConfig() { + return claimInformationPointConfig; + } + + public void setClaimInformationPointConfig(Map> claimInformationPointConfig) { + this.claimInformationPointConfig = claimInformationPointConfig; + } + @Override public String toString() { return "PathConfig{" + diff --git a/core/src/main/java/org/keycloak/representations/idm/authorization/AuthorizationRequest.java b/core/src/main/java/org/keycloak/representations/idm/authorization/AuthorizationRequest.java index 8a34e98439..14f1f3d664 100644 --- a/core/src/main/java/org/keycloak/representations/idm/authorization/AuthorizationRequest.java +++ b/core/src/main/java/org/keycloak/representations/idm/authorization/AuthorizationRequest.java @@ -41,7 +41,7 @@ public class AuthorizationRequest { private String audience; private String accessToken; private boolean submitRequest; - private Map claims; + private Map> claims; public AuthorizationRequest(String ticket) { this.ticket = ticket; @@ -131,11 +131,11 @@ public class AuthorizationRequest { return accessToken; } - public Map getClaims() { + public Map> getClaims() { return claims; } - public void setClaims(Map claims) { + public void setClaims(Map> claims) { this.claims = claims; } diff --git a/core/src/main/java/org/keycloak/representations/idm/authorization/Permission.java b/core/src/main/java/org/keycloak/representations/idm/authorization/Permission.java index ed392f07a7..53760c39c3 100644 --- a/core/src/main/java/org/keycloak/representations/idm/authorization/Permission.java +++ b/core/src/main/java/org/keycloak/representations/idm/authorization/Permission.java @@ -16,13 +16,14 @@ */ package org.keycloak.representations.idm.authorization; -import com.fasterxml.jackson.annotation.JsonInclude; -import com.fasterxml.jackson.annotation.JsonProperty; - import java.util.HashSet; import java.util.Map; +import java.util.Objects; import java.util.Set; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; + /** * @author Pedro Igor */ @@ -71,6 +72,21 @@ public class Permission { return claims; } + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + + Permission that = (Permission) o; + + return getResourceId().equals(that.resourceId); + } + + @Override + public int hashCode() { + return Objects.hash(resourceId); + } + @Override public String toString() { StringBuilder builder = new StringBuilder(); diff --git a/core/src/main/java/org/keycloak/representations/idm/authorization/PermissionTicketToken.java b/core/src/main/java/org/keycloak/representations/idm/authorization/PermissionTicketToken.java index d370cf77a7..a9f6ba5c70 100644 --- a/core/src/main/java/org/keycloak/representations/idm/authorization/PermissionTicketToken.java +++ b/core/src/main/java/org/keycloak/representations/idm/authorization/PermissionTicketToken.java @@ -69,6 +69,10 @@ public class PermissionTicketToken extends JsonWebToken { return claims; } + public void setClaims(Map> claims) { + this.claims = claims; + } + public static class ResourcePermission { @JsonProperty("id") diff --git a/services/src/main/java/org/keycloak/authorization/authorization/AuthorizationTokenService.java b/services/src/main/java/org/keycloak/authorization/authorization/AuthorizationTokenService.java index 0c23294cdd..b791f7fd21 100644 --- a/services/src/main/java/org/keycloak/authorization/authorization/AuthorizationTokenService.java +++ b/services/src/main/java/org/keycloak/authorization/authorization/AuthorizationTokenService.java @@ -16,6 +16,7 @@ */ package org.keycloak.authorization.authorization; +import java.io.IOException; import java.util.ArrayList; import java.util.Arrays; import java.util.HashMap; @@ -54,6 +55,7 @@ import org.keycloak.authorization.store.ScopeStore; import org.keycloak.authorization.store.StoreFactory; import org.keycloak.authorization.util.Permissions; import org.keycloak.authorization.util.Tokens; +import org.keycloak.common.util.Base64Url; import org.keycloak.events.EventBuilder; import org.keycloak.jose.jws.JWSInput; import org.keycloak.jose.jws.JWSInputException; @@ -77,12 +79,15 @@ import org.keycloak.representations.idm.authorization.PermissionTicketToken; import org.keycloak.services.CorsErrorResponseException; import org.keycloak.services.ErrorResponseException; import org.keycloak.services.resources.Cors; +import org.keycloak.util.JsonSerialization; /** * @author Pedro Igor */ public class AuthorizationTokenService { + public static final String CLAIM_TOKEN_FORMAT_ID_TOKEN = "http://openid.net/specs/openid-connect-core-1_0.html#IDToken"; + private static final Logger logger = Logger.getLogger(AuthorizationTokenService.class); private static Map> SUPPORTED_CLAIM_TOKEN_FORMATS; @@ -91,16 +96,29 @@ public class AuthorizationTokenService { SUPPORTED_CLAIM_TOKEN_FORMATS.put("urn:ietf:params:oauth:token-type:jwt", (authorizationRequest, authorization) -> { String claimToken = authorizationRequest.getClaimToken(); - if (claimToken == null) { - claimToken = authorizationRequest.getAccessToken(); + if (claimToken != null) { + try { + Map claims = JsonSerialization.readValue(Base64Url.decode(authorizationRequest.getClaimToken()), Map.class); + authorizationRequest.setClaims(claims); + return new KeycloakEvaluationContext(new KeycloakIdentity(authorization.getKeycloakSession(), Tokens.getAccessToken(authorizationRequest.getAccessToken(), authorization.getKeycloakSession())), claims, authorization.getKeycloakSession()); + } catch (IOException cause) { + throw new RuntimeException("Failed to map claims from claim token [" + claimToken + "]", cause); + } } - return new KeycloakEvaluationContext(new KeycloakIdentity(authorization.getKeycloakSession(), Tokens.getAccessToken(claimToken, authorization.getKeycloakSession())), authorizationRequest.getClaims(), authorization.getKeycloakSession()); + throw new RuntimeException("Claim token can not be null"); }); - SUPPORTED_CLAIM_TOKEN_FORMATS.put("http://openid.net/specs/openid-connect-core-1_0.html#IDToken", (authorizationRequest, authorization) -> { + SUPPORTED_CLAIM_TOKEN_FORMATS.put(CLAIM_TOKEN_FORMAT_ID_TOKEN, (authorizationRequest, authorization) -> { try { KeycloakSession keycloakSession = authorization.getKeycloakSession(); - IDToken idToken = new TokenManager().verifyIDTokenSignature(keycloakSession, authorization.getRealm(), authorizationRequest.getClaimToken()); + RealmModel realm = authorization.getRealm(); + String accessToken = authorizationRequest.getAccessToken(); + + if (accessToken == null) { + throw new RuntimeException("Claim token can not be null and must be a valid IDToken"); + } + + IDToken idToken = new TokenManager().verifyIDTokenSignature(keycloakSession, realm, accessToken); return new KeycloakEvaluationContext(new KeycloakIdentity(keycloakSession, idToken), authorizationRequest.getClaims(), keycloakSession); } catch (OAuthErrorException cause) { throw new RuntimeException("Failed to verify ID token", cause); @@ -127,10 +145,15 @@ public class AuthorizationTokenService { throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_GRANT, "Invalid authorization request.", Status.BAD_REQUEST); } + // it is not secure to allow public clients to push arbitrary claims because message can be tampered + if (isPublicClientRequestingEntitlemesWithClaims(request)) { + throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_GRANT, "Public clients are not allowed to send claims", Status.FORBIDDEN); + } + try { PermissionTicketToken ticket = getPermissionTicket(request); - request.setClaims(ticket.getOtherClaims()); + request.setClaims(ticket.getClaims()); ResourceServer resourceServer = getResourceServer(ticket); KeycloakEvaluationContext evaluationContext = createEvaluationContext(request); @@ -156,7 +179,7 @@ public class AuthorizationTokenService { } ClientModel targetClient = this.authorization.getRealm().getClientById(resourceServer.getId()); - AuthorizationResponse response = new AuthorizationResponse(createRequestingPartyToken(identity, permissions, targetClient), request.getRpt() != null); + AuthorizationResponse response = new AuthorizationResponse(createRequestingPartyToken(identity, permissions, request, targetClient), request.getRpt() != null); return Cors.add(httpRequest, Response.status(Status.OK).type(MediaType.APPLICATION_JSON_TYPE).entity(response)) .allowedOrigins(getKeycloakSession().getContext().getUri(), targetClient) @@ -173,6 +196,10 @@ public class AuthorizationTokenService { } } + private boolean isPublicClientRequestingEntitlemesWithClaims(AuthorizationRequest request) { + return request.getClaimToken() != null && getKeycloakSession().getContext().getClient().isPublicClient() && request.getTicket() == null; + } + private List evaluatePermissions(AuthorizationRequest authorizationRequest, PermissionTicketToken ticket, ResourceServer resourceServer, KeycloakEvaluationContext evaluationContext, KeycloakIdentity identity) { return authorization.evaluators() .from(createPermissions(ticket, authorizationRequest, resourceServer, identity, authorization), evaluationContext) @@ -191,7 +218,7 @@ public class AuthorizationTokenService { .evaluate(); } - private AccessTokenResponse createRequestingPartyToken(KeycloakIdentity identity, List entitlements, ClientModel targetClient) { + private AccessTokenResponse createRequestingPartyToken(KeycloakIdentity identity, List entitlements, AuthorizationRequest request, ClientModel targetClient) { KeycloakSession keycloakSession = getKeycloakSession(); AccessToken accessToken = identity.getAccessToken(); UserSessionModel userSessionModel = keycloakSession.sessions().getUserSession(getRealm(), accessToken.getSessionState()); @@ -208,6 +235,7 @@ public class AuthorizationTokenService { Authorization authorization = new Authorization(); authorization.setPermissions(entitlements); + authorization.setClaims(request.getClaims()); rpt.setAuthorization(authorization); @@ -267,7 +295,7 @@ public class AuthorizationTokenService { String claimTokenFormat = authorizationRequest.getClaimTokenFormat(); if (claimTokenFormat == null) { - claimTokenFormat = "urn:ietf:params:oauth:token-type:jwt"; + claimTokenFormat = CLAIM_TOKEN_FORMAT_ID_TOKEN; } BiFunction evaluationContextProvider = SUPPORTED_CLAIM_TOKEN_FORMATS.get(claimTokenFormat); diff --git a/services/src/main/java/org/keycloak/authorization/common/DefaultEvaluationContext.java b/services/src/main/java/org/keycloak/authorization/common/DefaultEvaluationContext.java index 5d67819c25..e740cdf393 100644 --- a/services/src/main/java/org/keycloak/authorization/common/DefaultEvaluationContext.java +++ b/services/src/main/java/org/keycloak/authorization/common/DefaultEvaluationContext.java @@ -24,7 +24,6 @@ import org.keycloak.authorization.policy.evaluation.EvaluationContext; import org.keycloak.models.KeycloakSession; import java.text.SimpleDateFormat; -import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; import java.util.Date; @@ -40,13 +39,13 @@ public class DefaultEvaluationContext implements EvaluationContext { protected final KeycloakSession keycloakSession; protected final Identity identity; - private final Map claims; + private final Map> claims; public DefaultEvaluationContext(Identity identity, KeycloakSession keycloakSession) { this(identity, null, keycloakSession); } - public DefaultEvaluationContext(Identity identity, Map claims, KeycloakSession keycloakSession) { + public DefaultEvaluationContext(Identity identity, Map> claims, KeycloakSession keycloakSession) { this.identity = identity; this.claims = claims; this.keycloakSession = keycloakSession; @@ -73,16 +72,8 @@ public class DefaultEvaluationContext implements EvaluationContext { attributes.put("kc.realm.name", Arrays.asList(this.keycloakSession.getContext().getRealm().getName())); if (claims != null) { - for (Entry entry : claims.entrySet()) { - Object value = entry.getValue(); - - if (value.getClass().isArray()) { - attributes.put(entry.getKey(), Arrays.asList(String[].class.cast(value))); - } else if (value instanceof Collection) { - attributes.put(entry.getKey(), Collection.class.cast(value)); - } else { - attributes.put(entry.getKey(), Arrays.asList(String.valueOf(value))); - } + for (Entry> entry : claims.entrySet()) { + attributes.put(entry.getKey(), entry.getValue()); } } diff --git a/services/src/main/java/org/keycloak/authorization/common/KeycloakEvaluationContext.java b/services/src/main/java/org/keycloak/authorization/common/KeycloakEvaluationContext.java index fd43153368..bbb4218c1c 100644 --- a/services/src/main/java/org/keycloak/authorization/common/KeycloakEvaluationContext.java +++ b/services/src/main/java/org/keycloak/authorization/common/KeycloakEvaluationContext.java @@ -20,6 +20,7 @@ package org.keycloak.authorization.common; import java.util.Arrays; import java.util.Collection; +import java.util.List; import java.util.Map; import org.keycloak.authorization.identity.Identity; @@ -37,7 +38,7 @@ public class KeycloakEvaluationContext extends DefaultEvaluationContext { this(identity, null, keycloakSession); } - public KeycloakEvaluationContext(KeycloakIdentity identity, Map claims, KeycloakSession keycloakSession) { + public KeycloakEvaluationContext(KeycloakIdentity identity, Map> claims, KeycloakSession keycloakSession) { super(identity, claims, keycloakSession); this.identity = identity; } diff --git a/services/src/main/java/org/keycloak/authorization/common/KeycloakIdentity.java b/services/src/main/java/org/keycloak/authorization/common/KeycloakIdentity.java index b2ea5d4900..c02ecb5bf0 100644 --- a/services/src/main/java/org/keycloak/authorization/common/KeycloakIdentity.java +++ b/services/src/main/java/org/keycloak/authorization/common/KeycloakIdentity.java @@ -112,7 +112,6 @@ public class KeycloakIdentity implements Identity { if (token instanceof AccessToken) { this.accessToken = AccessToken.class.cast(token); } else { - UserModel userById = keycloakSession.users().getUserById(token.getSubject(), realm); UserSessionModel userSession = keycloakSession.sessions().getUserSession(realm, token.getSessionState()); ClientModel client = realm.getClientByClientId(token.getIssuedFor()); AuthenticatedClientSessionModel clientSessionModel = userSession.getAuthenticatedClientSessions().get(client.getId()); @@ -123,7 +122,7 @@ public class KeycloakIdentity implements Identity { requestedRoles.add(role); } } - this.accessToken = new TokenManager().createClientAccessToken(keycloakSession, requestedRoles, realm, client, userById, userSession, clientSessionModel); + this.accessToken = new TokenManager().createClientAccessToken(keycloakSession, requestedRoles, realm, client, userSession.getUser(), userSession, clientSessionModel); } AccessToken.Access realmAccess = this.accessToken.getRealmAccess(); diff --git a/services/src/main/java/org/keycloak/authorization/protection/permission/AbstractPermissionService.java b/services/src/main/java/org/keycloak/authorization/protection/permission/AbstractPermissionService.java index 272ad3a9ab..e045d31de1 100644 --- a/services/src/main/java/org/keycloak/authorization/protection/permission/AbstractPermissionService.java +++ b/services/src/main/java/org/keycloak/authorization/protection/permission/AbstractPermissionService.java @@ -18,9 +18,9 @@ package org.keycloak.authorization.protection.permission; import java.util.ArrayList; import java.util.Collections; +import java.util.HashMap; import java.util.List; import java.util.Map; -import java.util.Map.Entry; import java.util.Set; import java.util.stream.Collectors; @@ -152,17 +152,20 @@ public class AbstractPermissionService { KeyManager.ActiveRsaKey keys = this.authorization.getKeycloakSession().keys().getActiveRsaKey(this.authorization.getRealm()); ClientModel targetClient = authorization.getRealm().getClientById(resourceServer.getId()); PermissionTicketToken token = new PermissionTicketToken(permissions, targetClient.getClientId(), this.identity.getAccessToken()); + Map> claims = new HashMap<>(); for (PermissionRequest permissionRequest : request) { - Map> claims = permissionRequest.getClaims(); + Map> requestClaims = permissionRequest.getClaims(); - if (claims != null) { - for (Entry> claim : claims.entrySet()) { - token.setOtherClaims(claim.getKey(), claim.getValue()); - } + if (requestClaims != null) { + claims.putAll(requestClaims); } } + if (!claims.isEmpty()) { + token.setClaims(claims); + } + return new JWSBuilder().kid(keys.getKid()).jsonContent(token) .rsa256(keys.getPrivateKey()); } diff --git a/services/src/main/java/org/keycloak/protocol/oidc/endpoints/TokenEndpoint.java b/services/src/main/java/org/keycloak/protocol/oidc/endpoints/TokenEndpoint.java index 38d2489d9d..b6e1050ea6 100644 --- a/services/src/main/java/org/keycloak/protocol/oidc/endpoints/TokenEndpoint.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/endpoints/TokenEndpoint.java @@ -997,6 +997,8 @@ public class TokenEndpoint { accessTokenString = new AppAuthManager().extractAuthorizationHeaderToken(headers); } + // we allow public clients to authenticate using a bearer token, where the token should be a valid access token. + // public clients don't have secret and should be able to obtain a RPT by providing an access token previously issued by the server if (accessTokenString != null) { AccessToken accessToken = Tokens.getAccessToken(session); @@ -1004,7 +1006,11 @@ public class TokenEndpoint { throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_GRANT, "Invalid bearer token", Status.UNAUTHORIZED); } - cors.allowedOrigins(uriInfo, realm.getClientByClientId(accessToken.getIssuedFor())); + ClientModel client = realm.getClientByClientId(accessToken.getIssuedFor()); + + session.getContext().setClient(client); + + cors.allowedOrigins(uriInfo, client); } String claimToken = null; @@ -1014,18 +1020,30 @@ public class TokenEndpoint { claimToken = formParams.get("claim_token").get(0); } + String claimTokenFormat = formParams.getFirst("claim_token_format"); + + if (claimToken != null && claimTokenFormat == null) { + claimTokenFormat = AuthorizationTokenService.CLAIM_TOKEN_FORMAT_ID_TOKEN; + } + if (accessTokenString == null) { // in case no bearer token is provided, we force client authentication checkClient(); - // Clients need to authenticate in order to obtain a RPT from the server. - // In order to support cases where the client is obtaining permissions on its on behalf, we issue a temporary access token - accessTokenString = AccessTokenResponse.class.cast(clientCredentialsGrant().getEntity()).getToken(); + + // if a claim token is provided, we check if the format is a OpenID Connect IDToken and assume the token represents the identity asking for permissions + if (AuthorizationTokenService.CLAIM_TOKEN_FORMAT_ID_TOKEN.equalsIgnoreCase(claimTokenFormat)) { + accessTokenString = claimToken; + } else { + // Clients need to authenticate in order to obtain a RPT from the server. + // In order to support cases where the client is obtaining permissions on its on behalf, we issue a temporary access token + accessTokenString = AccessTokenResponse.class.cast(clientCredentialsGrant().getEntity()).getToken(); + } } AuthorizationRequest authorizationRequest = new AuthorizationRequest(formParams.getFirst("ticket")); authorizationRequest.setClaimToken(claimToken); - authorizationRequest.setClaimTokenFormat(formParams.getFirst("claim_token_format")); + authorizationRequest.setClaimTokenFormat(claimTokenFormat); authorizationRequest.setPct(formParams.getFirst("pct")); authorizationRequest.setRpt(formParams.getFirst("rpt")); authorizationRequest.setScope(formParams.getFirst("scope")); diff --git a/testsuite/integration-arquillian/test-apps/servlet-authz/keycloak-claim-information-point-authz-service.json b/testsuite/integration-arquillian/test-apps/servlet-authz/keycloak-claim-information-point-authz-service.json new file mode 100644 index 0000000000..04f09064c5 --- /dev/null +++ b/testsuite/integration-arquillian/test-apps/servlet-authz/keycloak-claim-information-point-authz-service.json @@ -0,0 +1,25 @@ +{ + "realm": "servlet-authz", + "realm-public-key" : "MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCrVrCuTtArbgaZzL1hvh0xtL5mc7o0NqPVnYXkLvgcwiC3BjLGw1tGEGoJaXDuSaRllobm53JBhjx33UNv+5z/UMG4kytBWxheNVKnL6GgqlNabMaFfPLPCF8kAgKnsi79NMo+n6KnSY8YeUmec/p2vjO2NjsSAVcWEQMVhJ31LwIDAQAB", + "auth-server-url" : "http://localhost:8180/auth", + "ssl-required" : "external", + "resource" : "servlet-authz-app", + "public-client" : false, + "credentials": { + "secret": "secret" + }, + "policy-enforcer": { + "on-deny-redirect-to" : "/servlet-authz-app/accessDenied.jsp", + "lazy-load-paths": true, + "paths": [ + { + "path": "/protected/context/context.jsp", + "claim-information-point": { + "claims": { + "request-claim": "{request.parameter['request-claim']}" + } + } + } + ] + } +} \ No newline at end of file diff --git a/testsuite/integration-arquillian/test-apps/servlet-authz/servlet-authz-app-authz-service.json b/testsuite/integration-arquillian/test-apps/servlet-authz/servlet-authz-app-authz-service.json index b074ebcba7..b986bb6a80 100644 --- a/testsuite/integration-arquillian/test-apps/servlet-authz/servlet-authz-app-authz-service.json +++ b/testsuite/integration-arquillian/test-apps/servlet-authz/servlet-authz-app-authz-service.json @@ -56,6 +56,10 @@ "name": "write" } ] + }, + { + "name": "Resource Protected With Claim", + "uri": "/protected/context/context.jsp" } ], "policies": [ @@ -183,6 +187,26 @@ "scopes": "[\"write\"]", "applyPolicies": "[\"Deny Policy\"]" } + }, + { + "name": "Resource Protected With Claim Permission", + "type": "resource", + "logic": "POSITIVE", + "decisionStrategy": "UNANIMOUS", + "config": { + "resources": "[\"Resource Protected With Claim\"]", + "applyPolicies": "[\"Request Claim Policy\"]" + } + }, + { + "name": "Request Claim Policy", + "description": "A policy that grants access based on claims from an http request", + "type": "js", + "logic": "POSITIVE", + "decisionStrategy": "UNANIMOUS", + "config": { + "code": "var context = $evaluation.getContext();\nvar attributes = context.getAttributes();\nvar claim = attributes.getValue('request-claim');\n\nif (claim && claim.asString(0) == 'expected-value') {\n $evaluation.grant();\n}" + } } ] } \ No newline at end of file diff --git a/testsuite/integration-arquillian/test-apps/servlet-authz/src/main/webapp/protected/context/context.jsp b/testsuite/integration-arquillian/test-apps/servlet-authz/src/main/webapp/protected/context/context.jsp new file mode 100644 index 0000000000..e01c5da6a1 --- /dev/null +++ b/testsuite/integration-arquillian/test-apps/servlet-authz/src/main/webapp/protected/context/context.jsp @@ -0,0 +1,14 @@ +<%@page import="org.keycloak.AuthorizationContext" %> +<%@ page import="org.keycloak.KeycloakSecurityContext" %> + +<% + KeycloakSecurityContext keycloakSecurityContext = (KeycloakSecurityContext) request.getAttribute(KeycloakSecurityContext.class.getName()); + AuthorizationContext authzContext = keycloakSecurityContext.getAuthorizationContext(); +%> + + + +

Access granted: <%= authzContext.isGranted() %>

+<%@include file="../../logout-include.jsp"%> + + \ No newline at end of file diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/adapter/example/authorization/AbstractServletAuthzCIPAdapterTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/adapter/example/authorization/AbstractServletAuthzCIPAdapterTest.java new file mode 100644 index 0000000000..8ab56dad96 --- /dev/null +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/adapter/example/authorization/AbstractServletAuthzCIPAdapterTest.java @@ -0,0 +1,59 @@ +/* + * Copyright 2018 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.adapter.example.authorization; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import java.io.File; +import java.io.IOException; + +import org.jboss.arquillian.container.test.api.Deployment; +import org.jboss.shrinkwrap.api.spec.WebArchive; +import org.junit.Test; + +/** + * @author Pedro Igor + */ +public abstract class AbstractServletAuthzCIPAdapterTest extends AbstractServletAuthzFunctionalAdapterTest { + + @Deployment(name = RESOURCE_SERVER_ID, managed = false) + public static WebArchive deployment() throws IOException { + return exampleDeployment(RESOURCE_SERVER_ID) + .addAsWebInfResource(new File(TEST_APPS_HOME_DIR + "/servlet-authz-app/keycloak-claim-information-point-authz-service.json"), "keycloak.json"); + } + + @Test + public void testClaimInformationPoint() { + performTests(() -> { + login("alice", "alice"); + assertWasNotDenied(); + + this.driver.navigate().to(getResourceServerUrl() + "/protected/context/context.jsp?request-claim=unexpected-value"); + + assertWasDenied(); + + this.driver.navigate().to(getResourceServerUrl() + "/protected/context/context.jsp?request-claim=expected-value"); + assertWasNotDenied(); + hasText("Access granted: true"); + + this.driver.navigate().to(getResourceServerUrl() + "/protected/context/context.jsp"); + + assertWasDenied(); + }); + } +} diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/client/authorization/ClaimInformationPointProviderTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/client/authorization/ClaimInformationPointProviderTest.java new file mode 100644 index 0000000000..87c4382f7b --- /dev/null +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/client/authorization/ClaimInformationPointProviderTest.java @@ -0,0 +1,457 @@ +/* + * Copyright 2018 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.admin.client.authorization; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.keycloak.testsuite.util.IOUtil.loadRealm; + +import java.io.BufferedInputStream; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import javax.security.cert.X509Certificate; + +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.core.TreeNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.undertow.Undertow; +import io.undertow.server.handlers.form.FormData; +import io.undertow.server.handlers.form.FormDataParser; +import io.undertow.server.handlers.form.FormParserFactory; +import org.apache.http.impl.client.HttpClients; +import org.junit.AfterClass; +import org.junit.BeforeClass; +import org.junit.Test; +import org.keycloak.KeycloakSecurityContext; +import org.keycloak.adapters.KeycloakDeployment; +import org.keycloak.adapters.KeycloakDeploymentBuilder; +import org.keycloak.adapters.OIDCHttpFacade; +import org.keycloak.adapters.authorization.ClaimInformationPointProvider; +import org.keycloak.adapters.authorization.ClaimInformationPointProviderFactory; +import org.keycloak.adapters.authorization.PolicyEnforcer; +import org.keycloak.adapters.spi.AuthenticationError; +import org.keycloak.adapters.spi.HttpFacade; +import org.keycloak.adapters.spi.HttpFacade.Cookie; +import org.keycloak.adapters.spi.HttpFacade.Request; +import org.keycloak.adapters.spi.HttpFacade.Response; +import org.keycloak.adapters.spi.LogoutError; +import org.keycloak.representations.AccessToken; +import org.keycloak.representations.IDToken; +import org.keycloak.representations.adapters.config.PolicyEnforcerConfig.PathConfig; +import org.keycloak.representations.idm.RealmRepresentation; +import org.keycloak.testsuite.AbstractKeycloakTest; +import org.keycloak.testsuite.ProfileAssume; +import org.keycloak.util.JsonSerialization; + +/** + * @author Pedro Igor + */ +public class ClaimInformationPointProviderTest extends AbstractKeycloakTest { + + private static Undertow httpService; + + @BeforeClass + public static void onBeforeClass() { + ProfileAssume.assumePreview(); + httpService = Undertow.builder().addHttpListener(8989, "localhost").setHandler(exchange -> { + if (exchange.isInIoThread()) { + try { + if (exchange.getRelativePath().equals("/post-claim-information-provider")) { + FormParserFactory parserFactory = FormParserFactory.builder().build(); + FormDataParser parser = parserFactory.createParser(exchange); + FormData formData = parser.parseBlocking(); + + if (!"Bearer tokenString".equals(exchange.getRequestHeaders().getFirst("Authorization")) + || !"post".equalsIgnoreCase(exchange.getRequestMethod().toString()) + || !"application/x-www-form-urlencoded".equals(exchange.getRequestHeaders().getFirst("Content-Type")) + || !exchange.getRequestHeaders().get("header-b").contains("header-b-value1") + || !exchange.getRequestHeaders().get("header-b").contains("header-b-value2") + || !formData.get("param-a").getFirst().getValue().equals("param-a-value1") + || !formData.get("param-a").getLast().getValue().equals("param-a-value2") + || !formData.get("param-subject").getFirst().getValue().equals("sub") + || !formData.get("param-user-name").getFirst().getValue().equals("username") + || !formData.get("param-other-claims").getFirst().getValue().equals("param-other-claims-value1") + || !formData.get("param-other-claims").getLast().getValue().equals("param-other-claims-value2")) { + exchange.setStatusCode(400); + return; + } + + exchange.setStatusCode(200); + } else if (exchange.getRelativePath().equals("/get-claim-information-provider")) { + if (!"Bearer idTokenString".equals(exchange.getRequestHeaders().getFirst("Authorization")) + || !"get".equalsIgnoreCase(exchange.getRequestMethod().toString()) + || !exchange.getRequestHeaders().get("header-b").contains("header-b-value1") + || !exchange.getRequestHeaders().get("header-b").contains("header-b-value2") + || !exchange.getQueryParameters().get("param-a").contains("param-a-value1") + || !exchange.getQueryParameters().get("param-a").contains("param-a-value2") + || !exchange.getQueryParameters().get("param-subject").contains("sub") + || !exchange.getQueryParameters().get("param-user-name").contains("username")) { + exchange.setStatusCode(400); + return; + } + + exchange.setStatusCode(200); + } else { + exchange.setStatusCode(404); + } + } finally { + if (exchange.getStatusCode() == 200) { + try { + ObjectMapper mapper = JsonSerialization.mapper; + JsonParser jsonParser = mapper.getFactory().createParser("{\"a\": \"a-value1\", \"b\": \"b-value1\", \"d\": [\"d-value1\", \"d-value2\"]}"); + TreeNode treeNode = mapper.readTree(jsonParser); + exchange.getResponseSender().send(treeNode.toString()); + } catch (Exception ignore) { + ignore.printStackTrace(); + } + } + exchange.endExchange(); + } + } + }).build(); + + httpService.start(); + } + + @AfterClass + public static void onAfterClass() { + httpService.stop(); + } + + @Override + public void addTestRealms(List testRealms) { + RealmRepresentation realm = loadRealm(getClass().getResourceAsStream("/authorization-test/test-authz-realm.json")); + testRealms.add(realm); + } + + private ClaimInformationPointProvider getClaimInformationProviderForPath(String path, String providerName) { + KeycloakDeployment deployment = KeycloakDeploymentBuilder.build(getClass().getResourceAsStream("/authorization-test/enforcer-config-claims-provider.json")); + deployment.setClient(HttpClients.createDefault()); + PolicyEnforcer policyEnforcer = deployment.getPolicyEnforcer(); + Map providers = policyEnforcer.getClaimInformationPointProviderFactories(); + + PathConfig pathConfig = policyEnforcer.getPaths().get(path); + + assertNotNull(pathConfig); + + Map> cipConfig = pathConfig.getClaimInformationPointConfig(); + + assertNotNull(cipConfig); + + ClaimInformationPointProviderFactory factory = providers.get(providerName); + + assertNotNull(factory); + + Map claimsConfig = cipConfig.get(providerName); + + return factory.create(claimsConfig); + } + + @Test + public void testBasicClaimsInformationPoint() { + HttpFacade httpFacade = createHttpFacade(); + Map> claims = getClaimInformationProviderForPath("/claims-provider", "claims").resolve(httpFacade); + + assertEquals("parameter-a", claims.get("claim-from-request-parameter").get(0)); + assertEquals("header-b", claims.get("claim-from-header").get(0)); + assertEquals("cookie-c", claims.get("claim-from-cookie").get(0)); + assertEquals("user-remote-addr", claims.get("claim-from-remoteAddr").get(0)); + assertEquals("GET", claims.get("claim-from-method").get(0)); + assertEquals("/app/request-uri", claims.get("claim-from-uri").get(0)); + assertEquals("/request-relative-path", claims.get("claim-from-relativePath").get(0)); + assertEquals("true", claims.get("claim-from-secure").get(0)); + assertEquals("static value", claims.get("claim-from-static-value").get(0)); + assertEquals("static", claims.get("claim-from-multiple-static-value").get(0)); + assertEquals("value", claims.get("claim-from-multiple-static-value").get(1)); + assertEquals("Test param-other-claims-value1 and parameter-a", claims.get("param-replace-multiple-placeholder").get(0)); + } + + @Test + public void testBodyJsonClaimsInformationPoint() throws Exception { + Map> headers = new HashMap<>(); + + headers.put("Content-Type", Arrays.asList("application/json")); + + ObjectMapper mapper = JsonSerialization.mapper; + JsonParser parser = mapper.getFactory().createParser("{\"a\": {\"b\": {\"c\": \"c-value\"}}, \"d\": [\"d-value1\", \"d-value2\"]}"); + TreeNode treeNode = mapper.readTree(parser); + HttpFacade httpFacade = createHttpFacade(headers, new ByteArrayInputStream(treeNode.toString().getBytes())); + + Map> claims = getClaimInformationProviderForPath("/claims-provider", "claims").resolve(httpFacade); + + assertEquals("c-value", claims.get("claim-from-json-body-object").get(0)); + assertEquals("d-value2", claims.get("claim-from-json-body-array").get(0)); + } + + @Test + public void testBodyClaimsInformationPoint() { + HttpFacade httpFacade = createHttpFacade(new HashMap<>(), new ByteArrayInputStream("raw-body-text".getBytes())); + + Map> claims = getClaimInformationProviderForPath("/claims-provider", "claims").resolve(httpFacade); + + assertEquals("raw-body-text", claims.get("claim-from-body").get(0)); + } + + @Test + public void testHttpClaimInformationPointProviderWithoutClaims() { + HttpFacade httpFacade = createHttpFacade(); + + Map> claims = getClaimInformationProviderForPath("/http-get-claim-provider", "http").resolve(httpFacade); + + assertEquals("a-value1", claims.get("a").get(0)); + assertEquals("b-value1", claims.get("b").get(0)); + assertEquals("d-value1", claims.get("d").get(0)); + assertEquals("d-value2", claims.get("d").get(1)); + + assertNull(claims.get("claim-a")); + assertNull(claims.get("claim-d")); + assertNull(claims.get("claim-d0")); + assertNull(claims.get("claim-d-all")); + } + + @Test + public void testHttpClaimInformationPointProviderWithClaims() { + HttpFacade httpFacade = createHttpFacade(); + + Map> claims = getClaimInformationProviderForPath("/http-post-claim-provider", "http").resolve(httpFacade); + + assertEquals("a-value1", claims.get("claim-a").get(0)); + assertEquals("d-value1", claims.get("claim-d").get(0)); + assertEquals("d-value2", claims.get("claim-d").get(1)); + assertEquals("d-value1", claims.get("claim-d0").get(0)); + assertEquals("d-value1", claims.get("claim-d-all").get(0)); + assertEquals("d-value2", claims.get("claim-d-all").get(1)); + + assertNull(claims.get("a")); + assertNull(claims.get("b")); + assertNull(claims.get("d")); + } + + private HttpFacade createHttpFacade(Map> headers, InputStream requestBody) { + return new OIDCHttpFacade() { + private Request request; + + @Override + public KeycloakSecurityContext getSecurityContext() { + AccessToken token = new AccessToken(); + + token.subject("sub"); + token.setPreferredUsername("username"); + token.getOtherClaims().put("custom_claim", Arrays.asList("param-other-claims-value1", "param-other-claims-value2")); + + IDToken idToken = new IDToken(); + + idToken.subject("sub"); + idToken.setPreferredUsername("username"); + idToken.getOtherClaims().put("custom_claim", Arrays.asList("param-other-claims-value1", "param-other-claims-value2")); + + return new KeycloakSecurityContext("tokenString", token, "idTokenString", idToken); + } + + @Override + public Request getRequest() { + if (request == null) { + request = createHttpRequest(headers, requestBody); + } + return request; + } + + @Override + public Response getResponse() { + return createHttpResponse(); + } + + @Override + public X509Certificate[] getCertificateChain() { + return new X509Certificate[0]; + } + }; + } + + private HttpFacade createHttpFacade() { + return createHttpFacade(new HashMap<>(), null); + } + + private Response createHttpResponse() { + return new Response() { + @Override + public void setStatus(int status) { + + } + + @Override + public void addHeader(String name, String value) { + + } + + @Override + public void setHeader(String name, String value) { + + } + + @Override + public void resetCookie(String name, String path) { + + } + + @Override + public void setCookie(String name, String value, String path, String domain, int maxAge, boolean secure, boolean httpOnly) { + + } + + @Override + public OutputStream getOutputStream() { + return null; + } + + @Override + public void sendError(int code) { + + } + + @Override + public void sendError(int code, String message) { + + } + + @Override + public void end() { + + } + }; + } + + private Request createHttpRequest(Map> headers, InputStream requestBody) { + Map> queryParameter = new HashMap<>(); + + queryParameter.put("a", Arrays.asList("parameter-a")); + + headers.put("b", Arrays.asList("header-b")); + + Map cookies = new HashMap<>(); + + cookies.put("c", new Cookie("c", "cookie-c", 1, "localhost", "/")); + + return new Request() { + + private InputStream inputStream; + + @Override + public String getMethod() { + return "GET"; + } + + @Override + public String getURI() { + return "/app/request-uri"; + } + + @Override + public String getRelativePath() { + return "/request-relative-path"; + } + + @Override + public boolean isSecure() { + return true; + } + + @Override + public String getFirstParam(String param) { + List values = queryParameter.getOrDefault(param, Collections.emptyList()); + + if (!values.isEmpty()) { + return values.get(0); + } + + return null; + } + + @Override + public String getQueryParamValue(String param) { + return getFirstParam(param); + } + + @Override + public Cookie getCookie(String cookieName) { + return cookies.get(cookieName); + } + + @Override + public String getHeader(String name) { + List headers = getHeaders(name); + + if (!headers.isEmpty()) { + return headers.get(0); + } + + return null; + } + + @Override + public List getHeaders(String name) { + return headers.getOrDefault(name, Collections.emptyList()); + } + + @Override + public InputStream getInputStream() { + return getInputStream(false); + } + + @Override + public InputStream getInputStream(boolean buffer) { + if (requestBody == null) { + return new ByteArrayInputStream(new byte[] {}); + } + + if (inputStream != null) { + return inputStream; + } + + if (buffer) { + return inputStream = new BufferedInputStream(requestBody); + } + + return requestBody; + } + + @Override + public String getRemoteAddr() { + return "user-remote-addr"; + } + + @Override + public void setError(AuthenticationError error) { + + } + + @Override + public void setError(LogoutError error) { + + } + }; + } +} diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/client/authorization/EnforcerConfigTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/client/authorization/EnforcerConfigTest.java index 081bb79305..327b733e54 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/client/authorization/EnforcerConfigTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/client/authorization/EnforcerConfigTest.java @@ -22,6 +22,7 @@ import org.keycloak.adapters.KeycloakDeployment; import org.keycloak.adapters.KeycloakDeploymentBuilder; import org.keycloak.adapters.authorization.PolicyEnforcer; import org.keycloak.representations.adapters.config.PolicyEnforcerConfig; +import org.keycloak.representations.adapters.config.PolicyEnforcerConfig.PathConfig; import org.keycloak.representations.idm.RealmRepresentation; import org.keycloak.testsuite.AbstractKeycloakTest; import org.keycloak.testsuite.ProfileAssume; @@ -30,6 +31,8 @@ import java.util.List; import java.util.Map; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; import static org.keycloak.testsuite.util.IOUtil.loadRealm; /** @@ -47,11 +50,34 @@ public class EnforcerConfigTest extends AbstractKeycloakTest { } @Test - public void testMultiplePathsWithSameName() throws Exception{ + public void testMultiplePathsWithSameName() { KeycloakDeployment deployment = KeycloakDeploymentBuilder.build(getClass().getResourceAsStream("/authorization-test/enforcer-config-paths-same-name.json")); PolicyEnforcer policyEnforcer = deployment.getPolicyEnforcer(); Map paths = policyEnforcer.getPaths(); assertEquals(1, paths.size()); assertEquals(4, paths.values().iterator().next().getMethods().size()); } + + @Test + public void testPathConfigClaimInformationPoint() { + KeycloakDeployment deployment = KeycloakDeploymentBuilder.build(getClass().getResourceAsStream("/authorization-test/enforcer-config-path-cip.json")); + PolicyEnforcer policyEnforcer = deployment.getPolicyEnforcer(); + Map paths = policyEnforcer.getPaths(); + + assertEquals(1, paths.size()); + + PathConfig pathConfig = paths.values().iterator().next(); + Map> cipConfig = pathConfig.getClaimInformationPointConfig(); + + assertEquals(1, cipConfig.size()); + + Map claims = cipConfig.get("claims"); + + assertNotNull(claims); + + assertEquals(3, claims.size()); + assertEquals("{request.parameter['a']}", claims.get("claim-a")); + assertEquals("{request.header['b']}", claims.get("claim-b")); + assertEquals("{request.cookie['c']}", claims.get("claim-c")); + } } diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/client/authorization/PolicyEnforcerTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/client/authorization/PolicyEnforcerTest.java new file mode 100644 index 0000000000..81f2ffde8d --- /dev/null +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/client/authorization/PolicyEnforcerTest.java @@ -0,0 +1,512 @@ +/* + * Copyright 2018 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.admin.client.authorization; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +import java.io.BufferedInputStream; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import javax.security.cert.X509Certificate; + +import org.junit.BeforeClass; +import org.junit.Test; +import org.keycloak.AuthorizationContext; +import org.keycloak.KeycloakSecurityContext; +import org.keycloak.adapters.KeycloakDeployment; +import org.keycloak.adapters.KeycloakDeploymentBuilder; +import org.keycloak.adapters.OIDCHttpFacade; +import org.keycloak.adapters.authorization.PolicyEnforcer; +import org.keycloak.adapters.spi.AuthenticationError; +import org.keycloak.adapters.spi.HttpFacade.Cookie; +import org.keycloak.adapters.spi.HttpFacade.Request; +import org.keycloak.adapters.spi.HttpFacade.Response; +import org.keycloak.adapters.spi.LogoutError; +import org.keycloak.admin.client.resource.ClientResource; +import org.keycloak.admin.client.resource.ClientsResource; +import org.keycloak.authorization.client.AuthzClient; +import org.keycloak.authorization.client.Configuration; +import org.keycloak.jose.jws.JWSInput; +import org.keycloak.jose.jws.JWSInputException; +import org.keycloak.representations.AccessToken; +import org.keycloak.representations.idm.ClientRepresentation; +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.JSPolicyRepresentation; +import org.keycloak.representations.idm.authorization.ResourceRepresentation; +import org.keycloak.representations.idm.authorization.ScopePermissionRepresentation; +import org.keycloak.representations.idm.authorization.ScopeRepresentation; +import org.keycloak.testsuite.AbstractKeycloakTest; +import org.keycloak.testsuite.ProfileAssume; +import org.keycloak.testsuite.util.ClientBuilder; +import org.keycloak.testsuite.util.RealmBuilder; +import org.keycloak.testsuite.util.RoleBuilder; +import org.keycloak.testsuite.util.RolesBuilder; +import org.keycloak.testsuite.util.UserBuilder; +import org.keycloak.util.JsonSerialization; + +/** + * @author Pedro Igor + */ +public class PolicyEnforcerTest extends AbstractKeycloakTest { + + protected static final String REALM_NAME = "authz-test"; + + @BeforeClass + public static void onBeforeClass() { + ProfileAssume.assumePreview(); + } + + @Override + public void addTestRealms(List testRealms) { + testRealms.add(RealmBuilder.create().name(REALM_NAME) + .roles(RolesBuilder.create() + .realmRole(RoleBuilder.create().name("uma_authorization").build()) + .realmRole(RoleBuilder.create().name("uma_protection").build()) + ) + .user(UserBuilder.create().username("marta").password("password") + .addRoles("uma_authorization", "uma_protection") + .role("resource-server-test", "uma_protection")) + .user(UserBuilder.create().username("kolo").password("password")) + .client(ClientBuilder.create().clientId("resource-server-uma-test") + .secret("secret") + .authorizationServicesEnabled(true) + .redirectUris("http://localhost/resource-server-uma-test") + .defaultRoles("uma_protection") + .directAccessGrants()) + .client(ClientBuilder.create().clientId("resource-server-test") + .secret("secret") + .authorizationServicesEnabled(true) + .redirectUris("http://localhost/resource-server-test") + .defaultRoles("uma_protection") + .directAccessGrants()) + .build()); + } + + @Test + public void testEnforceUMAAccessWithClaimsUsingBearerToken() { + initAuthorizationSettings(getClientResource("resource-server-uma-test")); + + KeycloakDeployment deployment = KeycloakDeploymentBuilder.build(getAdapterConfiguration("enforcer-uma-claims-test.json")); + PolicyEnforcer policyEnforcer = deployment.getPolicyEnforcer(); + HashMap> headers = new HashMap<>(); + HashMap> parameters = new HashMap<>(); + + parameters.put("withdrawal.amount", Arrays.asList("50")); + + AuthzClient authzClient = getAuthzClient("enforcer-uma-claims-test.json"); + String token = authzClient.obtainAccessToken("marta", "password").getToken(); + + headers.put("Authorization", Arrays.asList("Bearer " + token)); + + AuthorizationContext context = policyEnforcer.enforce(createHttpFacade("/api/bank/account/1/withdrawal", token, headers, parameters)); + assertFalse(context.isGranted()); + + AuthorizationRequest request = new AuthorizationRequest(); + + request.setTicket(extractTicket(headers)); + + AuthorizationResponse response = authzClient.authorization("marta", "password").authorize(request); + token = response.getToken(); + + assertNotNull(token); + + context = policyEnforcer.enforce(createHttpFacade("/api/bank/account/1/withdrawal", token, headers, parameters)); + assertTrue(context.isGranted()); + + parameters.put("withdrawal.amount", Arrays.asList("200")); + + context = policyEnforcer.enforce(createHttpFacade("/api/bank/account/1/withdrawal", token, headers, parameters)); + assertFalse(context.isGranted()); + + parameters.put("withdrawal.amount", Arrays.asList("50")); + + context = policyEnforcer.enforce(createHttpFacade("/api/bank/account/1/withdrawal", token, headers, parameters)); + assertTrue(context.isGranted()); + + parameters.put("withdrawal.amount", Arrays.asList("10")); + + context = policyEnforcer.enforce(createHttpFacade("/api/bank/account/1/withdrawal", token, headers, parameters)); + + request = new AuthorizationRequest(); + + request.setTicket(extractTicket(headers)); + + response = authzClient.authorization("marta", "password").authorize(request); + token = response.getToken(); + + context = policyEnforcer.enforce(createHttpFacade("/api/bank/account/1/withdrawal", token, headers, parameters)); + assertTrue(context.isGranted()); + } + + @Test + public void testEnforceEntitlementAccessWithClaimsWithoutBearerToken() { + initAuthorizationSettings(getClientResource("resource-server-test")); + + KeycloakDeployment deployment = KeycloakDeploymentBuilder.build(getAdapterConfiguration("enforcer-entitlement-claims-test.json")); + PolicyEnforcer policyEnforcer = deployment.getPolicyEnforcer(); + HashMap> headers = new HashMap<>(); + HashMap> parameters = new HashMap<>(); + + AuthzClient authzClient = getAuthzClient("enforcer-entitlement-claims-test.json"); + String token = authzClient.obtainAccessToken("marta", "password").getToken(); + + AuthorizationContext context = policyEnforcer.enforce(createHttpFacade("/api/bank/account/1/withdrawal", token, headers, parameters)); + assertFalse(context.isGranted()); + + parameters.put("withdrawal.amount", Arrays.asList("50")); + + context = policyEnforcer.enforce(createHttpFacade("/api/bank/account/1/withdrawal", token, headers, parameters)); + assertTrue(context.isGranted()); + + parameters.put("withdrawal.amount", Arrays.asList("200")); + + context = policyEnforcer.enforce(createHttpFacade("/api/bank/account/1/withdrawal", token, headers, parameters)); + assertFalse(context.isGranted()); + + parameters.put("withdrawal.amount", Arrays.asList("50")); + + context = policyEnforcer.enforce(createHttpFacade("/api/bank/account/1/withdrawal", token, headers, parameters)); + assertTrue(context.isGranted()); + + parameters.put("withdrawal.amount", Arrays.asList("10")); + + context = policyEnforcer.enforce(createHttpFacade("/api/bank/account/1/withdrawal", token, headers, parameters)); + + assertTrue(context.isGranted()); + } + + @Test + public void testEnforceEntitlementAccessWithClaimsWithBearerToken() { + initAuthorizationSettings(getClientResource("resource-server-test")); + + KeycloakDeployment deployment = KeycloakDeploymentBuilder.build(getAdapterConfiguration("enforcer-entitlement-claims-test.json")); + PolicyEnforcer policyEnforcer = deployment.getPolicyEnforcer(); + HashMap> headers = new HashMap<>(); + HashMap> parameters = new HashMap<>(); + + AuthzClient authzClient = getAuthzClient("enforcer-entitlement-claims-test.json"); + String token = authzClient.obtainAccessToken("marta", "password").getToken(); + + headers.put("Authorization", Arrays.asList("Bearer " + token)); + + AuthorizationContext context = policyEnforcer.enforce(createHttpFacade("/api/bank/account/1/withdrawal", token, headers, parameters)); + assertFalse(context.isGranted()); + + parameters.put("withdrawal.amount", Arrays.asList("50")); + + context = policyEnforcer.enforce(createHttpFacade("/api/bank/account/1/withdrawal", token, headers, parameters)); + assertTrue(context.isGranted()); + + parameters.put("withdrawal.amount", Arrays.asList("200")); + + context = policyEnforcer.enforce(createHttpFacade("/api/bank/account/1/withdrawal", token, headers, parameters)); + assertFalse(context.isGranted()); + + parameters.put("withdrawal.amount", Arrays.asList("50")); + + context = policyEnforcer.enforce(createHttpFacade("/api/bank/account/1/withdrawal", token, headers, parameters)); + assertTrue(context.isGranted()); + + parameters.put("withdrawal.amount", Arrays.asList("10")); + + context = policyEnforcer.enforce(createHttpFacade("/api/bank/account/1/withdrawal", token, headers, parameters)); + + assertTrue(context.isGranted()); + } + + private String extractTicket(HashMap> headers) { + List wwwAuthenticateHeader = headers.get("WWW-Authenticate"); + + assertNotNull(wwwAuthenticateHeader); + assertFalse(wwwAuthenticateHeader.isEmpty()); + + String wwwAuthenticate = wwwAuthenticateHeader.get(0); + return wwwAuthenticate.substring(wwwAuthenticate.indexOf("ticket=") + "ticket=\"".length(), wwwAuthenticate.lastIndexOf('"')); + } + + private void initAuthorizationSettings(ClientResource clientResource) { + if (clientResource.authorization().resources().findByName("Bank Account").isEmpty()) { + JSPolicyRepresentation policy = new JSPolicyRepresentation(); + + policy.setName("Withdrawal Limit Policy"); + + StringBuilder code = new StringBuilder(); + + code.append("var context = $evaluation.getContext();"); + code.append("var attributes = context.getAttributes();"); + code.append("var withdrawalAmount = attributes.getValue('withdrawal.amount');"); + code.append("if (withdrawalAmount && withdrawalAmount.asDouble(0) <= 100) {"); + code.append(" $evaluation.grant();"); + code.append("}"); + + policy.setCode(code.toString()); + + clientResource.authorization().policies().js().create(policy); + + createResource(clientResource, "Bank Account", "/api/bank/account/{id}/withdrawal", "withdrawal"); + + ScopePermissionRepresentation permission = new ScopePermissionRepresentation(); + + permission.setName("Withdrawal Permission"); + permission.addScope("withdrawal"); + permission.addPolicy(policy.getName()); + + clientResource.authorization().permissions().scope().create(permission); + } + } + + private InputStream getAdapterConfiguration(String fileName) { + return getClass().getResourceAsStream("/authorization-test/" + fileName); + } + + private ResourceRepresentation createResource(ClientResource clientResource, String name, String uri, String... scopes) { + ResourceRepresentation representation = new ResourceRepresentation(); + + representation.setName(name); + representation.setUri(uri); + representation.setScopes(Arrays.asList(scopes).stream().map(ScopeRepresentation::new).collect(Collectors.toSet())); + + javax.ws.rs.core.Response response = clientResource.authorization().resources().create(representation); + + representation.setId(response.readEntity(ResourceRepresentation.class).getId()); + + return representation; + } + + private ClientResource getClientResource(String name) { + ClientsResource clients = realmsResouce().realm(REALM_NAME).clients(); + ClientRepresentation representation = clients.findByClientId(name).get(0); + return clients.get(representation.getId()); + } + + private OIDCHttpFacade createHttpFacade(String path, String token, Map> headers, Map> parameters, InputStream requestBody) { + return new OIDCHttpFacade() { + Request request; + Response response; + + @Override + public KeycloakSecurityContext getSecurityContext() { + AccessToken accessToken; + try { + accessToken = new JWSInput(token).readJsonContent(AccessToken.class); + } catch (JWSInputException cause) { + throw new RuntimeException(cause); + } + return new KeycloakSecurityContext(token, accessToken, null, null); + } + + @Override + public Request getRequest() { + if (request == null) { + request = createHttpRequest(path, headers, parameters, requestBody); + } + return request; + } + + @Override + public Response getResponse() { + if (response == null) { + response = createHttpResponse(headers); + } + return response; + } + + @Override + public X509Certificate[] getCertificateChain() { + return new X509Certificate[0]; + } + }; + } + + private OIDCHttpFacade createHttpFacade(String path, String token, Map> headers, Map> parameters) { + return createHttpFacade(path, token, headers, parameters, null); + } + + private Response createHttpResponse(Map> headers) { + return new Response() { + + private int status; + + @Override + public void setStatus(int status) { + this.status = status; + } + + @Override + public void addHeader(String name, String value) { + setHeader(name, value); + } + + @Override + public void setHeader(String name, String value) { + headers.put(name, Arrays.asList(value)); + } + + @Override + public void resetCookie(String name, String path) { + + } + + @Override + public void setCookie(String name, String value, String path, String domain, int maxAge, boolean secure, boolean httpOnly) { + + } + + @Override + public OutputStream getOutputStream() { + return null; + } + + @Override + public void sendError(int code) { + + } + + @Override + public void sendError(int code, String message) { + + } + + @Override + public void end() { + + } + }; + } + + private Request createHttpRequest(String path, Map> headers, Map> parameters, InputStream requestBody) { + return new Request() { + + private InputStream inputStream; + + @Override + public String getMethod() { + return "GET"; + } + + @Override + public String getURI() { + return path; + } + + @Override + public String getRelativePath() { + return path; + } + + @Override + public boolean isSecure() { + return true; + } + + @Override + public String getFirstParam(String param) { + List values = parameters.getOrDefault(param, Collections.emptyList()); + + if (!values.isEmpty()) { + return values.get(0); + } + + return null; + } + + @Override + public String getQueryParamValue(String param) { + return getFirstParam(param); + } + + @Override + public Cookie getCookie(String cookieName) { + return null; + } + + @Override + public String getHeader(String name) { + List headers = getHeaders(name); + + if (!headers.isEmpty()) { + return headers.get(0); + } + + return null; + } + + @Override + public List getHeaders(String name) { + return headers.getOrDefault(name, Collections.emptyList()); + } + + @Override + public InputStream getInputStream() { + return getInputStream(false); + } + + @Override + public InputStream getInputStream(boolean buffer) { + if (requestBody == null) { + return new ByteArrayInputStream(new byte[] {}); + } + + if (inputStream != null) { + return inputStream; + } + + if (buffer) { + return inputStream = new BufferedInputStream(requestBody); + } + + return requestBody; + } + + @Override + public String getRemoteAddr() { + return "user-remote-addr"; + } + + @Override + public void setError(AuthenticationError error) { + + } + + @Override + public void setError(LogoutError error) { + + } + }; + } + + protected AuthzClient getAuthzClient(String fileName) { + try { + return AuthzClient.create(JsonSerialization.readValue(getAdapterConfiguration(fileName), Configuration.class)); + } catch (IOException cause) { + throw new RuntimeException("Failed to create authz client", cause); + } + } +} diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/authz/EntitlementAPITest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/authz/EntitlementAPITest.java index a7fab87278..167761fbc4 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/authz/EntitlementAPITest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/authz/EntitlementAPITest.java @@ -20,19 +20,25 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; import java.io.IOException; +import java.util.HashMap; import java.util.List; import java.util.function.Supplier; import org.junit.Before; import org.junit.Test; +import org.keycloak.OAuth2Constants; import org.keycloak.admin.client.resource.AuthorizationResource; import org.keycloak.admin.client.resource.ClientResource; 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.authorization.client.Configuration; +import org.keycloak.authorization.client.util.HttpResponseException; +import org.keycloak.common.util.Base64Url; import org.keycloak.representations.AccessToken; import org.keycloak.representations.idm.RealmRepresentation; import org.keycloak.representations.idm.authorization.AuthorizationRequest; @@ -61,6 +67,8 @@ public class EntitlementAPITest extends AbstractAuthzTest { private static final String PAIRWISE_RESOURCE_SERVER_TEST = "pairwise-resource-server-test"; private static final String PAIRWISE_TEST_CLIENT = "test-client-pairwise"; private static final String PAIRWISE_AUTHZ_CLIENT_CONFIG = "default-keycloak-pairwise.json"; + private static final String PUBLIC_TEST_CLIENT = "test-public-client"; + private static final String PUBLIC_TEST_CLIENT_CONFIG = "default-keycloak-public-client.json"; private AuthzClient authzClient; @@ -94,6 +102,10 @@ public class EntitlementAPITest extends AbstractAuthzTest { .redirectUris("http://localhost/test-client") .pairwise("http://pairwise.com") .directAccessGrants()) + .client(ClientBuilder.create().clientId(PUBLIC_TEST_CLIENT) + .secret("secret") + .redirectUris("http://localhost:8180/auth/realms/master/app/auth/*") + .publicClient()) .build()); } @@ -164,6 +176,65 @@ public class EntitlementAPITest extends AbstractAuthzTest { testRptRequestWithResourceName(PAIRWISE_AUTHZ_CLIENT_CONFIG); } + @Test + public void testInvalidRequestWithClaimsFromConfidentialClient() throws IOException { + AuthorizationRequest request = new AuthorizationRequest(); + + request.addPermission("Resource 13"); + HashMap obj = new HashMap<>(); + + obj.put("claim-a", "claim-a"); + + request.setClaimToken(Base64Url.encode(JsonSerialization.writeValueAsBytes(obj))); + + assertResponse(new Metadata(), () -> getAuthzClient(AUTHZ_CLIENT_CONFIG).authorization("marta", "password").authorize(request)); + } + + @Test + public void testInvalidRequestWithClaimsFromPublicClient() throws IOException { + oauth.realm("authz-test"); + oauth.clientId(PUBLIC_TEST_CLIENT); + + oauth.doLogin("marta", "password"); + + // Token request + String code = oauth.getCurrentQuery().get(OAuth2Constants.CODE); + OAuthClient.AccessTokenResponse response = oauth.doAccessTokenRequest(code, null); + + AuthorizationRequest request = new AuthorizationRequest(); + + request.addPermission("Resource 13"); + HashMap obj = new HashMap<>(); + + obj.put("claim-a", "claim-a"); + + request.setClaimToken(Base64Url.encode(JsonSerialization.writeValueAsBytes(obj))); + + try { + getAuthzClient(AUTHZ_CLIENT_CONFIG).authorization(response.getAccessToken()).authorize(request); + } catch (AuthorizationDeniedException expected) { + assertEquals(403, HttpResponseException.class.cast(expected.getCause()).getStatusCode()); + assertTrue(HttpResponseException.class.cast(expected.getCause()).toString().contains("Public clients are not allowed to send claims")); + } + } + + @Test + public void testRequestWithoutClaimsFromPublicClient() { + oauth.realm("authz-test"); + oauth.clientId(PUBLIC_TEST_CLIENT); + + oauth.doLogin("marta", "password"); + + // Token request + String code = oauth.getCurrentQuery().get(OAuth2Constants.CODE); + OAuthClient.AccessTokenResponse response = oauth.doAccessTokenRequest(code, null); + + AuthorizationRequest request = new AuthorizationRequest(); + + request.addPermission("Resource 13"); + + assertResponse(new Metadata(), () -> getAuthzClient(AUTHZ_CLIENT_CONFIG).authorization(response.getAccessToken()).authorize(request)); + } public void testRptRequestWithResourceName(String configFile) { Metadata metadata = new Metadata(); diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/authz/PermissionManagementTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/authz/PermissionManagementTest.java index f488d32897..9d47f8d931 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/authz/PermissionManagementTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/authz/PermissionManagementTest.java @@ -219,7 +219,7 @@ public class PermissionManagementTest extends AbstractResourceServerTest { try { authzClient.authorization().authorize(request); } catch (Exception e) { - + e.printStackTrace(); } List permissions = authzClient.protection().permission().findByResource(resource.getId()); diff --git a/testsuite/integration-arquillian/tests/base/src/test/resources/authorization-test/default-keycloak-public-client.json b/testsuite/integration-arquillian/tests/base/src/test/resources/authorization-test/default-keycloak-public-client.json new file mode 100644 index 0000000000..b04b57fef2 --- /dev/null +++ b/testsuite/integration-arquillian/tests/base/src/test/resources/authorization-test/default-keycloak-public-client.json @@ -0,0 +1,5 @@ +{ + "realm": "authz-test", + "auth-server-url" : "http://localhost:8180/auth", + "resource" : "test-public-client" +} \ No newline at end of file diff --git a/testsuite/integration-arquillian/tests/base/src/test/resources/authorization-test/enforcer-config-claims-provider.json b/testsuite/integration-arquillian/tests/base/src/test/resources/authorization-test/enforcer-config-claims-provider.json new file mode 100644 index 0000000000..9da5dae147 --- /dev/null +++ b/testsuite/integration-arquillian/tests/base/src/test/resources/authorization-test/enforcer-config-claims-provider.json @@ -0,0 +1,89 @@ +{ + "realm": "test-realm-authz", + "realm-public-key": "MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCrVrCuTtArbgaZzL1hvh0xtL5mc7o0NqPVnYXkLvgcwiC3BjLGw1tGEGoJaXDuSaRllobm53JBhjx33UNv+5z/UMG4kytBWxheNVKnL6GgqlNabMaFfPLPCF8kAgKnsi79NMo+n6KnSY8YeUmec/p2vjO2NjsSAVcWEQMVhJ31LwIDAQAB", + "auth-server-url": "http://localhost:8180/auth", + "ssl-required": "external", + "resource": "test-app-authz", + "bearer-only": true, + "credentials": { + "secret": "secret" + }, + "policy-enforcer": { + "paths": [ + { + "path": "/claims-provider", + "methods": [ + { + "method": "POST", + "scopes": [ + "create" + ] + } + ], + "claim-information-point": { + "claims": { + "claim-from-request-parameter": "{request.parameter['a']}", + "claim-from-header": "{request.header['b']}", + "claim-from-cookie": "{request.cookie['c']}", + "claim-from-remoteAddr": "{request.remoteAddr}", + "claim-from-method": "{request.method}", + "claim-from-uri": "{request.uri}", + "claim-from-relativePath": "{request.relativePath}", + "claim-from-secure": "{request.secure}", + "claim-from-json-body-object": "{request.body['/a/b/c']}", + "claim-from-json-body-array": "{request.body['/d/1']}", + "claim-from-body": "{request.body}", + "claim-from-static-value": "static value", + "claim-from-multiple-static-value": ["static", "value"], + "param-replace-multiple-placeholder": "Test {keycloak.access_token['/custom_claim/0']} and {request.parameter['a']} " + } + } + }, + { + "path": "/http-post-claim-provider", + "claim-information-point": { + "http": { + "claims": { + "claim-a": "/a", + "claim-d": "/d", + "claim-d0": "/d/0", + "claim-d-all": ["/d/0", "/d/1"] + }, + "url": "http://localhost:8989/post-claim-information-provider", + "method": "POST", + "headers": { + "Content-Type": "application/x-www-form-urlencoded", + "header-b": ["header-b-value1", "header-b-value2"], + "Authorization": "Bearer {keycloak.access_token}" + }, + "parameters": { + "param-a": ["param-a-value1", "param-a-value2"], + "param-subject": "{keycloak.access_token['/sub']}", + "param-user-name": "{keycloak.access_token['/preferred_username']}", + "param-other-claims": "{keycloak.access_token['/custom_claim']}" + } + } + } + }, + { + "path": "/http-get-claim-provider", + "claim-information-point": { + "http": { + "url": "http://localhost:8989/get-claim-information-provider", + "method": "get", + "headers": { + "Content-Type": "application/x-www-form-urlencoded", + "header-b": ["header-b-value1", "header-b-value2"], + "Authorization": "Bearer {keycloak.id_token}" + }, + "parameters": { + "param-a": ["param-a-value1", "param-a-value2"], + "param-subject": "{keycloak.id_token['/sub']}", + "param-user-name": "{keycloak.id_token['/preferred_username']}" + } + } + } + } + ] + } +} diff --git a/testsuite/integration-arquillian/tests/base/src/test/resources/authorization-test/enforcer-config-path-cip.json b/testsuite/integration-arquillian/tests/base/src/test/resources/authorization-test/enforcer-config-path-cip.json new file mode 100644 index 0000000000..78fd7c7cae --- /dev/null +++ b/testsuite/integration-arquillian/tests/base/src/test/resources/authorization-test/enforcer-config-path-cip.json @@ -0,0 +1,33 @@ +{ + "realm": "test-realm-authz", + "realm-public-key": "MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCrVrCuTtArbgaZzL1hvh0xtL5mc7o0NqPVnYXkLvgcwiC3BjLGw1tGEGoJaXDuSaRllobm53JBhjx33UNv+5z/UMG4kytBWxheNVKnL6GgqlNabMaFfPLPCF8kAgKnsi79NMo+n6KnSY8YeUmec/p2vjO2NjsSAVcWEQMVhJ31LwIDAQAB", + "auth-server-url": "http://localhost:8180/auth", + "ssl-required": "external", + "resource": "test-app-authz", + "bearer-only": true, + "credentials": { + "secret": "secret" + }, + "policy-enforcer": { + "paths": [ + { + "path": "/v1/product/*", + "methods": [ + { + "method": "POST", + "scopes": [ + "create" + ] + } + ], + "claim-information-point": { + "claims": { + "claim-a": "{request.parameter['a']}", + "claim-b": "{request.header['b']}", + "claim-c": "{request.cookie['c']}" + } + } + } + ] + } +} diff --git a/testsuite/integration-arquillian/tests/base/src/test/resources/authorization-test/enforcer-entitlement-claims-test.json b/testsuite/integration-arquillian/tests/base/src/test/resources/authorization-test/enforcer-entitlement-claims-test.json new file mode 100644 index 0000000000..b01909d920 --- /dev/null +++ b/testsuite/integration-arquillian/tests/base/src/test/resources/authorization-test/enforcer-entitlement-claims-test.json @@ -0,0 +1,29 @@ +{ + "realm": "authz-test", + "auth-server-url": "http://localhost:8180/auth", + "ssl-required": "external", + "resource": "resource-server-test", + "credentials": { + "secret": "secret" + }, + "policy-enforcer": { + "paths": [ + { + "path": "/api/bank/account/{id}/withdrawal", + "methods": [ + { + "method": "POST", + "scopes": [ + "withdrawal" + ] + } + ], + "claim-information-point": { + "claims": { + "withdrawal.amount": "{request.parameter['withdrawal.amount']}" + } + } + } + ] + } +} diff --git a/testsuite/integration-arquillian/tests/base/src/test/resources/authorization-test/enforcer-uma-claims-test.json b/testsuite/integration-arquillian/tests/base/src/test/resources/authorization-test/enforcer-uma-claims-test.json new file mode 100644 index 0000000000..9729103db9 --- /dev/null +++ b/testsuite/integration-arquillian/tests/base/src/test/resources/authorization-test/enforcer-uma-claims-test.json @@ -0,0 +1,31 @@ +{ + "realm": "authz-test", + "auth-server-url": "http://localhost:8180/auth", + "ssl-required": "external", + "resource": "resource-server-uma-test", + "bearer-only": true, + "credentials": { + "secret": "secret" + }, + "policy-enforcer": { + "user-managed-access": {}, + "paths": [ + { + "path": "/api/bank/account/{id}/withdrawal", + "methods": [ + { + "method": "POST", + "scopes": [ + "withdrawal" + ] + } + ], + "claim-information-point": { + "claims": { + "withdrawal.amount": "{request.parameter['withdrawal.amount']}" + } + } + } + ] + } +} diff --git a/testsuite/integration-arquillian/tests/other/adapters/jboss/wildfly/src/test/java/org/keycloak/testsuite/adapter/example/authorization/WildflyServletAuthzCIPAdapterTest.java b/testsuite/integration-arquillian/tests/other/adapters/jboss/wildfly/src/test/java/org/keycloak/testsuite/adapter/example/authorization/WildflyServletAuthzCIPAdapterTest.java new file mode 100644 index 0000000000..d59955da86 --- /dev/null +++ b/testsuite/integration-arquillian/tests/other/adapters/jboss/wildfly/src/test/java/org/keycloak/testsuite/adapter/example/authorization/WildflyServletAuthzCIPAdapterTest.java @@ -0,0 +1,31 @@ +/* + * Copyright 2018 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.adapter.example.authorization; + +import org.jboss.arquillian.container.test.api.RunAsClient; +import org.keycloak.testsuite.arquillian.annotation.AppServerContainer; + +/** + * + * @author tkyjovsk + */ +@RunAsClient +@AppServerContainer("app-server-wildfly") +//@AdapterLibsLocationProperty("adapter.libs.wildfly") +public class WildflyServletAuthzCIPAdapterTest extends AbstractServletAuthzCIPAdapterTest { + +}