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}