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 &mdash; 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&nbsp;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 &mdash; 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} &rarr;
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 &mdash; 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 &mdash; 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 &mdash; 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    * &mdash; 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 &mdash; 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}