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}