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