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}