Skip to content

Commit bd37875

Browse files
y-tabatapedroigor
authored andcommitted
allow specifying format of "permission" parameter in the UMA grant token
endpoint (keycloak#15947)
1 parent e9accaf commit bd37875

File tree

7 files changed

+256
-12
lines changed

7 files changed

+256
-12
lines changed

authz/client/src/main/java/org/keycloak/authorization/client/util/HttpMethodAuthenticator.java

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,14 @@ public HttpMethod<R> uma(AuthorizationRequest request) {
134134
if (metadata.getResponseMode() != null) {
135135
method.param("response_mode", metadata.getResponseMode());
136136
}
137+
138+
if (metadata.getPermissionResourceFormat() != null) {
139+
method.param("permission_resource_format", metadata.getPermissionResourceFormat().toString());
140+
}
141+
142+
if (metadata.getPermissionResourceMatchingUri() != null) {
143+
method.param("permission_resource_matching_uri", metadata.getPermissionResourceMatchingUri().toString());
144+
}
137145
}
138146

139147
return method;

core/src/main/java/org/keycloak/representations/idm/authorization/AuthorizationRequest.java

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,8 @@ public static class Metadata {
187187
private Boolean includeResourceName;
188188
private Integer limit;
189189
private String responseMode;
190+
private String permissionResourceFormat;
191+
private Boolean permissionResourceMatchingUri;
190192

191193
public Boolean getIncludeResourceName() {
192194
if (includeResourceName == null) {
@@ -214,5 +216,21 @@ public void setResponseMode(String responseMode) {
214216
public String getResponseMode() {
215217
return responseMode;
216218
}
219+
220+
public String getPermissionResourceFormat() {
221+
return permissionResourceFormat;
222+
}
223+
224+
public void setPermissionResourceFormat(String permissionResourceFormat) {
225+
this.permissionResourceFormat = permissionResourceFormat;
226+
}
227+
228+
public Boolean getPermissionResourceMatchingUri() {
229+
return permissionResourceMatchingUri;
230+
}
231+
232+
public void setPermissionResourceMatchingUri(Boolean permissionResourceMatchingUri) {
233+
this.permissionResourceMatchingUri = permissionResourceMatchingUri;
234+
}
217235
}
218236
}

docs/documentation/authorization_services/topics/service-authorization-obtaining-permission.adoc

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,14 @@ This parameter is *optional*. A string representing a set of one or more resourc
3838
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
3939
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`.
4040
+
41+
* **permission_resource_format**
42+
+
43+
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`.
44+
+
45+
* **permission_resource_matching_uri**
46+
+
47+
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.
48+
+
4149
* **audience**
4250
+
4351
This parameter is *optional*. The client identifier of the resource server to which the client is seeking access. This parameter is mandatory

services/src/main/java/org/keycloak/authorization/authorization/AuthorizationTokenService.java

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@
1919
import java.util.ArrayList;
2020
import java.util.Arrays;
2121
import java.util.Collection;
22+
import java.util.Collections;
23+
import java.util.EnumMap;
2224
import java.util.HashMap;
2325
import java.util.HashSet;
2426
import java.util.LinkedHashMap;
@@ -59,12 +61,14 @@
5961
import org.keycloak.common.ClientConnection;
6062
import org.keycloak.common.constants.ServiceAccountConstants;
6163
import org.keycloak.common.util.Base64Url;
64+
import org.keycloak.common.util.PathMatcher;
6265
import org.keycloak.events.Details;
6366
import org.keycloak.events.Errors;
6467
import org.keycloak.events.EventBuilder;
6568
import org.keycloak.models.AuthenticatedClientSessionModel;
6669
import org.keycloak.models.ClientModel;
6770
import org.keycloak.models.ClientSessionContext;
71+
import org.keycloak.models.Constants;
6872
import org.keycloak.models.KeycloakSession;
6973
import org.keycloak.models.RealmModel;
7074
import org.keycloak.models.UserModel;
@@ -802,5 +806,129 @@ RealmModel getRealm() {
802806
ClientConnection getClientConnection() {
803807
return clientConnection;
804808
}
809+
810+
public void addPermissions(List<String> permissionList, String permissionResourceFormat, boolean matchingUri) {
811+
if (permissionResourceFormat == null) {
812+
permissionResourceFormat = "id";
813+
}
814+
815+
switch (permissionResourceFormat) {
816+
case "id":
817+
addPermissionsById(permissionList);
818+
break;
819+
case "uri":
820+
addPermissionsByUri(permissionList, matchingUri);
821+
break;
822+
}
823+
824+
}
825+
826+
private void addPermissionsById(List<String> permissionList) {
827+
for (String permission : permissionList) {
828+
String[] parts = permission.split("#");
829+
String rsid = parts[0];
830+
831+
if (parts.length == 1) {
832+
addPermission(rsid);
833+
} else {
834+
String[] scopes = parts[1].split(",");
835+
addPermission(rsid, scopes);
836+
}
837+
}
838+
}
839+
840+
private void addPermissionsByUri(List<String> permissionList, boolean matchingUri) {
841+
StoreFactory storeFactory = authorization.getStoreFactory();
842+
843+
for (String permission : permissionList) {
844+
String[] parts = permission.split("#");
845+
String uri = parts[0];
846+
847+
if (parts.length == 1) {
848+
// only resource uri is specified
849+
if (uri.isEmpty()) {
850+
CorsErrorResponseException invalidResourceException = new CorsErrorResponseException(getCors(),
851+
OAuthErrorException.INVALID_REQUEST, "You must provide the uri", Status.BAD_REQUEST);
852+
fireErrorEvent(getEvent(), Errors.INVALID_REQUEST, invalidResourceException);
853+
throw invalidResourceException;
854+
}
855+
856+
List<Resource> resources = getResourceListByUri(uri, storeFactory, matchingUri);
857+
858+
if (resources == null || resources.isEmpty()) {
859+
CorsErrorResponseException invalidResourceException = new CorsErrorResponseException(getCors(),
860+
"invalid_resource", "Resource with uri [" + uri + "] does not exist.", Status.BAD_REQUEST);
861+
fireErrorEvent(getEvent(), Errors.INVALID_REQUEST, invalidResourceException);
862+
throw invalidResourceException;
863+
}
864+
865+
resources.stream().forEach(resource -> addPermission(resource.getId()));
866+
} else {
867+
// resource uri and scopes are specified, or only scopes are specified
868+
String[] scopes = parts[1].split(",");
869+
870+
if (uri.isEmpty()) {
871+
// only scopes are specified
872+
addPermission("", scopes);
873+
return;
874+
}
875+
876+
List<Resource> resources = getResourceListByUri(uri, storeFactory, matchingUri);
877+
878+
if (resources == null || resources.isEmpty()) {
879+
CorsErrorResponseException invalidResourceException = new CorsErrorResponseException(getCors(),
880+
"invalid_resource", "Resource with uri [" + uri + "] does not exist.", Status.BAD_REQUEST);
881+
fireErrorEvent(getEvent(), Errors.INVALID_REQUEST, invalidResourceException);
882+
throw invalidResourceException;
883+
}
884+
885+
resources.stream().forEach(resource -> addPermission(resource.getId(), scopes));
886+
}
887+
}
888+
}
889+
890+
private List<Resource> getResourceListByUri(String uri, StoreFactory storeFactory, boolean matchingUri) {
891+
Map<Resource.FilterOption, String[]> search = new EnumMap<>(Resource.FilterOption.class);
892+
search.put(Resource.FilterOption.URI, new String[] { uri });
893+
ResourceServer resourceServer = storeFactory.getResourceServerStore()
894+
.findByClient(getRealm().getClientByClientId(getAudience()));
895+
List<Resource> resources = storeFactory.getResourceStore().find(getRealm(), resourceServer, search, -1,
896+
Constants.DEFAULT_MAX_RESULTS);
897+
898+
if (!matchingUri || !resources.isEmpty()) {
899+
return resources;
900+
}
901+
902+
search = new EnumMap<>(Resource.FilterOption.class);
903+
search.put(Resource.FilterOption.URI_NOT_NULL, new String[] { "true" });
904+
search.put(Resource.FilterOption.OWNER, new String[] { resourceServer.getClientId() });
905+
906+
List<Resource> serverResources = storeFactory.getResourceStore().find(getRealm(), resourceServer, search, -1, -1);
907+
908+
PathMatcher<Map.Entry<String, Resource>> pathMatcher = new PathMatcher<Map.Entry<String, Resource>>() {
909+
@Override
910+
protected String getPath(Map.Entry<String, Resource> entry) {
911+
return entry.getKey();
912+
}
913+
914+
@Override
915+
protected Collection<Map.Entry<String, Resource>> getPaths() {
916+
Map<String, Resource> result = new HashMap<>();
917+
serverResources.forEach(resource -> resource.getUris().forEach(uri -> {
918+
result.put(uri, resource);
919+
}));
920+
921+
return result.entrySet();
922+
}
923+
};
924+
925+
Map.Entry<String, Resource> matches = pathMatcher.matches(uri);
926+
927+
if (matches != null) {
928+
return Collections.singletonList(matches.getValue());
929+
}
930+
931+
return null;
932+
}
805933
}
806934
}

services/src/main/java/org/keycloak/protocol/oidc/endpoints/TokenEndpoint.java

Lines changed: 3 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -960,17 +960,9 @@ public Response permissionGrant() {
960960

961961
if (permissions != null) {
962962
event.detail(Details.PERMISSION, String.join("|", permissions));
963-
for (String permission : permissions) {
964-
String[] parts = permission.split("#");
965-
String resource = parts[0];
966-
967-
if (parts.length == 1) {
968-
authorizationRequest.addPermission(resource);
969-
} else {
970-
String[] scopes = parts[1].split(",");
971-
authorizationRequest.addPermission(parts[0], scopes);
972-
}
973-
}
963+
String permissionResourceFormat = formParams.getFirst("permission_resource_format");
964+
boolean permissionResourceMatchingUri = Boolean.parseBoolean(formParams.getFirst("permission_resource_matching_uri"));
965+
authorizationRequest.addPermissions(permissions, permissionResourceFormat, permissionResourceMatchingUri);
974966
}
975967

976968
Metadata metadata = new Metadata();

testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/authz/AbstractResourceServerTest.java

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
import static org.junit.Assert.assertEquals;
2020

2121
import java.io.IOException;
22+
import java.util.ArrayList;
2223
import java.util.Arrays;
2324
import java.util.Collection;
2425
import java.util.Iterator;
@@ -35,6 +36,7 @@
3536
import org.keycloak.authorization.client.resource.ProtectionResource;
3637
import org.keycloak.representations.idm.RealmRepresentation;
3738
import org.keycloak.representations.idm.authorization.AuthorizationRequest;
39+
import org.keycloak.representations.idm.authorization.AuthorizationRequest.Metadata;
3840
import org.keycloak.representations.idm.authorization.AuthorizationResponse;
3941
import org.keycloak.representations.idm.authorization.Permission;
4042
import org.keycloak.representations.idm.authorization.PermissionRequest;
@@ -165,6 +167,31 @@ protected AuthorizationResponse authorize(String userName, String password, Stri
165167
return authorization.authorize(authorizationRequest);
166168
}
167169

170+
protected AuthorizationResponse authorizeDecision(String accessToken, Boolean matchingUri, PermissionRequest... permissions) {
171+
AuthorizationRequest authorizationRequest = new AuthorizationRequest();
172+
173+
org.keycloak.authorization.client.resource.AuthorizationResource authorization;
174+
175+
if (accessToken != null) {
176+
authorization = getAuthzClient().authorization(accessToken);
177+
} else {
178+
authorization = getAuthzClient().authorization();
179+
}
180+
181+
for (PermissionRequest permission : permissions)
182+
authorizationRequest.addPermission(permission.getResourceId(), new ArrayList<String>(permission.getScopes()));
183+
184+
Metadata metadata = new Metadata();
185+
metadata.setResponseMode("decision");
186+
metadata.setPermissionResourceFormat("uri");
187+
if (matchingUri != null)
188+
metadata.setPermissionResourceMatchingUri(matchingUri);
189+
190+
authorizationRequest.setMetadata(metadata);
191+
192+
return authorization.authorize(authorizationRequest);
193+
}
194+
168195
protected RealmResource getRealm() {
169196
return adminClient.realm("authz-test");
170197
}
@@ -209,6 +236,11 @@ protected ResourceRepresentation addResource(String resourceName, boolean ownerM
209236
}
210237

211238
protected ResourceRepresentation addResource(String resourceName, String owner, boolean ownerManagedAccess, String... scopeNames) throws Exception {
239+
return addResource(resourceName, owner, null, ownerManagedAccess, scopeNames);
240+
}
241+
242+
protected ResourceRepresentation addResource(String resourceName, String owner, Set<String> uris,
243+
boolean ownerManagedAccess, String... scopeNames) throws Exception {
212244
ClientResource client = getClient(getRealm());
213245
AuthorizationResource authorization = client.authorization();
214246
ResourceRepresentation resource = new ResourceRepresentation(resourceName);
@@ -219,6 +251,9 @@ protected ResourceRepresentation addResource(String resourceName, String owner,
219251

220252
resource.setOwnerManagedAccess(ownerManagedAccess);
221253
resource.addScope(scopeNames);
254+
if (uris != null) {
255+
resource.setUris(uris);
256+
}
222257

223258
Response response = authorization.resources().create(resource);
224259
ResourceRepresentation temp = response.readEntity(ResourceRepresentation.class);

testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/authz/UmaGrantTypeTest.java

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
import java.net.URI;
3131
import java.util.Arrays;
3232
import java.util.Collection;
33+
import java.util.Collections;
3334
import java.util.LinkedList;
3435
import java.util.List;
3536
import java.util.Map;
@@ -95,7 +96,7 @@ public void configureAuthorization() throws Exception {
9596
authorization.policies().js().create(policy).close();
9697

9798
ResourcePermissionRepresentation permission = new ResourcePermissionRepresentation();
98-
resourceA = addResource("Resource A", "ScopeA", "ScopeB", "ScopeC");
99+
resourceA = addResource("Resource A", null, Collections.singleton("/resource"), false, "ScopeA", "ScopeB", "ScopeC");
99100

100101
permission.setName(resourceA.getName() + " Permission");
101102
permission.addResource(resourceA.getName());
@@ -371,6 +372,60 @@ public void testObtainRptUsingAccessToken() throws Exception {
371372
assertTrue(permissions.isEmpty());
372373
}
373374

375+
@Test
376+
public void testObtainDecisionUsingAccessToken() throws Exception {
377+
AccessTokenResponse accessTokenResponse = getAuthzClient().obtainAccessToken("marta", "password");
378+
379+
// use "rsid" as "uri"
380+
// uri and scopes exist
381+
AuthorizationResponse response = authorizeDecision(accessTokenResponse.getToken(), null,
382+
new PermissionRequest("/resource", "ScopeA", "ScopeB"));
383+
assertTrue((Boolean) response.getOtherClaims().getOrDefault("result", "false"));
384+
385+
// uri and scopes are empty
386+
try {
387+
response = authorizeDecision(accessTokenResponse.getToken(), null, new PermissionRequest(null));
388+
fail();
389+
} catch (Exception ignore) {
390+
}
391+
392+
// uri is empty but scopes exist
393+
response = authorizeDecision(accessTokenResponse.getToken(), null, new PermissionRequest(null, "ScopeA", "ScopeB"));
394+
assertTrue((Boolean) response.getOtherClaims().getOrDefault("result", "false"));
395+
396+
// test wild card
397+
ResourcePermissionRepresentation permission = new ResourcePermissionRepresentation();
398+
ResourceRepresentation resourceB = addResource("Resource B", null, Collections.singleton("/rs/*"), false, "ScopeD",
399+
"ScopeE");
400+
401+
permission.setName(resourceB.getName() + " Permission");
402+
permission.addResource(resourceB.getName());
403+
permission.addPolicy("Default Policy");
404+
405+
getClient(getRealm()).authorization().permissions().resource().create(permission).close();
406+
407+
// matchingUri is null, then result error
408+
try {
409+
response = authorizeDecision(accessTokenResponse.getToken(), null,
410+
new PermissionRequest("/rs/data", "ScopeD", "ScopeE"));
411+
fail();
412+
} catch (Exception ignore) {
413+
}
414+
415+
// matchingUri is true, then result true
416+
response = authorizeDecision(accessTokenResponse.getToken(), true,
417+
new PermissionRequest("/rs/data", "ScopeD", "ScopeE"));
418+
assertTrue((Boolean) response.getOtherClaims().getOrDefault("result", "false"));
419+
420+
// matchingUri is false, then result error
421+
try {
422+
response = authorizeDecision(accessTokenResponse.getToken(), false,
423+
new PermissionRequest("/rs/data", "ScopeD", "ScopeE"));
424+
fail();
425+
} catch (Exception ignore) {
426+
}
427+
}
428+
374429
@Test
375430
public void testCORSHeadersInFailedRptRequest() throws Exception {
376431
AccessTokenResponse accessTokenResponse = getAuthzClient().obtainAccessToken("marta", "password");

0 commit comments

Comments
 (0)