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

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

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

    TODO-397: Document injectable LoginStateStore SPI for multi-node OIDC login 
state
---
 pages/release-notes/10.0.0.md          | 11 +++++++++++
 pages/topics/10.48.OidcRelyingParty.md | 29 ++++++++++++++++++++++++++---
 2 files changed, 37 insertions(+), 3 deletions(-)

diff --git a/pages/release-notes/10.0.0.md b/pages/release-notes/10.0.0.md
index d2e1303e8e..b830be7c8a 100644
--- a/pages/release-notes/10.0.0.md
+++ b/pages/release-notes/10.0.0.md
@@ -1215,6 +1215,17 @@ See the [confirm-delete 
recipe](/docs/topics/JuneauMcpRecipes#elicitation--multi
 
 See 
[juneau-rest-server-mcp](/docs/topics/JuneauRestServerMcp#resource-templates-reads-and-completions)
 for the resolution-order and error-code details.
 
+### OIDC Relying Party — injectable `LoginStateStore` SPI for clustered login 
(TODO-397)
+
+`juneau-rest-server-auth-oidc-rp` gains a pluggable **`LoginStateStore`** SPI 
so an OIDC login flow can survive a redirect/callback landing on different 
nodes — i.e. clustered login **without** sticky sessions. Previously the 
transient `state` → `(nonce, code_verifier, redirectTarget)` map was per-JVM 
only.
+
+- **New `LoginStateStore` interface** — `store(state, PendingLogin)` + atomic 
single-use `consume(state)`, with a framework-owned `MAX_TTL` ceiling (30 min). 
`PendingLogin` (moved onto `LoginStateStore`) now carries a framework-minted 
`expiresAt` and a `toString()` that redacts the secret-bearing `nonce` / 
`code_verifier`.
+- **`Builder.loginStateStore(...)`** — inject a shared (Redis / JDBC) store 
for clustered deployments; the `stateNonceTtl` cap `(0, MAX_TTL]` is enforced 
on the custom-store path too, not just the default implementation.
+- **Consume-time fail-closed re-checks** — after `consume(state)` and 
**before** the token exchange, the framework re-validates `redirectTarget` via 
`safeRelativePath` (a writable shared store cannot smuggle in an off-site 
redirect) and re-checks `expiresAt` against its own clock (a `null`-timestamp 
or over-TTL record is rejected as expired rather than trusted). The store is 
treated as an untrusted blob store, not an oracle.
+- **Default implementation renamed `EphemeralStore` → 
`InMemoryLoginStateStore`.** As this module is new in 10.0.0, the rename, the 
`PendingLogin` move onto `LoginStateStore`, and the new `store(String, 
PendingLogin)` signature are source-level deltas within the unreleased line — 
no migration for released users.
+
+See [OIDC Relying Party 
Login](/docs/topics/OidcRelyingParty#login-state-store-clustering) for the 
clustering guidance and store contract.
+
 ### Bug Fixes
 
 - **Fixed RRPC method calls never dispatching over POST.** Every HTTP POST to 
an `@RestOp(method="RRPC")` operation previously returned a 404 instead of 
reaching the target method. `RrpcRestOpSession` derived the RRPC method key by 
splitting the request path on the last `/`, but RRPC keys are of the form 
`methodName/(paramTypes)` and themselves contain a `/`, so the method name was 
stripped off and the lookup always fell through to `NotFound`. The key is now 
derived from the already-comp [...]
diff --git a/pages/topics/10.48.OidcRelyingParty.md 
b/pages/topics/10.48.OidcRelyingParty.md
index 4b0d4f5e1a..c35e9ae9d5 100644
--- a/pages/topics/10.48.OidcRelyingParty.md
+++ b/pages/topics/10.48.OidcRelyingParty.md
@@ -18,7 +18,8 @@ This module owns only orchestration, the session SPI, and the 
single-use `state`
 | 
[`OidcSession`](/site/apidocs/org/apache/juneau/rest/server/auth/oidc/rp/OidcSession.html)
 | Immutable record: principal + roles + tokens + `sub`/`sid` + expiry. |
 | 
[`SignedCookieSessionStore`](/site/apidocs/org/apache/juneau/rest/server/auth/oidc/rp/SignedCookieSessionStore.html)
 | **The documented default.** Stateless HMAC-signed cookie; scales 
horizontally, survives restart. |
 | 
[`InMemorySessionStore`](/site/apidocs/org/apache/juneau/rest/server/auth/oidc/rp/InMemorySessionStore.html)
 | Single-instance / dev option. Server-side indexed by `sub`/`sid` &mdash; the 
one that supports back-channel logout. |
-| 
[`EphemeralStore`](/site/apidocs/org/apache/juneau/rest/server/auth/oidc/rp/EphemeralStore.html)
 | Single-use, TTL-bounded `state` &rarr; `(nonce, codeVerifier, 
redirectTarget)` store. |
+| 
[`LoginStateStore`](/site/apidocs/org/apache/juneau/rest/server/auth/oidc/rp/LoginStateStore.html)
 | SPI for the single-use, TTL-bounded `state` &rarr; `PendingLogin` (`nonce` / 
`code_verifier` / `redirectTarget`) store; framework-owned `MAX_TTL` ceiling + 
atomic single-use consume. Inject a shared store via 
`Builder.loginStateStore(...)` for clustered login. |
+| 
[`InMemoryLoginStateStore`](/site/apidocs/org/apache/juneau/rest/server/auth/oidc/rp/InMemoryLoginStateStore.html)
 | **The documented default.** Single-use, TTL-bounded, per-JVM 
`LoginStateStore` (renamed from `EphemeralStore`). |
 | 
[`OidcSessionAuthFilter`](/site/apidocs/org/apache/juneau/rest/server/auth/oidc/rp/OidcSessionAuthFilter.html)
 | `AuthFilter` that resolves the session cookie into a `ClaimsPrincipal` on 
each request. |
 | 
[`IdTokenValidatorAdapter`](/site/apidocs/org/apache/juneau/rest/server/auth/oidc/rp/IdTokenValidatorAdapter.html)
 | Wraps Nimbus's `IDTokenValidator` (signature + 
`iss`/`aud`/`azp`/`exp`/`nonce`). |
 
@@ -93,17 +94,39 @@ 
SignedCookieSessionStore.create().signingKey(env("SESSION_KEY")).build();
 InMemorySessionStore.create();
 ```
 
+## Login-state store (clustering)
+
+The transient `state` &rarr; `PendingLogin` map that bridges the authorization 
redirect and the callback is a separate SPI from the session store. The default 
[`InMemoryLoginStateStore`](/site/apidocs/org/apache/juneau/rest/server/auth/oidc/rp/InMemoryLoginStateStore.html)
 is per-JVM, so it enforces single-use **only within one node**.
+
+| Store | Enforces single-use across nodes | Notes |
+|-------|:---:|-------|
+| `InMemoryLoginStateStore` (default) | no (per-JVM only) | Fine for 
single-instance or sticky-session deployments. |
+| Caller-supplied shared store (Redis / JDBC) via 
`Builder.loginStateStore(...)` | yes (if implemented atomically) | Required for 
clustered login **without** sticky sessions. |
+
+```java
+OidcRelyingParty.create()
+    // ...issuer / clientId / redirectUri / sessionStore...
+    .loginStateStore(myRedisLoginStateStore)   // shared, atomic single-use
+    .build();
+```
+
+A clustered deployment that cannot pin the redirect and callback to the same 
node must supply a shared `LoginStateStore`. When you do, honor the contract on 
[`LoginStateStore`](/site/apidocs/org/apache/juneau/rest/server/auth/oidc/rp/LoginStateStore.html):
+
+- **The store is secret-bearing.** `PendingLogin` carries the PKCE 
`code_verifier` and the `nonce`. TLS + ACLs are the floor; encryption-at-rest 
is recommended; never log the payload (`PendingLogin.toString()` already 
redacts the secrets).
+- **Consume must be atomically single-use** against the primary &mdash; an 
atomic `GETDEL` (Redis) / `SELECT … FOR UPDATE … DELETE` (JDBC) / Lua, not a 
read-then-delete race. A replayed `state` must resolve at most once.
+- **The framework does not trust the store as an oracle.** On consume it 
re-validates `redirectTarget` via `safeRelativePath` and re-checks `expiresAt` 
against its own clock (rejecting `null`-timestamp or over-`MAX_TTL` records) 
before any token exchange. Treat the store as an untrusted blob store.
+
 ## Security
 
 The module is opt-in, security-reviewed glue. The defaults are fail-closed:
 
-- **Single-use `state` + `nonce`** &mdash; generated before the redirect, 
stored TTL-bounded (default 5&nbsp;min), and atomically consumed on the 
callback. A missing / replayed `state` fails the callback (CSRF + 
ID-token-replay defense).
+- **Single-use `state` + `nonce`** &mdash; generated before the redirect, 
stored TTL-bounded (default 5&nbsp;min, capped by a framework-owned 30&nbsp;min 
`MAX_TTL`), and atomically consumed on the callback. A missing / replayed 
`state` fails the callback (CSRF + ID-token-replay defense). On consume the 
framework **re-checks `expiresAt` against its own clock** &mdash; independently 
of the store &mdash; and rejects an over-TTL (or `null`-timestamp) record 
fail-closed **before** the token e [...]
 - **PKCE S256** is enforced end-to-end (verifier persisted across the 
redirect, used at exchange).
 - **Strict ID-token validation** &mdash; signature against the IdP JWKS, exact 
`iss` match, `aud` contains the client id, `azp` on multi-audience tokens, 
`exp`/`iat` within clock skew, and `nonce` match. The signing-algorithm 
allowlist defaults to `[RS256, ES256]`; `none` and SHA-1-family algorithms are 
rejected (inheriting the `juneau-rest-server-auth-jwt` strict-default stance).
 - **Session-id rotation** &mdash; a fresh session id is generated on login; 
there is no pre-auth fixation window.
 - **Token redaction** &mdash; auth responses set `Cache-Control: no-store`; 
tokens are never logged.
 - **Cookie flags** &mdash; the session cookie is `HttpOnly` + `Secure` + 
`SameSite=Lax` by default (override via the builder).
-- **Open-redirect defense** &mdash; the post-login `redirect` parameter is 
honored only when it is a safe app-relative path; absolute / protocol-relative 
targets are dropped.
+- **Open-redirect defense** &mdash; the post-login `redirect` parameter is 
honored only when it is a safe app-relative path; absolute / protocol-relative 
targets are dropped. The `redirectTarget` read back from the login-state store 
is **re-validated via `safeRelativePath` on consume** as well, so a writable 
shared `LoginStateStore` cannot smuggle in an off-site target (e.g. 
`https://evil.example.com`) &mdash; the store is treated as an untrusted blob 
store, not a trusted oracle.
 
 ## Refresh-token rotation
 

Reply via email to