allow specifying format of "permission" parameter in the UMA grant token

endpoint (#15947)
This commit is contained in:
Yoshiyuki Tabata 2023-05-09 15:20:12 +09:00 committed by Pedro Igor
parent e9accaf387
commit bd37875a66
7 changed files with 256 additions and 12 deletions

View file

@ -134,6 +134,14 @@ public class HttpMethodAuthenticator<R> {
if (metadata.getResponseMode() != null) { if (metadata.getResponseMode() != null) {
method.param("response_mode", metadata.getResponseMode()); 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; return method;

View file

@ -187,6 +187,8 @@ public class AuthorizationRequest {
private Boolean includeResourceName; private Boolean includeResourceName;
private Integer limit; private Integer limit;
private String responseMode; private String responseMode;
private String permissionResourceFormat;
private Boolean permissionResourceMatchingUri;
public Boolean getIncludeResourceName() { public Boolean getIncludeResourceName() {
if (includeResourceName == null) { if (includeResourceName == null) {
@ -214,5 +216,21 @@ public class AuthorizationRequest {
public String getResponseMode() { public String getResponseMode() {
return responseMode; 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;
}
} }
} }

View file

@ -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 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 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** * **audience**
+ +
This parameter is *optional*. The client identifier of the resource server to which the client is seeking access. This parameter is mandatory This parameter is *optional*. The client identifier of the resource server to which the client is seeking access. This parameter is mandatory

View file

@ -19,6 +19,8 @@ package org.keycloak.authorization.authorization;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Arrays; import java.util.Arrays;
import java.util.Collection; import java.util.Collection;
import java.util.Collections;
import java.util.EnumMap;
import java.util.HashMap; import java.util.HashMap;
import java.util.HashSet; import java.util.HashSet;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
@ -59,12 +61,14 @@ import org.keycloak.authorization.util.Tokens;
import org.keycloak.common.ClientConnection; import org.keycloak.common.ClientConnection;
import org.keycloak.common.constants.ServiceAccountConstants; import org.keycloak.common.constants.ServiceAccountConstants;
import org.keycloak.common.util.Base64Url; import org.keycloak.common.util.Base64Url;
import org.keycloak.common.util.PathMatcher;
import org.keycloak.events.Details; import org.keycloak.events.Details;
import org.keycloak.events.Errors; import org.keycloak.events.Errors;
import org.keycloak.events.EventBuilder; import org.keycloak.events.EventBuilder;
import org.keycloak.models.AuthenticatedClientSessionModel; import org.keycloak.models.AuthenticatedClientSessionModel;
import org.keycloak.models.ClientModel; import org.keycloak.models.ClientModel;
import org.keycloak.models.ClientSessionContext; import org.keycloak.models.ClientSessionContext;
import org.keycloak.models.Constants;
import org.keycloak.models.KeycloakSession; import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel; import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel; import org.keycloak.models.UserModel;
@ -802,5 +806,129 @@ public class AuthorizationTokenService {
ClientConnection getClientConnection() { ClientConnection getClientConnection() {
return clientConnection; return clientConnection;
} }
public void addPermissions(List<String> 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<String> 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<String> 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<Resource> 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<Resource> 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<Resource> getResourceListByUri(String uri, StoreFactory storeFactory, boolean matchingUri) {
Map<Resource.FilterOption, String[]> search = new EnumMap<>(Resource.FilterOption.class);
search.put(Resource.FilterOption.URI, new String[] { uri });
ResourceServer resourceServer = storeFactory.getResourceServerStore()
.findByClient(getRealm().getClientByClientId(getAudience()));
List<Resource> 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<Resource> serverResources = storeFactory.getResourceStore().find(getRealm(), resourceServer, search, -1, -1);
PathMatcher<Map.Entry<String, Resource>> pathMatcher = new PathMatcher<Map.Entry<String, Resource>>() {
@Override
protected String getPath(Map.Entry<String, Resource> entry) {
return entry.getKey();
}
@Override
protected Collection<Map.Entry<String, Resource>> getPaths() {
Map<String, Resource> result = new HashMap<>();
serverResources.forEach(resource -> resource.getUris().forEach(uri -> {
result.put(uri, resource);
}));
return result.entrySet();
}
};
Map.Entry<String, Resource> matches = pathMatcher.matches(uri);
if (matches != null) {
return Collections.singletonList(matches.getValue());
}
return null;
}
} }
} }

View file

@ -960,17 +960,9 @@ public class TokenEndpoint {
if (permissions != null) { if (permissions != null) {
event.detail(Details.PERMISSION, String.join("|", permissions)); event.detail(Details.PERMISSION, String.join("|", permissions));
for (String permission : permissions) { String permissionResourceFormat = formParams.getFirst("permission_resource_format");
String[] parts = permission.split("#"); boolean permissionResourceMatchingUri = Boolean.parseBoolean(formParams.getFirst("permission_resource_matching_uri"));
String resource = parts[0]; authorizationRequest.addPermissions(permissions, permissionResourceFormat, permissionResourceMatchingUri);
if (parts.length == 1) {
authorizationRequest.addPermission(resource);
} else {
String[] scopes = parts[1].split(",");
authorizationRequest.addPermission(parts[0], scopes);
}
}
} }
Metadata metadata = new Metadata(); Metadata metadata = new Metadata();

View file

@ -19,6 +19,7 @@ package org.keycloak.testsuite.authz;
import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertEquals;
import java.io.IOException; import java.io.IOException;
import java.util.ArrayList;
import java.util.Arrays; import java.util.Arrays;
import java.util.Collection; import java.util.Collection;
import java.util.Iterator; import java.util.Iterator;
@ -35,6 +36,7 @@ import org.keycloak.authorization.client.AuthzClient;
import org.keycloak.authorization.client.resource.ProtectionResource; import org.keycloak.authorization.client.resource.ProtectionResource;
import org.keycloak.representations.idm.RealmRepresentation; import org.keycloak.representations.idm.RealmRepresentation;
import org.keycloak.representations.idm.authorization.AuthorizationRequest; 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.AuthorizationResponse;
import org.keycloak.representations.idm.authorization.Permission; import org.keycloak.representations.idm.authorization.Permission;
import org.keycloak.representations.idm.authorization.PermissionRequest; import org.keycloak.representations.idm.authorization.PermissionRequest;
@ -165,6 +167,31 @@ public abstract class AbstractResourceServerTest extends AbstractAuthzTest {
return authorization.authorize(authorizationRequest); 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<String>(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() { protected RealmResource getRealm() {
return adminClient.realm("authz-test"); 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 { 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<String> uris,
boolean ownerManagedAccess, String... scopeNames) throws Exception {
ClientResource client = getClient(getRealm()); ClientResource client = getClient(getRealm());
AuthorizationResource authorization = client.authorization(); AuthorizationResource authorization = client.authorization();
ResourceRepresentation resource = new ResourceRepresentation(resourceName); ResourceRepresentation resource = new ResourceRepresentation(resourceName);
@ -219,6 +251,9 @@ public abstract class AbstractResourceServerTest extends AbstractAuthzTest {
resource.setOwnerManagedAccess(ownerManagedAccess); resource.setOwnerManagedAccess(ownerManagedAccess);
resource.addScope(scopeNames); resource.addScope(scopeNames);
if (uris != null) {
resource.setUris(uris);
}
Response response = authorization.resources().create(resource); Response response = authorization.resources().create(resource);
ResourceRepresentation temp = response.readEntity(ResourceRepresentation.class); ResourceRepresentation temp = response.readEntity(ResourceRepresentation.class);

View file

@ -30,6 +30,7 @@ import static org.keycloak.testsuite.util.OAuthClient.AUTH_SERVER_ROOT;
import java.net.URI; import java.net.URI;
import java.util.Arrays; import java.util.Arrays;
import java.util.Collection; import java.util.Collection;
import java.util.Collections;
import java.util.LinkedList; import java.util.LinkedList;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
@ -95,7 +96,7 @@ public class UmaGrantTypeTest extends AbstractResourceServerTest {
authorization.policies().js().create(policy).close(); authorization.policies().js().create(policy).close();
ResourcePermissionRepresentation permission = new ResourcePermissionRepresentation(); 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.setName(resourceA.getName() + " Permission");
permission.addResource(resourceA.getName()); permission.addResource(resourceA.getName());
@ -371,6 +372,60 @@ public class UmaGrantTypeTest extends AbstractResourceServerTest {
assertTrue(permissions.isEmpty()); 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 @Test
public void testCORSHeadersInFailedRptRequest() throws Exception { public void testCORSHeadersInFailedRptRequest() throws Exception {
AccessTokenResponse accessTokenResponse = getAuthzClient().obtainAccessToken("marta", "password"); AccessTokenResponse accessTokenResponse = getAuthzClient().obtainAccessToken("marta", "password");