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=<url></code> - redirects 066 * the user for authorization against the specified provider 067 * <li><code>{clientEndpoint}/logout?goto=<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 * <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}