001/* 002 * Licensed to the Apache Software Foundation (ASF) under one or more 003 * contributor license agreements. See the NOTICE file distributed with 004 * this work for additional information regarding copyright ownership. 005 * The ASF licenses this file to You under the Apache License, Version 2.0 006 * (the "License"); you may not use this file except in compliance with 007 * the License. You may obtain a copy of the License at 008 * 009 * http://www.apache.org/licenses/LICENSE-2.0 010 * 011 * Unless required by applicable law or agreed to in writing, software 012 * distributed under the License is distributed on an "AS IS" BASIS, 013 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 014 * See the License for the specific language governing permissions and 015 * limitations under the License. 016 */ 017package org.apache.juneau.rest.server.auth.oidc.rp; 018 019import static org.apache.juneau.commons.utils.Shorts.*; 020import static org.apache.juneau.commons.utils.StringUtils.*; 021 022import java.io.*; 023import java.net.*; 024import java.security.*; 025import java.time.*; 026import java.util.*; 027import java.util.function.*; 028 029import org.apache.juneau.commons.utils.*; 030import org.apache.juneau.rest.server.auth.*; 031import org.apache.juneau.rest.auth.oauth.flow.*; 032import org.apache.juneau.rest.auth.oauth.oidc.*; 033 034import com.nimbusds.jose.*; 035import com.nimbusds.jose.jwk.*; 036import com.nimbusds.jose.jwk.source.*; 037import com.nimbusds.jose.proc.*; 038import com.nimbusds.jwt.*; 039import com.nimbusds.jwt.proc.*; 040import com.nimbusds.oauth2.sdk.http.*; 041import com.nimbusds.oauth2.sdk.id.*; 042import com.nimbusds.oauth2.sdk.pkce.*; 043import com.nimbusds.oauth2.sdk.token.*; 044import com.nimbusds.openid.connect.sdk.*; 045import com.nimbusds.openid.connect.sdk.claims.*; 046import com.nimbusds.openid.connect.sdk.validators.*; 047 048import jakarta.servlet.http.*; 049 050/** 051 * Facade orchestrating an OpenID Connect Authorization-Code-with-PKCE <i>Relying Party</i> login flow on 052 * top of the {@code juneau-rest-server-auth-oauth} building blocks. 053 * 054 * <p> 055 * A single instance wires the login / callback / logout endpoints plus the session SPI for one IdP. The 056 * application mounts the three endpoints as explicit {@code @RestGet} methods that delegate to this 057 * facade — there are no auto-mounted servlet routes (URL ownership stays with the app): 058 * 059 * <p class='bjava'> 060 * <ja>@Rest</ja>(path=<js>"/auth"</js>) 061 * <jk>public class</jk> LoginResource <jk>extends</jk> BasicRestServlet { 062 * 063 * <jk>private final</jk> OidcRelyingParty <jf>rp</jf> = OidcRelyingParty.<jsm>create</jsm>() 064 * .issuer(URI.<jsm>create</jsm>(<js>"https://accounts.google.com"</js>)) 065 * .clientId(<js>"web-app"</js>) 066 * .clientSecret(<jv>env</jv>(<js>"OIDC_CLIENT_SECRET"</js>)) 067 * .redirectUri(URI.<jsm>create</jsm>(<js>"https://app.example.com/auth/callback"</js>)) 068 * .scope(<js>"openid"</js>, <js>"profile"</js>, <js>"email"</js>) 069 * .sessionStore(SignedCookieSessionStore.<jsm>create</jsm>().signingKey(<jv>env</jv>(<js>"SESSION_KEY"</js>)).build()) 070 * .build(); 071 * 072 * <ja>@RestGet</ja>(path=<js>"/login"</js>) 073 * <jk>public void</jk> login(RestRequest <jv>req</jv>, RestResponse <jv>res</jv>) <jk>throws</jk> Exception { 074 * <jf>rp</jf>.startLogin(<jv>req</jv>, <jv>res</jv>); 075 * } 076 * 077 * <ja>@RestGet</ja>(path=<js>"/callback"</js>) 078 * <jk>public void</jk> callback(RestRequest <jv>req</jv>, RestResponse <jv>res</jv>) <jk>throws</jk> Exception { 079 * <jf>rp</jf>.completeLogin(<jv>req</jv>, <jv>res</jv>); 080 * } 081 * 082 * <ja>@RestGet</ja>(path=<js>"/logout"</js>) 083 * <jk>public void</jk> logout(RestRequest <jv>req</jv>, RestResponse <jv>res</jv>) <jk>throws</jk> Exception { 084 * <jf>rp</jf>.logout(<jv>req</jv>, <jv>res</jv>); 085 * } 086 * } 087 * </p> 088 * 089 * <p> 090 * Register {@link #authFilter()} in the servlet filter chain (or {@code AuthFilterChain}) so each request 091 * resolves the session cookie into a {@link ClaimsPrincipal} for {@code RoleBasedRestGuard} / 092 * {@code @Auth Principal}. 093 * 094 * <h5 class='section'>Security</h5> 095 * <ul> 096 * <li>{@code state} and {@code nonce} are single-use and TTL-bounded (default 5 min); a 097 * missing / replayed value fails the callback. 098 * <li>PKCE S256 is enforced end-to-end. 099 * <li>The ID token is validated for signature, {@code iss}, {@code aud}/{@code azp}, {@code exp}, and 100 * {@code nonce} (see {@link IdTokenValidatorAdapter}). 101 * <li>The session id is freshly generated on login (no pre-auth fixation). 102 * <li>Auth responses set {@code Cache-Control: no-store}; tokens are never logged. 103 * <li>The session cookie is {@code HttpOnly} + {@code Secure} + {@code SameSite} by default. 104 * </ul> 105 * 106 * <h5 class='section'>See Also:</h5><ul> 107 * <li class='jc'>{@link SessionStore} 108 * <li class='jc'>{@link OidcSessionAuthFilter} 109 * <li class='link'><a class="doclink" href="https://juneau.apache.org/docs/topics/OidcRelyingParty">OIDC Relying Party Login (juneau-rest-server-auth-oidc-rp)</a> 110 * <li class='link'><a class="doclink" href="https://openid.net/specs/openid-connect-core-1_0.html">OpenID Connect Core 1.0</a> 111 * </ul> 112 * 113 * @since 10.0.0 114 */ 115@SuppressWarnings({ 116 "java:S1192", // Duplicate string literals are OIDC protocol parameter names and claim names; intentional 117 "java:S3077", // The lazily built metadata/code-flow/validator/JWK/logout-processor caches are volatile references to effectively immutable objects, published once without compound updates 118 "java:S9358" // Ternary chooses a different constructor/null vs value; moving it inside one call is not equivalent. 119}) 120public class OidcRelyingParty { 121 122 private static final String DEFAULT_COOKIE_NAME = "JUNEAU_OIDC_SESSION"; 123 private static final String DEFAULT_ROLES_CLAIM = "scope"; 124 private static final Duration DEFAULT_SESSION_TTL = Duration.ofHours(8); 125 126 /** 127 * Static creator. 128 * 129 * @return A new builder. 130 */ 131 public static Builder create() { 132 return new Builder(); 133 } 134 135 /** 136 * Builder. 137 */ 138 public static class Builder { 139 URI issuer; 140 OidcMetadata metadata; 141 String clientId; 142 Supplier<String> clientSecretSupplier; 143 URI redirectUri; 144 URI postLogoutRedirectUri; 145 String postLoginRedirect = "/"; 146 Set<String> scopes = st(); 147 SessionStore sessionStore; 148 LoginStateStore loginStateStore; 149 String rolesClaim = DEFAULT_ROLES_CLAIM; 150 Duration stateNonceTtl = InMemoryLoginStateStore.DEFAULT_TTL; 151 int ephemeralMaxEntries = InMemoryLoginStateStore.DEFAULT_MAX_ENTRIES; 152 Duration sessionTtl = DEFAULT_SESSION_TTL; 153 String cookieName = DEFAULT_COOKIE_NAME; 154 boolean cookieSecure = true; 155 boolean cookieHttpOnly = true; 156 String cookieSameSite = "Lax"; 157 Consumer<HTTPRequest> httpRequestConfigurator; 158 Consumer<AuthenticationRequest.Builder> authenticationRequestCustomizer; 159 JWSAlgorithm[] idTokenAlgorithms; 160 int clockSkewSeconds = 60; 161 JWKSet jwkSet; 162 JWKSource<SecurityContext> jwkSource; 163 Set<String> userInfoClaims = st(); 164 Clock clock = Clock.systemUTC(); 165 166 /** Constructor. */ 167 protected Builder() {} 168 169 /** 170 * Sets the IdP issuer URL. OIDC discovery resolves the endpoints + JWKS URI lazily. Either 171 * this or {@link #metadata(OidcMetadata)} is required. 172 * 173 * <p> 174 * The issuer (and every endpoint discovered from it) must use <js>"https"</js> or target a loopback 175 * host; discovery, JWKS, and token exchanges carry trust-anchor key material and client credentials, so 176 * a plaintext transport to a non-loopback host is rejected. 177 * 178 * @param value The issuer URL. Must not be <jk>null</jk> and must use HTTPS or target a loopback host. 179 * @return This object. 180 */ 181 public Builder issuer(URI value) { 182 issuer = UriUtils.assertSecureOrLoopback(reqnn("value", value)); 183 return this; 184 } 185 186 /** 187 * Sets the IdP metadata explicitly, bypassing discovery. Either this or {@link #issuer(URI)} 188 * is required. 189 * 190 * @param value The metadata. Must not be <jk>null</jk>. 191 * @return This object. 192 */ 193 public Builder metadata(OidcMetadata value) { 194 metadata = reqnn("value", value); 195 return this; 196 } 197 198 /** 199 * Sets the OAuth client id. Required. 200 * 201 * @param value The client id. Must not be <jk>null</jk> or blank. 202 * @return This object. 203 */ 204 public Builder clientId(String value) { 205 clientId = reqnb("value", value); 206 return this; 207 } 208 209 /** 210 * Sets the client secret. May be omitted for public clients (PKCE-only). 211 * 212 * @param value The client secret. Must not be <jk>null</jk> or blank. 213 * @return This object. 214 */ 215 public Builder clientSecret(String value) { 216 reqnb("value", value); 217 clientSecretSupplier = () -> value; 218 return this; 219 } 220 221 /** 222 * Sets the client secret via a supplier. 223 * 224 * @param value The supplier. Must not be <jk>null</jk>. 225 * @return This object. 226 */ 227 public Builder clientSecretSupplier(Supplier<String> value) { 228 clientSecretSupplier = reqnn("value", value); 229 return this; 230 } 231 232 /** 233 * Sets the redirect URI registered with the IdP. Required. 234 * 235 * @param value The redirect URI. Must not be <jk>null</jk>. 236 * @return This object. 237 */ 238 public Builder redirectUri(URI value) { 239 redirectUri = reqnn("value", value); 240 return this; 241 } 242 243 /** 244 * Sets the post-logout redirect URI passed to the IdP end-session endpoint. 245 * 246 * @param value The post-logout redirect URI. Must not be <jk>null</jk>. 247 * @return This object. 248 */ 249 public Builder postLogoutRedirectUri(URI value) { 250 postLogoutRedirectUri = reqnn("value", value); 251 return this; 252 } 253 254 /** 255 * Sets the default post-login redirect target (when the login request supplied no safe 256 * {@code redirect} parameter). Defaults to {@code "/"}. 257 * 258 * @param value The default redirect path. Must not be <jk>null</jk> or blank. 259 * @return This object. 260 */ 261 public Builder postLoginRedirect(String value) { 262 postLoginRedirect = reqnb("value", value); 263 return this; 264 } 265 266 /** 267 * Adds requested scopes. {@code openid} is always included. 268 * 269 * @param values The scopes. 270 * @return This object. 271 */ 272 public Builder scope(String...values) { 273 reqnn("values", values); 274 for (var v : values) { 275 reqnb("scope", v); 276 scopes.add(v); 277 } 278 return this; 279 } 280 281 /** 282 * Sets the session store. Required. 283 * 284 * @param value The session store. Must not be <jk>null</jk>. 285 * @return This object. 286 */ 287 public Builder sessionStore(SessionStore value) { 288 sessionStore = reqnn("value", value); 289 return this; 290 } 291 292 /** 293 * Sets the claim roles are extracted from. Defaults to {@code "scope"} (whitespace-split); 294 * array claims are also accepted. Mirrors the {@code OAuthFilter} convention. 295 * 296 * @param value The claim name. Must not be <jk>null</jk> or blank. 297 * @return This object. 298 */ 299 public Builder rolesClaim(String value) { 300 rolesClaim = reqnb("value", value); 301 return this; 302 } 303 304 /** 305 * Sets the single-use TTL the framework enforces for the login-state ({@code state} / {@code nonce} / 306 * PKCE {@code code_verifier}) association. Defaults to 5 minutes. 307 * 308 * <p> 309 * This is the RP-enforced policy applied to <b>every</b> consumed record — both the default 310 * {@link InMemoryLoginStateStore} and any custom {@link #loginStateStore(LoginStateStore) injected store}. 311 * The framework mints {@code expiresAt = createdAt + stateNonceTtl} on the record and re-checks it on 312 * consume, so the ceiling holds even for a shared store that fails to expire an entry. The 313 * {@code (0, }{@link LoginStateStore#MAX_TTL}{@code ]} cap is enforced here (not only in the default 314 * impl's constructor, which is skipped when a custom store is injected). 315 * 316 * @param value The TTL. Must be positive and not exceed {@link LoginStateStore#MAX_TTL} (30 minutes). 317 * @return This object. 318 */ 319 public Builder stateNonceTtl(Duration value) { 320 reqnn("value", value); 321 req(!value.isZero() && !value.isNegative(), "stateNonceTtl must be positive"); 322 req(value.compareTo(LoginStateStore.MAX_TTL) <= 0, "stateNonceTtl must not exceed 30 minutes (was %s)", value); 323 stateNonceTtl = value; 324 return this; 325 } 326 327 /** 328 * Sets an optional custom {@link LoginStateStore} for the pending-login ({@code state} → 329 * {@code nonce} / PKCE {@code code_verifier} / redirect) association. 330 * 331 * <p> 332 * Optional (unlike {@link #sessionStore(SessionStore)}): when omitted the RP uses the shipped per-JVM 333 * {@link InMemoryLoginStateStore}, and behavior is identical to a single-node deployment. Supply a 334 * shared (Redis / JDBC) implementation to support a clustered deployment where {@code /login} and 335 * {@code /callback} may land on different nodes without sticky sessions — the same motivation as a 336 * distributed {@link SessionStore}. 337 * 338 * <p> 339 * <b>The store is secret-bearing</b> ({@code code_verifier} + {@code nonce}); read the 340 * {@link LoginStateStore} security contract before implementing one (TLS + locked-down ACL floor, 341 * encryption-at-rest recommended, never log the payload, atomic {@code GETDEL}/Lua single-use consume). 342 * The framework retains authority over the security-critical decisions regardless of the store: it 343 * re-validates {@code redirectTarget} via {@code safeRelativePath} and re-checks {@code expiresAt} 344 * against its own {@link #clock(Clock) clock} on consume, and enforces the 345 * {@link #stateNonceTtl(Duration)} ceiling — the store persists the record and enforces single-use, 346 * but is treated as a blob store, not a trusted oracle. 347 * 348 * @param value The login-state store. Must not be <jk>null</jk>. 349 * @return This object. 350 */ 351 public Builder loginStateStore(LoginStateStore value) { 352 loginStateStore = reqnn("value", value); 353 return this; 354 } 355 356 /** 357 * Sets the session lifetime (bounded independently of token lifetime). Defaults to 8 hours. 358 * 359 * @param value The session TTL. Must be positive. 360 * @return This object. 361 */ 362 public Builder sessionTtl(Duration value) { 363 reqnn("value", value); 364 req(!value.isZero() && !value.isNegative(), "sessionTtl must be positive"); // HTT: JaCoCo bytecode artifact; zero and negative are tested by m01/m02 but one short-circuit branch edge remains instrumented 365 sessionTtl = value; 366 return this; 367 } 368 369 /** 370 * Sets the session cookie name. Defaults to {@code JUNEAU_OIDC_SESSION}. 371 * 372 * @param value The cookie name. Must not be <jk>null</jk> or blank. 373 * @return This object. 374 */ 375 public Builder cookieName(String value) { 376 cookieName = reqnb("value", value); 377 return this; 378 } 379 380 /** 381 * Sets the cookie {@code Secure} flag. Defaults to <jk>true</jk> (fail-closed). 382 * 383 * @param value The flag. 384 * @return This object. 385 */ 386 public Builder cookieSecure(boolean value) { 387 cookieSecure = value; 388 return this; 389 } 390 391 /** 392 * Sets the cookie {@code HttpOnly} flag. Defaults to <jk>true</jk> (fail-closed). 393 * 394 * @param value The flag. 395 * @return This object. 396 */ 397 public Builder cookieHttpOnly(boolean value) { 398 cookieHttpOnly = value; 399 return this; 400 } 401 402 /** 403 * Sets the cookie {@code SameSite} attribute. Defaults to {@code "Lax"}. 404 * 405 * @param value The SameSite value (e.g. {@code "Lax"} or {@code "Strict"}). Must not be 406 * <jk>null</jk> or blank. 407 * @return This object. 408 */ 409 public Builder cookieSameSite(String value) { 410 cookieSameSite = reqnb("value", value); 411 return this; 412 } 413 414 /** 415 * Sets a Nimbus {@link HTTPRequest} configurator escape hatch (proxy / TLS / timeouts) applied 416 * to discovery + token + userinfo calls. 417 * 418 * @param value The callback. Must not be <jk>null</jk>. 419 * @return This object. 420 */ 421 public Builder httpRequestConfigurator(Consumer<HTTPRequest> value) { 422 httpRequestConfigurator = reqnn("value", value); 423 return this; 424 } 425 426 /** 427 * Sets a Nimbus {@link com.nimbusds.openid.connect.sdk.AuthenticationRequest.Builder} customizer escape hatch (e.g. {@code prompt}, 428 * {@code max_age}, {@code acr_values}) applied to the authorization redirect. 429 * 430 * @param value The callback. Must not be <jk>null</jk>. 431 * @return This object. 432 */ 433 public Builder authenticationRequestCustomizer(Consumer<AuthenticationRequest.Builder> value) { 434 authenticationRequestCustomizer = reqnn("value", value); 435 return this; 436 } 437 438 /** 439 * Sets the ID-token signing-algorithm allowlist. Defaults to {@code [RS256, ES256]}. 440 * 441 * @param values The allowed algorithms. 442 * @return This object. 443 */ 444 public Builder idTokenAlgorithms(JWSAlgorithm...values) { 445 idTokenAlgorithms = reqnn("values", values); 446 return this; 447 } 448 449 /** 450 * Sets the clock-skew tolerance (seconds) for ID-token validation. Defaults to 60. 451 * 452 * @param value The tolerance in seconds. Must be non-negative. 453 * @return This object. 454 */ 455 public Builder clockSkewSeconds(int value) { 456 req(value >= 0, "clockSkewSeconds must be non-negative (was %s)", value); 457 clockSkewSeconds = value; 458 return this; 459 } 460 461 /** 462 * Injects a fixed JWK set for ID-token / logout-token signature verification, bypassing the 463 * JWKS endpoint. Useful for tests and static-key deployments. 464 * 465 * @param value The JWK set. Must not be <jk>null</jk>. 466 * @return This object. 467 */ 468 public Builder jwkSet(JWKSet value) { 469 jwkSet = reqnn("value", value); 470 return this; 471 } 472 473 /** 474 * Injects a custom JWK source for ID-token / logout-token signature verification. 475 * 476 * @param value The JWK source. Must not be <jk>null</jk>. 477 * @return This object. 478 */ 479 public Builder jwkSource(JWKSource<SecurityContext> value) { 480 jwkSource = reqnn("value", value); 481 return this; 482 } 483 484 /** 485 * Adds claim names to backfill from the UserInfo endpoint when absent from the ID token. 486 * 487 * <p> 488 * When empty (the default) the UserInfo endpoint is never called — the {@link Principal} 489 * is built from ID-token claims only. 490 * 491 * @param values The claim names to backfill. 492 * @return This object. 493 */ 494 public Builder userInfoClaims(String...values) { 495 reqnn("values", values); 496 for (var v : values) { 497 reqnb("claim", v); 498 userInfoClaims.add(v); 499 } 500 return this; 501 } 502 503 /** 504 * Overrides the clock used for session expiry and the login-state store. Useful in tests. 505 * 506 * <p> 507 * The login-state {@code expiresAt} is both minted (on {@code startLogin}) and re-checked (on 508 * {@code completeLogin}) against this clock. In a clustered deployment with a shared 509 * {@link #loginStateStore(LoginStateStore) LoginStateStore}, the ceiling is therefore only as 510 * trustworthy as clock synchronization across nodes; a backend {@code EXPIRE}/TTL is a GC hint, not the 511 * authoritative expiry decision. 512 * 513 * @param value The clock. Must not be <jk>null</jk>. 514 * @return This object. 515 */ 516 public Builder clock(Clock value) { 517 clock = reqnn("value", value); 518 return this; 519 } 520 521 /** 522 * Builds the relying party. 523 * 524 * @return A new {@link OidcRelyingParty}. 525 */ 526 public OidcRelyingParty build() { 527 if (issuer == null && metadata == null) 528 throw new IllegalStateException("OidcRelyingParty requires issuer(...) or metadata(...)"); 529 if (clientId == null) 530 throw new IllegalStateException("OidcRelyingParty requires clientId(...)"); 531 if (redirectUri == null) 532 throw new IllegalStateException("OidcRelyingParty requires redirectUri(...)"); 533 if (sessionStore == null) 534 throw new IllegalStateException("OidcRelyingParty requires sessionStore(...)"); 535 return new OidcRelyingParty(this); 536 } 537 } 538 539 private final URI issuer; 540 private final String clientId; 541 private final Supplier<String> clientSecretSupplier; 542 private final URI redirectUri; 543 private final URI postLogoutRedirectUri; 544 private final String postLoginRedirect; 545 private final Set<String> scopes; 546 private final SessionStore sessionStore; 547 private final String rolesClaim; 548 private final Duration sessionTtl; 549 private final String cookieName; 550 private final boolean cookieSecure; 551 private final boolean cookieHttpOnly; 552 private final String cookieSameSite; 553 private final Consumer<HTTPRequest> httpRequestConfigurator; 554 private final Consumer<AuthenticationRequest.Builder> authenticationRequestCustomizer; 555 private final JWSAlgorithm[] idTokenAlgorithms; 556 private final int clockSkewSeconds; 557 private final JWKSet jwkSet; 558 private final JWKSource<SecurityContext> injectedJwkSource; 559 private final Set<String> userInfoClaims; 560 private final Clock clock; 561 private final Duration stateNonceTtl; 562 private final LoginStateStore loginStateStore; 563 564 private volatile OidcMetadata metadataCache; 565 private final OidcMetadata explicitMetadata; 566 private volatile OAuthAuthorizationCodeFlow codeFlowCache; 567 private volatile IdTokenValidatorAdapter idTokenValidatorCache; 568 private volatile JWKSource<SecurityContext> jwkSourceCache; 569 private volatile ConfigurableJWTProcessor<SecurityContext> logoutTokenProcessorCache; 570 571 /** 572 * Constructor. 573 * 574 * @param b The builder. 575 */ 576 protected OidcRelyingParty(Builder b) { 577 this.issuer = b.issuer; 578 this.explicitMetadata = b.metadata; 579 this.clientId = b.clientId; 580 this.clientSecretSupplier = b.clientSecretSupplier; 581 this.redirectUri = b.redirectUri; 582 this.postLogoutRedirectUri = b.postLogoutRedirectUri; 583 this.postLoginRedirect = b.postLoginRedirect; 584 var s = new LinkedHashSet<String>(); 585 s.add("openid"); 586 s.addAll(b.scopes); 587 this.scopes = u(s); 588 this.sessionStore = b.sessionStore; 589 this.rolesClaim = b.rolesClaim; 590 this.sessionTtl = b.sessionTtl; 591 this.cookieName = b.cookieName; 592 this.cookieSecure = b.cookieSecure; 593 this.cookieHttpOnly = b.cookieHttpOnly; 594 this.cookieSameSite = b.cookieSameSite; 595 this.httpRequestConfigurator = b.httpRequestConfigurator; 596 this.authenticationRequestCustomizer = b.authenticationRequestCustomizer; 597 this.idTokenAlgorithms = b.idTokenAlgorithms; 598 this.clockSkewSeconds = b.clockSkewSeconds; 599 this.jwkSet = b.jwkSet; 600 this.injectedJwkSource = b.jwkSource; 601 this.userInfoClaims = u(cp(b.userInfoClaims)); 602 this.clock = b.clock; 603 this.stateNonceTtl = b.stateNonceTtl; 604 this.loginStateStore = b.loginStateStore != null 605 ? b.loginStateStore 606 : new InMemoryLoginStateStore(b.stateNonceTtl, b.ephemeralMaxEntries, b.clock); 607 } 608 609 //----------------------------------------------------------------------------------------------------------------- 610 // Public flow methods 611 //----------------------------------------------------------------------------------------------------------------- 612 613 /** 614 * Begins the login flow: generates {@code state} + {@code nonce} + PKCE verifier, stores them 615 * single-use, and redirects the user-agent to the IdP authorization endpoint. 616 * 617 * @param req The HTTP request. An optional {@code redirect} query parameter (a safe relative path) 618 * is remembered as the post-login target. 619 * @param res The HTTP response. 620 * @throws IOException If the redirect cannot be written. 621 */ 622 public void startLogin(HttpServletRequest req, HttpServletResponse res) throws IOException { 623 reqnn("req", req); 624 reqnn("res", res); 625 var state = new State().getValue(); 626 var nonce = new Nonce().getValue(); 627 var verifier = new CodeVerifier(); 628 var challenge = CodeChallenge.compute(CodeChallengeMethod.S256, verifier); 629 var redirectTarget = safeRelativePath(req.getParameter("redirect")); 630 var createdAt = clock.instant(); 631 var pending = new LoginStateStore.PendingLogin(nonce, verifier.getValue(), redirectTarget, createdAt, createdAt.plus(stateNonceTtl)); 632 try { 633 loginStateStore.store(state, pending); 634 } catch (RuntimeException e) { 635 // Fail closed before any authorization URL is issued. Do NOT chain the raw backend exception as the 636 // cause: a shared-store backend may echo the state/code_verifier into its own message, and 637 // AuthenticationException publishes the cause via getCause(), so a raw chain would re-expose the secret. 638 throw new AuthenticationException("OIDC login-state store unavailable"); 639 } 640 var authUrl = codeFlow().buildAuthenticationUrl(state, challenge, nonce, authenticationRequestCustomizer); 641 noStore(res); 642 res.sendRedirect(authUrl.toString()); 643 } 644 645 /** 646 * Completes the login flow: parses the callback, verifies + consumes {@code state}, exchanges the 647 * code (PKCE), validates the ID token (signature / {@code iss} / {@code aud} / {@code exp} / 648 * {@code nonce}), creates a session, sets the session cookie, and redirects to the application. 649 * 650 * @param req The callback HTTP request (carrying {@code code} + {@code state}). 651 * @param res The HTTP response. 652 * @throws IOException If the redirect cannot be written. 653 * @throws AuthenticationException If the callback is an error, the state is missing / replayed, or 654 * ID-token validation fails. 655 */ 656 public void completeLogin(HttpServletRequest req, HttpServletResponse res) throws IOException, AuthenticationException { 657 reqnn("req", req); 658 reqnn("res", res); 659 660 var callbackUri = fullRequestUri(req); 661 AuthenticationResponse parsed; 662 try { 663 parsed = AuthenticationResponseParser.parse(callbackUri); 664 } catch (com.nimbusds.oauth2.sdk.ParseException e) { 665 throw new AuthenticationException(e, "OIDC callback could not be parsed"); 666 } 667 if (! parsed.indicatesSuccess()) { 668 var err = parsed.toErrorResponse().getErrorObject(); 669 throw new AuthenticationException("OIDC callback returned an error: %s", err == null ? "unknown" : err.getCode()); // HTT: err==null branch requires an error response with no error object, which Nimbus never produces 670 } 671 var success = parsed.toSuccessResponse(); 672 if (success.getAuthorizationCode() == null) 673 throw new AuthenticationException("OIDC callback contained no authorization code"); 674 var code = success.getAuthorizationCode().getValue(); 675 var state = success.getState() == null ? null : success.getState().getValue(); // HTT: null state branch: AuthCode response always carries state per PKCE flow; null branch is defensive dead code 676 677 Optional<LoginStateStore.PendingLogin> consumed; 678 try { 679 consumed = loginStateStore.consume(state); 680 } catch (RuntimeException e) { 681 // Infra failure (backend unreachable) is distinct from a CSRF/replay miss; reject as unavailable 682 // without leaking the payload (do not chain the raw backend exception as cause). 683 throw new AuthenticationException("OIDC login-state store unavailable"); 684 } 685 var pending = consumed.orElseThrow( 686 () -> new AuthenticationException("OIDC callback state is missing, expired, or already used")); 687 688 // Fail-closed expiry re-check BEFORE the token exchange: the framework owns the TTL ceiling independently 689 // of the store, so an honest store that failed to expire an over-age record cannot extend usability. Null 690 // createdAt/expiresAt (a foreign/deserialized blob) is treated as expired, not an NPE->500. 691 if (pending.createdAt() == null || pending.expiresAt() == null || ! clock.instant().isBefore(pending.expiresAt())) 692 throw new AuthenticationException("OIDC callback state is missing, expired, or already used"); 693 694 OAuthToken token; 695 try { 696 token = codeFlow().exchange(code, new CodeVerifier(pending.codeVerifier())); 697 } catch (OAuthFlowException e) { 698 throw new AuthenticationException(e, "OIDC token exchange failed"); 699 } 700 var idToken = token.idToken().orElseThrow( 701 () -> new AuthenticationException("IdP token response contained no id_token")); 702 703 var principal = idTokenValidator().validate(idToken, pending.nonce()); 704 principal = maybeBackfillUserInfo(principal, token); 705 706 var subject = principal.getName(); 707 var sid = principal.getClaim("sid", String.class); 708 var roles = extractRoles(principal); 709 var now = clock.instant(); 710 var session = new OidcSession( 711 new State().getValue(), 712 subject, 713 sid, 714 principal, 715 roles, 716 o(token), 717 now, 718 now.plus(sessionTtl)); 719 720 var cookieValue = sessionStore.createSessionCookieValue(session); 721 noStore(res); 722 res.addHeader("Set-Cookie", buildSetCookie(cookieValue, sessionTtl.toSeconds())); 723 // Re-validate the redirect target on consume: a writable/compromised shared store cannot inject an open 724 // redirect. A failing re-validation falls back to the operator-configured default (not itself re-sanitized). 725 res.sendRedirect(or(safeRelativePath(pending.redirectTarget()), postLoginRedirect)); 726 } 727 728 /** 729 * Logs the user out: invalidates the session, clears the session cookie, and (when the IdP 730 * advertises an end-session endpoint) redirects through it; otherwise redirects to the 731 * post-logout target. 732 * 733 * @param req The HTTP request. 734 * @param res The HTTP response. 735 * @throws IOException If the redirect cannot be written. 736 */ 737 public void logout(HttpServletRequest req, HttpServletResponse res) throws IOException { 738 reqnn("req", req); 739 reqnn("res", res); 740 var cookieValue = readCookie(req); 741 JWT idTokenHint = null; 742 if (cookieValue != null) { 743 idTokenHint = idTokenHintFromSession(cookieValue); 744 sessionStore.invalidate(cookieValue); 745 } 746 noStore(res); 747 res.addHeader("Set-Cookie", buildSetCookie("", 0)); 748 749 var endSession = metadata().endSessionEndpoint(); 750 if (endSession != null) { 751 var logoutReq = new LogoutRequest(endSession, idTokenHint, postLogoutRedirectUri, null); 752 res.sendRedirect(logoutReq.toURI().toString()); 753 } else { 754 res.sendRedirect(postLogoutRedirectUri != null ? postLogoutRedirectUri.toString() : postLoginRedirect); 755 } 756 } 757 758 /** 759 * Refreshes the current session's access token using the rotating-refresh-token grant. 760 * 761 * <p> 762 * Replaces the stored refresh token with the one the IdP returns; if the IdP rejects the refresh 763 * (e.g. a rotated token was reused / revoked), the session is invalidated. Only applies to stores 764 * that retain tokens (server-side); {@link SignedCookieSessionStore} sessions carry no tokens and 765 * yield {@link Optional#empty()}. 766 * 767 * @param req The HTTP request. 768 * @param res The HTTP response (a refreshed session resets the cookie). 769 * @return The refreshed session, or {@link Optional#empty()} if there was nothing to refresh. 770 */ 771 public Optional<OidcSession> refresh(HttpServletRequest req, HttpServletResponse res) { 772 reqnn("req", req); 773 reqnn("res", res); 774 var cookieValue = readCookie(req); 775 if (cookieValue == null) 776 return oe(); 777 var existing = sessionStore.lookup(cookieValue); 778 if (existing.isEmpty()) 779 return oe(); 780 var tokenOpt = existing.get().token(); 781 if (tokenOpt.isEmpty()) 782 return oe(); 783 var oldToken = tokenOpt.get(); 784 var refreshTokenOpt = oldToken.refreshToken(); 785 if (refreshTokenOpt.isEmpty()) 786 return oe(); 787 788 OAuthToken refreshed; 789 try { 790 var flowBuilder = OAuthRefreshTokenFlow.create() 791 .tokenEndpoint(metadata().tokenEndpoint()) 792 .clientId(clientId) 793 .refreshToken(refreshTokenOpt.get()); 794 if (clientSecretSupplier != null) // HTT: false branch (null secret = public client) reachable via refresh path only; no dedicated public-client refresh test 795 flowBuilder.clientSecretSupplier(clientSecretSupplier); 796 if (httpRequestConfigurator != null) // HTT: false branch (no configurator) unreachable in current refresh tests because the test RP always omits a configurator, so this block is never entered in tests 797 flowBuilder.httpRequestConfigurator(httpRequestConfigurator); 798 refreshed = flowBuilder.build().acquire(); 799 } catch (OAuthFlowException e) { 800 // Rotating-refresh-token reuse / revocation -> invalidate the session (fail-closed). 801 sessionStore.invalidate(cookieValue); 802 res.addHeader("Set-Cookie", buildSetCookie("", 0)); 803 return oe(); 804 } 805 806 var old = existing.get(); 807 var now = clock.instant(); 808 var newSession = new OidcSession( 809 new State().getValue(), 810 old.subject(), 811 old.sid(), 812 old.principal(), 813 old.roles(), 814 o(refreshed), 815 old.createdAt(), 816 now.plus(sessionTtl)); 817 sessionStore.invalidate(cookieValue); 818 var newCookie = sessionStore.createSessionCookieValue(newSession); 819 noStore(res); 820 res.addHeader("Set-Cookie", buildSetCookie(newCookie, sessionTtl.toSeconds())); 821 return o(newSession); 822 } 823 824 /** 825 * Processes an IdP-pushed OpenID Connect back-channel logout token, invalidating the matching 826 * server-side session(s). 827 * 828 * <p> 829 * The logout token is validated via Nimbus's {@link LogoutTokenValidator} (signature against the 830 * JWKS, {@code iss}, {@code aud}, the back-channel {@code events} claim, and the {@code sub} / 831 * {@code sid} presence rule). Sessions are then revoked by {@code sid} (preferred, single-session) 832 * or {@code sub} (all of a subject's sessions). 833 * 834 * <p> 835 * Requires a {@link SessionStore#supportsServerSideRevocation() server-side-revocable} store 836 * — the default {@link SignedCookieSessionStore} cannot satisfy back-channel logout because a 837 * stateless cookie is not server-revocable. 838 * 839 * @param logoutToken The raw {@code logout_token} JWT pushed by the IdP. Must not be <jk>null</jk> 840 * or blank. 841 * @return The number of sessions invalidated. 842 * @throws AuthenticationException If the logout token fails validation. 843 * @throws IllegalStateException If the configured session store does not support server-side 844 * revocation. 845 */ 846 public int backChannelLogout(String logoutToken) throws AuthenticationException { 847 reqnb("logoutToken", logoutToken); 848 if (! sessionStore.supportsServerSideRevocation()) 849 throw new IllegalStateException("Back-channel logout requires a server-side-revocable SessionStore (InMemorySessionStore or a distributed store); the configured store is stateless."); 850 JWT jwt; 851 try { 852 jwt = JWTParser.parse(logoutToken); 853 } catch (java.text.ParseException e) { 854 throw new AuthenticationException(e, "Logout token could not be parsed"); 855 } 856 LogoutTokenClaimsSet claims; 857 try { 858 var verified = logoutTokenProcessor().process(jwt, null); 859 claims = new LogoutTokenClaimsSet(verified); 860 } catch (com.nimbusds.jose.proc.BadJOSEException | com.nimbusds.jose.JOSEException e) { 861 throw new AuthenticationException(e, "Logout token validation failed: %s", e.getMessage()); 862 } catch (com.nimbusds.oauth2.sdk.ParseException e) { 863 throw new AuthenticationException(e, "Logout token claims could not be parsed"); 864 } 865 var sid = claims.getSessionID(); 866 if (sid != null) 867 return sessionStore.invalidateBySessionId(sid.getValue()); 868 var sub = claims.getSubject(); 869 if (sub != null) // HTT: false branch (sub==null with sid==null) rejected by LogoutTokenClaimsVerifier which requires at least one of sub or sid; defensive dead code 870 return sessionStore.invalidateBySubject(sub.getValue()); 871 return 0; 872 } 873 874 /** 875 * Returns a request-time {@link AuthFilter} that resolves this RP's session cookie into a 876 * {@link ClaimsPrincipal}. 877 * 878 * @return A new {@link OidcSessionAuthFilter} bound to this RP's session store + cookie name. 879 */ 880 public OidcSessionAuthFilter authFilter() { 881 return new OidcSessionAuthFilter(sessionStore, cookieName); 882 } 883 884 /** 885 * Returns the configured session store. 886 * 887 * @return The session store. 888 */ 889 public SessionStore sessionStore() { 890 return sessionStore; 891 } 892 893 /** 894 * Returns the session cookie name. 895 * 896 * @return The cookie name. 897 */ 898 public String cookieName() { 899 return cookieName; 900 } 901 902 //----------------------------------------------------------------------------------------------------------------- 903 // Internals 904 //----------------------------------------------------------------------------------------------------------------- 905 906 private OidcMetadata metadata() { 907 var m = metadataCache; 908 if (m != null) 909 return m; 910 synchronized (this) { 911 if (metadataCache != null) // HTT: DCL second-check; true branch requires concurrent initialization race 912 return metadataCache; 913 if (explicitMetadata != null) { // HTT: false branch = discovery path; all tests inject .metadata(...) directly so explicitMetadata is always non-null 914 metadataCache = validateEndpoints(explicitMetadata); 915 return metadataCache; 916 } 917 try { 918 var clientBuilder = OidcDiscoveryClient.create().issuer(issuer); 919 if (httpRequestConfigurator != null) // HTT: true branch requires discovery path (no explicit metadata) plus httpRequestConfigurator; all tests inject metadata directly 920 clientBuilder.httpRequestConfigurator(httpRequestConfigurator); 921 metadataCache = validateEndpoints(clientBuilder.build().discover()); 922 } catch (IOException | OidcDiscoveryException e) { 923 throw isex(e, "OIDC discovery failed for issuer %s", issuer); 924 } 925 return metadataCache; 926 } 927 } 928 929 /** 930 * Requires the token-bearing endpoints resolved from metadata (issuer, token, JWKS) to use https or a 931 * loopback host, so key material and client credentials are never exchanged over plaintext to a remote host. 932 */ 933 private static OidcMetadata validateEndpoints(OidcMetadata m) { 934 if (m.issuer() != null) // HTT: null issuer unreachable; OIDC metadata always carries an issuer 935 UriUtils.assertSecureOrLoopback(m.issuer()); 936 if (m.tokenEndpoint() != null) 937 UriUtils.assertSecureOrLoopback(m.tokenEndpoint()); 938 if (m.jwksUri() != null) 939 UriUtils.assertSecureOrLoopback(m.jwksUri()); 940 return m; 941 } 942 943 private OAuthAuthorizationCodeFlow codeFlow() { 944 var f = codeFlowCache; 945 if (f != null) 946 return f; 947 synchronized (this) { 948 if (codeFlowCache != null) // HTT: DCL second-check; true branch requires concurrent initialization race 949 return codeFlowCache; 950 var md = metadata(); 951 var b = OAuthAuthorizationCodeFlow.create() 952 .authorizationEndpoint(md.authorizationEndpoint()) 953 .tokenEndpoint(md.tokenEndpoint()) 954 .clientId(clientId) 955 .redirectUri(redirectUri) 956 .scope(scopes.toArray(new String[0])); 957 if (clientSecretSupplier != null) // HTT: false branch (public client with no secret) not covered; all test RPs set a client secret 958 b.clientSecretSupplier(clientSecretSupplier); 959 if (httpRequestConfigurator != null) // HTT: false branch not hit in codeFlow init; test RPs don't set an httpRequestConfigurator 960 b.httpRequestConfigurator(httpRequestConfigurator); 961 codeFlowCache = b.build(); 962 return codeFlowCache; 963 } 964 } 965 966 private IdTokenValidatorAdapter idTokenValidator() { 967 var v = idTokenValidatorCache; 968 if (v != null) 969 return v; 970 synchronized (this) { 971 if (idTokenValidatorCache != null) // HTT: DCL second-check; true branch requires concurrent initialization race 972 return idTokenValidatorCache; 973 var b = IdTokenValidatorAdapter.create() 974 .issuer(metadata().issuer().toString()) 975 .clientId(clientId) 976 .jwkSource(jwkSource()) 977 .maxClockSkewSeconds(clockSkewSeconds); 978 if (idTokenAlgorithms != null) // HTT: false branch (custom algorithm list) not covered; test RPs use default RS256/ES256 979 b.algorithms(idTokenAlgorithms); 980 idTokenValidatorCache = b.build(); 981 return idTokenValidatorCache; 982 } 983 } 984 985 private JWKSource<SecurityContext> jwkSource() { 986 var s = jwkSourceCache; 987 if (s != null) 988 return s; 989 synchronized (this) { 990 if (jwkSourceCache != null) // HTT: DCL second-check; true branch requires concurrent initialization race 991 return jwkSourceCache; 992 if (injectedJwkSource != null) { // HTT: true branch requires jwkSource(JWKSource) builder call, which no current test uses (tests inject jwkSet instead) 993 jwkSourceCache = injectedJwkSource; 994 } else if (jwkSet != null) { // HTT: false branch (no jwkSet → use JWKS URI from discovery) requires a live IdP and no injected jwkSet; all tests inject jwkSet 995 jwkSourceCache = new ImmutableJWKSet<>(jwkSet); 996 } else { 997 try { 998 jwkSourceCache = JWKSourceBuilder.create(metadata().jwksUri().toURL()).build(); 999 } catch (MalformedURLException e) { 1000 throw isex(e, "Invalid JWKS URI: %s", metadata().jwksUri()); 1001 } 1002 } 1003 return jwkSourceCache; 1004 } 1005 } 1006 1007 /** 1008 * Builds (and caches) the JWT processor used to validate back-channel logout tokens. 1009 * 1010 * <p> 1011 * Nimbus's {@link LogoutTokenValidator} hardcodes a {@link LogoutTokenClaimsVerifier} internally and 1012 * exposes no clock hook, so we drive an equivalent {@link DefaultJWTProcessor} directly: the JWS type 1013 * verifier replicates {@code LogoutTokenValidator}'s default (accept untyped, {@code logout+jwt}, or 1014 * {@code JWT}), and the claims verifier is a {@link ClockAwareLogoutTokenClaimsVerifier} that sources 1015 * "now" from {@link #clock}. With {@link Clock#systemUTC()} the behavior is identical to stock 1016 * {@code LogoutTokenValidator}. 1017 */ 1018 private ConfigurableJWTProcessor<SecurityContext> logoutTokenProcessor() { 1019 var p = logoutTokenProcessorCache; 1020 if (p != null) 1021 return p; 1022 synchronized (this) { 1023 if (logoutTokenProcessorCache != null) // HTT: DCL second-check; true branch requires concurrent initialization race 1024 return logoutTokenProcessorCache; 1025 Set<JWSAlgorithm> algs = idTokenAlgorithms != null 1026 ? new LinkedHashSet<>(Arrays.asList(idTokenAlgorithms)) 1027 : new LinkedHashSet<>(Arrays.asList(JWSAlgorithm.RS256, JWSAlgorithm.ES256)); 1028 var keySelector = new JWSVerificationKeySelector<SecurityContext>(algs, jwkSource()); 1029 var expectedIssuer = new Issuer(metadata().issuer().toString()); 1030 var processor = new DefaultJWTProcessor<SecurityContext>(); 1031 processor.setJWSTypeVerifier(new DefaultJOSEObjectTypeVerifier<>(LogoutTokenValidator.TYPE, JOSEObjectType.JWT, null)); 1032 processor.setJWSKeySelector(keySelector); 1033 @SuppressWarnings({ 1034 "unchecked" // ClockAwareLogoutTokenClaimsVerifier conforms to JWTClaimsSetVerifier<SecurityContext>; Nimbus's raw type forces an unchecked conversion here. 1035 }) 1036 JWTClaimsSetVerifier<SecurityContext> claimsVerifier = new ClockAwareLogoutTokenClaimsVerifier(expectedIssuer, new ClientID(clientId), clock); 1037 processor.setJWTClaimsSetVerifier(claimsVerifier); 1038 logoutTokenProcessorCache = processor; 1039 return logoutTokenProcessorCache; 1040 } 1041 } 1042 1043 private ClaimsPrincipal maybeBackfillUserInfo(ClaimsPrincipal principal, OAuthToken token) { 1044 if (userInfoClaims.isEmpty()) 1045 return principal; 1046 var endpoint = metadata().userinfoEndpoint(); 1047 if (endpoint == null) 1048 return principal; 1049 var missing = userInfoClaims.stream().anyMatch(c -> ! principal.hasClaim(c)); 1050 if (! missing) 1051 return principal; 1052 try { 1053 var http = new UserInfoRequest(endpoint, new BearerAccessToken(token.accessToken())).toHTTPRequest(); 1054 if (httpRequestConfigurator != null) 1055 httpRequestConfigurator.accept(http); 1056 var resp = UserInfoResponse.parse(http.send()); 1057 if (! resp.indicatesSuccess()) 1058 return principal; 1059 var ui = resp.toSuccessResponse().getUserInfo().toJSONObject(); 1060 var merged = new LinkedHashMap<String,Object>(principal.getClaims()); 1061 for (var c : userInfoClaims) 1062 if (! merged.containsKey(c) && ui.containsKey(c)) 1063 merged.put(c, ui.get(c)); 1064 return new ClaimsPrincipal(principal.getName(), merged); 1065 } catch (IOException | com.nimbusds.oauth2.sdk.ParseException | RuntimeException e) { 1066 return principal; // UserInfo backfill is best-effort; never fail the login on it. 1067 } 1068 } 1069 1070 private Set<String> extractRoles(ClaimsPrincipal principal) { 1071 var v = principal.getClaims().get(rolesClaim); 1072 if (v == null) 1073 return Collections.emptySet(); 1074 var out = new LinkedHashSet<String>(); 1075 if (v instanceof String v2) { 1076 for (var piece : v2.split("\\s+")) 1077 if (! piece.isBlank()) 1078 out.add(piece); 1079 } else if (v instanceof Collection<?> v2) { 1080 for (var item : v2) 1081 if (item instanceof String item2) 1082 out.add(item2); 1083 } 1084 return out; 1085 } 1086 1087 private String buildSetCookie(String value, long maxAgeSeconds) { 1088 var sb = new StringBuilder(); 1089 sb.append(cookieName).append('=').append(value); 1090 sb.append("; Path=/"); 1091 sb.append("; Max-Age=").append(maxAgeSeconds); 1092 if (cookieHttpOnly) 1093 sb.append("; HttpOnly"); 1094 if (cookieSecure) 1095 sb.append("; Secure"); 1096 if (cookieSameSite != null) // HTT: false branch requires null cookieSameSite, which the builder's reqnb guard prevents 1097 sb.append("; SameSite=").append(cookieSameSite); 1098 return sb.toString(); 1099 } 1100 1101 /** 1102 * Best-effort extraction of the {@code id_token_hint} for the RP-initiated logout request from the 1103 * session referenced by the given cookie value. Returns <jk>null</jk> when the session, its token, 1104 * or its id_token is absent, or when the id_token cannot be parsed. 1105 */ 1106 private JWT idTokenHintFromSession(String cookieValue) { 1107 var session = sessionStore.lookup(cookieValue); 1108 if (session.isEmpty()) 1109 return null; 1110 var token = session.get().token(); 1111 if (token.isEmpty()) 1112 return null; 1113 var idt = token.get().idToken(); 1114 if (idt.isEmpty()) // HTT: requires OAuthToken with no id_token; code-exchange always produces one when idToken is set on the stub, and the field is final 1115 return null; 1116 try { 1117 return JWTParser.parse(idt.get()); 1118 } catch (java.text.ParseException e) { 1119 return null; // HTT - best-effort hint only 1120 } 1121 } 1122 1123 private String readCookie(HttpServletRequest req) { 1124 var cookies = req.getCookies(); 1125 if (cookies == null) 1126 return null; 1127 for (var c : cookies) 1128 if (eq(cookieName, c.getName()) && c.getValue() != null && ! c.getValue().isBlank()) 1129 return c.getValue(); 1130 return null; 1131 } 1132 1133 private static void noStore(HttpServletResponse res) { 1134 res.setHeader("Cache-Control", "no-store"); 1135 res.setHeader("Pragma", "no-cache"); 1136 } 1137 1138 private static URI fullRequestUri(HttpServletRequest req) { 1139 var url = new StringBuilder(req.getRequestURL()); 1140 var qs = req.getQueryString(); 1141 if (qs != null) // HTT: false branch (no QS) would cause Nimbus to fail to parse the callback; all test callbacks include query params 1142 url.append('?').append(qs); 1143 return URI.create(url.toString()); 1144 } 1145 1146 /** 1147 * Returns the value only if it is a safe app-relative path (defeats open-redirect via the 1148 * {@code redirect} parameter). 1149 */ 1150 private static String safeRelativePath(String value) { 1151 if (isBlank(value)) 1152 return null; 1153 if (! value.startsWith("/")) 1154 return null; 1155 if (value.startsWith("//") || value.contains("\\") || value.contains("://")) 1156 return null; 1157 return value; 1158 } 1159 1160 /** 1161 * A {@link LogoutTokenClaimsVerifier} whose {@code exp} window is evaluated against an injected 1162 * {@link Clock} instead of {@code new Date()}. 1163 * 1164 * <p> 1165 * {@link LogoutTokenClaimsVerifier} extends Nimbus's {@link DefaultJWTClaimsVerifier}, which exposes 1166 * an overridable {@link DefaultJWTClaimsVerifier#currentTime() currentTime()} hook — so this is 1167 * a one-method override that injects the clock without cloning any Nimbus claim-checking logic. With 1168 * {@link Clock#systemUTC()} the behavior is identical to stock Nimbus. 1169 */ 1170 private static final class ClockAwareLogoutTokenClaimsVerifier extends LogoutTokenClaimsVerifier { 1171 1172 private final Clock clock; 1173 1174 ClockAwareLogoutTokenClaimsVerifier(Issuer issuer, ClientID clientId, Clock clock) { 1175 super(issuer, clientId); 1176 this.clock = clock; 1177 } 1178 1179 @Override /* DefaultJWTClaimsVerifier */ 1180 protected Date currentTime() { 1181 return Date.from(clock.instant()); 1182 } 1183 } 1184}