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.client; 018 019import static java.lang.String.format; 020import static java.util.Collections.emptyMap; 021import static org.forgerock.http.handler.Handlers.chainOf; 022import static org.forgerock.openig.el.Bindings.bindings; 023import static org.forgerock.openig.filter.oauth2.client.OAuth2Error.E_ACCESS_DENIED; 024import static org.forgerock.openig.filter.oauth2.client.OAuth2Error.E_INVALID_REQUEST; 025import static org.forgerock.openig.filter.oauth2.client.OAuth2Error.E_INVALID_TOKEN; 026import static org.forgerock.openig.filter.oauth2.client.OAuth2Utils.buildUri; 027import static org.forgerock.openig.filter.oauth2.client.OAuth2Utils.createAuthorizationNonceHash; 028import static org.forgerock.openig.filter.oauth2.client.OAuth2Utils.httpRedirect; 029import static org.forgerock.openig.filter.oauth2.client.OAuth2Utils.httpResponse; 030import static org.forgerock.openig.filter.oauth2.client.OAuth2Utils.loadOrCreateSession; 031import static org.forgerock.openig.filter.oauth2.client.OAuth2Utils.matchesUri; 032import static org.forgerock.openig.filter.oauth2.client.OAuth2Utils.removeSession; 033import static org.forgerock.openig.filter.oauth2.client.OAuth2Utils.saveSession; 034import static org.forgerock.openig.heap.Keys.CLIENT_HANDLER_HEAP_KEY; 035import static org.forgerock.openig.heap.Keys.TIME_SERVICE_HEAP_KEY; 036import static org.forgerock.openig.util.JsonValues.asExpression; 037import static org.forgerock.openig.util.JsonValues.getWithDeprecation; 038import static org.forgerock.util.Utils.closeSilently; 039import static org.forgerock.util.promise.Promises.newResultPromise; 040import static org.forgerock.util.time.Duration.duration; 041 042import java.net.URI; 043import java.util.LinkedHashMap; 044import java.util.Map; 045import java.util.concurrent.Callable; 046import java.util.concurrent.ExecutionException; 047import java.util.concurrent.Executors; 048import java.util.concurrent.ScheduledExecutorService; 049 050import org.forgerock.http.Filter; 051import org.forgerock.http.Handler; 052import org.forgerock.http.protocol.Request; 053import org.forgerock.http.protocol.Response; 054import org.forgerock.http.protocol.ResponseException; 055import org.forgerock.http.protocol.Status; 056import org.forgerock.http.routing.UriRouterContext; 057import org.forgerock.json.JsonValue; 058import org.forgerock.json.jose.jws.SignedJwt; 059import org.forgerock.openig.el.Expression; 060import org.forgerock.openig.heap.GenericHeapObject; 061import org.forgerock.openig.heap.GenericHeaplet; 062import org.forgerock.openig.heap.Heap; 063import org.forgerock.openig.heap.HeapException; 064import org.forgerock.openig.util.ThreadSafeCache; 065import org.forgerock.services.context.Context; 066import org.forgerock.util.AsyncFunction; 067import org.forgerock.util.Factory; 068import org.forgerock.util.Function; 069import org.forgerock.util.LazyMap; 070import org.forgerock.util.promise.NeverThrowsException; 071import org.forgerock.util.promise.Promise; 072import org.forgerock.util.time.Duration; 073import org.forgerock.util.time.TimeService; 074 075/** 076 * A filter which is responsible for authenticating the end-user using OAuth 2.0 077 * delegated authorization. The filter does the following depending on the 078 * incoming request URI: 079 * <ul> 080 * <li>{@code {clientEndpoint}/login/{registration}?goto=<url>} - redirects 081 * the user for authorization against the specified client 082 * registration 083 * <li>{@code {clientEndpoint}/login?{*}discovery={input}&goto=<url>} - 084 * performs issuer discovery and dynamic client registration if possible on 085 * the given user input and redirects the user to the client endpoint. 086 * <li>{@code {clientEndpoint}/logout?goto=<url>} - removes 087 * authorization state for the end-user 088 * <li>{@code {clientEndpoint}/callback} - OAuth 2.0 authorization 089 * call-back end-point (state encodes nonce, goto, and client registration) 090 * <li>all other requests - restores authorization state and places it in the 091 * target location. 092 * </ul> 093 * <p> 094 * Configuration options: 095 * 096 * <pre> 097 * {@code 098 * "target" : expression, [OPTIONAL - default is ${attributes.openid}] 099 * "clientEndpoint" : expression, [REQUIRED] 100 * "loginHandler" : handler, [REQUIRED - if multiple client registrations] 101 * OR 102 * "registration" : reference or [REQUIRED - if you want to use a single client 103 * inlined declaration, registration] 104 * "discoveryHandler" : handler, [OPTIONAL - by default it uses the 'ClientHandler' 105 * provided in heap.] 106 * "failureHandler" : handler, [REQUIRED] 107 * "defaultLoginGoto" : expression, [OPTIONAL - default return empty page] 108 * "defaultLogoutGoto" : expression, [OPTIONAL - default return empty page] 109 * "requireLogin" : boolean [OPTIONAL - default require login] 110 * "requireHttps" : boolean [OPTIONAL - default require SSL] 111 * "cacheExpiration" : duration [OPTIONAL - default to 20 seconds] 112 * "metadata" : { [OPTIONAL - contains metadata dedicated for dynamic 113 * client registration.] 114 * "redirect_uris" : [ strings ], [REQUIRED for dynamic client registration.] 115 * "scopes" : [ strings ] [OPTIONAL - usage with OpenAM only.] 116 * } 117 * } 118 * </pre> 119 * 120 * For example, if you want to use a nascar page (multiple client 121 * registrations): 122 * 123 * <pre> 124 * {@code 125 * { 126 * "name": "OpenIDConnect", 127 * "type": "OAuth2ClientFilter", 128 * "config": { 129 * "target" : "${attributes.openid}", 130 * "clientEndpoint" : "/openid", 131 * "loginHandler" : "NascarPage", 132 * "failureHandler" : "LoginFailed", 133 * "defaultLoginGoto" : "/homepage", 134 * "defaultLogoutGoto" : "/loggedOut", 135 * "requireHttps" : false, 136 * "requireLogin" : true 137 * } 138 * } 139 * } 140 * </pre> 141 * 142 * This one, containing a nascar page and allowing dynamic client registration with OpenAM: 143 * 144 * <pre> 145 * {@code 146 * { 147 * "name": "OpenIDConnect", 148 * "type": "OAuth2ClientFilter", 149 * "config": { 150 * "target" : "${attributes.openid}", 151 * "clientEndpoint" : "/openid", 152 * "loginHandler" : "NascarPage", 153 * "failureHandler" : "LoginFailed", 154 * "defaultLoginGoto" : "/homepage", 155 * "defaultLogoutGoto" : "/loggedOut", 156 * "requireHttps" : false, 157 * "requireLogin" : true, 158 * "metadata" : { 159 * "client_name": "iRock", 160 * "contacts": [ "werock@example.com", "werock@forgerock.org" ], 161 * "scopes": [ 162 * "openid", "profile" 163 * ], 164 * "redirect_uris": [ "http://my.example.com:8082/openid/callback" ], 165 * "logo_uri": "https://client.example.org/logo.png", 166 * "subject_type": "pairwise" 167 * } 168 * } 169 * } 170 * } 171 * </pre> 172 * 173 * Or this one, with a single client registration. 174 * 175 * <pre> 176 * {@code 177 * { 178 * "name": "OpenIDConnect", 179 * "type": "OAuth2ClientFilter", 180 * "config": { 181 * "target" : "${attributes.openid}", 182 * "clientEndpoint" : "/openid", 183 * "registration" : "openam", 184 * "failureHandler" : "LoginFailed" 185 * } 186 * } 187 * } 188 * </pre> 189 * 190 * Once authorized, this filter will inject the following information into 191 * the target location: 192 * 193 * <pre> 194 * {@code 195 * "openid" : { 196 * "client_registration" : "google", 197 * "access_token" : "xxx", 198 * "id_token" : "xxx", 199 * "token_type" : "Bearer", 200 * "expires_in" : 3599, 201 * "scope" : [ "openid", "profile", "email" ], 202 * "client_endpoint" : "http://www.example.com:8081/openid", 203 * "id_token_claims" : { 204 * "at_hash" : "xxx", 205 * "sub" : "xxx", 206 * "aud" : [ "xxx.apps.googleusercontent.com" ], 207 * "email_verified" : true, 208 * "azp" : "xxx.apps.googleusercontent.com", 209 * "iss" : "accounts.google.com", 210 * "exp" : "2014-07-25T00:12:53+0000", 211 * "iat" : "2014-07-24T23:07:53+0000", 212 * "email" : "micky.mouse@gmail.com" 213 * }, 214 * "user_info" : { 215 * "sub" : "xxx", 216 * "email_verified" : "true", 217 * "gender" : "male", 218 * "kind" : "plus#personOpenIdConnect", 219 * "profile" : "https://plus.google.com/xxx", 220 * "name" : "Micky Mouse", 221 * "given_name" : "Micky", 222 * "locale" : "en-GB", 223 * "family_name" : "Mouse", 224 * "picture" : "https://lh4.googleusercontent.com/xxx/photo.jpg?sz=50", 225 * "email" : "micky.mouse@gmail.com" 226 * } 227 * } 228 * } 229 * } 230 * </pre> 231 */ 232public final class OAuth2ClientFilter extends GenericHeapObject implements Filter { 233 234 /** The expression which will be used for storing authorization information in the context. */ 235 public static final String DEFAULT_TOKEN_KEY = "openid"; 236 237 private Expression<String> clientEndpoint; 238 private Expression<String> defaultLoginGoto; 239 private Expression<String> defaultLogoutGoto; 240 private final Handler discoveryHandler; 241 private Handler failureHandler; 242 private Handler loginHandler; 243 private ClientRegistration clientRegistration; 244 private boolean requireHttps = true; 245 private boolean requireLogin = true; 246 private Expression<?> target; 247 private final TimeService time; 248 private ThreadSafeCache<String, Map<String, Object>> userInfoCache; 249 private final Heap heap; 250 private final Handler discoveryAndDynamicRegistrationChain; 251 252 /** 253 * Constructs an {@link OAuth2ClientFilter}. 254 * 255 * @param time 256 * The TimeService to use. 257 * @param heap 258 * The current heap. 259 * @param metadata 260 * The json configuration containing all the OpenID metadata used for 261 * the dynamic client registration. 262 * @param name 263 * The name of this filter. 264 * @param discoveryHandler 265 * The handler used for discovery and dynamic client 266 * registration. 267 * @param clientEndpoint 268 * The expression which will be used for obtaining the base URI 269 * for this filter. 270 */ 271 public OAuth2ClientFilter(TimeService time, 272 Heap heap, 273 JsonValue metadata, 274 String name, 275 Handler discoveryHandler, 276 Expression<String> clientEndpoint) { 277 this.time = time; 278 this.heap = heap; 279 this.discoveryHandler = discoveryHandler; 280 this.clientEndpoint = clientEndpoint; 281 discoveryAndDynamicRegistrationChain = buildDiscoveryAndDynamicRegistrationChain(heap, 282 metadata, 283 name); 284 } 285 286 private Handler buildDiscoveryAndDynamicRegistrationChain(final Heap heap, 287 final JsonValue metadata, 288 final String name) { 289 return chainOf(new AuthorizationRedirectHandler(time, clientEndpoint), 290 new DiscoveryFilter(discoveryHandler, heap), 291 new ClientRegistrationFilter(discoveryHandler, metadata, heap, name)); 292 } 293 294 @Override 295 public Promise<Response, NeverThrowsException> filter(final Context context, 296 final Request request, 297 final Handler next) { 298 try { 299 // Login: {clientEndpoint}/login 300 UriRouterContext routerContext = context.asContext(UriRouterContext.class); 301 URI originalUri = routerContext.getOriginalUri(); 302 if (matchesUri(originalUri, buildLoginUri(context, request))) { 303 if (request.getForm().containsKey("discovery")) { 304 // User input: {clientEndpoint}/login?discovery={input}[&goto={url}] 305 return handleUserInitiatedDiscovery(request, context); 306 } else { 307 // Login: {clientEndpoint}/login?registration={name}[&goto={url}] 308 checkRequestIsSufficientlySecure(context); 309 return handleUserInitiatedLogin(context, request); 310 } 311 } 312 313 // Authorize call-back: {clientEndpoint}/callback?... 314 if (matchesUri(originalUri, buildCallbackUri(context, request))) { 315 checkRequestIsSufficientlySecure(context); 316 return handleAuthorizationCallback(context, request); 317 } 318 319 // Logout: {clientEndpoint}/logout[?goto={url}] 320 if (matchesUri(originalUri, buildLogoutUri(context, request))) { 321 return handleUserInitiatedLogout(context, request); 322 } 323 324 // Everything else... 325 return handleProtectedResource(context, request, next); 326 } catch (final OAuth2ErrorException e) { 327 return handleOAuth2ErrorException(context, request, e); 328 } catch (ResponseException e) { 329 return newResultPromise(e.getResponse()); 330 } 331 } 332 333 /** 334 * Sets the expression which will be used for obtaining the default login 335 * "goto" URI. The default goto URI will be used when a user performs a user 336 * initiated login without providing a "goto" http parameter. This 337 * configuration parameter is optional. If no "goto" parameter is provided 338 * in the request and there is no default "goto" then user initiated login 339 * requests will simply return a 200 status. 340 * 341 * @param endpoint 342 * The expression which will be used for obtaining the default 343 * login "goto" URI. 344 * @return This filter. 345 */ 346 public OAuth2ClientFilter setDefaultLoginGoto(final Expression<String> endpoint) { 347 this.defaultLoginGoto = endpoint; 348 return this; 349 } 350 351 /** 352 * Sets the expression which will be used for obtaining the default logout 353 * "goto" URI. The default goto URI will be used when a user performs a user 354 * initiated logout without providing a "goto" http parameter. This 355 * configuration parameter is optional. If no "goto" parameter is provided 356 * in the request and there is no default "goto" then user initiated logout 357 * requests will simply return a 200 status. 358 * 359 * @param endpoint 360 * The expression which will be used for obtaining the default 361 * logout "goto" URI. 362 * @return This filter. 363 */ 364 public OAuth2ClientFilter setDefaultLogoutGoto(final Expression<String> endpoint) { 365 this.defaultLogoutGoto = endpoint; 366 return this; 367 } 368 369 /** 370 * Sets the handler which will be invoked when authentication fails. This 371 * configuration parameter is required. If authorization fails for any 372 * reason and the request cannot be processed using the next filter/handler, 373 * then the request will be forwarded to the failure handler. In addition, 374 * the target expression will be populated with the following OAuth 375 * 2.0 error information: 376 * 377 * <pre> 378 * {@code 379 * <target> : { 380 * "client_registration" : "google", 381 * "error" : { 382 * "realm" : string, [OPTIONAL] 383 * "scope" : array of string, [OPTIONAL list of required scopes] 384 * "error" : string, [OPTIONAL] 385 * "error_description" : string, [OPTIONAL] 386 * "error_uri" : string [OPTIONAL] 387 * }, 388 * // The following fields may or may not be present depending on 389 * // how far authorization proceeded. 390 * "access_token" : "xxx", 391 * "id_token" : "xxx", 392 * "token_type" : "Bearer", 393 * "expires_in" : 3599, 394 * "scope" : [ "openid", "profile", "email" ], 395 * "client_endpoint" : "http://www.example.com:8081/openid", 396 * } 397 * } 398 * </pre> 399 * 400 * See {@link OAuth2Error} for a detailed description of the various error 401 * fields and their possible values. 402 * 403 * @param handler 404 * The handler which will be invoked when authentication fails. 405 * @return This filter. 406 */ 407 public OAuth2ClientFilter setFailureHandler(final Handler handler) { 408 this.failureHandler = handler; 409 return this; 410 } 411 412 /** 413 * Sets the handler which will be invoked when the user needs to 414 * authenticate. This configuration parameter is required if there are more 415 * than one client registration configured. 416 * 417 * @param handler 418 * The handler which will be invoked when the user needs to 419 * authenticate. 420 * @return This filter. 421 */ 422 public OAuth2ClientFilter setLoginHandler(final Handler handler) { 423 this.loginHandler = handler; 424 return this; 425 } 426 427 /** 428 * You can avoid a nascar page by setting a client registration. 429 * 430 * @param registration 431 * The client registration to use. 432 * @return This filter. 433 */ 434 public OAuth2ClientFilter setClientRegistration(final ClientRegistration registration) { 435 this.clientRegistration = registration; 436 return this; 437 } 438 439 /** 440 * Specifies whether all incoming requests must use TLS. This configuration 441 * parameter is optional and set to {@code true} by default. 442 * 443 * @param requireHttps 444 * {@code true} if all incoming requests must use TLS, 445 * {@code false} by default. 446 * @return This filter. 447 */ 448 public OAuth2ClientFilter setRequireHttps(final boolean requireHttps) { 449 this.requireHttps = requireHttps; 450 return this; 451 } 452 453 /** 454 * Specifies whether authentication is required for all incoming requests. 455 * This configuration parameter is optional and set to {@code true} by 456 * default. 457 * 458 * @param requireLogin 459 * {@code true} if authentication is required for all incoming 460 * requests, or {@code false} if authentication should be 461 * performed only when required (default {@code true}. 462 * @return This filter. 463 */ 464 public OAuth2ClientFilter setRequireLogin(final boolean requireLogin) { 465 this.requireLogin = requireLogin; 466 return this; 467 } 468 469 /** 470 * Sets the expression which will be used for storing authorization 471 * information in the context. This configuration parameter is required. 472 * 473 * @param target 474 * The expression which will be used for storing authorization 475 * information in the context. 476 * @return This filter. 477 */ 478 public OAuth2ClientFilter setTarget(final Expression<?> target) { 479 this.target = target; 480 return this; 481 } 482 483 private URI buildCallbackUri(final Context context, final Request request) throws ResponseException { 484 return buildUri(context, request, clientEndpoint, "callback"); 485 } 486 487 private URI buildLoginUri(final Context context, final Request request) throws ResponseException { 488 return buildUri(context, request, clientEndpoint, "login"); 489 } 490 491 private URI buildLogoutUri(final Context context, final Request request) throws ResponseException { 492 return buildUri(context, request, clientEndpoint, "logout"); 493 } 494 495 private void checkRequestIsSufficientlySecure(final Context context) 496 throws OAuth2ErrorException { 497 // FIXME: use enforce filter? 498 UriRouterContext routerContext = context.asContext(UriRouterContext.class); 499 if (requireHttps && !"https".equalsIgnoreCase(routerContext.getOriginalUri().getScheme())) { 500 throw new OAuth2ErrorException(E_INVALID_REQUEST, 501 "SSL is required in order to perform this operation"); 502 } 503 } 504 505 private ClientRegistration getClientRegistration(final OAuth2Session session) { 506 final String name = session.getClientRegistrationName(); 507 return name != null ? getClientRegistrationFromHeap(name) : null; 508 } 509 510 private Promise<Response, NeverThrowsException> handleAuthorizationCallback(final Context context, 511 final Request request) 512 throws OAuth2ErrorException { 513 514 try { 515 if (!"GET".equals(request.getMethod())) { 516 throw new OAuth2ErrorException(E_INVALID_REQUEST, 517 "Authorization call-back failed because the request was not a GET"); 518 } 519 520 /* 521 * The state must be valid regardless of whether the authorization 522 * succeeded or failed. 523 */ 524 final String state = request.getForm().getFirst("state"); 525 if (state == null) { 526 throw new OAuth2ErrorException(E_INVALID_REQUEST, 527 "Authorization call-back failed because there was no state parameter"); 528 } 529 final OAuth2Session session = loadOrCreateSession(context, request, clientEndpoint, time); 530 if (!session.isAuthorizing()) { 531 throw new OAuth2ErrorException(E_INVALID_REQUEST, 532 "Authorization call-back failed because there is no authorization in progress"); 533 } 534 final int colonPos = state.indexOf(':'); 535 final String actualHash = colonPos < 0 ? state : state.substring(0, colonPos); 536 final String gotoUri = colonPos < 0 ? null : state.substring(colonPos + 1); 537 final String expectedHash = 538 createAuthorizationNonceHash(session.getAuthorizationRequestNonce()); 539 if (!expectedHash.equals(actualHash)) { 540 throw new OAuth2ErrorException(E_INVALID_REQUEST, 541 "Authorization call-back failed because the state parameter contained " 542 + "an unexpected value"); 543 } 544 545 final String code = request.getForm().getFirst("code"); 546 if (code == null) { 547 throw new OAuth2ErrorException(OAuth2Error.valueOfForm(request.getForm())); 548 } 549 550 final ClientRegistration client = getClientRegistrationFromHeap(session.getClientRegistrationName()); 551 if (client == null) { 552 throw new OAuth2ErrorException(E_INVALID_REQUEST, format( 553 "Authorization call-back failed because the client registration %s was unrecognized", 554 session.getClientRegistrationName())); 555 } 556 final JsonValue accessTokenResponse = client.getAccessToken(context, 557 code, 558 buildCallbackUri(context, request).toString()); 559 560 /* 561 * Finally complete the authorization request by redirecting to the 562 * original goto URI and saving the session. It is important to save the 563 * session after setting the response because it may need to access 564 * response cookies. 565 */ 566 final OAuth2Session authorizedSession = session.stateAuthorized(accessTokenResponse); 567 return httpRedirectGoto(context, request, gotoUri, defaultLoginGoto) 568 .then(new Function<Response, Response, NeverThrowsException>() { 569 @Override 570 public Response apply(final Response response) { 571 try { 572 saveSession(context, authorizedSession, buildUri(context, request, clientEndpoint)); 573 } catch (ResponseException e) { 574 return e.getResponse(); 575 } 576 return response; 577 } 578 }); 579 } catch (ResponseException e) { 580 return newResultPromise(e.getResponse()); 581 } 582 } 583 584 private Promise<Response, NeverThrowsException> handleOAuth2ErrorException(final Context context, 585 final Request request, 586 final OAuth2ErrorException e) { 587 final OAuth2Error error = e.getOAuth2Error(); 588 if (error.is(E_ACCESS_DENIED) || error.is(E_INVALID_TOKEN)) { 589 logger.debug(e.getMessage()); 590 } else { 591 // Assume all other errors are more serious operational errors. 592 logger.warning(e.getMessage()); 593 } 594 final Map<String, Object> info = new LinkedHashMap<>(); 595 try { 596 final OAuth2Session session = loadOrCreateSession(context, request, clientEndpoint, time); 597 info.putAll(session.getAccessTokenResponse()); 598 599 // Override these with effective values. 600 info.put("clientRegistration", session.getClientRegistrationName()); 601 info.put("client_endpoint", session.getClientEndpoint()); 602 info.put("expires_in", session.getExpiresIn()); 603 info.put("scope", session.getScopes()); 604 final SignedJwt idToken = session.getIdToken(); 605 if (idToken != null) { 606 final Map<String, Object> idTokenClaims = new LinkedHashMap<>(); 607 for (final String claim : idToken.getClaimsSet().keys()) { 608 idTokenClaims.put(claim, idToken.getClaimsSet().getClaim(claim)); 609 } 610 info.put("id_token_claims", idTokenClaims); 611 } 612 } catch (Exception ignored) { 613 /* 614 * The session could not be decoded. Presumably this is why we are 615 * here already, so simply ignore the error, and use the error that 616 * was passed in to this method. 617 */ 618 } 619 info.put("error", error.toJsonContent()); 620 target.set(bindings(context, request), info); 621 return failureHandler.handle(context, request); 622 } 623 624 private Promise<Response, NeverThrowsException> handleProtectedResource(final Context context, 625 final Request request, 626 final Handler next) 627 throws OAuth2ErrorException { 628 try { 629 final OAuth2Session session = loadOrCreateSession(context, request, clientEndpoint, time); 630 if (!session.isAuthorized() && requireLogin) { 631 return sendAuthorizationRedirect(context, request, null); 632 } 633 final OAuth2Session refreshedSession = 634 session.isAuthorized() ? prepareContext(context, session, request) : session; 635 return next.handle(context, request) 636 .thenAsync(new AsyncFunction<Response, Response, NeverThrowsException>() { 637 @Override 638 public Promise<Response, NeverThrowsException> apply(final Response response) { 639 if (Status.UNAUTHORIZED.equals(response.getStatus()) && !refreshedSession.isAuthorized()) { 640 closeSilently(response); 641 return sendAuthorizationRedirect(context, request, null); 642 } else if (session != refreshedSession) { 643 /* 644 * Only update the session if it has changed in order to avoid send 645 * back JWT session cookies with every response. 646 */ 647 try { 648 saveSession(context, 649 refreshedSession, 650 buildUri(context, request, clientEndpoint)); 651 } catch (ResponseException e) { 652 return newResultPromise(e.getResponse()); 653 } 654 } 655 return newResultPromise(response); 656 } 657 }); 658 } catch (ResponseException e) { 659 return newResultPromise(e.getResponse()); 660 } 661 } 662 663 private Promise<Response, NeverThrowsException> handleUserInitiatedDiscovery(final Request request, 664 final Context context) 665 throws OAuth2ErrorException, ResponseException { 666 667 return discoveryAndDynamicRegistrationChain.handle(context, request); 668 } 669 670 private Promise<Response, NeverThrowsException> handleUserInitiatedLogin(final Context context, 671 final Request request) 672 throws OAuth2ErrorException, ResponseException { 673 final String clientRegistrationName = request.getForm().getFirst("registration"); 674 if (clientRegistrationName == null) { 675 throw new OAuth2ErrorException(E_INVALID_REQUEST, 676 "Authorization OpenID Connect Provider must be specified"); 677 } 678 final ClientRegistration clientRegistration = getClientRegistrationFromHeap(clientRegistrationName); 679 if (clientRegistration == null) { 680 throw new OAuth2ErrorException(E_INVALID_REQUEST, "Authorization OpenID Connect Provider '" 681 + clientRegistrationName + "' was not recognized"); 682 } 683 return sendAuthorizationRedirect(context, request, clientRegistration); 684 } 685 686 private Promise<Response, NeverThrowsException> handleUserInitiatedLogout(final Context context, 687 final Request request) 688 throws ResponseException { 689 final String gotoUri = request.getForm().getFirst("goto"); 690 return httpRedirectGoto(context, request, gotoUri, defaultLogoutGoto) 691 .then(new Function<Response, Response, NeverThrowsException>() { 692 @Override 693 public Response apply(final Response response) { 694 try { 695 removeSession(context, request, clientEndpoint); 696 } catch (ResponseException e) { 697 return e.getResponse(); 698 } 699 return response; 700 } 701 }); 702 } 703 704 private Promise<Response, NeverThrowsException> httpRedirectGoto(final Context context, 705 final Request request, 706 final String gotoUri, 707 final Expression<String> defaultGotoUri) { 708 try { 709 if (gotoUri != null) { 710 return completion(httpRedirect(gotoUri)); 711 } else if (defaultGotoUri != null) { 712 return completion(httpRedirect(buildUri(context, request, defaultGotoUri).toString())); 713 } else { 714 return completion(httpResponse(Status.OK)); 715 } 716 } catch (ResponseException e) { 717 return newResultPromise(e.getResponse()); 718 } 719 } 720 721 private Promise<Response, NeverThrowsException> completion(Response response) { 722 return newResultPromise(response); 723 } 724 725 private OAuth2Session prepareContext(final Context context, final OAuth2Session session, final Request request) 726 throws ResponseException, OAuth2ErrorException { 727 try { 728 tryPrepareContext(context, session, request); 729 return session; 730 } catch (final OAuth2ErrorException e) { 731 /* 732 * Try again if the access token looks like it has expired and can 733 * be refreshed. 734 */ 735 final OAuth2Error error = e.getOAuth2Error(); 736 final ClientRegistration clientRegistration = getClientRegistration(session); 737 if (error.is(E_INVALID_TOKEN) && clientRegistration != null && session.getRefreshToken() != null) { 738 // The session is updated with new access token. 739 final JsonValue accessTokenResponse = clientRegistration.refreshAccessToken(context, session); 740 final OAuth2Session refreshedSession = session.stateRefreshed(accessTokenResponse); 741 tryPrepareContext(context, refreshedSession, request); 742 return refreshedSession; 743 } 744 745 /* 746 * It looks like the token cannot be refreshed or something more 747 * serious happened, e.g. the token has the wrong scopes. Re-throw 748 * the error and let the failure-handler deal with it. 749 */ 750 throw e; 751 } 752 } 753 754 private Promise<Response, NeverThrowsException> sendAuthorizationRedirect(final Context context, 755 final Request request, 756 final ClientRegistration cr) { 757 if (cr == null && loginHandler != null) { 758 return loginHandler.handle(context, request); 759 } 760 return new AuthorizationRedirectHandler(time, 761 clientEndpoint, 762 cr != null ? cr : clientRegistration) 763 .handle(context, request); 764 } 765 766 private ClientRegistration getClientRegistrationFromHeap(final String name) { 767 ClientRegistration clientRegistration = null; 768 try { 769 clientRegistration = heap.get(name, ClientRegistration.class); 770 } catch (HeapException e) { 771 logger.error(format("Cannot retrieve the client registration '%s' from the heap", name)); 772 } 773 return clientRegistration; 774 } 775 776 private void tryPrepareContext(final Context context, final OAuth2Session session, final Request request) 777 throws ResponseException, OAuth2ErrorException { 778 final Map<String, Object> info = 779 new LinkedHashMap<>(session.getAccessTokenResponse()); 780 // Override these with effective values. 781 info.put("client_registration", session.getClientRegistrationName()); 782 info.put("client_endpoint", session.getClientEndpoint()); 783 info.put("expires_in", session.getExpiresIn()); 784 info.put("scope", session.getScopes()); 785 final SignedJwt idToken = session.getIdToken(); 786 if (idToken != null) { 787 final Map<String, Object> idTokenClaims = new LinkedHashMap<>(); 788 for (final String claim : idToken.getClaimsSet().keys()) { 789 idTokenClaims.put(claim, idToken.getClaimsSet().getClaim(claim)); 790 } 791 info.put("id_token_claims", idTokenClaims); 792 } 793 794 final ClientRegistration clientRegistration = getClientRegistration(session); 795 if (clientRegistration != null 796 && clientRegistration.getIssuer().hasUserInfoEndpoint() 797 && session.getScopes().contains("openid")) { 798 // Load the user_info resources lazily (when requested) 799 info.put("user_info", new LazyMap<>(new UserInfoFactory(session, 800 clientRegistration, 801 context, 802 request))); 803 } 804 target.set(bindings(context, null), info); 805 } 806 807 /** 808 * Set the cache of user info resources. The cache is keyed by the OAuth 2.0 Access Token. It should be configured 809 * with a small expiration duration (something between 5 and 30 seconds). 810 * 811 * @param userInfoCache 812 * the cache of user info resources. 813 */ 814 public void setUserInfoCache(final ThreadSafeCache<String, Map<String, Object>> userInfoCache) { 815 this.userInfoCache = userInfoCache; 816 } 817 818 /** Creates and initializes the filter in a heap environment. */ 819 public static class Heaplet extends GenericHeaplet { 820 821 private ScheduledExecutorService executor; 822 private ThreadSafeCache<String, Map<String, Object>> cache; 823 824 @Override 825 public Object create() throws HeapException { 826 827 final Handler discoveryHandler = heap.resolve(config.get("discoveryHandler") 828 .defaultTo(CLIENT_HANDLER_HEAP_KEY), 829 Handler.class); 830 TimeService time = heap.get(TIME_SERVICE_HEAP_KEY, TimeService.class); 831 final Expression<String> clientEndpoint = asExpression(config.get("clientEndpoint").required(), 832 String.class); 833 final OAuth2ClientFilter filter = new OAuth2ClientFilter(time, 834 heap, 835 config.get("metadata"), 836 this.name, 837 discoveryHandler, 838 clientEndpoint); 839 840 filter.setTarget(asExpression(config.get("target").defaultTo( 841 format("${attributes.%s}", DEFAULT_TOKEN_KEY)), Object.class)); 842 final JsonValue registration = getWithDeprecation(config, logger, "registration", "clientRegistrationName"); 843 if (registration.isNotNull()) { 844 filter.setClientRegistration(heap.resolve(registration.required(), 845 ClientRegistration.class)); 846 } else { 847 final Handler loginHandler = heap.resolve(config.get("loginHandler").required(), Handler.class, true); 848 filter.setLoginHandler(loginHandler); 849 } 850 filter.setFailureHandler(heap.resolve(config.get("failureHandler"), 851 Handler.class)); 852 filter.setDefaultLoginGoto(asExpression(config.get("defaultLoginGoto"), String.class)); 853 filter.setDefaultLogoutGoto(asExpression(config.get("defaultLogoutGoto"), String.class)); 854 filter.setRequireHttps(config.get("requireHttps").defaultTo(true).asBoolean()); 855 filter.setRequireLogin(config.get("requireLogin").defaultTo(true).asBoolean()); 856 // Build the cache of user-info 857 Duration expiration = duration(config.get("cacheExpiration").defaultTo("20 seconds").asString()); 858 if (!expiration.isZero()) { 859 executor = Executors.newSingleThreadScheduledExecutor(); 860 cache = new ThreadSafeCache<>(executor); 861 cache.setDefaultTimeout(expiration); 862 filter.setUserInfoCache(cache); 863 } 864 865 return filter; 866 } 867 868 @Override 869 public void destroy() { 870 if (executor != null) { 871 executor.shutdownNow(); 872 } 873 if (cache != null) { 874 cache.clear(); 875 } 876 } 877 } 878 879 /** 880 * UserInfoFactory is responsible to load the profile of the authenticated user 881 * from the OpenID Connect Provider's user_info endpoint when the lazy map is accessed for the first time. 882 * If a cache has been configured 883 */ 884 private class UserInfoFactory implements Factory<Map<String, Object>> { 885 886 private final LoadUserInfoCallable callable; 887 888 public UserInfoFactory(final OAuth2Session session, 889 final ClientRegistration clientRegistration, 890 final Context context, 891 final Request request) { 892 this.callable = new LoadUserInfoCallable(session, clientRegistration, context, request); 893 } 894 895 @Override 896 public Map<String, Object> newInstance() { 897 /* 898 * When the 'user_info' attribute is accessed for the first time, 899 * try to load the value (from the cache or not depending on the configuration). 900 * The callable (factory for loading user info resource) will perform the appropriate HTTP request 901 * to retrieve the user info as JSON, and then will return that content as a Map 902 */ 903 904 if (userInfoCache == null) { 905 // No cache is configured, go directly though the callable 906 try { 907 return callable.call(); 908 } catch (Exception e) { 909 logger.warning(format("Unable to call UserInfo Endpoint from client registration '%s'", 910 callable.getClientRegistration().getName())); 911 logger.warning(e); 912 } 913 } else { 914 // A cache is configured, extract the value from the cache 915 try { 916 return userInfoCache.getValue(callable.getSession().getAccessToken(), 917 callable); 918 } catch (InterruptedException e) { 919 logger.warning(format("Interrupted when calling UserInfo Endpoint from client registration '%s'", 920 callable.getClientRegistration().getName())); 921 logger.warning(e); 922 } catch (ExecutionException e) { 923 logger.warning(format("Unable to call UserInfo Endpoint from client registration '%s'", 924 callable.getClientRegistration().getName())); 925 logger.warning(e); 926 } 927 } 928 929 // In case of errors, returns an empty Map 930 return emptyMap(); 931 } 932 } 933 934 /** 935 * LoadUserInfoCallable simply encapsulate the logic required to load the user_info resources. 936 */ 937 private class LoadUserInfoCallable implements Callable<Map<String, Object>> { 938 private OAuth2Session session; 939 private final ClientRegistration clientRegistration; 940 private final Context context; 941 private final Request request; 942 943 public LoadUserInfoCallable(final OAuth2Session session, 944 final ClientRegistration clientRegistration, 945 final Context context, 946 final Request request) { 947 this.session = session; 948 this.clientRegistration = clientRegistration; 949 this.context = context; 950 this.request = request; 951 } 952 953 @Override 954 public Map<String, Object> call() throws Exception { 955 try { 956 return clientRegistration.getUserInfo(context, session).asMap(); 957 } catch (OAuth2ErrorException e) { 958 final OAuth2Error error = e.getOAuth2Error(); 959 if (error.is(E_INVALID_TOKEN) && clientRegistration != null && session.getRefreshToken() != null) { 960 // Supposed expired token, try to update it by generating a new access token. 961 return updateSessionStateWithRefreshTokenOrFailWithNewSession(); 962 } 963 throw e; 964 } 965 } 966 967 private Map<String, Object> updateSessionStateWithRefreshTokenOrFailWithNewSession() throws ResponseException, 968 OAuth2ErrorException { 969 try { 970 JsonValue refreshAccessToken = clientRegistration.refreshAccessToken(context, session); 971 session = session.stateRefreshed(refreshAccessToken); 972 saveSession(context, session, buildUri(context, request, clientEndpoint)); 973 return clientRegistration.getUserInfo(context, session).asMap(); 974 } catch (OAuth2ErrorException ex) { 975 logger.debug("Fail to refresh OAuth2 Access Token"); 976 logger.debug(ex); 977 session = OAuth2Session.stateNew(time); 978 saveSession(context, session, buildUri(context, request, clientEndpoint)); 979 throw ex; 980 } 981 } 982 983 public OAuth2Session getSession() { 984 return session; 985 } 986 987 public ClientRegistration getClientRegistration() { 988 return clientRegistration; 989 } 990 } 991}