From bd37875a66e1e23100e99a71f64a53e6336918b6 Mon Sep 17 00:00:00 2001 From: Yoshiyuki Tabata Date: Tue, 9 May 2023 15:20:12 +0900 Subject: [PATCH] allow specifying format of "permission" parameter in the UMA grant token endpoint (#15947) --- .../client/util/HttpMethodAuthenticator.java | 8 ++ .../authorization/AuthorizationRequest.java | 18 +++ ...ce-authorization-obtaining-permission.adoc | 8 ++ .../AuthorizationTokenService.java | 128 ++++++++++++++++++ .../oidc/endpoints/TokenEndpoint.java | 14 +- .../authz/AbstractResourceServerTest.java | 35 +++++ .../testsuite/authz/UmaGrantTypeTest.java | 57 +++++++- 7 files changed, 256 insertions(+), 12 deletions(-) diff --git a/authz/client/src/main/java/org/keycloak/authorization/client/util/HttpMethodAuthenticator.java b/authz/client/src/main/java/org/keycloak/authorization/client/util/HttpMethodAuthenticator.java index 9c9978bfa9..8e928bb188 100644 --- a/authz/client/src/main/java/org/keycloak/authorization/client/util/HttpMethodAuthenticator.java +++ b/authz/client/src/main/java/org/keycloak/authorization/client/util/HttpMethodAuthenticator.java @@ -134,6 +134,14 @@ public class HttpMethodAuthenticator { if (metadata.getResponseMode() != null) { method.param("response_mode", metadata.getResponseMode()); } + + if (metadata.getPermissionResourceFormat() != null) { + method.param("permission_resource_format", metadata.getPermissionResourceFormat().toString()); + } + + if (metadata.getPermissionResourceMatchingUri() != null) { + method.param("permission_resource_matching_uri", metadata.getPermissionResourceMatchingUri().toString()); + } } return method; 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 a305cf4125..9d62835de2 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 @@ -187,6 +187,8 @@ public class AuthorizationRequest { private Boolean includeResourceName; private Integer limit; private String responseMode; + private String permissionResourceFormat; + private Boolean permissionResourceMatchingUri; public Boolean getIncludeResourceName() { if (includeResourceName == null) { @@ -214,5 +216,21 @@ public class AuthorizationRequest { public String getResponseMode() { return responseMode; } + + public String getPermissionResourceFormat() { + return permissionResourceFormat; + } + + public void setPermissionResourceFormat(String permissionResourceFormat) { + this.permissionResourceFormat = permissionResourceFormat; + } + + public Boolean getPermissionResourceMatchingUri() { + return permissionResourceMatchingUri; + } + + public void setPermissionResourceMatchingUri(Boolean permissionResourceMatchingUri) { + this.permissionResourceMatchingUri = permissionResourceMatchingUri; + } } } diff --git a/docs/documentation/authorization_services/topics/service-authorization-obtaining-permission.adoc b/docs/documentation/authorization_services/topics/service-authorization-obtaining-permission.adoc index 6e98e89934..3f79ff647b 100644 --- a/docs/documentation/authorization_services/topics/service-authorization-obtaining-permission.adoc +++ b/docs/documentation/authorization_services/topics/service-authorization-obtaining-permission.adoc @@ -38,6 +38,14 @@ This parameter is *optional*. A string representing a set of one or more resourc in order to request permission for multiple resource and scopes. This parameter is an extension to `urn:ietf:params:oauth:grant-type:uma-ticket` grant type in order to allow clients to send authorization requests without a permission ticket. The format of the string must be: `RESOURCE_ID#SCOPE_ID`. For instance: `Resource A#Scope A`, `Resource A#Scope A, Scope B, Scope C`, `Resource A`, `#Scope A`. + +* **permission_resource_format** ++ +This parameter is *optional*. A string representing a format indicating the resource in the `permission` parameter. Possible values are `id` and `uri`. `id` indicates the format is `RESOURCE_ID`. `uri` indicates the format is `URI`. If not specified, the default is `id`. ++ +* **permission_resource_matching_uri** ++ +This parameter is *optional*. A boolean value that indicates whether to use path matching when representing resources in URI format in the `permission` parameter. If not specified, the default is false. ++ * **audience** + This parameter is *optional*. The client identifier of the resource server to which the client is seeking access. This parameter is mandatory 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 52931471d0..d251a8cd10 100644 --- a/services/src/main/java/org/keycloak/authorization/authorization/AuthorizationTokenService.java +++ b/services/src/main/java/org/keycloak/authorization/authorization/AuthorizationTokenService.java @@ -19,6 +19,8 @@ package org.keycloak.authorization.authorization; import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; +import java.util.Collections; +import java.util.EnumMap; import java.util.HashMap; import java.util.HashSet; import java.util.LinkedHashMap; @@ -59,12 +61,14 @@ import org.keycloak.authorization.util.Tokens; import org.keycloak.common.ClientConnection; import org.keycloak.common.constants.ServiceAccountConstants; import org.keycloak.common.util.Base64Url; +import org.keycloak.common.util.PathMatcher; import org.keycloak.events.Details; import org.keycloak.events.Errors; import org.keycloak.events.EventBuilder; import org.keycloak.models.AuthenticatedClientSessionModel; import org.keycloak.models.ClientModel; import org.keycloak.models.ClientSessionContext; +import org.keycloak.models.Constants; import org.keycloak.models.KeycloakSession; import org.keycloak.models.RealmModel; import org.keycloak.models.UserModel; @@ -802,5 +806,129 @@ public class AuthorizationTokenService { ClientConnection getClientConnection() { return clientConnection; } + + public void addPermissions(List permissionList, String permissionResourceFormat, boolean matchingUri) { + if (permissionResourceFormat == null) { + permissionResourceFormat = "id"; + } + + switch (permissionResourceFormat) { + case "id": + addPermissionsById(permissionList); + break; + case "uri": + addPermissionsByUri(permissionList, matchingUri); + break; + } + + } + + private void addPermissionsById(List permissionList) { + for (String permission : permissionList) { + String[] parts = permission.split("#"); + String rsid = parts[0]; + + if (parts.length == 1) { + addPermission(rsid); + } else { + String[] scopes = parts[1].split(","); + addPermission(rsid, scopes); + } + } + } + + private void addPermissionsByUri(List permissionList, boolean matchingUri) { + StoreFactory storeFactory = authorization.getStoreFactory(); + + for (String permission : permissionList) { + String[] parts = permission.split("#"); + String uri = parts[0]; + + if (parts.length == 1) { + // only resource uri is specified + if (uri.isEmpty()) { + CorsErrorResponseException invalidResourceException = new CorsErrorResponseException(getCors(), + OAuthErrorException.INVALID_REQUEST, "You must provide the uri", Status.BAD_REQUEST); + fireErrorEvent(getEvent(), Errors.INVALID_REQUEST, invalidResourceException); + throw invalidResourceException; + } + + List resources = getResourceListByUri(uri, storeFactory, matchingUri); + + if (resources == null || resources.isEmpty()) { + CorsErrorResponseException invalidResourceException = new CorsErrorResponseException(getCors(), + "invalid_resource", "Resource with uri [" + uri + "] does not exist.", Status.BAD_REQUEST); + fireErrorEvent(getEvent(), Errors.INVALID_REQUEST, invalidResourceException); + throw invalidResourceException; + } + + resources.stream().forEach(resource -> addPermission(resource.getId())); + } else { + // resource uri and scopes are specified, or only scopes are specified + String[] scopes = parts[1].split(","); + + if (uri.isEmpty()) { + // only scopes are specified + addPermission("", scopes); + return; + } + + List resources = getResourceListByUri(uri, storeFactory, matchingUri); + + if (resources == null || resources.isEmpty()) { + CorsErrorResponseException invalidResourceException = new CorsErrorResponseException(getCors(), + "invalid_resource", "Resource with uri [" + uri + "] does not exist.", Status.BAD_REQUEST); + fireErrorEvent(getEvent(), Errors.INVALID_REQUEST, invalidResourceException); + throw invalidResourceException; + } + + resources.stream().forEach(resource -> addPermission(resource.getId(), scopes)); + } + } + } + + private List getResourceListByUri(String uri, StoreFactory storeFactory, boolean matchingUri) { + Map search = new EnumMap<>(Resource.FilterOption.class); + search.put(Resource.FilterOption.URI, new String[] { uri }); + ResourceServer resourceServer = storeFactory.getResourceServerStore() + .findByClient(getRealm().getClientByClientId(getAudience())); + List resources = storeFactory.getResourceStore().find(getRealm(), resourceServer, search, -1, + Constants.DEFAULT_MAX_RESULTS); + + if (!matchingUri || !resources.isEmpty()) { + return resources; + } + + search = new EnumMap<>(Resource.FilterOption.class); + search.put(Resource.FilterOption.URI_NOT_NULL, new String[] { "true" }); + search.put(Resource.FilterOption.OWNER, new String[] { resourceServer.getClientId() }); + + List serverResources = storeFactory.getResourceStore().find(getRealm(), resourceServer, search, -1, -1); + + PathMatcher> pathMatcher = new PathMatcher>() { + @Override + protected String getPath(Map.Entry entry) { + return entry.getKey(); + } + + @Override + protected Collection> getPaths() { + Map result = new HashMap<>(); + serverResources.forEach(resource -> resource.getUris().forEach(uri -> { + result.put(uri, resource); + })); + + return result.entrySet(); + } + }; + + Map.Entry matches = pathMatcher.matches(uri); + + if (matches != null) { + return Collections.singletonList(matches.getValue()); + } + + return null; + } } } 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 4cb985dac0..13eb542052 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 @@ -960,17 +960,9 @@ public class TokenEndpoint { if (permissions != null) { event.detail(Details.PERMISSION, String.join("|", permissions)); - for (String permission : permissions) { - String[] parts = permission.split("#"); - String resource = parts[0]; - - if (parts.length == 1) { - authorizationRequest.addPermission(resource); - } else { - String[] scopes = parts[1].split(","); - authorizationRequest.addPermission(parts[0], scopes); - } - } + String permissionResourceFormat = formParams.getFirst("permission_resource_format"); + boolean permissionResourceMatchingUri = Boolean.parseBoolean(formParams.getFirst("permission_resource_matching_uri")); + authorizationRequest.addPermissions(permissions, permissionResourceFormat, permissionResourceMatchingUri); } Metadata metadata = new Metadata(); diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/authz/AbstractResourceServerTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/authz/AbstractResourceServerTest.java index c2a53ee068..c09fd19e2b 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/authz/AbstractResourceServerTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/authz/AbstractResourceServerTest.java @@ -19,6 +19,7 @@ package org.keycloak.testsuite.authz; import static org.junit.Assert.assertEquals; import java.io.IOException; +import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; import java.util.Iterator; @@ -35,6 +36,7 @@ import org.keycloak.authorization.client.AuthzClient; import org.keycloak.authorization.client.resource.ProtectionResource; import org.keycloak.representations.idm.RealmRepresentation; import org.keycloak.representations.idm.authorization.AuthorizationRequest; +import org.keycloak.representations.idm.authorization.AuthorizationRequest.Metadata; import org.keycloak.representations.idm.authorization.AuthorizationResponse; import org.keycloak.representations.idm.authorization.Permission; import org.keycloak.representations.idm.authorization.PermissionRequest; @@ -165,6 +167,31 @@ public abstract class AbstractResourceServerTest extends AbstractAuthzTest { return authorization.authorize(authorizationRequest); } + protected AuthorizationResponse authorizeDecision(String accessToken, Boolean matchingUri, PermissionRequest... permissions) { + AuthorizationRequest authorizationRequest = new AuthorizationRequest(); + + org.keycloak.authorization.client.resource.AuthorizationResource authorization; + + if (accessToken != null) { + authorization = getAuthzClient().authorization(accessToken); + } else { + authorization = getAuthzClient().authorization(); + } + + for (PermissionRequest permission : permissions) + authorizationRequest.addPermission(permission.getResourceId(), new ArrayList(permission.getScopes())); + + Metadata metadata = new Metadata(); + metadata.setResponseMode("decision"); + metadata.setPermissionResourceFormat("uri"); + if (matchingUri != null) + metadata.setPermissionResourceMatchingUri(matchingUri); + + authorizationRequest.setMetadata(metadata); + + return authorization.authorize(authorizationRequest); + } + protected RealmResource getRealm() { return adminClient.realm("authz-test"); } @@ -209,6 +236,11 @@ public abstract class AbstractResourceServerTest extends AbstractAuthzTest { } protected ResourceRepresentation addResource(String resourceName, String owner, boolean ownerManagedAccess, String... scopeNames) throws Exception { + return addResource(resourceName, owner, null, ownerManagedAccess, scopeNames); + } + + protected ResourceRepresentation addResource(String resourceName, String owner, Set uris, + boolean ownerManagedAccess, String... scopeNames) throws Exception { ClientResource client = getClient(getRealm()); AuthorizationResource authorization = client.authorization(); ResourceRepresentation resource = new ResourceRepresentation(resourceName); @@ -219,6 +251,9 @@ public abstract class AbstractResourceServerTest extends AbstractAuthzTest { resource.setOwnerManagedAccess(ownerManagedAccess); resource.addScope(scopeNames); + if (uris != null) { + resource.setUris(uris); + } Response response = authorization.resources().create(resource); ResourceRepresentation temp = response.readEntity(ResourceRepresentation.class); diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/authz/UmaGrantTypeTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/authz/UmaGrantTypeTest.java index 1087c210c7..ed823cea0f 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/authz/UmaGrantTypeTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/authz/UmaGrantTypeTest.java @@ -30,6 +30,7 @@ import static org.keycloak.testsuite.util.OAuthClient.AUTH_SERVER_ROOT; import java.net.URI; import java.util.Arrays; import java.util.Collection; +import java.util.Collections; import java.util.LinkedList; import java.util.List; import java.util.Map; @@ -95,7 +96,7 @@ public class UmaGrantTypeTest extends AbstractResourceServerTest { authorization.policies().js().create(policy).close(); ResourcePermissionRepresentation permission = new ResourcePermissionRepresentation(); - resourceA = addResource("Resource A", "ScopeA", "ScopeB", "ScopeC"); + resourceA = addResource("Resource A", null, Collections.singleton("/resource"), false, "ScopeA", "ScopeB", "ScopeC"); permission.setName(resourceA.getName() + " Permission"); permission.addResource(resourceA.getName()); @@ -371,6 +372,60 @@ public class UmaGrantTypeTest extends AbstractResourceServerTest { assertTrue(permissions.isEmpty()); } + @Test + public void testObtainDecisionUsingAccessToken() throws Exception { + AccessTokenResponse accessTokenResponse = getAuthzClient().obtainAccessToken("marta", "password"); + + // use "rsid" as "uri" + // uri and scopes exist + AuthorizationResponse response = authorizeDecision(accessTokenResponse.getToken(), null, + new PermissionRequest("/resource", "ScopeA", "ScopeB")); + assertTrue((Boolean) response.getOtherClaims().getOrDefault("result", "false")); + + // uri and scopes are empty + try { + response = authorizeDecision(accessTokenResponse.getToken(), null, new PermissionRequest(null)); + fail(); + } catch (Exception ignore) { + } + + // uri is empty but scopes exist + response = authorizeDecision(accessTokenResponse.getToken(), null, new PermissionRequest(null, "ScopeA", "ScopeB")); + assertTrue((Boolean) response.getOtherClaims().getOrDefault("result", "false")); + + // test wild card + ResourcePermissionRepresentation permission = new ResourcePermissionRepresentation(); + ResourceRepresentation resourceB = addResource("Resource B", null, Collections.singleton("/rs/*"), false, "ScopeD", + "ScopeE"); + + permission.setName(resourceB.getName() + " Permission"); + permission.addResource(resourceB.getName()); + permission.addPolicy("Default Policy"); + + getClient(getRealm()).authorization().permissions().resource().create(permission).close(); + + // matchingUri is null, then result error + try { + response = authorizeDecision(accessTokenResponse.getToken(), null, + new PermissionRequest("/rs/data", "ScopeD", "ScopeE")); + fail(); + } catch (Exception ignore) { + } + + // matchingUri is true, then result true + response = authorizeDecision(accessTokenResponse.getToken(), true, + new PermissionRequest("/rs/data", "ScopeD", "ScopeE")); + assertTrue((Boolean) response.getOtherClaims().getOrDefault("result", "false")); + + // matchingUri is false, then result error + try { + response = authorizeDecision(accessTokenResponse.getToken(), false, + new PermissionRequest("/rs/data", "ScopeD", "ScopeE")); + fail(); + } catch (Exception ignore) { + } + } + @Test public void testCORSHeadersInFailedRptRequest() throws Exception { AccessTokenResponse accessTokenResponse = getAuthzClient().obtainAccessToken("marta", "password");