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