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` — 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` → `(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` → `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` → `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 — 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`** — generated before the redirect, stored TTL-bounded (default 5 min), and atomically consumed on the callback. A missing / replayed `state` fails the callback (CSRF + ID-token-replay defense). +- **Single-use `state` + `nonce`** — generated before the redirect, stored TTL-bounded (default 5 min, capped by a framework-owned 30 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** — independently of the store — 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** — 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** — a fresh session id is generated on login; there is no pre-auth fixation window. - **Token redaction** — auth responses set `Cache-Control: no-store`; tokens are never logged. - **Cookie flags** — the session cookie is `HttpOnly` + `Secure` + `SameSite=Lax` by default (override via the builder). -- **Open-redirect defense** — 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** — 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`) — the store is treated as an untrusted blob store, not a trusted oracle. ## Refresh-token rotation
