Interface LoginStateStore

All Known Implementing Classes:
InMemoryLoginStateStore

public interface LoginStateStore
SPI for the single-use, TTL-bounded 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 Classes
    Modifier and Type
    Interface
    Description
    static final record 
    A pending-login association awaiting the IdP callback.
  • Field Summary

    Fields
    Modifier and Type
    Field
    Description
    static final Duration
    Hard cap on the framework-enforced login-state TTL — the single public home for the ceiling.
  • Method Summary

    Modifier and Type
    Method
    Description
    consume(String state)
    Atomically removes and returns the pending-login association for state, if present.
    void
    Stores a framework-built pending-login association keyed by state.
  • Field Details

    • MAX_TTL

      static final Duration MAX_TTL
      Hard cap on the framework-enforced login-state TTL — the single public home for the ceiling.
  • Method Details

    • store

      void store(String state, LoginStateStore.PendingLogin pending)
      Stores a framework-built pending-login association keyed by state.

      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: store is invoked on the 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 LoginStateStore.PendingLogin payload; wrap / sanitize backend exceptions so the codeVerifier / state do not leak into a 500 body or log.

      Last-writer-wins: a repeated store for the same state overwrites (a Nimbus State collision is astronomical). A backend MAY use SET NX but is not required to. A backend MAY use the remaining time to expiresAt as a GC / EXPIRE hint, but GC failure MUST NOT extend usability past the framework's re-checked expiresAt.

      Parameters:
      state - The opaque state value. Must not be null or blank.
      pending - The framework-built association (already stamped with createdAt / expiresAt). Must not be null.
    • consume

      Atomically removes and returns the pending-login association for state, if present.

      Single-use / atomicity is mandatory (TOCTOU). The removal and the read MUST be a single atomic operation so a given state is consumed at most once. Of any two concurrent calls with the same state, at most one returns a present Optional; the other MUST miss — including across nodes sharing the store. Use an atomic primitive against the primary (Redis GETDEL or a Lua GET+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 completeLogin on a request thread indefinitely.

      empty() vs. throw — distinct meanings. Return Optional.empty() only for absent / already-consumed / (optionally) store-expired states — the caller treats empty() as a CSRF / replay / expiry rejection. On an infrastructure failure (backend unreachable) the implementation MUST throw (not return 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 LoginStateStore.PendingLogin.

      Expiry is not the store's sole responsibility. An implementation MAY treat past-expiresAt entries as a miss (early eviction), but the RP re-checks expiresAt on 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 - The state value from the callback. May be null (returns empty(), matching the defensive callback path — do not throw).
      Returns:
      The association, or Optional.empty() if absent, expired, or already consumed.