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 2014-2015 ForgeRock AS.
015 */
016
017package org.forgerock.openig.filter.oauth2.client;
018
019import static java.lang.String.format;
020import static java.util.Collections.emptyMap;
021import static org.forgerock.http.handler.Handlers.chainOf;
022import static org.forgerock.openig.el.Bindings.bindings;
023import static org.forgerock.openig.filter.oauth2.client.OAuth2Error.E_ACCESS_DENIED;
024import static org.forgerock.openig.filter.oauth2.client.OAuth2Error.E_INVALID_REQUEST;
025import static org.forgerock.openig.filter.oauth2.client.OAuth2Error.E_INVALID_TOKEN;
026import static org.forgerock.openig.filter.oauth2.client.OAuth2Utils.buildUri;
027import static org.forgerock.openig.filter.oauth2.client.OAuth2Utils.createAuthorizationNonceHash;
028import static org.forgerock.openig.filter.oauth2.client.OAuth2Utils.httpRedirect;
029import static org.forgerock.openig.filter.oauth2.client.OAuth2Utils.httpResponse;
030import static org.forgerock.openig.filter.oauth2.client.OAuth2Utils.loadOrCreateSession;
031import static org.forgerock.openig.filter.oauth2.client.OAuth2Utils.matchesUri;
032import static org.forgerock.openig.filter.oauth2.client.OAuth2Utils.removeSession;
033import static org.forgerock.openig.filter.oauth2.client.OAuth2Utils.saveSession;
034import static org.forgerock.openig.heap.Keys.CLIENT_HANDLER_HEAP_KEY;
035import static org.forgerock.openig.heap.Keys.TIME_SERVICE_HEAP_KEY;
036import static org.forgerock.openig.util.JsonValues.asExpression;
037import static org.forgerock.openig.util.JsonValues.getWithDeprecation;
038import static org.forgerock.util.Utils.closeSilently;
039import static org.forgerock.util.promise.Promises.newResultPromise;
040import static org.forgerock.util.time.Duration.duration;
041
042import java.net.URI;
043import java.util.LinkedHashMap;
044import java.util.Map;
045import java.util.concurrent.Callable;
046import java.util.concurrent.ExecutionException;
047import java.util.concurrent.Executors;
048import java.util.concurrent.ScheduledExecutorService;
049
050import org.forgerock.http.Filter;
051import org.forgerock.http.Handler;
052import org.forgerock.http.protocol.Request;
053import org.forgerock.http.protocol.Response;
054import org.forgerock.http.protocol.ResponseException;
055import org.forgerock.http.protocol.Status;
056import org.forgerock.http.routing.UriRouterContext;
057import org.forgerock.json.JsonValue;
058import org.forgerock.json.jose.jws.SignedJwt;
059import org.forgerock.openig.el.Expression;
060import org.forgerock.openig.heap.GenericHeapObject;
061import org.forgerock.openig.heap.GenericHeaplet;
062import org.forgerock.openig.heap.Heap;
063import org.forgerock.openig.heap.HeapException;
064import org.forgerock.openig.util.ThreadSafeCache;
065import org.forgerock.services.context.Context;
066import org.forgerock.util.AsyncFunction;
067import org.forgerock.util.Factory;
068import org.forgerock.util.Function;
069import org.forgerock.util.LazyMap;
070import org.forgerock.util.promise.NeverThrowsException;
071import org.forgerock.util.promise.Promise;
072import org.forgerock.util.time.Duration;
073import org.forgerock.util.time.TimeService;
074
075/**
076 * A filter which is responsible for authenticating the end-user using OAuth 2.0
077 * delegated authorization. The filter does the following depending on the
078 * incoming request URI:
079 * <ul>
080 * <li>{@code {clientEndpoint}/login/{registration}?goto=<url>} - redirects
081 * the user for authorization against the specified client
082 * registration
083 * <li>{@code {clientEndpoint}/login?{*}discovery={input}&goto=<url>} -
084 * performs issuer discovery and dynamic client registration if possible on
085 * the given user input and redirects the user to the client endpoint.
086 * <li>{@code {clientEndpoint}/logout?goto=<url>} - removes
087 * authorization state for the end-user
088 * <li>{@code {clientEndpoint}/callback} - OAuth 2.0 authorization
089 * call-back end-point (state encodes nonce, goto, and client registration)
090 * <li>all other requests - restores authorization state and places it in the
091 * target location.
092 * </ul>
093 * <p>
094 * Configuration options:
095 *
096 * <pre>
097 * {@code
098 * "target"                       : expression,         [OPTIONAL - default is ${attributes.openid}]
099 * "clientEndpoint"               : expression,         [REQUIRED]
100 * "loginHandler"                 : handler,            [REQUIRED - if multiple client registrations]
101 * OR
102 * "registration"                 : reference or        [REQUIRED - if you want to use a single client
103 *                                  inlined declaration,            registration]
104 * "discoveryHandler"             : handler,            [OPTIONAL - by default it uses the 'ClientHandler'
105 *                                                                  provided in heap.]
106 * "failureHandler"               : handler,            [REQUIRED]
107 * "defaultLoginGoto"             : expression,         [OPTIONAL - default return empty page]
108 * "defaultLogoutGoto"            : expression,         [OPTIONAL - default return empty page]
109 * "requireLogin"                 : boolean             [OPTIONAL - default require login]
110 * "requireHttps"                 : boolean             [OPTIONAL - default require SSL]
111 * "cacheExpiration"              : duration            [OPTIONAL - default to 20 seconds]
112 * "metadata"                     : {                   [OPTIONAL - contains metadata dedicated for dynamic
113 *                                                                  client registration.]
114 *             "redirect_uris"    : [ strings ],            [REQUIRED for dynamic client registration.]
115 *             "scopes"           : [ strings ]             [OPTIONAL - usage with OpenAM only.]
116 * }
117 * }
118 * </pre>
119 *
120 * For example, if you want to use a nascar page (multiple client
121 * registrations):
122 *
123 * <pre>
124 * {@code
125 * {
126 *     "name": "OpenIDConnect",
127 *     "type": "OAuth2ClientFilter",
128 *     "config": {
129 *         "target"                : "${attributes.openid}",
130 *         "clientEndpoint"        : "/openid",
131 *         "loginHandler"          : "NascarPage",
132 *         "failureHandler"        : "LoginFailed",
133 *         "defaultLoginGoto"      : "/homepage",
134 *         "defaultLogoutGoto"     : "/loggedOut",
135 *         "requireHttps"          : false,
136 *         "requireLogin"          : true
137 *     }
138 * }
139 * }
140 * </pre>
141 *
142 * This one, containing a nascar page and allowing dynamic client registration with OpenAM:
143 *
144 * <pre>
145 * {@code
146 * {
147 *     "name": "OpenIDConnect",
148 *     "type": "OAuth2ClientFilter",
149 *     "config": {
150 *         "target"                : "${attributes.openid}",
151 *         "clientEndpoint"        : "/openid",
152 *         "loginHandler"          : "NascarPage",
153 *         "failureHandler"        : "LoginFailed",
154 *         "defaultLoginGoto"      : "/homepage",
155 *         "defaultLogoutGoto"     : "/loggedOut",
156 *         "requireHttps"          : false,
157 *         "requireLogin"          : true,
158 *         "metadata"              : {
159 *             "client_name": "iRock",
160 *             "contacts": [ "werock@example.com", "werock@forgerock.org" ],
161 *             "scopes": [
162 *                 "openid", "profile"
163 *             ],
164 *             "redirect_uris": [ "http://my.example.com:8082/openid/callback" ],
165 *             "logo_uri": "https://client.example.org/logo.png",
166 *             "subject_type": "pairwise"
167 *         }
168 *     }
169 * }
170 * }
171 * </pre>
172 *
173 * Or this one, with a single client registration.
174 *
175 * <pre>
176 * {@code
177 * {
178 *     "name": "OpenIDConnect",
179 *     "type": "OAuth2ClientFilter",
180 *     "config": {
181 *         "target"                : "${attributes.openid}",
182 *         "clientEndpoint"        : "/openid",
183 *         "registration"          : "openam",
184 *         "failureHandler"        : "LoginFailed"
185 *     }
186 * }
187 * }
188 * </pre>
189 *
190 * Once authorized, this filter will inject the following information into
191 * the target location:
192 *
193 * <pre>
194 * {@code
195 * "openid" : {
196 *         "client_registration" : "google",
197 *         "access_token"       : "xxx",
198 *         "id_token"           : "xxx",
199 *         "token_type"         : "Bearer",
200 *         "expires_in"         : 3599,
201 *         "scope"              : [ "openid", "profile", "email" ],
202 *         "client_endpoint"    : "http://www.example.com:8081/openid",
203 *         "id_token_claims"    : {
204 *             "at_hash"            : "xxx",
205 *             "sub"                : "xxx",
206 *             "aud"                : [ "xxx.apps.googleusercontent.com" ],
207 *             "email_verified"     : true,
208 *             "azp"                : "xxx.apps.googleusercontent.com",
209 *             "iss"                : "accounts.google.com",
210 *             "exp"                : "2014-07-25T00:12:53+0000",
211 *             "iat"                : "2014-07-24T23:07:53+0000",
212 *             "email"              : "micky.mouse@gmail.com"
213 *         },
214 *         "user_info"          : {
215 *             "sub"                : "xxx",
216 *             "email_verified"     : "true",
217 *             "gender"             : "male",
218 *             "kind"               : "plus#personOpenIdConnect",
219 *             "profile"            : "https://plus.google.com/xxx",
220 *             "name"               : "Micky Mouse",
221 *             "given_name"         : "Micky",
222 *             "locale"             : "en-GB",
223 *             "family_name"        : "Mouse",
224 *             "picture"            : "https://lh4.googleusercontent.com/xxx/photo.jpg?sz=50",
225 *             "email"              : "micky.mouse@gmail.com"
226 *         }
227 *     }
228 * }
229 * }
230 * </pre>
231 */
232public final class OAuth2ClientFilter extends GenericHeapObject implements Filter {
233
234    /** The expression which will be used for storing authorization information in the context. */
235    public static final String DEFAULT_TOKEN_KEY = "openid";
236
237    private Expression<String> clientEndpoint;
238    private Expression<String> defaultLoginGoto;
239    private Expression<String> defaultLogoutGoto;
240    private final Handler discoveryHandler;
241    private Handler failureHandler;
242    private Handler loginHandler;
243    private ClientRegistration clientRegistration;
244    private boolean requireHttps = true;
245    private boolean requireLogin = true;
246    private Expression<?> target;
247    private final TimeService time;
248    private ThreadSafeCache<String, Map<String, Object>> userInfoCache;
249    private final Heap heap;
250    private final Handler discoveryAndDynamicRegistrationChain;
251
252    /**
253     * Constructs an {@link OAuth2ClientFilter}.
254     *
255     * @param time
256     *            The TimeService to use.
257     * @param heap
258     *            The current heap.
259     * @param metadata
260     *            The json configuration containing all the OpenID metadata used for
261     *            the dynamic client registration.
262     * @param name
263     *            The name of this filter.
264     * @param discoveryHandler
265     *            The handler used for discovery and dynamic client
266     *            registration.
267     * @param clientEndpoint
268     *            The expression which will be used for obtaining the base URI
269     *            for this filter.
270     */
271    public OAuth2ClientFilter(TimeService time,
272                              Heap heap,
273                              JsonValue metadata,
274                              String name,
275                              Handler discoveryHandler,
276                              Expression<String> clientEndpoint) {
277        this.time = time;
278        this.heap = heap;
279        this.discoveryHandler = discoveryHandler;
280        this.clientEndpoint = clientEndpoint;
281        discoveryAndDynamicRegistrationChain = buildDiscoveryAndDynamicRegistrationChain(heap,
282                                                                                         metadata,
283                                                                                         name);
284    }
285
286    private Handler buildDiscoveryAndDynamicRegistrationChain(final Heap heap,
287                                                              final JsonValue metadata,
288                                                              final String name) {
289        return chainOf(new AuthorizationRedirectHandler(time, clientEndpoint),
290                       new DiscoveryFilter(discoveryHandler, heap),
291                       new ClientRegistrationFilter(discoveryHandler, metadata, heap, name));
292    }
293
294    @Override
295    public Promise<Response, NeverThrowsException> filter(final Context context,
296                                                          final Request request,
297                                                          final Handler next) {
298        try {
299            // Login: {clientEndpoint}/login
300            UriRouterContext routerContext = context.asContext(UriRouterContext.class);
301            URI originalUri = routerContext.getOriginalUri();
302            if (matchesUri(originalUri, buildLoginUri(context, request))) {
303                if (request.getForm().containsKey("discovery")) {
304                    // User input: {clientEndpoint}/login?discovery={input}[&goto={url}]
305                    return handleUserInitiatedDiscovery(request, context);
306                } else {
307                    // Login: {clientEndpoint}/login?registration={name}[&goto={url}]
308                    checkRequestIsSufficientlySecure(context);
309                    return handleUserInitiatedLogin(context, request);
310                }
311            }
312
313            // Authorize call-back: {clientEndpoint}/callback?...
314            if (matchesUri(originalUri, buildCallbackUri(context, request))) {
315                checkRequestIsSufficientlySecure(context);
316                return handleAuthorizationCallback(context, request);
317            }
318
319            // Logout: {clientEndpoint}/logout[?goto={url}]
320            if (matchesUri(originalUri, buildLogoutUri(context, request))) {
321                return handleUserInitiatedLogout(context, request);
322            }
323
324            // Everything else...
325            return handleProtectedResource(context, request, next);
326        } catch (final OAuth2ErrorException e) {
327            return handleOAuth2ErrorException(context, request, e);
328        } catch (ResponseException e) {
329            return newResultPromise(e.getResponse());
330        }
331    }
332
333    /**
334     * Sets the expression which will be used for obtaining the default login
335     * "goto" URI. The default goto URI will be used when a user performs a user
336     * initiated login without providing a "goto" http parameter. This
337     * configuration parameter is optional. If no "goto" parameter is provided
338     * in the request and there is no default "goto" then user initiated login
339     * requests will simply return a 200 status.
340     *
341     * @param endpoint
342     *            The expression which will be used for obtaining the default
343     *            login "goto" URI.
344     * @return This filter.
345     */
346    public OAuth2ClientFilter setDefaultLoginGoto(final Expression<String> endpoint) {
347        this.defaultLoginGoto = endpoint;
348        return this;
349    }
350
351    /**
352     * Sets the expression which will be used for obtaining the default logout
353     * "goto" URI. The default goto URI will be used when a user performs a user
354     * initiated logout without providing a "goto" http parameter. This
355     * configuration parameter is optional. If no "goto" parameter is provided
356     * in the request and there is no default "goto" then user initiated logout
357     * requests will simply return a 200 status.
358     *
359     * @param endpoint
360     *            The expression which will be used for obtaining the default
361     *            logout "goto" URI.
362     * @return This filter.
363     */
364    public OAuth2ClientFilter setDefaultLogoutGoto(final Expression<String> endpoint) {
365        this.defaultLogoutGoto = endpoint;
366        return this;
367    }
368
369    /**
370     * Sets the handler which will be invoked when authentication fails. This
371     * configuration parameter is required. If authorization fails for any
372     * reason and the request cannot be processed using the next filter/handler,
373     * then the request will be forwarded to the failure handler. In addition,
374     * the target expression will be populated with the following OAuth
375     * 2.0 error information:
376     *
377     * <pre>
378     * {@code
379     * <target> : {
380     *     "client_registration" : "google",
381     *     "error"               : {
382     *         "realm"              : string,          [OPTIONAL]
383     *         "scope"              : array of string, [OPTIONAL list of required scopes]
384     *         "error"              : string,          [OPTIONAL]
385     *         "error_description"  : string,          [OPTIONAL]
386     *         "error_uri"          : string           [OPTIONAL]
387     *     },
388     *     // The following fields may or may not be present depending on
389     *     // how far authorization proceeded.
390     *     "access_token"       : "xxx",
391     *     "id_token"           : "xxx",
392     *     "token_type"         : "Bearer",
393     *     "expires_in"         : 3599,
394     *     "scope"              : [ "openid", "profile", "email" ],
395     *     "client_endpoint"    : "http://www.example.com:8081/openid",
396     * }
397     * }
398     * </pre>
399     *
400     * See {@link OAuth2Error} for a detailed description of the various error
401     * fields and their possible values.
402     *
403     * @param handler
404     *            The handler which will be invoked when authentication fails.
405     * @return This filter.
406     */
407    public OAuth2ClientFilter setFailureHandler(final Handler handler) {
408        this.failureHandler = handler;
409        return this;
410    }
411
412    /**
413     * Sets the handler which will be invoked when the user needs to
414     * authenticate. This configuration parameter is required if there are more
415     * than one client registration configured.
416     *
417     * @param handler
418     *            The handler which will be invoked when the user needs to
419     *            authenticate.
420     * @return This filter.
421     */
422    public OAuth2ClientFilter setLoginHandler(final Handler handler) {
423        this.loginHandler = handler;
424        return this;
425    }
426
427    /**
428     * You can avoid a nascar page by setting a client registration.
429     *
430     * @param registration
431     *            The client registration to use.
432     * @return This filter.
433     */
434    public OAuth2ClientFilter setClientRegistration(final ClientRegistration registration) {
435        this.clientRegistration = registration;
436        return this;
437    }
438
439    /**
440     * Specifies whether all incoming requests must use TLS. This configuration
441     * parameter is optional and set to {@code true} by default.
442     *
443     * @param requireHttps
444     *            {@code true} if all incoming requests must use TLS,
445     *            {@code false} by default.
446     * @return This filter.
447     */
448    public OAuth2ClientFilter setRequireHttps(final boolean requireHttps) {
449        this.requireHttps = requireHttps;
450        return this;
451    }
452
453    /**
454     * Specifies whether authentication is required for all incoming requests.
455     * This configuration parameter is optional and set to {@code true} by
456     * default.
457     *
458     * @param requireLogin
459     *            {@code true} if authentication is required for all incoming
460     *            requests, or {@code false} if authentication should be
461     *            performed only when required (default {@code true}.
462     * @return This filter.
463     */
464    public OAuth2ClientFilter setRequireLogin(final boolean requireLogin) {
465        this.requireLogin = requireLogin;
466        return this;
467    }
468
469    /**
470     * Sets the expression which will be used for storing authorization
471     * information in the context. This configuration parameter is required.
472     *
473     * @param target
474     *            The expression which will be used for storing authorization
475     *            information in the context.
476     * @return This filter.
477     */
478    public OAuth2ClientFilter setTarget(final Expression<?> target) {
479        this.target = target;
480        return this;
481    }
482
483    private URI buildCallbackUri(final Context context, final Request request) throws ResponseException {
484        return buildUri(context, request, clientEndpoint, "callback");
485    }
486
487    private URI buildLoginUri(final Context context, final Request request) throws ResponseException {
488        return buildUri(context, request, clientEndpoint, "login");
489    }
490
491    private URI buildLogoutUri(final Context context, final Request request) throws ResponseException {
492        return buildUri(context, request, clientEndpoint, "logout");
493    }
494
495    private void checkRequestIsSufficientlySecure(final Context context)
496            throws OAuth2ErrorException {
497        // FIXME: use enforce filter?
498        UriRouterContext routerContext = context.asContext(UriRouterContext.class);
499        if (requireHttps && !"https".equalsIgnoreCase(routerContext.getOriginalUri().getScheme())) {
500            throw new OAuth2ErrorException(E_INVALID_REQUEST,
501                    "SSL is required in order to perform this operation");
502        }
503    }
504
505    private ClientRegistration getClientRegistration(final OAuth2Session session) {
506        final String name = session.getClientRegistrationName();
507        return name != null ? getClientRegistrationFromHeap(name) : null;
508    }
509
510    private Promise<Response, NeverThrowsException> handleAuthorizationCallback(final Context context,
511                                                                                final Request request)
512            throws OAuth2ErrorException {
513
514        try {
515            if (!"GET".equals(request.getMethod())) {
516                throw new OAuth2ErrorException(E_INVALID_REQUEST,
517                        "Authorization call-back failed because the request was not a GET");
518            }
519
520            /*
521             * The state must be valid regardless of whether the authorization
522             * succeeded or failed.
523             */
524            final String state = request.getForm().getFirst("state");
525            if (state == null) {
526                throw new OAuth2ErrorException(E_INVALID_REQUEST,
527                        "Authorization call-back failed because there was no state parameter");
528            }
529            final OAuth2Session session = loadOrCreateSession(context, request, clientEndpoint, time);
530            if (!session.isAuthorizing()) {
531                throw new OAuth2ErrorException(E_INVALID_REQUEST,
532                        "Authorization call-back failed because there is no authorization in progress");
533            }
534            final int colonPos = state.indexOf(':');
535            final String actualHash = colonPos < 0 ? state : state.substring(0, colonPos);
536            final String gotoUri = colonPos < 0 ? null : state.substring(colonPos + 1);
537            final String expectedHash =
538                    createAuthorizationNonceHash(session.getAuthorizationRequestNonce());
539            if (!expectedHash.equals(actualHash)) {
540                throw new OAuth2ErrorException(E_INVALID_REQUEST,
541                        "Authorization call-back failed because the state parameter contained "
542                                + "an unexpected value");
543            }
544
545            final String code = request.getForm().getFirst("code");
546            if (code == null) {
547                throw new OAuth2ErrorException(OAuth2Error.valueOfForm(request.getForm()));
548            }
549
550            final ClientRegistration client = getClientRegistrationFromHeap(session.getClientRegistrationName());
551            if (client == null) {
552                throw new OAuth2ErrorException(E_INVALID_REQUEST, format(
553                        "Authorization call-back failed because the client registration %s was unrecognized",
554                        session.getClientRegistrationName()));
555            }
556            final JsonValue accessTokenResponse = client.getAccessToken(context,
557                                                                        code,
558                                                                        buildCallbackUri(context, request).toString());
559
560            /*
561             * Finally complete the authorization request by redirecting to the
562             * original goto URI and saving the session. It is important to save the
563             * session after setting the response because it may need to access
564             * response cookies.
565             */
566            final OAuth2Session authorizedSession = session.stateAuthorized(accessTokenResponse);
567            return httpRedirectGoto(context, request, gotoUri, defaultLoginGoto)
568                    .then(new Function<Response, Response, NeverThrowsException>() {
569                        @Override
570                        public Response apply(final Response response) {
571                            try {
572                                saveSession(context, authorizedSession, buildUri(context, request, clientEndpoint));
573                            } catch (ResponseException e) {
574                                return e.getResponse();
575                            }
576                            return response;
577                        }
578                    });
579        } catch (ResponseException e) {
580            return newResultPromise(e.getResponse());
581        }
582    }
583
584    private Promise<Response, NeverThrowsException> handleOAuth2ErrorException(final Context context,
585                                                                               final Request request,
586                                                                               final OAuth2ErrorException e) {
587        final OAuth2Error error = e.getOAuth2Error();
588        if (error.is(E_ACCESS_DENIED) || error.is(E_INVALID_TOKEN)) {
589            logger.debug(e.getMessage());
590        } else {
591            // Assume all other errors are more serious operational errors.
592            logger.warning(e.getMessage());
593        }
594        final Map<String, Object> info = new LinkedHashMap<>();
595        try {
596            final OAuth2Session session = loadOrCreateSession(context, request, clientEndpoint, time);
597            info.putAll(session.getAccessTokenResponse());
598
599            // Override these with effective values.
600            info.put("clientRegistration", session.getClientRegistrationName());
601            info.put("client_endpoint", session.getClientEndpoint());
602            info.put("expires_in", session.getExpiresIn());
603            info.put("scope", session.getScopes());
604            final SignedJwt idToken = session.getIdToken();
605            if (idToken != null) {
606                final Map<String, Object> idTokenClaims = new LinkedHashMap<>();
607                for (final String claim : idToken.getClaimsSet().keys()) {
608                    idTokenClaims.put(claim, idToken.getClaimsSet().getClaim(claim));
609                }
610                info.put("id_token_claims", idTokenClaims);
611            }
612        } catch (Exception ignored) {
613            /*
614             * The session could not be decoded. Presumably this is why we are
615             * here already, so simply ignore the error, and use the error that
616             * was passed in to this method.
617             */
618        }
619        info.put("error", error.toJsonContent());
620        target.set(bindings(context, request), info);
621        return failureHandler.handle(context, request);
622    }
623
624    private Promise<Response, NeverThrowsException> handleProtectedResource(final Context context,
625                                                                            final Request request,
626                                                                            final Handler next)
627            throws OAuth2ErrorException {
628        try {
629            final OAuth2Session session = loadOrCreateSession(context, request, clientEndpoint, time);
630            if (!session.isAuthorized() && requireLogin) {
631                return sendAuthorizationRedirect(context, request, null);
632            }
633            final OAuth2Session refreshedSession =
634                    session.isAuthorized() ? prepareContext(context, session, request) : session;
635            return next.handle(context, request)
636                    .thenAsync(new AsyncFunction<Response, Response, NeverThrowsException>() {
637                        @Override
638                        public Promise<Response, NeverThrowsException> apply(final Response response) {
639                            if (Status.UNAUTHORIZED.equals(response.getStatus()) && !refreshedSession.isAuthorized()) {
640                                closeSilently(response);
641                                return sendAuthorizationRedirect(context, request, null);
642                            } else if (session != refreshedSession) {
643                                /*
644                                 * Only update the session if it has changed in order to avoid send
645                                 * back JWT session cookies with every response.
646                                 */
647                                try {
648                                    saveSession(context,
649                                                refreshedSession,
650                                                buildUri(context, request, clientEndpoint));
651                                } catch (ResponseException e) {
652                                    return newResultPromise(e.getResponse());
653                                }
654                            }
655                            return newResultPromise(response);
656                        }
657                    });
658        } catch (ResponseException e) {
659            return newResultPromise(e.getResponse());
660        }
661    }
662
663    private Promise<Response, NeverThrowsException> handleUserInitiatedDiscovery(final Request request,
664                                                                                 final Context context)
665            throws OAuth2ErrorException, ResponseException {
666
667        return discoveryAndDynamicRegistrationChain.handle(context, request);
668    }
669
670    private Promise<Response, NeverThrowsException> handleUserInitiatedLogin(final Context context,
671                                                                             final Request request)
672            throws OAuth2ErrorException, ResponseException {
673        final String clientRegistrationName = request.getForm().getFirst("registration");
674        if (clientRegistrationName == null) {
675            throw new OAuth2ErrorException(E_INVALID_REQUEST,
676                    "Authorization OpenID Connect Provider must be specified");
677        }
678        final ClientRegistration clientRegistration = getClientRegistrationFromHeap(clientRegistrationName);
679        if (clientRegistration == null) {
680            throw new OAuth2ErrorException(E_INVALID_REQUEST, "Authorization OpenID Connect Provider '"
681                    + clientRegistrationName + "' was not recognized");
682        }
683        return sendAuthorizationRedirect(context, request, clientRegistration);
684    }
685
686    private Promise<Response, NeverThrowsException> handleUserInitiatedLogout(final Context context,
687                                                                              final Request request)
688            throws ResponseException {
689        final String gotoUri = request.getForm().getFirst("goto");
690        return httpRedirectGoto(context, request, gotoUri, defaultLogoutGoto)
691                .then(new Function<Response, Response, NeverThrowsException>() {
692                    @Override
693                    public Response apply(final Response response) {
694                        try {
695                            removeSession(context, request, clientEndpoint);
696                        } catch (ResponseException e) {
697                            return e.getResponse();
698                        }
699                        return response;
700                    }
701                });
702    }
703
704    private Promise<Response, NeverThrowsException> httpRedirectGoto(final Context context,
705                                                                     final Request request,
706                                                                     final String gotoUri,
707                                                                     final Expression<String> defaultGotoUri) {
708        try {
709            if (gotoUri != null) {
710                return completion(httpRedirect(gotoUri));
711            } else if (defaultGotoUri != null) {
712                return completion(httpRedirect(buildUri(context, request, defaultGotoUri).toString()));
713            } else {
714                return completion(httpResponse(Status.OK));
715            }
716        } catch (ResponseException e) {
717            return newResultPromise(e.getResponse());
718        }
719    }
720
721    private Promise<Response, NeverThrowsException> completion(Response response) {
722        return newResultPromise(response);
723    }
724
725    private OAuth2Session prepareContext(final Context context, final OAuth2Session session, final Request request)
726            throws ResponseException, OAuth2ErrorException {
727        try {
728            tryPrepareContext(context, session, request);
729            return session;
730        } catch (final OAuth2ErrorException e) {
731            /*
732             * Try again if the access token looks like it has expired and can
733             * be refreshed.
734             */
735            final OAuth2Error error = e.getOAuth2Error();
736            final ClientRegistration clientRegistration = getClientRegistration(session);
737            if (error.is(E_INVALID_TOKEN) && clientRegistration != null && session.getRefreshToken() != null) {
738                // The session is updated with new access token.
739                final JsonValue accessTokenResponse = clientRegistration.refreshAccessToken(context, session);
740                final OAuth2Session refreshedSession = session.stateRefreshed(accessTokenResponse);
741                tryPrepareContext(context, refreshedSession, request);
742                return refreshedSession;
743            }
744
745            /*
746             * It looks like the token cannot be refreshed or something more
747             * serious happened, e.g. the token has the wrong scopes. Re-throw
748             * the error and let the failure-handler deal with it.
749             */
750            throw e;
751        }
752    }
753
754    private Promise<Response, NeverThrowsException> sendAuthorizationRedirect(final Context context,
755                                                                              final Request request,
756                                                                              final ClientRegistration cr) {
757        if (cr == null && loginHandler != null) {
758            return loginHandler.handle(context, request);
759        }
760        return new AuthorizationRedirectHandler(time,
761                                                clientEndpoint,
762                                                cr != null ? cr : clientRegistration)
763                                    .handle(context, request);
764    }
765
766    private ClientRegistration getClientRegistrationFromHeap(final String name) {
767        ClientRegistration clientRegistration = null;
768        try {
769            clientRegistration = heap.get(name, ClientRegistration.class);
770        } catch (HeapException e) {
771            logger.error(format("Cannot retrieve the client registration '%s' from the heap", name));
772        }
773        return clientRegistration;
774    }
775
776    private void tryPrepareContext(final Context context, final OAuth2Session session, final Request request)
777            throws ResponseException, OAuth2ErrorException {
778        final Map<String, Object> info =
779                new LinkedHashMap<>(session.getAccessTokenResponse());
780        // Override these with effective values.
781        info.put("client_registration", session.getClientRegistrationName());
782        info.put("client_endpoint", session.getClientEndpoint());
783        info.put("expires_in", session.getExpiresIn());
784        info.put("scope", session.getScopes());
785        final SignedJwt idToken = session.getIdToken();
786        if (idToken != null) {
787            final Map<String, Object> idTokenClaims = new LinkedHashMap<>();
788            for (final String claim : idToken.getClaimsSet().keys()) {
789                idTokenClaims.put(claim, idToken.getClaimsSet().getClaim(claim));
790            }
791            info.put("id_token_claims", idTokenClaims);
792        }
793
794        final ClientRegistration clientRegistration = getClientRegistration(session);
795        if (clientRegistration != null
796                && clientRegistration.getIssuer().hasUserInfoEndpoint()
797                && session.getScopes().contains("openid")) {
798            // Load the user_info resources lazily (when requested)
799            info.put("user_info", new LazyMap<>(new UserInfoFactory(session,
800                                                                    clientRegistration,
801                                                                    context,
802                                                                    request)));
803        }
804        target.set(bindings(context, null), info);
805    }
806
807    /**
808     * Set the cache of user info resources. The cache is keyed by the OAuth 2.0 Access Token. It should be configured
809     * with a small expiration duration (something between 5 and 30 seconds).
810     *
811     * @param userInfoCache
812     *         the cache of user info resources.
813     */
814    public void setUserInfoCache(final ThreadSafeCache<String, Map<String, Object>> userInfoCache) {
815        this.userInfoCache = userInfoCache;
816    }
817
818    /** Creates and initializes the filter in a heap environment. */
819    public static class Heaplet extends GenericHeaplet {
820
821        private ScheduledExecutorService executor;
822        private ThreadSafeCache<String, Map<String, Object>> cache;
823
824        @Override
825        public Object create() throws HeapException {
826
827            final Handler discoveryHandler = heap.resolve(config.get("discoveryHandler")
828                                                                .defaultTo(CLIENT_HANDLER_HEAP_KEY),
829                                                          Handler.class);
830            TimeService time = heap.get(TIME_SERVICE_HEAP_KEY, TimeService.class);
831            final Expression<String> clientEndpoint = asExpression(config.get("clientEndpoint").required(),
832                                                                   String.class);
833            final OAuth2ClientFilter filter = new OAuth2ClientFilter(time,
834                                                                     heap,
835                                                                     config.get("metadata"),
836                                                                     this.name,
837                                                                     discoveryHandler,
838                                                                     clientEndpoint);
839
840            filter.setTarget(asExpression(config.get("target").defaultTo(
841                    format("${attributes.%s}", DEFAULT_TOKEN_KEY)), Object.class));
842            final JsonValue registration = getWithDeprecation(config, logger, "registration", "clientRegistrationName");
843            if (registration.isNotNull()) {
844                filter.setClientRegistration(heap.resolve(registration.required(),
845                                                          ClientRegistration.class));
846            } else {
847                final Handler loginHandler = heap.resolve(config.get("loginHandler").required(), Handler.class, true);
848                filter.setLoginHandler(loginHandler);
849            }
850            filter.setFailureHandler(heap.resolve(config.get("failureHandler"),
851                    Handler.class));
852            filter.setDefaultLoginGoto(asExpression(config.get("defaultLoginGoto"), String.class));
853            filter.setDefaultLogoutGoto(asExpression(config.get("defaultLogoutGoto"), String.class));
854            filter.setRequireHttps(config.get("requireHttps").defaultTo(true).asBoolean());
855            filter.setRequireLogin(config.get("requireLogin").defaultTo(true).asBoolean());
856            // Build the cache of user-info
857            Duration expiration = duration(config.get("cacheExpiration").defaultTo("20 seconds").asString());
858            if (!expiration.isZero()) {
859                executor = Executors.newSingleThreadScheduledExecutor();
860                cache = new ThreadSafeCache<>(executor);
861                cache.setDefaultTimeout(expiration);
862                filter.setUserInfoCache(cache);
863            }
864
865            return filter;
866        }
867
868        @Override
869        public void destroy() {
870            if (executor != null) {
871                executor.shutdownNow();
872            }
873            if (cache != null) {
874                cache.clear();
875            }
876        }
877    }
878
879    /**
880     * UserInfoFactory is responsible to load the profile of the authenticated user
881     * from the OpenID Connect Provider's user_info endpoint when the lazy map is accessed for the first time.
882     * If a cache has been configured
883     */
884    private class UserInfoFactory implements Factory<Map<String, Object>> {
885
886        private final LoadUserInfoCallable callable;
887
888        public UserInfoFactory(final OAuth2Session session,
889                               final ClientRegistration clientRegistration,
890                               final Context context,
891                               final Request request) {
892            this.callable = new LoadUserInfoCallable(session, clientRegistration, context, request);
893        }
894
895        @Override
896        public Map<String, Object> newInstance() {
897            /*
898             * When the 'user_info' attribute is accessed for the first time,
899             * try to load the value (from the cache or not depending on the configuration).
900             * The callable (factory for loading user info resource) will perform the appropriate HTTP request
901             * to retrieve the user info as JSON, and then will return that content as a Map
902             */
903
904            if (userInfoCache == null) {
905                // No cache is configured, go directly though the callable
906                try {
907                    return callable.call();
908                } catch (Exception e) {
909                    logger.warning(format("Unable to call UserInfo Endpoint from client registration '%s'",
910                                          callable.getClientRegistration().getName()));
911                    logger.warning(e);
912                }
913            } else {
914                // A cache is configured, extract the value from the cache
915                try {
916                    return userInfoCache.getValue(callable.getSession().getAccessToken(),
917                                                  callable);
918                } catch (InterruptedException e) {
919                    logger.warning(format("Interrupted when calling UserInfo Endpoint from client registration '%s'",
920                                          callable.getClientRegistration().getName()));
921                    logger.warning(e);
922                } catch (ExecutionException e) {
923                    logger.warning(format("Unable to call UserInfo Endpoint from client registration '%s'",
924                                          callable.getClientRegistration().getName()));
925                    logger.warning(e);
926                }
927            }
928
929            // In case of errors, returns an empty Map
930            return emptyMap();
931        }
932    }
933
934    /**
935     * LoadUserInfoCallable simply encapsulate the logic required to load the user_info resources.
936     */
937    private class LoadUserInfoCallable implements Callable<Map<String, Object>> {
938        private OAuth2Session session;
939        private final ClientRegistration clientRegistration;
940        private final Context context;
941        private final Request request;
942
943        public LoadUserInfoCallable(final OAuth2Session session,
944                                    final ClientRegistration clientRegistration,
945                                    final Context context,
946                                    final Request request) {
947            this.session = session;
948            this.clientRegistration = clientRegistration;
949            this.context = context;
950            this.request = request;
951        }
952
953        @Override
954        public Map<String, Object> call() throws Exception {
955            try {
956                return clientRegistration.getUserInfo(context, session).asMap();
957            } catch (OAuth2ErrorException e) {
958                final OAuth2Error error = e.getOAuth2Error();
959                if (error.is(E_INVALID_TOKEN)  && clientRegistration != null && session.getRefreshToken() != null) {
960                    // Supposed expired token, try to update it by generating a new access token.
961                    return updateSessionStateWithRefreshTokenOrFailWithNewSession();
962                }
963                throw e;
964            }
965        }
966
967        private Map<String, Object> updateSessionStateWithRefreshTokenOrFailWithNewSession() throws ResponseException,
968                                                                                             OAuth2ErrorException {
969            try {
970                JsonValue refreshAccessToken = clientRegistration.refreshAccessToken(context, session);
971                session = session.stateRefreshed(refreshAccessToken);
972                saveSession(context, session, buildUri(context, request, clientEndpoint));
973                return clientRegistration.getUserInfo(context, session).asMap();
974            } catch (OAuth2ErrorException ex) {
975                logger.debug("Fail to refresh OAuth2 Access Token");
976                logger.debug(ex);
977                session = OAuth2Session.stateNew(time);
978                saveSession(context, session, buildUri(context, request, clientEndpoint));
979                throw ex;
980            }
981        }
982
983        public OAuth2Session getSession() {
984            return session;
985        }
986
987        public ClientRegistration getClientRegistration() {
988            return clientRegistration;
989        }
990    }
991}