Interface LoginStateStore
- All Known Implementing Classes:
InMemoryLoginStateStore
state → (nonce, PKCE code_verifier,
redirect target) association created during OidcRelyingParty.startLogin(jakarta.servlet.http.HttpServletRequest, jakarta.servlet.http.HttpServletResponse) and consumed during
OidcRelyingParty.completeLogin(jakarta.servlet.http.HttpServletRequest, jakarta.servlet.http.HttpServletResponse).
The shipped default is InMemoryLoginStateStore (per-JVM, bounded, TTL-swept). Supply a shared
(Redis / JDBC) implementation via OidcRelyingParty.Builder.loginStateStore(LoginStateStore) to
support a clustered deployment where /login and /callback may land on different nodes
without sticky sessions — the same motivation as a distributed SessionStore. When the
hook is unused, behavior is identical to the single-node default.
Security contract (read before implementing)
Secret-bearing. A stored entry carries the PKCE code_verifier (and nonce). With a
stolen authorization code (Referer, proxy log, browser history) the 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 LoginStateStore.PendingLogin (its
toString() is redacted for exactly this reason), and MUST NOT put LoginStateStore.PendingLogin.codeVerifier()
or LoginStateStore.PendingLogin.nonce() into MDC, metric tags, or field-wise serializers — the accessors
exist only for the token exchange and the ID-token nonce check.
The framework does not trust the store as an oracle. 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 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 expiresAt against its own Clock.
The store MAY evict early; it is NOT the authoritative expiry decision-maker. Note this defeats an
honest store that merely forgot to expire a record (past expiresAt); a malicious rewrite
of 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.
Thread-safe. Implementations MUST be safe for concurrent store / consume from
multiple request threads (as SessionStore requires).
Per-process by default. The shipped default enforces single-use only within one JVM. Cross-node
single-use requires a shared backend with an atomic consume primitive (see consume(String)).
See Also:
- Since:
- 10.0.0
-
Nested Class Summary
Nested ClassesModifier and TypeInterfaceDescriptionstatic final recordA pending-login association awaiting the IdP callback. -
Field Summary
Fields -
Method Summary
Modifier and TypeMethodDescriptionAtomically removes and returns the pending-login association forstate, if present.voidstore(String state, LoginStateStore.PendingLogin pending) Stores a framework-built pending-login association keyed bystate.
-
Field Details
-
MAX_TTL
Hard cap on the framework-enforced login-state TTL — the single public home for the ceiling.
-
-
Method Details
-
store
Stores a framework-built pending-login association keyed bystate.Implementations MUST persist the association durably enough that a subsequent
consume(String)on any node handling the callback can retrieve it. On an infrastructure failure (backend unreachable, write rejected) implementations MUST throw rather than silently drop the record:storeis invoked on thestartLoginleg before any authorization URL is issued, so a thrown exception fails the flow closed with no half-initiated login. Implementations MUST NOT log theLoginStateStore.PendingLoginpayload; wrap / sanitize backend exceptions so thecodeVerifier/statedo not leak into a 500 body or log.Last-writer-wins: a repeated
storefor the samestateoverwrites (a NimbusStatecollision is astronomical). A backend MAY useSET NXbut is not required to. A backend MAY use the remaining time toexpiresAtas a GC /EXPIREhint, but GC failure MUST NOT extend usability past the framework's re-checkedexpiresAt.- Parameters:
state- The opaquestatevalue. Must not benullor blank.pending- The framework-built association (already stamped withcreatedAt/expiresAt). Must not benull.
-
consume
Atomically removes and returns the pending-login association forstate, if present.Single-use / atomicity is mandatory (TOCTOU). The removal and the read MUST be a single atomic operation so a given
stateis consumed at most once. Of any two concurrent calls with the samestate, at most one returns a presentOptional; the other MUST miss — including across nodes sharing the store. Use an atomic primitive against the primary (RedisGETDELor a LuaGET+DEL), not a read-then-delete and not 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 — never a best-effort put-back (put-back reopens replay); the user simply restarts login.Bounded I/O. Implementations MUST apply a bounded deadline to the backend round-trip so a hung backend cannot stall
completeLoginon a request thread indefinitely.empty()vs. throw — distinct meanings. ReturnOptional.empty()only for absent / already-consumed / (optionally) store-expired states — the caller treatsempty()as a CSRF / replay / expiry rejection. On an infrastructure failure (backend unreachable) the implementation MUST throw (not returnempty()) 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 aLoginStateStore.PendingLogin.Expiry is not the store's sole responsibility. An implementation MAY treat past-
expiresAtentries as a miss (early eviction), but the RP re-checksexpiresAton the returned record regardless, so it is not a security defect if a store returns an over-age record — the framework rejects it.- Parameters:
state- Thestatevalue from the callback. May benull(returnsempty(), matching the defensive callback path — do not throw).- Returns:
- The association, or
Optional.empty()if absent, expired, or already consumed.
-