001/* 002 * The contents of this file are subject to the terms of the Common Development and 003 * Distribution License (the License). You may not use this file except in compliance with the 004 * License. 005 * 006 * You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the 007 * specific language governing permission and limitations under the License. 008 * 009 * When distributing Covered Software, include this CDDL Header Notice in each file and include 010 * the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL 011 * Header, with the fields enclosed by brackets [] replaced by your own identifying 012 * information: "Portions copyright [year] [name of copyright owner]". 013 * 014 * Copyright 2015 ForgeRock AS. 015 */ 016 017package org.forgerock.openig.uma; 018 019import static java.lang.String.format; 020import static org.forgerock.json.JsonValue.field; 021import static org.forgerock.json.JsonValue.json; 022import static org.forgerock.json.JsonValue.object; 023import static org.forgerock.json.resource.Resources.newCollection; 024import static org.forgerock.json.resource.http.CrestHttp.newHttpHandler; 025import static org.forgerock.openig.util.JsonValues.asExpression; 026import static org.forgerock.openig.util.JsonValues.evaluate; 027import static org.forgerock.util.promise.Promises.newExceptionPromise; 028 029import java.io.IOException; 030import java.net.URI; 031import java.net.URISyntaxException; 032import java.util.ArrayList; 033import java.util.HashSet; 034import java.util.List; 035import java.util.Map; 036import java.util.Set; 037import java.util.TreeMap; 038import java.util.regex.Matcher; 039import java.util.regex.Pattern; 040 041import org.forgerock.http.Handler; 042import org.forgerock.http.MutableUri; 043import org.forgerock.http.protocol.Request; 044import org.forgerock.http.protocol.Response; 045import org.forgerock.http.protocol.Status; 046import org.forgerock.json.JsonValue; 047import org.forgerock.json.JsonValueException; 048import org.forgerock.openig.heap.GenericHeaplet; 049import org.forgerock.openig.heap.HeapException; 050import org.forgerock.openig.http.EndpointRegistry; 051import org.forgerock.services.context.Context; 052import org.forgerock.util.Function; 053import org.forgerock.util.promise.NeverThrowsException; 054import org.forgerock.util.promise.Promise; 055 056/** 057 * An {@link UmaSharingService} provides core UMA features to OpenIG when acting as an UMA Resource Server. 058 * 059 * <p>It is linked to a single UMA Authorization Server and needs to be pre-registered as an OAuth 2.0 client on that 060 * AS. 061 * 062 * <p>It is also the place where protected application knowledge is described: each item of the {@code resources} 063 * array describe a resource set (that can be composed of multiple endpoints) that share the same set of scopes. 064 * 065 * <p>Each resource contains a {@code pattern} used to define which one of them to use when a {@link Share} is 066 * {@linkplain #createShare(Context, String, String) created}. A resource also contains a list of {@code actions} that 067 * defines the set of scopes to require when a requesting party request comes in. 068 * 069 * <pre> 070 * {@code { 071 * "name": "UmaService", 072 * "type": "UmaService", 073 * "config": { 074 * "protectionApiHandler": "HttpsClient", 075 * "authorizationServerUri": "https://openam.example.com:8443/openam", 076 * "clientId": "uma", 077 * "clientSecret": "welcome", 078 * "resources": [ 079 * { 080 * "pattern": "/guillaume/.*", 081 * "actions" : [ 082 * { 083 * "scopes" : [ "http://api.example.com/operations#read" ], 084 * "condition" : "${request.method == 'GET'}" 085 * }, 086 * { 087 * "scopes" : [ "http://api.example.com/operations#delete" ], 088 * "condition" : "${request.method == 'DELETE'}" 089 * } 090 * ] 091 * } 092 * ] 093 * } 094 * } 095 * } 096 * </pre> 097 * 098 * Along with the {@code UmaService}, a REST endpoint is deployed in OpenIG's API namespace: 099 * {@literal /openig/api/system/objects/../objects/[name-of-the-uma-service-object]/share}. 100 * The dotted segment depends on your deployment (like which RouterHandler hosts the route that 101 * in turns contains this object). 102 */ 103public class UmaSharingService { 104 105 private final List<ShareTemplate> templates = new ArrayList<>(); 106 private final Map<String, Share> shares = new TreeMap<>(); 107 108 private final Handler protectionApiHandler; 109 private final URI authorizationServer; 110 private final URI introspectionEndpoint; 111 private final URI ticketEndpoint; 112 private final URI resourceSetEndpoint; 113 private final String clientId; 114 private final String clientSecret; 115 116 /** 117 * Constructs an UmaSharingService bound to the given {@code authorizationServer} and dedicated to protect resource 118 * sets described by the given {@code templates}. 119 * 120 * @param protectionApiHandler 121 * used to call the resource set endpoint 122 * @param templates 123 * list of resource descriptions 124 * @param authorizationServer 125 * Bound UMA Authorization Server 126 * @param clientId 127 * OAuth 2.0 Client identifier 128 * @param clientSecret 129 * OAuth 2.0 Client secret 130 * @throws URISyntaxException 131 * when the authorization server URI cannot be "normalized" (trailing '/' append if required) 132 */ 133 public UmaSharingService(final Handler protectionApiHandler, 134 final List<ShareTemplate> templates, 135 final URI authorizationServer, 136 final String clientId, 137 final String clientSecret) 138 throws URISyntaxException { 139 this.protectionApiHandler = protectionApiHandler; 140 this.templates.addAll(templates); 141 this.authorizationServer = appendTrailingSlash(authorizationServer); 142 // TODO Should find theses values looking at the .well-known/uma-configuration endpoint 143 this.introspectionEndpoint = authorizationServer.resolve("oauth2/introspect"); 144 this.ticketEndpoint = authorizationServer.resolve("uma/permission_request"); 145 this.resourceSetEndpoint = authorizationServer.resolve("oauth2/resource_set"); 146 this.clientId = clientId; 147 this.clientSecret = clientSecret; 148 } 149 150 /** 151 * Append a trailing {@literal /} if missing. 152 * 153 * @param uri 154 * URI to be "normalized" 155 * @return a URI with a trailing {@literal /} 156 * @throws URISyntaxException should never happen 157 */ 158 private static URI appendTrailingSlash(final URI uri) throws URISyntaxException { 159 if (!uri.getPath().endsWith("/")) { 160 MutableUri mutable = new MutableUri(uri); 161 mutable.setRawPath(uri.getRawPath().concat("/")); 162 return mutable.asURI(); 163 } 164 return uri; 165 } 166 167 /** 168 * Creates a Share that will be used to protect the given {@code resourcePath}. 169 * 170 * @param context 171 * Context chain used to keep a relationship between requests (tracking) 172 * @param resourcePath 173 * resource to be protected 174 * @param pat 175 * Protection Api Token (PAT) 176 * @return the created {@link Share} asynchronously 177 * @see <a href="https://docs.kantarainitiative.org/uma/draft-oauth-resource-reg.html#rfc.section.2">Resource Set 178 * Registration</a> 179 */ 180 public Promise<Share, UmaException> createShare(final Context context, 181 final String resourcePath, 182 final String pat) { 183 184 if (isShared(resourcePath)) { 185 // We do not accept re-sharing or post-creation resource_set configuration 186 return newExceptionPromise(new UmaException(format("Resource %s is already shared", resourcePath))); 187 } 188 189 // Need to find which ShareTemplate to use 190 final ShareTemplate matching = findShareTemplate(resourcePath); 191 192 if (matching == null) { 193 return newExceptionPromise(new UmaException(format("Can't find a template for resource %s", resourcePath))); 194 } 195 196 return createResourceSet(context, matching, resourcePath, pat) 197 .then(new Function<Response, Share, UmaException>() { 198 @Override 199 public Share apply(final Response response) throws UmaException { 200 if (response.getStatus() == Status.CREATED) { 201 try { 202 JsonValue value = json(response.getEntity().getJson()); 203 Share share = new Share(matching, value, Pattern.compile(resourcePath), pat); 204 shares.put(share.getId(), share); 205 return share; 206 } catch (IOException e) { 207 throw new UmaException("Can't read the CREATE resource_set response", e); 208 } 209 } 210 throw new UmaException("Cannot register resource_set in AS"); 211 } 212 }, new Function<NeverThrowsException, Share, UmaException>() { 213 @Override 214 public Share apply(final NeverThrowsException value) throws UmaException { 215 // Cannot happen 216 return null; 217 } 218 }); 219 } 220 221 /** 222 * Select, among the registered templates, the one that match best the resource path to be shared. 223 * 224 * @param resourcePath 225 * path of the resource to be shared 226 * @return the best match, or {@code null} if no match have been found 227 */ 228 private ShareTemplate findShareTemplate(final String resourcePath) { 229 ShareTemplate matching = null; 230 int longest = -1; 231 for (ShareTemplate template : templates) { 232 Matcher matcher = template.getPattern().matcher(resourcePath); 233 if (matcher.matches() && (matcher.end() > longest)) { 234 matching = template; 235 longest = matcher.end(); 236 } 237 } 238 return matching; 239 } 240 241 private boolean isShared(final String path) { 242 for (Share share : shares.values()) { 243 if (path.equals(share.getPattern().toString())) { 244 return true; 245 } 246 } 247 return false; 248 } 249 250 private Promise<Response, NeverThrowsException> createResourceSet(final Context context, 251 final ShareTemplate template, 252 final String path, 253 final String pat) { 254 Request request = new Request(); 255 request.setMethod("POST"); 256 request.setUri(resourceSetEndpoint); 257 request.getHeaders().put("Authorization", format("Bearer %s", pat)); 258 request.getHeaders().put("Accept", "application/json"); 259 260 request.setEntity(resourceSet(path, template).asMap()); 261 262 return protectionApiHandler.handle(context, request); 263 } 264 265 private JsonValue resourceSet(final String name, final ShareTemplate template) { 266 return json(object(field("name", uniqueName(name)), 267 field("scopes", template.getAllScopes()))); 268 } 269 270 private String uniqueName(final String name) { 271 // TODO this is a workaround until we have persistence on the OpenIG side 272 return format("%s @ %d", name, System.currentTimeMillis()); 273 } 274 275 /** 276 * Find a {@link Share}. 277 * 278 * @param request 279 * the incoming requesting party request 280 * @return a {@link Share} to be used to protect the resource access 281 * @throws UmaException 282 * when no {@link Share} can handle the request. 283 */ 284 public Share findShare(Request request) throws UmaException { 285 286 // Need to find which Share to use 287 // The logic here is that the longest matching segment denotes the best share 288 // request: /alice/allergies/pollen 289 // shares: [ /alice.*, /alice/allergies, /alice/allergies/pollen ] 290 // expects the last share to be returned 291 Share matching = null; 292 String path = request.getUri().getPath(); 293 int longest = -1; 294 for (Share share : shares.values()) { 295 Matcher matcher = share.getPattern().matcher(path); 296 if (matcher.matches()) { 297 if (matcher.end() > longest) { 298 matching = share; 299 longest = matcher.end(); 300 } 301 } 302 } 303 304 // Fail-fast if no shares matched 305 if (matching == null) { 306 throw new UmaException(format("Can't find any shared resource for %s", path)); 307 } 308 309 return matching; 310 } 311 312 /** 313 * Removes the previously created Share from the registered shares. In effect, the resources is no more 314 * shared/protected 315 * 316 * @param shareId 317 * share identifier 318 * @return the removed Share instance if found, {@code null} otherwise. 319 */ 320 public Share removeShare(String shareId) { 321 return shares.remove(shareId); 322 } 323 324 /** 325 * Returns a copy of the list of currently managed shares. 326 * @return a copy of the list of currently managed shares. 327 */ 328 public Set<Share> listShares() { 329 return new HashSet<>(shares.values()); 330 } 331 332 /** 333 * Returns the UMA authorization server base Uri. 334 * @return the UMA authorization server base Uri. 335 */ 336 public URI getAuthorizationServer() { 337 return authorizationServer; 338 } 339 340 /** 341 * Returns the UMA Permission Request endpoint Uri. 342 * @return the UMA Permission Request endpoint Uri. 343 */ 344 public URI getTicketEndpoint() { 345 return ticketEndpoint; 346 } 347 348 /** 349 * Returns the OAuth 2.0 Introspection endpoint Uri. 350 * @return the OAuth 2.0 Introspection endpoint Uri. 351 */ 352 public URI getIntrospectionEndpoint() { 353 return introspectionEndpoint; 354 } 355 356 /** 357 * Returns the {@link Share} with the given {@code id}. 358 * @param id Share identifier 359 * @return the {@link Share} with the given {@code id} (or {@code null} if none was found). 360 */ 361 public Share getShare(final String id) { 362 return shares.get(id); 363 } 364 365 /** 366 * Returns the client identifier used to identify this RS as an OAuth 2.0 client. 367 * @return the client identifier used to identify this RS as an OAuth 2.0 client. 368 */ 369 public String getClientId() { 370 return clientId; 371 } 372 373 /** 374 * Returns the client secret. 375 * @return the client secret. 376 */ 377 public String getClientSecret() { 378 return clientSecret; 379 } 380 381 /** 382 * Creates and initializes an UMA service in a heap environment. 383 */ 384 public static class Heaplet extends GenericHeaplet { 385 386 @Override 387 public Object create() throws HeapException { 388 Handler handler = heap.resolve(config.get("protectionApiHandler").required(), Handler.class); 389 URI uri = config.get("authorizationServerUri").required().asURI(); 390 String clientId = evaluate(config.get("clientId").required()); 391 String clientSecret = evaluate(config.get("clientSecret").required()); 392 try { 393 UmaSharingService service = new UmaSharingService(handler, 394 createResourceTemplates(), 395 uri, 396 clientId, 397 clientSecret); 398 // register admin endpoint 399 Handler httpHandler = newHttpHandler(newCollection(new ShareCollectionProvider(service))); 400 EndpointRegistry.Registration share = endpointRegistry().register("share", httpHandler); 401 logger.info(format("UMA Share endpoint available at '%s'", share.getPath())); 402 403 return service; 404 } catch (URISyntaxException e) { 405 throw new HeapException("Cannot build UmaSharingService", e); 406 } 407 } 408 409 private List<ShareTemplate> createResourceTemplates() throws HeapException { 410 return config.get("resources").required().asList(new Function<JsonValue, ShareTemplate, HeapException>() { 411 @Override 412 public ShareTemplate apply(final JsonValue value) throws HeapException { 413 return new ShareTemplate(value.get("pattern").required().asPattern(), 414 actions(value.get("actions").expect(List.class))); 415 } 416 }); 417 } 418 419 private List<ShareTemplate.Action> actions(final JsonValue actions) { 420 return actions.asList(new Function<JsonValue, ShareTemplate.Action, JsonValueException>() { 421 @Override 422 public ShareTemplate.Action apply(final JsonValue value) { 423 return new ShareTemplate.Action(asExpression(value.get("condition").required(), Boolean.class), 424 value.get("scopes").asSet(String.class)); 425 } 426 }); 427 } 428 } 429}