This is an automated email from the ASF dual-hosted git repository.

jamesbognar pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/juneau.git

commit bd08d89b252c0f7635e2651eb0a3e35083309f01
Author: James Bognar <[email protected]>
AuthorDate: Sun Aug 16 13:40:36 2026 -0400

    TODO-397: Add injectable LoginStateStore SPI for multi-node OIDC login state
    
    Extract a LoginStateStore SPI (rename EphemeralStore -> 
InMemoryLoginStateStore) so
    clustered deployments can share ephemeral login state. Framework owns the 
TTL ceiling
    on the custom-store path, re-validates the redirect target and expiry on 
consume
    (fail-closed), and redacts secrets in PendingLogin.toString().
---
 .../auth/oidc/rp/InMemoryLoginStateStore_Test.java |  74 ++--
 .../auth/oidc/rp/InMemorySessionStore_Test.java    |   2 +-
 .../oidc/rp/SignedCookieSessionStore_Test.java     |   2 +-
 ...eralStore.java => InMemoryLoginStateStore.java} |  78 ++---
 .../rest/server/auth/oidc/rp/LoginStateStore.java  | 172 ++++++++++
 .../rest/server/auth/oidc/rp/OidcRelyingParty.java | 100 +++++-
 .../auth/oidc/rp/InMemoryLoginStateStore_Test.java |  75 +++--
 .../auth/oidc/rp/InMemorySessionStore_Test.java    |   2 +-
 .../server/auth/oidc/rp/LoginStateStore_Test.java  | 373 +++++++++++++++++++++
 .../auth/oidc/rp/OidcRelyingPartyBuilder_Test.java |  54 +++
 .../rest/server/auth/oidc/rp/OidcTestSupport.java  |  35 ++
 .../oidc/rp/SignedCookieSessionStore_Test.java     |   2 +-
 12 files changed, 849 insertions(+), 120 deletions(-)

diff --git 
a/juneau-rest/juneau-rest-server-auth-oidc-rp/src/test/java/org/apache/juneau/rest/server/auth/oidc/rp/EphemeralStore_Test.java
 
b/juneau-integration-tests/src/test/java/org/apache/juneau/rest/server/auth/oidc/rp/InMemoryLoginStateStore_Test.java
similarity index 52%
rename from 
juneau-rest/juneau-rest-server-auth-oidc-rp/src/test/java/org/apache/juneau/rest/server/auth/oidc/rp/EphemeralStore_Test.java
rename to 
juneau-integration-tests/src/test/java/org/apache/juneau/rest/server/auth/oidc/rp/InMemoryLoginStateStore_Test.java
index bfd2230ee0..e53ec7f20b 100644
--- 
a/juneau-rest/juneau-rest-server-auth-oidc-rp/src/test/java/org/apache/juneau/rest/server/auth/oidc/rp/EphemeralStore_Test.java
+++ 
b/juneau-integration-tests/src/test/java/org/apache/juneau/rest/server/auth/oidc/rp/InMemoryLoginStateStore_Test.java
@@ -21,29 +21,39 @@ import static org.junit.jupiter.api.Assertions.*;
 import java.time.*;
 
 import org.apache.juneau.*;
+import org.apache.juneau.rest.server.auth.oidc.rp.LoginStateStore.PendingLogin;
+import org.apache.juneau.rest.server.auth.oidc.rp.OidcTestSupport.MutableClock;
 import org.junit.jupiter.api.*;
 
 /**
- * Tests for {@link EphemeralStore} &mdash; single-use, TTL-bounded, 
size-bounded state/nonce storage.
+ * Tests for {@link InMemoryLoginStateStore} &mdash; the shipped single-node, 
single-use, TTL-bounded,
+ * size-bounded default {@link LoginStateStore}.
  *
  * @since 10.0.0
  */
 @SuppressWarnings({
        "java:S5778"  // assertThrows lambdas with chained calls; intermediate 
invocations do not throw in practice
 })
-class EphemeralStore_Test extends TestBase {
+class InMemoryLoginStateStore_Test extends TestBase {
 
        // Fixed clock seam: these cases don't depend on wall-clock time, so a 
deterministic clock
        // replaces the system clock (java:S8692) without changing behavior.
        private static final Clock CLOCK = 
Clock.fixed(Instant.parse("2026-01-01T00:00:00Z"), ZoneOffset.UTC);
+       private static final Duration TTL = Duration.ofMinutes(5);
 
-       private static EphemeralStore store(Clock clock) {
-               return new EphemeralStore(Duration.ofMinutes(5), 100, clock);
+       private static InMemoryLoginStateStore store(Clock clock) {
+               return new InMemoryLoginStateStore(TTL, 100, clock);
+       }
+
+       /** Builds a framework-shaped record stamped from the given clock 
(createdAt=now, expiresAt=now+TTL). */
+       private static PendingLogin pending(Clock clock, String nonce, String 
verifier, String redirect) {
+               var now = clock.instant();
+               return new PendingLogin(nonce, verifier, redirect, now, 
now.plus(TTL));
        }
 
        @Test void a01_storeThenConsume_roundTrips() {
                var s = store(CLOCK);
-               s.store("state-1", "nonce-1", "verifier-1", "/dashboard");
+               s.store("state-1", pending(CLOCK, "nonce-1", "verifier-1", 
"/dashboard"));
                var p = s.consume("state-1");
                assertTrue(p.isPresent());
                assertEquals("nonce-1", p.get().nonce());
@@ -53,7 +63,7 @@ class EphemeralStore_Test extends TestBase {
 
        @Test void a02_consume_isSingleUse() {
                var s = store(CLOCK);
-               s.store("state-1", "nonce-1", "verifier-1", null);
+               s.store("state-1", pending(CLOCK, "nonce-1", "verifier-1", 
null));
                assertTrue(s.consume("state-1").isPresent());
                assertTrue(s.consume("state-1").isEmpty(), "second consume of 
same state must miss (replay defense)");
        }
@@ -71,61 +81,65 @@ class EphemeralStore_Test extends TestBase {
        @Test void b01_expiredEntry_isMissed() {
                var base = Instant.parse("2026-01-01T00:00:00Z");
                var clock = new MutableClock(base);
-               var s = new EphemeralStore(Duration.ofMinutes(5), 100, clock);
-               s.store("state-1", "nonce-1", "verifier-1", null);
+               var s = new InMemoryLoginStateStore(TTL, 100, clock);
+               s.store("state-1", pending(clock, "nonce-1", "verifier-1", 
null));
                clock.advance(Duration.ofMinutes(6));
-               assertTrue(s.consume("state-1").isEmpty(), "entry past TTL must 
be treated as a miss");
+               assertTrue(s.consume("state-1").isEmpty(), "entry past 
expiresAt must be treated as a miss");
        }
 
        @Test void b02_notYetExpired_survives() {
                var base = Instant.parse("2026-01-01T00:00:00Z");
                var clock = new MutableClock(base);
-               var s = new EphemeralStore(Duration.ofMinutes(5), 100, clock);
-               s.store("state-1", "nonce-1", "verifier-1", null);
+               var s = new InMemoryLoginStateStore(TTL, 100, clock);
+               s.store("state-1", pending(clock, "nonce-1", "verifier-1", 
null));
                clock.advance(Duration.ofMinutes(4));
                assertTrue(s.consume("state-1").isPresent());
        }
 
+       /** A stored record with a {@code null} expiresAt fails closed as 
expired (defensive against a foreign blob). */
+       @Test void b03_nullExpiresAt_isTreatedAsExpired() {
+               var s = store(CLOCK);
+               var now = CLOCK.instant();
+               s.store("state-1", new PendingLogin("nonce-1", "verifier-1", 
null, now, null));
+               assertTrue(s.consume("state-1").isEmpty(), "null expiresAt must 
fail closed as expired");
+       }
+
        @Test void c01_sizeCap_evictsEldest() {
-               var s = new EphemeralStore(Duration.ofMinutes(5), 3, CLOCK);
-               s.store("s1", "n", "v", null);
-               s.store("s2", "n", "v", null);
-               s.store("s3", "n", "v", null);
-               s.store("s4", "n", "v", null);  // evicts s1
+               var s = new InMemoryLoginStateStore(TTL, 3, CLOCK);
+               s.store("s1", pending(CLOCK, "n", "v", null));
+               s.store("s2", pending(CLOCK, "n", "v", null));
+               s.store("s3", pending(CLOCK, "n", "v", null));
+               s.store("s4", pending(CLOCK, "n", "v", null));  // evicts s1
                assertEquals(3, s.size());
                assertTrue(s.consume("s1").isEmpty());
                assertTrue(s.consume("s4").isPresent());
        }
 
        @Test void d01_rejectsNonPositiveTtl() {
-               assertThrows(IllegalArgumentException.class, () -> new 
EphemeralStore(Duration.ZERO, 100, CLOCK));
+               assertThrows(IllegalArgumentException.class, () -> new 
InMemoryLoginStateStore(Duration.ZERO, 100, CLOCK));
        }
 
-       /** Negative TTL rejected — line 82 second bytecode branch of {@code 
!isZero && !isNegative}. */
+       /** Negative TTL rejected — second bytecode branch of {@code !isZero && 
!isNegative}. */
        @Test void d05_rejectsNegativeTtl() {
-               assertThrows(IllegalArgumentException.class, () -> new 
EphemeralStore(Duration.ofSeconds(-1), 100, CLOCK));
+               assertThrows(IllegalArgumentException.class, () -> new 
InMemoryLoginStateStore(Duration.ofSeconds(-1), 100, CLOCK));
        }
 
        @Test void d02_rejectsTtlAbove30Minutes() {
-               assertThrows(IllegalArgumentException.class, () -> new 
EphemeralStore(Duration.ofMinutes(31), 100, CLOCK));
+               assertThrows(IllegalArgumentException.class, () -> new 
InMemoryLoginStateStore(Duration.ofMinutes(31), 100, CLOCK));
        }
 
        @Test void d03_rejectsNonPositiveMaxEntries() {
-               assertThrows(IllegalArgumentException.class, () -> new 
EphemeralStore(Duration.ofMinutes(5), 0, CLOCK));
+               assertThrows(IllegalArgumentException.class, () -> new 
InMemoryLoginStateStore(Duration.ofMinutes(5), 0, CLOCK));
        }
 
        @Test void d04_rejectsBlankStateOnStore() {
                var s = store(CLOCK);
-               assertThrows(IllegalArgumentException.class, () -> s.store("", 
"n", "v", null));
+               var p = pending(CLOCK, "n", "v", null);
+               assertThrows(IllegalArgumentException.class, () -> s.store("", 
p));
        }
 
-       /** A test clock the test can advance manually. */
-       static final class MutableClock extends Clock {
-               private Instant now;
-               MutableClock(Instant start) { now = start; }
-               void advance(Duration d) { now = now.plus(d); }
-               @Override public ZoneId getZone() { return ZoneOffset.UTC; }
-               @Override public Clock withZone(ZoneId zone) { return this; }
-               @Override public Instant instant() { return now; }
+       @Test void d06_rejectsNullPendingOnStore() {
+               var s = store(CLOCK);
+               assertThrows(IllegalArgumentException.class, () -> 
s.store("state-1", null));
        }
 }
diff --git 
a/juneau-integration-tests/src/test/java/org/apache/juneau/rest/server/auth/oidc/rp/InMemorySessionStore_Test.java
 
b/juneau-integration-tests/src/test/java/org/apache/juneau/rest/server/auth/oidc/rp/InMemorySessionStore_Test.java
index 9089550ba3..bcc035e4b2 100644
--- 
a/juneau-integration-tests/src/test/java/org/apache/juneau/rest/server/auth/oidc/rp/InMemorySessionStore_Test.java
+++ 
b/juneau-integration-tests/src/test/java/org/apache/juneau/rest/server/auth/oidc/rp/InMemorySessionStore_Test.java
@@ -103,7 +103,7 @@ class InMemorySessionStore_Test extends TestBase {
 
        @Test void c01_expiredSession_missedAndRemoved() {
                var base = Instant.parse("2026-01-01T00:00:00Z");
-               var clock = new EphemeralStore_Test.MutableClock(base);
+               var clock = new OidcTestSupport.MutableClock(base);
                var store = new InMemorySessionStore(100, clock);
                store.createSessionCookieValue(session("id-1", "alice", 
"sess-1", base));
                clock.advance(Duration.ofHours(9));
diff --git 
a/juneau-integration-tests/src/test/java/org/apache/juneau/rest/server/auth/oidc/rp/SignedCookieSessionStore_Test.java
 
b/juneau-integration-tests/src/test/java/org/apache/juneau/rest/server/auth/oidc/rp/SignedCookieSessionStore_Test.java
index 9a13b36a92..da0af03e2f 100644
--- 
a/juneau-integration-tests/src/test/java/org/apache/juneau/rest/server/auth/oidc/rp/SignedCookieSessionStore_Test.java
+++ 
b/juneau-integration-tests/src/test/java/org/apache/juneau/rest/server/auth/oidc/rp/SignedCookieSessionStore_Test.java
@@ -86,7 +86,7 @@ class SignedCookieSessionStore_Test extends TestBase {
 
        @Test void c01_expiredCookie_isRejected() {
                var base = Instant.parse("2026-01-01T00:00:00Z");
-               var clock = new EphemeralStore_Test.MutableClock(base);
+               var clock = new OidcTestSupport.MutableClock(base);
                var store = 
SignedCookieSessionStore.create().signingKey(KEY).clock(clock).build();
                var cookie = store.createSessionCookieValue(session(base, 
null));
                clock.advance(Duration.ofHours(9));
diff --git 
a/juneau-rest/juneau-rest-server-auth-oidc-rp/src/main/java/org/apache/juneau/rest/server/auth/oidc/rp/EphemeralStore.java
 
b/juneau-rest/juneau-rest-server-auth-oidc-rp/src/main/java/org/apache/juneau/rest/server/auth/oidc/rp/InMemoryLoginStateStore.java
similarity index 58%
rename from 
juneau-rest/juneau-rest-server-auth-oidc-rp/src/main/java/org/apache/juneau/rest/server/auth/oidc/rp/EphemeralStore.java
rename to 
juneau-rest/juneau-rest-server-auth-oidc-rp/src/main/java/org/apache/juneau/rest/server/auth/oidc/rp/InMemoryLoginStateStore.java
index e1206b3309..27d0f8f910 100644
--- 
a/juneau-rest/juneau-rest-server-auth-oidc-rp/src/main/java/org/apache/juneau/rest/server/auth/oidc/rp/EphemeralStore.java
+++ 
b/juneau-rest/juneau-rest-server-auth-oidc-rp/src/main/java/org/apache/juneau/rest/server/auth/oidc/rp/InMemoryLoginStateStore.java
@@ -23,26 +23,35 @@ import java.time.*;
 import java.util.*;
 
 /**
- * Single-use, TTL-bounded store for the per-login {@code state} &rarr; {@code 
(nonce, codeVerifier,
- * redirectTarget)} association created during {@link 
OidcRelyingParty#startLogin} and consumed during
- * {@link OidcRelyingParty#completeLogin}.
+ * The shipped, single-node default {@link LoginStateStore}: a single-use, 
TTL-bounded, size-bounded
+ * in-memory store for the per-login {@code state} &rarr; {@link 
LoginStateStore.PendingLogin} association
+ * created during {@link OidcRelyingParty#startLogin} and consumed during 
{@link OidcRelyingParty#completeLogin}.
  *
  * <p>
  * Security properties (see OpenID Connect Core &sect;3.1.2.1 and the RP 
charter):
  * <ul>
- *     <li><b>Single-use</b> &mdash; {@link #consume(String)} atomically 
removes the entry, so a replayed
- *             callback with the same {@code state} fails.
- *     <li><b>TTL-bounded</b> &mdash; entries older than the configured TTL 
are treated as a miss and swept.
+ *     <li><b>Single-use</b> &mdash; {@link #consume(String)} atomically 
removes the entry under a monitor, so a
+ *             replayed callback with the same {@code state} fails.  
Cross-node single-use requires a shared store
+ *             with an atomic consume primitive (see {@link 
LoginStateStore#consume(String)}); this impl only
+ *             guarantees single-use within one JVM.
+ *     <li><b>TTL-bounded</b> &mdash; entries at or past their 
framework-minted {@code expiresAt} are treated as a
+ *             miss and swept.
  *     <li><b>Size-bounded</b> &mdash; an LRU cap (default 10 000) prevents 
unbounded growth from abandoned
  *             login attempts; eviction mirrors the {@code 
BoundedLruTokenCache} shape.
  * </ul>
  *
  * <p>
+ * The framework mints {@code createdAt}/{@code expiresAt} on the {@link 
LoginStateStore.PendingLogin} record;
+ * this impl persists them verbatim and does not stamp timestamps of its own.  
The {@code ttl} argument here
+ * only bounds sweep/GC of abandoned entries and asserts the framework's 
{@code (0, MAX_TTL]} ceiling for the
+ * default path; the relying party re-checks {@code expiresAt} independently 
on consume.
+ *
+ * <p>
  * Thread-safe.
  *
  * @since 10.0.0
  */
-public class EphemeralStore {
+public class InMemoryLoginStateStore implements LoginStateStore {
 
        /** Default maximum number of in-flight login attempts retained. */
        public static final int DEFAULT_MAX_ENTRIES = 10_000;
@@ -50,22 +59,7 @@ public class EphemeralStore {
        /** Default time-to-live for a pending login (the user has at most a 
few minutes at the IdP). */
        public static final Duration DEFAULT_TTL = Duration.ofMinutes(5);
 
-       /** Hard cap on the configurable TTL. */
-       static final Duration MAX_TTL = Duration.ofMinutes(30);
-
-       /**
-        * A pending-login association awaiting the IdP callback.
-        *
-        * @param nonce The OIDC {@code nonce} value bound into the 
authorization request.
-        * @param codeVerifier The PKCE {@code code_verifier} string.
-        * @param redirectTarget The application URL to redirect to after a 
successful login.  May be
-        *      {@code null}.
-        * @param createdAt The instant the entry was stored.
-        */
-       public record PendingLogin(String nonce, String codeVerifier, String 
redirectTarget, Instant createdAt) {}
-
        private final int maxEntries;
-       private final Duration ttl;
        private final Clock clock;
        private final Map<String,PendingLogin> entries;
        private final Object lock = new Object();
@@ -73,53 +67,40 @@ public class EphemeralStore {
        /**
         * Constructor.
         *
-        * @param ttl The single-use TTL.  Must be positive and not exceed 30 
minutes.
+        * @param ttl The single-use TTL.  Must be positive and not exceed 
{@link LoginStateStore#MAX_TTL}.  Bounds
+        *      sweep of abandoned entries and asserts the framework ceiling 
for the default path; per-entry expiry is
+        *      driven by the framework-minted {@link 
LoginStateStore.PendingLogin#expiresAt()}.
         * @param maxEntries The LRU size cap.  Must be positive.
-        * @param clock The clock for TTL comparisons.  Must not be 
<jk>null</jk>.
+        * @param clock The clock for TTL sweeping.  Must not be <jk>null</jk>.
         */
-       public EphemeralStore(Duration ttl, int maxEntries, Clock clock) {
+       public InMemoryLoginStateStore(Duration ttl, int maxEntries, Clock 
clock) {
                assertArgNotNull("ttl", ttl);
                assertArg(!ttl.isZero() && !ttl.isNegative(), "ttl must be 
positive");
                assertArg(ttl.compareTo(MAX_TTL) <= 0, "ttl must not exceed 30 
minutes (was %s)", ttl);
                assertArg(maxEntries > 0, "maxEntries must be positive (was 
%s)", maxEntries);
-               this.ttl = ttl;
                this.maxEntries = maxEntries;
                this.clock = assertArgNotNull("clock", clock);
                this.entries = new LinkedHashMap<>(16, 0.75f, true) {
                        private static final long serialVersionUID = 1L;
                        @Override
                        protected boolean 
removeEldestEntry(Map.Entry<String,PendingLogin> eldest) {
-                               return size() > EphemeralStore.this.maxEntries;
+                               return size() > 
InMemoryLoginStateStore.this.maxEntries;
                        }
                };
        }
 
-       /**
-        * Stores a pending-login association keyed by {@code state}.
-        *
-        * @param state The opaque {@code state} value.  Must not be 
<jk>null</jk> or blank.
-        * @param nonce The OIDC {@code nonce}.  Must not be <jk>null</jk> or 
blank.
-        * @param codeVerifier The PKCE {@code code_verifier}.  Must not be 
<jk>null</jk> or blank.
-        * @param redirectTarget The post-login redirect target.  May be 
<jk>null</jk>.
-        */
-       public void store(String state, String nonce, String codeVerifier, 
String redirectTarget) {
+       @Override /* LoginStateStore */
+       public void store(String state, PendingLogin pending) {
                assertArgNotNullOrBlank("state", state);
-               assertArgNotNullOrBlank("nonce", nonce);
-               assertArgNotNullOrBlank("codeVerifier", codeVerifier);
+               assertArgNotNull("pending", pending);
                var now = clock.instant();
                synchronized (lock) {
                        sweepExpired(now);
-                       entries.put(state, new PendingLogin(nonce, 
codeVerifier, redirectTarget, now));
+                       entries.put(state, pending);
                }
        }
 
-       /**
-        * Atomically removes and returns the pending-login association for 
{@code state}, if present and
-        * not expired.
-        *
-        * @param state The {@code state} value from the callback.  Must not be 
<jk>null</jk>.
-        * @return The association, or {@link Optional#empty()} if absent or 
expired.
-        */
+       @Override /* LoginStateStore */
        public Optional<PendingLogin> consume(String state) {
                if (state == null)
                        return oe();
@@ -146,7 +127,10 @@ public class EphemeralStore {
        }
 
        private boolean isExpired(PendingLogin p, Instant now) {
-               return !now.isBefore(p.createdAt().plus(ttl));
+               var exp = p.expiresAt();
+               if (exp == null)
+                       return true;
+               return !now.isBefore(exp);
        }
 
        private void sweepExpired(Instant now) {
diff --git 
a/juneau-rest/juneau-rest-server-auth-oidc-rp/src/main/java/org/apache/juneau/rest/server/auth/oidc/rp/LoginStateStore.java
 
b/juneau-rest/juneau-rest-server-auth-oidc-rp/src/main/java/org/apache/juneau/rest/server/auth/oidc/rp/LoginStateStore.java
new file mode 100644
index 0000000000..28d7efb476
--- /dev/null
+++ 
b/juneau-rest/juneau-rest-server-auth-oidc-rp/src/main/java/org/apache/juneau/rest/server/auth/oidc/rp/LoginStateStore.java
@@ -0,0 +1,172 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.rest.server.auth.oidc.rp;
+
+import java.time.*;
+import java.util.*;
+
+/**
+ * SPI for the single-use, TTL-bounded {@code state} &rarr; ({@code nonce}, 
PKCE {@code code_verifier},
+ * redirect target) association created during {@link 
OidcRelyingParty#startLogin} and consumed during
+ * {@link OidcRelyingParty#completeLogin}.
+ *
+ * <p>
+ * The shipped default is {@link InMemoryLoginStateStore} (per-JVM, bounded, 
TTL-swept).  Supply a shared
+ * (Redis / JDBC) implementation via {@link 
OidcRelyingParty.Builder#loginStateStore(LoginStateStore)} to
+ * support a clustered deployment where {@code /login} and {@code /callback} 
may land on different nodes
+ * without sticky sessions &mdash; the same motivation as a distributed {@link 
SessionStore}.  When the
+ * hook is unused, behavior is identical to the single-node default.
+ *
+ * <h5 class='section'>Security contract (read before implementing)</h5>
+ *
+ * <p>
+ * <b>Secret-bearing.</b> A stored entry carries the PKCE {@code 
code_verifier} (and {@code nonce}).  With a
+ * stolen authorization {@code code} (Referer, proxy log, browser history) the 
{@code code_verifier} IS the
+ * token-exchange credential.  The in-memory default keeps it on the JVM heap; 
a shared (Redis / JDBC)
+ * implementation writes it to an external backend.  Implementations therefore 
MUST protect confidentiality
+ * and integrity: TLS in transit and a locked-down ACL are the floor; 
application-level encryption-at-rest
+ * is strongly recommended.  Implementations and callers MUST NOT log a {@link 
PendingLogin} (its
+ * {@code toString()} is redacted for exactly this reason), and MUST NOT put 
{@link PendingLogin#codeVerifier()}
+ * or {@link PendingLogin#nonce()} into MDC, metric tags, or field-wise 
serializers &mdash; the accessors
+ * exist only for the token exchange and the ID-token {@code nonce} check.
+ *
+ * <p>
+ * <b>The framework does not trust the store as an oracle.</b> A writable 
shared store (mis-ACL,
+ * SSRF-to-Redis, replica, backup restore, compromised sibling service) can 
mint or alter records.  The
+ * relying party therefore re-validates on consume: it re-sanitizes {@code 
redirectTarget} to a safe
+ * relative path before redirecting (a tampered absolute / external URL is 
discarded in favor of the
+ * configured post-login default), and it re-checks {@code expiresAt} against 
its own {@link java.time.Clock}.
+ * The store MAY evict early; it is NOT the authoritative expiry 
decision-maker.  Note this defeats an
+ * <i>honest</i> store that merely forgot to expire a record (past {@code 
expiresAt}); a malicious rewrite
+ * of {@code expiresAt} to a far-future instant is the same trust boundary as 
writing a fresh forged blob
+ * (an integrity concern an HMAC would address), which the framework does not 
attempt to defeat here.
+ *
+ * <p>
+ * <b>Thread-safe.</b> Implementations MUST be safe for concurrent {@code 
store} / {@code consume} from
+ * multiple request threads (as {@link SessionStore} requires).
+ *
+ * <p>
+ * <b>Per-process by default.</b> The shipped default enforces single-use only 
within one JVM.  Cross-node
+ * single-use requires a shared backend with an atomic consume primitive (see 
{@link #consume(String)}).
+ *
+ * <h5 class='section'>See Also:</h5><ul>
+ *     <li class='jc'>{@link InMemoryLoginStateStore}
+ *     <li class='jc'>{@link 
OidcRelyingParty.Builder#loginStateStore(LoginStateStore)}
+ *     <li class='jc'>{@link SessionStore}
+ * </ul>
+ *
+ * @since 10.0.0
+ */
+public interface LoginStateStore {
+
+       /** Hard cap on the framework-enforced login-state TTL &mdash; the 
single public home for the ceiling. */
+       Duration MAX_TTL = Duration.ofMinutes(30);
+
+       /**
+        * A pending-login association awaiting the IdP callback.
+        *
+        * <p>
+        * <b>Secret-bearing record.</b> {@code codeVerifier} and {@code nonce} 
are credentials (see the SPI
+        * security contract).  {@code toString()} redacts both, matching 
{@code OAuthToken.toString()} and the
+        * MCP {@code PendingAuthorization} precedent, so a stray log line, 
exception message, or debugger dump
+        * does not disclose them.
+        *
+        * <p>
+        * <b>Framework-minted timestamps.</b> The relying party stamps {@code 
createdAt} (from its configured
+        * {@link java.time.Clock}) and {@code expiresAt} ({@code createdAt} 
plus the RP's capped
+        * {@code stateNonceTtl}).  A store MUST persist these as opaque values 
and MUST NOT recompute them at
+        * consume time &mdash; the RP re-checks {@code expiresAt} 
independently, so an honest store that forgot
+        * to expire a record cannot extend usability.  Either timestamp may be 
{@code null} only for a foreign /
+        * deserialized blob; the RP treats a {@code null} {@code createdAt} / 
{@code expiresAt} on consume as
+        * expired (fail-closed), so no compact-constructor {@code 
requireNonNull} is imposed here.
+        *
+        * @param nonce The OIDC {@code nonce}.  Redacted in {@code toString()}.
+        * @param codeVerifier The PKCE {@code code_verifier}.  Redacted in 
{@code toString()}.
+        * @param redirectTarget The post-login redirect target.  May be {@code 
null}.  Re-validated by the RP.
+        * @param createdAt Framework-minted store instant.
+        * @param expiresAt Framework-minted expiry ceiling ({@code createdAt + 
stateNonceTtl}).
+        */
+       record PendingLogin(String nonce, String codeVerifier, String 
redirectTarget, Instant createdAt, Instant expiresAt) {
+               @Override
+               public String toString() {
+                       return 
"PendingLogin(nonce=<redacted>,codeVerifier=<redacted>,redirectTarget=" + 
redirectTarget
+                               + ",createdAt=" + createdAt + ",expiresAt=" + 
expiresAt + ")";
+               }
+       }
+
+       /**
+        * Stores a framework-built pending-login association keyed by {@code 
state}.
+        *
+        * <p>
+        * Implementations MUST persist the association durably enough that a 
subsequent {@link #consume(String)}
+        * on any node handling the callback can retrieve it.  On an 
<b>infrastructure failure</b> (backend
+        * unreachable, write rejected) implementations MUST throw rather than 
silently drop the record:
+        * {@code store} is invoked on the {@code startLogin} leg before any 
authorization URL is issued, so a
+        * thrown exception fails the flow closed with no half-initiated login. 
 Implementations MUST NOT log the
+        * {@link PendingLogin} payload; wrap / sanitize backend exceptions so 
the {@code codeVerifier} /
+        * {@code state} do not leak into a 500 body or log.
+        *
+        * <p>
+        * Last-writer-wins: a repeated {@code store} for the same {@code 
state} overwrites (a Nimbus
+        * {@code State} collision is astronomical).  A backend MAY use {@code 
SET NX} but is not required to.  A
+        * backend MAY use the remaining time to {@code expiresAt} as a GC / 
{@code EXPIRE} hint, but GC failure
+        * MUST NOT extend usability past the framework's re-checked {@code 
expiresAt}.
+        *
+        * @param state The opaque {@code state} value.  Must not be {@code 
null} or blank.
+        * @param pending The framework-built association (already stamped with 
{@code createdAt} / {@code expiresAt}).
+        *      Must not be {@code null}.
+        */
+       void store(String state, PendingLogin pending);
+
+       /**
+        * Atomically removes and returns the pending-login association for 
{@code state}, if present.
+        *
+        * <p>
+        * <b>Single-use / atomicity is mandatory (TOCTOU).</b> The removal and 
the read MUST be a single atomic
+        * operation so a given {@code state} is consumed at most once.  Of any 
two concurrent calls with the same
+        * {@code state}, <b>at most one</b> returns a present {@link 
Optional}; the other MUST miss &mdash;
+        * including across nodes sharing the store.  Use an atomic primitive 
against the primary (Redis
+        * {@code GETDEL} or a Lua {@code GET}+{@code DEL}), <b>not</b> a 
read-then-delete and <b>not</b> a
+        * possibly-stale replica read.  A consume that removes the entry but 
fails to return it (lost response)
+        * MUST be treated as a miss on retry &mdash; never a best-effort 
put-back (put-back reopens replay); the
+        * user simply restarts login.
+        *
+        * <p>
+        * <b>Bounded I/O.</b> Implementations MUST apply a bounded deadline to 
the backend round-trip so a hung
+        * backend cannot stall {@code completeLogin} on a request thread 
indefinitely.
+        *
+        * <p>
+        * <b>{@code empty()} vs. throw &mdash; distinct meanings.</b> Return 
{@link Optional#empty()}
+        * <b>only</b> for absent / already-consumed / (optionally) 
store-expired states &mdash; the caller
+        * treats {@code empty()} as a CSRF / replay / expiry rejection.  On an 
<b>infrastructure failure</b>
+        * (backend unreachable) the implementation MUST throw (not return 
{@code empty()}) so the caller can
+        * distinguish an attack from an outage; the RP catches it, logs 
without the payload, and rejects with an
+        * "unavailable" authentication error.  Either way the login fails 
closed; implementations MUST NEVER
+        * fabricate a {@link PendingLogin}.
+        *
+        * <p>
+        * <b>Expiry is not the store's sole responsibility.</b> An 
implementation MAY treat past-{@code expiresAt}
+        * entries as a miss (early eviction), but the RP re-checks {@code 
expiresAt} on the returned record
+        * regardless, so it is not a security defect if a store returns an 
over-age record &mdash; the framework
+        * rejects it.
+        *
+        * @param state The {@code state} value from the callback.  May be 
{@code null} (returns {@code empty()},
+        *      matching the defensive callback path &mdash; do not throw).
+        * @return The association, or {@link Optional#empty()} if absent, 
expired, or already consumed.
+        */
+       Optional<PendingLogin> consume(String state);
+}
diff --git 
a/juneau-rest/juneau-rest-server-auth-oidc-rp/src/main/java/org/apache/juneau/rest/server/auth/oidc/rp/OidcRelyingParty.java
 
b/juneau-rest/juneau-rest-server-auth-oidc-rp/src/main/java/org/apache/juneau/rest/server/auth/oidc/rp/OidcRelyingParty.java
index f83e6056da..db467f4c25 100644
--- 
a/juneau-rest/juneau-rest-server-auth-oidc-rp/src/main/java/org/apache/juneau/rest/server/auth/oidc/rp/OidcRelyingParty.java
+++ 
b/juneau-rest/juneau-rest-server-auth-oidc-rp/src/main/java/org/apache/juneau/rest/server/auth/oidc/rp/OidcRelyingParty.java
@@ -144,9 +144,10 @@ public class OidcRelyingParty {
                String postLoginRedirect = "/";
                Set<String> scopes = st();
                SessionStore sessionStore;
+               LoginStateStore loginStateStore;
                String rolesClaim = DEFAULT_ROLES_CLAIM;
-               Duration stateNonceTtl = EphemeralStore.DEFAULT_TTL;
-               int ephemeralMaxEntries = EphemeralStore.DEFAULT_MAX_ENTRIES;
+               Duration stateNonceTtl = InMemoryLoginStateStore.DEFAULT_TTL;
+               int ephemeralMaxEntries = 
InMemoryLoginStateStore.DEFAULT_MAX_ENTRIES;
                Duration sessionTtl = DEFAULT_SESSION_TTL;
                String cookieName = DEFAULT_COOKIE_NAME;
                boolean cookieSecure = true;
@@ -300,13 +301,54 @@ public class OidcRelyingParty {
                }
 
                /**
-                * Sets the single-use TTL for the {@code state} / {@code 
nonce} store.  Defaults to 5 minutes.
+                * Sets the single-use TTL the framework enforces for the 
login-state ({@code state} / {@code nonce} /
+                * PKCE {@code code_verifier}) association.  Defaults to 5 
minutes.
                 *
-                * @param value The TTL.  Must be positive and not exceed 30 
minutes.
+                * <p>
+                * This is the RP-enforced policy applied to <b>every</b> 
consumed record &mdash; both the default
+                * {@link InMemoryLoginStateStore} and any custom {@link 
#loginStateStore(LoginStateStore) injected store}.
+                * The framework mints {@code expiresAt = createdAt + 
stateNonceTtl} on the record and re-checks it on
+                * consume, so the ceiling holds even for a shared store that 
fails to expire an entry.  The
+                * {@code (0, }{@link LoginStateStore#MAX_TTL}{@code ]} cap is 
enforced here (not only in the default
+                * impl's constructor, which is skipped when a custom store is 
injected).
+                *
+                * @param value The TTL.  Must be positive and not exceed 
{@link LoginStateStore#MAX_TTL} (30 minutes).
                 * @return This object.
                 */
                public Builder stateNonceTtl(Duration value) {
-                       stateNonceTtl = assertArgNotNull("value", value);
+                       assertArgNotNull("value", value);
+                       assertArg(!value.isZero() && !value.isNegative(), 
"stateNonceTtl must be positive");
+                       assertArg(value.compareTo(LoginStateStore.MAX_TTL) <= 
0, "stateNonceTtl must not exceed 30 minutes (was %s)", value);
+                       stateNonceTtl = value;
+                       return this;
+               }
+
+               /**
+                * Sets an optional custom {@link LoginStateStore} for the 
pending-login ({@code state} &rarr;
+                * {@code nonce} / PKCE {@code code_verifier} / redirect) 
association.
+                *
+                * <p>
+                * Optional (unlike {@link #sessionStore(SessionStore)}): when 
omitted the RP uses the shipped per-JVM
+                * {@link InMemoryLoginStateStore}, and behavior is identical 
to a single-node deployment.  Supply a
+                * shared (Redis / JDBC) implementation to support a clustered 
deployment where {@code /login} and
+                * {@code /callback} may land on different nodes without sticky 
sessions &mdash; the same motivation as a
+                * distributed {@link SessionStore}.
+                *
+                * <p>
+                * <b>The store is secret-bearing</b> ({@code code_verifier} + 
{@code nonce}); read the
+                * {@link LoginStateStore} security contract before 
implementing one (TLS + locked-down ACL floor,
+                * encryption-at-rest recommended, never log the payload, 
atomic {@code GETDEL}/Lua single-use consume).
+                * The framework retains authority over the security-critical 
decisions regardless of the store: it
+                * re-validates {@code redirectTarget} via {@code 
safeRelativePath} and re-checks {@code expiresAt}
+                * against its own {@link #clock(Clock) clock} on consume, and 
enforces the
+                * {@link #stateNonceTtl(Duration)} ceiling &mdash; the store 
persists the record and enforces single-use,
+                * but is treated as a blob store, not a trusted oracle.
+                *
+                * @param value The login-state store.  Must not be 
<jk>null</jk>.
+                * @return This object.
+                */
+               public Builder loginStateStore(LoginStateStore value) {
+                       loginStateStore = assertArgNotNull("value", value);
                        return this;
                }
 
@@ -458,7 +500,14 @@ public class OidcRelyingParty {
                }
 
                /**
-                * Overrides the clock used for session expiry + the ephemeral 
store.  Useful in tests.
+                * Overrides the clock used for session expiry and the 
login-state store.  Useful in tests.
+                *
+                * <p>
+                * The login-state {@code expiresAt} is both minted (on {@code 
startLogin}) and re-checked (on
+                * {@code completeLogin}) against this clock.  In a clustered 
deployment with a shared
+                * {@link #loginStateStore(LoginStateStore) LoginStateStore}, 
the ceiling is therefore only as
+                * trustworthy as clock synchronization across nodes; a backend 
{@code EXPIRE}/TTL is a GC hint, not the
+                * authoritative expiry decision.
                 *
                 * @param value The clock.  Must not be <jk>null</jk>.
                 * @return This object.
@@ -508,7 +557,8 @@ public class OidcRelyingParty {
        private final JWKSource<SecurityContext> injectedJwkSource;
        private final Set<String> userInfoClaims;
        private final Clock clock;
-       private final EphemeralStore ephemeralStore;
+       private final Duration stateNonceTtl;
+       private final LoginStateStore loginStateStore;
 
        @SuppressWarnings({
                "java:S3077" // Publish-once cache: assigned once under 
double-checked locking in metadata(); the OidcMetadata payload is fully built 
before assignment, so volatile safe-publication is sufficient.
@@ -564,7 +614,10 @@ public class OidcRelyingParty {
                this.injectedJwkSource = b.jwkSource;
                this.userInfoClaims = u(cp(b.userInfoClaims));
                this.clock = b.clock;
-               this.ephemeralStore = new EphemeralStore(b.stateNonceTtl, 
b.ephemeralMaxEntries, b.clock);
+               this.stateNonceTtl = b.stateNonceTtl;
+               this.loginStateStore = b.loginStateStore != null
+                       ? b.loginStateStore
+                       : new InMemoryLoginStateStore(b.stateNonceTtl, 
b.ephemeralMaxEntries, b.clock);
        }
 
        
//-----------------------------------------------------------------------------------------------------------------
@@ -588,7 +641,16 @@ public class OidcRelyingParty {
                var verifier = new CodeVerifier();
                var challenge = CodeChallenge.compute(CodeChallengeMethod.S256, 
verifier);
                var redirectTarget = 
safeRelativePath(req.getParameter("redirect"));
-               ephemeralStore.store(state, nonce, verifier.getValue(), 
redirectTarget);
+               var createdAt = clock.instant();
+               var pending = new LoginStateStore.PendingLogin(nonce, 
verifier.getValue(), redirectTarget, createdAt, createdAt.plus(stateNonceTtl));
+               try {
+                       loginStateStore.store(state, pending);
+               } catch (RuntimeException e) {
+                       // Fail closed before any authorization URL is issued.  
Do NOT chain the raw backend exception as the
+                       // cause: a shared-store backend may echo the 
state/code_verifier into its own message, and
+                       // AuthenticationException publishes the cause via 
getCause(), so a raw chain would re-expose the secret.
+                       throw new AuthenticationException("OIDC login-state 
store unavailable");
+               }
                var authUrl = codeFlow().buildAuthenticationUrl(state, 
challenge, nonce, authenticationRequestCustomizer);
                noStore(res);
                res.sendRedirect(authUrl.toString());
@@ -626,9 +688,23 @@ public class OidcRelyingParty {
                var code = success.getAuthorizationCode().getValue();
                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
 
-               var pending = ephemeralStore.consume(state).orElseThrow(
+               Optional<LoginStateStore.PendingLogin> consumed;
+               try {
+                       consumed = loginStateStore.consume(state);
+               } catch (RuntimeException e) {
+                       // Infra failure (backend unreachable) is distinct from 
a CSRF/replay miss; reject as unavailable
+                       // without leaking the payload (do not chain the raw 
backend exception as cause).
+                       throw new AuthenticationException("OIDC login-state 
store unavailable");
+               }
+               var pending = consumed.orElseThrow(
                        () -> new AuthenticationException("OIDC callback state 
is missing, expired, or already used"));
 
+               // Fail-closed expiry re-check BEFORE the token exchange: the 
framework owns the TTL ceiling independently
+               // of the store, so an honest store that failed to expire an 
over-age record cannot extend usability.  Null
+               // createdAt/expiresAt (a foreign/deserialized blob) is treated 
as expired, not an NPE->500.
+               if (pending.createdAt() == null || pending.expiresAt() == null 
|| ! clock.instant().isBefore(pending.expiresAt()))
+                       throw new AuthenticationException("OIDC callback state 
is missing, expired, or already used");
+
                OAuthToken token;
                try {
                        token = codeFlow().exchange(code, new 
CodeVerifier(pending.codeVerifier()));
@@ -658,7 +734,9 @@ public class OidcRelyingParty {
                var cookieValue = 
sessionStore.createSessionCookieValue(session);
                noStore(res);
                res.addHeader("Set-Cookie", buildSetCookie(cookieValue, 
sessionTtl.toSeconds()));
-               res.sendRedirect(or(pending.redirectTarget(), 
postLoginRedirect));
+               // Re-validate the redirect target on consume: a 
writable/compromised shared store cannot inject an open
+               // redirect.  A failing re-validation falls back to the 
operator-configured default (not itself re-sanitized).
+               res.sendRedirect(or(safeRelativePath(pending.redirectTarget()), 
postLoginRedirect));
        }
 
        /**
diff --git 
a/juneau-integration-tests/src/test/java/org/apache/juneau/rest/server/auth/oidc/rp/EphemeralStore_Test.java
 
b/juneau-rest/juneau-rest-server-auth-oidc-rp/src/test/java/org/apache/juneau/rest/server/auth/oidc/rp/InMemoryLoginStateStore_Test.java
similarity index 51%
rename from 
juneau-integration-tests/src/test/java/org/apache/juneau/rest/server/auth/oidc/rp/EphemeralStore_Test.java
rename to 
juneau-rest/juneau-rest-server-auth-oidc-rp/src/test/java/org/apache/juneau/rest/server/auth/oidc/rp/InMemoryLoginStateStore_Test.java
index 15ad79cfe4..e53ec7f20b 100644
--- 
a/juneau-integration-tests/src/test/java/org/apache/juneau/rest/server/auth/oidc/rp/EphemeralStore_Test.java
+++ 
b/juneau-rest/juneau-rest-server-auth-oidc-rp/src/test/java/org/apache/juneau/rest/server/auth/oidc/rp/InMemoryLoginStateStore_Test.java
@@ -21,29 +21,39 @@ import static org.junit.jupiter.api.Assertions.*;
 import java.time.*;
 
 import org.apache.juneau.*;
+import org.apache.juneau.rest.server.auth.oidc.rp.LoginStateStore.PendingLogin;
+import org.apache.juneau.rest.server.auth.oidc.rp.OidcTestSupport.MutableClock;
 import org.junit.jupiter.api.*;
 
 /**
- * Tests for {@link EphemeralStore} &mdash; single-use, TTL-bounded, 
size-bounded state/nonce storage.
+ * Tests for {@link InMemoryLoginStateStore} &mdash; the shipped single-node, 
single-use, TTL-bounded,
+ * size-bounded default {@link LoginStateStore}.
  *
  * @since 10.0.0
  */
 @SuppressWarnings({
        "java:S5778"  // assertThrows lambdas with chained calls; intermediate 
invocations do not throw in practice
 })
-class EphemeralStore_Test extends TestBase {
+class InMemoryLoginStateStore_Test extends TestBase {
 
        // Fixed clock seam: these cases don't depend on wall-clock time, so a 
deterministic clock
        // replaces the system clock (java:S8692) without changing behavior.
        private static final Clock CLOCK = 
Clock.fixed(Instant.parse("2026-01-01T00:00:00Z"), ZoneOffset.UTC);
+       private static final Duration TTL = Duration.ofMinutes(5);
 
-       private static EphemeralStore store(Clock clock) {
-               return new EphemeralStore(Duration.ofMinutes(5), 100, clock);
+       private static InMemoryLoginStateStore store(Clock clock) {
+               return new InMemoryLoginStateStore(TTL, 100, clock);
+       }
+
+       /** Builds a framework-shaped record stamped from the given clock 
(createdAt=now, expiresAt=now+TTL). */
+       private static PendingLogin pending(Clock clock, String nonce, String 
verifier, String redirect) {
+               var now = clock.instant();
+               return new PendingLogin(nonce, verifier, redirect, now, 
now.plus(TTL));
        }
 
        @Test void a01_storeThenConsume_roundTrips() {
                var s = store(CLOCK);
-               s.store("state-1", "nonce-1", "verifier-1", "/dashboard");
+               s.store("state-1", pending(CLOCK, "nonce-1", "verifier-1", 
"/dashboard"));
                var p = s.consume("state-1");
                assertTrue(p.isPresent());
                assertEquals("nonce-1", p.get().nonce());
@@ -53,7 +63,7 @@ class EphemeralStore_Test extends TestBase {
 
        @Test void a02_consume_isSingleUse() {
                var s = store(CLOCK);
-               s.store("state-1", "nonce-1", "verifier-1", null);
+               s.store("state-1", pending(CLOCK, "nonce-1", "verifier-1", 
null));
                assertTrue(s.consume("state-1").isPresent());
                assertTrue(s.consume("state-1").isEmpty(), "second consume of 
same state must miss (replay defense)");
        }
@@ -71,56 +81,65 @@ class EphemeralStore_Test extends TestBase {
        @Test void b01_expiredEntry_isMissed() {
                var base = Instant.parse("2026-01-01T00:00:00Z");
                var clock = new MutableClock(base);
-               var s = new EphemeralStore(Duration.ofMinutes(5), 100, clock);
-               s.store("state-1", "nonce-1", "verifier-1", null);
+               var s = new InMemoryLoginStateStore(TTL, 100, clock);
+               s.store("state-1", pending(clock, "nonce-1", "verifier-1", 
null));
                clock.advance(Duration.ofMinutes(6));
-               assertTrue(s.consume("state-1").isEmpty(), "entry past TTL must 
be treated as a miss");
+               assertTrue(s.consume("state-1").isEmpty(), "entry past 
expiresAt must be treated as a miss");
        }
 
        @Test void b02_notYetExpired_survives() {
                var base = Instant.parse("2026-01-01T00:00:00Z");
                var clock = new MutableClock(base);
-               var s = new EphemeralStore(Duration.ofMinutes(5), 100, clock);
-               s.store("state-1", "nonce-1", "verifier-1", null);
+               var s = new InMemoryLoginStateStore(TTL, 100, clock);
+               s.store("state-1", pending(clock, "nonce-1", "verifier-1", 
null));
                clock.advance(Duration.ofMinutes(4));
                assertTrue(s.consume("state-1").isPresent());
        }
 
+       /** A stored record with a {@code null} expiresAt fails closed as 
expired (defensive against a foreign blob). */
+       @Test void b03_nullExpiresAt_isTreatedAsExpired() {
+               var s = store(CLOCK);
+               var now = CLOCK.instant();
+               s.store("state-1", new PendingLogin("nonce-1", "verifier-1", 
null, now, null));
+               assertTrue(s.consume("state-1").isEmpty(), "null expiresAt must 
fail closed as expired");
+       }
+
        @Test void c01_sizeCap_evictsEldest() {
-               var s = new EphemeralStore(Duration.ofMinutes(5), 3, CLOCK);
-               s.store("s1", "n", "v", null);
-               s.store("s2", "n", "v", null);
-               s.store("s3", "n", "v", null);
-               s.store("s4", "n", "v", null);  // evicts s1
+               var s = new InMemoryLoginStateStore(TTL, 3, CLOCK);
+               s.store("s1", pending(CLOCK, "n", "v", null));
+               s.store("s2", pending(CLOCK, "n", "v", null));
+               s.store("s3", pending(CLOCK, "n", "v", null));
+               s.store("s4", pending(CLOCK, "n", "v", null));  // evicts s1
                assertEquals(3, s.size());
                assertTrue(s.consume("s1").isEmpty());
                assertTrue(s.consume("s4").isPresent());
        }
 
        @Test void d01_rejectsNonPositiveTtl() {
-               assertThrows(IllegalArgumentException.class, () -> new 
EphemeralStore(Duration.ZERO, 100, CLOCK));
+               assertThrows(IllegalArgumentException.class, () -> new 
InMemoryLoginStateStore(Duration.ZERO, 100, CLOCK));
+       }
+
+       /** Negative TTL rejected — second bytecode branch of {@code !isZero && 
!isNegative}. */
+       @Test void d05_rejectsNegativeTtl() {
+               assertThrows(IllegalArgumentException.class, () -> new 
InMemoryLoginStateStore(Duration.ofSeconds(-1), 100, CLOCK));
        }
 
        @Test void d02_rejectsTtlAbove30Minutes() {
-               assertThrows(IllegalArgumentException.class, () -> new 
EphemeralStore(Duration.ofMinutes(31), 100, CLOCK));
+               assertThrows(IllegalArgumentException.class, () -> new 
InMemoryLoginStateStore(Duration.ofMinutes(31), 100, CLOCK));
        }
 
        @Test void d03_rejectsNonPositiveMaxEntries() {
-               assertThrows(IllegalArgumentException.class, () -> new 
EphemeralStore(Duration.ofMinutes(5), 0, CLOCK));
+               assertThrows(IllegalArgumentException.class, () -> new 
InMemoryLoginStateStore(Duration.ofMinutes(5), 0, CLOCK));
        }
 
        @Test void d04_rejectsBlankStateOnStore() {
                var s = store(CLOCK);
-               assertThrows(IllegalArgumentException.class, () -> s.store("", 
"n", "v", null));
+               var p = pending(CLOCK, "n", "v", null);
+               assertThrows(IllegalArgumentException.class, () -> s.store("", 
p));
        }
 
-       /** A test clock the test can advance manually. */
-       static final class MutableClock extends Clock {
-               private Instant now;
-               MutableClock(Instant start) { now = start; }
-               void advance(Duration d) { now = now.plus(d); }
-               @Override public ZoneId getZone() { return ZoneOffset.UTC; }
-               @Override public Clock withZone(ZoneId zone) { return this; }
-               @Override public Instant instant() { return now; }
+       @Test void d06_rejectsNullPendingOnStore() {
+               var s = store(CLOCK);
+               assertThrows(IllegalArgumentException.class, () -> 
s.store("state-1", null));
        }
 }
diff --git 
a/juneau-rest/juneau-rest-server-auth-oidc-rp/src/test/java/org/apache/juneau/rest/server/auth/oidc/rp/InMemorySessionStore_Test.java
 
b/juneau-rest/juneau-rest-server-auth-oidc-rp/src/test/java/org/apache/juneau/rest/server/auth/oidc/rp/InMemorySessionStore_Test.java
index 9731941e6b..52a823c7c8 100644
--- 
a/juneau-rest/juneau-rest-server-auth-oidc-rp/src/test/java/org/apache/juneau/rest/server/auth/oidc/rp/InMemorySessionStore_Test.java
+++ 
b/juneau-rest/juneau-rest-server-auth-oidc-rp/src/test/java/org/apache/juneau/rest/server/auth/oidc/rp/InMemorySessionStore_Test.java
@@ -103,7 +103,7 @@ class InMemorySessionStore_Test extends TestBase {
 
        @Test void c01_expiredSession_missedAndRemoved() {
                var base = Instant.parse("2026-01-01T00:00:00Z");
-               var clock = new EphemeralStore_Test.MutableClock(base);
+               var clock = new OidcTestSupport.MutableClock(base);
                var store = new InMemorySessionStore(100, clock);
                store.createSessionCookieValue(session("id-1", "alice", 
"sess-1", base));
                clock.advance(Duration.ofHours(9));
diff --git 
a/juneau-rest/juneau-rest-server-auth-oidc-rp/src/test/java/org/apache/juneau/rest/server/auth/oidc/rp/LoginStateStore_Test.java
 
b/juneau-rest/juneau-rest-server-auth-oidc-rp/src/test/java/org/apache/juneau/rest/server/auth/oidc/rp/LoginStateStore_Test.java
new file mode 100644
index 0000000000..ad6d492730
--- /dev/null
+++ 
b/juneau-rest/juneau-rest-server-auth-oidc-rp/src/test/java/org/apache/juneau/rest/server/auth/oidc/rp/LoginStateStore_Test.java
@@ -0,0 +1,373 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.rest.server.auth.oidc.rp;
+
+import static org.apache.juneau.rest.server.auth.oidc.rp.OidcTestSupport.*;
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.net.*;
+import java.time.*;
+import java.util.*;
+import java.util.concurrent.*;
+import java.util.concurrent.atomic.*;
+
+import org.apache.juneau.*;
+import org.apache.juneau.commons.lang.*;
+import org.apache.juneau.rest.mock.*;
+import org.apache.juneau.rest.server.auth.*;
+import org.apache.juneau.rest.server.auth.oidc.rp.LoginStateStore.PendingLogin;
+import org.junit.jupiter.api.*;
+
+import com.nimbusds.jose.jwk.*;
+
+/**
+ * Security gates for the injectable {@link LoginStateStore} SPI: 
injected-store wiring, fail-closed store
+ * outages, consume-time redirect re-validation, the framework expiry ceiling 
(enforced before the token
+ * exchange), {@link PendingLogin} redaction, and the default impl's 
concurrent single-use race.
+ *
+ * <p>
+ * These are the substance ("red-on-broken") gates for TODO-397: each is 
constructed so a no-op setter,
+ * a mis-ordered check, or a class-only assertion would fail it.  The 
recording fake plus distinctive
+ * hardcoded values ensure the injected instance (not the hidden default 
store) is actually exercised.
+ *
+ * @since 10.0.0
+ */
+@SuppressWarnings({
+       "java:S8692", // Nimbus oauth2-oidc-sdk exposes no clock hook on the 
ID-token path; the login-state expiry re-check IS clock-injectable and is 
tested deterministically.
+       "java:S1130", // Test methods declare throws Exception for checked 
exceptions MockServletResponse may propagate; declarations are intentionally 
broad.
+       "java:S5778", // assertThrows lambdas with chained calls; intermediate 
invocations do not throw in practice.
+       "java:S5976", // Similar-shaped gates assert distinct security 
outcomes; parameterizing would obscure intent.
+       "resource"    // Closeable StubIdp fixture; lifecycle managed by 
@BeforeEach/@AfterEach, not a real leak.
+})
+class LoginStateStore_Test extends TestBase {
+
+       private static final String CID = "web-app";
+       private static final URI REDIRECT_URI = 
URI.create("https://app.example.com/auth/callback";);
+       private static final URI AUTHZ = 
URI.create("https://stub-idp.example.com/authorize";);
+       private static final URI END_SESSION = 
URI.create("https://stub-idp.example.com/logout";);
+
+       // A fixed clock shared by the RP AND the fixture timestamps (mandatory 
— mixing clocks makes a correct
+       // expiry check look like a failure).  The ID-token exp uses wall-clock 
time internally in Nimbus, so ID
+       // tokens are still signed with Instant.now().
+       private static final Instant NOW = 
Instant.parse("2026-06-01T12:00:00Z");
+       private static final Clock CLOCK = Clock.fixed(NOW, ZoneOffset.UTC);
+
+       // Distinctive hardcoded values the hidden default store could NOT have 
produced (S8 / gate 1).
+       private static final String FAKE_NONCE = "fake-nonce-distinctive-8f3a";
+       private static final String FAKE_VERIFIER = 
"fakeVERIFIERfakeVERIFIERfakeVERIFIERfakeVER1"; // 44 chars, PKCE-valid 
[A-Za-z0-9]
+       private static final String FAKE_REDIRECT = "/from-fake-store";
+
+       // A distinctive secret marker embedded in a backend exception message 
(gate 4 / gate 5), so "the error
+       // surface does not echo the secret" is an observable assertion, not 
vacuously true.
+       private static final String LEAK_SENTINEL = 
"code_verifier=SUPERSECRET-LEAK-SENTINEL-9x7q";
+
+       private StubIdp idp;
+       private RSAKey key;
+
+       @BeforeEach void setup() throws Exception {
+               key = generateRsa("k1");
+               idp = new StubIdp();
+       }
+
+       @AfterEach void teardown() {
+               if (idp != null)
+                       idp.close();
+       }
+
+       private OidcRelyingParty rp(LoginStateStore loginStateStore) {
+               return OidcRelyingParty.create()
+                       .metadata(idp.metadata(AUTHZ, END_SESSION))
+                       .clientId(CID)
+                       .clientSecret("client-secret")
+                       .redirectUri(REDIRECT_URI)
+                       .scope("openid", "profile")
+                       .sessionStore(InMemorySessionStore.create())
+                       .jwkSet(publicJwks(key))
+                       .loginStateStore(loginStateStore)
+                       .clock(CLOCK)
+                       .build();
+       }
+
+       private String startLoginGetState(OidcRelyingParty rp) throws Exception 
{
+               var req = MockServletRequest.create("GET", "/auth/login");
+               var res = MockServletResponse.create();
+               rp.startLogin(req, res);
+               var loc = res.getHeader("Location");
+               assertNotNull(loc, "startLogin must redirect to the IdP");
+               return queryParam(loc, "state");
+       }
+
+       private MockServletResponse completeLogin(OidcRelyingParty rp, String 
state) throws Exception {
+               var req = MockServletRequest.create("GET", REDIRECT_URI + 
"?code=abc123&state=" + state);
+               var res = MockServletResponse.create();
+               rp.completeLogin(req, res);
+               return res;
+       }
+
+       private void signIdTokenWith(String nonce) throws Exception {
+               idp.idToken = signIdToken(key, idp.issuer, CID, "alice", 
"sess-1", nonce, Instant.now(), Duration.ofMinutes(5),
+                       Map.of("scope", "openid profile"));
+       }
+
+       private static PendingLogin pending(String redirect, Instant createdAt, 
Instant expiresAt) {
+               return new PendingLogin(FAKE_NONCE, FAKE_VERIFIER, redirect, 
createdAt, expiresAt);
+       }
+
+       
//-----------------------------------------------------------------------------------------------------------------
+       // A recording fake LoginStateStore configurable per gate.
+       
//-----------------------------------------------------------------------------------------------------------------
+
+       static final class FakeStore implements LoginStateStore {
+               final IntegerHolder storeCount = IntegerHolder.create();
+               final IntegerHolder consumeCount = IntegerHolder.create();
+               volatile String lastStoreState;
+               volatile String lastConsumeState;
+               volatile Optional<PendingLogin> lastConsumeReturned = 
Optional.empty();
+
+               RuntimeException storeThrows;
+               RuntimeException consumeThrows;
+               Optional<PendingLogin> consumeResult = Optional.empty();
+
+               @Override public void store(String state, PendingLogin p) {
+                       storeCount.increment();
+                       lastStoreState = state;
+                       if (storeThrows != null)
+                               throw storeThrows;
+               }
+
+               @Override public Optional<PendingLogin> consume(String state) {
+                       consumeCount.increment();
+                       lastConsumeState = state;
+                       if (consumeThrows != null)
+                               throw consumeThrows;
+                       lastConsumeReturned = consumeResult;
+                       return consumeResult;
+               }
+       }
+
+       
//-----------------------------------------------------------------------------------------------------------------
+       // Gate 1 — injected store honored on both legs, with observable 
distinctive outputs + mandatory call-counts.
+       
//-----------------------------------------------------------------------------------------------------------------
+
+       @Test void g01_injectedStore_honoredOnBothLegs() throws Exception {
+               var fake = new FakeStore();
+               fake.consumeResult = Optional.of(pending(FAKE_REDIRECT, NOW, 
NOW.plus(Duration.ofMinutes(5))));
+               var rp = rp(fake);
+
+               var state = startLoginGetState(rp);
+               assertTrue(fake.storeCount.is(1), "store must be invoked once 
on the injected instance");
+               assertEquals(state, fake.lastStoreState, "store must receive 
the generated state");
+
+               signIdTokenWith(FAKE_NONCE);  // sign with the fake's 
distinctive nonce; a mismatch fails validation
+               var res = completeLogin(rp, state);
+
+               assertTrue(fake.consumeCount.is(1), "consume must be invoked 
once on the injected instance");
+               assertEquals(state, fake.lastConsumeState, "consume must 
receive the callback state");
+               assertNotNull(cookieValue(res.getHeader("Set-Cookie")), "login 
must succeed (session cookie set)");
+               assertEquals(FAKE_REDIRECT, res.getHeader("Location"), 
"Location must be the fake's distinctive redirectTarget");
+               // The verifier exchanged with the IdP is the fake's 
distinctive one (record-only capture on the stub).
+               assertEquals(FAKE_VERIFIER, idp.lastCodeVerifier, "the fake's 
distinctive code_verifier must be exchanged");
+               // Login succeeding with an ID token signed by FAKE_NONCE 
proves the fake's nonce was the one validated.
+       }
+
+       
//-----------------------------------------------------------------------------------------------------------------
+       // Gate 3 — consume miss on the INJECTED store is the CSRF/replay 
decision (fails against a no-op setter).
+       
//-----------------------------------------------------------------------------------------------------------------
+
+       @Test void g03_consumeMiss_onInjectedStore_rejected() throws Exception {
+               var fake = new FakeStore();
+               fake.consumeResult = Optional.empty();  // injected consume 
misses
+               var rp = rp(fake);
+
+               // startLogin FIRST: a hidden default store would hold a live 
entry and login would succeed if the fake
+               // were ignored.  So a no-op setter is caught two ways below 
(consume count stays 0; no miss).
+               var state = startLoginGetState(rp);
+               assertTrue(fake.storeCount.is(1));
+               assertEquals(state, fake.lastStoreState);
+
+               signIdTokenWith(FAKE_NONCE);
+               var ex = assertThrows(AuthenticationException.class, () -> 
completeLogin(rp, state));
+
+               assertTrue(fake.consumeCount.is(1), "the injected consume must 
be the CSRF/replay decision point");
+               assertEquals(state, fake.lastConsumeState);
+               assertTrue(ex.getMessage().contains("missing, expired, or 
already used"),
+                       "must reject with the consume-miss message, got: " + 
ex.getMessage());
+               assertEquals(0, idp.tokenHits.get(), "a consume miss must not 
reach the token exchange");
+       }
+
+       
//-----------------------------------------------------------------------------------------------------------------
+       // Gate 4 — store outage on startLogin → fail closed (no redirect) + no 
secret leak in the error surface.
+       
//-----------------------------------------------------------------------------------------------------------------
+
+       @Test void g04_storeOutage_onStartLogin_failsClosed_noLeak() {
+               var fake = new FakeStore();
+               fake.storeThrows = new RuntimeException("backend echoed " + 
LEAK_SENTINEL);
+               var rp = rp(fake);
+
+               var req = MockServletRequest.create("GET", "/auth/login");
+               var res = MockServletResponse.create();
+               var ex = assertThrows(AuthenticationException.class, () -> 
rp.startLogin(req, res));
+
+               assertNull(res.getHeader("Location"), "no authorization 
redirect may be issued on a store outage (fail closed)");
+               
assertFalse(String.valueOf(ex.getMessage()).contains(LEAK_SENTINEL),
+                       "the propagated error must not echo the backend 
secret");
+               assertNull(ex.getCause(), "the raw backend exception (carrying 
the secret) must not be chained as the cause");
+       }
+
+       
//-----------------------------------------------------------------------------------------------------------------
+       // Gate 5 — consume outage → rejected with the DISTINCT "unavailable" 
message (vs the CSRF-miss message).
+       
//-----------------------------------------------------------------------------------------------------------------
+
+       @Test void g05_consumeOutage_rejectedAsUnavailable() throws Exception {
+               var fake = new FakeStore();
+               fake.consumeThrows = new RuntimeException("backend down " + 
LEAK_SENTINEL);
+               var rp = rp(fake);
+
+               var state = startLoginGetState(rp);
+               signIdTokenWith(FAKE_NONCE);
+               var ex = assertThrows(AuthenticationException.class, () -> 
completeLogin(rp, state));
+
+               assertTrue(ex.getMessage().contains("unavailable"),
+                       "consume outage must be rejected as unavailable, got: " 
+ ex.getMessage());
+               assertFalse(ex.getMessage().contains("missing, expired, or 
already used"),
+                       "the infra-outage signal must be distinct from the 
CSRF/replay miss");
+               
assertFalse(String.valueOf(ex.getMessage()).contains(LEAK_SENTINEL), "must not 
echo the backend secret");
+               assertEquals(0, idp.tokenHits.get(), "a consume outage must not 
reach the token exchange");
+       }
+
+       
//-----------------------------------------------------------------------------------------------------------------
+       // Gate 6 — consume-time redirect re-validation: an evil redirectTarget 
from the store is discarded → "/".
+       
//-----------------------------------------------------------------------------------------------------------------
+
+       @Test void g06_evilRedirectTarget_fromStore_isDiscarded() throws 
Exception {
+               var fake = new FakeStore();
+               fake.consumeResult = 
Optional.of(pending("https://evil.example.com";, NOW, 
NOW.plus(Duration.ofMinutes(5))));
+               var rp = rp(fake);
+
+               var state = startLoginGetState(rp);
+               signIdTokenWith(FAKE_NONCE);
+               var res = completeLogin(rp, state);
+
+               assertEquals("/", res.getHeader("Location"),
+                       "a writable-store absolute redirect must be 
re-validated away in favor of the post-login default");
+       }
+
+       
//-----------------------------------------------------------------------------------------------------------------
+       // Gate 7 — framework expiry ceiling beats an over-age store record, 
BEFORE the token exchange.
+       
//-----------------------------------------------------------------------------------------------------------------
+
+       @Test void g07_overTtlRecord_rejected_beforeExchange() throws Exception 
{
+               // Past its minted expiresAt (now-1min) but well within MAX_TTL 
of createdAt (only 6 min old): a
+               // MAX_TTL-only implementation would accept it, so this pins 
the minted-expiresAt re-check.
+               var overTtl = pending(FAKE_REDIRECT, 
NOW.minus(Duration.ofMinutes(6)), NOW.minus(Duration.ofMinutes(1)));
+               var fake = new FakeStore();
+               fake.consumeResult = Optional.of(overTtl);
+               var rp = rp(fake);
+
+               var state = startLoginGetState(rp);
+               signIdTokenWith(FAKE_NONCE);  // would-succeed setup: the ONLY 
thing between success and rejection is the expiry re-check
+               var ex = assertThrows(AuthenticationException.class, () -> 
completeLogin(rp, state));
+
+               // (2) message-specific, not class-only — distinct from the 
post-exchange throws.
+               assertTrue(ex.getMessage().contains("missing, expired, or 
already used"),
+                       "over-age record must be rejected with the expiry/miss 
message, got: " + ex.getMessage());
+               // (3) token endpoint never hit — proves the re-check runs 
BEFORE codeFlow().exchange.
+               assertEquals(0, idp.tokenHits.get(), "the expiry re-check must 
run before the token exchange (code not burned)");
+               // consume-miss disambiguation — the present over-TTL record 
WAS consumed once, then rejected for age.
+               assertTrue(fake.consumeCount.is(1), "the injected consume must 
have been invoked once");
+               assertEquals(state, fake.lastConsumeState);
+               assertTrue(fake.lastConsumeReturned.isPresent(), "the record 
was present (not a consume miss)");
+               assertEquals(NOW.minus(Duration.ofMinutes(1)), 
fake.lastConsumeReturned.get().expiresAt(),
+                       "the rejected record is the over-TTL one the fake 
returned");
+       }
+
+       
//-----------------------------------------------------------------------------------------------------------------
+       // Gate 7b — null timestamps fail closed (rejected as expired, NOT an 
NPE→500).
+       
//-----------------------------------------------------------------------------------------------------------------
+
+       @Test void g07b_nullExpiresAt_failsClosed() throws Exception {
+               var fake = new FakeStore();
+               fake.consumeResult = Optional.of(pending(FAKE_REDIRECT, NOW, 
null));
+               var rp = rp(fake);
+
+               var state = startLoginGetState(rp);
+               signIdTokenWith(FAKE_NONCE);
+               var ex = assertThrows(AuthenticationException.class, () -> 
completeLogin(rp, state));
+
+               assertTrue(ex.getMessage().contains("missing, expired, or 
already used"),
+                       "null expiresAt must be treated as expired, got: " + 
ex.getMessage());
+               assertEquals(0, idp.tokenHits.get(), "must reject before the 
token exchange");
+       }
+
+       @Test void g07c_nullCreatedAt_failsClosed() throws Exception {
+               var fake = new FakeStore();
+               fake.consumeResult = Optional.of(pending(FAKE_REDIRECT, null, 
NOW.plus(Duration.ofMinutes(5))));
+               var rp = rp(fake);
+
+               var state = startLoginGetState(rp);
+               signIdTokenWith(FAKE_NONCE);
+               var ex = assertThrows(AuthenticationException.class, () -> 
completeLogin(rp, state));
+
+               assertTrue(ex.getMessage().contains("missing, expired, or 
already used"),
+                       "null createdAt must be treated as expired, got: " + 
ex.getMessage());
+               assertEquals(0, idp.tokenHits.get(), "must reject before the 
token exchange");
+       }
+
+       
//-----------------------------------------------------------------------------------------------------------------
+       // Gate 8 — PendingLogin.toString() redacts both secrets.
+       
//-----------------------------------------------------------------------------------------------------------------
+
+       @Test void g08_pendingLogin_toString_redactsSecrets() {
+               var p = new PendingLogin("nonce123", "verifierXYZ", "/home", 
NOW, NOW.plus(Duration.ofMinutes(5)));
+               var s = p.toString();
+               assertFalse(s.contains("nonce123"), "nonce must be redacted");
+               assertFalse(s.contains("verifierXYZ"), "code_verifier must be 
redacted");
+               assertTrue(s.contains("<redacted>"), "toString must mark 
redacted secrets");
+               assertTrue(s.contains("/home"), "non-secret redirectTarget may 
be shown for diagnostics");
+       }
+
+       
//-----------------------------------------------------------------------------------------------------------------
+       // Gate 9 — default impl atomic single-use survives a 
concurrent-consume race (in-process only).
+       
//-----------------------------------------------------------------------------------------------------------------
+
+       @Test void g09_defaultStore_concurrentConsume_isSingleUse() throws 
Exception {
+               var store = new InMemoryLoginStateStore(Duration.ofMinutes(5), 
1000, CLOCK);
+               var pool = Executors.newFixedThreadPool(2);
+               try {
+                       for (var i = 0; i < 300; i++) {
+                               var stateKey = "state-" + i;
+                               store.store(stateKey, pending(null, NOW, 
NOW.plus(Duration.ofMinutes(5))));
+                               var present = new AtomicInteger();
+                               var start = new CountDownLatch(1);
+                               Callable<Void> task = () -> {
+                                       start.await();
+                                       if (store.consume(stateKey).isPresent())
+                                               present.incrementAndGet();
+                                       return null;
+                               };
+                               var f1 = pool.submit(task);
+                               var f2 = pool.submit(task);
+                               start.countDown();
+                               f1.get();
+                               f2.get();
+                               assertEquals(1, present.get(), "exactly one 
concurrent consume may win the single-use race");
+                       }
+               } finally {
+                       pool.shutdownNow();
+               }
+       }
+}
diff --git 
a/juneau-rest/juneau-rest-server-auth-oidc-rp/src/test/java/org/apache/juneau/rest/server/auth/oidc/rp/OidcRelyingPartyBuilder_Test.java
 
b/juneau-rest/juneau-rest-server-auth-oidc-rp/src/test/java/org/apache/juneau/rest/server/auth/oidc/rp/OidcRelyingPartyBuilder_Test.java
index 31119c3f9e..48d14adc1a 100644
--- 
a/juneau-rest/juneau-rest-server-auth-oidc-rp/src/test/java/org/apache/juneau/rest/server/auth/oidc/rp/OidcRelyingPartyBuilder_Test.java
+++ 
b/juneau-rest/juneau-rest-server-auth-oidc-rp/src/test/java/org/apache/juneau/rest/server/auth/oidc/rp/OidcRelyingPartyBuilder_Test.java
@@ -20,8 +20,10 @@ import static org.junit.jupiter.api.Assertions.*;
 
 import java.net.*;
 import java.time.*;
+import java.util.*;
 
 import org.apache.juneau.*;
+import org.apache.juneau.rest.server.auth.oidc.rp.LoginStateStore.PendingLogin;
 import org.junit.jupiter.api.*;
 
 /**
@@ -134,4 +136,56 @@ class OidcRelyingPartyBuilder_Test extends TestBase {
                        .build();
                assertNotNull(rp);
        }
+
+       // 
-----------------------------------------------------------------------------------------------------------------
+       // F: stateNonceTtl (0, MAX_TTL] cap enforced on a site that runs for 
CUSTOM stores + loginStateStore hook.
+       // 
-----------------------------------------------------------------------------------------------------------------
+
+       // A TTL-oblivious no-op store, constructed independently of any TTL 
value (S1): it does NOT implement the
+       // (0, MAX_TTL] cap, so a rejection with this store injected proves the 
cap comes from the builder side, not
+       // from the default InMemoryLoginStateStore constructor (which is 
skipped when a custom store is supplied).
+       private static final LoginStateStore NOOP_STORE = new LoginStateStore() 
{
+               @Override public void store(String state, PendingLogin pending) 
{ /* no-op */ }
+               @Override public Optional<PendingLogin> consume(String state) { 
return Optional.empty(); }
+       };
+
+       /**
+        * Over-30-min TTL rejected even when a TTL-oblivious custom store is 
injected — proves the cap runs on the
+        * custom-store path (Phase 2 gate).  On main the cap lives only in the 
default impl ctor, which a custom
+        * store skips, so this over-TTL config would build successfully → gate 
red.
+        */
+       @Test void f01_stateNonceTtl_over30Min_rejected_customStorePath() {
+               assertThrows(IllegalArgumentException.class,
+                       () -> 
base().loginStateStore(NOOP_STORE).stateNonceTtl(Duration.ofMinutes(31)).build());
+       }
+
+       /** Zero TTL rejected on the custom-store path (fail-closed = 
immediately-expired), sibling of the 31-min case. */
+       @Test void f02_stateNonceTtl_zero_rejected_customStorePath() {
+               assertThrows(IllegalArgumentException.class,
+                       () -> 
base().loginStateStore(NOOP_STORE).stateNonceTtl(Duration.ZERO).build());
+       }
+
+       /** Negative TTL rejected — second bytecode branch of {@code !isZero && 
!isNegative}. */
+       @Test void f03_stateNonceTtl_negative_rejected() {
+               assertThrows(IllegalArgumentException.class, () -> 
base().stateNonceTtl(Duration.ofSeconds(-1)));
+       }
+
+       /** A valid TTL with a custom store is accepted. */
+       @Test void f04_stateNonceTtl_valid_customStore_accepted() {
+               assertDoesNotThrow(() -> 
base().loginStateStore(NOOP_STORE).stateNonceTtl(Duration.ofMinutes(10)).build());
+       }
+
+       /** The optional loginStateStore hook rejects null. */
+       @Test void f05_loginStateStore_null_rejected() {
+               assertThrows(IllegalArgumentException.class, () -> 
base().loginStateStore(null));
+       }
+
+       /**
+        * Over-30-min TTL rejected on the DEFAULT-store path too (S5 
throw-site test).  Same
+        * {@link IllegalArgumentException} type as on main; only the throw 
<i>site</i> moves (impl ctor at
+        * {@code build()} &rarr; the {@code stateNonceTtl(...)} setter), 
pinning the Decision-3 delta.
+        */
+       @Test void f06_stateNonceTtl_over30Min_rejected_defaultStorePath() {
+               assertThrows(IllegalArgumentException.class, () -> 
base().stateNonceTtl(Duration.ofMinutes(31)).build());
+       }
 }
diff --git 
a/juneau-rest/juneau-rest-server-auth-oidc-rp/src/test/java/org/apache/juneau/rest/server/auth/oidc/rp/OidcTestSupport.java
 
b/juneau-rest/juneau-rest-server-auth-oidc-rp/src/test/java/org/apache/juneau/rest/server/auth/oidc/rp/OidcTestSupport.java
index bc2c02c3e2..4385c0ac3f 100644
--- 
a/juneau-rest/juneau-rest-server-auth-oidc-rp/src/test/java/org/apache/juneau/rest/server/auth/oidc/rp/OidcTestSupport.java
+++ 
b/juneau-rest/juneau-rest-server-auth-oidc-rp/src/test/java/org/apache/juneau/rest/server/auth/oidc/rp/OidcTestSupport.java
@@ -121,6 +121,19 @@ final class OidcTestSupport {
                return null;
        }
 
+       /** Extracts a single {@code application/x-www-form-urlencoded} field 
value from a request body (URL-decoded). */
+       static String formField(String body, String name) {
+               if (body == null)
+                       return null;
+               for (var pair : body.split("&")) {
+                       var i = pair.indexOf('=');
+                       var k = i < 0 ? pair : pair.substring(0, i);
+                       if (k.equals(name))
+                               return i < 0 ? "" : 
URLDecoder.decode(pair.substring(i + 1), StandardCharsets.UTF_8);
+               }
+               return null;
+       }
+
        /** Parses the cookie value out of a {@code Set-Cookie} header (the bit 
between {@code =} and the first {@code ;}). */
        static String cookieValue(String setCookieHeader) {
                if (setCookieHeader == null)
@@ -154,6 +167,16 @@ final class OidcTestSupport {
                volatile boolean userInfoFail;
                /** Extra claims served by {@code /userinfo}. */
                volatile Map<String,Object> userInfo = new LinkedHashMap<>();
+               /**
+                * Number of times {@code /token} was invoked.  An order proof 
for gate 7: a rejected {@code completeLogin}
+                * that never reaches {@code codeFlow().exchange} leaves this 
at {@code 0}.
+                */
+               final java.util.concurrent.atomic.AtomicInteger tokenHits = new 
java.util.concurrent.atomic.AtomicInteger();
+               /**
+                * The PKCE {@code code_verifier} observed on the most recent 
{@code /token} call.  Record-only &mdash; the
+                * stub still succeeds with any verifier; this only lets a test 
assert the injected verifier was exchanged.
+                */
+               volatile String lastCodeVerifier;
 
                StubIdp() throws IOException {
                        server = HttpServer.create(new 
InetSocketAddress(InetAddress.getLoopbackAddress(), 0), 0);
@@ -163,7 +186,9 @@ final class OidcTestSupport {
                }
 
                private void handleToken(HttpExchange ex) throws IOException {
+                       tokenHits.incrementAndGet();
                        var body = new 
String(ex.getRequestBody().readAllBytes(), StandardCharsets.UTF_8);
+                       lastCodeVerifier = formField(body, "code_verifier");
                        if (failToken) {
                                writeJson(ex, 400, 
"{\"error\":\"invalid_grant\"}");
                                return;
@@ -237,4 +262,14 @@ final class OidcTestSupport {
                        server.stop(0);
                }
        }
+
+       /** A test clock the test can advance manually. */
+       static final class MutableClock extends Clock {
+               private Instant now;
+               MutableClock(Instant start) { now = start; }
+               void advance(Duration d) { now = now.plus(d); }
+               @Override public ZoneId getZone() { return ZoneOffset.UTC; }
+               @Override public Clock withZone(ZoneId zone) { return this; }
+               @Override public Instant instant() { return now; }
+       }
 }
diff --git 
a/juneau-rest/juneau-rest-server-auth-oidc-rp/src/test/java/org/apache/juneau/rest/server/auth/oidc/rp/SignedCookieSessionStore_Test.java
 
b/juneau-rest/juneau-rest-server-auth-oidc-rp/src/test/java/org/apache/juneau/rest/server/auth/oidc/rp/SignedCookieSessionStore_Test.java
index 0bd532927d..033ff700d7 100644
--- 
a/juneau-rest/juneau-rest-server-auth-oidc-rp/src/test/java/org/apache/juneau/rest/server/auth/oidc/rp/SignedCookieSessionStore_Test.java
+++ 
b/juneau-rest/juneau-rest-server-auth-oidc-rp/src/test/java/org/apache/juneau/rest/server/auth/oidc/rp/SignedCookieSessionStore_Test.java
@@ -90,7 +90,7 @@ class SignedCookieSessionStore_Test extends TestBase {
 
        @Test void c01_expiredCookie_isRejected() {
                var base = Instant.parse("2026-01-01T00:00:00Z");
-               var clock = new EphemeralStore_Test.MutableClock(base);
+               var clock = new OidcTestSupport.MutableClock(base);
                var store = 
SignedCookieSessionStore.create().signingKey(KEY).clock(clock).build();
                var cookie = store.createSessionCookieValue(session(base, 
null));
                clock.advance(Duration.ofHours(9));

Reply via email to