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;
018
019import static java.lang.String.*;
020import static org.forgerock.openig.util.Duration.*;
021import static org.forgerock.openig.util.Json.*;
022
023import java.io.IOException;
024import java.util.Collections;
025import java.util.HashSet;
026import java.util.List;
027import java.util.Set;
028import java.util.concurrent.Executors;
029import java.util.concurrent.ScheduledExecutorService;
030
031import org.forgerock.openig.el.Expression;
032import org.forgerock.openig.el.ExpressionException;
033import org.forgerock.openig.filter.GenericFilter;
034import org.forgerock.openig.filter.oauth2.cache.CachingAccessTokenResolver;
035import org.forgerock.openig.filter.oauth2.cache.ThreadSafeCache;
036import org.forgerock.openig.filter.oauth2.challenge.InsufficientScopeChallengeHandler;
037import org.forgerock.openig.filter.oauth2.challenge.InvalidRequestChallengeHandler;
038import org.forgerock.openig.filter.oauth2.challenge.InvalidTokenChallengeHandler;
039import org.forgerock.openig.filter.oauth2.challenge.NoAuthenticationChallengeHandler;
040import org.forgerock.openig.filter.oauth2.resolver.OpenAmAccessTokenResolver;
041import org.forgerock.openig.handler.Handler;
042import org.forgerock.openig.handler.HandlerException;
043import org.forgerock.openig.heap.GenericHeaplet;
044import org.forgerock.openig.heap.HeapException;
045import org.forgerock.openig.http.Exchange;
046import org.forgerock.openig.http.Headers;
047import org.forgerock.openig.http.Request;
048import org.forgerock.openig.util.Duration;
049import org.forgerock.util.time.TimeService;
050
051/**
052 * Validates an {@link Exchange} that contains an OAuth 2.0 access token. <p> This filter expects an OAuth 2.0 token to
053 * be available in the HTTP {@literal Authorization} header:
054 *
055 * <pre>{@code Authorization: Bearer 1fc0e143-f248-4e50-9c13-1d710360cec9}</pre>
056 *
057 * It extracts the token and validate it against the {@literal token-info-endpoint} URL provided in the configuration.
058 *
059 * <pre>
060 * {
061 *         "name": "ProtectedResourceFilter",
062 *         "type": "org.forgerock.openig.filter.oauth2.OAuth2ResourceServerFilter",
063 *         "config": {
064 *           "scopes": [ "email", "profile" ],
065 *           "tokenInfoEndpoint": "https://openam.example.com:8443/openam/oauth2/tokeninfo",
066 *           "cacheExpiration": "2 minutes",
067 *           "requireHttps": false,
068 *           "providerHandler": "ClientHandler",
069 *           "realm": "Informative realm name",
070 *           "target": "${exchange.oauth2AccessToken}"
071 *         }
072 * }
073 * </pre>
074 *
075 * {@literal scopes}, {@literal tokenInfoEndpoint} and {@literal providerHandler} are the 3 only mandatory
076 * configuration attributes.
077 * <p>
078 * If {@literal cacheExpiration} is not set, the default is to keep the {@link AccessToken}s for 1 minute.
079 * {@literal cacheExpiration} is expressed using natural language (use {@literal zero} or {@literal none}
080 * to deactivate caching, any 0 valued duration will also deactivate it):
081 * <pre>
082 *     "cacheExpiration": "2 minutes"
083 *     "cacheExpiration": "3 days and 6 hours"
084 *     "cacheExpiration": "5m" // 5 minutes
085 *     "cacheExpiration": "10 min, 30 sec"
086 *     "cacheExpiration": "zero" // no cache
087 *     "cacheExpiration": "0 s" // no cache
088 * </pre>
089 * <p>
090 * {@literal providerHandler} is a name reference to another handler available in the heap. It will be used to perform
091 * access token validation against the {@literal tokenInfoEndpoint} URL.
092 * It is usually a reference to some {@link org.forgerock.openig.handler.ClientHandler}.
093 * <p>
094 * The {@literal requireHttps} optional attribute control if this filter only accepts requests targeting the HTTPS
095 * scheme. By default, it is enabled (only URI starting with {@literal https://...} will be accepted,
096 * an Exception is thrown otherwise).
097 * <p>
098 * The {@literal realm} optional attribute specifies the name of the realm used in the authentication challenges
099 * returned back to the client in case of errors.
100 * <p>
101 * The {@literal target} optional attribute specifies the expression which will be used for storing the OAuth 2.0 access
102 * token information in the exchange. Defaults to <tt>${exchange.oauth2AccessToken}</tt>.
103 *
104 * @see Duration
105 */
106public class OAuth2ResourceServerFilter extends GenericFilter {
107
108    /**
109     * The key under which downstream handlers will find the access token in the {@link Exchange}.
110     */
111    public static final String DEFAULT_ACCESS_TOKEN_KEY = "oauth2AccessToken";
112
113    /**
114     * Name of the realm when none is specified in the heaplet.
115     */
116    public static final String DEFAULT_REALM_NAME = "OpenIG";
117
118    private final AccessTokenResolver resolver;
119    private final BearerTokenExtractor extractor;
120    private final TimeService time;
121    private Set<Expression> scopes;
122    private String realm;
123
124    private final Handler noAuthentication;
125    private final Handler invalidToken;
126    private final Handler invalidRequest;
127    private final Expression target;
128
129    /**
130     * Creates a new {@code OAuth2Filter}.
131     *
132     * @param resolver
133     *         A {@code AccessTokenResolver} instance.
134     * @param extractor
135     *         A {@code BearerTokenExtractor} instance.
136     * @param time
137     *         A {@link TimeService} instance used to check if token is expired or not.
138     * @param target
139     *            The {@literal target} optional attribute specifies the expression which will be used for storing the
140     *            OAuth 2.0 access token information in the exchange. Should not be null.
141     */
142    public OAuth2ResourceServerFilter(final AccessTokenResolver resolver,
143                                      final BearerTokenExtractor extractor,
144                                      final TimeService time,
145                                      final Expression target) {
146        this(resolver, extractor, time, Collections.<Expression> emptySet(), DEFAULT_REALM_NAME, target);
147    }
148
149    /**
150     * Creates a new {@code OAuth2Filter}.
151     *
152     * @param resolver
153     *         A {@code AccessTokenResolver} instance.
154     * @param extractor
155     *         A {@code BearerTokenExtractor} instance.
156     * @param time
157     *         A {@link TimeService} instance used to check if token is expired or not.
158     * @param scopes
159     *         A set of scope expressions to be checked in the resolved access tokens.
160     * @param realm
161     *         Name of the realm (used in authentication challenge returned in case of error).
162     * @param target
163     *            The {@literal target} optional attribute specifies the expression which will be used for storing the
164     *            OAuth 2.0 access token information in the exchange. Should not be null.
165     */
166    public OAuth2ResourceServerFilter(final AccessTokenResolver resolver,
167                                      final BearerTokenExtractor extractor,
168                                      final TimeService time,
169                                      final Set<Expression> scopes,
170                                      final String realm,
171                                      final Expression target) {
172        this.resolver = resolver;
173        this.extractor = extractor;
174        this.time = time;
175        this.scopes = scopes;
176        this.realm = realm;
177        this.noAuthentication = new NoAuthenticationChallengeHandler(realm);
178        this.invalidToken = new InvalidTokenChallengeHandler(realm);
179        this.invalidRequest = new InvalidRequestChallengeHandler(realm);
180        this.target = target;
181    }
182
183    @Override
184    public void filter(final Exchange exchange, final Handler next) throws HandlerException, IOException {
185        String token = null;
186        try {
187            token = getAccessToken(exchange.request);
188            if (token == null) {
189                logger.debug("Missing OAuth 2.0 Bearer Token in the Authorization header");
190                noAuthentication.handle(exchange);
191                return;
192            }
193        } catch (OAuth2TokenException e) {
194            logger.debug("Multiple 'Authorization' headers in the request");
195            logger.debug(e);
196            invalidRequest.handle(exchange);
197            return;
198        }
199
200        // Resolve the token
201        AccessToken accessToken;
202        try {
203            accessToken = resolver.resolve(token);
204        } catch (OAuth2TokenException e) {
205            logger.debug(format("Access Token '%s' cannot be resolved", token));
206            logger.debug(e);
207            invalidToken.handle(exchange);
208            return;
209        }
210
211        // Validate the token (expiration + scopes)
212        if (isExpired(accessToken)) {
213            logger.debug(format("Access Token '%s' is expired", token));
214            invalidToken.handle(exchange);
215            return;
216        }
217
218        final Set<String> setOfScopes = getScopes(exchange);
219        if (areRequiredScopesMissing(accessToken, setOfScopes)) {
220            logger.debug(format("Access Token '%s' is missing required scopes", token));
221            new InsufficientScopeChallengeHandler(realm, setOfScopes).handle(exchange);
222            return;
223        }
224
225        // Store the AccessToken in the exchange for downstream handlers
226        target.set(exchange, accessToken);
227
228        // Call the rest of the chain
229        next.handle(exchange);
230    }
231
232    private boolean isExpired(final AccessToken accessToken) {
233        return time.now() > accessToken.getExpiresAt();
234    }
235
236    private boolean areRequiredScopesMissing(final AccessToken accessToken, final Set<String> scopes)
237            throws HandlerException {
238        return !accessToken.getScopes().containsAll(scopes);
239    }
240
241    private Set<String> getScopes(final Exchange exchange) throws HandlerException {
242        final Set<String> scopeValues = new HashSet<String>(this.scopes.size());
243        for (final Expression scope : this.scopes) {
244            final String result = scope.eval(exchange, String.class);
245            if (result == null) {
246                throw new HandlerException(
247                        "The OAuth 2.0 resource server filter scope expression could not be resolved");
248            }
249            scopeValues.add(result);
250        }
251        return scopeValues;
252    }
253
254    /**
255     * Pulls the access token off of the request, by looking for the {@literal Authorization} header containing a
256     * {@literal Bearer} token.
257     *
258     * @param request
259     *         The Http {@link Request} message.
260     * @return The access token, or {@literal null} if the access token was not present or was not using {@literal
261     * Bearer} authorization.
262     */
263    private String getAccessToken(final Request request) throws OAuth2TokenException {
264        Headers headers = request.getHeaders();
265        List<String> authorizations = headers.get("Authorization");
266        if ((authorizations != null) && (authorizations.size() >= 2)) {
267            throw new OAuth2TokenException("Can't use more than 1 'Authorization' Header to convey"
268                                                   + " the OAuth2 AccessToken");
269        }
270        String header = headers.getFirst("Authorization");
271        return extractor.getAccessToken(header);
272    }
273
274    /** Creates and initializes an OAuth2 filter in a heap environment. */
275    public static class Heaplet extends GenericHeaplet {
276
277        private ThreadSafeCache<String, AccessToken> cache;
278        private ScheduledExecutorService executorService;
279
280        @Override
281        public Object create() throws HeapException {
282            Handler httpHandler =
283                    heap.resolve(getWithDeprecation(config, logger, "providerHandler",
284                            "httpHandler").required(), Handler.class);
285
286            TimeService time = TimeService.SYSTEM;
287            AccessTokenResolver resolver = new OpenAmAccessTokenResolver(
288                    httpHandler,
289                    time,
290                    config.get("tokenInfoEndpoint").required().asString());
291
292            // Build the cache
293            Duration expiration = duration(config.get("cacheExpiration").defaultTo("1 minute").asString());
294            if (!expiration.isZero()) {
295                executorService = Executors.newSingleThreadScheduledExecutor();
296                cache = new ThreadSafeCache<String, AccessToken>(executorService);
297                cache.setTimeout(expiration);
298                resolver = new CachingAccessTokenResolver(resolver, cache);
299            }
300
301            Set<Expression> scopes =
302                    getWithDeprecation(config, logger, "scopes", "requiredScopes").required().asSet(ofExpression());
303
304            String realm = config.get("realm").defaultTo(DEFAULT_REALM_NAME).asString();
305
306            final Expression target = asExpression(config.get("target").defaultTo(
307                    format("${exchange.%s}", DEFAULT_ACCESS_TOKEN_KEY)));
308
309            final OAuth2ResourceServerFilter filter = new OAuth2ResourceServerFilter(resolver,
310                                                           new BearerTokenExtractor(),
311                                                           time,
312                                                           scopes,
313                                                           realm,
314                                                           target);
315
316            if (getWithDeprecation(config, logger, "requireHttps", "enforceHttps").defaultTo(
317                    Boolean.TRUE).asBoolean()) {
318                try {
319                    return new EnforcerFilter(new Expression("${exchange.request.uri.scheme == 'https'}"), filter);
320                } catch (ExpressionException e) {
321                    // Can be ignored, since we completely control the expression
322                }
323            }
324            return filter;
325        }
326
327        @Override
328        public void destroy() {
329            if (executorService != null) {
330                executorService.shutdownNow();
331            }
332            if (cache != null) {
333                cache.clear();
334            }
335        }
336    }
337
338}