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.http.header.WarningHeader.MISCELLANEOUS_WARNING;
021import static org.forgerock.http.protocol.Response.newResponsePromise;
022import static org.forgerock.json.JsonValue.array;
023import static org.forgerock.json.JsonValue.field;
024import static org.forgerock.json.JsonValue.json;
025import static org.forgerock.json.JsonValue.object;
026import static org.forgerock.openig.http.Responses.newInternalServerError;
027import static org.forgerock.util.Utils.closeSilently;
028
029import java.io.IOException;
030import java.util.Collections;
031import java.util.List;
032import java.util.Set;
033
034import org.forgerock.http.header.WarningHeader;
035import org.forgerock.services.context.Context;
036import org.forgerock.http.Filter;
037import org.forgerock.http.Handler;
038import org.forgerock.http.protocol.Form;
039import org.forgerock.http.protocol.Request;
040import org.forgerock.http.protocol.Response;
041import org.forgerock.http.protocol.Status;
042import org.forgerock.json.JsonValue;
043import org.forgerock.openig.filter.oauth2.BearerTokenExtractor;
044import org.forgerock.openig.heap.GenericHeapObject;
045import org.forgerock.openig.heap.GenericHeaplet;
046import org.forgerock.openig.heap.HeapException;
047import org.forgerock.util.AsyncFunction;
048import org.forgerock.util.Function;
049import org.forgerock.util.promise.NeverThrowsException;
050import org.forgerock.util.promise.Promise;
051import org.forgerock.util.promise.ResultHandler;
052
053/**
054 * An {@link UmaResourceServerFilter} implements a PEP (Policy Enforcement Point) and is responsible to ensure the
055 * incoming requests (from requesting parties) all have a valid RPT (Request Party Token) with the required set of
056 * scopes.
057 *
058 * <pre>
059 *     {@code {
060 *         "type": "UmaFilter",
061 *         "config": {
062 *           "protectionApiHandler": "HttpsClient",
063 *           "umaService": "UmaService"
064 *         }
065 *       }
066 *     }
067 * </pre>
068 */
069public class UmaResourceServerFilter extends GenericHeapObject implements Filter {
070
071    private final UmaSharingService umaService;
072    private final Handler protectionApiHandler;
073    private final String realm;
074
075    private final BearerTokenExtractor tokenExtractor = new BearerTokenExtractor();
076
077    /**
078     * Constructs a new UmaResourceServerFilter.
079     *
080     * @param umaService
081     *         core service to use
082     * @param protectionApiHandler
083     *         protectionApiHandler to use when interacting with introspection and permission request endpoints
084     * @param realm
085     *         UMA realm name (can be {@code null})
086     */
087    public UmaResourceServerFilter(final UmaSharingService umaService,
088                                   final Handler protectionApiHandler,
089                                   final String realm) {
090        this.umaService = umaService;
091        this.protectionApiHandler = protectionApiHandler;
092        this.realm = realm;
093    }
094
095    @Override
096    public Promise<Response, NeverThrowsException> filter(final Context context,
097                                                          final Request request,
098                                                          final Handler next) {
099
100        try {
101            // Find a Share for this request
102            final Share share = umaService.findShare(request);
103            String rpt = tokenExtractor.getAccessToken(request.getHeaders().getFirst("Authorization"));
104
105            // Is there an RPT ?
106            if (rpt != null) {
107                // Validate the token
108                return introspectToken(context, rpt, share.getPAT())
109                        .thenAsync(new VerifyScopesAsyncFunction(share, context, request, next));
110            }
111
112            // Error case: ask for a ticket
113            return ticket(context, share, request);
114
115        } catch (UmaException e) {
116            // No share found
117            // Make sure we return a 404
118            return newResponsePromise(e.getResponse().setStatus(Status.NOT_FOUND));
119        }
120    }
121
122    /**
123     * Call the UMA Permission Registration Endpoint to register a requested permission with the authorization server.
124     *
125     * <p>If the registration succeed, the obtained opaque ticket is returned to the client with an additional {@literal
126     * WWW-Authenticate} header:
127     *
128     * <pre>
129     *     {@code HTTP/1.1 403 Forbidden
130     *       WWW-Authenticate: UMA realm="example", as_uri="https://as.example.com"
131     *
132     *       {
133     *         "ticket": "016f84e8-f9b9-11e0-bd6f-0021cc6004de"
134     *       }
135     *     }
136     * </pre>
137     *
138     * @param context
139     *         Context chain used to keep a relationship between requests (tracking)
140     * @param share
141     *         represents protection information about the requested resource
142     * @param incoming
143     *         request used to infer the set of permissions to ask
144     * @return an asynchronous {@link Response}
145     * @see <a href="https://docs.kantarainitiative.org/uma/rec-uma-core-v1_0.html#rfc.section.3.2">Request Permission
146     * Registration</a>
147     */
148    private Promise<Response, NeverThrowsException> ticket(final Context context,
149                                                           final Share share,
150                                                           final Request incoming) {
151        Request request = new Request();
152        request.setMethod("POST");
153        request.setUri(umaService.getTicketEndpoint());
154        request.getHeaders().put("Authorization", format("Bearer %s", share.getPAT()));
155        request.getHeaders().put("Accept", "application/json");
156        request.setEntity(createPermissionRequest(share, incoming).asMap());
157
158        return protectionApiHandler.handle(context, request)
159                                   .then(new TicketResponseFunction());
160    }
161
162    /**
163     * Builds the resource set registration {@link Request}'s JSON content.
164     *
165     * @param share
166     *         represents protection information about the requested resource
167     * @param request
168     *         request used to infer the set of permissions to ask
169     * @return a JSON structure that represents a resource set registration
170     * @see <a href="https://docs.kantarainitiative.org/uma/rec-oauth-resource-reg.html#resource-set-desc">Resource Set
171     * Descriptions</a>
172     */
173    private JsonValue createPermissionRequest(final Share share, final Request request) {
174        ShareTemplate template = share.getTemplate();
175        Set<String> scopes = template.getScopes(request);
176
177        return json(object(field("resource_set_id", share.getResourceSetId()),
178                           field("scopes", array(scopes.toArray(new Object[scopes.size()])))));
179    }
180
181    private Promise<Response, NeverThrowsException> introspectToken(final Context context,
182                                                                    final String token,
183                                                                    final String pat) {
184        Request request = new Request();
185        request.setUri(umaService.getIntrospectionEndpoint());
186        // Should accept a PAT as per the spec (See OPENAM-6320 / OPENAM-5928)
187        //request.getHeaders().put("Authorization", format("Bearer %s", pat));
188        request.getHeaders().put("Accept", "application/json");
189
190        Form query = new Form();
191        query.putSingle("token", token);
192        query.putSingle("client_id", umaService.getClientId());
193        query.putSingle("client_secret", umaService.getClientSecret());
194        query.toRequestEntity(request);
195
196        return protectionApiHandler.handle(context, request);
197    }
198
199    private class VerifyScopesAsyncFunction implements AsyncFunction<Response, Response, NeverThrowsException> {
200        private final Share share;
201        private final Context context;
202        private final Request request;
203        private final Handler next;
204
205        public VerifyScopesAsyncFunction(final Share share,
206                                         final Context context,
207                                         final Request request,
208                                         final Handler next) {
209            this.share = share;
210            this.context = context;
211            this.request = request;
212            this.next = next;
213        }
214
215        @Override
216        public Promise<Response, NeverThrowsException> apply(final Response token) {
217
218            if (Status.OK == token.getStatus()) {
219                JsonValue value = null;
220                try {
221                    value = json(token.getEntity().getJson());
222                } catch (IOException e) {
223                    return newResponsePromise(newInternalServerError(e));
224                }
225                if (value.get("active").asBoolean()) {
226                    // Got a valid token
227                    // Need to verify embed scopes against required scopes
228                    ShareTemplate template = share.getTemplate();
229                    Set<String> required = template.getScopes(request);
230                    if (getScopes(value, share.getResourceSetId()).containsAll(required)) {
231                        // All required scopes are present, continue the request processing
232                        return next.handle(context, request);
233                    }
234
235                    // Not all of the required scopes are in the token
236                    // Error case: ask for a ticket, append an error code
237                    return ticket(context, share, request)
238                            .thenOnResult(new ResultHandler<Response>() {
239                                @Override
240                                public void handleResult(final Response response) {
241
242                                    // Update the Authorization header with a proper error code
243                                    String authorization = response.getHeaders()
244                                                                   .getFirst("WWW-Authenticate");
245                                    if (authorization != null) {
246                                        authorization = authorization.concat(", error=\"insufficient_scope\"");
247                                        response.getHeaders().put("WWW-Authenticate", authorization);
248                                    }
249                                }
250                            });
251                }
252            }
253
254            // Error case: ask for a ticket
255            return ticket(context, share, request);
256        }
257
258        private List<String> getScopes(final JsonValue value, final String resourceSetId) {
259            for (JsonValue permission : value.get("permissions")) {
260                if (resourceSetId.equals(permission.get("resource_set_id").asString())) {
261                    return permission.get("scopes").asList(String.class);
262                }
263            }
264            return Collections.emptyList();
265        }
266    }
267
268    private class TicketResponseFunction implements Function<Response, Response, NeverThrowsException> {
269        @Override
270        public Response apply(final Response response) {
271            if (Status.CREATED == response.getStatus()) {
272                // Create a new response with authenticate header and status code
273                try {
274                    JsonValue value = json(response.getEntity().getJson());
275                    Response forbidden = new Response(Status.UNAUTHORIZED);
276                    String ticket = value.get("ticket").asString();
277                    forbidden.getHeaders().put("WWW-Authenticate",
278                                                     format("UMA realm=\"%s\", as_uri=\"%s\", ticket=\"%s\"",
279                                                            realm,
280                                                            umaService.getAuthorizationServer(),
281                                                            ticket));
282                    return forbidden;
283                } catch (IOException e) {
284                    // JSON parsing exception
285                    // Ignored here, will be handled in the later catch-all block
286                }
287            }
288            // Close previous response object
289            closeSilently(response);
290
291            // Properly handle 400 errors and UMA error codes
292            // The PAT may need to be refreshed
293            Response forbidden = new Response(Status.FORBIDDEN);
294            forbidden.getHeaders().put(new WarningHeader(MISCELLANEOUS_WARNING,
295                                                         "-",
296                                                         "\"UMA Authorization Server Unreachable\""));
297            return forbidden;
298        }
299    }
300
301    /**
302     * Creates and initializes an UMA resource server filter in a heap environment.
303     */
304    public static class Heaplet extends GenericHeaplet {
305
306        @Override
307        public Object create() throws HeapException {
308            UmaSharingService service = heap.resolve(config.get("umaService").required(), UmaSharingService.class);
309            Handler handler = heap.resolve(config.get("protectionApiHandler").required(), Handler.class);
310            String realm = config.get("realm").defaultTo("uma").asString();
311            return new UmaResourceServerFilter(service, handler, realm);
312        }
313    }
314}