From e3aeaeff3e88ed82f62d86fea69d9e3d4a33e78c Mon Sep 17 00:00:00 2001 From: Tihomir Mateev Date: Wed, 19 Aug 2026 08:40:09 +0300 Subject: [PATCH 01/11] docs: add session management research dossier Records the research and recommendation for server-side sessions, plus the upstream strategy (Section 6) and the store-contract decision (Section 7). Committed before the ASD-STE100 simplification pass so the analytical prose version stays recoverable and the rewrite is a reviewable diff. Co-Authored-By: Claude Opus 5 (1M context) --- docs/specs/session-mgmt.md | 534 +++++++++++++++++++++++++++++++++++++ 1 file changed, 534 insertions(+) create mode 100644 docs/specs/session-mgmt.md diff --git a/docs/specs/session-mgmt.md b/docs/specs/session-mgmt.md new file mode 100644 index 0000000..78de818 --- /dev/null +++ b/docs/specs/session-mgmt.md @@ -0,0 +1,534 @@ +# Session management in the Starlette/FastAPI ecosystem — research dossier & recommendation + +## Context + +`fastapi-redis-sdk` is the official Redis integration for FastAPI. It already ships +connection management, DI-based caching (`cache`/`cache_evict`/`cache_put`, +`CacheBackend`), rate limiting (`rate_limit`, `RateLimitBackend`), and OTel +instrumentation, all behind the fluent `FastAPIRedis(app).lifespan().caching().rate_limiting().otel()` +surface. The open question is whether **server-side sessions** are the right next +primitive: is the absence of a canonical FastAPI session solution a sign the need +doesn't exist, or a genuine gap the ecosystem would adopt? + +This document records what the research found (primary sources, adoption numbers, +maintainer commitments) and what I recommend building as a result. + +--- + +## 1. What core maintainers have committed to — it is settled, and it is "no" + +Both upstream projects have explicitly and repeatedly declined to own session +backends. This is not neglect; it is a stated scope decision, reaffirmed over +seven years. + +| Source | Date | Outcome | +|---|---|---| +| [fastapi#754 "First-class session support"](https://github.com/fastapi/fastapi/issues/754) | Nov 2019 → closed Feb 2020 | tiangolo: *"It's already in place. More or less like the rest of the security tools… It's just not properly documented yet."* — pointed at `APIKeyCookie` + JWT-in-cookie. Closed as answered. | +| [encode/starlette#499 "Add pluggable session backends"](https://github.com/encode/starlette/pull/499) | May 2019 → closed **unmerged** Feb 2022 | Tom Christie: *"let's continue with the 'as a third party packages' approach… it looks to me like we oughta scope Starlette as 'feature complete' and just let folks build other stuff on top of it."* | +| [starlette#1801 "Why are Starlette sessions so basic?"](https://github.com/Kludex/starlette/discussions/1801) | Aug 2022 | adriangb (accepted answer): Starlette is *"a **minimal** and **composable** web toolkit, not a complete web framework with all of the batteries included"*; *"if it can be implemented as an external package, we'd prefer that to bringing it into Starlette itself."* When the OP proposed **removing** sessions from core entirely and pointing at `starsessions`: adriangb *"I don't disagree with you"*, Kludex *"That's a good point. 👍"* | +| [starlette#2256 (+ PR #2255) JWT sessions](https://github.com/Kludex/starlette/discussions/2256) | Aug 2023 | Rejected. Response: *"can very well be an independent package… If it's a feature that's popular… it will be maintained by the community."* Discussion left unanswered; author shipped an alpha third-party package instead. | +| [fastapi#10370 Roadmap](https://github.com/fastapi/fastapi/issues/10370) | current | No session or auth workstream. Items are Pydantic v2, Starlette upgrades, dropping EOL Pythons. | + +**Key rebuttal on record, still unanswered:** in #754, the requester pointed out +that JWT-in-a-cookie only works while session data fits in +[RFC 6265's 4096-byte cookie limit](https://tools.ietf.org/html/rfc6265#section-6.1), +and that the real ask was Django/PHP-style swappable server-side backends. dmontagu +(then core) agreed FastAPI should only add DI + OpenAPI polish over *Starlette-owned* +backends — and Starlette then declined to own them. The feature fell down the gap +between the two projects and has stayed there. + +**Governance note:** Starlette and Uvicorn moved from `encode` to Kludex's personal +handle; Starlette shipped 1.0 and is at **1.6.0 (Aug 2026)**. A 1.0 release under a +"feature complete" scope makes core adoption *less* likely, not more. + +### 1a. The one thing that did change — and it matters to us + +[starlette#3166](https://github.com/Kludex/starlette/pull/3166), authored by **Kludex** +and merged **2026-03-01**, replaces the plain `dict` at `scope["session"]` with a +`Session` subclass that tracks two flags, explicitly following Django and Flask +convention: + +- `accessed` — set when the session is read (via the `HTTPConnection.session` property) +- `modified` — set on `__setitem__`/`__delitem__`/`clear`/`update`; `pop`/`setdefault` + mark modified only when the value actually changes + +The middleware now emits `Set-Cookie` **only when `modified`**, and adds +`Vary: Cookie` when `accessed`. This closes the long-standing +[#2019 race condition](https://github.com/Kludex/starlette/issues/2019) where a slow +read-only response clobbered a newer session cookie. + +Core still has **no backend parameter** — it remains itsdangerous-signed cookies only. +But `accessed`/`modified` is precisely the primitive a server-side store needs to avoid +a Redis round-trip on every request. Starlette has, incidentally, just built the hook. +This is the single most important technical finding: it did not exist when every +current session library was designed. + +--- + +## 2. What exists today, and how much it is actually used + +PyPI downloads, last 30 days (`pypistats`), with repo health: + +| Package | Downloads/mo | Stars | Status | +|---|---|---|---| +| `starlette` | 665M | — | 1.6.0, Aug 2026 | +| `fastapi` | 603M | — | active | +| **`fastapi-users`** | **1.53M** | 6.2k | active (Aug 2026) | +| `starsessions` | 429k | 123 | active, v2.3.0a1 Mar 2026 — **the one Starlette docs point to** | +| `fastapi-sessions` | 70k | 109 | **ARCHIVED**, last push Jul 2023 | +| `authx` | 69k | 1.2k | active | +| `starlette-session` | 41k | 37 | stale, last push Feb 2023 | +| `starlette-authlib` | 2.6k | — | niche | +| `fastsession` | 224 | — | negligible | +| *for contrast:* `pyjwt` | 705M | — | — | +| *for contrast:* `authlib` | 154M | — | — | + +Two readings, both true: + +- **All dedicated session libraries combined ≈ 610k/mo against 665M Starlette + installs — under 0.1%.** No incumbent has won. The Starlette-endorsed option has + 123 stars. 70k downloads/month flow to an **archived** package, which is a live + supply-chain problem, not a healthy market. +- **`fastapi-users` alone is 2.5× all session libraries combined**, and its + [`RedisStrategy`](https://fastapi-users.github.io/fastapi-users/latest/configuration/authentication/strategies/redis/) + *is* a server-side session store in all but name: an opaque token is stored in Redis + mapped to a user id, looked up per request, and **deleted on logout** for true + revocation. It is the most-used server-side session implementation in the ecosystem + and it is not labelled "session". + +**Conclusion: demand is real but rerouted.** It is expressed as (a) `fastapi-users` +Redis strategy, (b) the hand-rolled `secrets.token_urlsafe(32)` + `SETEX` + cookie +pattern that every tutorial and vendor guide teaches, and (c) hosted IdPs +(WorkOS/Auth0/PropelAuth). "Session library downloads" is the wrong metric. + +### 2a. What Starlette's built-in session is actually used for + +The dominant real-world use of `SessionMiddleware` is **not** user sessions — it is +**OAuth handshake state for Authlib**, which stores the `state` parameter and PKCE code +verifier in `request.session` and hard-requires the middleware. That explains why the +basic signed-cookie version was "good enough" for so long: the payload is tiny and +short-lived. + +It also explains the ecosystem's most-reported session bug class: `mismatching_state` / +`MismatchingStateError`, caused by SameSite/Secure misconfiguration, secret rotation +invalidating old cookies, or the signed cookie silently exceeding 4KB once anyone puts +a token or profile in it. Practitioner guidance for all of these converges on +*"keep the session payload small; store tokens server-side"* — i.e. the thing nobody +ships a blessed solution for. + +--- + +## 3. Is session management tied to security/auth — and can you avoid it? + +Yes it is tied, and largely no you cannot avoid it, once requirements go past +machine-to-machine APIs. From the +[OWASP Session Management Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html): + +- **Opaque IDs + server state:** *"The session ID content (or value) must be meaningless + to prevent information disclosure attacks"*; business logic *"must be stored on the + server side, and specifically, in session objects or in a session management database + or repository."* ≥64 bits entropy from a CSPRNG (≥128 recommended for custom IDs). +- **Rotation:** *"The session ID must be renewed or regenerated… after any privilege + level change"*; *"regeneration is mandatory to prevent session fixation attacks."* + Triggers: authentication, password change, permission change, role elevation. +- **Timeouts:** *"All sessions should implement an idle or inactivity timeout"* **and** + *"an absolute timeout, regardless of session activity"*, and + *"expiration must be enforced server-side."* Client-side enforcement is rejected + outright. Plus an optional **renewal timeout** rotating the ID mid-session. +- **Termination:** on expiry the app *"must take active actions to invalidate the session + on both sides, client and server. The latter is the most relevant."* +- **Concurrency:** users should be able to view active sessions, get concurrent-logon + alerts, and **remotely terminate** sessions. +- **Hijack detection:** *"highly recommended to bind the session ID to other user or + client properties, such as the client IP address, User-Agent"* — for detection, with + the explicit caveat that NAT/proxies/spoofing mean it *"cannot be used… to trustingly + defend."* +- **Implementation:** *"It is recommended to use these built-in frameworks versus + building a home made one from scratch."* +- Tokens must never go in `localStorage`/`sessionStorage`. + +Mapping that against carrier choice: + +| Requirement | Signed cookie / JWT | Server-side store | +|---|---|---| +| Immediate logout / revocation | ✗ (valid until exp) | ✓ delete the key | +| Server-enforced idle **and** absolute timeout | ✗ | ✓ two clocks | +| ID rotation on privilege change | partial (reissue, old still valid) | ✓ | +| List / remotely kill a user's sessions | ✗ | ✓ | +| Payload > 4KB | ✗ | ✓ | +| Keep data off the client | ✗ (signed ≠ encrypted) | ✓ | +| Zero-infrastructure, no per-request lookup | ✓ | ✗ | + +**So:** stateless tokens are legitimately correct for service-to-service calls and +short-lived access tokens. But every OWASP requirement above that involves *revoking, +rotating, bounding, or enumerating* a session needs shared state. The widely-recommended +"hybrid" fix — embed a session id in the JWT and check it against a Redis denylist — +is a session store with strictly worse properties: you pay the lookup anyway, and keep +the token's size and staleness. Once revocation is a requirement, the stateless +argument has already been conceded. + +Note the double edge of OWASP's last point: *prefer well-scrutinised framework code +over home-made*. It argues against another weekend package, and for one seriously +implemented and reviewed. Session fixation, rotation and CSRF handling are CVE +territory, not bug territory. + +--- + +## 4. Would the need arrive anyway as FastAPI grows? Yes — three converging drivers + +**(a) Every mature ecosystem converged here.** Django ships swappable session backends +including cache/Redis; PHP has `SessionHandler`; Rails has its store abstraction; +Express pairs `express-session` with `connect-redis`. The closest precedent is +**Spring Session Data Redis**: putting `spring-session-data-redis` on the classpath +auto-configures Redis-backed `HttpSession` with *no application code change*, replacing +in-memory sessions so any instance serves any request, removing sticky sessions, and +surviving node restarts. That is a vendor-maintained integration for exactly this +problem, and it is the model to emulate. FastAPI is the only major modern web framework +without an equivalent. + +**(b) The JWT-for-sessions backlash is now mainstream.** 2026 practitioner guidance +routinely leads with "JWTs can't be revoked natively" and lands on Redis denylists or +hybrid session records. The intellectual argument that carried FastAPI's stateless +default has largely turned over. + +**(c) The new one — AI agent and MCP backends.** FastAPI is the default substrate here, +and the **MCP 2026-07-28 revision is stateless by design**, deliberately pushing session +semantics *up to the application*: the server hands the client an identifier and the +client passes it back. FastMCP's session state is in-memory by default and needs Redis +(or another shared store) once more than one server instance is involved; its +resumability event store is Redis-backed. This is a fast-growing population of FastAPI +apps whose core need is a shared, TTL'd, per-session key-value store with rotation and +cleanup. Redis already occupies the adjacent space with `redis/agent-memory-server` +(session/working memory with TTL, promoted to long-term memory). + +Also growing and structurally cookie-bound: server-rendered FastAPI (Jinja + HTMX), +which is the original #754 use case and cannot use bearer tokens cleanly. + +**Honest counter-arguments:** +- Upstream will not bless it. This stays a third-party package indefinitely. +- We would compete with `starsessions` (Starlette-docs-endorsed) and with + `fastapi-users`' `RedisStrategy` (the actual incumbent by usage). Differentiation + must be explicit, and interop matters more than feature count. +- It puts the SDK on the security-critical path. That raises the review, docs, and + disclosure bar (`SECURITY.md` already exists; it would need to mean more). + +--- + +## 5. Recommendation + +**Build it — as `FastAPIRedis(app).sessions()`, positioned as the OWASP-complete, +Redis-native session store for FastAPI, with first-class interop rather than a new +`request.session` dialect.** The gap is real, the incumbents are thin or mislabelled, +and Redis is the natural vendor for it, exactly as Spring Session Data Redis is for +Spring. Two things make now the right moment: Starlette 1.x just landed the +`accessed`/`modified` primitive, and the agent/MCP wave is creating net-new demand. + +### Differentiation — the four things existing options don't do + +1. **Ride `Session.accessed`/`modified` (Starlette ≥1.x, #3166).** Write to Redis only + on mutation; refresh idle TTL on access; let core emit `Vary: Cookie`. No + `load_session()` call for users to forget — `starsessions` solves the same problem + with an explicit load that raises `SessionNotLoaded` if skipped, which is a footgun + we can now simply not have. This is genuinely unavailable to any library designed + before March 2026. +2. **OWASP operations as API, not as documentation.** `rotate()` (fixation defence on + login/privilege change), **separate idle and absolute TTLs**, `revoke()`, and + `list_sessions(user_id)` / `revoke_all(user_id)` for concurrent-session control — + backed by a per-user Redis SET of session ids. That last capability is structurally + impossible for a cookie library and is the clearest "why Redis" argument. +3. **OpenAPI visibility via `APIKeyCookie`.** This answers #754's original complaint + directly, using tiangolo's own recommended primitive, so sessions appear as a + declared security scheme. +4. **Interop as the adoption hook.** Stay drop-in compatible with `request.session` so + existing code and **Authlib OAuth flows work unchanged** — which incidentally fixes + the 4KB-overflow and `Set-Cookie`-race classes of `mismatching_state` bugs for free. + Document a migration path from `fastapi-users`' `RedisStrategy` and from + `starsessions`. + +### Shape, following existing SDK conventions + +Mirror the caching/rate-limiting split already in the tree: + +| New file | Mirrors | Contents | +|---|---|---| +| `src/redis_fastapi/sessions.py` | `cache.py`, `ratelimit.py` | `SessionMiddleware`, `session()` DI factory, `add_redis_sessions()` | +| `src/redis_fastapi/session_backend.py` | `cache_backend.py`, `ratelimit_backend.py` | `SessionStore` (ABC, owns the lifecycle) + `RedisSessionBackend` / `SyncSessionBackend`: `load`/`save`/`delete`/`rotate`/`touch`/`list_for_user`/`revoke_all`. See §7 for why this one is an ABC and not a bare `Protocol`. | + +Extend, don't duplicate: +- `src/redis_fastapi/setup.py` — add `.sessions()` to the `FastAPIRedis` chain. +- `src/redis_fastapi/deps.py` — add `SessionDep` / `SessionBackendDep` + + `get_session_backend`, alongside the existing `get_cache_backend` / + `get_rate_limit_backend`, preserving `dependency_overrides` testability. +- `src/redis_fastapi/config.py` — `REDIS_SESSION_*` settings on `RedisSettings` + (cookie name, idle TTL, absolute TTL, samesite, https_only, key prefix, rotation + policy). +- `src/redis_fastapi/telemetry.py` — add `session_*` instruments following the file's + established pattern exactly: new fields on `_OTelState`, a `session_span()` alongside + `cache_span`/`ratelimit_span`, `record_session_*()` guarded helpers, and a + `timed_session()` context manager. Suggested instruments: + `redis_fastapi.sessions.operations` (counter, by `operation` = load/save/rotate/revoke + and `result` = hit/miss/expired), `redis_fastapi.sessions.latency` (histogram), + `redis_fastapi.sessions.active` (up-down counter or gauge). Keep the + import-guarded no-op discipline and `disable_telemetry()` reset behaviour. + Note that `telemetry.py`'s pattern — module-global `_state` plus free functions + called *inside* backend methods — is one of the reasons §7 lands on an ABC: the + instrumentation call sites belong in the base class, emitted once, rather than being + re-implemented (or silently skipped) per store. +- `src/redis_fastapi/__init__.py` — export the new public names into `__all__`. +- Docs: `docs/guide/sessions.md` + mkdocs nav; a section in + `docs/guide/observability.md` for the new metrics; an `examples/` app showing + login → `rotate()` → logout → `revoke_all()`, plus an Authlib OAuth example. + +### Security requirements to treat as acceptance criteria, not nice-to-haves + +- **Declare a direct `starlette>=1.0.0` dependency.** The design rides + `Session.accessed`/`modified`, which shipped in **Starlette 1.0.0 (2026-03-22)** — that + release is exactly where #3166 landed. Today `pyproject.toml` declares no direct + Starlette dependency at all (it arrives transitively via `fastapi>=0.115.0`, and + `uv.lock` resolves 1.3.1), so the current spec permits a 0.3x Starlette with no + `Session` class whatsoever. Without an explicit floor the "write only on mutation" + optimisation degrades into a silent no-write. +- Session IDs from `secrets.token_urlsafe(32)` (≥128 bits); IDs opaque, never carrying data. +- `rotate()` mandatory on authentication and privilege change; old key deleted, data migrated. +- Idle **and** absolute TTL, both enforced server-side in Redis. +- Cookie defaults strict: `HttpOnly`, `SameSite=Lax`, `Secure` on by default (with a + documented dev escape hatch) — note `starsessions` chose strict-by-default and it is + the right call. +- `Cache-Control: no-store` on session-bearing responses; support `Clear-Site-Data` on logout. +- Log a salted hash of the session ID, never the ID itself — applies to telemetry + attributes too: **session IDs must never become span attributes or metric labels.** +- Document CSRF explicitly: cookie sessions reintroduce CSRF exposure that bearer tokens + avoid. Reference `fastapi-csrf-protect` (138k/mo) or provide guidance. +- Optional, off by default: bind to IP/User-Agent for hijack *detection*, with OWASP's + caveat documented so nobody treats it as a control. + +### Verification + +- Unit tests under `tests/unit/` mirroring the existing cache/ratelimit test layout: + rotation preserves data and invalidates the old key; idle vs absolute expiry are + independent; `revoke_all` kills every session for a user; no Redis write when the + session is untouched (assert on `modified`); `Vary: Cookie` present when accessed. +- **Do not trust `modified` blindly** (see §6.1 — it currently under-reports upstream). + Tests must assert that a `popitem()`, a `|=`, and a *nested* mutation + (`session["a"]["b"] = 1`) are still persisted. Nested mutation is undetectable by any + `dict` subclass and therefore unfixable upstream, so the store needs an explicit + `save()` escape hatch and an opt-in always-write mode. These tests must pass whether or + not upstream #3436 merges. +- Integration tests under `tests/integration/` against a real Redis: cross-worker + session sharing (two app instances, one Redis), TTL behaviour, concurrent-request + race that previously clobbered cookies. +- Interop test: an Authlib OAuth flow completing against the Redis-backed store + unmodified, plus a >4KB payload that would break the signed-cookie path. +- Telemetry test following the existing pattern: assert instruments record and that + `disable_telemetry()` restores a clean `_OTelState`; assert no session ID appears in + any attribute. +- Run `nox` (lint, mypy, bandit, coverage) — the repo gates on all four. +- Manual: run the `examples/` login app, confirm `HttpOnly`/`Secure`/`SameSite` flags, + inspect keys in Redis, confirm logout deletes them. + +### Sequencing suggestion + +Ship `session_backend.py` + `sessions.py` + DI + config + strict cookie defaults + +`rotate`/`revoke` first (the OWASP core). Add `list_sessions`/`revoke_all` +(concurrent-session control) and the Authlib interop example as a close follow-up — +they are the differentiators worth their own release note. + +--- + +## 6. Upstream strategy — what we propose, and what we own + +A follow-up question on this dossier: should the vendor-agnostic part be proposed to +Starlette or FastAPI as common code, so other vendors could extend it later? + +**Vendor-agnostic yes; upstream-owned no.** Decomposing the feature by neutrality shows +where the seam actually falls: + +| Layer | Vendor-neutral? | Upstream-viable? | +|---|---|---| +| L1 `Session` dict + `accessed`/`modified` tracking | already upstream | already there — but incomplete, see §6.1 | +| L2 `Session` importable without the cookie middleware | pure refactor | plausible small PR (§6.3) | +| L3 Cookie carrier parameterised by a store | neutral | **this is PR #499's diff** | +| L4 `SessionStore` protocol (`load`/`save`/`delete`/`touch`) | neutral — *this is the ask* | **declined twice** | +| L5 OWASP lifecycle (`rotate`, dual TTL, `revoke_all`, per-user index) | neutral interface | out of scope for a "feature complete" toolkit | +| L6 Redis implementation | vendor-specific | ours | + +L3+L4 is precisely what a "submit it upstream" proposal would be — and precisely what +[starlette#499](https://github.com/encode/starlette/pull/499) already *was*: pluggable +session backends, no vendor in the diff. It sat open roughly three years and closed +unmerged. The evidence is not "nobody proposed the neutral version"; the neutral version +is the one that was refused, and [#2256](https://github.com/Kludex/starlette/discussions/2256) +got the same answer in 2023. + +Verified against source rather than the issue tracker: `starlette/middleware/sessions.py` +on `master` today still has **no store, backend, or serializer parameter** — state is +hardcoded to JSON + base64 + `TimestampSigner`. Nothing has moved on L3/L4. + +There is also an independent reason not to *want* it upstream even if it were offered: +**release-cadence coupling.** Session handling is CVE-adjacent (fixation, rotation, cookie +flags). If our store implements an upstream-owned protocol, every interface fix ships on +Starlette's timeline and we carry `hasattr` shims across N-1/N-2. Owning the interface +locally is a feature, not a compromise. + +Precedent points the same way. In every ecosystem §4a cites, the store abstraction lived +one layer *below* the framework and one *above* the vendor: `express-session` (itself a +third-party package) defines the `Store` base and `connect-redis` implements it — Node core +owns neither. Spring's `SessionRepository` lives in Spring Session, not in the Servlet +spec. So the abstraction should be vendor-agnostic, and it should be ours. + +What *is* worth sending upstream is much smaller, and one item is time-sensitive. + +### 6.1 Harden the `accessed`/`modified` contract we depend on — highest value + +Differentiator #1 above is "write to Redis only on mutation." That is only *sound* if +`modified` never under-reports. **Today it does.** Verified in +`starlette/middleware/sessions.py`: + +`Session` overrides `__setitem__`, `__delitem__`, `clear`, `pop`, `setdefault` and +`update` — but **not `popitem()` and not `|=`**. `dict.__ior__` updates at C level, which +bypasses the Python-level `update()` override entirely. For a signed cookie that costs a +`Set-Cookie`; for a server-side store it is a **silently lost write**, which is strictly +worse. + +This is already covered by [starlette#3436](https://github.com/Kludex/starlette/pull/3436) +(opened 2026-08-10 by an outside contributor, still open and unmerged as of this writing). +**Review and co-sign it — do not duplicate it.** There is an open PR modifying the exact +primitive we intend to build on; putting a Redis-maintainer voice in that thread before +the contract settles is worth more than proposing an abstraction that will be declined. + +### 6.2 New PR — `pop()` sets `modified` but never `accessed` + +`pop()` does `self.modified = self.modified or key in self`, while `mark_modified()` sets +*both* flags. A pop-only request therefore emits `Set-Cookie` without `Vary: Cookie` — +inconsistent with every other mutating path, and the `Vary` header is what +[#2019](https://github.com/Kludex/starlette/issues/2019) was about. Two lines plus a test, +and outside #3436's stated scope. + +### 6.3 Proposal — a neutral import path for `Session` + +Propose `starlette.datastructures.Session` (or a `starlette.sessions` module) with the +existing name kept as an alias. Argument: a third-party store must currently import from +`starlette.middleware.sessions` — pulling in the itsdangerous cookie middleware — just to +reference the type contract. Supporting evidence that the contract is already de-facto +public and duck-typed: `starlette/requests.py:169-175` type-imports `Session` under +`TYPE_CHECKING` and then guards the call with `hasattr(session, "mark_accessed")`. + +Moderate odds, low cost, and **zero blocker if declined** — we import from the middleware +module or duck-type, exactly as core itself does. + +### 6.4 Docs PRs, post-launch — the lever upstream reliably accepts + +List the SDK on Starlette's and FastAPI's third-party pages once shipped. Higher value: +correct pointers that still route ~70k downloads/month to the **archived** +`fastapi-sessions` (§2) — a live supply-chain problem, and a contribution nobody has to +argue about. + +--- + +## 7. Store contract — an ABC for the lifecycle, a narrow Protocol at the seam + +The natural instinct for "let other vendors extend this later" is a `typing.Protocol`, so +a Postgres or Valkey store could conform structurally with no dependency on this package. +For most seams that is the right call. **For this one it is not**, and the reason is +security rather than style. + +**A Protocol cannot hold an invariant.** It is a type-checker artefact that evaporates at +runtime. A conforming store passes `mypy` while seeding IDs from `random.random()`, or +implementing `rotate()` as write-new-without-deleting-old — which is session fixation, the +exact attack §3 lists rotation as the defence against. + +**The dangerous methods are the ones most likely to be stubbed.** `revoke_all(user_id)` +and `list_for_user(user_id)` require a per-user index (a Redis SET). A vendor for whom +that is awkward will write `pass` / `return []`. That structurally conforms *and* turns a +security control into a silent no-op — an empty list reads to the calling application as +"this user has no other sessions." That is worse than an unimplemented method, because it +is indistinguishable from a correct answer. + +**It also inverts OWASP's own guidance** quoted in §3 — *"recommended to use these +built-in frameworks versus building a home made one from scratch"*. A Protocol means N +implementations of fixation defence and dual-TTL clock arithmetic; a base class means one, +written and reviewed once. + +**And `telemetry.py` settles it.** That module's state is a global (`_state`) and its +helpers are free functions invoked *inside* backend methods — `cache_span`, +`record_cache_request` and `timed_operation` are woven into `CacheBackend.get()`, not +layered over it. So under a bare Protocol a non-Redis store either imports +`redis_fastapi.telemetry` (reintroducing the dependency edge that was the whole point) or +emits nothing at all, and `redis_fastapi.sessions.operations{operation="rotate"}` goes +silent. For a security metric, silence is indistinguishable from "no rotations are +happening." + +### Decision + +- **`SessionStore(ABC)` owns the lifecycle concretely:** `new_id()` via + `secrets.token_urlsafe(32)`; `rotate()` as a template method with fixed ordering (write + new → migrate data → delete old → update user index); idle/absolute TTL arithmetic; and + the telemetry call sites, so every store inherits instrumentation and the + "never log a raw session ID" rule is enforced at one set of call sites instead of being + re-litigated per vendor. +- **Abstract only the storage primitives:** `_read`, `_write`, `_delete`, `_expire`, + `_index_add`, `_index_members`. The vendor seam is deliberately boring. +- **Capability flags, not silent stubs:** `supports_user_index` and friends, so the + middleware refuses to expose a control the store cannot deliver rather than returning a + misleading answer. +- **Keep a narrow `SessionStoreProtocol`** describing only what `sessions.py` middleware + and DI actually consume — so tests and integrators can substitute without inheriting, + and the middleware never types against the concrete Redis class. This is the honest + vendor-agnostic piece. (`dependency_overrides` testability is DI-level and unaffected + either way.) + +**Repo idiom supports exactly this split.** The one existing Protocol, `Coder` +(`src/redis_fastapi/types.py:15`), is a stateless two-method value-conversion seam whose +default implementation `JsonCoder` deliberately does *not* inherit it. The classes that own +*behaviour* — `CacheBackend`, `RateLimitBackend` — are plain concrete classes. Protocol for +consequence-free seams; owned code for behaviour. A session store is behaviour, and +security behaviour at that. No ABC exists in the tree yet, so this is a new idiom and +worth flagging as one — but neither caching nor rate limiting carries invariants whose +violation is a CVE. + +### Corollary — interop is an adapter, not a contract concession + +Differentiator #4 makes interop the adoption hook, which invites shaping our contract to +match `starsessions`' store interface (roughly `read`/`write`/`remove`/`exists`, with one +TTL on `write`). **Do not.** That alignment costs precisely the operations §3 requires: + +- **No `rotate()` in the contract** → fixation defence becomes caller-side read + + write-new + remove-old: three round trips, non-atomic, and a crash mid-sequence leaves + two valid sessions. +- **One TTL parameter cannot express two clocks** → absolute expiry has to be smuggled + into the payload and checked after decode, so expiry is enforced by our Python rather + than by Redis — directly against *"expiration must be enforced server-side."* +- **No per-user index** → `revoke_all` becomes impossible, deleting the one capability + §5 calls structurally impossible for a cookie library. + +Correct position: **adapter in, not contract out.** Ship a thin adapter so a +`starsessions` store can be *used* by our middleware in declared-degraded mode (missing +capabilities reported `False`), and document the migration path — without bending our own +contract to four methods. + +--- + +## Open question for you + +Whether to scope v1 at **web sessions** (cookie + OWASP lifecycle, competing with +`starsessions`/`fastapi-users`) or to also cover **agent/MCP session state** (header or +argument-carried session id, no cookie, TTL'd working memory — the driver from §4c). +They share a backend but differ in transport and in who the audience is. My inclination +is web sessions first with the backend deliberately transport-agnostic, so the agent +case is a thin second adapter rather than a rewrite — but this overlaps with +`redis/agent-memory-server`, so it is a portfolio question as much as a technical one. + +§7 partly answers the technical half: the ABC-plus-narrow-Protocol split is *what makes* +"transport-agnostic backend" real rather than aspirational. The lifecycle (IDs, rotation, +dual TTL, per-user index, telemetry) lives in the base and is transport-free; only the +cookie carrier is web-specific and it lives in `sessions.py`, not in the store. An +agent/MCP adapter then supplies a different carrier — header or argument-carried session +id — against the same base. The remaining question is genuinely a portfolio one. + +### Immediate next actions + +1. Review and co-sign [starlette#3436](https://github.com/Kludex/starlette/pull/3436) + (§6.1) — time-sensitive, it is open now and touches the primitive this design rides. +2. Open the `pop()`/`accessed` PR (§6.2). +3. Add a direct `starlette>=1.0.0` floor to `pyproject.toml` when implementation starts. +4. Float the neutral `Session` import path (§6.3); proceed regardless of the answer. +5. Docs PRs after launch (§6.4). From 6a57aeec578f87c160d6c288832a8b222e55cd03 Mon Sep 17 00:00:00 2001 From: Tihomir Mateev Date: Wed, 19 Aug 2026 14:56:46 +0300 Subject: [PATCH 02/11] Research documentation --- docs/specs/session-mgmt.md | 1124 +++++++++++++++++++++--------------- 1 file changed, 650 insertions(+), 474 deletions(-) diff --git a/docs/specs/session-mgmt.md b/docs/specs/session-mgmt.md index 78de818..236245e 100644 --- a/docs/specs/session-mgmt.md +++ b/docs/specs/session-mgmt.md @@ -1,534 +1,710 @@ -# Session management in the Starlette/FastAPI ecosystem — research dossier & recommendation +# Session management for Starlette and FastAPI: research and recommendation ## Context -`fastapi-redis-sdk` is the official Redis integration for FastAPI. It already ships -connection management, DI-based caching (`cache`/`cache_evict`/`cache_put`, -`CacheBackend`), rate limiting (`rate_limit`, `RateLimitBackend`), and OTel -instrumentation, all behind the fluent `FastAPIRedis(app).lifespan().caching().rate_limiting().otel()` -surface. The open question is whether **server-side sessions** are the right next -primitive: is the absence of a canonical FastAPI session solution a sign the need -doesn't exist, or a genuine gap the ecosystem would adopt? +`fastapi-redis-sdk` is the official Redis integration for FastAPI. The package +already gives you four things: -This document records what the research found (primary sources, adoption numbers, -maintainer commitments) and what I recommend building as a result. +- connection management +- cache operations that use dependency injection (`cache`, `cache_evict`, + `cache_put`, and `CacheBackend`) +- rate limit operations (`rate_limit` and `RateLimitBackend`) +- OpenTelemetry instrumentation + +You configure all four with one chain of methods: +`FastAPIRedis(app).lifespan().caching().rate_limiting().otel()`. + +The FastAPI framework has no standard session solution and this document answers the question if server-side sessions should be the next feature. --- -## 1. What core maintainers have committed to — it is settled, and it is "no" +## 1. What the core maintainers decided -Both upstream projects have explicitly and repeatedly declined to own session -backends. This is not neglect; it is a stated scope decision, reaffirmed over -seven years. +Both upstream projects refused to own session backends. It is a decision about project scope, and the maintainers confirmed it across seven years. | Source | Date | Outcome | |---|---|---| -| [fastapi#754 "First-class session support"](https://github.com/fastapi/fastapi/issues/754) | Nov 2019 → closed Feb 2020 | tiangolo: *"It's already in place. More or less like the rest of the security tools… It's just not properly documented yet."* — pointed at `APIKeyCookie` + JWT-in-cookie. Closed as answered. | -| [encode/starlette#499 "Add pluggable session backends"](https://github.com/encode/starlette/pull/499) | May 2019 → closed **unmerged** Feb 2022 | Tom Christie: *"let's continue with the 'as a third party packages' approach… it looks to me like we oughta scope Starlette as 'feature complete' and just let folks build other stuff on top of it."* | -| [starlette#1801 "Why are Starlette sessions so basic?"](https://github.com/Kludex/starlette/discussions/1801) | Aug 2022 | adriangb (accepted answer): Starlette is *"a **minimal** and **composable** web toolkit, not a complete web framework with all of the batteries included"*; *"if it can be implemented as an external package, we'd prefer that to bringing it into Starlette itself."* When the OP proposed **removing** sessions from core entirely and pointing at `starsessions`: adriangb *"I don't disagree with you"*, Kludex *"That's a good point. 👍"* | -| [starlette#2256 (+ PR #2255) JWT sessions](https://github.com/Kludex/starlette/discussions/2256) | Aug 2023 | Rejected. Response: *"can very well be an independent package… If it's a feature that's popular… it will be maintained by the community."* Discussion left unanswered; author shipped an alpha third-party package instead. | -| [fastapi#10370 Roadmap](https://github.com/fastapi/fastapi/issues/10370) | current | No session or auth workstream. Items are Pydantic v2, Starlette upgrades, dropping EOL Pythons. | - -**Key rebuttal on record, still unanswered:** in #754, the requester pointed out -that JWT-in-a-cookie only works while session data fits in -[RFC 6265's 4096-byte cookie limit](https://tools.ietf.org/html/rfc6265#section-6.1), -and that the real ask was Django/PHP-style swappable server-side backends. dmontagu -(then core) agreed FastAPI should only add DI + OpenAPI polish over *Starlette-owned* -backends — and Starlette then declined to own them. The feature fell down the gap -between the two projects and has stayed there. - -**Governance note:** Starlette and Uvicorn moved from `encode` to Kludex's personal -handle; Starlette shipped 1.0 and is at **1.6.0 (Aug 2026)**. A 1.0 release under a -"feature complete" scope makes core adoption *less* likely, not more. - -### 1a. The one thing that did change — and it matters to us - -[starlette#3166](https://github.com/Kludex/starlette/pull/3166), authored by **Kludex** -and merged **2026-03-01**, replaces the plain `dict` at `scope["session"]` with a -`Session` subclass that tracks two flags, explicitly following Django and Flask -convention: - -- `accessed` — set when the session is read (via the `HTTPConnection.session` property) -- `modified` — set on `__setitem__`/`__delitem__`/`clear`/`update`; `pop`/`setdefault` - mark modified only when the value actually changes - -The middleware now emits `Set-Cookie` **only when `modified`**, and adds -`Vary: Cookie` when `accessed`. This closes the long-standing -[#2019 race condition](https://github.com/Kludex/starlette/issues/2019) where a slow -read-only response clobbered a newer session cookie. - -Core still has **no backend parameter** — it remains itsdangerous-signed cookies only. -But `accessed`/`modified` is precisely the primitive a server-side store needs to avoid -a Redis round-trip on every request. Starlette has, incidentally, just built the hook. -This is the single most important technical finding: it did not exist when every -current session library was designed. +| [fastapi#754 "First-class session support"](https://github.com/fastapi/fastapi/issues/754) | Opened Nov 2019. Closed Feb 2020. | tiangolo: *"It's already in place. More or less like the rest of the security tools… It's just not properly documented yet."* He pointed to `APIKeyCookie` and a JWT in a cookie. He closed the issue as answered. | +| [encode/starlette#499 "Add pluggable session backends"](https://github.com/encode/starlette/pull/499) | Opened May 2019. Closed **unmerged** Feb 2022. | Tom Christie: *"let's continue with the 'as a third party packages' approach… it looks to me like we oughta scope Starlette as 'feature complete' and just let folks build other stuff on top of it."* | +| [starlette#1801 "Why are Starlette sessions so basic?"](https://github.com/Kludex/starlette/discussions/1801) | Aug 2022 | adriangb gave the accepted answer. Starlette is *"a **minimal** and **composable** web toolkit, not a complete web framework with all of the batteries included"*. Also: *"if it can be implemented as an external package, we'd prefer that to bringing it into Starlette itself."* The original poster then proposed to **remove** sessions from the core and to point users to `starsessions`. adriangb replied *"I don't disagree with you"*. Kludex replied *"That's a good point. 👍"* | +| [starlette#2256 (with PR #2255) JWT sessions](https://github.com/Kludex/starlette/discussions/2256) | Aug 2023 | The maintainers rejected it: *"can very well be an independent package… If it's a feature that's popular… it will be maintained by the community."* Nobody answered the discussion. The author released an alpha third-party package instead. | +| [fastapi#10370 Roadmap](https://github.com/fastapi/fastapi/issues/10370) | current | The roadmap has no work item for sessions or authentication. The items are Pydantic v2, Starlette upgrades, and the removal of Python versions at end of life. | + +**One argument on the record has no answer.** In #754, the requester made an +important point. A JWT in a cookie works only while the session data is small +enough. The limit is 4096 bytes, from +[RFC 6265](https://tools.ietf.org/html/rfc6265#section-6.1). The requester wanted +server-side backends that you can exchange, in the style of Django and PHP. + +dmontagu was then a core maintainer. He agreed that FastAPI must add only +dependency injection and OpenAPI support. Starlette must own the backends. But +Starlette then refused to own them. Neither project took the feature, and the +situation did not change. + +**A note about project governance.** Kludex moved Starlette and Uvicorn from the +`encode` organisation to a personal account. Starlette then released version 1.0, +and the current release is **1.6.0 (Aug 2026)**. The maintainers call the project +feature complete. A 1.0 release with that scope makes adoption into the core +**less** probable, not more probable. + +### 1a. One change that helps us + +Kludex wrote [starlette#3166](https://github.com/Kludex/starlette/pull/3166) and +merged it on **2026-03-01**. Before this change, `scope["session"]` held a plain +`dict`. Now it holds a `Session` subclass. The subclass records two flags, in the +same way as Django and Flask: + +- `accessed` becomes true when code reads the session. The + `HTTPConnection.session` property sets this flag. +- `modified` becomes true when code calls `__setitem__`, `__delitem__`, `clear`, + or `update`. The `pop` and `setdefault` methods set the flag only if the value + changes. + +The middleware now sends `Set-Cookie` **only if `modified` is true**. It adds +`Vary: Cookie` if `accessed` is true. This corrects +[race condition #2019](https://github.com/Kludex/starlette/issues/2019). In that +condition, a slow response that only read the session replaced a newer session +cookie. + +The core still has **no backend parameter**. It supports only cookies that +itsdangerous signs. But a server-side store needs exactly the `accessed` and +`modified` flags. The flags let the store avoid a Redis request on every HTTP +request. Starlette built this connection point for its own purpose. + +This is the most important technical result of the research. The flags did not +exist when the authors designed the current session libraries. --- -## 2. What exists today, and how much it is actually used +## 2. What exists today, and how much people use it -PyPI downloads, last 30 days (`pypistats`), with repo health: +The table shows PyPI downloads for the last 30 days, from `pypistats`. It also +shows the condition of each repository. -| Package | Downloads/mo | Stars | Status | +| Package | Downloads each month | Stars | Status | |---|---|---|---| | `starlette` | 665M | — | 1.6.0, Aug 2026 | | `fastapi` | 603M | — | active | | **`fastapi-users`** | **1.53M** | 6.2k | active (Aug 2026) | -| `starsessions` | 429k | 123 | active, v2.3.0a1 Mar 2026 — **the one Starlette docs point to** | -| `fastapi-sessions` | 70k | 109 | **ARCHIVED**, last push Jul 2023 | +| `starsessions` | 429k | 123 | active, v2.3.0a1 Mar 2026. The Starlette documentation points to this package. | +| `fastapi-sessions` | 70k | 109 | **ARCHIVED**. Last change Jul 2023. | | `authx` | 69k | 1.2k | active | -| `starlette-session` | 41k | 37 | stale, last push Feb 2023 | -| `starlette-authlib` | 2.6k | — | niche | -| `fastsession` | 224 | — | negligible | -| *for contrast:* `pyjwt` | 705M | — | — | -| *for contrast:* `authlib` | 154M | — | — | - -Two readings, both true: - -- **All dedicated session libraries combined ≈ 610k/mo against 665M Starlette - installs — under 0.1%.** No incumbent has won. The Starlette-endorsed option has - 123 stars. 70k downloads/month flow to an **archived** package, which is a live - supply-chain problem, not a healthy market. -- **`fastapi-users` alone is 2.5× all session libraries combined**, and its +| `starlette-session` | 41k | 37 | not maintained. Last change Feb 2023. | +| `starlette-authlib` | 2.6k | — | small user base | +| `fastsession` | 224 | — | almost no users | +| for comparison: `pyjwt` | 705M | — | — | +| for comparison: `authlib` | 154M | — | — | + +Two conclusions are possible. Both are correct. + +- **The session libraries together get approximately 610k downloads each month. + Starlette gets 665M. The session libraries are therefore less than 0.1% of + that.** No package became the standard. The package that the Starlette + documentation recommends has 123 stars. An **archived** package gets 70k + downloads each month. This is a supply chain risk, not a healthy market. +- **`fastapi-users` alone gets 2.5 times the downloads of all session libraries + together.** Its [`RedisStrategy`](https://fastapi-users.github.io/fastapi-users/latest/configuration/authentication/strategies/redis/) - *is* a server-side session store in all but name: an opaque token is stored in Redis - mapped to a user id, looked up per request, and **deleted on logout** for true - revocation. It is the most-used server-side session implementation in the ecosystem - and it is not labelled "session". - -**Conclusion: demand is real but rerouted.** It is expressed as (a) `fastapi-users` -Redis strategy, (b) the hand-rolled `secrets.token_urlsafe(32)` + `SETEX` + cookie -pattern that every tutorial and vendor guide teaches, and (c) hosted IdPs -(WorkOS/Auth0/PropelAuth). "Session library downloads" is the wrong metric. - -### 2a. What Starlette's built-in session is actually used for - -The dominant real-world use of `SessionMiddleware` is **not** user sessions — it is -**OAuth handshake state for Authlib**, which stores the `state` parameter and PKCE code -verifier in `request.session` and hard-requires the middleware. That explains why the -basic signed-cookie version was "good enough" for so long: the payload is tiny and -short-lived. - -It also explains the ecosystem's most-reported session bug class: `mismatching_state` / -`MismatchingStateError`, caused by SameSite/Secure misconfiguration, secret rotation -invalidating old cookies, or the signed cookie silently exceeding 4KB once anyone puts -a token or profile in it. Practitioner guidance for all of these converges on -*"keep the session payload small; store tokens server-side"* — i.e. the thing nobody -ships a blessed solution for. + *is* a server-side session store, but it has a different name. Redis holds an + opaque token that maps to a user ID. The strategy reads the token on each + request. It **deletes the token when the user logs out**, which gives correct + revocation. This is the server-side session implementation with the most users, + and its name does not include the word session. + +**Conclusion: the demand is real, but users satisfy it in other ways.** They use +three methods. First, the Redis strategy in `fastapi-users`. Second, a pattern +that they write themselves: `secrets.token_urlsafe(32)`, then `SETEX`, then a +cookie. Nearly every tutorial and vendor guide teaches this pattern. Third, +hosted identity providers such as WorkOS, Auth0, and PropelAuth. Download counts +for session libraries are therefore the wrong measurement. + +### 2a. How applications use the Starlette session today + +Most applications do **not** use `SessionMiddleware` for user sessions. They use +it to hold OAuth state for Authlib. Authlib puts the `state` parameter and the +PKCE code verifier into `request.session`, and it requires the middleware. This +explains why the simple signed cookie was sufficient for many years. The data is +small, and it exists for a short time. + +This also explains the most frequent session fault that users report: +`mismatching_state` and `MismatchingStateError`. Three conditions cause this +fault. First, an incorrect `SameSite` or `Secure` setting. Second, a change of the +secret, which makes the old cookies invalid. Third, a signed cookie that becomes +larger than 4KB after somebody adds a token or a user profile. + +The guidance for all three conditions is the same: +*"keep the session payload small; store tokens server-side"*. No package supplies +a recommended solution for that guidance. --- -## 3. Is session management tied to security/auth — and can you avoid it? +## 3. Is session management part of security, and can you avoid it? -Yes it is tied, and largely no you cannot avoid it, once requirements go past -machine-to-machine APIs. From the +It is part of security. You can avoid it only for machine-to-machine interfaces. +The requirements below come from the [OWASP Session Management Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html): -- **Opaque IDs + server state:** *"The session ID content (or value) must be meaningless - to prevent information disclosure attacks"*; business logic *"must be stored on the - server side, and specifically, in session objects or in a session management database - or repository."* ≥64 bits entropy from a CSPRNG (≥128 recommended for custom IDs). -- **Rotation:** *"The session ID must be renewed or regenerated… after any privilege - level change"*; *"regeneration is mandatory to prevent session fixation attacks."* - Triggers: authentication, password change, permission change, role elevation. -- **Timeouts:** *"All sessions should implement an idle or inactivity timeout"* **and** - *"an absolute timeout, regardless of session activity"*, and - *"expiration must be enforced server-side."* Client-side enforcement is rejected - outright. Plus an optional **renewal timeout** rotating the ID mid-session. -- **Termination:** on expiry the app *"must take active actions to invalidate the session - on both sides, client and server. The latter is the most relevant."* -- **Concurrency:** users should be able to view active sessions, get concurrent-logon - alerts, and **remotely terminate** sessions. -- **Hijack detection:** *"highly recommended to bind the session ID to other user or - client properties, such as the client IP address, User-Agent"* — for detection, with - the explicit caveat that NAT/proxies/spoofing mean it *"cannot be used… to trustingly - defend."* -- **Implementation:** *"It is recommended to use these built-in frameworks versus +- **Opaque IDs and server state.** *"The session ID content (or value) must be + meaningless to prevent information disclosure attacks"*. Business data + *"must be stored on the server side, and specifically, in session objects or in a + session management database or repository."* An ID needs at least 64 bits of + entropy from a cryptographic random number generator. OWASP recommends at least + 128 bits for a custom ID. +- **Rotation.** *"The session ID must be renewed or regenerated… after any privilege + level change"*. Also: *"regeneration is mandatory to prevent session fixation + attacks."* Four events start a rotation: authentication, a password change, a + permission change, and an increase of the user role. +- **Timeouts.** *"All sessions should implement an idle or inactivity timeout"* + **and** *"an absolute timeout, regardless of session activity"*. Also: + *"expiration must be enforced server-side."* OWASP rejects enforcement on the + client. A **renewal timeout** is optional. It rotates the ID while the session + continues. +- **Termination.** When a session expires, the application *"must take active + actions to invalidate the session on both sides, client and server. The latter is + the most relevant."* +- **Concurrency.** A user must be able to see the active sessions and to receive an + alert about a new login. The user must also be able to **terminate a session from + a different device**. +- **Detection of a stolen session.** OWASP writes: *"highly recommended to + bind the session ID to other user or client properties, such as the client IP + address, User-Agent"*. Use this for detection only. OWASP gives a clear warning. + Network address translation, proxy servers, and spoofing mean that this binding + *"cannot be used… to trustingly defend."* +- **Implementation.** *"It is recommended to use these built-in frameworks versus building a home made one from scratch."* -- Tokens must never go in `localStorage`/`sessionStorage`. +- Never put a token in `localStorage` or in `sessionStorage`. -Mapping that against carrier choice: +The next table compares the two ways to carry a session against these +requirements. The symbol ✓ means that the method meets the requirement. The +symbol ✗ means that it does not meet the requirement. -| Requirement | Signed cookie / JWT | Server-side store | +| Requirement | Signed cookie or JWT | Server-side store | |---|---|---| -| Immediate logout / revocation | ✗ (valid until exp) | ✓ delete the key | -| Server-enforced idle **and** absolute timeout | ✗ | ✓ two clocks | -| ID rotation on privilege change | partial (reissue, old still valid) | ✓ | -| List / remotely kill a user's sessions | ✗ | ✓ | -| Payload > 4KB | ✗ | ✓ | -| Keep data off the client | ✗ (signed ≠ encrypted) | ✓ | -| Zero-infrastructure, no per-request lookup | ✓ | ✗ | - -**So:** stateless tokens are legitimately correct for service-to-service calls and -short-lived access tokens. But every OWASP requirement above that involves *revoking, -rotating, bounding, or enumerating* a session needs shared state. The widely-recommended -"hybrid" fix — embed a session id in the JWT and check it against a Redis denylist — -is a session store with strictly worse properties: you pay the lookup anyway, and keep -the token's size and staleness. Once revocation is a requirement, the stateless -argument has already been conceded. - -Note the double edge of OWASP's last point: *prefer well-scrutinised framework code -over home-made*. It argues against another weekend package, and for one seriously -implemented and reviewed. Session fixation, rotation and CSRF handling are CVE -territory, not bug territory. +| Immediate logout and revocation | ✗ valid until it expires | ✓ delete the key | +| The server enforces an idle timeout **and** an absolute timeout | ✗ | ✓ two clocks | +| Rotation of the ID after a privilege change | partial. You issue a new token, but the old token stays valid. | ✓ | +| Show and terminate the sessions of one user | ✗ | ✓ | +| A payload larger than 4KB | ✗ | ✓ | +| Keep the data away from the client | ✗ a signature is not encryption | ✓ | +| No infrastructure and no lookup for each request | ✓ | ✗ | + +Stateless tokens are correct for service-to-service calls and for access tokens +with a short life. But some requirements above need shared state. These are the +requirements to revoke, to rotate, to limit, and to list a session. + +Many sources recommend a hybrid method. You put a session ID into the JWT. Then +you compare that ID against a denylist in Redis. This method is a session store, +but its properties are worse. You do the lookup, and you also keep the size and +the staleness of the token. If you must revoke a session, you have already +accepted the cost of shared state. + +The last OWASP point has two effects. It tells you to prefer framework code that +many people examined. Therefore it is an argument against one more small package. +It is also an argument for one implementation that the authors build and review +with care. Faults in session fixation, rotation, and CSRF protection become +security vulnerabilities, not simple defects. --- -## 4. Would the need arrive anyway as FastAPI grows? Yes — three converging drivers - -**(a) Every mature ecosystem converged here.** Django ships swappable session backends -including cache/Redis; PHP has `SessionHandler`; Rails has its store abstraction; -Express pairs `express-session` with `connect-redis`. The closest precedent is -**Spring Session Data Redis**: putting `spring-session-data-redis` on the classpath -auto-configures Redis-backed `HttpSession` with *no application code change*, replacing -in-memory sessions so any instance serves any request, removing sticky sessions, and -surviving node restarts. That is a vendor-maintained integration for exactly this -problem, and it is the model to emulate. FastAPI is the only major modern web framework -without an equivalent. - -**(b) The JWT-for-sessions backlash is now mainstream.** 2026 practitioner guidance -routinely leads with "JWTs can't be revoked natively" and lands on Redis denylists or -hybrid session records. The intellectual argument that carried FastAPI's stateless -default has largely turned over. - -**(c) The new one — AI agent and MCP backends.** FastAPI is the default substrate here, -and the **MCP 2026-07-28 revision is stateless by design**, deliberately pushing session -semantics *up to the application*: the server hands the client an identifier and the -client passes it back. FastMCP's session state is in-memory by default and needs Redis -(or another shared store) once more than one server instance is involved; its -resumability event store is Redis-backed. This is a fast-growing population of FastAPI -apps whose core need is a shared, TTL'd, per-session key-value store with rotation and -cleanup. Redis already occupies the adjacent space with `redis/agent-memory-server` -(session/working memory with TTL, promoted to long-term memory). - -Also growing and structurally cookie-bound: server-rendered FastAPI (Jinja + HTMX), -which is the original #754 use case and cannot use bearer tokens cleanly. - -**Honest counter-arguments:** -- Upstream will not bless it. This stays a third-party package indefinitely. -- We would compete with `starsessions` (Starlette-docs-endorsed) and with - `fastapi-users`' `RedisStrategy` (the actual incumbent by usage). Differentiation - must be explicit, and interop matters more than feature count. -- It puts the SDK on the security-critical path. That raises the review, docs, and - disclosure bar (`SECURITY.md` already exists; it would need to mean more). +## 4. Will the need come as FastAPI grows? Yes, for three reasons + +**(a) Every mature ecosystem added this feature.** Django supplies session +backends that you can exchange, and one of them uses the cache or Redis. PHP has +`SessionHandler`. Rails has a store abstraction. Express uses `express-session` +together with `connect-redis`. + +The closest example is **Spring Session Data Redis**. You put +`spring-session-data-redis` on the classpath. Spring then configures a +Redis-backed `HttpSession` automatically, and you change *no application code*. +Redis replaces the in-memory sessions. Every application instance can then serve +every request, so you do not need sticky sessions, and a session survives a +restart of a node. + +A vendor maintains that integration for this exact problem, and we must follow the +same model. FastAPI is the only large modern web framework without an equivalent. + +**(b) Most practitioners now reject JWTs for sessions.** Guidance from 2026 +usually starts with the statement "JWTs can't be revoked natively". It then +recommends a denylist in Redis or a hybrid session record. The technical argument +for the stateless default in FastAPI is therefore much weaker than before. + +**(c) The new reason: AI agents and MCP backends.** Most developers build these +services with FastAPI. The **Model Context Protocol (MCP) revision of 2026-07-28 +is stateless by design**. It moves session behaviour to the application on +purpose. The server gives the client an identifier, and the client returns it. + +In FastMCP, the session state is in memory by default. FastMCP needs Redis, or +another shared store, as soon as you run more than one server instance. Its event +store for resumability already uses Redis. + +This group of FastAPI applications grows quickly. Each one needs the same thing: a +shared key-value store, with a time to live (TTL) for each session, plus rotation +and cleanup. Redis already supplies a similar product, +`redis/agent-memory-server`. That product holds session memory and working memory +with a TTL, and it can promote the data to long-term memory. + +One more group grows, and it must use cookies: FastAPI applications that render +pages on the server with Jinja and HTMX. This is the original use case in #754. +These applications cannot use bearer tokens easily. + +**Arguments against the feature:** + +- Upstream will not adopt it. It stays a third-party package. +- We compete with `starsessions`, which the Starlette documentation recommends. We + also compete with the `RedisStrategy` in `fastapi-users`, which has the most + users today. We must show the differences clearly. Compatibility is more + important than the number of features. +- The SDK becomes part of the security path. This raises the standard for review, + documentation, and vulnerability disclosure. `SECURITY.md` exists, but it must + then have more content. --- ## 5. Recommendation -**Build it — as `FastAPIRedis(app).sessions()`, positioned as the OWASP-complete, -Redis-native session store for FastAPI, with first-class interop rather than a new -`request.session` dialect.** The gap is real, the incumbents are thin or mislabelled, -and Redis is the natural vendor for it, exactly as Spring Session Data Redis is for -Spring. Two things make now the right moment: Starlette 1.x just landed the -`accessed`/`modified` primitive, and the agent/MCP wave is creating net-new demand. - -### Differentiation — the four things existing options don't do - -1. **Ride `Session.accessed`/`modified` (Starlette ≥1.x, #3166).** Write to Redis only - on mutation; refresh idle TTL on access; let core emit `Vary: Cookie`. No - `load_session()` call for users to forget — `starsessions` solves the same problem - with an explicit load that raises `SessionNotLoaded` if skipped, which is a footgun - we can now simply not have. This is genuinely unavailable to any library designed - before March 2026. -2. **OWASP operations as API, not as documentation.** `rotate()` (fixation defence on - login/privilege change), **separate idle and absolute TTLs**, `revoke()`, and - `list_sessions(user_id)` / `revoke_all(user_id)` for concurrent-session control — - backed by a per-user Redis SET of session ids. That last capability is structurally - impossible for a cookie library and is the clearest "why Redis" argument. -3. **OpenAPI visibility via `APIKeyCookie`.** This answers #754's original complaint - directly, using tiangolo's own recommended primitive, so sessions appear as a - declared security scheme. -4. **Interop as the adoption hook.** Stay drop-in compatible with `request.session` so - existing code and **Authlib OAuth flows work unchanged** — which incidentally fixes - the 4KB-overflow and `Set-Cookie`-race classes of `mismatching_state` bugs for free. - Document a migration path from `fastapi-users`' `RedisStrategy` and from - `starsessions`. - -### Shape, following existing SDK conventions - -Mirror the caching/rate-limiting split already in the tree: - -| New file | Mirrors | Contents | +**Build the feature as `FastAPIRedis(app).sessions()`.** Present it as the +Redis-native session store for FastAPI that meets the OWASP requirements. Give it +good compatibility with the existing `request.session` interface. Do not invent a +new interface. The gap is real. The existing packages are either small or have a +different name. Redis is the correct vendor for this feature, in the same way that +Spring Session Data Redis is correct for Spring. + +Two conditions make this the right time. Starlette 1.x added the `accessed` and +`modified` flags. The agent and MCP applications also create new demand. + +### The four things that existing packages do not do + +1. **Use `Session.accessed` and `Session.modified` (Starlette 1.x and later, + #3166).** Write to Redis only when the data changes. Refresh the idle TTL when + code reads the session. Let the core send `Vary: Cookie`. The user does not have + to call `load_session()`. `starsessions` solves the same problem with an + explicit load call, and it raises `SessionNotLoaded` if you forget the call. + That is a mistake which is easy to make, and we can prevent it. No library + designed before March 2026 can use this method. +2. **Supply the OWASP operations as an API, not as documentation.** Give the user + `rotate()`, which defends against session fixation at login and after a + privilege change. Give **separate idle and absolute TTLs**. Give `revoke()`. + Give `list_sessions(user_id)` and `revoke_all(user_id)` to control concurrent + sessions. A Redis SET for each user holds the session IDs. A cookie library + cannot supply that last capability, and this is the clearest reason to use + Redis. +3. **Make the session visible in OpenAPI with `APIKeyCookie`.** This answers the + original request in #754 directly. It uses the primitive that tiangolo + recommends, so the session becomes a declared security scheme. +4. **Use compatibility to get adoption.** Stay compatible with `request.session`. + Existing code and **Authlib OAuth flows then work without any change**. This + also corrects two classes of `mismatching_state` fault: the 4KB overflow, and + the `Set-Cookie` race condition. Document how to migrate from the + `RedisStrategy` in `fastapi-users`, and from `starsessions`. + +### Structure, which follows the existing conventions of the SDK + +Use the same division as the existing cache and rate limit code: + +| New file | Follows | Contents | |---|---|---| -| `src/redis_fastapi/sessions.py` | `cache.py`, `ratelimit.py` | `SessionMiddleware`, `session()` DI factory, `add_redis_sessions()` | -| `src/redis_fastapi/session_backend.py` | `cache_backend.py`, `ratelimit_backend.py` | `SessionStore` (ABC, owns the lifecycle) + `RedisSessionBackend` / `SyncSessionBackend`: `load`/`save`/`delete`/`rotate`/`touch`/`list_for_user`/`revoke_all`. See §7 for why this one is an ABC and not a bare `Protocol`. | - -Extend, don't duplicate: -- `src/redis_fastapi/setup.py` — add `.sessions()` to the `FastAPIRedis` chain. -- `src/redis_fastapi/deps.py` — add `SessionDep` / `SessionBackendDep` + - `get_session_backend`, alongside the existing `get_cache_backend` / - `get_rate_limit_backend`, preserving `dependency_overrides` testability. -- `src/redis_fastapi/config.py` — `REDIS_SESSION_*` settings on `RedisSettings` - (cookie name, idle TTL, absolute TTL, samesite, https_only, key prefix, rotation - policy). -- `src/redis_fastapi/telemetry.py` — add `session_*` instruments following the file's - established pattern exactly: new fields on `_OTelState`, a `session_span()` alongside - `cache_span`/`ratelimit_span`, `record_session_*()` guarded helpers, and a - `timed_session()` context manager. Suggested instruments: - `redis_fastapi.sessions.operations` (counter, by `operation` = load/save/rotate/revoke - and `result` = hit/miss/expired), `redis_fastapi.sessions.latency` (histogram), - `redis_fastapi.sessions.active` (up-down counter or gauge). Keep the - import-guarded no-op discipline and `disable_telemetry()` reset behaviour. - Note that `telemetry.py`'s pattern — module-global `_state` plus free functions - called *inside* backend methods — is one of the reasons §7 lands on an ABC: the - instrumentation call sites belong in the base class, emitted once, rather than being - re-implemented (or silently skipped) per store. -- `src/redis_fastapi/__init__.py` — export the new public names into `__all__`. -- Docs: `docs/guide/sessions.md` + mkdocs nav; a section in - `docs/guide/observability.md` for the new metrics; an `examples/` app showing - login → `rotate()` → logout → `revoke_all()`, plus an Authlib OAuth example. - -### Security requirements to treat as acceptance criteria, not nice-to-haves - -- **Declare a direct `starlette>=1.0.0` dependency.** The design rides - `Session.accessed`/`modified`, which shipped in **Starlette 1.0.0 (2026-03-22)** — that - release is exactly where #3166 landed. Today `pyproject.toml` declares no direct - Starlette dependency at all (it arrives transitively via `fastapi>=0.115.0`, and - `uv.lock` resolves 1.3.1), so the current spec permits a 0.3x Starlette with no - `Session` class whatsoever. Without an explicit floor the "write only on mutation" - optimisation degrades into a silent no-write. -- Session IDs from `secrets.token_urlsafe(32)` (≥128 bits); IDs opaque, never carrying data. -- `rotate()` mandatory on authentication and privilege change; old key deleted, data migrated. -- Idle **and** absolute TTL, both enforced server-side in Redis. -- Cookie defaults strict: `HttpOnly`, `SameSite=Lax`, `Secure` on by default (with a - documented dev escape hatch) — note `starsessions` chose strict-by-default and it is - the right call. -- `Cache-Control: no-store` on session-bearing responses; support `Clear-Site-Data` on logout. -- Log a salted hash of the session ID, never the ID itself — applies to telemetry - attributes too: **session IDs must never become span attributes or metric labels.** -- Document CSRF explicitly: cookie sessions reintroduce CSRF exposure that bearer tokens - avoid. Reference `fastapi-csrf-protect` (138k/mo) or provide guidance. -- Optional, off by default: bind to IP/User-Agent for hijack *detection*, with OWASP's - caveat documented so nobody treats it as a control. +| `src/redis_fastapi/sessions.py` | `cache.py`, `ratelimit.py` | `SessionMiddleware`, the `session()` factory for dependency injection, and `add_redis_sessions()` | +| `src/redis_fastapi/session_backend.py` | `cache_backend.py`, `ratelimit_backend.py` | `SessionStore`, an abstract base class (ABC) that owns the lifecycle. Also `RedisSessionBackend` and `SyncSessionBackend`, with these methods: `load`, `save`, `delete`, `rotate`, `touch`, `list_for_user`, and `revoke_all`. Section 7 gives the reason for an ABC instead of a plain `Protocol`. | + +Extend the existing files. Do not write the same code again. + +- `src/redis_fastapi/setup.py`: add `.sessions()` to the `FastAPIRedis` chain. +- `src/redis_fastapi/deps.py`: add `SessionDep`, `SessionBackendDep`, and + `get_session_backend`. Put them beside the existing `get_cache_backend` and + `get_rate_limit_backend`. Keep the `dependency_overrides` behaviour, because + tests need it. +- `src/redis_fastapi/config.py`: add `REDIS_SESSION_*` settings to + `RedisSettings`. These settings control the cookie name, the idle TTL, the + absolute TTL, the `SameSite` value, the `https_only` flag, the key prefix, and + the rotation policy. +- `src/redis_fastapi/telemetry.py`: add `session_*` instruments. Follow the + existing pattern in that file exactly. Add new fields to `_OTelState`. Add a + `session_span()` function beside `cache_span` and `ratelimit_span`. Add + `record_session_*()` helper functions with the same guard conditions. Add a + `timed_session()` context manager. Use these three instruments: + - `redis_fastapi.sessions.operations`, a counter. The `operation` attribute is + `load`, `save`, `rotate`, or `revoke`. The `result` attribute is `hit`, + `miss`, or `expired`. + - `redis_fastapi.sessions.latency`, a histogram. + - `redis_fastapi.sessions.active`, an up-down counter or a gauge. + + Keep two existing behaviours: each helper does nothing if the import failed, + and `disable_telemetry()` resets the state. + + Note the pattern in `telemetry.py`. It uses a module-global `_state`, and the + backend methods call free functions. This pattern is one reason why Section 7 + chooses an ABC. The instrumentation calls belong in the base class. Each store + then emits the telemetry once, and no vendor can omit them. +- `src/redis_fastapi/__init__.py`: add the new public names to `__all__`. +- Documentation: write `docs/guide/sessions.md` and add it to the mkdocs + navigation. Add a section to `docs/guide/observability.md` for the new metrics. + Write an application in `examples/` that shows a login, then `rotate()`, then a + logout, then `revoke_all()`. Write a second example for an Authlib OAuth flow. + +### Security requirements. Treat these as acceptance criteria. + +- **Declare a direct dependency on `starlette>=1.0.0`.** The design uses + `Session.accessed` and `Session.modified`. Starlette added these flags in + **version 1.0.0 (2026-03-22)**, because #3166 went into that release. Today + `pyproject.toml` declares no direct dependency on Starlette. Starlette arrives + through `fastapi>=0.115.0`, and `uv.lock` selects version 1.3.1. The current + specification therefore permits a 0.3x version of Starlette, which has no + `Session` class. Without a minimum version, the store writes nothing when the + data changes. +- Make session IDs with `secrets.token_urlsafe(32)`, which gives at least 128 + bits. Keep the IDs opaque. Never put data into an ID. +- Call `rotate()` after authentication and after a privilege change. Delete the + old key and move the data to the new key. +- Enforce an idle TTL **and** an absolute TTL. Redis must enforce both of them. +- Use strict cookie defaults: `HttpOnly`, `SameSite=Lax`, and `Secure`. Supply a + documented method to disable `Secure` during development. `starsessions` also + uses strict defaults, and that decision is correct. +- Send `Cache-Control: no-store` with each response that carries a session. + Support `Clear-Site-Data` at logout. +- Write a salted hash of the session ID to the log. Never write the ID itself. + This rule also applies to telemetry: **a session ID must never become a span + attribute or a metric label.** +- Document CSRF clearly. A cookie session has CSRF exposure, but a bearer token + does not. Point the user to `fastapi-csrf-protect`, which gets 138k downloads + each month, or write your own guidance. +- Make one feature optional and disable it by default: a check of the IP address + and the User-Agent, to *detect* a stolen session. Document the OWASP warning + with this feature. No user must think that it is a control. ### Verification -- Unit tests under `tests/unit/` mirroring the existing cache/ratelimit test layout: - rotation preserves data and invalidates the old key; idle vs absolute expiry are - independent; `revoke_all` kills every session for a user; no Redis write when the - session is untouched (assert on `modified`); `Vary: Cookie` present when accessed. -- **Do not trust `modified` blindly** (see §6.1 — it currently under-reports upstream). - Tests must assert that a `popitem()`, a `|=`, and a *nested* mutation - (`session["a"]["b"] = 1`) are still persisted. Nested mutation is undetectable by any - `dict` subclass and therefore unfixable upstream, so the store needs an explicit - `save()` escape hatch and an opt-in always-write mode. These tests must pass whether or - not upstream #3436 merges. -- Integration tests under `tests/integration/` against a real Redis: cross-worker - session sharing (two app instances, one Redis), TTL behaviour, concurrent-request - race that previously clobbered cookies. -- Interop test: an Authlib OAuth flow completing against the Redis-backed store - unmodified, plus a >4KB payload that would break the signed-cookie path. -- Telemetry test following the existing pattern: assert instruments record and that - `disable_telemetry()` restores a clean `_OTelState`; assert no session ID appears in - any attribute. -- Run `nox` (lint, mypy, bandit, coverage) — the repo gates on all four. -- Manual: run the `examples/` login app, confirm `HttpOnly`/`Secure`/`SameSite` flags, - inspect keys in Redis, confirm logout deletes them. - -### Sequencing suggestion - -Ship `session_backend.py` + `sessions.py` + DI + config + strict cookie defaults + -`rotate`/`revoke` first (the OWASP core). Add `list_sessions`/`revoke_all` -(concurrent-session control) and the Authlib interop example as a close follow-up — -they are the differentiators worth their own release note. +- Write unit tests in `tests/unit/`. Use the same structure as the existing cache + and rate limit tests. Test these conditions: + - `rotate()` keeps the data and makes the old key invalid. + - The idle timeout and the absolute timeout work independently. + - `revoke_all` removes every session of one user. + - The store writes nothing to Redis if no code touched the session. Assert on + the `modified` flag. + - The response has a `Vary: Cookie` header if code read the session. +- **Do not trust the `modified` flag.** Section 6.1 explains that the flag does not + report every change today. The tests must show that the store keeps the data + after each of these three operations: + - `popitem()` + - `|=` + - a change inside a nested object, such as `session["a"]["b"] = 1` + + No `dict` subclass can detect the nested change, so nobody can correct it + upstream. The store therefore needs an explicit `save()` method, and an + optional mode that always writes. These tests must pass even if the + maintainers never merge #3436. +- Write integration tests in `tests/integration/` against a real Redis server. + Test that two application instances share one session through one Redis server. + Test the TTL behaviour. Test the concurrent requests that replaced a cookie + before #3166. +- Write a compatibility test. An Authlib OAuth flow must complete against the + Redis store without any change. Also test a payload larger than 4KB, which a + signed cookie cannot hold. +- Write a telemetry test. Follow the existing pattern. Assert that the instruments + record the data. Assert that `disable_telemetry()` gives a clean `_OTelState`. + Assert that no attribute contains a session ID. +- Run `nox`. It runs the lint, mypy, bandit, and coverage steps, and the + repository requires all four. +- Do a manual test. Run the login application in `examples/`. Confirm the + `HttpOnly`, `Secure`, and `SameSite` flags. Look at the keys in Redis. Confirm + that a logout deletes them. + +### Suggested order of work + +Release these parts first, because they are the OWASP core: +`session_backend.py`, `sessions.py`, the dependency injection, the configuration, +the strict cookie defaults, `rotate`, and `revoke`. Then release +`list_sessions` and `revoke_all`, which control concurrent sessions, and the +Authlib compatibility example. These parts are the differences from other +packages, so give them their own release note. --- -## 6. Upstream strategy — what we propose, and what we own +## 6. Upstream strategy: what we propose, and what we keep -A follow-up question on this dossier: should the vendor-agnostic part be proposed to -Starlette or FastAPI as common code, so other vendors could extend it later? +A second question came after the research above. Must we propose the +vendor-neutral part to Starlette or to FastAPI as common code, so that other +vendors can extend it later? -**Vendor-agnostic yes; upstream-owned no.** Decomposing the feature by neutrality shows -where the seam actually falls: +**Make it vendor-neutral, but keep it in this package.** The table below divides +the feature into layers, and it shows where the correct division falls. -| Layer | Vendor-neutral? | Upstream-viable? | +| Layer | Vendor-neutral? | Can upstream accept it? | |---|---|---| -| L1 `Session` dict + `accessed`/`modified` tracking | already upstream | already there — but incomplete, see §6.1 | -| L2 `Session` importable without the cookie middleware | pure refactor | plausible small PR (§6.3) | -| L3 Cookie carrier parameterised by a store | neutral | **this is PR #499's diff** | -| L4 `SessionStore` protocol (`load`/`save`/`delete`/`touch`) | neutral — *this is the ask* | **declined twice** | -| L5 OWASP lifecycle (`rotate`, dual TTL, `revoke_all`, per-user index) | neutral interface | out of scope for a "feature complete" toolkit | -| L6 Redis implementation | vendor-specific | ours | - -L3+L4 is precisely what a "submit it upstream" proposal would be — and precisely what -[starlette#499](https://github.com/encode/starlette/pull/499) already *was*: pluggable -session backends, no vendor in the diff. It sat open roughly three years and closed -unmerged. The evidence is not "nobody proposed the neutral version"; the neutral version -is the one that was refused, and [#2256](https://github.com/Kludex/starlette/discussions/2256) -got the same answer in 2023. - -Verified against source rather than the issue tracker: `starlette/middleware/sessions.py` -on `master` today still has **no store, backend, or serializer parameter** — state is -hardcoded to JSON + base64 + `TimestampSigner`. Nothing has moved on L3/L4. - -There is also an independent reason not to *want* it upstream even if it were offered: -**release-cadence coupling.** Session handling is CVE-adjacent (fixation, rotation, cookie -flags). If our store implements an upstream-owned protocol, every interface fix ships on -Starlette's timeline and we carry `hasattr` shims across N-1/N-2. Owning the interface -locally is a feature, not a compromise. - -Precedent points the same way. In every ecosystem §4a cites, the store abstraction lived -one layer *below* the framework and one *above* the vendor: `express-session` (itself a -third-party package) defines the `Store` base and `connect-redis` implements it — Node core -owns neither. Spring's `SessionRepository` lives in Spring Session, not in the Servlet -spec. So the abstraction should be vendor-agnostic, and it should be ours. - -What *is* worth sending upstream is much smaller, and one item is time-sensitive. - -### 6.1 Harden the `accessed`/`modified` contract we depend on — highest value - -Differentiator #1 above is "write to Redis only on mutation." That is only *sound* if -`modified` never under-reports. **Today it does.** Verified in -`starlette/middleware/sessions.py`: - -`Session` overrides `__setitem__`, `__delitem__`, `clear`, `pop`, `setdefault` and -`update` — but **not `popitem()` and not `|=`**. `dict.__ior__` updates at C level, which -bypasses the Python-level `update()` override entirely. For a signed cookie that costs a -`Set-Cookie`; for a server-side store it is a **silently lost write**, which is strictly -worse. - -This is already covered by [starlette#3436](https://github.com/Kludex/starlette/pull/3436) -(opened 2026-08-10 by an outside contributor, still open and unmerged as of this writing). -**Review and co-sign it — do not duplicate it.** There is an open PR modifying the exact -primitive we intend to build on; putting a Redis-maintainer voice in that thread before -the contract settles is worth more than proposing an abstraction that will be declined. - -### 6.2 New PR — `pop()` sets `modified` but never `accessed` - -`pop()` does `self.modified = self.modified or key in self`, while `mark_modified()` sets -*both* flags. A pop-only request therefore emits `Set-Cookie` without `Vary: Cookie` — -inconsistent with every other mutating path, and the `Vary` header is what -[#2019](https://github.com/Kludex/starlette/issues/2019) was about. Two lines plus a test, -and outside #3436's stated scope. - -### 6.3 Proposal — a neutral import path for `Session` - -Propose `starlette.datastructures.Session` (or a `starlette.sessions` module) with the -existing name kept as an alias. Argument: a third-party store must currently import from -`starlette.middleware.sessions` — pulling in the itsdangerous cookie middleware — just to -reference the type contract. Supporting evidence that the contract is already de-facto -public and duck-typed: `starlette/requests.py:169-175` type-imports `Session` under -`TYPE_CHECKING` and then guards the call with `hasattr(session, "mark_accessed")`. - -Moderate odds, low cost, and **zero blocker if declined** — we import from the middleware -module or duck-type, exactly as core itself does. - -### 6.4 Docs PRs, post-launch — the lever upstream reliably accepts - -List the SDK on Starlette's and FastAPI's third-party pages once shipped. Higher value: -correct pointers that still route ~70k downloads/month to the **archived** -`fastapi-sessions` (§2) — a live supply-chain problem, and a contribution nobody has to -argue about. +| L1 The `Session` dict with the `accessed` and `modified` flags | already upstream | already present, but incomplete. See Section 6.1. | +| L2 A `Session` import that does not need the cookie middleware | a refactor only | a small PR is possible. See Section 6.3. | +| L3 A cookie carrier with a store as a parameter | neutral | **this is the diff of PR #499** | +| L4 A `SessionStore` protocol with `load`, `save`, `delete`, and `touch` | neutral. **This is the proposal.** | **refused two times** | +| L5 The OWASP lifecycle: `rotate`, two TTLs, `revoke_all`, and an index for each user | the interface is neutral | outside the scope of a feature-complete toolkit | +| L6 The Redis implementation | specific to the vendor | ours | + +Layers L3 and L4 together are the proposal. They are also exactly the content of +[starlette#499](https://github.com/encode/starlette/pull/499): session backends +that you can exchange, with no vendor code in the diff. That PR stayed open for +approximately three years, and the maintainers closed it without a merge. Nobody +can say that the neutral version has no proposal. The maintainers refused the +neutral version, and they gave the same answer to +[#2256](https://github.com/Kludex/starlette/discussions/2256) in 2023. + +We verified the current state in the source code, not only in the issue tracker. +Today `starlette/middleware/sessions.py` on `master` still has **no parameter for +a store, a backend, or a serializer**. The code always uses JSON, then base64, +then `TimestampSigner`. Nothing changed at layers L3 and L4. + +There is a second reason to keep the interface. **An upstream interface follows +the upstream release schedule.** Faults in session code become security +vulnerabilities, because they involve fixation, rotation, and cookie flags. If our +store uses an upstream protocol, then every correction to that protocol waits for +a Starlette release. We must also support one or two older releases with +`hasattr` tests. Control of the interface is therefore an advantage. + +The history of other ecosystems gives the same answer. In each ecosystem in +Section 4, item (a), the store abstraction sits between the framework and the +vendor. +`express-session` is a third-party package, and it defines the `Store` base class. +`connect-redis` implements that class. The Node core owns neither of them. + +Spring puts `SessionRepository` in Spring Session, not in the Servlet +specification. Therefore make the abstraction vendor-neutral, and keep it here. + +The parts that we must send upstream are much smaller. One of them is urgent. + +### 6.1 Correct the `accessed` and `modified` flags that we depend on + +Difference 1 in Section 5 is the rule to write to Redis only when the data +changes. That rule is correct only if the `modified` flag reports every change. +**Today it does not.** We verified this in +`starlette/middleware/sessions.py`. + +The `Session` class overrides `__setitem__`, `__delitem__`, `clear`, `pop`, +`setdefault`, and `update`. It does **not** override `popitem()` and it does not +override `|=`. The `dict.__ior__` method updates the dictionary in C code, so it +does not use the `update()` override. For a signed cookie, the result is one lost +`Set-Cookie` header. For a server-side store, the result is a lost write, and the +store gives no error. The second result is much worse. + +[starlette#3436](https://github.com/Kludex/starlette/pull/3436) already corrects +this. An external contributor opened it on 2026-08-10, and it is still open. +**Review that PR and support it. Do not write a second PR for the same problem.** +An open PR changes the exact behaviour that our design uses. A comment from a +Redis maintainer in that discussion has more value than a proposal that the +maintainers will refuse. + +### 6.2 A new PR: `pop()` sets `modified` but never sets `accessed` + +The `pop()` method runs `self.modified = self.modified or key in self`. The +`mark_modified()` method sets *both* flags. A request that only calls `pop()` +therefore sends `Set-Cookie` without `Vary: Cookie`. This result is different from +every other method that changes the data. The `Vary` header is also the subject of +[#2019](https://github.com/Kludex/starlette/issues/2019). The correction needs two +lines and one test, and it is outside the scope of #3436. + +### 6.3 A proposal: a neutral import path for `Session` + +Propose `starlette.datastructures.Session`, or a new `starlette.sessions` module. +Keep the existing name as an alias. Give this argument: a third-party store must +import from `starlette.middleware.sessions` today. That import loads the +itsdangerous cookie middleware, and the store needs only the type. The core code +shows that the type is already public in practice. +`starlette/requests.py:169-175` imports `Session` under `TYPE_CHECKING`, and then +it tests the object with `hasattr(session, "mark_accessed")`. + +This proposal has a moderate chance and a low cost. It also **blocks nothing**. If +the maintainers refuse it, we import from the middleware module, or we test the +object in the same way as the core code. + +### 6.4 Documentation PRs after the release + +Add the SDK to the third-party pages of Starlette and FastAPI after we release it. +One task has more value. Some pages still send approximately 70k downloads each +month to the **archived** `fastapi-sessions` package, as Section 2 shows. That is +a supply chain risk. Nobody will argue against a correction. --- -## 7. Store contract — an ABC for the lifecycle, a narrow Protocol at the seam - -The natural instinct for "let other vendors extend this later" is a `typing.Protocol`, so -a Postgres or Valkey store could conform structurally with no dependency on this package. -For most seams that is the right call. **For this one it is not**, and the reason is -security rather than style. - -**A Protocol cannot hold an invariant.** It is a type-checker artefact that evaporates at -runtime. A conforming store passes `mypy` while seeding IDs from `random.random()`, or -implementing `rotate()` as write-new-without-deleting-old — which is session fixation, the -exact attack §3 lists rotation as the defence against. - -**The dangerous methods are the ones most likely to be stubbed.** `revoke_all(user_id)` -and `list_for_user(user_id)` require a per-user index (a Redis SET). A vendor for whom -that is awkward will write `pass` / `return []`. That structurally conforms *and* turns a -security control into a silent no-op — an empty list reads to the calling application as -"this user has no other sessions." That is worse than an unimplemented method, because it -is indistinguishable from a correct answer. - -**It also inverts OWASP's own guidance** quoted in §3 — *"recommended to use these -built-in frameworks versus building a home made one from scratch"*. A Protocol means N -implementations of fixation defence and dual-TTL clock arithmetic; a base class means one, -written and reviewed once. - -**And `telemetry.py` settles it.** That module's state is a global (`_state`) and its -helpers are free functions invoked *inside* backend methods — `cache_span`, -`record_cache_request` and `timed_operation` are woven into `CacheBackend.get()`, not -layered over it. So under a bare Protocol a non-Redis store either imports -`redis_fastapi.telemetry` (reintroducing the dependency edge that was the whole point) or -emits nothing at all, and `redis_fastapi.sessions.operations{operation="rotate"}` goes -silent. For a security metric, silence is indistinguishable from "no rotations are -happening." +## 7. Store contract: an ABC for the lifecycle, a Protocol at the interface + +For the goal "let other vendors extend this later", a `typing.Protocol` looks +correct. A Postgres store or a Valkey store can then match the type without a +dependency on this package. For most interfaces this is the right choice. **For +this interface it is the wrong choice.** The reason is security, not style. + +**A Protocol cannot enforce a rule.** The type checker uses the Protocol. Python +does not use it at run time. A store can satisfy `mypy` and still be unsafe. + +For example, it can make session IDs with `random.random()`. It can also write the +new key in `rotate()` but not delete the old key. This second fault causes session +fixation. Section 3 shows that rotation is the defence against that attack. + +**A vendor is most likely to omit the dangerous methods.** The +`revoke_all(user_id)` and `list_for_user(user_id)` methods need an index for each +user, such as a Redis SET. A vendor that finds this difficult will write `pass` or +`return []`. The type checker accepts that code, but the security control then +does nothing. An empty list tells the application that the user has no other +sessions. This result is worse than a method that raises an error, because the +application cannot see the difference from a correct answer. + +**A Protocol also contradicts the OWASP guidance** in Section 3: +*"recommended to use these built-in frameworks versus building a home made one +from scratch"*. A Protocol gives one implementation of fixation defence for each +vendor. It also gives one implementation of the two-clock TTL calculation for each +vendor. A base class gives one implementation in total, and the authors write and +review it one time. + +**The code in `telemetry.py` gives the final reason.** That module keeps its state +in a global object, `_state`. Its helper functions are free functions, and the +backend methods call them. For example, `CacheBackend.get()` contains calls to +`cache_span`, `record_cache_request`, and `timed_operation`. The telemetry is +inside the method, not around it. + +With a plain Protocol, a store that does not use Redis has two options. It can +import `redis_fastapi.telemetry`, but then it depends on this package again, and +the Protocol has no purpose. Or it can emit nothing, and then +`redis_fastapi.sessions.operations{operation="rotate"}` reports no data. For a +security metric, no data looks the same as no rotations. ### Decision -- **`SessionStore(ABC)` owns the lifecycle concretely:** `new_id()` via - `secrets.token_urlsafe(32)`; `rotate()` as a template method with fixed ordering (write - new → migrate data → delete old → update user index); idle/absolute TTL arithmetic; and - the telemetry call sites, so every store inherits instrumentation and the - "never log a raw session ID" rule is enforced at one set of call sites instead of being - re-litigated per vendor. -- **Abstract only the storage primitives:** `_read`, `_write`, `_delete`, `_expire`, - `_index_add`, `_index_members`. The vendor seam is deliberately boring. -- **Capability flags, not silent stubs:** `supports_user_index` and friends, so the - middleware refuses to expose a control the store cannot deliver rather than returning a - misleading answer. -- **Keep a narrow `SessionStoreProtocol`** describing only what `sessions.py` middleware - and DI actually consume — so tests and integrators can substitute without inheriting, - and the middleware never types against the concrete Redis class. This is the honest - vendor-agnostic piece. (`dependency_overrides` testability is DI-level and unaffected - either way.) - -**Repo idiom supports exactly this split.** The one existing Protocol, `Coder` -(`src/redis_fastapi/types.py:15`), is a stateless two-method value-conversion seam whose -default implementation `JsonCoder` deliberately does *not* inherit it. The classes that own -*behaviour* — `CacheBackend`, `RateLimitBackend` — are plain concrete classes. Protocol for -consequence-free seams; owned code for behaviour. A session store is behaviour, and -security behaviour at that. No ABC exists in the tree yet, so this is a new idiom and -worth flagging as one — but neither caching nor rate limiting carries invariants whose -violation is a CVE. - -### Corollary — interop is an adapter, not a contract concession - -Differentiator #4 makes interop the adoption hook, which invites shaping our contract to -match `starsessions`' store interface (roughly `read`/`write`/`remove`/`exists`, with one -TTL on `write`). **Do not.** That alignment costs precisely the operations §3 requires: - -- **No `rotate()` in the contract** → fixation defence becomes caller-side read + - write-new + remove-old: three round trips, non-atomic, and a crash mid-sequence leaves - two valid sessions. -- **One TTL parameter cannot express two clocks** → absolute expiry has to be smuggled - into the payload and checked after decode, so expiry is enforced by our Python rather - than by Redis — directly against *"expiration must be enforced server-side."* -- **No per-user index** → `revoke_all` becomes impossible, deleting the one capability - §5 calls structurally impossible for a cookie library. - -Correct position: **adapter in, not contract out.** Ship a thin adapter so a -`starsessions` store can be *used* by our middleware in declared-degraded mode (missing -capabilities reported `False`), and document the migration path — without bending our own -contract to four methods. +- **`SessionStore(ABC)` contains the lifecycle code.** It makes IDs with + `secrets.token_urlsafe(32)`. Its `rotate()` method is a template method. That + method keeps a fixed order: + 1. Write the new key. + 2. Move the data to the new key. + 3. Delete the old key. + 4. Update the index of the user. + + The class also calculates the idle TTL and the absolute TTL. It contains the + telemetry calls, so every store gets the instrumentation. The rule against a + raw session ID applies at one set of calls. No vendor can change it. +- **Declare only the storage methods abstract:** `_read`, `_write`, `_delete`, + `_expire`, `_index_add`, and `_index_members`. The interface for a vendor stays + simple on purpose. +- **Use capability flags. Do not accept empty methods.** Add + `supports_user_index` and similar flags. The middleware then refuses to offer a + control that the store cannot supply. It does not return an answer that misleads + the application. +- **Also declare a small `SessionStoreProtocol`.** It describes only the methods + that the middleware in `sessions.py` and the dependency injection actually call. + Tests and other developers can then supply an object without inheritance, and + the middleware never uses the concrete Redis class as a type. This is the part + that is truly vendor-neutral. The `dependency_overrides` behaviour works at the + dependency injection level, so this decision does not change it. + +**The existing code in this repository supports the same division.** The +repository has one Protocol today: `Coder`, in +`src/redis_fastapi/types.py:15`. It has two methods, it holds no state, and it +only converts a value. Its default implementation, `JsonCoder`, does *not* inherit +from it. The classes that contain behaviour are plain classes: `CacheBackend` and +`RateLimitBackend`. So the repository uses a Protocol for a simple interface, and +a normal class for behaviour. + +A session store contains behaviour, and that behaviour is part of security. The +repository has no ABC today, so an ABC is a new pattern here, and the +specification must say so. But the cache code and the rate limit code hold no rule +whose failure becomes a security vulnerability. + +### Result: compatibility needs an adapter, not a smaller contract + +Difference 4 in Section 5 uses compatibility to get adoption. That goal suggests +one more step: give our contract the same shape as the store interface in +`starsessions`. Their `SessionStore` abstract base class has three methods: + +```python +async def read(self, session_id: str, lifetime: int) -> bytes +async def write(self, session_id: str, data: bytes, lifetime: int, ttl: int) -> str +async def remove(self, session_id: str) -> None +``` + +**The benefit is real.** A vendor writes one class, and that class then works with +both packages. Structural typing needs no inheritance, so an existing +`starsessions` store for Postgres or for Memcached would satisfy our Protocol +without any change. Today a vendor must choose one package or write two classes. +Most vendors write for the larger ecosystem, and that is not us. + +**But do not copy the contract.** It cannot express three operations that +Section 3 requires. + +- **The contract has no `rotate()`.** The caller must build the defence against + fixation from three calls: `read` the old key, `write` the new key, then + `remove` the old key. Those calls are not atomic. Between the write and the + remove, two IDs give access to the same authenticated session. If the process + stops, or the caller ignores an error from `remove`, the old ID stays valid. + + `starsessions` shows the risk in its own code. Its `regenerate_id()` method + keeps the old ID in `_remove_data_for_session` and deletes it only at the next + `save()`. If `save()` never runs, the old ID survives until the absolute + timeout. In the Redis store, that timeout becomes `gc_ttl` when `lifetime` is + zero, and the default value of `gc_ttl` is 30 days. +- **The two time parameters describe one clock.** `lifetime` is the total session + duration. `ttl` is the time that remains under that same duration, because the + caller computes it as `(created + lifetime) - now`. Neither value is an idle + timer, and the contract has no `touch` method. Their Redis store also ignores + the `lifetime` argument to `read()`, so a read refreshes nothing. To add an idle + timeout you must keep `last_access` in the payload and test it after you decode + the payload. Redis still holds the key, and only Python refuses the session. + This result contradicts the OWASP requirement, + *"expiration must be enforced server-side."* +- **The contract has no index for each user.** Every method takes only a + `session_id`. Nothing records that a session belongs to a user, so `revoke_all` + is not weaker. It is impossible. Section 5 calls that capability the clearest + reason to use Redis. + +The correct position is an adapter, in one direction only. Keep our own contract. +Write a small adapter that maps their three methods onto our storage primitives: + +- `read` to `_read` +- `write` to `_write` +- `remove` to `_delete` + +Our `rotate()` then still deletes the old key before it returns. For the +primitives that they cannot supply, set the capability flags to `False`: +`supports_idle_ttl` and `supports_user_index`. `revoke_all()` then raises a clear +error. It does not return an empty list that misleads the application. + +Their stores therefore work with our middleware. Our stores do not work with +`starsessions`, and that is the cost of this decision. It is the smaller cost. +Document how to migrate. --- -## Open question for you - -Whether to scope v1 at **web sessions** (cookie + OWASP lifecycle, competing with -`starsessions`/`fastapi-users`) or to also cover **agent/MCP session state** (header or -argument-carried session id, no cookie, TTL'd working memory — the driver from §4c). -They share a backend but differ in transport and in who the audience is. My inclination -is web sessions first with the backend deliberately transport-agnostic, so the agent -case is a thin second adapter rather than a rewrite — but this overlaps with -`redis/agent-memory-server`, so it is a portfolio question as much as a technical one. - -§7 partly answers the technical half: the ABC-plus-narrow-Protocol split is *what makes* -"transport-agnostic backend" real rather than aspirational. The lifecycle (IDs, rotation, -dual TTL, per-user index, telemetry) lives in the base and is transport-free; only the -cookie carrier is web-specific and it lives in `sessions.py`, not in the store. An -agent/MCP adapter then supplies a different carrier — header or argument-carried session -id — against the same base. The remaining question is genuinely a portfolio one. - -### Immediate next actions - -1. Review and co-sign [starlette#3436](https://github.com/Kludex/starlette/pull/3436) - (§6.1) — time-sensitive, it is open now and touches the primitive this design rides. -2. Open the `pop()`/`accessed` PR (§6.2). -3. Add a direct `starlette>=1.0.0` floor to `pyproject.toml` when implementation starts. -4. Float the neutral `Session` import path (§6.3); proceed regardless of the answer. -5. Docs PRs after launch (§6.4). +## The open question + +Must version 1 support only **web sessions**? That scope means a cookie and the +OWASP lifecycle, and it competes with `starsessions` and `fastapi-users`. Or must +version 1 also support **session state for agents and MCP**? That scope means a +session ID in a header or in an argument, no cookie, and working memory with a +TTL. Section 4, item (c), describes this group of users. Both scopes use the same +backend, +but the transport is different and the users are different. + +My opinion: build the web sessions first, and make the backend independent of the +transport. The support for agents is then a second adapter, not new work. But this +scope also overlaps with `redis/agent-memory-server`. The decision is therefore +about the Redis product range as much as about the technical design. + +Section 7 answers part of the technical question. The ABC with a small Protocol is +the reason that a transport-independent backend is possible. The base class holds +the complete lifecycle, and no part of it depends on the transport: + +- the session IDs +- the rotation +- the idle TTL and the absolute TTL +- the index for each user +- the telemetry + +Only the cookie carrier is specific to the web, and it lives in `sessions.py`, not +in the store. An adapter for agents and MCP then supplies a different carrier. +That carrier reads the session ID from a header or from an argument, and it uses +the same base class. The remaining question is about the product range. + +### Next actions + +1. Review and support + [starlette#3436](https://github.com/Kludex/starlette/pull/3436), as Section 6.1 + explains. This action is urgent. The PR is open now, and it changes the + behaviour that this design uses. +2. Open the PR for `pop()` and the `accessed` flag, as Section 6.2 explains. +3. Add a minimum version of `starlette>=1.0.0` to `pyproject.toml` when the + implementation starts. +4. Propose the neutral import path for `Session`, as Section 6.3 explains. + Continue with the work whatever the answer is. +5. Send the documentation PRs after the release, as Section 6.4 explains. From f2021534007821ad2116ce9afdd0a6de1c9e3b48 Mon Sep 17 00:00:00 2001 From: Tihomir Mateev Date: Thu, 3 Sep 2026 16:05:51 +0300 Subject: [PATCH 03/11] Finalized design --- docs/specs/session-design.md | 2058 ++++++++++++++++++++++++++++++++++ docs/specs/session-mgmt.md | 951 +++++++++++++--- 2 files changed, 2842 insertions(+), 167 deletions(-) create mode 100644 docs/specs/session-design.md diff --git a/docs/specs/session-design.md b/docs/specs/session-design.md new file mode 100644 index 0000000..fe1e113 --- /dev/null +++ b/docs/specs/session-design.md @@ -0,0 +1,2058 @@ +# Session management: implementation design + +Companion to [`session-mgmt.md`](session-mgmt.md) which answers *why* we build +this and *what* goes into version 1 while this document answers the *how*. + +Research date: 2026-08-24. Starlette 1.6.0, FastAPI 0.138.2 in `uv.lock`. + +--- + +## 0. Requirements + +Everything after this section explains *how* a functional or non-functional requirement is met, or *why* an excluded +item is excluded. Each row carries an ID so a commit, a test or a review comment can cite it. + +### 0.1 Functional requirements + +| ID | Category | Requirement | +|------|----------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| F-1 | Lifecycle | Creating or destroying the session does not require an explicit API call, loaded in the middleware before any dependency or endpoint reads it, written only when its data changed. | +| F-2 | Lifecycle | **Rotation is automatic.** The middleware issues a new ID, deleting the old key first, whenever the session's principal changes on a successful response. | +| F-3 | Lifecycle | The rotation trigger is the **principal** — the identity plus whatever the application declares privilege-bearing — while the index key is the **subject**, which stays stable per user; rotation is executed both in case of escalation and de-escalation | +| F-4 | Lifecycle | Revocation in three forms: the current session, one session by ID scoped to its subject, and every session of a subject. | +| F-5 | Expiry | Two independent clocks, idle and absolute, both enforced by Redis. Writing the payload never extends the absolute deadline. | +| F-6 | Expiry | Cookie `max-age` derives from the same server-side numbers as the record. Redis can eventually reclaim what a closed browser abandoned. | +| F-7 | Reverse lookup | A session may be bound to a subject, which need not be a user. List and count a subject's live sessions, each with a descriptor, which keeps all the needed data, without having to consult the straight-record. | +| F-8 | Reverse lookup | A listing never reports a session that has already died. Reads to the session remove dead entries; writes re-assert lost ones. | +| F-9 | Transport | The cookie carries a signed opaque identifier and no session data. An invalid value is rejected and yields a new session. | +| F-10 | Transport | Cookie name, `Domain`, `Path`, `SameSite`, `Secure` and `HttpOnly` are configurable. `Vary: Cookie` is emitted whenever the session was accessed. | +| F-11 | API | One call enables the feature. A dict-like dependency needs no load or save, and `request.session` behaves as before, so existing code and Authlib run unchanged. | +| F-12 | API | The store exposes rotate, revoke, revoke-by-ID, revoke-all, list and count. Both dependencies resolve through `Depends`, so `dependency_overrides` works. | +| F-13 | API | Every part of the feature works from a `def` endpoint as well as an `async def` one, with no second pattern to learn. | +| F-14 | Data | `created`, `last_access` and `lifetime` are readable under those names(to preserve compatibility withother frameworks), stored alongside your data rather than mixed into it. | +| F-15 | Data | The payload is serialized through a replaceable coder, and optionally encrypted | +| F-16 | Data | Mutation is detected for `popitem()`, `\|=` and `pop()`. Nested mutation, which no `dict` subclass can see, has a documented escape route. | +| F-17 | Extensibility | Coder, encryptor, store, key prefix, ID factory, subject resolver and cookie builder are all replaceable. A supplied ID factory is validated on every call. | +| F-18 | Observability | Spans and metrics for every store operation, following the pattern the package already uses. | +| F-19 | Errors | One exception base, with configuration and store errors beneath it; no driver error reaches the caller. A failed read yields an empty session by default, and a failed write always raises. | +| F-20 | Migration | A documented path from each of four starting points: an in-process store, the Starlette signed cookie, `starsessions` with Redis, and the `fastapi-users` Redis strategy. | +| F-21 | Events | **Opt-in real-time session events.** When the server supports and is configured for them, Redis notifications drive application callbacks on session death. When it does not, the feature turns itself off and the application is unaffected. Section 13.4. | + +### 0.2 Non-functional requirements + +| ID | Category | Requirement | +|------|-----------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| N-1 | Performance | A request with no session cookie costs zero Redis calls. A read-only request with one costs a single pipelined round trip. An unchanged payload is never re-serialized or re-written. | +| N-2 | Performance | List and revoke-all cost two round trips regardless of session count. A count below the configured limit is `O(1)` with no verification. | +| N-3 | Compatibility | Redis 7.4 and later — the floor this package already declares — with no rise in the FastAPI floor. | +| N-4 | Compatibility | Correct on standalone and on Cluster, with no behavioural difference between them. | +| N-5 | Compatibility | No new required runtime dependency. A newer server lowers cost with no code change and no configuration. | +| N-6 | Correctness | Outliving the absolute deadline is unreachable by construction, not merely avoided by arithmetic. | +| N-7 | Correctness | Multi-key sequences are correct by ordering rather than by transaction, because Cluster forbids one across them. An interrupted rotation signs the user out and never leaves two valid IDs. | +| N-8 | Correctness | No interleaving or partial failure leaves a session that revoke-all cannot find. Where a guarantee cannot be given, its limit is documented rather than implied away. | +| N-9 | Security | Identifiers are opaque, carry no data, and come from a CSPRNG with at least 128 bits. `HttpOnly`, `SameSite=Lax` and `Secure` are on unless deliberately relaxed. | +| N-10 | Security | No session ID or subject appears in any log line, span attribute or metric label. Session-bearing responses are not stored by shared caches. | +| N-11 | Security | The CSRF exposure that a cookie session reintroduces is documented with a remedy. | +| N-12 | Operability | Documented guidance for running it: eviction policy, key legibility during an incident, and the per-subject key as a contention point with the seam that shards it. | +| N-13 | Testability | The unit suite runs against `fakeredis` with no Redis process, exercising the real key schema, TTL commands and index rather than a substitute. | +| N-14 | Testability | Every refuted claim has a test that fails against the earlier design. Documented recipes are executable code under test. | +| N-15 | Maintainability | Nothing ships gated on an unmerged upstream change. | +| N-16 | Maintainability | File split, dependency injection, settings and telemetry follow the existing cache and rate-limit code. An abstract base owns the lifecycle; a protocol bounds what callers touch. | +| N-17 | Correctness | No correctness claim rests on a notification. Every guarantee holds with events switched off, because Pub/Sub delivery can be dropped and an expiry event can lag the deadline it reports. | + +### 0.3 Out of scope + +| ID | Category | Excluded | Why | +|------|-----------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| X-1 | Not a session concern | Querying inside session payloads, and holding carts, orders or other domain entities there. | A session is not a queryable store. Keep identifiers in it and entities outside it. | +| X-2 | Not a session concern | User management: registration, password reset, verification, OAuth account linking. | We replace one layer of `fastapi-users`, not the framework. | +| X-3 | Other backends | An in-memory store, or a signed-cookie store. | `fakeredis` covers the test case and covers it better; Starlette's own middleware already is a cookie store. | +| X-4 | Other backends | Adopting `starsessions`' `read`/`write`/`remove` contract. | A cookie store dictates that shape, and it cannot express rotation, two clocks or a subject index. | +| X-5 | Deferred | A carrier for MCP and agent sessions. | Web sessions first. The store is already transport-agnostic, so this is a second carrier and not a rewrite. | +| X-6 | Deferred | An adapter for third-party `starsessions` stores, and reading an existing `starsessions` record in place. | Sessions do not survive the switch, by decision. Add the reader if users ask for a migration with no sign-out. | +| X-7 | Deferred | Concurrent-write detection: compare-and-set on the session payload. | **`SET IFEQ`/`IFDEQ` and `DELEX` cannot express this at all** — they are string commands and the session record is a hash. Section 13.5 gives the correction and names the mechanism that would work. Deferred, so two concurrent writes remain last-write-wins. | +| X-8 | Deferred | Client-side caching with `CLIENT TRACKING`. | Structurally inapplicable to the read path, because our read is a write command. | +| X-9 | Deferred | A query-engine index in place of the reverse lookup. | Raises the floor to 8.0 and can leave a session silently un-indexed, which revoke-all would then miss. | +| X-10 | Deferred | Re-rotating a live session on a schedule (OWASP's renewal timeout). | No timer in v1. Rotation on authentication and privilege change is automatic (Section 5.1); only the time-based variety is absent. A genuine gap rather than a boundary. | +| X-11 | Recipe, not code | A shipped AES-GCM encryptor. | Ship the seam and document the ten lines, rather than owning cryptographic code and its vulnerabilities. | +| X-12 | Recipe, not code | Hijack detection by IP and User-Agent; `Clear-Site-Data`, `Partitioned` and `__Host-` cookies; a Stream-backed audit log; per-tenant key namespaces and Cluster hash tags. | Each is reachable through a seam that already exists, so none needs code from us. | +| X-13 | Deferred | The `HIMPORT` family (Redis 8.10) for writing session keys. | `HIMPORT SET` takes no expiration option and overwrites the key, so it would destroy field `a` and its absolute deadline on every write — the one thing N-6 forbids. What it saves is field names on the wire, and ours are `a` and `d`. Section 13.5 gives the full reckoning. | + +## 1. Three corrections to `session-mgmt.md` + +### 1.1 The load is eager, not lazy + +`session-mgmt.md` once required "a load that happens only when code touches the session". +**No implementation can do that.** `HTTPConnection.session` is a **synchronous** property +in `starlette/requests.py`. A synchronous property cannot await a Redis `GET`. + +This is not our limitation. It is why `starsessions` ships `load_session()` and a +`LoadGuard` that raises `SessionNotLoaded`: the same wall, and they chose to hand the +problem to the user. + +We chose the other side of that trade. Section 5 difference 4 of `session-mgmt.md` keeps +the `scope["session"]` contract, which is what makes Authlib work with no change. That +choice puts the load in the middleware, which is the last point in the ASGI chain that can +still `await` before a dependency or an endpoint reads the session. Section 4 draws the +chain. + +**The rule: load when a session cookie is present.** No cookie means no Redis call, so +anonymous traffic costs nothing. An optional `skip` predicate excludes hot paths, which +is the same control `SessionAutoloadMiddleware` gives, inverted into an opt-out. + +The saving we do keep is on the write side, and it is the larger one: **we never +serialize or write a payload that did not change.** Section 4 gives the rule. + + +### 1.2 Redis Cluster removes atomicity, so ordering replaces it + +`src/redis_fastapi/ratelimit_backend.py:192` explains that rate-limit keys are flat, with +no hash tag, because that feature only ever touches one key at a time. + +**Sessions touch several.** A write touches the session key and the index key. A rotation +touches two session keys. On Redis Cluster those hash to different slots, so no Lua +script and no `MULTI` can hold them together. + +We cannot force them into one slot either. The lookup key is the session ID, and we do +not know the subject until after we read the record, so no hash tag can co-locate the two +without putting the subject in the cookie — which Section 3 of `session-mgmt.md` forbids, +because the ID must carry no meaning. + +**So we order the operations instead.** Section 5 gives the rotation order and the +argument for it. One good consequence: this store needs none of the Lua scripts or +capability probes that `ratelimit_backend.py` carries. Plain commands and pipelines. + +--- + +## 2. Modules + +Two new files, following the split that `cache.py` / `cache_backend.py` and +`ratelimit.py` / `ratelimit_backend.py` already use. + +| File | Contents | +|----------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------| +| `src/redis_fastapi/session_backend.py` | `SessionStore` (ABC, owns the lifecycle), `RedisSessionStore`, **`SyncSessionStore`**, `SessionStoreProtocol`, `SessionMetadata`, `SessionRecord` | +| `src/redis_fastapi/session_events.py` | `SessionEvents`, the tier probe, and the per-node subscriber task. Separate because it owns a background task and a Pub/Sub connection, which neither of the other two files does. Section 13.4 | +| `src/redis_fastapi/sessions.py` | `Session`, `SessionMiddleware`, the `session()` dependency factory, `add_redis_sessions()`, the cookie builder, the exceptions | + +Changes to some of the existing files include : + +| File | Addition | +|-----------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------| +| `src/redis_fastapi/setup.py` | `.sessions()` on `FastAPIRedis`, guarded by the existing `_has_middleware` helper | +| `src/redis_fastapi/deps.py` | `get_session_store`, `SessionStoreDep`, `SessionDep`, plus `get_sync_session_store` and `SyncSessionStoreDep`, beside the matching rate-limit pair | +| `src/redis_fastapi/config.py` | `session_*` fields on `RedisSettings`, beside the `rate_limit_*` fields | +| `src/redis_fastapi/telemetry.py` | `session_*` instruments, following the existing pattern exactly | +| `src/redis_fastapi/lifespan.py` | Start and stop the `SessionEvents` subscriber, and run its tier probe once per process, beside `probe_increx_support` | +| `src/redis_fastapi/__init__.py` | the new public names in `__all__` | + +Reused, not rewritten: + +- **`Coder` and `JsonCoder`**, `src/redis_fastapi/types.py:15` is the existing serialization + interface we can reuse without change. `pydantic_model_coder()` then works for a typed + session payload at no cost. +- **`settings.pattern_prefix()`**, `src/redis_fastapi/config.py:260`. +- **`get_async_redis` and `_get_pool_state`**, `src/redis_fastapi/deps.py`. +- **The `send_wrapper` pattern** in `RateLimitMiddleware.__call__`, + `src/redis_fastapi/ratelimit.py:538`. + +--- + +## 3. Redis keys + +### 3.1 The schema + +Two keys, and they answer two different questions. + +``` +# "Given this session ID from the cookie, what is the session?" +redis:fastapi:session: HASH + field "a" = "1" TTL = absolute the deadline marker + field "d" = TTL = idle the payload + +# "Given this user, which sessions do they have open?" <-- the reverse direction +redis:fastapi:sessions-of: HASH + field = TTL = absolute one field for each session +``` + +The first key is the one every request uses. The second exists **only** because three +things in Section 3 of `session-mgmt.md` need to go the other way — from a user to their +sessions — and the cookie cannot answer that. Section 3.3 explains it. + +Both are hashes with a TTL on each field, from +`settings.pattern_prefix("session")` and `settings.pattern_prefix("sessions-of")`. + +**Field `a` is always written, and always carries a TTL.** Section 3.2 explains why: a +missing `a` must mean one thing only. + +#### The two keys cannot collide, and not only by convention + +They live under **different prefixes**, so no session ID can produce the index key. The +strings `redis:fastapi:session:` and `redis:fastapi:sessions-of:` first differ at the +character after `session`, before either key's variable part begins, so equality is +impossible whatever the session ID contains. + +This is deliberate. An earlier draft nested the index under the session prefix and rested +the argument on `secrets.token_urlsafe` never emitting a `:`. That is true of the default +generator — but Section 9 exposes `id_factory` as a supported seam, and a factory +returning the literal `"by-subject:42"` produced a key byte-identical to the index key. +**An argument that holds only for the default is not a structural guarantee**, so the +structure now carries it instead. + +Section 9 additionally requires that a generated ID be validated, which catches the same +class of fault at its source rather than only in its consequence. + +Both keys are flat, with no hash tag, for the reason `ratelimit_backend.py:192` already +gives: a hash tag would send every session of one deployment to one slot and create a hot +shard. Section 5 explains how correctness survives without co-location. + +**Redis 7.4 is the floor this package already declares** (`README.md:39`, +`docs/getting-started/installation.md:29`), and hash-field expiration arrived in 7.4. So +every design below sits inside the support range we already promise. + +### 3.2 Two fields, two clocks, no arithmetic + +Section 3 of `session-mgmt.md` requires an idle timeout **and** an absolute timeout, and +requires that *"expiration must be enforced server-side"*. Two fields with two TTLs do +exactly that: + +| Field | TTL | Refreshed? | +|---|---|---| +| `a` | the absolute lifetime, set once at creation | **never** | +| `d` | the idle timeout | on every access | + +When `d` expires the session went idle. When `a` expires the absolute deadline passed, +whatever the user was doing. **Redis enforces both, and we compute neither.** + +#### Field `a` is always written, and always has a TTL + +Not an implementation detail — a correctness requirement, and getting it wrong disables +the whole feature for one supported configuration. + +`HTTL` answers with `-2` when a field is absent **or** its key is absent, and with `-1` +when the field exists with no expiry. An earlier draft read `-2` as "the absolute deadline +passed" and did not write `a` at all when `absolute_ttl` was `0`. Section 3.2 permits that +setting, and Section 9.4 of `session-mgmt.md` maps `starsessions`' `lifetime=0` onto it — +so for those deployments `HTTL` answered `-2` on every load and **every session was read +as expired the moment it was created.** + +Two rules remove the ambiguity: + +1. **Write `a` on every session creation, without exception.** This also keeps every + session key to one schema, which Section 13.2 depends on. +2. **Give `a` a TTL even when no absolute limit is configured.** With `absolute_ttl = 0` + it gets `gc_ttl`. Never leave it unexpiring: if `d` later expires and `a` does not, the + key survives with nobody to collect it. So `-1` never occurs, and `-2` carries exactly + one meaning. + +Section 4.1 reads the resulting state as a two-by-two, not as a single sentinel. + +This matters more than the round trip it saves. If we instead kept one key and set its +TTL to `min(absolute_remaining, idle)`, then the absolute deadline would be a number our +code recalculates on every write, and one arithmetic bug would let a session outlive its +absolute limit without any test noticing. With two fields that outcome is not a bug we +must avoid — it is unreachable. For a security control, the difference is the whole +point. + +Both settings accept an `int` or a `timedelta`, as `cache()` already does in this +package. With both at zero the session is cookie-only: no `max-age` on the cookie, so the +browser drops it when it closes, and **both** fields get `gc_ttl` so Redis eventually +collects what the browser abandoned. + +### 3.3 The subject index: from a user back to their sessions + +**This is not a search index, and it does not look inside session data.** It answers one +question, in the opposite direction to everything else in this design: *given a user, +which sessions do they currently have open?* + +A cookie carries a session ID, so the session key answers "who is this request?". Nothing +answers the reverse. Three controls that Section 3 of `session-mgmt.md` requires all need +that reverse direction: + +| The user does this | We need | Without the index | +|---|---|---| +| changes their password, or an administrator forces a sign-out | `revoke_all(user)` — end every session they have | impossible | +| opens the "signed in on these devices" screen | `list_sessions(user)` | impossible | +| signs in when a limit on concurrent sessions applies | a live count | impossible | + +"Impossible" is the accurate word. Redis has no query over key contents, so without a +second key the only way to find one user's sessions would be to `SCAN` the whole keyspace +and deserialize every session in the deployment to look at its payload. That is `O(all +sessions)` for one user's logout, and `SCAN` gives no consistent snapshot, so it could +still miss one. A reverse key turns all three into `O(1)` lookups. + +#### A worked example + +User 42 is signed in on a laptop and a phone. Three keys exist: + +``` +redis:fastapi:session:7Kp2...aQ -> a: "1" d: {"user_id": 42, ...} +redis:fastapi:session:mB9x...Lz -> a: "1" d: {"user_id": 42, ...} + +redis:fastapi:sessions-of:42 -> 7Kp2...aQ: {"ip": "10.0.0.4", "ua": "Firefox/Mac"} + mB9x...Lz: {"ip": "77.1.2.3", "ua": "Safari/iOS"} +``` + +A request arrives with cookie `7Kp2...aQ`, and only the first key is touched. The third +key is never read on the request path at all — it exists for the three controls above. +"Sign out everywhere" is then `DEL redis:fastapi:session:7Kp2...aQ`, +`DEL redis:fastapi:session:mB9x...Lz`, `DEL redis:fastapi:sessions-of:42`, with the +session IDs supplied by one `HGETALL` of the third key. + +**The subject need not be a user.** The `subject_of` seam in Section 9 chooses it, so it +can be a tenant, a device, or an API client. Returning `None` means no index entry, which +is the right answer for an anonymous session: no subject, nothing to revoke in bulk. + +#### The operations + +**The TTL on each field is that session's absolute deadline**, so Redis bounds the entry +and eventually removes it. + +| Operation | Command | Note | +|--------------------|------------------------------------------------------------------------------|---------------------------------------------------------------------------------| +| add | `HSETEX sessions-of: EX FIELDS 1 ` | 7.4: `HSET` then `HEXPIRE`. **Remaining, never the full lifetime** — see below. | +| list | `HGETALL sessions-of:`, then verify | see "the index is an upper bound" | +| count | `HLEN sessions-of:` | an **upper bound**, in `O(1)` | +| revoke one | `HDEL sessions-of: ` | | +| revoke all | `DEL sessions-of:` | | +| sweep dead entries | **none scheduled** | expiry does the bulk, reads repair the rest | + +#### The index is an upper bound, and a read must verify it + +This is the part an earlier draft got wrong, and the error is worth keeping visible so +nobody re-simplifies it away. That draft claimed `HGETALL` was "already free of expired +sessions" and that `HLEN` gave "a live count". + +**Both are false, because the entry's TTL is the absolute deadline and most sessions die +of idleness long before that.** With `idle = 30 min` and `absolute = 8 h`, a user who +closes their laptop at 10:00 has a dead session at 10:30 and an index entry until 18:00 — +so the "your devices" screen shows a phantom for seven and a half hours, and a cap on +concurrent sessions counts it. + +The entry cannot simply carry the idle TTL instead. The idle clock moves forward on every +request, so tracking it would mean writing the index key on every request, which would +destroy the single-round-trip read in Section 4.2. + +**So the index is authoritative about which sessions *might* be alive, and never about +which are.** `list_for_subject` and `revoke_all` both: + +1. `HGETALL` the index — one round trip, giving candidates and their descriptors. +2. Pipeline one `HTTL FIELDS 1 d` for each candidate — a second round trip, + whatever the number of candidates. +3. Drop the candidates whose `d` is gone, and `HDEL` them from the index. + +That is two round trips instead of one, on two operations that a user triggers by hand — +opening a screen, or changing a password. The request path is untouched. In exchange the +answer is correct, and step 3 makes every read repair the index, so dead entries never +accumulate even when nothing expires them. + +**`HLEN` stays useful precisely because it over-counts.** An upper bound below the limit +is a definitive answer, so a cap on concurrent sessions checks `HLEN` first and only pays +for verification when the bound is at or over the limit. The common case stays `O(1)`. + +Three properties survive, and neither a set nor a sorted set gives all three: + +1. **There is no scheduled prune.** Field expiry removes most entries with no code at all, + and the verification above removes the rest as a side effect of reading. No periodic + job, and no keyspace notification — which would need server configuration and can be + dropped anyway. +2. **The key bounds itself.** When the last field expires, Redis deletes the hash. A user + who never returns leaves nothing behind, and there is no TTL to maintain on the key. +3. **The value carries a descriptor**, so the listing needs no read of the session records + themselves — only the cheap `HTTL` liveness check. Put the creation time, the client + address and a device label in it, and the "your active sessions" screen that Section 3 + of `session-mgmt.md` asks for costs two round trips regardless of session count. + +#### Add and re-assert with the *remaining* absolute time + +The `add` row says `EX `, not `EX `, and Section 5.3 +re-asserts the entry on every session write. Those two facts interact. + +A relative `EX ` restarts the entry's clock on every re-assertion: measured, +an entry at 98 seconds of a 100-second lifetime went back to 100 on re-assert. An actively +used session would therefore hold an index entry that never expires and outlives the +session it describes — reintroducing the phantom this section exists to prevent. + +Use the remaining absolute time. Section 4.1 already reads it, as `HTTL` on field `a`, so +it costs no extra command. `EXAT` against the stored deadline is equivalent; what must not +happen is a fresh full lifetime. + +#### Why not the query engine instead? + +A reverse lookup is a kind of search, so the obvious question is why we maintain a second +key at all rather than declaring an index and letting Redis do it: + +``` +FT.CREATE idx:sessions ON HASH PREFIX 1 redis:fastapi:session: + SCHEMA subject TAG +FT.SEARCH idx:sessions '@subject:{42}' NOCONTENT +``` + +**Two of the arguments for it are correct, and should be recorded as such.** It is a +search. And we do pay a write we would not otherwise pay — although less than it appears, +because that write is pipelined with the session write, so it costs bytes and server CPU +rather than a round trip. It would also remove the hot-key problem in Section 13.3, since +there would be no per-tenant key for every login to contend on, and it would answer +questions our hash cannot: every session from one IP, every session created before a +given time, aggregations across tenants. + +It is not the choice for version 1, for four reasons, in order of weight. + +**1. A session can become silently un-revocable.** The index only contains documents whose +shape matches the schema. A session written without the indexed field — a bug, a partial +rollout, a custom `Coder` that nests the value differently — is simply absent from every +result. `revoke_all` then reports success and misses it, and the only signal is the +`hash_indexing_failures` counter in `FT.INFO`, which nothing in the request path reads. +Our hash has no such state: if `HSETEX` returned OK, the field is in the index, and if it +did not, the write failed loudly. Section 7 of `session-mgmt.md` rejects exactly this +class of failure — an answer the caller cannot distinguish from a correct one — and here +it would apply to the security control itself. + +**2. The floor.** The query engine became part of Redis Open Source in **8.0**. This +package supports **7.4**, where it is available only through Redis Stack. Building +`revoke_all` on it makes a security control conditional on the deployment, and a control +that is sometimes absent is worse than one that is always present. It is also absent from +several Redis-compatible services that users of this SDK do run. + +**3. On 7.4 to 7.x the index does not account for field expiration.** Redis 8 filters +logically-expired documents at query time, which is exactly right. Below 8, expired data +can still surface in `FT.SEARCH`, so `list_sessions` would over-report — the same failure +as reason 1, in the version range we support. Our design has no equivalent gap, because +the entry *is* the TTL. + +**4. `revoke_all` would need pagination.** `FT.SEARCH` caps `OFFSET + LIMIT`, so revoking +every session of a large tenant means cursor iteration. That is a loop, with partial +progress and a failure mode in the middle of a security operation, in place of one `DEL`. + +There is also a memory question we have not measured: an inverted index over millions of +session documents against one small hash per active subject. It would need `NOOFFSETS`, +`NOHL`, `NOFIELDS` and `NOFREQS` to trim what a session index never uses, and then a +benchmark. That is work version 1 does not need to do. + +**None of this closes the door, and the design already holds it open.** Section 7 of +`session-mgmt.md` splits the store into a concrete lifecycle and three abstract storage +primitives — `_index_add`, `_index_remove`, `_index_members`. A query-engine index is a +different implementation of those three and nothing else. So this is a store variant for +a later release, gated on Redis 8, and not a decision we are making permanently now. The +right moment to revisit is when the floor moves to 8.0, because reasons 2 and 3 disappear +at that point and reason 1 becomes a matter of validating writes. + +### 3.4 Searching inside session data: we do not, and neither does anyone else + +A separate question from the reverse lookup: can the application ask *"which sessions hold +a cart containing product X?"* — a query over the **contents** of the payload, not over an +identifier. + +**Not in this design.** The payload lives in field `d` as one value, produced by the +configured `Coder` and optionally by an `Encryptor`. Redis cannot look inside an opaque +string, and an encrypted payload could not be indexed even in principle. The only index +here is subject to sessions. + +**And not in the alternatives.** This is not a gap we alone have: + +| | Can it search session contents? | +|---------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------| +| `starsessions` | No. `SessionStore.read(session_id, lifetime)` is the only accessor; there is no method that takes anything but an ID. | +| `fastapi-users` `RedisStrategy` | No. The record holds `str(user.id)` and nothing else, so there is no payload to search. | +| Starlette `SessionMiddleware` | No. The data is in the client's cookie; the server keeps no copy. | +| Django, Rails, PHP, `express-session` | No. All are keyed by session ID. Django additionally base64-encodes the record, so even a SQL `LIKE` over the column finds nothing. | +| **Spring Session** | **Partly** — see below. | + +**Spring Session is the one that goes furthest**, and the shape of what it offers is +instructive. `FindByIndexNameSessionRepository.findByIndexNameAndIndexValue(name, value)` +returns every session whose declared index has that value, and a custom `IndexResolver` +chooses which attributes get indexed at save time. That is an equality index over a +**scalar attribute**, maintained as one set per value — the same structure as our +`sessions-of` key, generalised from one index to several. It still cannot answer "the cart +contains product X", because that is a containment query over a collection inside the +session, and no session framework in any ecosystem does that. + +Our `subject_of` seam is the singular form of the same idea. Generalising it to several +named indexes is a reasonable future request, and it is a small change: the store already +abstracts `_index_add`, `_index_remove` and `_index_members`. + +#### Why this is the right boundary, not a missing feature + +The cart example is the one that shows it. **A cart in a session is a design smell, and +the wish to search it is what exposes the smell.** + +- **A session expires; a cart should not.** Both clocks in Section 3.2 will delete the + session when the user goes idle or hits the absolute deadline. If the business wants to + know who has product X in their basket — for inventory, for an abandoned-basket email, + for merchandising — that answer must not depend on whether someone's laptop went to + sleep. +- **`rotate()` moves the data.** Every sign-in gives the session a new key, so any index + over its contents must follow the rotation. +- **It rules out encryption at rest.** Section 8.3.1 of `session-mgmt.md` offers an + `Encryptor`. Indexed contents cannot be encrypted, so a deployment would have to choose + between the two. +- **It rules out the compact-hash win.** Section 13.2 depends on every session key sharing + one schema. Per-session indexed fields break the template. +- **A basket is often anonymous**, so there is no subject to hang it from anyway. + +**The shape that works** is a pointer. The basket is a domain object with its own key, +its own lifetime and its own index; the session holds only its identifier: + +``` +redis:fastapi:session: -> d: {"cart_id": "c_8fA2"} small, uniform, encryptable +cart:c_8fA2 -> a hash, or JSON, that you own and index as you like +``` + +Now the basket survives the session, follows the user to another device when they sign in, +and can be queried with the full query engine — which is what that engine is for. Meanwhile +the session stays small, keeps one schema, and can be encrypted and rotated freely. + +Put this in the guide as a short rule, because it is the most common way people misuse a +session store: **keep identifiers in the session, keep entities outside it.** + +### 3.5 The record, and where metadata lives + +`starsessions` stores its metadata under a `__metadata__` key **inside** the session +payload, so it appears in the application's own `request.session`. Do not copy that. + +Field `d` holds an envelope: + +```json +{ + "m": {"created": 1756000000.0, "last_access": 1756000042.0, "lifetime": 3600}, + "d": {"user_id": 42, "cart_id": "c_8fA2", "flash": "Settings saved."} +} +``` + +`request.session` then holds the inner `d` alone, and contains only what the application +put there. The three field names in `m` are the three that `starsessions` uses, so its +`get_session_metadata()` accessors port across as a rename and not a redesign. + +The envelope is serialized by the configured `Coder`, then passed through the configured +`Encryptor` if one is set. Encryption wraps serialization, never the reverse: the `Coder` +must never see ciphertext. + +--- + +## 4. What runs on each request + +Everything below happens **inside one HTTP request**, every time. "Before the +application" and "after the application" are positions in the ASGI chain, not events in +the server's life: + +``` +request arrives +└─ SessionMiddleware ← §4.1 runs here: read the cookie, load from Redis + └─ the rest of the app ← "the application": router, dependencies, endpoint + └─ your endpoint ← reads request.session / SessionDep, already populated + ↑ SessionMiddleware ← §4.2 runs here, at http.response.start +response leaves +``` + +So "loaded before the application runs" means only this: **by the time any dependency or +endpoint touches the session, the Redis read has already happened.** It has to, because +`HTTPConnection.session` is a synchronous property and cannot await — Section 1.1. The +middleware is the last place in the chain that can still perform an `await`. + +### 4.1 Before the application + +1. Read the cookie named by `session_cookie_name`. +2. **Validate its value.** Accept only `[A-Za-z0-9_-]`, the alphabet of + `token_urlsafe`. Anything else is treated as no session at all. This is not + cosmetic: the value is written back into a `Set-Cookie` header, so an unvalidated + value is a header-injection vector. +3. No cookie, or a `skip` predicate that returns true: put an empty `Session` into + `scope["session"]` and call the application. **No Redis call.** +4. Otherwise read and refresh in **one** round trip, pipelined — both commands address + the same key, so they share a slot and the pipeline is safe on a cluster: + + ``` + HGETEX EX FIELDS 1 d -> the payload, and the idle clock restarts + HTTL FIELDS 1 a -> what remains of the absolute deadline + ``` + + `HGETEX` reads a field **and** sets its expiration in one command, so the load *is* + the idle refresh. There is no second command at response time and nothing to + optimise away. + +5. **Read the two answers as a pair, never one as a sentinel.** Section 3.2 guarantees + that `a` is always written and always carries a TTL, which is what makes this table + total: + + | `d` | `HTTL a` | Meaning | Action | + |---------|----------|----------------------------------------------------------------------------------------------------------|---------------------------------------------| + | present | `> 0` | alive | serve it; absolute remaining is that number | + | present | `-2` | the absolute deadline passed | empty `Session`; `DEL` the key | + | absent | `-2` | no such session — never existed, expired outright, or revoked | empty `Session` | + | absent | `> 0` | the idle clock ran out, absolute has time left | empty `Session`; `DEL` the key | + | any | `-1` | **cannot happen** — Section 3.2 forbids an unexpiring `a`. Treat as a bug: log and handle as no session. | | + + An earlier draft collapsed this into "`HTTL` returning `-2` means the absolute deadline + passed". `-2` is also what Redis answers for a field that was never written and for a + key that does not exist, so that reading broke every deployment with no absolute limit. + The pair disambiguates; a single value cannot. + + Deleting the key on rows two and four matters: it lets the index entry follow, rather + than leaving a candidate that every later verification has to reject. + +6. **Take the principal snapshot.** Evaluate `principal_of(session)` and keep the result + for the response. Section 5.1 explains what it is for; here it costs one call of a pure + function and no I/O. + +The application never sees the difference. An expired session, a revoked one and an absent +one are the same thing to a caller. + +The cookie `max-age` for the response is `min(idle, remaining a)`, and both numbers came +from Redis rather than from our own clock. + +### 4.2 After the application, at `http.response.start` + +Wrap `send`, as `RateLimitMiddleware` does at `ratelimit.py:551`. The two flags on +`Session` decide everything. + +| State | Redis (`refresh_on_load` on, the default) | Redis (`refresh_on_load` off) | Cookie | +|------------------------|-------------------------------------------------------|-------------------------------|-----------------------------| +| not accessed | nothing | nothing | nothing | +| accessed, not modified | **nothing** — step 4 already refreshed the idle clock | `HEXPIRE d ` | nothing | +| modified, non-empty | `HSETEX` field `d`, **then** `HSETEX` the index entry | same | `Set-Cookie` | +| modified, now empty | `DEL` the key, then `HDEL` the index entry | same | `Set-Cookie` that clears it | + +**Before any of those rows, take the second principal snapshot.** If it differs from the +one taken at step 6 of Section 4.1 **and** the status is below 400, the rotation sequence in +Section 5.2 replaces the row above and emits the new cookie. A changed principal on a +response of 400 or more persists nothing at all — Section 4.3 gives the reason. + +**The second row has two answers, and an earlier draft printed only the first.** It read +"nothing — step 4 already refreshed the idle clock", which is true only under the default. +With `refresh_on_load=False` the load is a plain `HGET`, nothing was refreshed, and this +row is the only place left to do it — so omitting the branch made that setting silently +stop the idle clock from ever advancing. + +The index write on row three is a re-assertion, not a create: it repeats on every write, +not only at login. `HSETEX` is idempotent and the command is already in the same pipeline, +so it costs nothing, and it repairs an index entry that a partial failure lost. Two +constraints on it, both from Section 3.3: the session key is written **before** the index +entry, and the entry takes the **remaining** absolute time — never a fresh full lifetime, +which would let the entry outlive the session. Section 4.1 has already read that remainder +from `HTTL a`. + +Add `Vary: Cookie` whenever `accessed` is true, so a cache never serves one user's page to +another. + +**The second row is where `HGETEX` pays.** A read-only request costs exactly one +pipelined round trip for the whole request, and no `refresh_threshold` is needed because +there is no extra command to suppress. An earlier draft carried that setting and defaulted +it to `0.1`, which traded up to ten per cent of idle-timeout precision for a saving that +`HGETEX` gives for free. The setting is gone. + +Writing field `d` never disturbs field `a`, so an active session keeps counting down to +its absolute deadline no matter how often it is written. + +**One semantic to state plainly.** Because the refresh happens at load, the idle clock +restarts for any request that arrives with a session cookie, whether or not the +application touched `request.session`. That matches PHP, Django with +`SESSION_SAVE_EVERY_REQUEST`, and `express-session` with `rolling`. An application that +wants the stricter reading — only a request that *used* the session counts as activity — +sets `refresh_on_load=False` and takes the second round trip. + +### 4.3 What a failed response persists + +The intuitive rule — a request that failed writes nothing — is wrong, and three ordinary +patterns break under it: + +- **A failed-login counter.** `session["failed_attempts"] += 1` then `raise 401`. Discard + the write and the counter never increments, so lockout silently stops working. +- **A flash message on error.** `session["flash"] = "Check the form"` then redirect. That + is the commonest use a flash message has. +- **A CSRF token minted, whose validation then failed.** The retry needs the token kept. + +So ordinary session data persists whatever the status. But nothing should hand out an +authenticated session on a request the client saw fail. Two rules: + +1. **Session data is written regardless of the response status.** +2. **Except when the principal changed and the status is 400 or more — then nothing is + written at all.** + +The exception is narrow and it exists to close one hole. Persisting the data while skipping +the rotation would leave `session["user_id"] = 42` stored against the *old*, unrotated ID: +an authenticated session with an identifier the client already had. That is precisely the +fixation this design exists to prevent, arrived at by being helpful. + +--- + +## 5. Rotation, and correctness without atomicity + +Rotation is the defence against session fixation, and Section 3 of `session-mgmt.md` +makes it mandatory after authentication and after any change of privilege. + +### 5.1 What triggers it: the principal changed + +**The application never calls rotation.** The middleware detects it. + +The middleware evaluates a pure function of the session, the **principal**, twice: once +before the application runs and once at `http.response.start`. If the two differ and the +response status is below 400, it rotates. + +This is the design's central safety property. Rotation-forgotten is the only mistake in +this API that is a vulnerability, and there is no call to forget. It follows that: + +- An application signs a user in by writing the identity. Nothing else. +- Code we did not write gets the same protection. An application migrating off Starlette's + signed cookie keeps its existing `request.session["user_id"] = …` and acquires fixation + defence without touching the handler — and Section 9.3.2 of `session-mgmt.md` makes that + the largest population of adopters. +- The failure asymmetry runs the right way. Rotating when we need not is harmless — a new + cookie carrying the same data. Not rotating when we should is the vulnerability. A + detector biased toward firing is therefore the safe bias. + +#### Principal and subject are two different questions + +They pull in opposite directions, so they are two functions: + +| | Answers | Must be | +|---|---|---| +| `subject_of(session)` | which key indexes this session | **stable** per user, or `revoke_all` breaks across a role change | +| `principal_of(session)` | what must not change without rotation | **sensitive** to privilege | + +`principal_of` defaults to `subject_of`, so an application that only cares about sign-in +configures nothing. One that wants OWASP's privilege-change rotation declares what counts: + +```python +FastAPIRedis(app).sessions(principal_keys=["user_id", "role"]) +``` + +Now `session["role"] = "admin"` rotates on its own — and so does dropping back to `"user"`, +which OWASP also requires and which an explicit call is especially easy to forget on the +way down. A callable is the escape hatch when a list of keys will not do: + +```python +FastAPIRedis(app).sessions( + principal_of=lambda s: (s.get("user_id"), s.get("role"), s.get("tenant")), +) +``` + +Prefer the list. It is greppable, it is reviewable — "what does this application consider +privilege?" is answerable without reading code — and it cannot accidentally perform I/O. + +An explicit `reauthenticate()` remains for the one case this cannot see: a privilege change +with no trace in session state, such as re-entering a password before a sensitive action. +That genuinely is an event rather than a state change, and reads correctly as a call. + +#### The sequence + +``` +Client SessionMiddleware principal_of SessionStore Redis + │ │ │ │ │ + ├─ POST /login ──────▶│ │ │ │ + │ Cookie: sess=OLD │ │ │ │ + │ ├─ validate charset │ │ │ + │ ├────────────────────┼────────────────┼──────────────▶│ + │ │ pipeline: HGETEX sess:OLD … d / HTTL … a │ + │ │◀───────────────────┼────────────────┼───────────────┤ + │ ├─ build Session(d) │ │ │ + │ ├───────────────────▶│ │ │ + │ │ before = None ◀─┤ snapshot BEFORE the app │ + │ ┌────────▼───────────────────────────────┐ │ │ + │ │ the app: router → deps → endpoint │ │ │ + │ │ user = authenticate(...) │ │ │ + │ │ session["user_id"] = 42 │ │ │ + │ └────────┬───────────────────────────────┘ │ │ + │ │ 200 │ │ │ + │ ├───────────────────▶│ │ │ + │ │ after = "42" ◀─┤ snapshot at response.start │ + │ │ │ │ │ + │ ├── changed, and status < 400 ───────▶│ │ + │ │ │ rotate() ├─ DEL sess:OLD▶│ + │ │ │ ├─ HDEL index ─▶│ + │ │ │ ├─ HSETEX a ───▶│ + │ │ │ ├─ HSETEX d ───▶│ + │ │ │ ├─ HSETEX ─────▶│ + │ │ │ │ sessions-of:42 + │◀── 200 ─────────────┤ │ │ │ + │ Set-Cookie: NEW │ │ │ │ +``` + +`principal_of` is called **exactly twice** per request. When the two snapshots match — every +ordinary request — the middleware skips this entirely and falls through to the write rule +in Section 4.2. + +#### What the detector must guarantee + +`principal_of` is user-supplied and a security control depends on it, so four rules are +not optional. + +| Situation | Rule | +|---|---| +| It raises at the response snapshot | **Fail the response.** We cannot tell whether a privilege transition happened, and both guesses are unsafe — rotating with no subject leaves an un-indexed, un-revocable session. A loud 500 beats either. | +| It is impure, does I/O, or is non-deterministic | Rejected by contract. Document that it must be pure and cheap; the two results are compared by value. | +| It returns unverified input, such as an email the client set | It must return a **verified** identity. The blast radius is bounded — the index grants nothing, so the worst case is a polluted device listing rather than an escalation — but say so. | +| Identity exists but authentication is incomplete, as at MFA step one | Not a defect, and `principal_of` is the right place to express it: `lambda s: s.get("user_id") if s.get("mfa_ok") else None`. | + +**And a misconfiguration is silent**, which is the one genuine cost of detecting rather +than being told. If an application stores `uid` while the resolver reads `user_id`, nothing +rotates and nothing complains. Two mitigations, and the first is a deliverable: + +- **Ship a test helper.** An assertion that a given login flow changes the cookie value + turns a configuration risk into a property the application proves once. Section 11 lists + it. +- The `operation="rotate"` counter in Section 10 is the runtime backstop: sign-ins with no + rotations is a visible anomaly on a dashboard. + +Note that the alternative is not safer here. Any design where the application calls +rotation must get that call right on **every** path that establishes identity — password, +OAuth callback, magic link, SAML, MFA step two, impersonation. Detection concentrates the +risk in one place that is configured once and tested once. + +### 5.2 How it runs: ordering, not atomicity + +We already hold the payload in memory, so no read is needed. The order is: + +``` +1. DEL redis:fastapi:session: +2. HDEL redis:fastapi:sessions-of: +3. HSETEX redis:fastapi:session: EX FIELDS 1 a 1 +4. HSETEX redis:fastapi:session: EX FIELDS 1 d +5. HSETEX redis:fastapi:sessions-of: EX FIELDS 1 +6. Set-Cookie with +``` + +Step 3 restarts the absolute clock, which is correct: rotation follows authentication or +a change of privilege, so a new session begins. Step 5 is therefore the one place where +the index entry legitimately takes the **full** absolute lifetime rather than a remainder +— the session it describes was created in step 3, one command earlier. Every other write +of that entry uses the remainder, for the reason in Section 3.3. + +When `absolute_ttl` is `0`, step 3 still runs and `a` takes `gc_ttl`, per Section 3.2. +There is no branch in which `a` goes unwritten. + +**Delete before write, and the order is the security control.** A crash between steps 1 +and 3 signs the user out, and they sign in again. A crash in the other order would leave +two session IDs valid for the same authenticated session, which is the fixation window +that Section 7 of `session-mgmt.md` criticises in `starsessions`, whose `regenerate_id()` +keeps the old ID until a later `save()` that may never run. + +So we do not need a transaction. Ordering gives the property that a transaction would +have given, and it gives it on a cluster too, where a transaction is not available. + +Steps 1 and 2 go in one pipeline, and steps 3 and 4 in a second. Two pipelines and not +one, because the boundary between them is the ordering guarantee. `transaction=False`: +on a cluster, redis-py splits a pipeline across nodes by slot, and these keys are on +different slots by design. + +`revoke()` is steps 1, 2 and 5 alone. `revoke_all(subject)` prunes, lists, deletes every +member in one pipeline, then deletes the index key. + +### 5.3 The reverse lookup is not atomic either, and does not need to be + +**A pipeline batches; it does not isolate.** Redis executes each command atomically, but +another client's commands can land between ours. On a cluster it is worse than that: the +session key and the index key are on different slots, so redis-py sends them to different +nodes and they are genuinely concurrent. The window is a network round trip, not the +microseconds between two back-to-back commands on a single-threaded server. + +So the question is what an interleaving can actually produce. There are only three +outcomes, and only one of them matters. + +| Outcome | How | Harm | +|-----------------------------------------------------------------------|-----------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------| +| index entry exists, session gone | `revoke()` interleaved between `DEL` and `HDEL`; or a session that expired by TTL | **none.** The field expires on its own, and a `revoke_all` that acts on it deletes a key that is already gone. | +| session exists, created after a concurrent `revoke_all` read its list | login racing a password change | **bounded.** The session is fully consistent and revocable next time. See the semantics below. | +| **session exists, index entry does not** | the index write failed after the session write succeeded | **this is the one.** The session is invisible to `revoke_all` until its absolute deadline. | + +**The third row is the only failure worth engineering against**, because it is the same +fault this design refuses in Section 3.3 when rejecting the query engine: a session that +`revoke_all` silently misses. + +**Atomicity would not fix the second row.** Even if create were one atomic unit, the +interleaving could be: the revoker reads the index and sees no new session, we atomically +write both keys, the revoker deletes what its stale list contained. The race is between +the revoker's snapshot and our write, not inside our write, so a transaction around our +half changes nothing. This is worth stating because it is the intuitive fix and it does +not work. + +Three measures instead, none of them a transaction: + +**1. Write the session key before the index entry.** The current order is not arbitrary. +Reversed, a concurrent `revoke_all` landing between the two deletes the index entry we +just wrote and then our session write lands — producing the third row, an orphaned and +un-revocable session. In the current order the same interleaving produces the second row, +which is consistent and revocable. **The ordering converts the dangerous outcome into the +harmless one**, exactly as it does for rotation above. + +**2. Re-assert the index entry on every session write, not only at creation.** `HSETEX` is +idempotent, and the write is already in the pipeline that Section 4.2 issues for a +modified session, so this costs no round trip and no extra command in the common case. +It turns "un-revocable until the absolute deadline" into "un-revocable until this user's +next request that changes the session". A partial failure then repairs itself instead of +persisting for hours. + +**Re-assert with the remaining absolute time, not a fresh lifetime.** Section 3.3 shows +what a relative `EX ` does here: it restarts the entry's clock on every write, +so a session in constant use holds an entry that never expires. Section 4.1 has already +read the remainder from `HTTL a`, so the correct value is in hand at no cost. This is the +easiest of the three measures to get subtly wrong, because the wrong version looks +identical and fails only on long-lived sessions. + +**3. Bound it regardless.** The index entry's TTL is the session's absolute deadline, so +even an orphan that is never repaired dies with the session it describes. Nothing leaks +past the absolute clock — provided measure 2 uses the remainder, which is the other reason +that detail matters. + +**A note on the reverse direction.** These measures make an entry that is *missing* rare +and self-healing. They do nothing about an entry that is *present but stale*, because the +index tracks the absolute clock while sessions usually die of idleness. That is not a race +at all but a structural property, and Section 3.3 handles it by verifying candidates on +read rather than trusting the membership. + +**State `revoke_all`'s semantics rather than implying stronger ones.** It ends every +session that existed when it read the index. A session created concurrently may survive, +and that is not only unavoidable but arguably right: it did not exist when the caller +asked. The documentation must say this, because "sign out everywhere" reads like a +guarantee about the future and is not one. Where the guarantee genuinely matters — +credential compromise — the caller must change the credential first and revoke second, so +that a racing login cannot succeed. + +**The alternative we are not taking.** A generation counter per subject would give strict +semantics: `revoke_all` becomes one atomic `INCR`, every session records the generation it +was born under, and any session older than the current value is dead. No enumeration, no +race. The cost is that every request must compare the two values, and the subject is +inside the session record, so learning it requires the read we have just done — a second +round trip on the request path, doubling its Redis cost. That is too much to pay for a +race this narrow, but it is the right answer for a deployment that needs revocation to be +strictly linearizable, and the store's abstract primitives leave room to add it. + +--- + +## 6. The two clocks + +Section 3.2 puts each clock on its own hash field, so **Redis enforces both and the store +computes neither**. What remains here is the cookie, which Redis cannot enforce. + +The cookie `max-age` must agree with whichever clock will fire first: + +``` +max_age = min(idle_ttl, HTTL(key, "a")) +``` + +Both numbers come from Redis — the configured idle window, and the absolute remainder +that the server itself is counting down. Nothing is derived from the application's own +clock, so a container with a skewed clock cannot produce a cookie that disagrees with the +record. + +**If the cookie and the record ever disagree, the browser deletes a cookie whose session +is still alive, and the user is signed out with no cause and no log line.** Section 8.3.2 +of `session-mgmt.md` records that failure. Deriving both from the same two server-side +numbers is what prevents it. + +In cookie-only mode the cookie carries no `max-age` at all and the browser decides. + +### Translation from `starsessions` + +Their `rolling=True` extends the cookie and the record by the full lifetime on every +response: that is our idle clock, field `d`. Their `rolling=False` keeps the original +expiry: that is our absolute clock, field `a`. We can express both, and we can run the +two together, which their single clock cannot. + +--- + +## 7. When Redis is unreachable + +`session_fail_closed` mirrors the existing `rate_limit_fail_closed`. The default is +**asymmetric**, and the asymmetry is the point. + +**A read that fails yields an empty session.** The request continues, and the user looks +anonymous. Nothing is silently permitted: the application's own authorization dependency +still runs, finds no user, and returns a login page or a 401. A protected route stays +protected, because it never depended on the session load succeeding. Log at `warning` and +record a `result="error"` metric. + +**A write that fails raises `SessionStoreError`.** Losing a login, or losing a rotation, +is the worst outcome in this design, and it must never be silent. A rotation that half +fails has already deleted the old key, so the user is signed out — safe, and Section 5 +explains why that ordering was chosen. + +Setting `session_fail_closed=True` turns the failed read into a `SessionStoreError` too, +for a deployment that would rather return 503 than serve an anonymous page. + +### Exceptions + +``` +SessionError base, so a caller can catch the whole feature +├── SessionConfigurationError a missing or invalid setting +└── SessionStoreError the store failed; wraps the driver error +``` + +Never let a `redis.RedisError` reach application code. There is no equivalent of +`SessionNotLoaded`: our load is automatic. + +--- + +## 8. The `Session` class + +A `dict` subclass with `accessed` and `modified`, and `mark_accessed()` under exactly +that name, so the `hasattr` hook in Starlette 1.0 and later finds it and calls it for us. +Section 5a of `session-mgmt.md` gives that evidence. + +It overrides what the upstream class overrides — `__setitem__`, `__delitem__`, `clear`, +`update`, `setdefault` — and then corrects the four faults from Section 6.1 of +`session-mgmt.md`: + +| Fault upstream | Ours | +|-----------------------------------------------------------------------------------|------------------------------------------------------------------------------------------------------------------| +| `popitem()` sets no flag | override it | +| `\|=` sets no flag, because `dict.__ior__` runs in C and never reaches `update()` | override `__ior__` | +| `pop()` sets `modified` but not `accessed` | call `mark_modified()`, which sets both | +| a nested change, `session["a"]["b"] = 1`, sets no flag | **unfixable in any `dict` subclass.** The store exposes `save()`, and a setting forces a write on every request. | + +The fourth row is a limit of the language and not a defect we inherited, so no release of +Starlette will remove it. The escape route is the answer. + +--- + +## 9. Public API + +### The store + +`SessionStore` is an abstract base class, not a bare protocol. Section 7 of +`session-mgmt.md` gives the reason: the lifecycle is a security control, and a base class +implements it once rather than once for each vendor. + +**Concrete on the base class** — written once, including the TTL rules and every telemetry +call: + +| Method | Signature | Purpose | +|--------------------------------------|-------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------| +| `rotate` | `(session, *, subject=None) -> str` | New ID, old key deleted first, cookie updated. **Normally driven by the middleware** when the principal changes (Section 5.1); public for the rare handler that must force one. | +| `reauthenticate` | `(session) -> None` | Force a rotation on the way out, for a privilege change that leaves no trace in session state. | +| `revoke` | `(session) -> None` | End the session in hand and clear its cookie. | +| `revoke_id` | `(session_id, *, subject) -> bool` | End one session by ID. **Requires the subject** and refuses an ID not indexed under it, so a caller cannot end a stranger's session. | +| `revoke_all` | `(subject) -> int` | End every session for a subject. Returns the number ended. | +| `list_for_subject` | `(subject) -> list[SessionInfo]` | Live sessions with their descriptors. Verifies liveness before returning (Section 3.3). | +| `count_for_subject` | `(subject) -> int` | Live count. `HLEN` fast path, verified only when it reaches a limit. | +| `session_id` | `(session) -> str \| None` | The current ID, for marking "this device" in a listing. | +| `save` / `touch` / `delete` / `load` | | The lifecycle the middleware calls; rarely needed directly. | +| `new_id` | `() -> str` | Generates and **validates** an ID. | + +`SessionInfo` is a frozen dataclass — `session_id`, `created`, `last_access`, `descriptor` +— so a listing needs no read of the session records themselves. + +**Abstract, and this is the whole surface a new backend implements:** `_read`, `_write`, +`_expire`, `_delete`, `_index_add`, `_index_remove`, `_index_members`. + +`SessionStoreProtocol` describes only what the middleware and the dependencies call, so a +test or another package can supply an object with no inheritance. + +#### Sync endpoints + +FastAPI serves `def` handlers as readily as `async def` ones, so the feature must work in +both with one pattern to learn, not two. + +**Reading and writing a session already needs nothing.** `SessionDep` is a dict in the ASGI +scope and the middleware performs all the I/O, so a `def` handler uses it unchanged and +awaits nothing: + +```python +@app.post("/settings") +def save(theme: str, session: SessionDep) -> dict: + session["flash"] = "Saved." # no await anywhere + return {"ok": True} +``` + +**The imperative store operations get a facade.** `SyncSessionStore` mirrors +`SyncCacheBackend` (`cache_backend.py:367`) and `SyncRateLimitBackend` +(`ratelimit_backend.py:459`): each method delegates through `anyio.from_thread.run`, and +`SyncSessionStoreDep` injects it. Copy the warning those two carry — it **only** works from +a FastAPI-managed worker thread, and raises `RuntimeError` elsewhere. State the remedy +explicitly for this feature, because "expire old sessions from a scheduled job" is a more +tempting misuse here than its equivalent is for a cache: outside a request, use the async +store. + +Two notes the implementation must not lose. + +**Thread safety is fine, and a reviewer will ask why.** A `def` handler mutates +`scope["session"]` on a worker thread while the middleware reads `accessed` and `modified` +on the event-loop thread afterwards. There is no race: Starlette runs the handler through +`await anyio.to_thread.run_sync(...)`, so the thread's completion orders every write before +the middleware's read. + +**A blocking call costs a worker thread for a whole round trip**, and anyio's default pool +is **40** threads. That is affordable for `revoke_all` or a device listing, which happen +once in a while. It would not be affordable for something on the sign-in path of every +request, which is one reason the sign-in shape is still open. + +### Setup and injection + +One line enables it, beside the calls this package already has: + +```python +from fastapi import FastAPI +from redis_fastapi import FastAPIRedis + +app = FastAPI() +FastAPIRedis(app).lifespan().sessions() +``` + +`SessionDep` gives the `Session` for the current request. `SessionStoreDep` gives the +store for the operations that reach beyond this request — `revoke_id()`, +`list_for_subject()`, `count_for_subject()` and `revoke_all()`. Both resolve +through `Depends`, so `dependency_overrides` works, which this package already +advertises. + +The cookie appears in OpenAPI through `fastapi.security.APIKeyCookie`, which answers the +original complaint in [fastapi#754](https://github.com/fastapi/fastapi/issues/754) using +the primitive tiangolo recommended there. + +#### Reading and writing a session + +`SessionDep` is a `dict`. Nothing is loaded by hand and nothing is saved by hand — the +middleware writes at response time, and only if the data changed (Section 4.2). + +The two things below are what a session is *for*: **state that should cease to exist when +the session does.** Where the user was heading before being asked to sign in, and a message +to show them once on the next page. If either is lost because the session expired, that is +the correct outcome and not data loss. + +```python +from typing import Annotated +from fastapi import Depends, Request +from fastapi.responses import RedirectResponse +from redis_fastapi import SessionDep + + +@app.get("/admin") +async def admin(session: SessionDep): + if "user_id" not in session: + session["next"] = "/admin" # remember the destination, then ask them in + return RedirectResponse("/login") + return {"panel": "..."} + + +@app.post("/settings") +async def save_settings(theme: str, session: SessionDep) -> dict: + await db.save_theme(session["user_id"], theme) + session["flash"] = "Settings saved." # to be shown exactly once + return {"ok": True} + + +@app.get("/dashboard") +async def dashboard(session: SessionDep) -> dict: + return { + "flash": session.pop("flash", None), # reading it also clears it + "user_id": session.get("user_id"), + } +``` + +Three details worth naming: + +- **Assignment is the trigger.** `session["next"] = …` marks the session modified, and the + middleware writes it at `http.response.start`. There is no `save()` to forget. +- **`pop()` counts as a change**, so consuming the flash message persists its removal. This + is the method whose upstream version forgets to mark the session accessed, which is why + Section 8 overrides it. +- **Reading alone writes nothing.** The `/dashboard` handler above does write, because + `pop()` mutated. A handler that only called `session.get(...)` would issue no Redis + command at response time at all. + +For what does **not** belong in here — a shopping cart's contents, an order, any entity +with a life of its own — see Section 3.4. The rule there is to keep identifiers in the +session and entities outside it. + +**`request.session` keeps working**, which is what makes Authlib and any existing code +run unchanged (Section 5a of `session-mgmt.md`). Use whichever fits: + +```python +@app.get("/whoami") +async def whoami(request: Request) -> dict: + return {"user_id": request.session.get("user_id")} +``` + +#### A dependency that requires a signed-in user + +The common shape. Note that it needs no knowledge of Redis, and that it is what makes the +fail-open read in Section 7 safe: a failed load yields an empty session, so this rejects. + +```python +from fastapi import HTTPException, status + + +async def current_user(session: SessionDep) -> int: + user_id = session.get("user_id") + if user_id is None: + raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Not signed in") + return user_id + + +CurrentUser = Annotated[int, Depends(current_user)] + + +@app.get("/orders") +async def my_orders(user_id: CurrentUser) -> list[dict]: + return await db.orders_for(user_id) +``` + +### The store, used directly + +`SessionStoreDep` is for the operations a session dict cannot express. These four cover +almost every real use. + +#### Recipe 1 — sign in, sign out, and change privilege + +**There is no sign-in call.** Writing the identity *is* signing in; the middleware sees the +principal change and rotates. Section 5.1 gives the mechanism. + +```python +from redis_fastapi import SessionDep + + +@app.post("/login") +async def login(form: LoginForm, session: SessionDep) -> dict: + user = await authenticate(form.username, form.password) + if user is None: + raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Bad credentials") + + session["user_id"] = user.id # principal changed → rotated on the way out + return {"ok": True} + + +@app.post("/logout") +async def logout(session: SessionDep) -> dict: + session.clear() # emptied → key deleted, cookie cleared + return {"ok": True} +``` + +Three things are absent on purpose: no store dependency, no `await`, and no rotation call +to forget. The handler is identical as a `def`, because nothing in it touches Redis. + +**Privilege changes rotate too, once declared.** With `principal_keys=["user_id", "role"]` +from Section 5.1, a role change is a principal change: + +```python +@app.post("/elevate") +async def become_admin(session: SessionDep, user_id: CurrentUser) -> dict: + await audit.record(user_id, "elevated") + session["role"] = "admin" # principal changed → rotated + return {"ok": True} +``` + +The `raise` in the first handler needs no undo. A principal that changes on a response of +400 or more persists nothing at all — Section 4.3. + +For a privilege change that leaves no trace in the session — re-entering a password before +a sensitive action, say — there is an explicit `reauthenticate()`. It is the exception, not +the pattern. + +#### Recipe 2 — sign out everywhere + +For a password change, an administrator forcing a sign-out, or a user who thinks a device +was stolen. This is the reverse lookup in Section 3.3 earning its keep. + +```python +@app.post("/password") +async def change_password( + new_password: str, + user_id: CurrentUser, + store: SessionStoreDep, +) -> dict: + await db.set_password(user_id, new_password) # credential first + ended = await store.revoke_all(subject=str(user_id)) + return {"sessions_ended": ended} +``` + +**Change the credential before revoking, as above.** Section 5.3 explains why: a session +created between the read and the delete may survive, so the new password must already be +in force for a racing sign-in to be harmless. + +To end only the current session, `revoke()` takes no subject: + +```python +@app.post("/logout") +async def logout(session: SessionDep, store: SessionStoreDep) -> dict: + await store.revoke(session) # deletes the key, clears the cookie + return {"ok": True} +``` + +#### Recipe 3 — "you are signed in on these devices" + +`list_for_subject()` returns the live sessions with the descriptor each one carries, so no +session record has to be read. It verifies liveness before returning, per Section 3.3, so +an idle-dead session never appears. + +```python +@app.get("/account/sessions") +async def my_sessions( + user_id: CurrentUser, + session: SessionDep, + store: SessionStoreDep, +) -> list[dict]: + current = store.session_id(session) + return [ + { + "created": s.created, + "last_seen": s.last_access, + "ip": s.descriptor.get("ip"), + "device": s.descriptor.get("ua"), + "current": s.session_id == current, + } + for s in await store.list_for_subject(str(user_id)) + ] + + +@app.delete("/account/sessions/{session_id}") +async def end_one( + session_id: str, + user_id: CurrentUser, + store: SessionStoreDep, +) -> dict: + # Scope the delete to this user, or one user could end another's session. + await store.revoke_id(session_id, subject=str(user_id)) + return {"ok": True} +``` + +**Never take the session ID from the client without scoping it to the caller.** +`revoke_id` requires the subject for that reason: it refuses an ID that is not indexed +under that subject. + +#### Recipe 4 — cap concurrent sessions + +Section 3 of `session-mgmt.md` lists this as a control. `count_for_subject()` is the +`HLEN` fast path from Section 3.3 — an upper bound, so a count under the limit needs no +verification, and only a count at the limit pays for it. + +```python +MAX_SESSIONS = 3 + + +@app.post("/login") +async def login_capped( + form: LoginForm, + session: SessionDep, + store: SessionStoreDep, +) -> dict: + user = await authenticate(form.username, form.password) + if user is None: + raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Bad credentials") + + subject = str(user.id) + if await store.count_for_subject(subject) >= MAX_SESSIONS: + # Either refuse the new sign-in... + raise HTTPException(status.HTTP_409_CONFLICT, "Too many active sessions") + # ...or evict the oldest, which is usually the better product decision: + # oldest = min(await store.list_for_subject(subject), key=lambda s: s.created) + # await store.revoke_id(oldest.session_id, subject=subject) + + session["user_id"] = user.id # principal changed → rotated on the way out + return {"ok": True} +``` + +This is the case that mixes both styles, and the mix is the point. The **check** reads +Redis, so it is awaited; the **sign-in** is still just a write. And the `raise` needs no +undo, because a principal that changed on a 409 persists nothing (Section 4.3). + +#### Testing against the store + +`dependency_overrides` works because both dependencies resolve through `Depends`. Unit +tests need no override at all — point the pool at `fakeredis`, exactly as +`tests/conftest.py:113` already does, and the real store runs against the real commands +(Section 8.3.3 of `session-mgmt.md`). + +### Extension points + +Every seam from Section 9.1 of `session-mgmt.md`, as a signature: + +| Seam | Signature | +|-------------------|----------------------------------------------------------------------| +| serialization | `Coder`, from `types.py:15` | +| encryption | `Encryptor`: `encrypt(bytes) -> bytes`, `decrypt(bytes) -> bytes` | +| storage | `SessionStore` subclass, or `SessionStoreProtocol` | +| key names | `key_prefix: str \| Callable[[str], str]` | +| ID format | `id_factory: Callable[[], str]` — **validated on output**, see below | +| index subject | `subject_of: Callable[[Session], str \| None]` | +| rotation trigger | `principal_keys: list[str]`, or `principal_of: Callable[[Session], Hashable]`; defaults to `subject_of`. See Section 5.1. | +| cookie attributes | `cookie_builder: Callable[[CookieSpec], str]` | + +**The cookie builder earns its place.** Section 2a of `session-mgmt.md` records that the +Starlette middleware cannot emit `Partitioned` and cannot use a `__Host-` prefix, and +that this is a common reason people abandon it. A seam here means a user adds the +attribute instead of waiting for our release. + +`subject_of` returning `None` disables the index for that session, which is correct for +an anonymous one: no subject, no index entry, and `revoke_all` has nothing to promise. + +**Validate what `id_factory` returns, on every call.** Apply the same character rule that +Section 4.1 applies to an incoming cookie — `[A-Za-z0-9_-]` — plus a minimum length, and +raise `SessionConfigurationError` on a violation rather than writing the key. + +A seam that supplies a security-critical value has to be checked, not trusted. Section 3.1 +records what a custom factory can otherwise do: returning `"by-subject:42"` built a key +that collided with the index key under the old layout. That particular collision is now +structurally impossible, but the general point stands — an ID containing a separator, or +one that is far too short, is a defect this package should refuse rather than store. The +check is one regular expression on a path that runs once per session. + +### Settings + +Fields on `RedisSettings`, beside the existing `rate_limit_*` ones and following the same +`Field(default=…, description=…)` style. The env prefix is `REDIS_` +(`config.py:207`), so `session_idle_ttl` is set by `REDIS_SESSION_IDLE_TTL`. + +| Setting | Type | Default | Description | +|-----------------------------|-------------------------------|---------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `session_cookie_name` | `str` | `"session"` | Name of the session cookie. Matches Starlette's and `starsessions`' default so a migration keeps existing cookie names. Validated at construction: letters, digits, `-` and `_` only. | +| `session_cookie_domain` | `str \| None` | `None` | `Domain` attribute. `None` scopes the cookie to the exact host that set it. Setting it also exposes the cookie to subdomains. | +| `session_cookie_path` | `str` | `"/"` | `Path` attribute. Narrow it (e.g. `"/admin"`) and other paths neither send nor receive the cookie. | +| `session_cookie_same_site` | `"lax" \| "strict" \| "none"` | `"lax"` | `SameSite` attribute. `"none"` requires `session_cookie_https_only=True`; the two are checked together and a contradiction raises `SessionConfigurationError`. | +| `session_cookie_https_only` | `bool` | `True` | Adds `Secure`, so the browser sends the cookie over HTTPS only. **On by default**; turn it off for local development over plain HTTP and nowhere else. | +| `session_idle_ttl` | `int \| timedelta` | `1800` (30 min) | The idle clock. The session dies this long after the last request that carried its cookie. Stored as the TTL of field `d`. `0` disables the idle clock. | +| `session_absolute_ttl` | `int \| timedelta` | `28800` (8 h) | The absolute clock. The session dies this long after creation however active the user is. Stored as the TTL of field `a`. `0` disables it, in which case `a` takes `session_gc_ttl` — see Section 3.2, which explains why `a` is never left unexpiring. | +| `session_gc_ttl` | `int \| timedelta` | `2592000` (30 days) | Backstop TTL for a key whose real deadline is unknown: cookie-only mode, or `session_absolute_ttl=0`. Never reached in normal operation; it exists so Redis can always collect an abandoned key. | +| `session_refresh_on_load` | `bool` | `True` | `True`: the load uses `HGETEX`, so any request carrying the cookie restarts the idle clock in the same round trip. `False`: the load uses `HGET` and only a request that touched `request.session` refreshes it, at the cost of a second round trip. Section 4.2 gives both branches. | +| `session_fail_closed` | `bool` | `False` | Behaviour when Redis is unreachable **on read**. `False` yields an empty session, so the caller looks anonymous and the application's own authorization rejects them. `True` raises `SessionStoreError` instead, for a deployment that prefers a 503 to an anonymous page. **Writes always raise, whatever this is set to** — Section 7 explains the asymmetry. | +| `session_always_save` | `bool` | `False` | Write the payload on every request that touched the session, even when no mutation was detected. The escape route for the one fault no `dict` subclass can see: a change inside a nested value, `session["a"]["b"] = 1` (Section 8). Costs a write per request; prefer reassigning the top-level key. | +| `session_principal_keys` | `list[str]` | `["user_id"]` | Session keys the rotation trigger watches. A change to any of them on a successful response rotates the ID. Add `"role"` or `"scopes"` for OWASP's privilege-change rotation. Section 5.1; use `principal_of` when a list of keys cannot express it. | +| `session_events_enabled` | `bool` | `False` | Subscribe to Redis notifications and call registered handlers when a session ends (Section 13.4, F-21). **Best-effort.** On a server below 8.8, or one where `notify-keyspace-events` lacks the subkey flags, or where `CONFIG GET` is unavailable, the store logs one warning at startup and the handlers never fire. Never enable the server setting on the operator's behalf. | +| `session_key_prefix` | `str \| None` | `None` | Overrides the key namespace. `None` uses `settings.pattern_prefix()`, giving `redis:fastapi:session:` and `redis:fastapi:sessions-of:`. A callable prefix is a constructor argument rather than a setting, since an environment variable cannot carry one (Section 9, extension points). | + +#### Three things the §5 list in `session-mgmt.md` names that are deliberately not settings + +- **Cookie-only mode** is not a flag. It is what you get with `session_idle_ttl=0` **and** + `session_absolute_ttl=0`: no `max-age` on the cookie, so the browser drops it when it + closes, and `session_gc_ttl` on both fields so Redis can still collect the key + (Section 3.2). A separate flag would be a second way to say the same thing, and the two + could disagree. +- **Encryption** is configured by passing an `Encryptor`, not by an environment variable. + A key is a secret with a lifecycle, and the seam takes an object so it can come from a + KMS or rotate without a restart. Off unless one is supplied — Section 8.3.1 of + `session-mgmt.md`. +- **A renewal timeout** — OWASP's optional re-rotation of a live session on a schedule — + has no setting, because v1 has no such timer. Note that this is *not* the same as the + rotation policy, which `session_principal_keys` does express: rotation on authentication + and on privilege change is automatic (Section 5.1). What is missing is only the + time-based variety. Record it as a gap rather than implying the knob exists. + +Two notes on the defaults. + +**Cookie defaults are strict** — `HttpOnly` always, `SameSite=Lax`, `Secure` on. +`starsessions` also defaults to strict and that is the right call: a permissive default is +a vulnerability that nobody reads the documentation to discover. + +**The two TTL defaults are deliberately different from each other**, because they are +different controls. 30 minutes of idle and 8 hours absolute is the shape OWASP describes +for an application someone uses through a working day: inactivity signs you out quickly, +and no session survives past the day regardless. Both accept a `timedelta`, as `cache()` +already does in this package. + +An earlier draft also carried `refresh_threshold`, to suppress a second round trip that +only existed because the idle refresh was a separate `EXPIRE`. `HGETEX` folds that refresh +into the load, so the setting has nothing left to save and is gone. Section 4.2 gives the +reasoning. + +--- + +## 10. Telemetry + +Follow `telemetry.py` exactly: fields on `_OTelState`, a `session_span()` beside +`cache_span` and `ratelimit_span`, guarded `record_session_*()` free functions, and a +`timed_session()` context manager. Keep both existing behaviours — every helper does +nothing when the import failed, and `disable_telemetry()` resets the state. + +| Instrument | Type | Attributes | +|-------------------------------------|-----------|-------------------------------------------------------------------------------------------| +| `redis_fastapi.sessions.operations` | counter | `operation` = load/save/touch/rotate/revoke/revoke_all; `result` = hit/miss/expired/error | +| `redis_fastapi.sessions.latency` | histogram | `operation` | +| `redis_fastapi.sessions.events` | counter | `cause` = idle/absolute/revoked; `result` = delivered/dropped | + +**No session ID may become a span attribute or a metric label.** Neither may a subject +identifier, which is usually a user ID. Section 5 of `session-mgmt.md` makes this an +acceptance criterion, and a test asserts it. Cardinality is the lesser reason; the real +one is that traces and metrics reach dashboards and third-party vendors that the session +store's threat model never considered. + +--- + +## 11. Tests + +Unit tests in `tests/unit/`, against the existing `fake_async_redis` fixture at +`tests/conftest.py:113`. Integration tests in `tests/integration/`, behind the existing +`@requires_redis` marker. This is the same split the cache and rate-limit suites use, and +`noxfile.py:102` already runs the unit suite with no Redis server. + +**`fakeredis 2.36.2` supports every command this design uses** — `HSETEX`, `HGETEX`, +`HEXPIRE`, `HTTL`, `HGETDEL` and `GETEX` all behave correctly against it, including field +expiry and the empty-key deletion in Section 3.3. That was verified before the design was +settled. Section 13.5 refuses `IFEQ` and `DELEX` on a stronger ground than tooling — they +are string commands and cannot address a hash field at all — but note that `fakeredis` does +not support them either, so they could not have been covered here in any case. + +**Unit** + +- Each row of the `Session` table in Section 8: `popitem()`, `|=`, `pop()` setting both + flags, and a nested change surviving through `save()`. +- The response rule in Section 4.2: no command when untouched, **no command when read** + because the load already refreshed, `HSETEX` when modified. +- **Writing field `d` leaves the TTL of field `a` alone.** This is the guarantee that + keeps the absolute deadline absolute, so assert it directly rather than inferring it. +- Both clocks, independently: idle expiry while the absolute clock still has time, and + absolute expiry despite continuous activity. +- Cookie `max-age` equal to `min(idle, HTTL(a))`, in every branch. +- Session-only mode: no `max-age`, and `gc_ttl` on **both** fields. +- `refresh_on_load=False` restoring the second round trip and refreshing only on access. + **Assert that the idle clock actually advances under this setting** — an earlier draft + of Section 4.2 omitted the branch, which would have left the clock frozen and every + session immortal until its absolute deadline. + +Four tests exist because adversarial review found the corresponding claims false. Each one +fails against the earlier design and passes against this one, so none may be dropped as +redundant: + +- **`absolute_ttl = 0` produces a usable session.** Create one, load it, and assert the + application receives the data. Under the earlier design `HTTL a` answered `-2`, which was + read as "absolute deadline passed", so every such session was dead on arrival. +- **All five rows of the Section 4.1 state table**, including the `-1` row, which must be + unreachable — assert that a session is never written with an unexpiring `a`. +- **The index does not report an idle-dead session.** Set idle far below absolute, let the + idle clock lapse, then assert `list_for_subject` omits the session and that the entry has + been removed from the index by the read. +- **Re-assertion never extends the index entry.** Record the entry's TTL, wait, modify the + session so the response writes, and assert the TTL has **decreased**. The buggy version + restores it to the full lifetime, which no end-state assertion would catch. +- **A custom `id_factory` returning an ID with a separator is rejected** with + `SessionConfigurationError`, and no key is written. +- A session ID with an unsafe character yields a new session, and no part of that value + reaches a response header. +- Read fail-open gives an empty session; write fail-closed raises `SessionStoreError`. +- No session ID and no subject in any telemetry attribute. +- **The tier probe answers `none` when it cannot ask.** Make `CONFIG GET` raise, and assert + the store starts, logs exactly one warning, and reports `events.tier == "none"`. A probe + that raises instead of degrading would take down every deployment on managed Redis. +- **Registering a handler at tier `none` never calls it**, and never raises. This is the + documented silent fallback, so assert it rather than leave it to chance. + +**Integration** + +- **Rotation deletes the old key before writing the new one.** Assert the order, not only + the end state — the order is the security control in Section 5.2. +- **Rotation fires with no application call.** Write the identity in a handler and assert + the cookie value changed. This is F-2 and the reason the design detects rather than + waits to be told. +- **A declared privilege key rotates too.** With `principal_keys=["user_id", "role"]`, + changing the role rotates — and so does changing it back, which OWASP requires and an + explicit call is easiest to forget. +- **A changed principal on a 4xx persists nothing**, while an unchanged principal on a 4xx + persists normally. Both halves of Section 4.3, because dropping either one breaks a real + pattern: the second is how a failed-login counter works. +- **`principal_of` raising at the response snapshot fails the response**, and writes + nothing. +- **Ship the wiring assertion as a test helper**, not only as a test. Section 5.1 makes it + the answer to a misconfigured resolver being silent; it belongs in the public surface so + applications can prove their own configuration. +- **The session key is written before its index entry**, for the reason in Section 5.3. + Assert the order here too: reversing it turns a harmless interleaving into an + un-revocable session. +- **An orphaned session repairs itself.** Delete the index entry behind the store's back, + then make one request that modifies the session, and confirm the entry is back and + `revoke_all` finds it. +- **`revoke_all` misses a session created after it read the index**, and the session is + still revocable by a second call. Pin this as the documented semantics rather than + leaving it to be discovered. +- Two application instances share one session through one Redis. +- **The index prunes itself with no help from us**: add a session, let its absolute TTL + pass, then confirm `HGETALL` omits it and `HLEN` has dropped — without any prune call. + Then remove the last field and confirm Redis deleted the key. +- `list_for_subject` answers in one round trip and returns the descriptor for each + session. +- `revoke_all` ends every session for one subject and leaves another subject untouched. +- A payload over 4KB, which a signed cookie cannot carry. +- An Authlib OAuth flow completing with no change to the application code. +- **The same scenarios again from `def` endpoints**, following + `tests/integration/test_ratelimit_sync.py`. Cover both halves: a sync handler reading and + writing `SessionDep` with no bridge, and a sync handler driving `SyncSessionStore`. + Assert that a sync and an async handler leave Redis in the same state, since F-13 + promises one pattern rather than two. +- **Against a real 7.4 server**, the `HSET` + `HEXPIRE` fallback in Section 13.1 produces + the same observable behaviour as the 8.0 path. + +**Recipes are tests too.** Section 9.6 of `session-mgmt.md` puts ten recipes in version 1. +Each one lives in `examples/` or in a test that `nox` runs. A recipe nobody runs stops +working at the first rename, and a broken recipe costs the reader more than a missing +one. + +Run `nox`: lint, mypy, bandit and coverage all gate. + +--- + +## 12. Order of work + +1. `Session`, and the tests for its four rows. It has no dependencies and it pins the + contract everything else uses. +2. `SessionStore` and `RedisSessionStore`: keys, the two fields, the envelope, + `load`/`save`/`touch`/`delete`. +3. `SessionMiddleware`: the eager load, the response rule, the cookie. +4. Settings, `deps.py`, `.sessions()` — the feature is usable at the end of this step. + Add `SyncSessionStore` and `SyncSessionStoreDep` here too, alongside the async pair, so + the two never drift apart. It is a mechanical delegation over an existing pattern; the + cost of adding it later is that every method written in between needs a second author. +5. The principal snapshots and automatic rotation, with `rotate` and `revoke` beneath them, + plus the ordering test. **The OWASP core is complete here.** +6. The subject index: `HSETEX`/`HDEL`, `list_for_subject`, `revoke_all`. +7. Telemetry, the exceptions, the extension points. +8. `SessionEvents`: the tier probe, the subscriber, the silent fallback. **Last, and + separable.** It is the only step whose omission changes nothing else — N-17 says every + guarantee holds without it — so it is the first thing to cut if the release is tight. +9. Documentation and the recipes. + +Steps 1 to 5 are P0 in Section 8.5 of `session-mgmt.md`. Step 6 is P1, but its **key +schema must be settled in step 2**: Section 8.4 of `session-mgmt.md` explains that an +index added after sessions exist reports a wrong answer for every session that predates +it. + +--- + +## 13. What Redis gives us that no other backend can + +This package is the Redis integration, so the design should be one that a Postgres or a +Memcached store could not copy. + +Three features carry that weight and all three sit inside the 7.4 floor this package +already declares, so version 1 depends on nothing newer. Section 13.2 then lists what a +later server adds for free, Section 13.3 gives the operational advice that comes with +running the server rather than only calling it, Section 13.4 designs the one opt-in feature +that a newer server unlocks, and Section 13.5 records what we looked at and left out. + +### 13.1 What we use, at the 7.4 floor + +| Feature | Since | Where | What it replaces | +|-------------------------------------------------------------------|-----------|-------------------------------|--------------------------------------------------------------| +| **TTL on a hash field** (`HEXPIRE`, `HSETEX`) | 7.4 / 8.0 | the index, and the two clocks | a prune job, or a sorted set with score filtering, or a scan | +| **`HGETEX`** — read a field and set its expiration in one command | 8.0 | the load path | `GET` then `EXPIRE`, and the whole `refresh_threshold` idea | +| **A key that deletes itself when its last field expires** | 7.4 | the index | a TTL on the index key, refreshed on every write | + +The index is the clearest case. **Every other store must answer the question "which +sessions has this user got?" by keeping a collection and then reconciling it**, because +no store tells you when a row quietly expired. Postgres needs a `DELETE ... WHERE +expires_at < now()` on a timer. DynamoDB's TTL sweeper runs on its own schedule, up to +48 hours late, so a read must filter anyway. Memcached cannot express the question at +all. Redis expires the fields itself, `HGETALL` returns exactly the live sessions, and +`HLEN` counts them in `O(1)`. + +The two clocks are the second case. An idle timeout and an absolute timeout are two +independent deadlines on one piece of state, and Section 3.2 explains why holding them as +two field TTLs makes "a session outlives its absolute deadline" unreachable rather than +merely unlikely. + +A note on version range. `HEXPIRE` is 7.4, which is our floor, but `HSETEX` and `HGETEX` +are 8.0. Against a 7.4 to 7.x server, fall back to `HSET` + `HEXPIRE` and to `HGET` + +`HEXPIRE`, pipelined. Follow the probe-once-per-process shape that +`probe_increx_support` already uses in `ratelimit_backend.py:67`. This fallback is +**only** an extra round trip, never a change in behaviour, which makes it far simpler +than the INCREX case: there is no correctness cliff to guard. + +### 13.2 What a newer server adds, with no code from us + +The design targets 7.4, and everything above works there. But later releases improve it +without a line of our code. One of them may also reward the two-field shape we chose for an +unrelated reason, and the paragraph below says plainly why we do not yet know. Say this in +the guide: **the same application gets cheaper and faster by upgrading the server.** + +| Release | Feature | What it gives a session store | +|---------|--------------------------------------------------------------------|--------------------------------------------------------------------| +| 8.6 | hash memory footprint down up to 16.7%, hash latency down up to 7% | every session key and every index key, for free | +| 8.8 | `HGETALL` up to 25% faster on hashes with 1K+ fields | `list_for_subject` for a tenant with many live sessions | +| 8.8 | **hash subkey notifications** | a new capability, not only a speed-up. See below. | +| 8.10 | **compact hashes** | a large memory win **if** field expiry does not disqualify us. Open. See below. | +| 8.10 | wide `HSET` on a fresh hash batched into one listpack append | session creation | + +**Compact hashes (8.10) suit the shape of our record, and may still exclude it.** The +encoding stores field names **once** across every key that shares a schema. A session store +looks like the ideal case: a million session keys, each a hash with exactly the fields `a` +and `d`, identical in every one, so the names are held once for the deployment instead of +once per session. Section 3.2 chose two fields to make the absolute deadline structurally +unbreakable, which is a security argument; the uniform schema is a by-product. If the +encoding does reward it, say plainly in the guide that this was luck and not foresight. + +**An earlier draft stopped there and called it "the largest memory win, aimed squarely at +us". That was asserted, not checked.** Redis offers two ways into the encoding, and each +has a problem for us. + +The first is automatic conversion, driven by `hash-min-template-entries`, and the +documentation excludes us by name: *"A hash is not converted if it uses field expiration, +even when its field count meets the minimum."* Every session key here uses field expiration +on both fields. That is Section 3.2 and it is not negotiable, so on this path our keys are +ineligible whatever their schema. + +The second is `HIMPORT`, which hints Redis to store the new key as a compact hash at +creation, before any `HEXPIRE` runs. Section 13.5 explains why we will not write session +keys with it. And whether a key created that way survives a later `HEXPIRE` on its fields is +undocumented in both directions: the hashes page says a converted key "never reverts to a +plain hash", which suggests it would, while the exclusion above suggests it would not. +Suggests is not knows. + +**So this row is a question, not a benefit, until someone measures it.** The measurement is +small. Against an 8.10 server, write ten thousand session keys the way Section 4.2 writes +them, then read `hash_templates` and `hash_template_keys` from `INFO STATS` and +`used_memory_hash_templates` from `INFO MEMORY`. A zero settles it. Section 12 should carry +that as an integration check, and the guide should claim nothing until it passes. + +Two consequences for the implementation hold either way, because both are free: keep the +field names short, and **keep them identical in every session**. Never write an optional +field into some sessions and not others. A divergent schema forfeits the template if we +ever qualify for one, and a short name is fewer bytes on the wire meanwhile. + +**Hash subkey notifications (8.8) are a new capability, and the one row here that does +need code from us.** Redis 7.4 gave fields a TTL, but key-level notifications carry no +field name, so nothing could say *which* field expired. Redis 8.8 adds field-level events +across four channel types. Section 13.4 designs the feature that consumes them. + +### 13.3 Operational guidance that only a Redis vendor will write + +**A session store is not a cache, and `maxmemory-policy` must say so.** Under +`allkeys-lru`, `allkeys-lfu` or `allkeys-random`, Redis will evict live sessions to make +room, and every evicted session is a user signed out mid-task with nothing in any log to +explain it. Use `volatile-ttl` or `noeviction`, or give sessions their own instance or +logical database. This is the single most likely production incident with this feature, +and no competing package documents it, because none of them is written by people who +support the server. + +Redis 8.6 adds `volatile-lrm` and `allkeys-lrm`, which track the least recently +**modified** key rather than the least recently used one. Worth one line in the guide, and +worth a caution with it: `HGETEX` is a write command, so our idle refresh counts as a +modification and LRM behaves much like LRU here. Neither is the answer. **Do not evict +sessions at all.** + +**Sessions are small, and Redis stores small hashes as a listpack.** Below +`hash-max-listpack-entries` and `hash-max-listpack-value` a hash is a flat array, not a +hash table, so a two-field session and a short index cost far less than the per-key +overhead suggests. Note the thresholds so an operator sizing a deployment finds which side +of them a typical session falls. + +**One index key can go hot, and 8.6 can find it.** Session keys spread across the keyspace +by session ID, but a tenant's index key is a single key that every login and every logout +writes. For a large tenant that key is a candidate hot spot, and slot migration will not +help, because moving one key only moves the problem. `HOTKEYS START METRICS 2 CPU NET +SAMPLE 100` identifies it by CPU and by network cost. Put this in the guide beside the +`subject_of` seam, because sharding a large tenant's index across several keys is exactly +what that seam is for. + +This is the one place where the query engine would be strictly better, since it has no +per-subject key to contend on. Section 3.3 records why version 1 does not use it, and what +would change that. + +### 13.4 Real-time session events + +A session store knows when a session dies. Every competing store discovers it on the next +request, because a row that expired quietly tells nobody. Redis can tell us, and F-21 turns +that into an opt-in feature: **the store subscribes to Redis notifications and calls the +application back when a session ends.** Closing a WebSocket the moment a user is signed out +is the case that pays for it. + +The feature is off by default, degrades to nothing on a server that cannot supply it, and +carries no guarantee. The rest of this section says exactly what that means. + +#### The two-field design pushes this to 8.8 + +Our session key has **no key-level TTL**. It dies as a side effect of its last field +expiring, which is the whole of Section 3.2. That has a consequence for notifications that +is easy to miss: + +| Tier | Needs | Channel | Names the clock that fired? | +|---------|------------------------------------------|------------------------------------------|-----------------------------| +| `none` | — | — | — | +| `key` | 7.4, plus `Eghx` in `notify-keyspace-events` | `__keyevent@__:del` | **No** | +| `field` | **8.8**, plus `h` and one of `S`/`T`/`I`/`V` | `__subkeyevent@__:hexpired`, whose payload names the field | **Yes** — `d` is idle, `a` is absolute | + +At the `key` tier a subscriber learns that a session key went away and nothing else. It +cannot separate an idle death from an absolute one, and it cannot separate either from a +revocation. That is most of what a caller wants to know, so **the useful ladder is two +rungs, not three: `field` or `none`.** Implement the `key` tier only if a concrete recipe +needs it; do not add it speculatively. + +One detail to confirm against a real 8.8 server before the guide claims it: whether field +expiry that empties a hash also emits a key-level `del`. The `field` tier does not depend +on the answer, which is another reason to build that tier and not the other. + +#### Probe, and never configure + +Two different questions, and the code must ask both: + +1. **Can the server do it?** Read `redis_version` from `INFO server`. +2. **Is it switched on?** Read `notify-keyspace-events` with `CONFIG GET` and look for `h` + together with one of `S`, `T`, `I`, `V`. + +**The four subkey flags are independent of `K` and `E`.** Enabling standard keyspace +notifications does not enable subkey notifications, and the reverse holds too. This will +be the commonest support question; say it in the guide in those words. + +**Both probes can fail, and failure is an answer.** Managed Redis often restricts, renames +or forbids `CONFIG`, and an ACL that omits `@admin` does the same. Treat any failure as +tier `none`. Follow the shape `probe_increx_support` already uses at +`ratelimit_backend.py:67`: probe once per process from the lifespan, return `None` when the +question could not be answered, and never cache a guess. + +**Never call `CONFIG SET`.** `notify-keyspace-events` is server-wide. Setting it changes +behaviour for every other application on that instance and costs CPU on every write. A +library must not make that decision for an operator. Document the flag string, and put it +beside the `maxmemory-policy` advice in Section 13.3, which has the same shape. + +#### The fallback is silence, and the guide must say so plainly + +When the tier is `none`, the store registers the handlers, logs **one** warning at startup, +and the handlers never run. Startup still succeeds and every request still works. + +This is the deliberate choice, and it has a sharp edge worth naming rather than hiding: a +revocation handler that never fires looks exactly like one that works. An application that +closes WebSockets on this signal and nothing else will hold them open after a sign-out, on +a server where the feature is unavailable. **So the guide must state that the callback is +best-effort, and that any application relying on prompt closure needs its own periodic +check as well.** One warning line at startup is the only thing the library will do about +it. + +#### What a subscriber costs + +- **On Cluster, keyspace events are node-local and are not broadcast.** A subscriber must + connect to every node to see every event. This is a per-node fan-out, not one connection, + and it is the largest implementation cost in the feature. +- **Every worker receives every event.** A deployment with eight uvicorn workers gets eight + deliveries of each session death. That is correct for closing a WebSocket, because only + the worker holding the socket acts. It is wrong for anything that writes, so an audit log + driven this way needs deduplication or a single designated subscriber. +- **Pub/Sub is fire-and-forget.** Events sent while no subscriber is connected are lost, and + the connection has to be re-established after a disconnect with no replay. +- **`hexpired` fires when Redis removes the field, not when the TTL reaches zero.** With + many keys carrying a TTL, the lag can be significant. + +#### The rule that does not move + +**N-17: no correctness claim rests on a notification.** The index keeps pruning itself +through field expiry (Section 3.3), and the load-time state table (Section 4.1) stays the +authority on whether a session is alive. Notifications are a reaction channel and strictly +additive. Nothing in Sections 3 to 8 changes because this feature exists, and every test in +Section 11 must pass with it switched off. + +#### Shape + +A `SessionEvents` object built in the lifespan, holding one subscriber task per node: + +```python +events = store.events() # tier probed once, at startup + +@events.on_session_end +async def _(sid: str, cause: Literal["idle", "absolute", "revoked"]) -> None: + await close_sockets_for(sid) + +print(events.tier) # "field" or "none" +``` + +`cause` is what the `field` tier buys and the `key` tier cannot give. At tier `none` the +handler is held and never called. + +### 13.5 Considered, and not in version 1 + +**Compare-and-set on the session payload.** There is a real gap here and it should be +named: two requests from the same browser can load a session, both modify it, and the +second write silently discards the first. This is the server-side twin of the cookie race +in [starlette#2019](https://github.com/Kludex/starlette/issues/2019). + +**An earlier draft answered it with `SET ... IFEQ` / `IFDEQ` and `DELEX ... IFEQ` (Redis +8.4). That was wrong, and wrong in a way worth recording, because the mistake is easy to +repeat.** Those are **string** commands. Our session record is a hash, so they cannot +address field `d` at all. `HSETEX` offers only `FNX` and `FXX` — field existence, not value +comparison — and through Redis 8.10 there is no `HDIGEST`, no `HDELEX`, and no hash-field +compare-and-swap of any kind. The draft also called `IFDEQ` an `O(1)` digest comparison; the +`DELEX` documentation gives `O(1)` for `IFEQ`/`IFNE` and **`O(N)` for `IFDEQ`/`IFDNE`**. +`IFDEQ` saves bytes on the wire against `IFEQ`. It does not save server time. + +Reaching those commands would mean splitting the record: the payload into a string key, the +two clocks into a hash. That is a second key, a `{sid}` hash tag to keep Cluster in one +slot, and a schema change — to buy a command that is no cheaper than the alternative below. + +**The mechanism that would work is Lua, and it works at the 7.4 floor.** A script reads +field `d`, compares `redis.sha1hex` of the stored bytes against a digest the client computed +from what it loaded, and writes only on a match. It touches field `d` and never field `a`, +so N-6 survives untouched. + +The cost is the part worth recording, because it is the question that gets asked: + +| Path | Today | With a Lua compare-and-set | +|------|----------------------------------------------|----------------------------| +| Load | 1 round trip, 2 commands (`HGETEX d`, `HTTL a`) | **unchanged** — the client hashes bytes it already received | +| Save | 1 round trip, 2 commands (`HSETEX d`, index `HSETEX`) | 1 round trip, 2 commands — `EVALSHA` replaces the first | + +**Identical round trips and identical command counts.** The new cost is CPU: one SHA-1 over +the payload in Python on load, one in Lua on save. Nor is the tooling an obstacle — the repo +already registers a script at `ratelimit_backend.py:348`, already falls back when `EVAL` is +unavailable at `cache_backend.py:332`, and already depends on `fakeredis[lua]` +(`pyproject.toml:83`), so the unit suite could cover it. + +**It still waits, for two reasons that survive all of the above.** First, compare-and-set +*detects* a conflict; it cannot resolve one. A session dict has no merge function, so the +store's only honest choices are to raise or to count the conflict and overwrite anyway — +and neither is obviously right for every application. Second, the digest must be taken over +the bytes as stored, not over a re-serialized value, so a coder that is not byte-stable +would fail every write; that constraint belongs in the `Coder` contract before it belongs in +a security control. + +Document the last-write-wins behaviour in the guide. When this returns, it returns as a Lua +script behind a setting, not as `IFDEQ`. + +**The `HIMPORT` family for session writes** (Redis 8.10, +[redis-py #4205](https://github.com/redis/redis-py/pull/4205)). Declare an ordered list of +field names once per connection, then create hashes by sending only their values. It exists +to cut the field names off the wire during a bulk import, and it hints Redis to store the +result as a compact hash — which is the only reason we looked at it. Section 13.2 gives +that reason. + +**One objection ends it.** `HIMPORT SET key fieldset-name value [value ...]` takes no +expiration option of any kind, and its documentation states that an existing key is +overwritten. Field `a` and its absolute deadline would be destroyed on every save. +Rebuilding them costs `HIMPORT SET`, then `HEXPIRE a`, then `HEXPIRE d` — three commands +where Section 4.2 issues one `HSETEX`, and between them a window where the session carries +no expiry at all. N-6 asks that outliving the absolute deadline be unreachable by +construction. This design makes it reachable by a crash. + +Four more, each sufficient on its own: + +- **There is nothing to save.** What HIMPORT saves is the field names, and ours are `a` and + `d`. Redis measured 11% on a pipelined import of a million three-field records named + `_uid`, `score` and `tag`. Our write path is one two-field write per HTTP request. +- **The index cannot use it at all.** `sessions-of:` carries session IDs as field + names — unique per key, unknown until write time. A fieldset is fixed and shared by + definition. +- **The three objections that X-7 already makes.** 8.10 against a 7.4 floor, `experimental` + in redis-py, and absent from `fakeredis 2.36.2`, so the suite in Section 12 could not + cover it. +- **It is unavailable where our users run.** Both command pages mark Redis Software and + Redis Cloud unsupported, Standard and Active-Active alike, and redis-py raises + `DataError` for every HIMPORT method on a multi-database client. On Cluster, redis-py + re-prepares lazily per connection and a discard does not reach every server session at + once — a standalone-versus-Cluster difference of exactly the kind N-4 forbids. + +Revisit if a later server gives `HIMPORT SET` a `KEEPTTL`, and then only to ask again +whether two field names are worth sending. + +**Client-side caching with RESP3 invalidation** (`CLIENT TRACKING`). Redis pushes an +invalidation when a key changes, so a worker could serve a repeated read from local memory +and still observe a revocation. Something Postgres and Memcached cannot offer. + +**It does not apply to the session read at all, and the reason is our own design.** The +load path in Section 4.1 is `HGETEX`, which is a *write* command — it changes a TTL — and +redis-py refuses to cache writes. Verified against `redis.cache.DefaultCache.is_cachable` +in redis-py 8.0.1: + +| Command | Cacheable | +|-----------------------------------------|-----------| +| `GET`, `HGET`, `HGETALL`, `HLEN` | yes | +| **`HGETEX`, `HSETEX`, `GETEX`, `HTTL`** | **no** | + +So the authentication path is never served from a client cache, whatever the +configuration. The refresh-on-read that Section 4.2 buys with `HGETEX` costs us the +ability to cache the read — and for a session lookup that is the right trade. + +**Where it could apply is the index**, because `HGETALL` and `HLEN` are cacheable. That +is worth a future look for `list_for_subject`, which a "your active sessions" screen may +call repeatedly. + +**On the objection itself, an earlier draft of this section was wrong.** It implied that +invalidations might not arrive. They do, and redis-py handles them carefully: before +serving a hit it drains pending pushes on the connection that cached the entry and +re-checks whether the entry survived (`redis/connection.py:1733-1754`), and it flushes the +whole cache on disconnect (`redis/connection.py:1700-1702`). Lost delivery is not the +problem. + +The real residual is **propagation delay, not reliability**. Redis sends the invalidation +after the write commits, and the drain above is non-blocking, so an invalidation still on +the wire is not seen and a hit is served from stale data. The window is a network +round trip, and no client can close it, because a client cannot know an invalidation is +coming until it arrives. Caching therefore makes a read *eventually* consistent with a +revocation rather than immediately consistent with it. + +That is harmless for a UI listing and unacceptable for an authorization decision, which +gives the rule to record now, before anyone enables caching later: + +> **`revoke_all()` must read the index uncached.** Acting on a stale member list means +> failing to kill a session that the caller was told had been killed. + +Redis 8.10 also fixed an ACL key-name leak in `BCAST` invalidations, a further reason to +let the feature settle before this package leans on it. + +**A Stream for the session audit log.** Section 3 of `session-mgmt.md` carries OWASP's +requirement to log the session lifecycle, using a salted hash of the session ID. `XADD` +with `MAXLEN` gives a bounded, ordered, replica-safe log that any instance can read, and +8.6's idempotent production (`XADD ... IDMP`) means a producer that retries after a crash +cannot double-write an audit entry. Pair it with the subkey notifications in Section 13.2: +the notification is the trigger, the stream is the record. This belongs in the recipe list +in Section 9.6 of `session-mgmt.md` rather than in the store. + +--- + +## 14. Migration + +One table per use case. Each names the three packages people arrive from and then ours. +Section 10 of `session-mgmt.md` gives the reasoning; this is the code. +**Existing sessions do not survive the change** — every user signs in again. + +### 14.1 Turning sessions on + +| From | Code | +|---|---| +| Starlette |
app.add_middleware(SessionMiddleware, secret_key=SECRET, max_age=1209600)
| +| `starsessions` |
app.add_middleware(SessionAutoloadMiddleware)
app.add_middleware(SessionMiddleware, store=RedisStore(connection=redis),
lifetime=3600, rolling=True)
| +| `fastapi-users` |
auth_backend = AuthenticationBackend(
name="redis", transport=CookieTransport(cookie_max_age=3600),
get_strategy=lambda: RedisStrategy(redis, lifetime_seconds=3600),
)
app.include_router(fastapi_users.get_auth_router(auth_backend), prefix="/auth/cookie")
| +| **This SDK** |
FastAPIRedis(app).lifespan().sessions()
| + +No store to construct: we use the SDK's pool. No autoload middleware: the load is eager +(Section 1.1). + +### 14.2 Sign in, sign out + +| From | Code | +|---|---| +| Starlette |
request.session["user_id"] = user.id
request.session.clear()
| +| `starsessions` |
await load_session(request)
request.session["user_id"] = user.id
regenerate_session_id(request)
| +| `fastapi-users` |
# the generated /auth/cookie/login route; the record holds the user ID alone
| +| **This SDK** |
async def login(session: SessionDep):
session["user_id"] = user.id # signing in is writing the identity

async def logout(session: SessionDep):
session.clear() # emptied, so the key and the cookie go
| + +Nothing to load and nothing to save. Writing the identity is what the middleware watches +(Section 5.1); `def` handlers work unchanged (Section 9). + +### 14.3 Rotation + +| From | Code | +|---|---| +| Starlette |
# not possible: the cookie is the store, so there is no ID to rotate
| +| `starsessions` |
regenerate_session_id(request)   # at every sign-in and privilege change
| +| `fastapi-users` |
# none
| +| **This SDK** |
FastAPIRedis(app).lifespan().sessions(principal_keys=["user_id", "role"])
# then nothing in the handler: session["role"] = "admin" rotates, and so does
# the way back down. store.reauthenticate(session) for a change state cannot see.
| + +Rotation is detected, not called, so it cannot be forgotten — the one mistake here that is +a vulnerability (Section 5.1). A change on a 4xx persists nothing (Section 4.3). + +### 14.4 Expiry + +| From | Code | +|---|---| +| Starlette |
max_age=1209600        # cookie only; the server enforces nothing
| +| `starsessions` |
lifetime=3600, rolling=True    # one clock, refreshed or not
| +| `fastapi-users` |
lifetime_seconds=3600  # absolute only; None means it never expires
| +| **This SDK** |
REDIS_SESSION_IDLE_TTL=1800        # field d, refreshed on access
REDIS_SESSION_ABSOLUTE_TTL=28800 # field a, never refreshed
| + +Two clocks, both enforced by Redis, neither computed by us (Section 3.2). Set them equal to +keep the single-clock behaviour of the row above. Section 9.4 of `session-mgmt.md` maps +every `starsessions` setting. + +### 14.5 Reverse lookup + +| From | Code | +|---|---| +| Starlette |
# impossible: nothing on the server knows the session exists
| +| `starsessions` |
# none; hand-rolled, and its members outlive the sessions they name
await redis.sadd(f"sessions-of:{user_id}", sid)
| +| `fastapi-users` |
# none
| +| **This SDK** |
await store.revoke_all(str(user_id))        # password change, forced sign-out
await store.revoke_id(sid, subject=str(user_id)) # one device
await store.list_for_subject(str(user_id)) # the "your devices" screen
await store.count_for_subject(str(user_id)) # a cap on concurrent sessions
| + +The index prunes itself and reads verify liveness, so a dead session is never listed +(Section 3.3). Change the credential before revoking (Section 5.3). + +### 14.6 Error handling + +| From | Code | +|---|---| +| Starlette |
# no store to fail; a bad signature silently yields an empty session
| +| `starsessions` |
except RedisError:   # the driver error reaches your handler
| +| `fastapi-users` |
# the driver error reaches the route
| +| **This SDK** |
# read fails  -> empty session, so the auth dependency returns 401
# write fails -> SessionStoreError, never silent
@app.exception_handler(SessionStoreError)
async def store_down(request: Request, exc: SessionStoreError):
return JSONResponse({"detail": "Session unavailable"}, status_code=503)
# REDIS_SESSION_FAIL_CLOSED=true -> a failed read raises too
| + +The asymmetry is deliberate (Section 7). No `redis.RedisError` reaches application code: +catch `SessionError`, or `SessionConfigurationError` and `SessionStoreError` beneath it. diff --git a/docs/specs/session-mgmt.md b/docs/specs/session-mgmt.md index 236245e..b258f19 100644 --- a/docs/specs/session-mgmt.md +++ b/docs/specs/session-mgmt.md @@ -74,6 +74,11 @@ request. Starlette built this connection point for its own purpose. This is the most important technical result of the research. The flags did not exist when the authors designed the current session libraries. +Section 5a explains what we do with this result. We copy the design of the flags +into our own class, and we do not import the Starlette class. That decision keeps +the minimum version of FastAPI where it is, and it lets us correct two faults in +the upstream flags without a wait. + --- ## 2. What exists today, and how much people use it @@ -282,8 +287,12 @@ Two conditions make this the right time. Starlette 1.x added the `accessed` and That is a mistake which is easy to make, and we can prevent it. No library designed before March 2026 can use this method. 2. **Supply the OWASP operations as an API, not as documentation.** Give the user - `rotate()`, which defends against session fixation at login and after a - privilege change. Give **separate idle and absolute TTLs**. Give `revoke()`. + rotation that defends against session fixation at login and after a privilege + change — and make it **automatic**, so no application call can be omitted. The + middleware compares a *principal* before and after each request and rotates when + it changes. Section 5.1 of [`session-design.md`](session-design.md) gives the + mechanism and the sequence. Give **separate idle and absolute TTLs**. Give + `revoke()`. Give `list_sessions(user_id)` and `revoke_all(user_id)` to control concurrent sessions. A Redis SET for each user holds the session IDs. A cookie library cannot supply that last capability, and this is the clearest reason to use @@ -297,26 +306,135 @@ Two conditions make this the right time. Starlette 1.x added the `accessed` and the `Set-Cookie` race condition. Document how to migrate from the `RedisStrategy` in `fastapi-users`, and from `starsessions`. +### 5a. Starlette already supports cookies. Why do we not use that support? + +We keep the cookie. We change the content of the cookie. + +| | The cookie holds | Where the data is | +|-----------------|---------------------------------|-----------------------------------------------------| +| Starlette today | `sign(b64(json(session_data)))` | the cookie **is** the database | +| This design | `sign(opaque_id)` | the cookie is a **pointer**. Redis is the database. | + +That one change gives us every row of the table in Section 3: revocation, an idle +timeout and an absolute timeout that the server enforces, a payload larger than 4KB, +data that stays away from the client, and `revoke_all`. None of them are possible +while the payload is in the cookie, because the server then keeps no copy of anything. + +**We cannot extend the middleware that exists.** It has no connection point. We +verified this in `starlette/middleware/sessions.py` on `main`: + +- The constructor takes `app`, `secret_key`, `session_cookie`, `max_age`, `path`, + `same_site`, `https_only`, and `domain`. It takes **no `backend`, no `store`, and no + `serializer`.** +- The read path is inside `__call__`: `signer.unsign`, then `b64decode`, then + `json.loads`, then `Session(...)`. +- The write path is inside the `send_wrapper` **closure**: `json.dumps`, then + `b64encode`, then `signer.sign`, then `Set-Cookie`. + +The encode and decode operations are in a closure inside `__call__`. A subclass +therefore has nothing to override except `__call__` itself, which means that it +rewrites the whole method. The missing constructor parameter is exactly the change in +[starlette#499](https://github.com/encode/starlette/pull/499). The table in Section 1 +records that the maintainers declined it. The parameter is absent on purpose. + +**Two other methods do not work. Do not propose them again.** + +- **Put our layer on top of the Starlette middleware**, and let the Starlette cookie + hold only `{"sid": ...}`. This fails difference 4 above. `request.session` then + becomes the *cookie* dictionary, so Authlib writes the OAuth state into the cookie + and not into Redis. We lose the correction for the 4KB limit, and we lose the + drop-in property. Two `max_age` values also then compete. +- **Use dependency injection with no middleware.** This is not possible. The + application must add `Set-Cookie` before it sends `http.response.start`. A + dependency that is a context manager stays open until the background tasks finish, + which is much later than the headers. sm-Fifteen recorded this limit in + [fastapi#754](https://github.com/fastapi/fastapi/issues/754), and it is the reason + why a wrapper around `send` is necessary. + +**What we reuse. The replacement is approximately 80 lines, not a fork.** + +- The `scope["session"]` contract. This is what keeps Authlib and every existing call + to `request.session` correct with no change. +- The construction of the cookie flags (`httponly; samesite=…; secure`), the + `add_vary_header("Cookie")` call, and the method to clear a cookie (the value + `null`, with an `expires` date in 1970). +- `itsdangerous.TimestampSigner`. We still sign, but we sign the ID and not the + payload. +- `MutableHeaders`, `HTTPConnection`, and `Secret`. + +**Starlette permits this. It is a connection point, not a workaround.** The `session` +property in `starlette/requests.py` contains: + +```python +session: Session = self.scope["session"] +# We keep the hasattr in case people actually use their own `SessionMiddleware` implementation. +if hasattr(session, "mark_accessed"): # pragma: no branch + session.mark_accessed() +``` + +The core supports a third-party session middleware that puts its own object into +`scope["session"]`, and a comment in the source says so. + +**Therefore: write our own `Session` class. Do not import the Starlette class.** We +write the middleware in any case, so we control the object in `scope["session"]`. This +gives four results. + +1. **The minimum version does not change.** The design works with Starlette 0.4x and + with 1.x. The declaration `fastapi>=0.115.0` stays correct. +2. **We correct the faults in the upstream class ourselves, now.** Section 6.1 lists + them: `popitem()` and `|=` set no flag, and `pop()` sets `modified` without + `accessed`. For a server-side store, a missed flag is a lost write with no error, + which is the worst fault in this design. We do not wait for a pull request from + another author. +3. **We depend on no change in Starlette.** We import nothing from + `starlette.middleware.sessions`, so no proposal of ours must succeed before we + ship. +4. With Starlette 1.0 and later, the property above still calls `mark_accessed()` for + us. With earlier versions the property only returns the dictionary, so we use a + safe default: treat the session as accessed, and always send `Vary: Cookie`. + +The cost: we own a class of approximately 40 lines, and we must read the upstream +`Session` class when it changes. That cost is smaller than a minimum version that we +cannot lower again. + ### Structure, which follows the existing conventions of the SDK Use the same division as the existing cache and rate limit code: -| New file | Follows | Contents | -|---|---|---| -| `src/redis_fastapi/sessions.py` | `cache.py`, `ratelimit.py` | `SessionMiddleware`, the `session()` factory for dependency injection, and `add_redis_sessions()` | +| New file | Follows | Contents | +|----------------------------------------|--------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `src/redis_fastapi/sessions.py` | `cache.py`, `ratelimit.py` | `SessionMiddleware`, the `session()` factory for dependency injection, and `add_redis_sessions()` | | `src/redis_fastapi/session_backend.py` | `cache_backend.py`, `ratelimit_backend.py` | `SessionStore`, an abstract base class (ABC) that owns the lifecycle. Also `RedisSessionBackend` and `SyncSessionBackend`, with these methods: `load`, `save`, `delete`, `rotate`, `touch`, `list_for_user`, and `revoke_all`. Section 7 gives the reason for an ABC instead of a plain `Protocol`. | Extend the existing files. Do not write the same code again. +**Serialize with the existing `Coder`.** The repository already has a serialization +interface: the `Coder` protocol and the `JsonCoder` implementation, in +`src/redis_fastapi/types.py:15`. Use them for the session payload. Do not write a +second interface. `starsessions` has a separate `Serializer` and `JsonSerializer` for +this purpose, but we do not need them. This choice also gives us +`pydantic_model_coder()` at no cost, so a user can hold a typed model in a session. + - `src/redis_fastapi/setup.py`: add `.sessions()` to the `FastAPIRedis` chain. - `src/redis_fastapi/deps.py`: add `SessionDep`, `SessionBackendDep`, and `get_session_backend`. Put them beside the existing `get_cache_backend` and `get_rate_limit_backend`. Keep the `dependency_overrides` behaviour, because tests need it. - `src/redis_fastapi/config.py`: add `REDIS_SESSION_*` settings to - `RedisSettings`. These settings control the cookie name, the idle TTL, the - absolute TTL, the `SameSite` value, the `https_only` flag, the key prefix, and - the rotation policy. + `RedisSettings`. Section 8.3 compares this list against `starsessions`, and every + entry below has an equivalent there. The settings control: + - the cookie name, and its `domain` and `path` + - the `SameSite` value and the `https_only` flag + - the idle TTL and the absolute TTL. **Both accept an `int` or a + `timedelta`**, as `cache()` already does in this repository. + - the session-only mode, where the cookie carries no `max-age` and the browser + deletes it when it closes + - `gc_ttl`, the TTL for a Redis key in that mode, where no exact expiry exists + - the key prefix, as a string **or a callable** + - the rotation policy, as `principal_keys`: the session keys whose value triggers a + rotation when it changes. The default watches the identity; add a role or a scope + list to rotate on a privilege change as well. + - the encryption, which is off by default. See Section 8.3.1. - `src/redis_fastapi/telemetry.py`: add `session_*` instruments. Follow the existing pattern in that file exactly. Add new fields to `_OTelState`. Add a `session_span()` function beside `cache_span` and `ratelimit_span`. Add @@ -338,23 +456,34 @@ Extend the existing files. Do not write the same code again. - `src/redis_fastapi/__init__.py`: add the new public names to `__all__`. - Documentation: write `docs/guide/sessions.md` and add it to the mkdocs navigation. Add a section to `docs/guide/observability.md` for the new metrics. - Write an application in `examples/` that shows a login, then `rotate()`, then a - logout, then `revoke_all()`. Write a second example for an Authlib OAuth flow. + Write an application in `examples/` that shows a login, a privilege change, a + logout, and `revoke_all()` — with the first two rotating on their own, so the example + demonstrates that no rotation call exists. Write a second example for an Authlib OAuth flow. ### Security requirements. Treat these as acceptance criteria. -- **Declare a direct dependency on `starlette>=1.0.0`.** The design uses - `Session.accessed` and `Session.modified`. Starlette added these flags in - **version 1.0.0 (2026-03-22)**, because #3166 went into that release. Today - `pyproject.toml` declares no direct dependency on Starlette. Starlette arrives - through `fastapi>=0.115.0`, and `uv.lock` selects version 1.3.1. The current - specification therefore permits a 0.3x version of Starlette, which has no - `Session` class. Without a minimum version, the store writes nothing when the - data changes. +- **Write our own `Session` class. Do not import the one from Starlette, and do not + raise the minimum version.** Section 5a gives the complete reasoning. In summary: + Starlette added `Session` in version 1.0.0 (2026-03-22). But `pyproject.toml` + declares no direct dependency on Starlette, and `fastapi>=0.115.0` permits a 0.4x + version, which has no `Session` class. A floor of `starlette>=1.0.0` therefore + raises the true FastAPI minimum by approximately twenty minor versions, and it + contradicts the row "FastAPI 0.115+" in the requirements table of `README.md`. We + write our own middleware in any case, so we can put our own object into + `scope["session"]`. - Make session IDs with `secrets.token_urlsafe(32)`, which gives at least 128 bits. Keep the IDs opaque. Never put data into an ID. -- Call `rotate()` after authentication and after a privilege change. Delete the - old key and move the data to the new key. +- **Validate the session ID from the cookie before any other use of it.** Accept + only the characters that are safe in a cookie value, and treat every other value + as no session at all. Without this test, a value from the client can inject a + header when the code writes the ID back into `Set-Cookie`. `starsessions` has this + control, and Section 8.3 records it. +- **Rotate after authentication and after a privilege change, without an application + call.** The middleware detects the change and rotates; the old key is deleted before + the new one is written. A control that must be invoked is a control that can be + omitted, and omitting this one is session fixation. Section 5.1 of + [`session-design.md`](session-design.md) gives the detector and its four safety + rules. - Enforce an idle TTL **and** an absolute TTL. Redis must enforce both of them. - Use strict cookie defaults: `HttpOnly`, `SameSite=Lax`, and `Secure`. Supply a documented method to disable `Secure` during development. `starsessions` also @@ -375,23 +504,20 @@ Extend the existing files. Do not write the same code again. - Write unit tests in `tests/unit/`. Use the same structure as the existing cache and rate limit tests. Test these conditions: - - `rotate()` keeps the data and makes the old key invalid. + - Rotation keeps the data and makes the old key invalid. + - **Rotation happens with no application call**, when the identity is written. + - A key declared privilege-bearing rotates on a change in either direction. - The idle timeout and the absolute timeout work independently. - `revoke_all` removes every session of one user. - The store writes nothing to Redis if no code touched the session. Assert on the `modified` flag. - The response has a `Vary: Cookie` header if code read the session. -- **Do not trust the `modified` flag.** Section 6.1 explains that the flag does not - report every change today. The tests must show that the store keeps the data - after each of these three operations: - - `popitem()` - - `|=` - - a change inside a nested object, such as `session["a"]["b"] = 1` - - No `dict` subclass can detect the nested change, so nobody can correct it - upstream. The store therefore needs an explicit `save()` method, and an - optional mode that always writes. These tests must pass even if the - maintainers never merge #3436. +- **Do not trust the `modified` flag.** Write one test for each row of the table in + Section 6.1: `popitem()`, `|=`, a `pop()` that must also set `accessed`, and a + change inside a nested object such as `session["a"]["b"] = 1`. In every case the + store must still hold the data afterwards. The last row passes through the explicit + `save()` method, because no subclass of `dict` can detect that change. These tests + must never depend on the release schedule of Starlette. - Write integration tests in `tests/integration/` against a real Redis server. Test that two application instances share one session through one Redis server. Test the TTL behaviour. Test the concurrent requests that replaced a cookie @@ -399,6 +525,20 @@ Extend the existing files. Do not write the same code again. - Write a compatibility test. An Authlib OAuth flow must complete against the Redis store without any change. Also test a payload larger than 4KB, which a signed cookie cannot hold. +- Test the items that Section 8.3 added: + - A session ID with an unsafe character gives a new session, and nothing from + that value reaches a response header. + - With encryption on, the value in Redis is not readable, and a round trip + returns the same data. With encryption off, no warning appears for each + request. + - The session-only mode sends no `max-age`, and the Redis key still gets a TTL + from `gc_ttl`. + - The idle TTL and the absolute TTL each move the `max-age` of the cookie and + the TTL of the key **together**. Section 8.3.2 explains why one test must + cover both. + - The key prefix works as a string and as a callable. + - Every extension point in Section 9 accepts a substitute, and the middleware + then uses it. - Write a telemetry test. Follow the existing pattern. Assert that the instruments record the data. Assert that `disable_telemetry()` gives a clean `_OTelState`. Assert that no attribute contains a session ID. @@ -410,114 +550,85 @@ Extend the existing files. Do not write the same code again. ### Suggested order of work -Release these parts first, because they are the OWASP core: -`session_backend.py`, `sessions.py`, the dependency injection, the configuration, -the strict cookie defaults, `rotate`, and `revoke`. Then release -`list_sessions` and `revoke_all`, which control concurrent sessions, and the -Authlib compatibility example. These parts are the differences from other -packages, so give them their own release note. +Section 8.5 gives the complete contents of version 1. Inside that release, build +the OWASP core first: `session_backend.py`, `sessions.py`, the dependency +injection, the configuration, the strict cookie defaults, `rotate`, and `revoke`. ---- +**Write the binding to a user and the index for each user in the same release**, +even if `list_sessions` and `revoke_all` appear later. Section 8.4 gives the +reason: an index that arrives after the first sessions exist reports a wrong +answer, and it reports it silently. -## 6. Upstream strategy: what we propose, and what we keep +Give `list_sessions`, `revoke_all`, and the Authlib compatibility example their own +release note. They are the clearest differences from the other packages. -A second question came after the research above. Must we propose the -vendor-neutral part to Starlette or to FastAPI as common code, so that other -vendors can extend it later? +--- -**Make it vendor-neutral, but keep it in this package.** The table below divides -the feature into layers, and it shows where the correct division falls. +## 6. What we implement better, and what we send upstream -| Layer | Vendor-neutral? | Can upstream accept it? | -|---|---|---| -| L1 The `Session` dict with the `accessed` and `modified` flags | already upstream | already present, but incomplete. See Section 6.1. | -| L2 A `Session` import that does not need the cookie middleware | a refactor only | a small PR is possible. See Section 6.3. | -| L3 A cookie carrier with a store as a parameter | neutral | **this is the diff of PR #499** | -| L4 A `SessionStore` protocol with `load`, `save`, `delete`, and `touch` | neutral. **This is the proposal.** | **refused two times** | -| L5 The OWASP lifecycle: `rotate`, two TTLs, `revoke_all`, and an index for each user | the interface is neutral | outside the scope of a feature-complete toolkit | -| L6 The Redis implementation | specific to the vendor | ours | - -Layers L3 and L4 together are the proposal. They are also exactly the content of -[starlette#499](https://github.com/encode/starlette/pull/499): session backends -that you can exchange, with no vendor code in the diff. That PR stayed open for -approximately three years, and the maintainers closed it without a merge. Nobody -can say that the neutral version has no proposal. The maintainers refused the -neutral version, and they gave the same answer to -[#2256](https://github.com/Kludex/starlette/discussions/2256) in 2023. - -We verified the current state in the source code, not only in the issue tracker. -Today `starlette/middleware/sessions.py` on `master` still has **no parameter for -a store, a backend, or a serializer**. The code always uses JSON, then base64, -then `TimestampSigner`. Nothing changed at layers L3 and L4. - -There is a second reason to keep the interface. **An upstream interface follows -the upstream release schedule.** Faults in session code become security -vulnerabilities, because they involve fixation, rotation, and cookie flags. If our -store uses an upstream protocol, then every correction to that protocol waits for -a Starlette release. We must also support one or two older releases with -`hasattr` tests. Control of the interface is therefore an advantage. - -The history of other ecosystems gives the same answer. In each ecosystem in -Section 4, item (a), the store abstraction sits between the framework and the -vendor. -`express-session` is a third-party package, and it defines the `Store` base class. -`connect-redis` implements that class. The Node core owns neither of them. - -Spring puts `SessionRepository` in Spring Session, not in the Servlet -specification. Therefore make the abstraction vendor-neutral, and keep it here. - -The parts that we must send upstream are much smaller. One of them is urgent. - -### 6.1 Correct the `accessed` and `modified` flags that we depend on - -Difference 1 in Section 5 is the rule to write to Redis only when the data -changes. That rule is correct only if the `modified` flag reports every change. -**Today it does not.** We verified this in +### 6.1 What our `Session` class must do better than the upstream one + +Section 5a decides that we write our own class. This is the list of faults in the +upstream class that ours must not repeat. We verified each one in `starlette/middleware/sessions.py`. -The `Session` class overrides `__setitem__`, `__delitem__`, `clear`, `pop`, -`setdefault`, and `update`. It does **not** override `popitem()` and it does not -override `|=`. The `dict.__ior__` method updates the dictionary in C code, so it -does not use the `update()` override. For a signed cookie, the result is one lost -`Set-Cookie` header. For a server-side store, the result is a lost write, and the -store gives no error. The second result is much worse. - -[starlette#3436](https://github.com/Kludex/starlette/pull/3436) already corrects -this. An external contributor opened it on 2026-08-10, and it is still open. -**Review that PR and support it. Do not write a second PR for the same problem.** -An open PR changes the exact behaviour that our design uses. A comment from a -Redis maintainer in that discussion has more value than a proposal that the -maintainers will refuse. - -### 6.2 A new PR: `pop()` sets `modified` but never sets `accessed` - -The `pop()` method runs `self.modified = self.modified or key in self`. The -`mark_modified()` method sets *both* flags. A request that only calls `pop()` -therefore sends `Set-Cookie` without `Vary: Cookie`. This result is different from -every other method that changes the data. The `Vary` header is also the subject of -[#2019](https://github.com/Kludex/starlette/issues/2019). The correction needs two -lines and one test, and it is outside the scope of #3436. - -### 6.3 A proposal: a neutral import path for `Session` - -Propose `starlette.datastructures.Session`, or a new `starlette.sessions` module. -Keep the existing name as an alias. Give this argument: a third-party store must -import from `starlette.middleware.sessions` today. That import loads the -itsdangerous cookie middleware, and the store needs only the type. The core code -shows that the type is already public in practice. -`starlette/requests.py:169-175` imports `Session` under `TYPE_CHECKING`, and then -it tests the object with `hasattr(session, "mark_accessed")`. - -This proposal has a moderate chance and a low cost. It also **blocks nothing**. If -the maintainers refuse it, we import from the middleware module, or we test the -object in the same way as the core code. - -### 6.4 Documentation PRs after the release - -Add the SDK to the third-party pages of Starlette and FastAPI after we release it. -One task has more value. Some pages still send approximately 70k downloads each -month to the **archived** `fastapi-sessions` package, as Section 2 shows. That is -a supply chain risk. Nobody will argue against a correction. +Difference 1 in Section 5 is the rule to write to Redis only when the data changes. +That rule is only as good as the `modified` flag. The upstream class overrides +`__setitem__`, `__delitem__`, `clear`, `pop`, `setdefault`, and `update`, and it +misses the cases below. + +**The consequence is worse for us than for the author of that class.** When the flag +fails for a signed cookie, one `Set-Cookie` header does not go out, and the next +request repairs the damage. When the flag fails for a server-side store, the write to +Redis never happens, no error appears, and the data is gone. + +| Fault | Reason | Our class | +|--------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------| +| `popitem()` sets no flag | The class does not override the method. | Override it. | +| `\|=` sets no flag | `dict.__ior__` changes the dictionary in C code, so it never reaches the `update()` override. | Override `__ior__`. | +| `pop()` sets `modified` but not `accessed` | It runs `self.modified = self.modified or key in self` instead of calling `mark_modified()`, which sets both flags. A request that only calls `pop()` therefore sends `Set-Cookie` with no `Vary: Cookie`. Issue [#2019](https://github.com/Kludex/starlette/issues/2019) covers the same header. | Set both flags. | +| A change inside a nested object, such as `session["a"]["b"] = 1`, sets no flag | **No subclass of `dict` can detect this.** The change happens inside the value, and the dictionary never sees a method call. | We cannot fix it either. The store must therefore supply an explicit `save()` method, and a mode that always writes. | + +The first three faults are ours to correct, and the tests in Section 5 must prove +each one. The fourth is different in kind: it is a limit of the language, so no +release of Starlette will remove it, and our answer must be an escape route rather +than a correction. + +### 6.2 Why the abstraction stays in this package + +Someone will ask whether we should give the vendor-neutral part to Starlette or to +FastAPI, so that other vendors can build on it. The answer is no, for two reasons. + +**The maintainers already refused it, twice.** That is what +[starlette#499](https://github.com/encode/starlette/pull/499) proposed: session +backends that a user can exchange, with no vendor code in the diff. It stayed open +for approximately three years and closed with no merge. Discussion +[#2256](https://github.com/Kludex/starlette/discussions/2256) got the same answer in +2023. Nobody can say that the neutral version lacked a proposal. + +**An upstream interface follows an upstream release schedule.** Faults in session +code are security faults, because they concern fixation, rotation, and cookie flags. +If our store depends on an upstream protocol, every correction to that protocol waits +for a Starlette release, and we carry `hasattr` tests for the older releases in the +meantime. Control of the interface is worth more than the neutrality we would buy. + +Section 4, item (a), gives the same conclusion from other ecosystems: the store +abstraction sits between the framework and the vendor, not inside the framework. + +### 6.3 Optional contributions to Starlette + +**None of these blocks the release.** Section 5a removed that condition: we write our +own class, so no fault upstream reaches our store. Do this work because it is right +for the ecosystem, and because a Redis maintainer in these discussions earns the +goodwill that the documentation PRs in Section 8.7 will need. + +- **Support [starlette#3436](https://github.com/Kludex/starlette/pull/3436).** An + external contributor opened it on 2026-08-10 to correct the `popitem()` and `|=` + faults in the upstream class, and it is still open. Review it. **Do not write a + second PR for the same problem.** +- **Open a PR for the `pop()` fault** in the table above. It needs two lines and one + test, and #3436 does not cover it. +- **Send the documentation PRs after the release**, as Section 8.7 describes. --- @@ -620,8 +731,15 @@ both packages. Structural typing needs no inheritance, so an existing without any change. Today a vendor must choose one package or write two classes. Most vendors write for the larger ecosystem, and that is not us. -**But do not copy the contract.** It cannot express three operations that -Section 3 requires. +**But do not copy the contract.** There are two reasons. The first is the shape of +the contract itself: **a store that is not Redis dictates it.** The `lifetime` +parameter exists for `CookieStore`, which needs it as the `max_age` of the signer. +Their own Redis store ignores that argument in `read()`. If we copy the contract, we +accept a limit that a cookie store created, inside a package that integrates Redis. +Section 8.3.4 records this. + +The second reason: the contract cannot express three operations that Section 3 +requires. - **The contract has no `rotate()`.** The caller must build the defence against fixation from three calls: `read` the old key, `write` the new key, then @@ -629,6 +747,15 @@ Section 3 requires. remove, two IDs give access to the same authenticated session. If the process stops, or the caller ignores an error from `remove`, the old ID stays valid. + **Our `rotate()` needs no transaction either, and it is still correct.** A + session write touches more than one key, and on Redis Cluster those keys sit in + different slots, so no Lua script and no `MULTI` can hold them together. We order + the operations instead: **delete the old key before writing the new one.** A + process that stops in the middle then signs the user out, which is safe, and no + interruption can leave two IDs valid at once. The order gives the property that a + transaction would have given, and it gives it on a cluster too. Section 5 of + [`session-design.md`](session-design.md) gives the sequence. + `starsessions` shows the risk in its own code. Its `regenerate_id()` method keeps the old ID in `_remove_data_for_session` and deletes it only at the next `save()`. If `save()` never runs, the old ID survives until the absolute @@ -666,24 +793,336 @@ Document how to migrate. --- -## The open question +## 8. The scope of version 1 + +Version 1 must be a **minimum solution that works**. It must also be complete +enough to replace the packages that people use today. This section says what that +requirement means in practice, after we read the source of both packages. + +### 8.1 We do not replace `fastapi-users`. Do not claim that we do. + +`fastapi-users` is a framework for user management. It supplies registration, +password hashing, password reset, email verification, links to OAuth accounts, +adapters for several databases, and a user manager. We replace exactly one layer of +it: `RedisStrategy` and `CookieTransport`. + +State this limit in the documentation. If we claim more, we invite a comparison +against features that nobody expects from a session store, and we lose it. + +### 8.2 What `RedisStrategy` is, in 33 lines -Must version 1 support only **web sessions**? That scope means a cookie and the -OWASP lifecycle, and it competes with `starsessions` and `fastapi-users`. Or must -version 1 also support **session state for agents and MCP**? That scope means a -session ID in a header or in an argument, no cookie, and working memory with a -TTL. Section 4, item (c), describes this group of users. Both scopes use the same -backend, -but the transport is different and the users are different. +We read the complete file. The strategy stores **only** `str(user.id)`, under an +opaque key from `secrets.token_urlsafe()`, with `ex=lifetime_seconds`. The +consequences: -My opinion: build the web sessions first, and make the backend independent of the -transport. The support for agents is then a second adapter, not new work. But this -scope also overlaps with `redis/agent-memory-server`. The decision is therefore -about the Redis product range as much as about the technical design. +- It holds **no session payload**. There is nowhere to put a shopping cart, a + wizard step, or OAuth state. +- It never refreshes the key when it reads it, so there is **no idle timeout**. The + `ex` argument gives an absolute timeout only. +- It has **no rotation** for a change of privilege inside a session. +- It has **no index for each user**, so `revoke_all` is not possible. +- `lifetime_seconds` defaults to `None`. With the default, **the token never + expires**. -Section 7 answers part of the technical question. The ABC with a small Protocol is -the reason that a transport-independent backend is possible. The base class holds -the complete lifecycle, and no part of it depends on the transport: +Its `CookieTransport` is correct in the parts that matter: it uses `APIKeyCookie`, +so the scheme reaches OpenAPI, and its defaults are `secure=True`, +`httponly=True`, and `samesite="lax"`. + +Parity with this strategy is therefore simple. We are better on every point above. +We are worse on one point only: we do not return a user object, because we do not +own the user model. + +### 8.3 A complete comparison against `starsessions` + +We read the full source: `middleware.py`, `session.py`, `serializers.py`, +`encryptors.py`, `exceptions.py`, `types.py`, and the four stores. An earlier draft +of this section read only `__init__.py`, and it therefore missed most of the list +below. The package root does not export the encryptors at all. + +**The comparison covers the features of a Redis backend, and no other kind.** This +package integrates Redis. A feature that belongs to a different backend is not a gap +for us, and we must not treat it as one. The correct goal is different: a user must +be able to move **to** Redis with very little work. Section 8.3.4 lists the items +that are outside our scope, with the reason for each one. + +In the tables: **✓** means that the specification covers it. **~** means that the +specification covers it in part, and the text needs a correction. **✗** means that +the specification does not cover it, and that this is a gap we must close. + +#### Parameters of `SessionMiddleware` + +| `starsessions` | Us | Note | +|---|---|---| +| `store` | ✓ | The `SessionStore` ABC in Sections 5 and 7. | +| `lifetime` (`int` or `timedelta`) | ~ | We have an absolute TTL. **It must also accept a `timedelta`.** Their parameter does, and `cache()` in this repository does. | +| `lifetime=0`, a session-only cookie | ✗ | **Gap.** No `max-age`, so the browser deletes the cookie when it closes. This also needs a `gc_ttl` value for the Redis key. See 8.3.1. | +| `rolling` | ~ | Our idle TTL gives a similar result, but not the same one. See 8.3.2. | +| `cookie_name` | ✓ | | +| `cookie_same_site` | ✓ | | +| `cookie_https_only` | ✓ | Both packages set `Secure` by default. | +| `cookie_domain` | ✗ | **Gap.** Section 5 does not list it. | +| `cookie_path` | ✗ | **Gap.** Section 5 does not list it. They also limit the deletion of a cookie to that path. | +| `serializer` | ✓ | Section 5 resolves this: use `Coder`, at `src/redis_fastapi/types.py:15`. | +| `encryptor` | ✗ | **Gap. This is a complete subsystem.** See 8.3.1. | + +#### Behaviour + +| `starsessions` | Us | Note | +|---|---|---| +| Reject an unsafe cookie value before use (`_SAFE_COOKIE_VALUE_RE`) | ✗ | **Gap, and it is a security control.** It prevents header injection when the code writes the ID into `Set-Cookie`. The cost is one regular expression. Copy it. | +| Validate `cookie_name` in the constructor | ✗ | Small. Copy it. | +| Delete the cookie **and** the record when a session becomes empty | ~ | Our `revoke()` implies this. Write it down. | +| Write nothing when an empty session stays empty (`initially_empty`) | ✓ | Our rule to write only when the data changes is stronger. | +| `SessionAutoloadMiddleware`, with paths and regular expressions | ✓ | We need no equivalent. Our design loads the session when code touches it, which is difference 1 in Section 5. That is better than a flag plus a `SessionNotLoaded` exception. | +| `LoadGuard` and `SessionNotLoaded` | ✓ | Absent on purpose, for the same reason. | + +#### Public functions + +| `starsessions` | Us | Note | +|---|---|---| +| `generate_session_id()`, `token_hex(16)`, 128 bits | ✓ | We use `token_urlsafe(32)`, which gives 256 bits. | +| `regenerate_session_id()` | ✓ | Our `rotate()`. Ours is atomic; Section 7 shows that theirs is not. | +| `get_session_id()` | ~ | Name it in the specification. | +| `load_session()` and `is_loaded()` | n/a | Our load is automatic. | +| `get_session_metadata()`, with `lifetime`, `created`, `last_access` | ~ | **Use the same three field names.** A migration is then a rename and not a redesign. | +| `get_session_remaining_seconds()` | ~ | The same. | +| `get_session_handler()` | n/a | Its own docstring says "private API, no backward compatibility guarantee". Ignore it. | + +#### Stores, serializers, encryptors, and exceptions + +| `starsessions` | Us | Note | +|---|---|---| +| The `SessionStore` ABC: `read`, `write`, `remove` | ✓ | Section 7, with the adapter. | +| `RedisStore(connection=…)` | ✓ | Ours uses the connection pool of the SDK. | +| `prefix`, a string **or a callable** | ~ | Section 5 says "key prefix". **It must also accept a callable.** Their documentation advertises this. | +| `gc_ttl` | ✗ | **Gap.** Necessary when `lifetime` is zero. | +| `InMemoryStore` | n/a | Outside our scope. See 8.3.3 and 8.3.4. | +| `CookieStore` | n/a | Outside our scope. See 8.3.4. | +| `Serializer` and `JsonSerializer(json_encoder, json_decoder)` | ✓ | `Coder` replaces both. Section 9 gives the translation. | +| **`Encryptor`, `NoopEncryptor`, `FernetEncryptor`, `AESGCMEncryptor`** | ✗ | **The largest gap.** See 8.3.1. | +| `SessionError`, `SessionNotLoaded`, `ImproperlyConfigured` | ✗ | **Gap.** The specification defines no exceptions. Section 9 defines them. | + +#### 8.3.1 Encryption of the data at rest + +`starsessions` accepts an `encryptor`, and it supplies Fernet and AES-GCM. Our +specification says nothing about encryption. + +The row "Keep the data away from the client" in the Section 3 table is not the same +statement. A server-side store keeps the data away from the browser, but the data is +then plaintext in Redis. It also reaches the RDB file, the AOF file, every replica, +and every backup or snapshot that a managed service makes. For a session that holds +personal data or an OAuth token, under a rule such as the GDPR, that difference is +the whole point. + +**Decision: supply the connection point, and document the implementation. Write no +cryptographic code in version 1.** + +Define an `Encryptor` protocol with two methods, `encrypt(bytes)` and +`decrypt(bytes)`. That is approximately five lines. Then write a recipe in the +documentation that implements it with AES-GCM from the `cryptography` package, in +approximately ten lines. Ship no implementation, and add no `cryptography` extra. + +The reason: cryptographic code that we ship is cryptographic code that we own, that +we must review, and whose vulnerabilities we must track and announce. Ten lines in +the documentation give the user the same result and keep that duty where the +`cryptography` project already discharges it. **The cost is real and we must state +it: a user who wants encryption writes ten lines instead of setting one flag.** If +users ask for a shipped implementation, promote the recipe into code and add the +extra then. + +Two details from their code that the recipe must respect: + +- **Do not copy `NoopEncryptor`.** It calls `warnings.warn()` inside `encrypt()`, so + it warns on every request. A warning at that rate gets filtered, and then nobody + reads it. Use `None` as the default value instead. +- **Use AES-GCM, not Fernet.** AES-GCM gives authenticated encryption in one + operation. Their Fernet path is AES-128-CBC with a separate HMAC. The protocol + stays public, so a user who prefers Fernet can still supply it. + +#### 8.3.2 "Rolling" and "idle" are two different behaviours + +Their `rolling=True` extends **both** the `max-age` of the cookie **and** the TTL of +the record by the complete `lifetime`, on every response. Their `rolling=False` +keeps the original expiry time and sends the seconds that **remain** as `max-age`. + +Our idle TTL refreshes the key in Redis. The specification does not say what happens +to the `max-age` of the cookie. + +**These two clocks must agree.** If they do not, the browser deletes a cookie while +the record in Redis is still alive. The user then sees a logout with no cause. + +Write both clocks against both carriers, and give the translation: their `rolling=True` +becomes our idle TTL, and their `rolling=False` becomes our absolute TTL. + +#### 8.3.3 Do not ship an `InMemoryStore`. Use `fakeredis` in tests. + +An earlier draft argued for a store of this kind, because a `starsessions` user runs +`InMemoryStore` in the tests, and because such a store lets `pytest` run with no +Redis container. **The second reason is already false in this repository**, and the +first reason then disappears with it. + +This repository solves the same problem, and it solves it better. +`tests/conftest.py:16` imports `fakeredis`, and `noxfile.py:102` describes the +`tests_unit` session as "the fakeredis-backed unit suite. Needs no Redis server." + +`fakeredis` is the better answer for a session store, not only an equal one. It runs +our **real** code: the real key schema, the real TTL commands, and the real index built +on hash field expiration. `fakeredis` supports every one of those commands, which we +verified before we settled the design. An in-memory session store runs none of that, so a test suite that passes +against it proves less than it appears to prove. Two ways to reach an empty test +database is one way too many, and the weaker way is the one that hides faults. + +**Decision: ship no in-memory store.** Instead write a recipe that shows a test with +`fakeredis`, and point to `tests/conftest.py` in this repository as the example that +we ourselves use. + +#### 8.3.4 What is outside our scope, and why + +This package integrates Redis. The items below belong to a different backend. They +are **not** gaps, and no later release must close them. + +| Item | Why it is not ours | +|---|---| +| `InMemoryStore` | It is not a Redis feature. `fakeredis` covers the test case, and it covers it better. See 8.3.3. | +| `CookieStore` | It is not a Redis feature. The Starlette middleware already is a cookie store, and Section 5a explains that it is competent for that one job. A user who wants a cookie store must keep it. | +| The `lifetime` and `ttl` pair in their `write()` | Section 7 already refuses this contract. Here is the sharper reason: the shape exists **because of `CookieStore`**, which needs `lifetime` for the `max_age` of the signer. Their own Redis store ignores the `lifetime` argument to `read()` completely. If we copy the contract, we accept a limit that a store which is not Redis created. | + +Our `SessionStoreProtocol` must still be wide enough to accept a store of any of +these kinds from a user. Section 9 lists it as a connection point. We do not write +one, but we do not prevent one. + +### 8.4 Move the index for each user into version 1 + +Section 5 puts `list_sessions` and `revoke_all` in a later release. **Move the +binding to a user, and the index, into version 1.** The two methods can still +appear later. + +The reason is the data, not the code. If we add the index afterwards, every session +from before that release has no entry in it. `revoke_all` then reports success and +removes nothing, and `list_sessions` hides a live session. Section 7 rejects +exactly this failure: an empty answer that the application cannot distinguish from +a correct one. Here we would cause it ourselves. + +The work in Redis is small. Use a **hash with a TTL on each field**: one field for each +session, and the TTL of that field is the absolute deadline of the session. Redis then +deletes the entry when the session dies. + +**Do not use a plain set.** An earlier draft did. Most sessions end because their TTL +runs out, and Redis calls nobody when a key expires, so a set keeps a member for every +session that ever timed out. It grows without limit, and `list_sessions` then reports +sessions that do not exist. + +A second draft used a sorted set scored by expiry, which is correct but which still asks +us to prune. **Hash field expiration, which Redis added in 7.4, removes even that.** This +package already requires Redis 7.4, so we may use it. `HGETALL` returns the live sessions +and nothing else, `HLEN` counts them for a limit on concurrent sessions, and the value of +each field can hold a descriptor, so the "your active sessions" screen costs one round +trip. Section 3.3 of [`session-design.md`](session-design.md) gives the complete design, +and Section 13 explains why no other backend can copy it. + +Do this while the key schema is still free. + +### 8.5 The contents of version 1 + +Divide the work by the answer to one question: can a user add this later, without +our help? + +- **Core.** No, the user cannot. It must be in the middleware or in the store. +- **A connection point.** Yes, but only if we expose a seam. Each seam is a few + lines, so every seam belongs in version 1. A feature that we do not write must + never become a feature that nobody can write. +- **A recipe.** Yes, with the seams that already exist. It needs no code from us, + only documentation. Therefore it also belongs in version 1. + +#### Core, P0. The release means nothing without these. + +- `SessionStore` (ABC) and `RedisSessionStore` +- our `SessionMiddleware`, with a signed opaque ID in the cookie +- our own `Session` class, which tracks `popitem()` and `|=` +- a load that happens with no call from the user, and only when the request carries a + session cookie. **An earlier draft said "only when code touches the session". No + implementation can do that**, because `HTTPConnection.session` is a synchronous + property and cannot await a Redis read. Section 1.1 of + [`session-design.md`](session-design.md) gives the evidence and the corrected rule. + The user still calls nothing, which is the promise in difference 1 above. +- a write that happens only when the data changes +- an idle TTL **and** an absolute TTL, with the clock of the cookie and the clock of + Redis in agreement. Section 8.3.2 explains the failure if they disagree. +- automatic rotation when the principal changes, with the ordering guarantee beneath it +- `revoke()` +- strict cookie defaults +- the validation of the session ID that arrives in the cookie +- `SessionDep`, `SessionStoreDep`, and `get_session_store` +- the `REDIS_SESSION_*` settings, and `.sessions()` on the builder + +#### Core, P1. Necessary to replace the packages that people use today. + +- the binding to a user **and** the index for each user (Section 8.4) +- the settings for the cookie domain and the cookie path +- the session-only mode, with `gc_ttl` for the Redis key +- the OpenAPI scheme through `APIKeyCookie` +- the accessors for the metadata, with the field names of `starsessions` +- serialization through the existing `Coder` +- the telemetry from Section 5 +- the exception hierarchy from Section 9.2 +- compatibility with Authlib, which needs no work. It follows from the + `scope["session"]` contract. +- **support for a `def` endpoint as well as an `async def` one.** Reading and writing a + session already needs no bridge, because the session is a dictionary and the + middleware does the input and output. The imperative store operations need + `SyncSessionStore` and `SyncSessionStoreDep`, which follow `SyncCacheBackend` and + `SyncRateLimitBackend` exactly. See the "Sync endpoints" part of Section 9 in + [`session-design.md`](session-design.md). + +#### Connection points. All of them, because each one is small. + +Section 9.1 gives the complete table: `Coder`, `Encryptor`, `SessionStoreProtocol`, +the key prefix as a string or a callable, the factory for a session ID, the +identifier for the index, and the builder for the cookie. + +#### Recipes. Documentation only, and no code from us. + +Section 9.6 gives the complete list. It includes an encryptor with AES-GCM, tests +with `fakeredis`, the four migrations, `Partitioned` and `__Host-` cookies, CSRF, +the check of the IP address and the User-Agent, and `Clear-Site-Data` at logout. + +**The last two arrived here from the excluded list.** An earlier draft excluded both +from version 1. That was wrong. The first compares two values that the session +payload already holds. The second sets one response header. Neither needs code that +we are not writing anyway, so neither has a reason to wait. + +#### Excluded from version 1 + +Every item below is excluded because it needs code that we choose not to write now, +and not because it is small: + +- the carrier for agents and MCP +- the adapter for `starsessions` stores, from Section 7 +- the reader that migrates a live `starsessions` session, from Section 9.3.3 + +**`SyncSessionStore` was on this list, and it should not have been.** The reason given +was that the middleware is asynchronous, so a synchronous store would serve only +imperative calls inside synchronous endpoints. That argument assumed such calls were +rare. They are not: Section 9 of [`session-design.md`](session-design.md) makes +imperative store calls the way an application signs a user in, signs them out, lists +their devices and caps their sessions. FastAPI serves a `def` endpoint as readily as an +`async def` one, so excluding the facade excluded every one of those operations from +half of the framework's users. + +The exclusion also contradicted itself. It noted that the absence was "visible, because +`SyncCacheBackend` and `SyncRateLimitBackend` exist" — that is an argument for building +it, not for deferring it. Two of three features shipping a synchronous facade and the +third not is exactly the kind of inconsistency this specification tries to avoid. + +Section 8.3.4 lists what is outside our scope permanently. Do not confuse that list +with this one. + +### 8.6 The answer to the open question + +**Build the web sessions first, and keep the store independent of the transport.** + +Section 7 already makes this possible. The base class holds the complete lifecycle, +and no part of it touches the transport: - the session IDs - the rotation @@ -691,20 +1130,198 @@ the complete lifecycle, and no part of it depends on the transport: - the index for each user - the telemetry -Only the cookie carrier is specific to the web, and it lives in `sessions.py`, not -in the store. An adapter for agents and MCP then supplies a different carrier. -That carrier reads the session ID from a header or from an argument, and it uses -the same base class. The remaining question is about the product range. - -### Next actions - -1. Review and support - [starlette#3436](https://github.com/Kludex/starlette/pull/3436), as Section 6.1 - explains. This action is urgent. The PR is open now, and it changes the - behaviour that this design uses. -2. Open the PR for `pop()` and the `accessed` flag, as Section 6.2 explains. -3. Add a minimum version of `starlette>=1.0.0` to `pyproject.toml` when the - implementation starts. -4. Propose the neutral import path for `Session`, as Section 6.3 explains. - Continue with the work whatever the answer is. -5. Send the documentation PRs after the release, as Section 6.4 explains. +Only the cookie carrier belongs to the web, and it lives in `sessions.py` and not +in the store. Support for agents and MCP is then a second carrier that reads the +session ID from a header or from an argument. It is not a rewrite. + +One question stays open, and it is not a technical question. Session state for +agents overlaps with `redis/agent-memory-server`. The product managers must decide +where that feature belongs. + +### 8.7 Next actions + +1. Write the code for version 1, as Section 8.5 defines it. Nothing upstream blocks + this work. +2. Make the two optional contributions to Starlette, as Section 6.3 lists them: + support #3436, and open the PR for the `pop()` fault. Neither one blocks us, + because we write our own `Session` class. +3. After the release, add the SDK to the third-party pages of Starlette and FastAPI. + One task there has more value than the listing itself: those pages still send + approximately 70k downloads each month to the **archived** `fastapi-sessions` + package, as Section 2 shows. That is a supply chain risk, and nobody will argue + against a correction. + +--- + +## 9. Extension points, exceptions, and how to change from another package + +Section 8 sets two goals. A user of another package must be able to change to this +one with very little work. And if we do not supply a feature, the user must be able +to supply it, with a callback or an extension, and never with a fork. + +This section is the contract for both goals. + +### 9.1 The extension points + +Each row is a connection point that we make public and that we keep. If you need to +add a row later, that is a sign that the design is too closed. + +| Connection point | Type | It lets a user replace | +|---|---|---| +| `Coder` (`src/redis_fastapi/types.py:15`) | Protocol | the serialization: a custom JSON encoder or decoder, msgpack, or a compressed format | +| `Encryptor` | Protocol | the encryption at rest: Fernet, a key from a KMS, or the rotation of a key | +| `SessionStore` (ABC) and `SessionStoreProtocol` | ABC and Protocol | the complete storage: Postgres, Valkey, or Memcached. Section 7 explains the two types. | +| the key prefix | `str` or `Callable[[str], str]` | the names of the keys: one space for each tenant, or a hash tag for OSS Cluster | +| the factory for a session ID | `Callable[[], str]` | the format of an ID, if a company standard demands one | +| the builder for the cookie | a callable, or every attribute passed through | **any cookie attribute that we did not plan** | +| the identifier for the index | `Callable[[Session], str \| None]` | the subject of the index: a tenant, a device, or an API client, and not only a user | +| the trigger for a rotation | `principal_keys: list[str]`, or a callable | **what counts as a privilege change.** Defaults to the identity alone; add a role or a scope list and an escalation rotates by itself. Section 5.1 of [`session-design.md`](session-design.md). | + +**The builder for the cookie is more important than it appears.** Section 2a records +that the Starlette middleware cannot send `Partitioned`, and that it cannot use a +`__Host-` prefix. Those two limits are a common reason to stop using it. A +connection point here means that we never repeat that fault. A user adds the +attribute; the user does not wait for our next release. + +### 9.2 The exceptions + +`starsessions` defines three exceptions, and this specification defined none. Define +these, so that a caller can catch every fault from this feature as one group: + +- `SessionError`, the base class for every exception below. +- `SessionConfigurationError`, for a setting that is absent or wrong. This is the + equivalent of their `ImproperlyConfigured`. +- `SessionStoreError`, for a store that fails. Wrap the error from the driver; + do not let a `redis.RedisError` reach the application code directly. + +We need no equivalent of their `SessionNotLoaded`. Our session always loads when +code touches it, which is difference 1 in Section 5. + +**Decide the behaviour when the store is unavailable, and write it down.** The rate +limit code in this repository already has a `fail_closed` setting for the same +question. Sessions must make the same choice explicit: if Redis is unreachable, +does the request continue with an empty session, or does it fail? Do not leave this +to an exception that escapes by accident. + +### 9.3 Where users arrive from + +Order the migration documentation by the value to the user, and not by the name of +the competitor. Four sources matter, and the first one matters most. + +**One rule applies to all four: the sessions that exist do not survive the change.** +Our key format, the position of the metadata, and the index for each user differ +from every source below. Every signed-in user signs in again. State this at the top +of each guide. A team must not find it in production. Recommend a release at a quiet +time. + +#### 9.3.1 From a session store that holds data in one process + +**This is the most important guide, and the specification did not have it.** + +The user has sessions in the memory of the process. That covers the Starlette +middleware with small payloads, `starsessions` with `InMemoryStore`, and a dictionary +that somebody wrote by hand. The application works, and it works until the day the +team starts a second worker or a second pod. Then a user signs in on one instance and +the next request reaches the other one, which has never heard of that session. + +That day is the reason this feature exists. Section 4 gives the same history for +Spring Session Data Redis. Write the guide for the person who is having that day: + +- Name the symptom first: a user signs out at random, and the rate of it grows with + the number of instances. That is what the person will search for. +- Show that the change removes the need for sticky sessions at the load balancer, so + the balancer returns to a plain round robin. +- Show that a session then survives a restart and a deployment. +- Note the one new duty: Redis becomes a dependency of the request path. Point to the + decision in Section 9.2 about the behaviour when the store is unavailable. + +#### 9.3.2 From the Starlette signed cookie + +This is the largest group of users, and most of them arrived through Authlib. + +For them the change is almost free. Section 5a explains why: we keep the +`scope["session"]` contract, so `request.session` behaves as before and Authlib needs +no change at all. The user replaces one middleware. + +Name the two faults that disappear at the same time, because Section 2a shows that +these are what people actually suffer from: + +- The limit of 4096 bytes disappears. A payload larger than that no longer breaks + the login without an error message. +- The race condition on `Set-Cookie` disappears, and with it one cause of + `mismatching_state`. + +#### 9.3.3 From `starsessions` with `RedisStore` + +The user already has the correct architecture. Only the package changes. The table in +Section 9.4 translates every setting. + +A reader for their format is possible, and it would remove the one interruption. It +would read their key, migrate the session at the first request, and write it again in +our format. It costs approximately 30 lines. Section 8.5 excludes it from version 1, +because it is code that we choose not to write yet. Add it if users ask for a +migration with no sign-out. + +#### 9.3.4 From the `RedisStrategy` of `fastapi-users` + +See Section 9.5. + +### 9.4 Translation of the settings from `starsessions` + +This table is the most valuable part of that migration, and it costs us nothing. + +| `starsessions` | This SDK | Note | +|---|---|---| +| `store=RedisStore(connection=…)` | `.sessions()` | We use the connection pool of the SDK. | +| `lifetime=N` | the absolute TTL | Both accept an `int` or a `timedelta`. | +| `lifetime=0` | the session-only mode | Set `gc_ttl` for the Redis key. | +| `rolling=True` | the idle TTL | Section 8.3.2 explains the two clocks. | +| `rolling=False` | the absolute TTL alone | | +| `cookie_name` | the cookie name | The same meaning. | +| `cookie_same_site` | the `SameSite` value | The same meaning. | +| `cookie_https_only` | the `https_only` flag | Both default to on. | +| `cookie_domain`, `cookie_path` | the cookie domain, the cookie path | The same meaning. | +| `serializer=JsonSerializer(...)` | `Coder` | A custom `json_encoder` becomes a custom `Coder`. | +| `encryptor=FernetEncryptor(key)` | the `Encryptor` connection point | Use the AES-GCM recipe, or supply their Fernet class. Section 8.3.1. | +| `prefix="x."` or a callable | the key prefix | We accept both forms. | +| `gc_ttl` | `gc_ttl` | The same meaning. | +| `regenerate_session_id()` | `rotate()` | Ours is atomic. Section 7 gives the difference. | +| `load_session()`, `is_loaded()` | nothing | Our load is automatic. Delete these calls. | +| `get_session_metadata()` | the accessor for the metadata | The same three field names. | +| `get_session_remaining_seconds()` | the same name | | +| `InMemoryStore` | `fakeredis` in the tests | Section 8.3.3. Outside our scope in production. | +| `CookieStore` | nothing | Outside our scope. Keep the Starlette middleware for this case. | +| `SessionAutoloadMiddleware` | nothing | Delete it. Our load is automatic. | + +### 9.5 Changing from the `RedisStrategy` of `fastapi-users` + +Section 8.1 states the limit: we replace `RedisStrategy` and `CookieTransport`, and +we replace nothing else. The user keeps `fastapi-users` for registration, for +passwords, and for OAuth. + +Their record maps a token to a user ID and holds nothing else. So the migration has +one direction that is simple: our session holds the user ID in the same way, and it +can also hold everything else. As above, the sessions that exist do not survive the +change. + +### 9.6 The recipes for version 1 + +A recipe is documentation, and it needs no code from us. Each one uses a connection +point from Section 9.1. Section 8.5 puts all of them in version 1 for that reason. + +| Recipe | It uses | Approximate size | +|---|---|---| +| An encryptor with AES-GCM | the `Encryptor` connection point and the `cryptography` package | 10 lines. Section 8.3.1. | +| Tests with `fakeredis` | `dependency_overrides`, which this package already supports | Point to `tests/conftest.py`. Section 8.3.3. | +| The four migrations | — | Section 9.3. | +| `Partitioned` and `__Host-` cookies | the builder for the cookie | 5 lines. It corrects the limit in Section 2a. | +| CSRF for a cookie session | `fastapi-csrf-protect` | Section 5 already requires this text. | +| A check of the IP address and the User-Agent | two values in the session payload | 10 lines. Repeat the OWASP warning from Section 3: it detects, and it does not defend. | +| `Clear-Site-Data` at logout | one response header | 2 lines. | +| One key space for each tenant | the key prefix as a callable | 3 lines. | +| A hash tag for OSS Cluster | the key prefix as a callable | 3 lines. | +| An index by device instead of by user | the identifier for the index | 5 lines. | + +**Write the recipes as tested code.** Put each one in `examples/`, or in a test that +`nox` runs. A recipe that nobody runs stops working at the first release that changes +a name, and then it damages the user more than an absent feature does. From 9d12eaf5a7024388e2b15140a30bb1c8ad0e1cb6 Mon Sep 17 00:00:00 2001 From: Tihomir Mateev Date: Thu, 3 Sep 2026 17:20:29 +0300 Subject: [PATCH 04/11] Initial implementation --- docs/guide/sessions.md | 260 ++++ mkdocs.yml | 1 + src/redis_fastapi/__init__.py | 50 +- src/redis_fastapi/config.py | 111 +- src/redis_fastapi/deps.py | 60 + src/redis_fastapi/session_backend.py | 1140 +++++++++++++++++ src/redis_fastapi/session_events.py | 278 ++++ src/redis_fastapi/sessions.py | 677 ++++++++++ src/redis_fastapi/setup.py | 50 +- src/redis_fastapi/telemetry.py | 95 ++ tests/integration/test_session_integration.py | 162 +++ tests/unit/test_session.py | 188 +++ tests/unit/test_session_backend.py | 352 +++++ tests/unit/test_session_events.py | 179 +++ tests/unit/test_session_failures.py | 243 ++++ tests/unit/test_session_index.py | 241 ++++ tests/unit/test_session_middleware.py | 386 ++++++ tests/unit/test_session_setup.py | 229 ++++ 18 files changed, 4698 insertions(+), 4 deletions(-) create mode 100644 docs/guide/sessions.md create mode 100644 src/redis_fastapi/session_backend.py create mode 100644 src/redis_fastapi/session_events.py create mode 100644 src/redis_fastapi/sessions.py create mode 100644 tests/integration/test_session_integration.py create mode 100644 tests/unit/test_session.py create mode 100644 tests/unit/test_session_backend.py create mode 100644 tests/unit/test_session_events.py create mode 100644 tests/unit/test_session_failures.py create mode 100644 tests/unit/test_session_index.py create mode 100644 tests/unit/test_session_middleware.py create mode 100644 tests/unit/test_session_setup.py diff --git a/docs/guide/sessions.md b/docs/guide/sessions.md new file mode 100644 index 0000000..6579048 --- /dev/null +++ b/docs/guide/sessions.md @@ -0,0 +1,260 @@ +# Sessions + +Server-side sessions for FastAPI. The cookie carries an opaque identifier and +nothing else; the record lives in Redis, where the server enforces both of its +deadlines. + +```python +from fastapi import FastAPI +from redis_fastapi import FastAPIRedis, SessionDep + +app = FastAPI() +FastAPIRedis(app).lifespan().sessions() + +@app.post("/login") +async def login(session: SessionDep) -> dict: + session["user_id"] = 42 # this rotates the session ID + return {"ok": True} + +@app.get("/me") +async def me(session: SessionDep) -> dict: + return {"user_id": session.get("user_id")} +``` + +`request.session` works too, so code written against Starlette's signed-cookie +middleware runs unchanged. + +--- + +## Rotation is automatic + +Writing the identity rotates the session ID. There is no `rotate()` to call and +therefore none to forget, which matters because forgetting it is the one +mistake in this API that is a vulnerability — session fixation. + +The middleware evaluates a **principal** twice per request, once before the +application runs and once at `http.response.start`. If the two differ and the +response status is below 400, it issues a new ID and deletes the old key first. + +Watch more than the user ID to get OWASP's privilege-change rotation: + +```python +FastAPIRedis(app).lifespan().sessions(principal_keys=["user_id", "role"]) +``` + +For anything a list of keys cannot express, supply a function: + +```python +FastAPIRedis(app).lifespan().sessions( + # Identity exists, but authentication is not finished at MFA step one. + principal_of=lambda s: s.get("user_id") if s.get("mfa_ok") else None, +) +``` + +`principal_of` must be **pure, cheap and deterministic** — it runs twice per +request and the two results are compared by value — and it must return a +*verified* identity, never something the client set. + +!!! warning "A misconfigured resolver is silent" + If your application writes `uid` but the resolver reads `user_id`, nothing + rotates and nothing complains. Assert it once: + + ```python + def test_login_rotates(client): + before = client.post("/write").cookies["session"] + after = client.post("/login").cookies["session"] + assert after != before + ``` + + The `operation="rotate"` counter is the runtime backstop: sign-ins with no + rotations is a visible anomaly on a dashboard. + +--- + +## Two clocks, both enforced by Redis + +| Setting | Default | What it bounds | +|---|---|---| +| `session_idle_ttl` | 30 min | Time since the last request carrying the cookie | +| `session_absolute_ttl` | 8 h | Time since the session was created, however active the user | + +They live on two separate hash fields with their own expirations, so **Redis +enforces both and this library computes neither**. Writing the payload touches +one field and never the other, so no number of writes can extend the absolute +deadline. + +The absolute clock is the one that matters against a stolen session. An idle +timeout cannot expire a session an attacker is actively using, because the +attacker's own requests keep refreshing it — OWASP says so directly. The +absolute deadline is the only clock that fires on a live compromise. + +Both settings accept an `int` or a `timedelta`. Setting either to `0` disables +that clock; setting both gives a cookie-only session that the browser drops +when it closes. + +The cookie's `max-age` is `min(idle, absolute remaining)`, and both numbers come +from Redis rather than from your process — so a container with a skewed clock +cannot produce a cookie that outlives its record and signs a user out with no +explanation. + +--- + +## Signing out, everywhere + +```python +from redis_fastapi import SessionStoreDep + +@app.post("/logout") +async def logout(session: SessionDep, store: SessionStoreDep) -> dict: + await store.revoke(session) + return {"ok": True} + +@app.post("/logout-everywhere") +async def logout_all(session: SessionDep, store: SessionStoreDep) -> dict: + return {"ended": await store.revoke_all(str(session["user_id"]))} + +@app.get("/devices") +async def devices(session: SessionDep, store: SessionStoreDep) -> list[dict]: + current = store.session_id(session) + return [ + {"id": info.session_id, "this_device": info.session_id == current, + "last_seen": info.last_access} + for info in await store.list_for_subject(str(session["user_id"])) + ] +``` + +`revoke_id` requires the subject and refuses an ID that is not indexed under it, +so a handler taking an ID from a request cannot end a stranger's session. + +A listing is **verified before it is returned**. The index that answers "which +sessions has this user got?" is an upper bound: entries expire on the absolute +clock while most sessions die of idleness long before, so an entry routinely +outlives the session it names. Reporting one would show a user a device they +are not signed in on and a sign-out button that does nothing. + +--- + +## When Redis is unreachable + +The default is asymmetric, and the asymmetry is the point. + +- **A failed read yields an empty session.** The user looks anonymous, your own + authorization dependency finds no user, and a protected route stays protected + because it never depended on the read succeeding. +- **A failed write raises `SessionStoreError`.** Losing a login or a rotation is + the worst outcome here and must never be silent. + +Set `session_fail_closed=True` to turn the failed read into an error too, for a +deployment that prefers a 503 to an anonymous page. Writes raise either way. + +--- + +## Nested changes are invisible + +```python +session["prefs"]["theme"] = "dark" # NOT saved +session["prefs"] = {**session["prefs"], "theme": "dark"} # saved +``` + +No `dict` subclass in any language can see a change inside a value it holds. +Reassign the top-level key, or set `session_always_save=True` to write on every +request that touched the session. + +--- + +## Real-time session events (optional, Redis 8.8+) + +Close a WebSocket the moment a session ends, instead of finding out on the next +HTTP request: + +```python +from redis_fastapi import SessionEvents + +events = SessionEvents(redis, key_prefix="redis:fastapi") + +@events.on_session_end +async def _(session_id: str, cause: str) -> None: # "idle" or "absolute" + await close_sockets_for(session_id) + +await events.start() +``` + +This needs Redis 8.8 for hash subkey notifications, and it needs the server +configured for them: + +``` +CONFIG SET notify-keyspace-events Th +``` + +The subkey flags `S`, `T`, `I`, `V` are **independent of `K` and `E`** — setting +`KEA` enables every standard keyspace event and still delivers none of these. +This library will never set the option for you: it is server-wide and affects +every other application on the instance. + +!!! danger "The callback is best-effort, and silence is a possible outcome" + On a server below 8.8, one without the flags, or one where `CONFIG GET` is + unavailable — which is common on managed Redis — `events.tier` is `"none"`, + one warning is logged at startup, and **your handlers never run**. Startup + still succeeds and every request still works. + + A revocation handler that never fires looks exactly like one that works. If + prompt closure matters, check `events.tier` and add a periodic sweep as + well. Redis Pub/Sub is fire-and-forget: events sent while no subscriber is + connected are lost, and an expiry event fires when Redis removes the field + rather than when the deadline passed. + + Nothing else depends on this. Expiry, revocation and the index all work + identically with events switched off. + +On a cluster, keyspace events are node-local and are not broadcast, so seeing +every event needs one subscriber per node. + +--- + +## Running it in production + +**A session store is not a cache, and `maxmemory-policy` must say so.** Under +`allkeys-lru`, `allkeys-lfu` or `allkeys-random`, Redis will evict live +sessions to make room, and every evicted session is a user signed out mid-task +with nothing in any log to explain it. Use `volatile-ttl` or `noeviction`, or +give sessions their own instance or logical database. + +This is the single most likely production incident with this feature. + +A large tenant's index key is a single key that every login and logout writes, +which makes it a candidate hot spot. `HOTKEYS START METRICS 2 CPU NET SAMPLE 100` +finds it; the `subject_of` seam is what shards it. + +--- + +## Sync endpoints + +```python +from redis_fastapi import SyncSessionStoreDep + +@app.post("/logout") +def logout(session: SessionDep, store: SyncSessionStoreDep) -> dict: + store.revoke(session) + return {"ok": True} +``` + +--- + +## Settings + +Every setting is an environment variable prefixed `REDIS_`, so +`session_idle_ttl` is `REDIS_SESSION_IDLE_TTL`. + +| Setting | Default | Notes | +|---|---|---| +| `session_cookie_name` | `session` | Matches Starlette and `starsessions` | +| `session_cookie_https_only` | `True` | Adds `Secure`. Turn it off for local HTTP only | +| `session_cookie_same_site` | `lax` | `none` requires `https_only=True` | +| `session_idle_ttl` | `1800` | `0` disables the idle clock | +| `session_absolute_ttl` | `28800` | `0` disables the absolute clock | +| `session_gc_ttl` | `2592000` | Backstop so Redis can always collect an abandoned key | +| `session_refresh_on_load` | `True` | `False`: only a request that *used* the session counts as activity | +| `session_fail_closed` | `False` | Read behaviour when Redis is down | +| `session_always_save` | `False` | Escape route for nested mutation | +| `session_principal_keys` | `["user_id"]` | What a change to rotates the ID | +| `session_events_enabled` | `False` | Opt in to real-time events | diff --git a/mkdocs.yml b/mkdocs.yml index c4090f1..27f455a 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -83,6 +83,7 @@ nav: - Architecture: guide/architecture.md - Caching: guide/caching.md - Rate Limiting: guide/rate-limiting.md + - Sessions: guide/sessions.md - Observability: guide/observability.md - Configuration: guide/configuration.md - Benchmarks: guide/benchmarks.md diff --git a/src/redis_fastapi/__init__.py b/src/redis_fastapi/__init__.py index f155971..e4e720d 100644 --- a/src/redis_fastapi/__init__.py +++ b/src/redis_fastapi/__init__.py @@ -16,13 +16,19 @@ AsyncRedisDep, CacheBackendDep, RateLimitBackendDep, + SessionDep, + SessionStoreDep, SyncCacheBackendDep, SyncRateLimitBackendDep, + SyncSessionStoreDep, get_async_redis, get_cache_backend, get_rate_limit_backend, + get_session, + get_session_store, get_sync_cache_backend, get_sync_rate_limit_backend, + get_sync_session_store, ) from redis_fastapi.lifespan import redis_lifespan from redis_fastapi.rate import Rate, parse_rate @@ -40,6 +46,25 @@ RateLimitResult, SyncRateLimitBackend, ) +from redis_fastapi.session_backend import ( + RedisSessionStore, + SessionInfo, + SessionMetadata, + SessionRecord, + SessionStore, + SyncSessionStore, +) +from redis_fastapi.session_events import SessionEvents +from redis_fastapi.sessions import ( + CookieSpec, + Session, + SessionConfigurationError, + SessionError, + SessionMiddleware, + SessionStoreError, + add_redis_sessions, + build_cookie, +) from redis_fastapi.setup import FastAPIRedis from redis_fastapi.telemetry import disable_telemetry, enable_telemetry from redis_fastapi.types import ( @@ -56,23 +81,41 @@ "CacheHitException", "CannotIdentifyClient", "Coder", + "CookieSpec", + "FastAPIRedis", "Identifier", "JsonCoder", "KeyBuilder", - "FastAPIRedis", "Rate", "RateLimitBackend", "RateLimitBackendDep", "RateLimitExceeded", "RateLimitMiddleware", "RateLimitResult", + "RedisSessionStore", "RedisSettings", + "Session", + "SessionConfigurationError", + "SessionDep", + "SessionError", + "SessionEvents", + "SessionInfo", + "SessionMetadata", + "SessionMiddleware", + "SessionRecord", + "SessionStore", + "SessionStoreDep", + "SessionStoreError", "SyncCacheBackend", "SyncCacheBackendDep", "SyncRateLimitBackend", "SyncRateLimitBackendDep", + "SyncSessionStore", + "SyncSessionStoreDep", "add_redis_caching", "add_redis_rate_limiting", + "add_redis_sessions", + "build_cookie", "cache", "cache_evict", "cache_put", @@ -82,12 +125,15 @@ "get_async_redis", "get_cache_backend", "get_rate_limit_backend", + "get_session", + "get_session_store", "get_settings", "get_sync_cache_backend", - "pydantic_model_coder", "get_sync_rate_limit_backend", + "get_sync_session_store", "ip_identifier", "parse_rate", + "pydantic_model_coder", "rate_limit", "redis_lifespan", ] diff --git a/src/redis_fastapi/config.py b/src/redis_fastapi/config.py index e0f266b..c4f8961 100644 --- a/src/redis_fastapi/config.py +++ b/src/redis_fastapi/config.py @@ -9,7 +9,7 @@ import warnings from functools import lru_cache from importlib.metadata import PackageNotFoundError, version -from typing import Any +from typing import Any, Literal from pydantic import Field, SecretStr, model_validator from pydantic_settings import BaseSettings, SettingsConfigDict @@ -173,6 +173,115 @@ class RedisSettings(BaseSettings): default=False, description=("Emit IETF draft RateLimit / RateLimit-Policy response headers."), ) + # -- Sessions -------------------------------------------------------------- + session_cookie_name: str = Field( + default="session", + description=( + "Name of the session cookie. Matches Starlette's and " + "starsessions' default so a migration keeps existing cookie names." + ), + ) + session_cookie_domain: str | None = Field( + default=None, + description=( + "Cookie Domain attribute. None scopes the cookie to the exact " + "host that set it; setting it also exposes the cookie to " + "subdomains." + ), + ) + session_cookie_path: str = Field( + default="/", + description="Cookie Path attribute.", + ) + session_cookie_same_site: Literal["lax", "strict", "none"] = Field( + default="lax", + description=( + "Cookie SameSite attribute. 'none' requires " + "session_cookie_https_only=True." + ), + ) + session_cookie_https_only: bool = Field( + default=True, + description=( + "Add Secure to the session cookie, so the browser sends it over " + "HTTPS only. On by default; turn it off for local development " + "over plain HTTP and nowhere else." + ), + ) + session_idle_ttl: int = Field( + default=1800, + ge=0, + description=( + "Idle clock, in seconds. The session dies this long after the " + "last request that carried its cookie. Stored as the TTL of hash " + "field 'd'. 0 disables the idle clock, and the field then takes " + "session_gc_ttl." + ), + ) + session_absolute_ttl: int = Field( + default=28800, + ge=0, + description=( + "Absolute clock, in seconds. The session dies this long after " + "creation however active the user is. Stored as the TTL of hash " + "field 'a', which is written once and never refreshed. 0 disables " + "it, and the field then takes session_gc_ttl." + ), + ) + session_gc_ttl: int = Field( + default=2592000, + gt=0, + description=( + "Backstop TTL for a field whose real deadline is unknown: " + "cookie-only mode, or session_absolute_ttl=0. Never reached in " + "normal operation; it exists so Redis can always collect an " + "abandoned key." + ), + ) + session_refresh_on_load: bool = Field( + default=True, + description=( + "True: the load uses HGETEX, so any request carrying the cookie " + "restarts the idle clock in the same round trip. False: only a " + "request that touched the session refreshes it, at the cost of a " + "second round trip." + ), + ) + session_fail_closed: bool = Field( + default=False, + description=( + "Behaviour when Redis is unreachable on READ. False yields an " + "empty session, so the caller looks anonymous and the " + "application's own authorization rejects them. True raises " + "instead. Writes always raise, whatever this is set to." + ), + ) + session_always_save: bool = Field( + default=False, + description=( + "Write the payload on every request that touched the session, " + "even when no mutation was detected. The escape route for a " + "change inside a nested value, which no dict subclass can see." + ), + ) + session_principal_keys: list[str] = Field( + default_factory=lambda: ["user_id"], + description=( + "Session keys the rotation trigger watches. A change to any of " + "them on a successful response rotates the session ID. Add 'role' " + "or 'scopes' for OWASP's privilege-change rotation." + ), + ) + session_events_enabled: bool = Field( + default=False, + description=( + "Subscribe to Redis notifications and call registered handlers " + "when a session ends. Best-effort: on a server that cannot " + "supply them the store logs one warning at startup and the " + "handlers never fire." + ), + ) + # -- Telemetry ------------------------------------------------------------- otel_enabled: bool = Field( default=False, diff --git a/src/redis_fastapi/deps.py b/src/redis_fastapi/deps.py index 5105d49..71fbb64 100644 --- a/src/redis_fastapi/deps.py +++ b/src/redis_fastapi/deps.py @@ -12,6 +12,7 @@ SyncRateLimitBackend, _BackendCapabilities, ) + from redis_fastapi.session_backend import _StoreCapabilities from fastapi import Depends, FastAPI, Request from redis.asyncio import ConnectionPool as AsyncConnectionPool @@ -20,6 +21,18 @@ from redis_fastapi.config import get_settings +# Imported at runtime, not under TYPE_CHECKING, and that is load-bearing. +# FastAPI resolves an endpoint's annotations with ``get_type_hints``, which +# evaluates the forward reference inside ``Annotated[...]`` against *this* +# module's namespace. A name that exists only for the type checker raises +# NameError there, and FastAPI then treats the parameter as an ordinary query +# parameter - so the endpoint answers 422 instead of receiving its session. +# Under ``from __future__ import annotations`` in the caller's module this is +# the only spelling that works. There is no import cycle: session_backend +# never imports deps at module level. +from redis_fastapi.session_backend import RedisSessionStore, SyncSessionStore +from redis_fastapi.sessions import Session + logger = logging.getLogger(__name__) # Type alias for async clients (standalone or cluster) @@ -47,6 +60,10 @@ class _PoolState: # and there is no import cycle: ratelimit_backend never imports deps. ratelimit_capabilities: _BackendCapabilities | None = None + # The same idea for the session store: HSETEX support is a property of the + # server, so it is discovered once per pool rather than on every request. + session_capabilities: _StoreCapabilities | None = None + # -- pool / cluster builders (static) ----------------------------------- @staticmethod @@ -106,6 +123,7 @@ def clear(self) -> None: """Reset cached clients (called during lifespan shutdown).""" self._async_client = None self.ratelimit_capabilities = None + self.session_capabilities = None def _get_pool_state(app: FastAPI) -> _PoolState: @@ -184,6 +202,45 @@ async def get_sync_rate_limit_backend(request: Request) -> SyncRateLimitBackend: return SyncRateLimitBackend(backend) +async def get_session_store(request: Request) -> RedisSessionStore: + """Return a :class:`RedisSessionStore` backed by the shared async pool. + + Built per request, but its server-capability cache lives on the pool + state, so ``HSETEX`` detection is paid once per process rather than + re-probed on every request. + """ + from redis_fastapi.session_backend import RedisSessionStore, _StoreCapabilities + + state = _get_pool_state(request.app) + if state.session_capabilities is None: + state.session_capabilities = _StoreCapabilities() + client = await get_async_redis(request) + return RedisSessionStore(client, capabilities=state.session_capabilities) + + +async def get_sync_session_store(request: Request) -> SyncSessionStore: + """Return a :class:`SyncSessionStore` for use in sync endpoints. + + The underlying async store is resolved on the event loop; the returned + wrapper bridges each call back via :func:`anyio.from_thread.run`. + """ + from redis_fastapi.session_backend import SyncSessionStore + + store = await get_session_store(request) + return SyncSessionStore(store) + + +async def get_session(request: Request) -> Session: + """Return the session the middleware already loaded for this request. + + Performs no I/O. The read happened before the application ran, because + ``request.session`` is a synchronous property and cannot await. + """ + from redis_fastapi.sessions import session_of + + return session_of(request) + + AsyncRedisDep = Annotated[AsyncClient, Depends(get_async_redis)] CacheBackendDep = Annotated["CacheBackend", Depends(get_cache_backend)] SyncCacheBackendDep = Annotated["SyncCacheBackend", Depends(get_sync_cache_backend)] @@ -191,3 +248,6 @@ async def get_sync_rate_limit_backend(request: Request) -> SyncRateLimitBackend: SyncRateLimitBackendDep = Annotated[ "SyncRateLimitBackend", Depends(get_sync_rate_limit_backend) ] +SessionStoreDep = Annotated[RedisSessionStore, Depends(get_session_store)] +SyncSessionStoreDep = Annotated[SyncSessionStore, Depends(get_sync_session_store)] +SessionDep = Annotated[Session, Depends(get_session)] diff --git a/src/redis_fastapi/session_backend.py b/src/redis_fastapi/session_backend.py new file mode 100644 index 0000000..8594510 --- /dev/null +++ b/src/redis_fastapi/session_backend.py @@ -0,0 +1,1140 @@ +"""Redis-backed session store. + +Two keys, both hashes with a time-to-live on each field:: + + redis:fastapi:session: field "a" = "1" TTL = absolute + field "d" = TTL = idle + redis:fastapi:sessions-of: field = + TTL = absolute + +Splitting the two clocks across two fields is what makes "a session outlives +its absolute deadline" unreachable rather than merely unlikely: Redis enforces +both deadlines and this module computes neither. Writing the payload touches +field ``d`` alone, so no number of writes can extend field ``a``. + +See ``docs/specs/session-design.md`` Sections 3 and 4. +""" + +from __future__ import annotations + +import logging +import secrets +import time +from abc import ABC, abstractmethod +from collections.abc import Callable +from dataclasses import dataclass +from datetime import timedelta +from typing import Any + +from redis.asyncio import Redis as AsyncRedis +from redis.asyncio.cluster import RedisCluster as AsyncRedisCluster +from redis.exceptions import RedisError + +from redis_fastapi.config import get_settings +from redis_fastapi.sessions import ( + Session, + SessionConfigurationError, + SessionStoreError, +) +from redis_fastapi.telemetry import ( + record_session_operation, + session_span, + timed_session, +) +from redis_fastapi.types import Coder, JsonCoder + +logger = logging.getLogger(__name__) + +# Hash field names. Two characters, and identical in every session key. +# Section 13.2: a uniform schema is what a future compact-hash encoding would +# reward, and a short name is fewer bytes on the wire meanwhile. +FIELD_ABSOLUTE = "a" +FIELD_DATA = "d" + +# The value of field ``a`` is never read - the field exists for its TTL alone. +_ABSOLUTE_MARKER = "1" + +# ``HTTL`` answers -2 for "no such field, or no such key" and -1 for "the field +# exists with no expiry". Naming them stops either being read as a duration. +TTL_NO_FIELD = -2 +TTL_NO_EXPIRY = -1 + +# Characters a session ID may contain: the alphabet of ``secrets.token_urlsafe``. +_ID_ALPHABET = frozenset( + "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_" +) +# 128 bits is the OWASP recommendation for a custom identifier; 32 bytes gives +# 256 and costs nothing. +_ID_BYTES = 32 +_ID_MIN_LENGTH = 22 + + +def _seconds(value: int | timedelta) -> int: + """Normalise a TTL setting to whole seconds. + + Accepts ``timedelta`` because ``cache()`` already does, and Section 9.4 of + ``session-mgmt.md`` requires it for the ``starsessions`` migration. + """ + if isinstance(value, timedelta): + return int(value.total_seconds()) + return int(value) + + +@dataclass(frozen=True) +class SessionMetadata: + """Timestamps kept beside the payload, never mixed into it. + + ``starsessions`` stores its metadata under a ``__metadata__`` key *inside* + the session, so it shows up in the application's own ``request.session``. + We keep it in a sibling slot of the envelope instead. The three names + match theirs, so their accessors port across as a rename. + """ + + created: float + last_access: float + lifetime: int + + +@dataclass(frozen=True) +class SessionRecord: + """What field ``d`` holds once decoded: the payload and its metadata.""" + + data: dict[str, Any] + metadata: SessionMetadata + + +@dataclass(frozen=True) +class SessionInfo: + """One row of a "you are signed in on these devices" listing. + + Built from the index alone, so listing a subject's sessions never reads the + session records themselves. + """ + + session_id: str + created: float + last_access: float + descriptor: dict[str, Any] + + +@dataclass +class _StoreCapabilities: + """Process-lifetime cache of server capability detection. + + Shared by every per-request store, in the same way + ``_BackendCapabilities`` is shared by the rate limiter, so the question is + asked once per pool rather than once per request. ``None`` means "not yet + known" and leaves detection to a later attempt rather than caching a guess. + + Attributes: + supports_hsetex: Whether the server has ``HSETEX``/``HGETEX``, which + arrived in Redis 8.0. Against 7.4 the store falls back to + ``HSET`` + ``HEXPIRE`` and ``HGET`` + ``HEXPIRE``, pipelined. That + costs one extra command, never a change in behaviour. + """ + + supports_hsetex: bool | None = None + + +async def probe_hsetex_support( + client: AsyncRedis | AsyncRedisCluster, +) -> bool | None: + """Ask the server whether it implements ``HSETEX``. + + Returns ``True`` when the server advertises the command, ``False`` when it + does not, and ``None`` when the question could not be answered, which + leaves detection to a later attempt. + + This mirrors :func:`~redis_fastapi.ratelimit_backend.probe_increx_support` + deliberately, including the reason it asks ``COMMAND INFO`` rather than + sending the command and reading the error: on a cluster, redis-py builds + its command map from the server's own ``COMMAND`` table and rejects an + unknown command client-side, so error text is not a usable signal. + + Unlike the ``INCREX`` case there is no correctness cliff behind this - both + paths write the same fields with the same expirations. + """ + try: + if isinstance(client, AsyncRedisCluster): + reply = await client.execute_command( + "COMMAND", "INFO", "HSETEX", target_nodes=AsyncRedisCluster.RANDOM + ) + else: + reply = await client.execute_command( # type: ignore[no-untyped-call] + "COMMAND", "INFO", "HSETEX" + ) + except TypeError: + # redis-py parses the COMMAND reply eagerly and indexes into a nil + # entry for a command the server does not know. That is the + # "unsupported" answer, not a broken probe. + return False + except (RedisError, OSError): + return None + if not reply: + return False + first = reply[0] if isinstance(reply, (list, tuple)) else reply + return first is not None + + +@dataclass(frozen=True) +class LoadedSession: + """A live session, plus the deadline Redis is counting down for it. + + ``absolute_remaining`` comes from ``HTTL`` on field ``a`` - the server's + own number, never one this process computed. Section 6 needs it to size + the cookie's ``max-age`` as ``min(idle, absolute_remaining)`` so the cookie + and the record cannot disagree. + """ + + record: SessionRecord + absolute_remaining: int + + +class SessionStore(ABC): + """Owns the session lifecycle; a subclass owns only the storage. + + The lifecycle is a security control, so it is written once here rather + than once per backend. A new backend implements the seven abstract + primitives at the bottom of this class and inherits the identifier rules, + the two-clock policy, the envelope format and the error handling. + + Section 7 of ``session-mgmt.md`` gives the reason this is an abstract base + class and not a bare protocol. ``SessionStoreProtocol`` describes the much + smaller surface the middleware calls, for a caller who wants no + inheritance. + """ + + def __init__( + self, + *, + coder: type[Coder] = JsonCoder, + idle_ttl: int | timedelta | None = None, + absolute_ttl: int | timedelta | None = None, + gc_ttl: int | timedelta | None = None, + id_factory: Callable[[], str] | None = None, + ) -> None: + settings = get_settings() + self._coder = coder + self._id_factory = id_factory + self._idle_ttl = _seconds( + idle_ttl if idle_ttl is not None else settings.session_idle_ttl + ) + self._absolute_ttl = _seconds( + absolute_ttl if absolute_ttl is not None else settings.session_absolute_ttl + ) + self._gc_ttl = _seconds( + gc_ttl if gc_ttl is not None else settings.session_gc_ttl + ) + for name, value in ( + ("session_idle_ttl", self._idle_ttl), + ("session_absolute_ttl", self._absolute_ttl), + ("session_gc_ttl", self._gc_ttl), + ): + if value < 0: + raise SessionConfigurationError( + f"{name} must not be negative, got {value}" + ) + if self._gc_ttl <= 0: + raise SessionConfigurationError( + "session_gc_ttl must be positive: it is the backstop that lets " + "Redis collect a key whose real deadline is unknown, and a " + "session must never be left without any expiry at all." + ) + + # -- the two clocks ------------------------------------------------------ + + @property + def idle_seconds(self) -> int: + """TTL for field ``d``. + + ``session_idle_ttl=0`` disables the idle clock, and the field then + falls back to ``gc_ttl`` rather than being left unexpiring. Section + 3.2: a field with no TTL breaks the meaning of ``HTTL``'s ``-2``. + """ + return self._idle_ttl or self._gc_ttl + + @property + def absolute_seconds(self) -> int: + """TTL for field ``a``, set once at creation and never refreshed. + + ``session_absolute_ttl=0`` disables the absolute clock, and the field + then falls back to ``gc_ttl`` for the same reason as above. + """ + return self._absolute_ttl or self._gc_ttl + + # -- identifiers --------------------------------------------------------- + + @staticmethod + def is_valid_id(value: str) -> bool: + """Whether *value* is shaped like an identifier this store issues. + + Applied to the incoming cookie as well as to whatever ``id_factory`` + returns. On the cookie this is not cosmetic: the value is written back + into a ``Set-Cookie`` header, so an unvalidated one is a + header-injection vector. + """ + return ( + isinstance(value, str) + and len(value) >= _ID_MIN_LENGTH + and not (set(value) - _ID_ALPHABET) + ) + + def new_id(self) -> str: + """Generate an identifier, and validate it before returning it. + + A seam that supplies a security-critical value has to be checked, not + trusted. A factory returning an identifier with a separator in it once + produced a key that collided with the index key; the prefixes now make + that collision structurally impossible, but a short or oddly-charactered + identifier is still a defect this package refuses rather than stores. + + Raises: + SessionConfigurationError: If a supplied ``id_factory`` returns a + value that is too short or uses characters outside + ``[A-Za-z0-9_-]``. + """ + if self._id_factory is None: + return secrets.token_urlsafe(_ID_BYTES) + value: str = self._id_factory() + if not self.is_valid_id(value): + raise SessionConfigurationError( + "id_factory returned an unusable session ID. It must be at " + f"least {_ID_MIN_LENGTH} characters of [A-Za-z0-9_-]; " + f"got {value!r}." + ) + return value + + # -- the envelope -------------------------------------------------------- + + def encode(self, record: SessionRecord) -> str: + """Serialize the envelope that field ``d`` holds. + + The metadata lives in a sibling slot ``m``, never inside the payload + ``d``, so it can never appear in the application's ``request.session``. + """ + encoded: str = self._coder.encode( + { + "m": { + "created": record.metadata.created, + "last_access": record.metadata.last_access, + "lifetime": record.metadata.lifetime, + }, + "d": record.data, + } + ) + return encoded + + def decode(self, raw: str | bytes) -> SessionRecord: + """Parse the envelope; raise :class:`SessionStoreError` if it is junk. + + A corrupted record is treated as unreadable rather than as an empty + session, because the two mean different things: the caller decides + whether to sign the user out or to fail the request. + """ + text = raw if isinstance(raw, str) else raw.decode() + try: + envelope = self._coder.decode(text) + meta = envelope["m"] + return SessionRecord( + data=dict(envelope["d"]), + metadata=SessionMetadata( + created=float(meta["created"]), + last_access=float(meta["last_access"]), + lifetime=int(meta["lifetime"]), + ), + ) + except Exception as exc: + raise SessionStoreError(f"Unreadable session record: {exc}") from exc + + def new_record(self, data: dict[str, Any]) -> SessionRecord: + """Build a record for a session that does not exist yet.""" + now = time.time() + return SessionRecord( + data=data, + metadata=SessionMetadata( + created=now, last_access=now, lifetime=self._absolute_ttl + ), + ) + + # -- lifecycle ----------------------------------------------------------- + + async def load( + self, session_id: str, *, refresh: bool = True + ) -> LoadedSession | None: + """Read a session, and restart its idle clock in the same round trip. + + Returns ``None`` for every way a session can fail to exist: never + created, idle-expired, past its absolute deadline, or revoked. The + caller cannot tell those apart, and must not: to an application they + are all "no session". + + *refresh* false makes this a plain read, for + ``session_refresh_on_load=False``. The idle clock then advances only + when the response writes. + + The two answers are read **as a pair**. Neither one is a sentinel on + its own: ``HTTL`` returns ``-2`` both for "this field expired" and for + "there is no such key", so reading it alone marks every session in a + deployment with no absolute limit as already dead. + + Raises: + SessionStoreError: If the record exists but cannot be decoded, or + if the read failed and ``session_fail_closed`` is set. + """ + if not self.is_valid_id(session_id): + return None + with session_span("session.load"), timed_session("load"): + try: + raw, absolute_ttl = await self._read( + session_id, refresh_idle=self.idle_seconds if refresh else None + ) + except (RedisError, OSError) as exc: + record_session_operation(operation="load", result="error") + self._read_failed(exc) + return None + + if absolute_ttl == TTL_NO_EXPIRY: + # Unreachable by construction: every write gives field "a" a TTL. + # Reaching it means something wrote the key outside this store, so + # say so loudly and treat the session as absent rather than guess. + logger.error( + "Session key has a field 'a' with no expiry, which this store " + "never writes. Treating the session as absent. Key was " + "written by something else, or by an older version." + ) + await self._safe_delete(session_id) + return None + + if raw is None or absolute_ttl <= 0: + # Rows two and four of the state table: a half-dead key, alive on + # one clock and dead on the other. Delete it so the index entry can + # follow, rather than leaving a candidate that every later + # verification has to reject. Row three - dead on both - is simply + # absent, and there is nothing to remove. + if raw is not None or absolute_ttl > 0: + await self._safe_delete(session_id) + record_session_operation( + operation="load", + result="expired" if raw is not None or absolute_ttl > 0 else "miss", + ) + return None + + record_session_operation(operation="load", result="hit") + return LoadedSession(record=self.decode(raw), absolute_remaining=absolute_ttl) + + async def save(self, session_id: str, record: SessionRecord) -> None: + """Write the payload, leaving the absolute deadline untouched. + + Field ``a`` is written **if it does not already exist**, so one call + covers both creating a session and updating one, and no number of + updates can extend the absolute deadline. That conditional write is + the whole of N-6: outliving the deadline is not a bug to avoid here, + it is unreachable. + + Raises: + SessionStoreError: On any store failure. A write never fails + quietly, whatever ``session_fail_closed`` says - losing a login + or a rotation is the worst outcome in this design. + """ + with session_span("session.save"), timed_session("save"): + try: + await self._write( + session_id, + self.encode(record), + idle=self.idle_seconds, + absolute=self.absolute_seconds, + ) + except (RedisError, OSError) as exc: + record_session_operation(operation="save", result="error") + raise SessionStoreError(f"Could not save session: {exc}") from exc + record_session_operation(operation="save", result="hit") + + async def touch(self, session_id: str) -> None: + """Restart the idle clock without rewriting the payload. + + Only needed under ``session_refresh_on_load=False``; the default load + already refreshed in the same round trip as the read. + """ + try: + await self._expire(session_id, self.idle_seconds) + except (RedisError, OSError) as exc: + raise SessionStoreError(f"Could not refresh session: {exc}") from exc + + async def delete(self, session_id: str) -> None: + """Remove a session outright. + + Raises: + SessionStoreError: On any store failure. Deletion is revocation, + so a failure here must reach the caller. + """ + try: + await self._delete(session_id) + except (RedisError, OSError) as exc: + raise SessionStoreError(f"Could not delete session: {exc}") from exc + + # -- rotation and revocation --------------------------------------------- + + def session_id(self, session: Session) -> str | None: + """The identifier this session is stored under, if it has one yet. + + ``None`` for a session that has never been written. Useful for + marking "this device" in a listing. + """ + return session.sid + + async def rotate( + self, + session: Session, + *, + subject: str | None = None, + descriptor: dict[str, Any] | None = None, + ) -> str: + """Issue a new identifier for *session*, deleting the old key first. + + This is the defence against session fixation, and the **middleware + normally drives it** when the principal changes (Section 5.1). It is + public for the rare handler that must force one - a step-up that + leaves no trace in session state. + + **The order is the security control, not an implementation detail.** + The old key and its index entry go first, so an interrupted rotation + signs the user out rather than leaving two identifiers that both work. + Signed out is recoverable; two live identifiers after a privilege + change is the fixation this design exists to prevent. + + The new session starts a fresh absolute clock, which is correct: + rotation follows authentication or a privilege change, so the deadline + should run from that moment rather than from whenever the anonymous + session began. + + Returns: + The new session ID. + + Raises: + SessionStoreError: On any store failure. + """ + old_id = session.sid + if old_id is not None: + await self.delete(old_id) + if subject is not None: + await self._index_drop(subject, old_id) + + new_id = self.new_id() + record = self.new_record(session.raw()) + await self.save(new_id, record) + session.sid = new_id + # Cleared, not set. ``rotated`` means "the middleware still owes this + # session a rotation"; we have just performed one. Leaving it set made + # the middleware rotate a second time at response start, which threw + # away the key written here and turned the ID returned to the caller + # into a stale value. The middleware notices the new ID by comparing it + # with the one it loaded, so the cookie still goes out. + session.rotated = False + session.revoked = False + if subject is not None: + await self.index( + subject, new_id, record, absolute_remaining=self.absolute_seconds + ) + # The runtime backstop for a misconfigured principal resolver: sign-ins + # with no rotations is a visible anomaly on a dashboard, and a silent + # misconfiguration is the one genuine cost of detecting rather than + # being told. + record_session_operation(operation="rotate", result="hit") + return new_id + + async def reauthenticate(self, session: Session) -> None: + """Force a rotation on the way out of this request. + + For a privilege change the principal cannot see - an impersonation + that leaves no trace in session state, or a step-up before a sensitive + action. The middleware performs the rotation at + ``http.response.start`` so it lands in the same response as the new + cookie. + """ + session.rotated = True + session.mark_modified() + + async def revoke(self, session: Session) -> None: + """End the session in hand and clear its cookie. + + Safe to call on a session that was never written. + """ + old_id = session.sid + if old_id is not None: + await self.delete(old_id) + session.clear() + session.sid = None + session.revoked = True + session.rotated = False + + async def revoke_id(self, session_id: str, *, subject: str) -> bool: + """End one session by ID, and refuse an ID not indexed under *subject*. + + The subject is **required** and checked, so a caller holding an + arbitrary identifier cannot end a stranger's session. Without it this + method would be a cross-user revocation primitive reachable from any + handler that takes an ID from a request. + + Returns: + True if a session was ended, False if that ID is not this + subject's - including when it has already expired. + """ + if not self.is_valid_id(session_id): + return False + try: + members = await self._index_members(subject) + except (RedisError, OSError) as exc: + raise SessionStoreError(f"Could not read the session index: {exc}") from exc + if session_id not in members: + return False + await self.delete(session_id) + await self._index_drop(subject, session_id) + return True + + async def revoke_all(self, subject: str) -> int: + """End every session belonging to *subject*. + + Returns: + How many session keys were removed. The index is an upper bound, + so this can be lower than the number of entries it held - the + difference is sessions that had already died. + """ + try: + members = await self._index_members(subject) + removed = 0 + for session_id in members: + await self._delete(session_id) + removed += 1 + for session_id in members: + await self._index_remove(subject, session_id) + except (RedisError, OSError) as exc: + record_session_operation(operation="revoke_all", result="error") + raise SessionStoreError(f"Could not revoke sessions: {exc}") from exc + record_session_operation(operation="revoke_all", result="hit") + return removed + + # -- the reverse lookup --------------------------------------------------- + + async def index( + self, + subject: str, + session_id: str, + record: SessionRecord, + *, + absolute_remaining: int, + ) -> None: + """Record a session under its subject, expiring with it. + + Re-asserted on every write rather than only at login, which is what + repairs an entry a partial failure lost. The entry takes the + **remaining** absolute time, never a fresh lifetime: a relative full + lifetime restarts the entry's clock on every write and lets the index + outlive the session it points at. + """ + descriptor = self._coder.encode( + { + "c": record.metadata.created, + "l": record.metadata.last_access, + "d": record.data.get("__descriptor__", {}), + } + ) + try: + await self._index_add(subject, session_id, descriptor, absolute_remaining) + except (RedisError, OSError) as exc: + raise SessionStoreError(f"Could not index the session: {exc}") from exc + + async def _index_drop(self, subject: str, session_id: str) -> None: + """Remove one index entry, wrapping driver errors.""" + try: + await self._index_remove(subject, session_id) + except (RedisError, OSError) as exc: + raise SessionStoreError( + f"Could not update the session index: {exc}" + ) from exc + + async def list_for_subject(self, subject: str) -> list[SessionInfo]: + """Live sessions for *subject*, verified before they are reported. + + **The index is an upper bound.** Most sessions die of idleness long + before their absolute deadline, and the entry's TTL is the absolute + deadline, so an entry routinely outlives the session it names. + Reporting one would show a user a device they are no longer signed in + on, and offer them a "sign out" button that does nothing. + + So every candidate is checked against its session key, and dead + entries are pruned on the way past. Two round trips whatever the + session count: one to read the index, one to verify the batch. + """ + try: + members = await self._index_members(subject) + except (RedisError, OSError) as exc: + self._read_failed(exc) + return [] + if not members: + return [] + + live = await self._verify(list(members)) + infos: list[SessionInfo] = [] + dead: list[str] = [] + for session_id, raw in members.items(): + if session_id not in live: + dead.append(session_id) + continue + infos.append(self._to_info(session_id, raw)) + for session_id in dead: + # Best-effort tidy-up on a read path: failing a device listing + # because we could not prune a stale row helps nobody. + try: + await self._index_remove(subject, session_id) + except (RedisError, OSError) as exc: + logger.warning("Could not prune a dead index entry: %s", exc) + infos.sort(key=lambda info: info.last_access, reverse=True) + return infos + + async def count_for_subject(self, subject: str, *, limit: int | None = None) -> int: + """How many sessions *subject* has, as an upper bound by default. + + Counting the index is ``O(1)`` and needs no verification, which is what + makes a "cap concurrent sessions" check cheap on every login. Pass + *limit* to make the count exact **only when it matters**: below the + limit the fast answer is returned, and the verification round trip is + paid solely by the request that is about to be refused. + """ + try: + members = await self._index_members(subject) + except (RedisError, OSError) as exc: + self._read_failed(exc) + return 0 + upper = len(members) + if limit is None or upper < limit: + return upper + return len(await self._verify(list(members))) + + async def _verify(self, session_ids: list[str]) -> set[str]: + """Return the subset of *session_ids* whose sessions are still alive. + + One batch, not one call per session, so a subject with fifty devices + costs the same round trip as one with two. + """ + try: + return await self._alive(session_ids) + except (RedisError, OSError) as exc: + logger.warning("Could not verify session liveness: %s", exc) + return set() + + def _to_info(self, session_id: str, raw: bytes | str) -> SessionInfo: + """Build a listing row from an index descriptor alone. + + Never reads the session record: that is the point of storing the + descriptor beside the identifier. + """ + text = raw if isinstance(raw, str) else raw.decode() + try: + payload = self._coder.decode(text) + return SessionInfo( + session_id=session_id, + created=float(payload.get("c", 0.0)), + last_access=float(payload.get("l", 0.0)), + descriptor=dict(payload.get("d", {})), + ) + except Exception: + # A descriptor is display data. A malformed one must not hide a + # live session from the user who is trying to sign it out. + logger.warning("Unreadable index descriptor for a live session") + return SessionInfo( + session_id=session_id, created=0.0, last_access=0.0, descriptor={} + ) + + @abstractmethod + async def _alive(self, session_ids: list[str]) -> set[str]: + """Return which of *session_ids* still have a live session.""" + + # -- failure policy ------------------------------------------------------ + + def _read_failed(self, exc: BaseException) -> None: + """Apply the read half of the fail-open / fail-closed policy. + + The default is asymmetric on purpose. A failed read yields no session, + so the caller looks anonymous and the application's own authorization + dependency returns a login page or a 401 - a protected route stays + protected, because it never depended on this read succeeding. + + Raises: + SessionStoreError: If ``session_fail_closed`` is set, for a + deployment that would rather return 503 than an anonymous page. + """ + if get_settings().session_fail_closed: + raise SessionStoreError(f"Could not load session: {exc}") from exc + logger.warning("Session load failed, continuing without a session: %s", exc) + + async def _safe_delete(self, session_id: str) -> None: + """Delete a key we already know is dead; never raise for it. + + This runs on the tidy-up path of a *read*. Failing the request because + we could not clean up a session that is already gone would turn a + cosmetic problem into an outage. + """ + try: + await self._delete(session_id) + except (RedisError, OSError) as exc: + logger.warning("Could not remove a dead session key: %s", exc) + + # -- the whole surface a new backend implements -------------------------- + + @abstractmethod + async def _read( + self, session_id: str, *, refresh_idle: int | None + ) -> tuple[bytes | str | None, int]: + """Return ``(payload or None, seconds left on the absolute clock)``. + + *refresh_idle* is the idle window in seconds, or ``None`` to read + without refreshing. A backend that can do both in one round trip + should; one that cannot may take two. + """ + + @abstractmethod + async def _write( + self, session_id: str, payload: str, *, idle: int, absolute: int + ) -> None: + """Write the payload with an idle TTL. + + Must give the absolute deadline its TTL **only when it does not + already exist**, so that repeated writes cannot extend it. + """ + + @abstractmethod + async def _expire(self, session_id: str, idle: int) -> None: + """Restart the idle clock, leaving the payload and the deadline alone.""" + + @abstractmethod + async def _delete(self, session_id: str) -> None: + """Remove the session entirely.""" + + @abstractmethod + async def _index_add( + self, subject: str, session_id: str, descriptor: str, absolute_remaining: int + ) -> None: + """Record *session_id* under *subject*, expiring with the session. + + *absolute_remaining* is what is left of the session's absolute clock, + never the full lifetime: a full lifetime here would restart the entry's + clock on every write and let the index outlive the session it points at. + """ + + @abstractmethod + async def _index_remove(self, subject: str, session_id: str) -> None: + """Drop one session from a subject's index.""" + + @abstractmethod + async def _index_members(self, subject: str) -> dict[str, bytes | str]: + """Return ``{session_id: descriptor}`` for a subject. + + An **upper bound**: entries can name sessions that have since died, so + a caller must verify before reporting them. + """ + + +class RedisSessionStore(SessionStore): + """The Redis implementation of the seven storage primitives. + + Everything here is one pipelined round trip per operation. Both keys are + flat, with no hash tag, for the reason ``ratelimit_backend.py`` already + gives for rate-limit keys: a hash tag would send every session of one + deployment to a single Cluster slot and create a hot shard. Nothing in + this class is a multi-key command, so nothing needs co-location. + """ + + def __init__( + self, + redis: AsyncRedis | AsyncRedisCluster, + *, + capabilities: _StoreCapabilities | None = None, + key_prefix: str | None = None, + **kwargs: Any, + ) -> None: + super().__init__(**kwargs) + self._redis = redis + self._caps = capabilities if capabilities is not None else _StoreCapabilities() + settings = get_settings() + base = key_prefix if key_prefix is not None else settings.prefix + self._session_prefix = f"{base}:session:" + self._index_prefix = f"{base}:sessions-of:" + + # -- keys ---------------------------------------------------------------- + + def session_key(self, session_id: str) -> str: + """``:session:``.""" + return f"{self._session_prefix}{session_id}" + + def index_key(self, subject: str) -> str: + """``:sessions-of:``. + + A different prefix from :meth:`session_key`, not a nested one. The two + strings first differ at the character after ``session``, before either + key's variable part begins, so no session ID - however exotic - can + produce the index key. An earlier layout nested them and rested on + ``token_urlsafe`` never emitting a ``:``, which stopped being true the + moment ``id_factory`` became a supported seam. + """ + return f"{self._index_prefix}{subject}" + + # -- capability ---------------------------------------------------------- + + async def _has_hsetex(self) -> bool: + """Whether to use the 8.0 commands. Probed once, then cached.""" + if self._caps.supports_hsetex is None: + self._caps.supports_hsetex = await probe_hsetex_support(self._redis) + # A probe that could not reach the server answers None. Take the + # 7.4 path: it works everywhere, so an unknown answer costs a command + # rather than an error. + return bool(self._caps.supports_hsetex) + + # -- the primitives ------------------------------------------------------ + + async def _read( + self, session_id: str, *, refresh_idle: int | None + ) -> tuple[bytes | str | None, int]: + key = self.session_key(session_id) + pipe = self._redis.pipeline(transaction=False) + if refresh_idle is None: + pipe.execute_command("HGET", key, FIELD_DATA) + reads = 1 + elif await self._has_hsetex(): + # HGETEX reads the field and sets its expiration in one command, + # so the load *is* the idle refresh: no second command at response + # time, and nothing for a refresh threshold to optimise away. + pipe.execute_command( + "HGETEX", key, "EX", refresh_idle, "FIELDS", 1, FIELD_DATA + ) + reads = 1 + else: + pipe.execute_command("HGET", key, FIELD_DATA) + pipe.execute_command("HEXPIRE", key, refresh_idle, "FIELDS", 1, FIELD_DATA) + reads = 2 + pipe.execute_command("HTTL", key, "FIELDS", 1, FIELD_ABSOLUTE) + replies = await pipe.execute() + return _first(replies[0]), _ttl(replies[reads]) + + async def _write( + self, session_id: str, payload: str, *, idle: int, absolute: int + ) -> None: + key = self.session_key(session_id) + pipe = self._redis.pipeline(transaction=False) + if await self._has_hsetex(): + # FNX: set only if the field does not already exist. On an update + # the whole command is a no-op, so field "a" keeps the deadline it + # was created with. + pipe.execute_command( + "HSETEX", + key, + "FNX", + "EX", + absolute, + "FIELDS", + 1, + FIELD_ABSOLUTE, + _ABSOLUTE_MARKER, + ) + pipe.execute_command( + "HSETEX", key, "EX", idle, "FIELDS", 1, FIELD_DATA, payload + ) + else: + # The 7.4 spelling of the same two writes. HSETNX will not touch an + # existing field, and HEXPIRE's NX only sets an expiry on a field + # that has none - which, given the line above, is only ever a field + # this call just created. + pipe.execute_command("HSETNX", key, FIELD_ABSOLUTE, _ABSOLUTE_MARKER) + pipe.execute_command( + "HEXPIRE", key, absolute, "NX", "FIELDS", 1, FIELD_ABSOLUTE + ) + pipe.execute_command("HSET", key, FIELD_DATA, payload) + pipe.execute_command("HEXPIRE", key, idle, "FIELDS", 1, FIELD_DATA) + await pipe.execute() + + async def _expire(self, session_id: str, idle: int) -> None: + await self._redis.execute_command( + "HEXPIRE", self.session_key(session_id), idle, "FIELDS", 1, FIELD_DATA + ) + + async def _delete(self, session_id: str) -> None: + await self._redis.delete(self.session_key(session_id)) + + async def _index_add( + self, subject: str, session_id: str, descriptor: str, absolute_remaining: int + ) -> None: + key = self.index_key(subject) + if absolute_remaining <= 0: + # Nothing to record: the session it would point at is already gone. + return + if await self._has_hsetex(): + await self._redis.execute_command( + "HSETEX", + key, + "EX", + absolute_remaining, + "FIELDS", + 1, + session_id, + descriptor, + ) + return + pipe = self._redis.pipeline(transaction=False) + pipe.execute_command("HSET", key, session_id, descriptor) + pipe.execute_command( + "HEXPIRE", key, absolute_remaining, "FIELDS", 1, session_id + ) + await pipe.execute() + + async def _index_remove(self, subject: str, session_id: str) -> None: + await self._redis.hdel(self.index_key(subject), session_id) + + async def _index_members(self, subject: str) -> dict[str, bytes | str]: + raw = await self._redis.hgetall(self.index_key(subject)) + return {(k.decode() if isinstance(k, bytes) else k): v for k, v in raw.items()} + + async def _alive(self, session_ids: list[str]) -> set[str]: + """One pipelined ``HTTL`` per candidate, sent as a single batch. + + **Both clocks are checked, not one.** A session is alive only while + field ``d`` and field ``a`` both have time left, and the two die for + different reasons: ``d`` when the user goes idle, ``a`` when the + absolute deadline passes however active they were. + + Asking about ``d`` alone would report a session past its absolute + deadline as live, because ``d`` may have been refreshed minutes ago and + still hold most of the idle window. It is tempting to argue that the + case cannot arise - the index entry carries the same absolute deadline + as field ``a``, so it should expire at the same moment and never become + a candidate. That is true today and it is not a guarantee: it holds + only while every write re-asserts the entry with the *remaining* + absolute time, which is one refactor away from being wrong. Asking + about both fields costs nothing and does not depend on the argument. + """ + if not session_ids: + return set() + pipe = self._redis.pipeline(transaction=False) + for session_id in session_ids: + pipe.execute_command( + "HTTL", + self.session_key(session_id), + "FIELDS", + 2, + FIELD_DATA, + FIELD_ABSOLUTE, + ) + replies = await pipe.execute() + return { + session_id + for session_id, reply in zip(session_ids, replies, strict=True) + if _all_ttls_positive(reply) + } + + +def _first(reply: Any) -> bytes | str | None: + """Unwrap a one-element array reply. + + ``HGETEX`` answers with an array even for a single field, while ``HGET`` + answers with the value itself. Both paths land here. + """ + if isinstance(reply, (list, tuple)): + first = reply[0] if reply else None + else: + first = reply + if first is None or isinstance(first, (bytes, str)): + return first + return str(first) + + +def _all_ttls_positive(reply: Any) -> bool: + """Whether every TTL in a multi-field ``HTTL`` reply has time left. + + An empty or malformed reply reads as "not alive": the safe answer for a + liveness check is to omit the session rather than to report one that may + already be gone. + """ + if not isinstance(reply, (list, tuple)) or not reply: + return False + return all(value is not None and int(value) > 0 for value in reply) + + +def _ttl(reply: Any) -> int: + """Unwrap ``HTTL``'s one-element array reply into an int.""" + value = reply[0] if isinstance(reply, (list, tuple)) and reply else reply + if value is None: + return TTL_NO_FIELD + return int(value) + + +class SyncSessionStore: + """Synchronous facade over :class:`SessionStore`. + + Every method delegates to the async store via + :func:`anyio.from_thread.run`, mirroring ``SyncCacheBackend`` and + ``SyncRateLimitBackend``. Only usable from FastAPI-managed worker threads + - a plain ``def`` endpoint or a ``def`` dependency. Calling it from the + main thread raises ``RuntimeError``. + + Only the methods a handler calls are exposed. The lifecycle + (``load``/``save``/``touch``) belongs to the middleware, which is async + and needs no bridge. + """ + + def __init__(self, store: SessionStore) -> None: + self._store = store + + @staticmethod + def _run(func: Any) -> Any: + import anyio.from_thread + + return anyio.from_thread.run(func) + + def session_id(self, session: Session) -> str | None: + """The identifier this session is stored under, if any (no I/O).""" + return self._store.session_id(session) + + def rotate( + self, + session: Session, + *, + subject: str | None = None, + descriptor: dict[str, Any] | None = None, + ) -> str: + """Issue a new identifier, deleting the old key first (blocking).""" + result: str = self._run( + lambda: self._store.rotate(session, subject=subject, descriptor=descriptor) + ) + return result + + def reauthenticate(self, session: Session) -> None: + """Force a rotation on the way out of this request (blocking).""" + self._run(lambda: self._store.reauthenticate(session)) + + def revoke(self, session: Session) -> None: + """End the session in hand and clear its cookie (blocking).""" + self._run(lambda: self._store.revoke(session)) + + def revoke_id(self, session_id: str, *, subject: str) -> bool: + """End one session by ID, scoped to *subject* (blocking).""" + result: bool = self._run( + lambda: self._store.revoke_id(session_id, subject=subject) + ) + return result + + def revoke_all(self, subject: str) -> int: + """End every session belonging to *subject* (blocking).""" + result: int = self._run(lambda: self._store.revoke_all(subject)) + return result + + def list_for_subject(self, subject: str) -> list[SessionInfo]: + """Live, verified sessions for *subject* (blocking).""" + result: list[SessionInfo] = self._run( + lambda: self._store.list_for_subject(subject) + ) + return result + + def count_for_subject(self, subject: str, *, limit: int | None = None) -> int: + """How many sessions *subject* has (blocking).""" + result: int = self._run( + lambda: self._store.count_for_subject(subject, limit=limit) + ) + return result diff --git a/src/redis_fastapi/session_events.py b/src/redis_fastapi/session_events.py new file mode 100644 index 0000000..4cd34ce --- /dev/null +++ b/src/redis_fastapi/session_events.py @@ -0,0 +1,278 @@ +"""Real-time session-end events, driven by Redis keyspace notifications. + +A session store knows when a session dies. Every other backend discovers it +on the next request, because a row that expired quietly tells nobody. Redis +can say so as it happens, and this module turns that into an application +callback - closing a WebSocket the moment a user is signed out being the case +that pays for it. + +**Two properties matter more than the feature itself.** + +*Nothing depends on it.* Pub/Sub is fire-and-forget, events sent while no +subscriber is connected are lost, and an expiry event fires when Redis removes +the field rather than when the deadline passed. So the index still prunes +itself through field expiry and the load-time state table is still the +authority on whether a session is alive. Every guarantee in the store holds +with this module switched off, which is why it is off by default and why it is +the last thing to build. + +*It degrades to silence.* On a server that cannot supply the events, handlers +are registered and never called. That is a deliberate choice with a sharp +edge: a revocation handler that never fires looks exactly like one that works. +An application that closes sockets on this signal and nothing else will hold +them open after a sign-out on such a server. The guide must say so; this +module logs one warning at startup and does nothing further about it. + +See ``docs/specs/session-design.md`` Section 13.4. +""" + +from __future__ import annotations + +import asyncio +import contextlib +import logging +from collections.abc import Awaitable, Callable +from typing import Any, Literal + +from redis.asyncio import Redis as AsyncRedis +from redis.asyncio.cluster import RedisCluster as AsyncRedisCluster +from redis.exceptions import RedisError + +from redis_fastapi.session_backend import FIELD_ABSOLUTE, FIELD_DATA +from redis_fastapi.telemetry import record_session_event + +logger = logging.getLogger(__name__) + +Tier = Literal["none", "field"] +Cause = Literal["idle", "absolute", "revoked"] +Handler = Callable[[str, Cause], Awaitable[None]] + +# Subkey notifications arrived in Redis 8.8. There is no key-level tier here +# on purpose: our session key has no key-level TTL - it dies as a side effect +# of its last field expiring - so a key-level event carries no field name and +# cannot separate an idle death from an absolute one. That is most of what a +# subscriber wants to know, so the ladder has two rungs, not three. +_MIN_VERSION = (8, 8) + +# The channel that names both the key and the field. +_CHANNEL = "__subkeyevent@{db}__:hexpired" + +# Flags that must be present in ``notify-keyspace-events``. The four subkey +# channels are S, T, I and V, and they are **independent of K and E** - +# enabling standard keyspace notifications does not enable these, and the +# reverse holds too. This is the commonest configuration mistake. +_SUBKEY_FLAGS = frozenset("STIV") +_HASH_FLAG = "h" + +REQUIRED_CONFIG = "Th" + + +class SessionEvents: + """Calls registered handlers when a session ends. + + Build one in the lifespan, register handlers on it, and let it run for the + life of the process:: + + events = SessionEvents(redis, key_prefix="redis:fastapi") + + @events.on_session_end + async def _(session_id: str, cause: str) -> None: + await close_sockets_for(session_id) + + await events.start() + ... + await events.stop() + + Attributes: + tier: ``"field"`` when the server can deliver events, ``"none"`` when + it cannot. Read it to decide whether a handler will ever run. + """ + + def __init__( + self, + redis: AsyncRedis | AsyncRedisCluster, + *, + key_prefix: str, + db: int = 0, + ) -> None: + self._redis = redis + self._session_prefix = f"{key_prefix}:session:" + self._db = db + self._handlers: list[Handler] = [] + self._task: asyncio.Task[None] | None = None + self.tier: Tier = "none" + + def on_session_end(self, handler: Handler) -> Handler: + """Register *handler*, and return it so this works as a decorator. + + The handler receives the session ID and the cause: ``"idle"`` when the + user went quiet, ``"absolute"`` when the deadline passed however + active they were. Distinguishing the two is the whole reason this + needs Redis 8.8. + """ + self._handlers.append(handler) + return handler + + # -- capability ---------------------------------------------------------- + + async def probe(self) -> Tier: + """Decide which tier this server can supply. Asked once, at startup. + + Two separate questions, and both can fail: + + 1. *Can the server do it?* ``INFO server`` for the version. + 2. *Is it switched on?* ``CONFIG GET notify-keyspace-events``. + + **A failure to ask is an answer.** Managed Redis commonly restricts, + renames or forbids ``CONFIG``, and an ACL without ``@admin`` does the + same. Any error here yields ``"none"`` rather than propagating, so a + locked-down instance degrades instead of breaking startup. + """ + try: + if not await self._version_ok(): + return "none" + return "field" if await self._config_ok() else "none" + except (RedisError, OSError) as exc: + logger.info("Could not probe session-event support: %s", exc) + return "none" + + async def _version_ok(self) -> bool: + info = await self._redis.info("server") + raw = str(info.get("redis_version", "0.0.0")) + parts: list[int] = [] + for chunk in raw.split(".")[:2]: + digits = "".join(c for c in chunk if c.isdigit()) + parts.append(int(digits) if digits else 0) + while len(parts) < 2: + parts.append(0) + if tuple(parts) < _MIN_VERSION: + logger.warning( + "Session events need Redis %d.%d or later for hash subkey " + "notifications; this server reports %s. Handlers will not " + "fire. Everything else works unchanged.", + _MIN_VERSION[0], + _MIN_VERSION[1], + raw, + ) + return False + return True + + async def _config_ok(self) -> bool: + config = await self._redis.config_get("notify-keyspace-events") + flags = str(config.get("notify-keyspace-events", "")) + if not (set(flags) & _SUBKEY_FLAGS) or _HASH_FLAG not in flags: + logger.warning( + "Session events need notify-keyspace-events to include '%s' " + "(a subkey channel plus hash events); this server has %r. " + "Handlers will not fire. Note that the subkey flags S/T/I/V " + "are independent of K and E. This library will not set the " + "option for you: it is server-wide and affects every other " + "application on the instance.", + REQUIRED_CONFIG, + flags, + ) + return False + return True + + # -- lifecycle ----------------------------------------------------------- + + async def start(self) -> None: + """Probe, and subscribe when the server can supply events. + + Never raises. When the tier is ``"none"`` this returns having done + nothing but log, and the handlers stay registered and silent. + """ + if self._task is not None: + return + self.tier = await self.probe() + if self.tier == "none": + return + self._task = asyncio.create_task(self._run()) + + async def stop(self) -> None: + """Cancel the subscriber and wait for it to finish.""" + task, self._task = self._task, None + if task is None: + return + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task + + async def _run(self) -> None: + """Subscribe and dispatch until cancelled. + + On a cluster this covers one node only. Keyspace events are + node-local and are **not** broadcast, so a full deployment needs one + subscriber per node - which the caller composes, because only the + caller knows the topology. + """ + channel = _CHANNEL.format(db=self._db) + try: + pubsub = self._redis.pubsub() + await pubsub.subscribe(channel) + async for message in pubsub.listen(): + if message.get("type") != "message": + continue + await self._dispatch(message.get("data")) + except asyncio.CancelledError: + raise + except (RedisError, OSError) as exc: + # Losing the subscription is not an application error. Say so once + # and stop; nothing downstream depends on this stream. + logger.warning("Session event subscription ended: %s", exc) + + async def _dispatch(self, data: Any) -> None: + parsed = self._parse(data) + if parsed is None: + return + session_id, cause = parsed + for handler in self._handlers: + try: + await handler(session_id, cause) + except Exception: + # One bad handler must not take down the subscriber and with + # it every other handler. + logger.exception("A session-end handler raised") + record_session_event(cause=cause, result="dropped") + else: + record_session_event(cause=cause, result="delivered") + + def _parse(self, data: Any) -> tuple[str, Cause] | None: + """Pull the session ID and the cause out of one notification. + + The payload is ``:|:[,...]`` - length + prefixed so a key or field containing the delimiters stays parseable. + Anything that does not match this store's key prefix is another + application's hash and is ignored. + """ + text = data.decode() if isinstance(data, bytes) else str(data) + key_part, _, field_part = text.partition("|") + key = _strip_length(key_part) + if key is None or not key.startswith(self._session_prefix): + return None + session_id = key[len(self._session_prefix) :] + + fields = { + stripped + for chunk in field_part.split(",") + if (stripped := _strip_length(chunk)) is not None + } + # Both fields expiring at once is the absolute deadline arriving: the + # idle clock is the shorter one, so it only ever expires alone. + if FIELD_ABSOLUTE in fields: + return session_id, "absolute" + if FIELD_DATA in fields: + return session_id, "idle" + return None + + +def _strip_length(chunk: str) -> str | None: + """Turn ``"7:field1"`` into ``"field1"``. + + Returns ``None`` for a chunk that carries no length prefix, which means + the payload is not the shape this version of Redis documents. + """ + length, sep, value = chunk.partition(":") + if not sep or not length.isdigit(): + return None + return value diff --git a/src/redis_fastapi/sessions.py b/src/redis_fastapi/sessions.py new file mode 100644 index 0000000..1290f7c --- /dev/null +++ b/src/redis_fastapi/sessions.py @@ -0,0 +1,677 @@ +"""Server-side sessions for FastAPI and Starlette. + +The session record lives in Redis; the cookie carries an opaque identifier and +nothing else. See ``docs/specs/session-design.md`` for the full design. + +This module holds the request-facing half of the feature: the :class:`Session` +mapping the application sees as ``request.session``, the middleware that loads +and saves it, and the exception hierarchy. The Redis half lives in +``session_backend.py``. +""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable, Iterable, Mapping +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any + +from fastapi import FastAPI +from starlette.requests import Request +from starlette.types import ASGIApp, Message, Receive, Scope, Send + +from redis_fastapi.config import get_settings + +if TYPE_CHECKING: + from redis_fastapi.session_backend import SessionStore + +# Sentinel for ``pop``/``setdefault`` so that ``None`` stays a usable default. +_MISSING: Any = object() + + +class SessionError(Exception): + """Base for every error this feature raises. + + Catching this catches the whole feature. A ``redis.RedisError`` never + reaches application code - the store wraps it in + :class:`SessionStoreError`. + """ + + +class SessionConfigurationError(SessionError): + """A session setting is missing, invalid, or contradicts another one.""" + + +class SessionStoreError(SessionError): + """The store could not complete an operation. + + Raised for every failed **write**, whatever ``session_fail_closed`` is set + to, because losing a login or a rotation must never be silent. Failed + reads raise this only when ``session_fail_closed`` is true; otherwise they + yield an empty session. Section 7 of the design explains the asymmetry. + """ + + +class Session(dict): # type: ignore[type-arg] + """The mapping an application sees as ``request.session``. + + A ``dict`` subclass that tracks two flags so the middleware knows what to + do on the way out: + + * ``accessed`` - the application read the session. Drives ``Vary: Cookie`` + and, under ``session_refresh_on_load=False``, the idle-clock refresh. + * ``modified`` - the application changed it. Drives the write. + + ``mark_accessed`` carries exactly that name so Starlette's own + ``hasattr(session, "mark_accessed")`` hook finds it and calls it. + + Beyond the methods Starlette's session overrides, this class also overrides + ``popitem``, ``__ior__`` and ``pop`` - each of which loses a mutation + upstream. See the table in Section 8 of the design. + + **One fault is unfixable here.** A change inside a nested value, + ``session["a"]["b"] = 1``, reaches no method of this class and sets no + flag. No ``dict`` subclass in any language can see it. Reassign the + top-level key, call ``save()``, or set ``session_always_save=True``. + """ + + __slots__ = ("accessed", "modified", "sid", "revoked", "rotated") + + def __init__(self, *args: Any, **kwargs: Any) -> None: + super().__init__(*args, **kwargs) + # Construction is the middleware populating us from Redis, not the + # application touching anything, so both flags start clean. + self.accessed = False + self.modified = False + # Set by the middleware after a load, and by the store after a + # rotation. ``None`` means this session has never been written, so + # there is no key to delete and no cookie to replace. + self.sid: str | None = None + # The store sets these; the middleware acts on them at + # ``http.response.start``. They are the only channel between the two + # halves of the feature, so they are attributes rather than a side + # table keyed on the request. + self.revoked = False + self.rotated = False + + # -- flags --------------------------------------------------------------- + + def mark_accessed(self) -> None: + """Record that the session was read.""" + self.accessed = True + + def mark_modified(self) -> None: + """Record that the session was changed. + + Sets ``accessed`` too: changing a session is a way of touching it, and + treating the two independently is the upstream ``pop()`` bug. + """ + self.accessed = True + self.modified = True + + # -- reads --------------------------------------------------------------- + def raw(self) -> dict[Any, Any]: + """Return a plain-``dict`` snapshot **without** marking the session. + + Every other read marks ``accessed``, which is what the application + wants and what drives ``Vary: Cookie``. Two callers must not: + + * the store, when it serializes the payload on the way out - a save + must not be able to flip a flag it is reacting to; + * ``principal_of``, which the middleware calls twice per request on + sessions the application may never have touched. + + Note that neither ``dict(session)`` nor ``session.copy()`` is a + substitute. CPython routes both through the subclass's ``keys()``, + which marks. Calling the unbound ``dict.items`` is what skips the + override. + """ + return dict(dict.items(self)) + + def __getitem__(self, key: Any) -> Any: + self.accessed = True + return super().__getitem__(key) + + def __contains__(self, key: Any) -> bool: + self.accessed = True + return super().__contains__(key) + + def __iter__(self) -> Any: + self.accessed = True + return super().__iter__() + + def get(self, key: Any, default: Any = None) -> Any: + self.accessed = True + return super().get(key, default) + + def keys(self) -> Any: + self.accessed = True + return super().keys() + + def values(self) -> Any: + self.accessed = True + return super().values() + + def items(self) -> Any: + self.accessed = True + return super().items() + + # -- writes -------------------------------------------------------------- + + def __setitem__(self, key: Any, value: Any) -> None: + self.mark_modified() + super().__setitem__(key, value) + + def __delitem__(self, key: Any) -> None: + self.mark_modified() + super().__delitem__(key) + + def __or__(self, other: Mapping[Any, Any]) -> Session: + """Return a new ``Session`` merging *other*; leave this one unchanged. + + Overridden only so its return type matches ``__ior__``. The result is + a fresh object, so its flags start clean. + """ + self.accessed = True + return Session({**dict(self), **dict(other)}) + + def __ior__(self, other: Mapping[Any, Any]) -> Session: # type: ignore[override] + """Override ``|=``. + + ``dict.__ior__`` runs in C and never calls ``update()``, so without + this the merge would set no flag and the write would be dropped. + """ + self.mark_modified() + super().update(other) + return self + + def clear(self) -> None: + self.mark_modified() + super().clear() + + def update( # type: ignore[override] + self, *args: Mapping[Any, Any] | Iterable[tuple[Any, Any]], **kwargs: Any + ) -> None: + self.mark_modified() + super().update(*args, **kwargs) + + def setdefault(self, key: Any, default: Any = None) -> Any: + """Insert *key* with *default* when absent, and flag only then. + + A ``setdefault`` that finds the key is a read, so flagging it as a + modification would write the record on every request that called it. + """ + if key in dict.keys(self): + self.accessed = True + return super().__getitem__(key) + self.mark_modified() + return super().setdefault(key, default) + + def pop(self, key: Any, default: Any = _MISSING) -> Any: + """Remove *key*, flagging both ``accessed`` and ``modified``. + + Upstream sets ``modified`` alone, which leaves ``Vary: Cookie`` off a + response that did touch the session. + """ + if default is _MISSING: + value = super().pop(key) + self.mark_modified() + return value + had_key = key in dict.keys(self) + value = super().pop(key, default) + if had_key: + self.mark_modified() + else: + self.accessed = True + return value + + def popitem(self) -> tuple[Any, Any]: + """Remove and return the last pair. Upstream sets no flag at all.""" + item = super().popitem() + self.mark_modified() + return item + + +@dataclass(frozen=True) +class CookieSpec: + """Everything needed to render one ``Set-Cookie`` header. + + Passed to a ``cookie_builder`` seam so an application can add attributes + this package does not know about. Starlette's own middleware cannot emit + ``Partitioned`` and cannot use a ``__Host-`` prefix, and Section 2a of + ``session-mgmt.md`` records that as a common reason people abandon it. + + ``max_age`` of ``None`` means a session cookie: no ``Max-Age``, and the + browser drops it when it closes. + """ + + name: str + value: str + max_age: int | None + path: str + domain: str | None + secure: bool + http_only: bool + same_site: str + + +def build_cookie(spec: CookieSpec) -> str: + """Render a ``Set-Cookie`` value. The default ``cookie_builder``.""" + parts = [f"{spec.name}={spec.value}", f"Path={spec.path}"] + if spec.max_age is not None: + parts.append(f"Max-Age={spec.max_age}") + if spec.domain: + parts.append(f"Domain={spec.domain}") + if spec.secure: + parts.append("Secure") + if spec.http_only: + parts.append("HttpOnly") + parts.append(f"SameSite={spec.same_site.capitalize()}") + return "; ".join(parts) + + +def clear_cookie(spec: CookieSpec) -> str: + """Render a ``Set-Cookie`` that deletes the cookie. + + An empty value and ``Max-Age=0``. Every attribute that scopes the cookie + must match the one that set it, or the browser keeps the original. + """ + parts = [f"{spec.name}=", f"Path={spec.path}", "Max-Age=0"] + if spec.domain: + parts.append(f"Domain={spec.domain}") + if spec.secure: + parts.append("Secure") + if spec.http_only: + parts.append("HttpOnly") + parts.append(f"SameSite={spec.same_site.capitalize()}") + return "; ".join(parts) + + +# --------------------------------------------------------------------------- +# SessionMiddleware - the eager load, the response rule, the cookie +# --------------------------------------------------------------------------- + +SCOPE_KEY = "session" +_STATE_ATTR = "_redis_session" + +# What ``principal_of`` returns when there is no identity to speak of. +_NO_PRINCIPAL: Any = None + + +@dataclass +class _RequestState: + """Per-request session bookkeeping, kept off the ``Session`` itself.""" + + session: Session + loaded_id: str | None + principal_before: Any + absolute_remaining: int | None + + +class SessionMiddleware: + """Loads the session before the application and writes it after. + + The load is **eager**, and it has to be: ``HTTPConnection.session`` is a + synchronous property and cannot await, so by the time any dependency or + endpoint touches ``request.session`` the Redis read has already happened. + This middleware is the last place in the chain that can still ``await``. + + Rotation is **detected, not requested**. The middleware evaluates the + principal once before the application runs and once at + ``http.response.start``; if the two differ on a successful response it + rotates. An application signs a user in by writing the identity and + nothing else, so there is no rotation call to forget - which is the only + mistake in this API that would be a vulnerability. + """ + + def __init__( + self, + app: ASGIApp, + *, + store_factory: Callable[[Request], Awaitable[SessionStore]], + principal_of: Callable[[Session], Any] | None = None, + subject_of: Callable[[Session], str | None] | None = None, + cookie_builder: Callable[[CookieSpec], str] | None = None, + skip: Callable[[Request], bool] | None = None, + ) -> None: + self.app = app + self._store_factory = store_factory + self._principal_of = principal_of or _default_principal_of + self._subject_of = subject_of or _default_subject_of + self._cookie_builder = cookie_builder or build_cookie + self._skip = skip + + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + if scope["type"] != "http": + await self.app(scope, receive, send) + return + + request = Request(scope, receive=receive) + if self._skip is not None and self._skip(request): + scope[SCOPE_KEY] = Session() + await self.app(scope, receive, send) + return + + settings = get_settings() + store = await self._store_factory(request) + state = await self._load(request, store, settings) + scope[SCOPE_KEY] = state.session + setattr(request.state, _STATE_ATTR, state) + + started = False + + async def send_with_session(message: Message) -> None: + nonlocal started + if message["type"] == "http.response.start" and not started: + started = True + headers = list(message.get("headers", [])) + message["headers"] = await self._on_response_start( + store, state, settings, message["status"], headers + ) + await send(message) + + await self.app(scope, receive, send_with_session) + + # -- before the application ---------------------------------------------- + + async def _load( + self, request: Request, store: SessionStore, settings: Any + ) -> _RequestState: + """Read the cookie, load the record, take the first principal snapshot.""" + raw_cookie = request.cookies.get(settings.session_cookie_name) + session = Session() + loaded_id: str | None = None + absolute_remaining: int | None = None + + # Validate before use. This is not cosmetic: the value is written back + # into a Set-Cookie header, so an unvalidated one is a header-injection + # vector. Anything unexpected is treated as no session at all. + if raw_cookie and store.is_valid_id(raw_cookie): + loaded = await store.load( + raw_cookie, refresh=settings.session_refresh_on_load + ) + if loaded is not None: + session = Session(loaded.record.data) + session.sid = raw_cookie + loaded_id = raw_cookie + absolute_remaining = loaded.absolute_remaining + + return _RequestState( + session=session, + loaded_id=loaded_id, + principal_before=self._snapshot(session), + absolute_remaining=absolute_remaining, + ) + + def _snapshot(self, session: Session) -> Any: + """Evaluate ``principal_of`` without letting it mark the session. + + The middleware calls this twice on every request, including requests + the application never touched. Letting those calls set ``accessed`` + would put ``Vary: Cookie`` on responses that do not vary by cookie and, + under ``refresh_on_load=False``, refresh the idle clock of a user who + did nothing. + """ + accessed, modified = session.accessed, session.modified + try: + return self._principal_of(session) + finally: + session.accessed, session.modified = accessed, modified + + # -- after the application ----------------------------------------------- + + async def _on_response_start( + self, + store: SessionStore, + state: _RequestState, + settings: Any, + status: int, + headers: list[tuple[bytes, bytes]], + ) -> list[tuple[bytes, bytes]]: + """Apply the write rule, then the cookie rule.""" + session = state.session + + if session.accessed: + # Without this a shared cache can serve one user's page to another. + headers.append((b"vary", b"Cookie")) + + if session.revoked: + headers.append( + (b"set-cookie", clear_cookie(self._spec(settings, "", None)).encode()) + ) + return headers + + principal_after = self._snapshot(session) + changed = principal_after != state.principal_before + rotating = (session.rotated or changed) and status < 400 + + if changed and status >= 400: + # A response the client saw fail must not hand out an + # authenticated session. Persisting the data while skipping the + # rotation would store the new identity against the *old*, + # unrotated ID - precisely the fixation this design prevents, + # arrived at by being helpful. So this request writes nothing. + return headers + + if rotating: + new_id = await store.rotate( + session, subject=self._subject_of(session) or None + ) + headers.append( + ( + b"set-cookie", + self._cookie_builder( + self._spec(settings, new_id, store.absolute_seconds) + ).encode(), + ) + ) + return headers + + if session.sid is not None and session.sid != state.loaded_id: + # A handler called ``store.rotate()`` itself, so the work is done + # and only the cookie is outstanding. Without this branch the + # browser would keep an identifier whose key the handler deleted, + # and the user would be signed out by their own step-up. + headers.append( + ( + b"set-cookie", + self._cookie_builder( + self._spec(settings, session.sid, store.absolute_seconds) + ).encode(), + ) + ) + return headers + + if not session.accessed: + return headers + + if session.modified or settings.session_always_save: + await self._write(store, state, settings) + headers.append( + ( + b"set-cookie", + self._cookie_builder( + self._spec( + settings, session.sid or "", state.absolute_remaining + ) + ).encode(), + ) + ) + elif not settings.session_refresh_on_load and state.loaded_id is not None: + # The load was a plain read under this setting, so this is the only + # place left that can advance the idle clock. Omitting this branch + # freezes the clock and makes every session immortal until its + # absolute deadline. + await store.touch(state.loaded_id) + + return headers + + async def _write( + self, store: SessionStore, state: _RequestState, settings: Any + ) -> None: + """Persist the payload, and re-assert the index entry beside it.""" + session = state.session + if session.sid is None: + session.sid = store.new_id() + state.absolute_remaining = store.absolute_seconds + record = store.new_record(session.raw()) + await store.save(session.sid, record) + + subject = self._subject_of(session) + if subject: + # Re-asserted on every write, not only at login: HSETEX is + # idempotent, it costs nothing extra here, and it repairs an entry + # that a partial failure lost. The remaining absolute time, never a + # fresh lifetime. + await store.index( + subject, + session.sid, + record, + absolute_remaining=state.absolute_remaining or store.absolute_seconds, + ) + + def _spec(self, settings: Any, value: str, absolute: int | None) -> CookieSpec: + """Build the cookie spec, sizing ``max-age`` from the server's clocks. + + ``min(idle, absolute remaining)`` - whichever deadline fires first, and + both numbers come from Redis rather than from this process. A cookie + that outlives its record gets the user signed out with no cause and no + log line; deriving both from the same two server-side numbers is what + prevents that. + """ + idle = settings.session_idle_ttl + max_age: int | None + if not idle and not settings.session_absolute_ttl: + max_age = None # cookie-only mode: the browser decides + elif absolute is None: + max_age = idle or None + elif not idle: + max_age = absolute + else: + max_age = min(idle, absolute) + return CookieSpec( + name=settings.session_cookie_name, + value=value, + max_age=max_age, + path=settings.session_cookie_path, + domain=settings.session_cookie_domain, + secure=settings.session_cookie_https_only, + http_only=True, + same_site=settings.session_cookie_same_site, + ) + + +def _default_principal_of(session: Session) -> Any: + """Read the configured principal keys, in order. + + Returns a tuple so that adding ``role`` or ``scopes`` to the setting makes + a privilege change rotate as well as a sign-in. + """ + keys = get_settings().session_principal_keys + values = tuple(dict.get(session, key) for key in keys) + return values if any(v is not None for v in values) else _NO_PRINCIPAL + + +def _default_subject_of(session: Session) -> str | None: + """The index key for this session, or ``None`` for an anonymous one. + + No subject means no index entry, which is the right answer: there is + nothing for ``revoke_all`` to promise. + """ + keys = get_settings().session_principal_keys + for key in keys: + value = dict.get(session, key) + if value is not None: + return str(value) + return None + + +# --------------------------------------------------------------------------- +# add_redis_sessions() - one-time app setup +# --------------------------------------------------------------------------- + + +def add_redis_sessions( + app: FastAPI, + *, + principal_of: Callable[[Session], Any] | None = None, + principal_keys: list[str] | None = None, + subject_of: Callable[[Session], str | None] | None = None, + cookie_builder: Callable[[CookieSpec], str] | None = None, + skip: Callable[[Request], bool] | None = None, +) -> None: + """Register :class:`SessionMiddleware` on *app*. + + Prefer the builder API:: + + FastAPIRedis(app).lifespan().sessions() + + Args: + app: The FastAPI application. + principal_of: Pure function returning the value whose change triggers + a rotation. Defaults to reading ``session_principal_keys``. + **Must be pure, cheap and deterministic** - it runs twice per + request and its two results are compared by value. It must also + return a *verified* identity, never something the client set. + principal_keys: Convenience for the common case: the session keys to + watch, instead of writing a function. Overrides the setting. + subject_of: Which subject a session is indexed under. ``None`` + disables the index for that session, which is correct for an + anonymous one. The subject need not be a user - it can be a + tenant, a device, or an API client. + cookie_builder: Renders the ``Set-Cookie`` value, for attributes this + package does not know about. + skip: Predicate for requests that need no session at all. A request + it returns true for costs zero Redis calls. + + Raises: + SessionConfigurationError: If the cookie settings contradict each + other. + """ + settings = get_settings() + if settings.session_cookie_same_site == "none" and not ( + settings.session_cookie_https_only + ): + raise SessionConfigurationError( + "session_cookie_same_site='none' requires " + "session_cookie_https_only=True: browsers reject a SameSite=None " + "cookie that is not Secure, so the session would never be stored." + ) + + resolver = principal_of + if resolver is None and principal_keys is not None: + watched = list(principal_keys) + + def resolver(session: Session) -> Any: # noqa: F811 + values = tuple(dict.get(session, key) for key in watched) + return values if any(v is not None for v in values) else None + + from redis_fastapi.deps import get_session_store + + app.add_middleware( + SessionMiddleware, + store_factory=get_session_store, + principal_of=resolver, + subject_of=subject_of, + cookie_builder=cookie_builder, + skip=skip, + ) + + +def session_of(request: Request) -> Session: + """Return the :class:`Session` the middleware loaded for *request*. + + The backing dependency for ``SessionDep``. Never performs I/O: the load + already happened before the application ran. + + Raises: + SessionConfigurationError: If no middleware is registered, which would + otherwise surface as a confusing ``KeyError`` deep in a handler. + """ + session = request.scope.get(SCOPE_KEY) + if not isinstance(session, Session): + raise SessionConfigurationError( + "No session was loaded for this request. Call " + "FastAPIRedis(app).lifespan().sessions() during setup, or " + "add_redis_sessions(app) directly." + ) + return session diff --git a/src/redis_fastapi/setup.py b/src/redis_fastapi/setup.py index d47d3ca..7bc5aac 100644 --- a/src/redis_fastapi/setup.py +++ b/src/redis_fastapi/setup.py @@ -16,7 +16,7 @@ from __future__ import annotations -from collections.abc import AsyncIterator, Mapping +from collections.abc import AsyncIterator, Callable, Mapping from contextlib import asynccontextmanager from typing import TYPE_CHECKING, Any @@ -25,9 +25,11 @@ if TYPE_CHECKING: from fastapi import FastAPI + from starlette.requests import Request from redis_fastapi.rate import Rate from redis_fastapi.ratelimit import Identifier, OnLimitExceeded, SkipWhen + from redis_fastapi.sessions import CookieSpec, Session class FastAPIRedis: @@ -138,6 +140,52 @@ def rate_limiting( ) return self + def sessions( + self, + *, + principal_of: Callable[[Session], Any] | None = None, + principal_keys: list[str] | None = None, + subject_of: Callable[[Session], str | None] | None = None, + cookie_builder: Callable[[CookieSpec], str] | None = None, + skip: Callable[[Request], bool] | None = None, + ) -> FastAPIRedis: + """Register the session middleware. + + Required for ``request.session``, ``SessionDep`` and + ``SessionStoreDep`` to work. Rotation after a sign-in or a privilege + change is automatic - the middleware detects the change rather than + waiting to be told, so there is no rotation call to forget:: + + FastAPIRedis(app).lifespan().sessions( + principal_keys=["user_id", "role"] + ) + + Calling this method more than once on the same app is a no-op. + + Args: + principal_of: Pure function whose changing return value triggers a + rotation. Defaults to reading ``session_principal_keys``. + principal_keys: The session keys to watch, for the common case + where a function is overkill. + subject_of: Which subject a session is indexed under; ``None`` + leaves it out of the index. + cookie_builder: Renders the ``Set-Cookie`` value. + skip: Requests that need no session at all, at zero Redis cost. + """ + from redis_fastapi.sessions import SessionMiddleware, add_redis_sessions + + if self._has_middleware(SessionMiddleware): + return self + add_redis_sessions( + self._app, + principal_of=principal_of, + principal_keys=principal_keys, + subject_of=subject_of, + cookie_builder=cookie_builder, + skip=skip, + ) + return self + def otel(self) -> FastAPIRedis: """Enable OpenTelemetry instrumentation for cache operations. diff --git a/src/redis_fastapi/telemetry.py b/src/redis_fastapi/telemetry.py index 71e79c9..960eb55 100644 --- a/src/redis_fastapi/telemetry.py +++ b/src/redis_fastapi/telemetry.py @@ -52,6 +52,11 @@ class _OTelState: ratelimit_requests: Any = None ratelimit_latency: Any = None + # Session metric instruments + session_operations: Any = None + session_latency: Any = None + session_events: Any = None + _state = _OTelState() @@ -115,6 +120,21 @@ def enable_telemetry() -> None: description="Rate-limit checks by result", unit="1", ) + _state.session_operations = _state.meter.create_counter( + name="redis_fastapi.sessions.operations", + description="Session store operations by kind and outcome", + unit="1", + ) + _state.session_latency = _state.meter.create_histogram( + name="redis_fastapi.sessions.latency", + description="Session store operation latency", + unit="s", + ) + _state.session_events = _state.meter.create_counter( + name="redis_fastapi.sessions.events", + description="Session-end notifications delivered to handlers", + unit="1", + ) _state.ratelimit_latency = _state.meter.create_histogram( name="redis_fastapi.ratelimit.latency", description="Rate-limit check duration", @@ -296,3 +316,78 @@ def timed_rate_limit(scope: str = "") -> Iterator[None]: yield finally: record_rate_limit_latency(duration=time.monotonic() - start, scope=scope) + + +# --------------------------------------------------------------------------- +# Session telemetry +# --------------------------------------------------------------------------- +# +# One rule governs every helper below, and it is an acceptance criterion +# rather than a preference: **no session ID and no subject may become a span +# attribute or a metric label.** Cardinality is the lesser reason. The real +# one is that traces and metrics reach dashboards and third-party vendors that +# the session store's threat model never considered - a subject is usually a +# user ID, and a session ID is a bearer credential. + + +@contextlib.contextmanager +def session_span( + name: str, + attributes: dict[str, Any] | None = None, +) -> Iterator[Any]: + """Create a span for a session operation. No-op when OTel is disabled.""" + if not _state.enabled or _state.tracer is None: + yield None + return + with _state.tracer.start_as_current_span(name, attributes=attributes or {}) as span: + yield span + + +def record_session_operation(*, operation: str, result: str) -> None: + """Count a session operation. + + Args: + operation: load, save, touch, rotate, revoke, revoke_all, list, count. + result: hit, miss, expired or error. + """ + if not _state.enabled or _state.session_operations is None: + return + try: + _state.session_operations.add(1, {"operation": operation, "result": result}) + except Exception: + logger.debug("Error recording session operation metric", exc_info=True) + + +def record_session_latency(*, duration: float, operation: str) -> None: + """Record session operation latency in seconds.""" + if not _state.enabled or _state.session_latency is None: + return + try: + _state.session_latency.record(duration, {"operation": operation}) + except Exception: + logger.debug("Error recording session latency metric", exc_info=True) + + +def record_session_event(*, cause: str, result: str) -> None: + """Count a session-end notification. + + Args: + cause: idle, absolute or revoked. + result: delivered or dropped. + """ + if not _state.enabled or _state.session_events is None: + return + try: + _state.session_events.add(1, {"cause": cause, "result": result}) + except Exception: + logger.debug("Error recording session event metric", exc_info=True) + + +@contextlib.contextmanager +def timed_session(operation: str) -> Iterator[None]: + """Context manager that records latency for a session operation.""" + start = time.monotonic() + try: + yield + finally: + record_session_latency(duration=time.monotonic() - start, operation=operation) diff --git a/tests/integration/test_session_integration.py b/tests/integration/test_session_integration.py new file mode 100644 index 0000000..27559ab --- /dev/null +++ b/tests/integration/test_session_integration.py @@ -0,0 +1,162 @@ +"""Integration tests for sessions against a real Redis server. + +These prove the things ``fakeredis`` cannot: that expiry is enforced by the +server itself, that the index prunes itself with no help from us, and that the +capability probe picks the right command tier for whatever server is running. +""" + +import asyncio + +import pytest +import redis.asyncio as async_redis + +from redis_fastapi.session_backend import ( + FIELD_ABSOLUTE, + FIELD_DATA, + RedisSessionStore, + probe_hsetex_support, +) +from tests.conftest import requires_redis + +pytestmark = [pytest.mark.integration, requires_redis, pytest.mark.asyncio] + + +def _store(redis, prefix: str, **kwargs) -> RedisSessionStore: + kwargs.setdefault("idle_ttl", 60) + kwargs.setdefault("absolute_ttl", 600) + return RedisSessionStore(redis, key_prefix=prefix, **kwargs) + + +async def test_capability_probe_answers_definitively( + real_async_redis: async_redis.Redis, +) -> None: + """Never ``None`` against a reachable server - that value means "unknown".""" + assert await probe_hsetex_support(real_async_redis) in {True, False} + + +async def test_round_trip( + real_async_redis: async_redis.Redis, test_prefix: str +) -> None: + store = _store(real_async_redis, test_prefix) + sid = store.new_id() + await store.save(sid, store.new_record({"user_id": 42})) + loaded = await store.load(sid) + assert loaded is not None + assert loaded.record.data == {"user_id": 42} + + +async def test_redis_enforces_the_idle_clock( + real_async_redis: async_redis.Redis, test_prefix: str +) -> None: + """The server expires the field; nothing here counts down.""" + store = _store(real_async_redis, test_prefix, idle_ttl=1, absolute_ttl=600) + sid = store.new_id() + await store.save(sid, store.new_record({"user_id": 42})) + assert await store.load(sid) is not None + + await asyncio.sleep(1.5) + assert await store.load(sid) is None, "the idle clock was not enforced" + + +async def test_redis_enforces_the_absolute_clock_despite_activity( + real_async_redis: async_redis.Redis, test_prefix: str +) -> None: + """The guarantee the two-field layout exists for. + + The session is loaded continuously - each load refreshes the idle clock - + and it must still die on schedule. + """ + store = _store(real_async_redis, test_prefix, idle_ttl=60, absolute_ttl=2) + sid = store.new_id() + await store.save(sid, store.new_record({"user_id": 42})) + + for _ in range(4): + await asyncio.sleep(0.6) + result = await store.load(sid) + await store.save(sid, store.new_record({"user_id": 42})) + if result is None: + break + else: + pytest.fail( + "the session outlived its absolute deadline under continuous " + "activity - writing the payload extended field 'a'" + ) + + +async def test_the_index_prunes_itself_with_no_help_from_us( + real_async_redis: async_redis.Redis, test_prefix: str +) -> None: + """No sweeper, no cron, no reconciliation pass. + + This is what no other backend can do: Postgres needs a scheduled DELETE, + DynamoDB's sweeper runs up to 48 hours late, Memcached cannot express the + question at all. + """ + store = _store(real_async_redis, test_prefix, idle_ttl=60, absolute_ttl=1) + sid = store.new_id() + record = store.new_record({"user_id": "42"}) + await store.save(sid, record) + await store.index("42", sid, record, absolute_remaining=1) + assert len(await store.list_for_subject("42")) == 1 + + await asyncio.sleep(1.5) + raw = await real_async_redis.hgetall(store.index_key("42")) + assert raw == {}, "Redis did not expire the index entry on its own" + + +async def test_rotation_deletes_the_old_key_before_writing_the_new_one( + real_async_redis: async_redis.Redis, test_prefix: str +) -> None: + from redis_fastapi.sessions import Session + + store = _store(real_async_redis, test_prefix) + session = Session({"user_id": 42}) + session.sid = store.new_id() + await store.save(session.sid, store.new_record(session.raw())) + old = session.sid + + new = await store.rotate(session, subject="42") + assert new != old + assert await real_async_redis.exists(store.session_key(old)) == 0 + assert await real_async_redis.exists(store.session_key(new)) == 1 + + +async def test_writing_the_payload_leaves_the_deadline_alone( + real_async_redis: async_redis.Redis, test_prefix: str +) -> None: + store = _store(real_async_redis, test_prefix) + sid = store.new_id() + key = store.session_key(sid) + await store.save(sid, store.new_record({"n": 0})) + + before = ( + await real_async_redis.execute_command("HTTL", key, "FIELDS", 1, FIELD_ABSOLUTE) + )[0] + for n in range(5): + await store.save(sid, store.new_record({"n": n})) + after = ( + await real_async_redis.execute_command("HTTL", key, "FIELDS", 1, FIELD_ABSOLUTE) + )[0] + + assert int(after) <= int(before) + idle = ( + await real_async_redis.execute_command("HTTL", key, "FIELDS", 1, FIELD_DATA) + )[0] + assert int(idle) > 0 + + +async def test_revoke_all_ends_every_session( + real_async_redis: async_redis.Redis, test_prefix: str +) -> None: + store = _store(real_async_redis, test_prefix) + ids = [] + for _ in range(3): + sid = store.new_id() + record = store.new_record({"user_id": "42"}) + await store.save(sid, record) + await store.index("42", sid, record, absolute_remaining=600) + ids.append(sid) + + assert await store.revoke_all("42") == 3 + for sid in ids: + assert await store.load(sid) is None diff --git a/tests/unit/test_session.py b/tests/unit/test_session.py new file mode 100644 index 0000000..3eb0ce2 --- /dev/null +++ b/tests/unit/test_session.py @@ -0,0 +1,188 @@ +"""Unit tests for the :class:`Session` mapping. + +Every test here corresponds to a row of the table in Section 8 of +``docs/specs/session-design.md``. The last four rows exist because the +upstream Starlette session gets them wrong, so each one fails against that +implementation and passes against ours. +""" + +from __future__ import annotations + +import pytest + +from redis_fastapi.sessions import ( + Session, + SessionConfigurationError, + SessionError, + SessionStoreError, +) + + +class TestFlagsStartClean: + """Construction is the middleware loading us, not the application.""" + + def test_empty_session_is_untouched(self) -> None: + s = Session() + assert s.accessed is False + assert s.modified is False + + def test_populated_session_is_untouched(self) -> None: + s = Session({"user_id": 42}) + assert s == {"user_id": 42} + assert s.accessed is False + assert s.modified is False + + +class TestRawDoesNotMark: + """The store and ``principal_of`` must be able to look without touching.""" + + def test_raw_leaves_both_flags_clean(self) -> None: + s = Session({"user_id": 42}) + assert s.raw() == {"user_id": 42} + assert s.accessed is False + assert s.modified is False + + def test_raw_is_a_copy_not_a_view(self) -> None: + s = Session({"a": 1}) + snapshot = s.raw() + snapshot["a"] = 2 + assert s["a"] == 1 + assert type(snapshot) is dict + + @pytest.mark.parametrize("spelling", [dict, lambda s: s.copy()]) + def test_the_obvious_spellings_do_mark(self, spelling) -> None: + """Why ``raw()`` exists. + + CPython routes both ``dict(session)`` and ``session.copy()`` through + the subclass's ``keys()``, so both mark. If this ever stops being + true, ``raw()`` can be simplified - but not before. + """ + s = Session({"a": 1}) + spelling(s) + assert s.accessed is True + + +class TestReadsMarkAccessedOnly: + @pytest.mark.parametrize( + "read", + [ + lambda s: s["a"], + lambda s: s.get("a"), + lambda s: "a" in s, + lambda s: list(s), + lambda s: list(s.keys()), + lambda s: list(s.values()), + lambda s: list(s.items()), + ], + ) + def test_read_sets_accessed_and_not_modified(self, read) -> None: + s = Session({"a": 1}) + read(s) + assert s.accessed is True + assert s.modified is False + + def test_mark_accessed_is_the_starlette_hook(self) -> None: + # Starlette calls this by name via hasattr; the name is load-bearing. + s = Session() + assert hasattr(s, "mark_accessed") + s.mark_accessed() + assert s.accessed is True + assert s.modified is False + + +class TestWritesMarkBoth: + @pytest.mark.parametrize( + "write", + [ + lambda s: s.__setitem__("b", 2), + lambda s: s.__delitem__("a"), + lambda s: s.clear(), + lambda s: s.update({"b": 2}), + lambda s: s.setdefault("b", 2), + ], + ) + def test_write_sets_both_flags(self, write) -> None: + s = Session({"a": 1}) + write(s) + assert s.modified is True + assert s.accessed is True, "a modification is also an access" + + +class TestTheFourUpstreamFaults: + """Section 8's table. Each of these is a bug in Starlette's session.""" + + def test_popitem_sets_the_flags(self) -> None: + s = Session({"a": 1}) + key, value = s.popitem() + assert (key, value) == ("a", 1) + assert s.modified is True, "upstream popitem() sets no flag" + assert s.accessed is True + + def test_ior_sets_the_flags(self) -> None: + s = Session({"a": 1}) + s |= {"b": 2} + assert s == {"a": 1, "b": 2} + assert s.modified is True, "dict.__ior__ runs in C and skips update()" + assert s.accessed is True + + def test_pop_sets_accessed_as_well_as_modified(self) -> None: + s = Session({"a": 1}) + assert s.pop("a") == 1 + assert s.modified is True + assert s.accessed is True, "upstream pop() sets modified but not accessed" + + def test_nested_mutation_is_invisible(self) -> None: + """The documented limit. No dict subclass can see this.""" + s = Session({"a": {"b": 0}}) + s["a"]["b"] = 1 + assert s.modified is False, ( + "if this ever becomes True the escape route in Section 8 is no " + "longer needed and the docs must change" + ) + assert s.accessed is True, "reading s['a'] is still an access" + + +class TestSetdefaultDoesNotWriteOnHit: + def test_hit_is_a_read(self) -> None: + s = Session({"a": 1}) + assert s.setdefault("a", 99) == 1 + assert s.modified is False, "setdefault on an existing key changes nothing" + assert s.accessed is True + + def test_miss_is_a_write(self) -> None: + s = Session({"a": 1}) + assert s.setdefault("b", 2) == 2 + assert s.modified is True + + +class TestPopWithDefault: + def test_missing_key_with_default_is_a_read(self) -> None: + s = Session({"a": 1}) + assert s.pop("nope", "fallback") == "fallback" + assert s.modified is False, "nothing was removed, so nothing to write" + assert s.accessed is True + + def test_missing_key_without_default_raises(self) -> None: + s = Session() + with pytest.raises(KeyError): + s.pop("nope") + + +class TestOrReturnsAFreshSession: + def test_or_does_not_mutate_the_original(self) -> None: + s = Session({"a": 1}) + merged = s | {"b": 2} + assert merged == {"a": 1, "b": 2} + assert s == {"a": 1} + assert s.modified is False + assert isinstance(merged, Session) + assert merged.modified is False, "a new object has not been modified" + + +class TestExceptionHierarchy: + def test_one_base_catches_everything(self) -> None: + assert issubclass(SessionConfigurationError, SessionError) + assert issubclass(SessionStoreError, SessionError) + + def test_store_error_is_not_a_configuration_error(self) -> None: + assert not issubclass(SessionStoreError, SessionConfigurationError) diff --git a/tests/unit/test_session_backend.py b/tests/unit/test_session_backend.py new file mode 100644 index 0000000..e1418bf --- /dev/null +++ b/tests/unit/test_session_backend.py @@ -0,0 +1,352 @@ +"""Unit tests for :class:`RedisSessionStore`, against ``fakeredis``. + +These run the real key schema, the real TTL commands and the real two-field +layout - not a substitute - which is the whole reason the design refuses an +in-memory store. +""" + +from __future__ import annotations + +import pytest + +from redis_fastapi.config import get_settings +from redis_fastapi.session_backend import ( + FIELD_ABSOLUTE, + FIELD_DATA, + RedisSessionStore, + SessionMetadata, + SessionRecord, + _StoreCapabilities, +) +from redis_fastapi.sessions import SessionConfigurationError, SessionStoreError + + +@pytest.fixture() +def store(fake_async_redis) -> RedisSessionStore: + get_settings.cache_clear() + return RedisSessionStore(fake_async_redis, idle_ttl=60, absolute_ttl=600) + + +async def _httl(redis, key: str, field: str) -> int: + reply = await redis.execute_command("HTTL", key, "FIELDS", 1, field) + return int(reply[0]) + + +def _about(actual: int, expected: int) -> bool: + """TTL equality, allowing for a second boundary crossing mid-test. + + Redis counts down in whole seconds, so a TTL set to N reads back as N or + N-1 depending on where the call landed. Asserting equality makes the + suite flaky for no gain; the guarantees under test are all about which + clock moved, not about sub-second precision. + """ + return expected - 1 <= actual <= expected + + +class TestKeySchema: + def test_the_two_prefixes_cannot_collide(self, store: RedisSessionStore) -> None: + """Structural, not conventional. + + The strings diverge before either key's variable part begins, so no + session ID can produce the index key - not even the ``by-subject:42`` + that broke the earlier nested layout. + """ + assert store.session_key("by-subject:42") != store.index_key("42") + assert not store.session_key("x").startswith(store._index_prefix) + assert not store.index_key("x").startswith(store._session_prefix) + + def test_keys_are_flat_with_no_hash_tag(self, store: RedisSessionStore) -> None: + # A hash tag would send every session of one deployment to one slot. + assert "{" not in store.session_key("abc") + assert "}" not in store.index_key("42") + + +class TestIdentifiers: + def test_generated_ids_are_valid_and_unique(self, store: RedisSessionStore) -> None: + ids = {store.new_id() for _ in range(50)} + assert len(ids) == 50 + assert all(store.is_valid_id(i) for i in ids) + + @pytest.mark.parametrize( + "bad", + ["short", "by-subject:42", "has space", "has/slash", "", "a" * 21], + ) + def test_invalid_ids_are_rejected(self, store: RedisSessionStore, bad: str) -> None: + assert store.is_valid_id(bad) is False + + def test_a_bad_id_factory_raises_rather_than_writing( + self, fake_async_redis + ) -> None: + store = RedisSessionStore(fake_async_redis, id_factory=lambda: "by-subject:42") + with pytest.raises(SessionConfigurationError, match="unusable session ID"): + store.new_id() + + def test_a_good_id_factory_is_accepted(self, fake_async_redis) -> None: + store = RedisSessionStore(fake_async_redis, id_factory=lambda: "a" * 30) + assert store.new_id() == "a" * 30 + + +class TestTwoFieldsTwoClocks: + async def test_save_writes_both_fields_with_their_own_ttls( + self, store: RedisSessionStore, fake_async_redis + ) -> None: + sid = store.new_id() + await store.save(sid, store.new_record({"user_id": 42})) + + key = store.session_key(sid) + assert _about(await _httl(fake_async_redis, key, FIELD_ABSOLUTE), 600) + assert _about(await _httl(fake_async_redis, key, FIELD_DATA), 60) + + async def test_writing_the_payload_never_extends_the_deadline( + self, store: RedisSessionStore, fake_async_redis + ) -> None: + """N-6, asserted directly rather than inferred. + + This is the guarantee the whole two-field layout exists for, so it is + checked by watching field ``a``'s TTL rather than by reasoning about + which command was sent. + """ + sid = store.new_id() + key = store.session_key(sid) + await store.save(sid, store.new_record({"n": 1})) + + # Age the absolute clock, then write the payload many times over. + await fake_async_redis.execute_command( + "HEXPIRE", key, 100, "FIELDS", 1, FIELD_ABSOLUTE + ) + for n in range(5): + await store.save(sid, store.new_record({"n": n})) + + assert await _httl(fake_async_redis, key, FIELD_ABSOLUTE) <= 100, ( + "field 'a' was refreshed by a payload write - the absolute " + "deadline is no longer absolute" + ) + assert _about(await _httl(fake_async_redis, key, FIELD_DATA), 60), ( + "field 'd' should have been refreshed by the write" + ) + + async def test_field_a_always_has_a_ttl( + self, store: RedisSessionStore, fake_async_redis + ) -> None: + """The ``-1`` row of the state table must be unreachable.""" + sid = store.new_id() + await store.save(sid, store.new_record({})) + assert await _httl(fake_async_redis, store.session_key(sid), FIELD_ABSOLUTE) > 0 + + async def test_zero_ttls_fall_back_to_gc_ttl(self, fake_async_redis) -> None: + """Cookie-only mode: both fields still expire eventually.""" + store = RedisSessionStore( + fake_async_redis, idle_ttl=0, absolute_ttl=0, gc_ttl=1234 + ) + sid = store.new_id() + await store.save(sid, store.new_record({})) + key = store.session_key(sid) + assert _about(await _httl(fake_async_redis, key, FIELD_ABSOLUTE), 1234) + assert _about(await _httl(fake_async_redis, key, FIELD_DATA), 1234) + + +class TestLoadStateTable: + """All five rows of the table in Section 4.1.""" + + async def test_alive(self, store: RedisSessionStore) -> None: + sid = store.new_id() + await store.save(sid, store.new_record({"user_id": 42})) + loaded = await store.load(sid) + assert loaded is not None + assert loaded.record.data == {"user_id": 42} + assert 0 < loaded.absolute_remaining <= 600 + + async def test_absolute_deadline_passed_deletes_the_key( + self, store: RedisSessionStore, fake_async_redis + ) -> None: + sid = store.new_id() + key = store.session_key(sid) + await store.save(sid, store.new_record({"user_id": 42})) + await fake_async_redis.execute_command( + "HDEL", key, FIELD_ABSOLUTE + ) # simulate 'a' expiring + assert await store.load(sid) is None + assert await fake_async_redis.exists(key) == 0, ( + "a half-dead key must be removed so the index entry can follow" + ) + + async def test_no_such_session(self, store: RedisSessionStore) -> None: + assert await store.load(store.new_id()) is None + + async def test_idle_expired_deletes_the_key( + self, store: RedisSessionStore, fake_async_redis + ) -> None: + sid = store.new_id() + key = store.session_key(sid) + await store.save(sid, store.new_record({"user_id": 42})) + await fake_async_redis.execute_command("HDEL", key, FIELD_DATA) + assert await store.load(sid) is None + assert await fake_async_redis.exists(key) == 0 + + async def test_an_absolute_limit_of_zero_still_loads( + self, fake_async_redis + ) -> None: + """The bug that made every such session dead on arrival. + + An earlier design read ``HTTL`` returning ``-2`` as "the absolute + deadline passed" and wrote no field ``a`` at all when the limit was + disabled, so every session in such a deployment was unreadable the + moment it was created. + """ + store = RedisSessionStore(fake_async_redis, idle_ttl=60, absolute_ttl=0) + sid = store.new_id() + await store.save(sid, store.new_record({"user_id": 7})) + loaded = await store.load(sid) + assert loaded is not None + assert loaded.record.data == {"user_id": 7} + + async def test_an_invalid_id_never_reaches_redis( + self, store: RedisSessionStore + ) -> None: + assert await store.load("not a valid id") is None + + +class TestIdleRefresh: + async def test_load_restarts_the_idle_clock( + self, store: RedisSessionStore, fake_async_redis + ) -> None: + sid = store.new_id() + key = store.session_key(sid) + await store.save(sid, store.new_record({})) + await fake_async_redis.execute_command( + "HEXPIRE", key, 5, "FIELDS", 1, FIELD_DATA + ) + await store.load(sid) + assert _about(await _httl(fake_async_redis, key, FIELD_DATA), 60) + + async def test_refresh_false_leaves_the_idle_clock_alone( + self, store: RedisSessionStore, fake_async_redis + ) -> None: + sid = store.new_id() + key = store.session_key(sid) + await store.save(sid, store.new_record({})) + await fake_async_redis.execute_command( + "HEXPIRE", key, 5, "FIELDS", 1, FIELD_DATA + ) + await store.load(sid, refresh=False) + assert await _httl(fake_async_redis, key, FIELD_DATA) <= 5 + + async def test_touch_advances_the_idle_clock( + self, store: RedisSessionStore, fake_async_redis + ) -> None: + """Under ``refresh_on_load=False`` this is the only thing that does. + + Omitting this branch froze the idle clock and made every session + immortal until its absolute deadline. + """ + sid = store.new_id() + key = store.session_key(sid) + await store.save(sid, store.new_record({})) + await fake_async_redis.execute_command( + "HEXPIRE", key, 5, "FIELDS", 1, FIELD_DATA + ) + await store.touch(sid) + assert _about(await _httl(fake_async_redis, key, FIELD_DATA), 60) + + async def test_touch_does_not_extend_the_absolute_clock( + self, store: RedisSessionStore, fake_async_redis + ) -> None: + sid = store.new_id() + key = store.session_key(sid) + await store.save(sid, store.new_record({})) + await fake_async_redis.execute_command( + "HEXPIRE", key, 100, "FIELDS", 1, FIELD_ABSOLUTE + ) + await store.touch(sid) + assert await _httl(fake_async_redis, key, FIELD_ABSOLUTE) <= 100 + + +class TestSevenFourFallback: + """The 7.4 path writes the same fields with the same expirations. + + Section 13.1: the fallback costs an extra command and never changes + behaviour, so every assertion above must hold here too. + """ + + @pytest.fixture() + def old_store(self, fake_async_redis) -> RedisSessionStore: + caps = _StoreCapabilities(supports_hsetex=False) + return RedisSessionStore( + fake_async_redis, capabilities=caps, idle_ttl=60, absolute_ttl=600 + ) + + async def test_round_trip( + self, old_store: RedisSessionStore, fake_async_redis + ) -> None: + sid = old_store.new_id() + await old_store.save(sid, old_store.new_record({"user_id": 42})) + loaded = await old_store.load(sid) + assert loaded is not None + assert loaded.record.data == {"user_id": 42} + + async def test_same_ttls_as_the_modern_path( + self, old_store: RedisSessionStore, fake_async_redis + ) -> None: + sid = old_store.new_id() + await old_store.save(sid, old_store.new_record({})) + key = old_store.session_key(sid) + assert _about(await _httl(fake_async_redis, key, FIELD_ABSOLUTE), 600) + assert _about(await _httl(fake_async_redis, key, FIELD_DATA), 60) + + async def test_repeated_writes_still_never_extend_the_deadline( + self, old_store: RedisSessionStore, fake_async_redis + ) -> None: + sid = old_store.new_id() + key = old_store.session_key(sid) + await old_store.save(sid, old_store.new_record({})) + await fake_async_redis.execute_command( + "HEXPIRE", key, 100, "FIELDS", 1, FIELD_ABSOLUTE + ) + await old_store.save(sid, old_store.new_record({"n": 2})) + assert await _httl(fake_async_redis, key, FIELD_ABSOLUTE) <= 100 + + +class TestEnvelope: + def test_metadata_never_reaches_the_payload(self, store: RedisSessionStore) -> None: + """``starsessions`` puts it inside the session. We do not.""" + record = store.new_record({"user_id": 42}) + decoded = store.decode(store.encode(record)) + assert decoded.data == {"user_id": 42} + assert "__metadata__" not in decoded.data + assert "m" not in decoded.data + + def test_round_trip_preserves_metadata(self, store: RedisSessionStore) -> None: + record = SessionRecord( + data={"a": 1}, + metadata=SessionMetadata(created=1.5, last_access=2.5, lifetime=600), + ) + decoded = store.decode(store.encode(record)) + assert decoded.metadata == record.metadata + + def test_a_corrupt_record_raises_rather_than_looking_empty( + self, store: RedisSessionStore + ) -> None: + with pytest.raises(SessionStoreError, match="Unreadable session record"): + store.decode("{not json") + + +class TestDelete: + async def test_delete_removes_the_key( + self, store: RedisSessionStore, fake_async_redis + ) -> None: + sid = store.new_id() + await store.save(sid, store.new_record({})) + await store.delete(sid) + assert await fake_async_redis.exists(store.session_key(sid)) == 0 + assert await store.load(sid) is None + + +class TestConfigurationIsChecked: + @pytest.mark.parametrize("kwargs", [{"idle_ttl": -1}, {"absolute_ttl": -5}]) + def test_negative_ttls_are_refused(self, fake_async_redis, kwargs) -> None: + with pytest.raises(SessionConfigurationError, match="must not be negative"): + RedisSessionStore(fake_async_redis, **kwargs) + + def test_gc_ttl_must_be_positive(self, fake_async_redis) -> None: + with pytest.raises(SessionConfigurationError, match="must be positive"): + RedisSessionStore(fake_async_redis, gc_ttl=0) diff --git a/tests/unit/test_session_events.py b/tests/unit/test_session_events.py new file mode 100644 index 0000000..49391d4 --- /dev/null +++ b/tests/unit/test_session_events.py @@ -0,0 +1,179 @@ +"""Tests for :class:`SessionEvents`. + +The two properties that matter are the ones asserted hardest: the probe +degrades instead of raising, and a handler at tier ``none`` is silent rather +than broken. +""" + +from __future__ import annotations + +import pytest +from redis.exceptions import RedisError + +from redis_fastapi.session_events import REQUIRED_CONFIG, SessionEvents + + +class _FakeRedis: + """Just enough Redis to answer a probe.""" + + def __init__(self, version: str = "8.8.0", flags: str = REQUIRED_CONFIG) -> None: + self._version = version + self._flags = flags + self.raise_on_config = False + self.raise_on_info = False + + async def info(self, section: str) -> dict: + if self.raise_on_info: + raise RedisError("INFO is not available") + return {"redis_version": self._version} + + async def config_get(self, name: str) -> dict: + if self.raise_on_config: + raise RedisError("unknown command 'CONFIG'") + return {name: self._flags} + + +def _events(redis) -> SessionEvents: + return SessionEvents(redis, key_prefix="redis:fastapi") + + +class TestProbe: + async def test_a_capable_and_configured_server_gives_the_field_tier(self) -> None: + assert await _events(_FakeRedis()).probe() == "field" + + @pytest.mark.parametrize("version", ["7.4.0", "8.0.3", "8.6.1"]) + async def test_a_server_below_8_8_gives_none(self, version: str) -> None: + assert await _events(_FakeRedis(version=version)).probe() == "none" + + @pytest.mark.parametrize("flags", ["", "KEA", "AKE", "Kh", "ST"]) + async def test_missing_flags_give_none(self, flags: str) -> None: + """The subkey flags are independent of K and E. + + ``KEA`` enables every standard keyspace event and still delivers no + subkey notification, which is the commonest configuration mistake. + ``ST`` picks subkey channels but omits the hash class. + """ + assert await _events(_FakeRedis(flags=flags)).probe() == "none" + + async def test_config_being_forbidden_degrades_rather_than_raising(self) -> None: + """Managed Redis routinely restricts or renames CONFIG. + + A probe that propagated the error would break startup on every such + deployment, so failing to ask is itself an answer. + """ + redis = _FakeRedis() + redis.raise_on_config = True + assert await _events(redis).probe() == "none" + + async def test_info_being_forbidden_degrades_too(self) -> None: + redis = _FakeRedis() + redis.raise_on_info = True + assert await _events(redis).probe() == "none" + + async def test_an_unparseable_version_degrades(self) -> None: + assert await _events(_FakeRedis(version="unknown")).probe() == "none" + + +class TestSilentFallback: + async def test_start_succeeds_at_tier_none(self) -> None: + events = _events(_FakeRedis(version="7.4.0")) + await events.start() + assert events.tier == "none" + await events.stop() + + async def test_a_handler_at_tier_none_is_never_called(self) -> None: + """The documented, deliberate failure mode. + + Asserted rather than left to chance, because a handler that silently + never fires is indistinguishable from one that works - which is + exactly why the guide has to warn about it. + """ + called: list[str] = [] + events = _events(_FakeRedis(version="7.4.0")) + + @events.on_session_end + async def _(session_id: str, cause: str) -> None: + called.append(session_id) + + await events.start() + await events.stop() + assert called == [] + + async def test_one_warning_is_logged_and_startup_continues(self, caplog) -> None: + events = _events(_FakeRedis(version="7.4.0")) + with caplog.at_level("WARNING"): + await events.start() + assert len(caplog.records) == 1 + assert "8.8" in caplog.text + + +class TestParsingNotifications: + @pytest.fixture() + def events(self) -> SessionEvents: + return _events(_FakeRedis()) + + def test_an_expired_idle_field_reads_as_idle(self, events: SessionEvents) -> None: + payload = "26:redis:fastapi:session:abc|1:d" + assert events._parse(payload) == ("abc", "idle") + + def test_an_expired_deadline_reads_as_absolute(self, events: SessionEvents) -> None: + payload = "26:redis:fastapi:session:abc|1:a" + assert events._parse(payload) == ("abc", "absolute") + + def test_both_fields_at_once_read_as_absolute(self, events: SessionEvents) -> None: + """The idle clock is the shorter one, so it only expires alone.""" + payload = "26:redis:fastapi:session:abc|1:d,1:a" + assert events._parse(payload) == ("abc", "absolute") + + def test_another_application_s_hash_is_ignored(self, events: SessionEvents) -> None: + assert events._parse("9:other:key|1:d") is None + + def test_the_index_key_is_ignored(self, events: SessionEvents) -> None: + """Index entries expire constantly and are not session deaths.""" + assert events._parse("29:redis:fastapi:sessions-of:42|3:abc") is None + + def test_bytes_are_accepted(self, events: SessionEvents) -> None: + assert events._parse(b"26:redis:fastapi:session:abc|1:d") == ("abc", "idle") + + @pytest.mark.parametrize( + "payload", + ["garbage", "", "nolengthprefix|1:d", "26:redis:fastapi:session:abc|nope"], + ) + def test_a_malformed_payload_is_dropped_not_raised( + self, events: SessionEvents, payload: str + ) -> None: + assert events._parse(payload) is None + + +class TestDispatch: + async def test_every_handler_receives_the_event(self) -> None: + events = _events(_FakeRedis()) + seen: list[tuple[str, str]] = [] + + @events.on_session_end + async def first(session_id: str, cause: str) -> None: + seen.append(("first", cause)) + + @events.on_session_end + async def second(session_id: str, cause: str) -> None: + seen.append(("second", cause)) + + await events._dispatch("26:redis:fastapi:session:abc|1:d") + assert seen == [("first", "idle"), ("second", "idle")] + + async def test_one_raising_handler_does_not_stop_the_others(self) -> None: + events = _events(_FakeRedis()) + seen: list[str] = [] + + @events.on_session_end + async def broken(session_id: str, cause: str) -> None: + raise ValueError("boom") + + @events.on_session_end + async def working(session_id: str, cause: str) -> None: + seen.append(session_id) + + await events._dispatch("26:redis:fastapi:session:abc|1:d") + assert seen == ["abc"], ( + "a bad handler took down the subscriber and with it every other handler" + ) diff --git a/tests/unit/test_session_failures.py b/tests/unit/test_session_failures.py new file mode 100644 index 0000000..6f6901c --- /dev/null +++ b/tests/unit/test_session_failures.py @@ -0,0 +1,243 @@ +"""Tests for what happens when Redis is unreachable. + +Section 7 of the design makes the read/write policy deliberately asymmetric, +and the asymmetry is a security control rather than a convenience: + +* a failed **read** yields an empty session, so the caller looks anonymous and + the application's own authorization rejects them; +* a failed **write** always raises, because losing a login or a rotation is + the worst outcome in this design and must never be silent. + +Every ``except (RedisError, OSError)`` arm in the store is exercised here. +Before this file existed the whole policy was unexecuted code. +""" + +from __future__ import annotations + +import pytest +from redis.exceptions import ConnectionError as RedisConnectionError + +from redis_fastapi.config import get_settings +from redis_fastapi.session_backend import RedisSessionStore +from redis_fastapi.sessions import Session, SessionStoreError + + +class _BrokenPipeline: + def execute_command(self, *args: object, **kwargs: object) -> None: + return None + + async def execute(self) -> object: + raise RedisConnectionError("Redis is down") + + +class _BrokenRedis: + """A client whose every command fails, as an unreachable server does.""" + + def pipeline(self, transaction: bool = True) -> _BrokenPipeline: + return _BrokenPipeline() + + async def execute_command(self, *args: object, **kwargs: object) -> object: + raise RedisConnectionError("Redis is down") + + async def delete(self, *keys: str) -> int: + raise RedisConnectionError("Redis is down") + + async def hdel(self, key: str, *fields: str) -> int: + raise RedisConnectionError("Redis is down") + + async def hgetall(self, key: str) -> dict: + raise RedisConnectionError("Redis is down") + + async def info(self, section: str) -> dict: + raise RedisConnectionError("Redis is down") + + +@pytest.fixture() +def broken() -> RedisSessionStore: + get_settings.cache_clear() + return RedisSessionStore(_BrokenRedis(), idle_ttl=60, absolute_ttl=600) + + +@pytest.fixture() +def fail_closed(monkeypatch) -> RedisSessionStore: + get_settings.cache_clear() + monkeypatch.setenv("REDIS_SESSION_FAIL_CLOSED", "true") + get_settings.cache_clear() + store = RedisSessionStore(_BrokenRedis(), idle_ttl=60, absolute_ttl=600) + yield store + get_settings.cache_clear() + + +class TestReadsFailOpen: + async def test_a_failed_load_yields_no_session( + self, broken: RedisSessionStore + ) -> None: + """The user looks anonymous; a protected route stays protected. + + It never depended on this read succeeding - the application's own + authorization dependency finds no user and returns a login page. + """ + assert await broken.load(broken.new_id()) is None + + async def test_a_failed_load_logs_a_warning( + self, broken: RedisSessionStore, caplog + ) -> None: + with caplog.at_level("WARNING"): + await broken.load(broken.new_id()) + assert "Session load failed" in caplog.text + + async def test_a_failed_listing_returns_empty( + self, broken: RedisSessionStore + ) -> None: + assert await broken.list_for_subject("42") == [] + + async def test_a_failed_count_returns_zero(self, broken: RedisSessionStore) -> None: + assert await broken.count_for_subject("42") == 0 + + +class TestFailClosedFlipsReadsOnly: + async def test_a_failed_load_raises(self, fail_closed: RedisSessionStore) -> None: + """For a deployment that prefers a 503 to an anonymous page.""" + with pytest.raises(SessionStoreError, match="Could not load session"): + await fail_closed.load(fail_closed.new_id()) + + async def test_the_setting_is_read_per_call_not_per_store( + self, broken: RedisSessionStore, monkeypatch + ) -> None: + assert await broken.load(broken.new_id()) is None + monkeypatch.setenv("REDIS_SESSION_FAIL_CLOSED", "true") + get_settings.cache_clear() + with pytest.raises(SessionStoreError): + await broken.load(broken.new_id()) + get_settings.cache_clear() + + +class TestWritesAlwaysRaise: + """Whatever ``session_fail_closed`` says. Section 7's asymmetry.""" + + async def test_save_raises(self, broken: RedisSessionStore) -> None: + with pytest.raises(SessionStoreError, match="Could not save session"): + await broken.save(broken.new_id(), broken.new_record({"user_id": 42})) + + async def test_touch_raises(self, broken: RedisSessionStore) -> None: + with pytest.raises(SessionStoreError, match="Could not refresh session"): + await broken.touch(broken.new_id()) + + async def test_delete_raises(self, broken: RedisSessionStore) -> None: + with pytest.raises(SessionStoreError, match="Could not delete session"): + await broken.delete(broken.new_id()) + + async def test_indexing_raises(self, broken: RedisSessionStore) -> None: + record = broken.new_record({"user_id": "42"}) + with pytest.raises(SessionStoreError, match="Could not index"): + await broken.index("42", broken.new_id(), record, absolute_remaining=600) + + async def test_revoke_all_raises(self, broken: RedisSessionStore) -> None: + with pytest.raises(SessionStoreError, match="Could not revoke"): + await broken.revoke_all("42") + + async def test_revoke_id_raises_rather_than_reporting_success( + self, broken: RedisSessionStore + ) -> None: + """Returning False here would read as "not your session". + + The caller cannot distinguish that from a genuine refusal, so a store + failure has to surface as one. + """ + with pytest.raises(SessionStoreError, match="Could not read the session index"): + await broken.revoke_id(broken.new_id(), subject="42") + + async def test_a_write_still_raises_under_fail_open( + self, broken: RedisSessionStore + ) -> None: + assert get_settings().session_fail_closed is False + with pytest.raises(SessionStoreError): + await broken.save(broken.new_id(), broken.new_record({})) + + +class TestTidyUpNeverFailsARequest: + async def test_a_failed_cleanup_delete_is_swallowed( + self, broken: RedisSessionStore, caplog + ) -> None: + """This runs on the tidy-up path of a read. + + Failing the request because we could not remove a session that is + already gone turns a cosmetic problem into an outage. + """ + with caplog.at_level("WARNING"): + await broken._safe_delete(broken.new_id()) + assert "Could not remove a dead session key" in caplog.text + + async def test_a_failed_liveness_check_reports_nothing_alive( + self, broken: RedisSessionStore + ) -> None: + assert await broken._verify(["a" * 30]) == set() + + +class TestUnreachableStates: + async def test_a_field_with_no_expiry_is_treated_as_absent( + self, fake_async_redis, caplog + ) -> None: + """The ``-1`` row of the state table, which must be unreachable. + + Reaching it means something outside this store wrote the key, so the + store says so loudly and refuses the session rather than guessing. + """ + store = RedisSessionStore(fake_async_redis, idle_ttl=60, absolute_ttl=600) + sid = store.new_id() + key = store.session_key(sid) + await fake_async_redis.hset(key, mapping={"a": "1", "d": "{}"}) + + with caplog.at_level("ERROR"): + assert await store.load(sid) is None + assert "no expiry" in caplog.text + assert await fake_async_redis.exists(key) == 0 + + async def test_a_corrupt_record_raises_rather_than_signing_the_user_out( + self, fake_async_redis + ) -> None: + store = RedisSessionStore(fake_async_redis, idle_ttl=60, absolute_ttl=600) + sid = store.new_id() + await store.save(sid, store.new_record({"user_id": 42})) + await fake_async_redis.execute_command( + "HSETEX", store.session_key(sid), "KEEPTTL", "FIELDS", 1, "d", "{not json" + ) + with pytest.raises(SessionStoreError, match="Unreadable session record"): + await store.load(sid) + + +class TestOsErrorsCountToo: + async def test_an_oserror_is_handled_like_a_redis_error(self) -> None: + """A dropped socket surfaces as OSError, not RedisError.""" + + class _SocketDied(_BrokenRedis): + def pipeline(self, transaction: bool = True) -> object: + class _P: + def execute_command(self, *a: object, **k: object) -> None: + return None + + async def execute(self) -> object: + raise OSError("connection reset by peer") + + return _P() + + get_settings.cache_clear() + store = RedisSessionStore(_SocketDied(), idle_ttl=60, absolute_ttl=600) + assert await store.load(store.new_id()) is None + with pytest.raises(SessionStoreError): + await store.save(store.new_id(), store.new_record({})) + + +class TestRevokeOnABrokenStore: + async def test_revoke_surfaces_the_failure(self, broken: RedisSessionStore) -> None: + """Revocation that quietly does nothing is the worst kind.""" + session = Session({"user_id": 42}) + session.sid = "a" * 30 + with pytest.raises(SessionStoreError): + await broken.revoke(session) + + async def test_rotate_surfaces_the_failure(self, broken: RedisSessionStore) -> None: + session = Session({"user_id": 42}) + session.sid = "a" * 30 + with pytest.raises(SessionStoreError): + await broken.rotate(session, subject="42") diff --git a/tests/unit/test_session_index.py b/tests/unit/test_session_index.py new file mode 100644 index 0000000..9b06502 --- /dev/null +++ b/tests/unit/test_session_index.py @@ -0,0 +1,241 @@ +"""Tests for the subject index: listing, counting and bulk revocation. + +The index answers "which sessions has this user got?", which the cookie +cannot. Its defining property is that it is an **upper bound**: an entry can +outlive the session it names, so every read verifies before reporting. +""" + +from __future__ import annotations + +import pytest + +from redis_fastapi.config import get_settings +from redis_fastapi.session_backend import ( + FIELD_ABSOLUTE, + FIELD_DATA, + RedisSessionStore, +) + + +@pytest.fixture() +def store(fake_async_redis) -> RedisSessionStore: + get_settings.cache_clear() + return RedisSessionStore(fake_async_redis, idle_ttl=60, absolute_ttl=600) + + +async def _make(store: RedisSessionStore, subject: str, **data) -> str: + """Create a session and index it under *subject*.""" + sid = store.new_id() + record = store.new_record({"user_id": subject, **data}) + await store.save(sid, record) + await store.index(subject, sid, record, absolute_remaining=600) + return sid + + +class TestListing: + async def test_lists_every_live_session(self, store: RedisSessionStore) -> None: + first = await _make(store, "42") + second = await _make(store, "42") + listed = {info.session_id for info in await store.list_for_subject("42")} + assert listed == {first, second} + + async def test_a_subject_sees_only_their_own( + self, store: RedisSessionStore + ) -> None: + mine = await _make(store, "42") + await _make(store, "99") + listed = {info.session_id for info in await store.list_for_subject("42")} + assert listed == {mine} + + async def test_an_unknown_subject_lists_nothing( + self, store: RedisSessionStore + ) -> None: + assert await store.list_for_subject("nobody") == [] + + async def test_a_listing_carries_timestamps_without_reading_the_record( + self, store: RedisSessionStore + ) -> None: + await _make(store, "42") + info = (await store.list_for_subject("42"))[0] + assert info.created > 0 + assert info.last_access > 0 + + +class TestTheIndexIsAnUpperBound: + async def test_an_idle_dead_session_is_not_reported( + self, store: RedisSessionStore, fake_async_redis + ) -> None: + """The failure the index exists to avoid. + + Most sessions die of idleness long before their absolute deadline, and + the index entry's TTL *is* the absolute deadline - so the entry + routinely outlives the session. Reporting it would show a user a + device they are not signed in on, with a sign-out button that does + nothing. + """ + live = await _make(store, "42") + idle_dead = await _make(store, "42") + await fake_async_redis.execute_command( + "HDEL", store.session_key(idle_dead), FIELD_DATA + ) + + listed = {info.session_id for info in await store.list_for_subject("42")} + assert listed == {live} + + async def test_a_session_past_its_absolute_deadline_is_not_reported( + self, store: RedisSessionStore, fake_async_redis + ) -> None: + """Why liveness checks both clocks, not just the idle one.""" + live = await _make(store, "42") + absolute_dead = await _make(store, "42") + await fake_async_redis.execute_command( + "HDEL", store.session_key(absolute_dead), FIELD_ABSOLUTE + ) + + listed = {info.session_id for info in await store.list_for_subject("42")} + assert listed == {live} + + async def test_reading_prunes_the_dead_entry( + self, store: RedisSessionStore, fake_async_redis + ) -> None: + dead = await _make(store, "42") + await fake_async_redis.execute_command( + "HDEL", store.session_key(dead), FIELD_DATA + ) + await store.list_for_subject("42") + remaining = await fake_async_redis.hgetall(store.index_key("42")) + assert dead.encode() not in remaining + + +class TestCounting: + async def test_count_is_o1_and_unverified_by_default( + self, store: RedisSessionStore, fake_async_redis + ) -> None: + await _make(store, "42") + dead = await _make(store, "42") + await fake_async_redis.execute_command( + "HDEL", store.session_key(dead), FIELD_DATA + ) + # The fast answer counts the entry that is about to be pruned. + assert await store.count_for_subject("42") == 2 + + async def test_a_limit_makes_the_count_exact_when_it_matters( + self, store: RedisSessionStore, fake_async_redis + ) -> None: + """The verification round trip is paid only by the request refused.""" + await _make(store, "42") + dead = await _make(store, "42") + await fake_async_redis.execute_command( + "HDEL", store.session_key(dead), FIELD_DATA + ) + assert await store.count_for_subject("42", limit=2) == 1 + + async def test_below_the_limit_takes_the_fast_path( + self, store: RedisSessionStore + ) -> None: + await _make(store, "42") + assert await store.count_for_subject("42", limit=5) == 1 + + +class TestRevokeById: + async def test_revokes_a_session_of_the_right_subject( + self, store: RedisSessionStore + ) -> None: + sid = await _make(store, "42") + assert await store.revoke_id(sid, subject="42") is True + assert await store.load(sid) is None + + async def test_refuses_a_session_belonging_to_someone_else( + self, store: RedisSessionStore + ) -> None: + """Without the subject check this is a cross-user revocation primitive. + + Any handler taking an ID from a request could end a stranger's + session, so the subject is required and verified against the index. + """ + theirs = await _make(store, "99") + assert await store.revoke_id(theirs, subject="42") is False + assert await store.load(theirs) is not None, "a stranger's session was ended" + + async def test_an_unknown_id_is_refused_rather_than_erroring( + self, store: RedisSessionStore + ) -> None: + assert await store.revoke_id(store.new_id(), subject="42") is False + + async def test_a_malformed_id_never_reaches_redis( + self, store: RedisSessionStore + ) -> None: + assert await store.revoke_id("not valid", subject="42") is False + + +class TestRevokeAll: + async def test_ends_every_session_of_a_subject( + self, store: RedisSessionStore + ) -> None: + first = await _make(store, "42") + second = await _make(store, "42") + assert await store.revoke_all("42") == 2 + assert await store.load(first) is None + assert await store.load(second) is None + + async def test_leaves_other_subjects_alone(self, store: RedisSessionStore) -> None: + mine = await _make(store, "42") + theirs = await _make(store, "99") + await store.revoke_all("42") + assert await store.load(mine) is None + assert await store.load(theirs) is not None + + async def test_clears_the_index_too( + self, store: RedisSessionStore, fake_async_redis + ) -> None: + await _make(store, "42") + await store.revoke_all("42") + assert await store.list_for_subject("42") == [] + + async def test_an_unknown_subject_revokes_nothing( + self, store: RedisSessionStore + ) -> None: + assert await store.revoke_all("nobody") == 0 + + +class TestIndexEntryLifetime: + async def test_the_entry_expires_with_the_session( + self, store: RedisSessionStore, fake_async_redis + ) -> None: + sid = await _make(store, "42") + reply = await fake_async_redis.execute_command( + "HTTL", store.index_key("42"), "FIELDS", 1, sid + ) + assert 0 < int(reply[0]) <= 600 + + async def test_re_assertion_never_extends_the_entry( + self, store: RedisSessionStore, fake_async_redis + ) -> None: + """The bug no end-state assertion would catch. + + Writing the entry with a relative full lifetime restarts its clock on + every request, so the index outlives the session it points at. The + assertion has to be that the TTL **decreased**. + """ + sid = await _make(store, "42") + key = store.index_key("42") + await fake_async_redis.execute_command("HEXPIRE", key, 100, "FIELDS", 1, sid) + + record = store.new_record({"user_id": "42"}) + await store.index( + "42", sid, record, absolute_remaining=90 + ) # the remainder, not 600 + + reply = await fake_async_redis.execute_command("HTTL", key, "FIELDS", 1, sid) + assert int(reply[0]) <= 100, ( + "the index entry's clock was restarted - it can now outlive the " + "session it names" + ) + + async def test_an_expired_remainder_writes_no_entry( + self, store: RedisSessionStore, fake_async_redis + ) -> None: + sid = store.new_id() + record = store.new_record({}) + await store.index("42", sid, record, absolute_remaining=0) + assert await fake_async_redis.hgetall(store.index_key("42")) == {} diff --git a/tests/unit/test_session_middleware.py b/tests/unit/test_session_middleware.py new file mode 100644 index 0000000..bf28650 --- /dev/null +++ b/tests/unit/test_session_middleware.py @@ -0,0 +1,386 @@ +"""End-to-end tests for the session middleware, against ``fakeredis``. + +These drive a real FastAPI app through ``TestClient``, so they exercise the +eager load, the response rule, the cookie, and - most importantly - rotation +happening with **no call from the application**. +""" + +from __future__ import annotations + +from http.cookies import SimpleCookie + +import pytest +from fastapi import FastAPI, Request, Response +from fastapi.testclient import TestClient + +from redis_fastapi.config import get_settings +from redis_fastapi.deps import SessionDep, SessionStoreDep, get_session_store +from redis_fastapi.session_backend import RedisSessionStore +from redis_fastapi.sessions import add_redis_sessions + + +def _set_cookies(response) -> dict[str, SimpleCookie]: + """Parse every ``Set-Cookie`` on a response, keyed by cookie name.""" + out: dict[str, SimpleCookie] = {} + for raw in response.headers.get_list("set-cookie"): + jar: SimpleCookie = SimpleCookie() + jar.load(raw) + for name in jar: + out[name] = jar + return out + + +@pytest.fixture() +def app(fake_async_redis, monkeypatch) -> FastAPI: + get_settings.cache_clear() + monkeypatch.setenv("REDIS_SESSION_COOKIE_HTTPS_ONLY", "false") + monkeypatch.setenv("REDIS_SESSION_IDLE_TTL", "60") + monkeypatch.setenv("REDIS_SESSION_ABSOLUTE_TTL", "600") + get_settings.cache_clear() + + application = FastAPI() + add_redis_sessions(application) + + store = RedisSessionStore(fake_async_redis, idle_ttl=60, absolute_ttl=600) + + async def _store(request: Request) -> RedisSessionStore: + return store + + application.dependency_overrides[get_session_store] = _store + # The middleware resolves the store directly, not through DI, so point it + # at the same fake instance. + for mw in application.user_middleware: + if "store_factory" in mw.kwargs: + mw.kwargs["store_factory"] = _store + + @application.get("/read") + async def read(session: SessionDep) -> dict: + return {"user_id": session.get("user_id")} + + @application.get("/untouched") + async def untouched() -> dict: + return {"ok": True} + + @application.post("/login") + async def login(session: SessionDep) -> dict: + session["user_id"] = 42 + return {"ok": True} + + @application.post("/login-fails") + async def login_fails(session: SessionDep, response: Response) -> dict: + session["user_id"] = 42 + response.status_code = 401 + return {"ok": False} + + @application.post("/write") + async def write(session: SessionDep) -> dict: + session["counter"] = session.get("counter", 0) + 1 + return {"counter": session["counter"]} + + @application.post("/logout") + async def logout(session: SessionDep, store: SessionStoreDep) -> dict: + await store.revoke(session) + return {"ok": True} + + @application.post("/step-up") + async def step_up(session: SessionDep, store: SessionStoreDep) -> dict: + return {"sid": await store.rotate(session, subject="42")} + + @application.post("/promote") + async def promote(session: SessionDep) -> dict: + session["user_id"] = 99 + return {"ok": True} + + application.state._store = store + return application + + +@pytest.fixture() +def client(app: FastAPI) -> TestClient: + return TestClient(app) + + +class TestZeroCostPaths: + def test_no_cookie_and_no_access_writes_nothing(self, client: TestClient) -> None: + response = client.get("/untouched") + assert response.status_code == 200 + assert "set-cookie" not in response.headers + assert "vary" not in response.headers + + def test_reading_an_empty_session_sets_no_cookie(self, client: TestClient) -> None: + """Reading changes nothing, so there is nothing to persist.""" + response = client.get("/read") + assert response.json() == {"user_id": None} + assert "set-cookie" not in response.headers + + +class TestVaryCookie: + def test_vary_is_emitted_when_the_session_was_accessed( + self, client: TestClient + ) -> None: + """Without it a shared cache can serve one user's page to another.""" + assert client.get("/read").headers["vary"] == "Cookie" + + def test_vary_is_absent_when_it_was_not(self, client: TestClient) -> None: + assert "vary" not in client.get("/untouched").headers + + +class TestWriteAndRoundTrip: + def test_a_write_sets_a_cookie_and_survives_the_next_request( + self, client: TestClient + ) -> None: + first = client.post("/write") + assert first.json() == {"counter": 1} + assert "session" in _set_cookies(first) + + second = client.post("/write") + assert second.json() == {"counter": 2}, "the session did not round-trip" + + def test_the_cookie_carries_no_session_data(self, client: TestClient) -> None: + response = client.post("/login") + value = _set_cookies(response)["session"]["session"].value + assert "42" not in value + assert "user_id" not in value + + def test_cookie_attributes_follow_the_settings(self, client: TestClient) -> None: + morsel = _set_cookies(client.post("/write"))["session"]["session"] + assert morsel["httponly"] is True + assert morsel["samesite"] == "Lax" + assert morsel["path"] == "/" + # min(idle, absolute remaining) - the idle clock is the shorter one. + assert int(morsel["max-age"]) == 60 + + +class TestAutomaticRotation: + """F-2: the application never calls rotation, and the middleware does.""" + + def test_signing_in_rotates_with_no_call_from_the_handler( + self, client: TestClient + ) -> None: + anonymous = client.post("/write") + before = _set_cookies(anonymous)["session"]["session"].value + + after = _set_cookies(client.post("/login"))["session"]["session"].value + assert after != before, ( + "the session ID did not change on sign-in - this is the session " + "fixation defence and it is not optional" + ) + + async def test_the_old_key_is_gone_after_a_rotation( + self, client: TestClient, app: FastAPI, fake_async_redis + ) -> None: + """The old key goes **first**, so an interrupted rotation signs out.""" + store = app.state._store + before = _set_cookies(client.post("/write"))["session"]["session"].value + client.post("/login") + assert await fake_async_redis.exists(store.session_key(before)) == 0 + + def test_a_privilege_change_rotates_too(self, client: TestClient) -> None: + first = _set_cookies(client.post("/login"))["session"]["session"].value + second = _set_cookies(client.post("/promote"))["session"]["session"].value + assert second != first + + def test_an_ordinary_write_does_not_rotate(self, client: TestClient) -> None: + """Rotating when we need not is harmless; doing it constantly is not. + + The failure asymmetry runs the right way, but a design that rotated on + every write would churn the keyspace and break "this device" listings, + so the stable case is asserted too. + """ + client.post("/login") + first = _set_cookies(client.post("/write"))["session"]["session"].value + second = _set_cookies(client.post("/write"))["session"]["session"].value + assert first == second + + def test_a_failed_sign_in_persists_nothing( + self, client: TestClient, app: FastAPI, fake_async_redis + ) -> None: + """Section 4.3's one exception. + + Persisting the data while skipping the rotation would store the new + identity against the old, unrotated ID - the exact fixation this + design prevents, arrived at by being helpful. + """ + response = client.post("/login-fails") + assert response.status_code == 401 + assert "set-cookie" not in response.headers + + # And nothing was written, so a later read finds no user. + assert client.get("/read").json() == {"user_id": None} + + +class TestCookieValidation: + def test_a_malformed_cookie_yields_a_new_session(self, client: TestClient) -> None: + client.cookies.set("session", "not a valid id") + assert client.get("/read").json() == {"user_id": None} + + def test_an_injection_attempt_never_reaches_a_header( + self, client: TestClient + ) -> None: + client.cookies.set("session", "abc\r\nSet-Cookie: evil=1") + response = client.get("/read") + assert response.status_code == 200 + assert "evil" not in str(response.headers) + + +class TestSkip: + def test_a_skipped_request_costs_no_redis_call( + self, fake_async_redis, monkeypatch + ) -> None: + get_settings.cache_clear() + monkeypatch.setenv("REDIS_SESSION_COOKIE_HTTPS_ONLY", "false") + get_settings.cache_clear() + + calls: list[str] = [] + store = RedisSessionStore(fake_async_redis) + + async def _store(request: Request) -> RedisSessionStore: + calls.append("built") + return store + + application = FastAPI() + add_redis_sessions(application, skip=lambda request: True) + for mw in application.user_middleware: + if "store_factory" in mw.kwargs: + mw.kwargs["store_factory"] = _store + + @application.get("/x") + async def x(session: SessionDep) -> dict: + return {"empty": len(session) == 0} + + with TestClient(application) as c: + assert c.get("/x").json() == {"empty": True} + assert calls == [], "a skipped request must not even build the store" + + +class TestRevokeClearsTheCookie: + """The header that actually signs a user out of their browser. + + ``store.revoke()`` removing the key is only half of it: OWASP requires the + session be invalidated on **both** sides, and the client half is this + ``Set-Cookie``. It was previously untested. + """ + + def test_logout_emits_a_clearing_cookie(self, client: TestClient) -> None: + client.post("/login") + response = client.post("/logout") + morsel = _set_cookies(response)["session"]["session"] + assert morsel.value == "" + assert morsel["max-age"] == "0" + + def test_the_clearing_cookie_repeats_the_scoping_attributes( + self, client: TestClient + ) -> None: + """A browser keeps the original unless every scoping attribute matches.""" + client.post("/login") + morsel = _set_cookies(client.post("/logout"))["session"]["session"] + assert morsel["path"] == "/" + assert morsel["samesite"] == "Lax" + assert morsel["httponly"] is True + + def test_the_session_is_gone_afterwards(self, client: TestClient) -> None: + client.post("/login") + client.post("/logout") + assert client.get("/read").json() == {"user_id": None} + + def test_revoking_an_untouched_session_is_safe(self, client: TestClient) -> None: + response = client.post("/logout") + assert response.status_code == 200 + + +class TestCookieOnlyMode: + """Both clocks disabled: the browser decides, so there is no ``Max-Age``.""" + + def test_no_max_age_is_emitted(self, fake_async_redis, monkeypatch) -> None: + get_settings.cache_clear() + monkeypatch.setenv("REDIS_SESSION_COOKIE_HTTPS_ONLY", "false") + monkeypatch.setenv("REDIS_SESSION_IDLE_TTL", "0") + monkeypatch.setenv("REDIS_SESSION_ABSOLUTE_TTL", "0") + get_settings.cache_clear() + + application = FastAPI() + add_redis_sessions(application) + store = RedisSessionStore(fake_async_redis, idle_ttl=0, absolute_ttl=0) + + async def _store(request: Request) -> RedisSessionStore: + return store + + for mw in application.user_middleware: + if "store_factory" in mw.kwargs: + mw.kwargs["store_factory"] = _store + + @application.post("/w") + async def w(session: SessionDep) -> dict: + session["n"] = 1 + return {} + + with TestClient(application) as c: + morsel = _set_cookies(c.post("/w"))["session"]["session"] + assert morsel["max-age"] == "", "cookie-only mode must not pin a lifetime" + get_settings.cache_clear() + + +class TestSecureAndDomainAttributes: + def test_secure_and_domain_are_emitted_when_configured( + self, fake_async_redis, monkeypatch + ) -> None: + get_settings.cache_clear() + monkeypatch.setenv("REDIS_SESSION_COOKIE_HTTPS_ONLY", "true") + monkeypatch.setenv("REDIS_SESSION_COOKIE_DOMAIN", "example.com") + get_settings.cache_clear() + + application = FastAPI() + add_redis_sessions(application) + store = RedisSessionStore(fake_async_redis) + + async def _store(request: Request) -> RedisSessionStore: + return store + + for mw in application.user_middleware: + if "store_factory" in mw.kwargs: + mw.kwargs["store_factory"] = _store + + @application.post("/w") + async def w(session: SessionDep) -> dict: + session["n"] = 1 + return {} + + with TestClient(application, base_url="https://example.com") as c: + raw = c.post("/w").headers["set-cookie"] + assert "Secure" in raw + assert "Domain=example.com" in raw + assert "HttpOnly" in raw + get_settings.cache_clear() + + +class TestAnExplicitRotateIsNotRepeated: + """``store.rotate()`` does the work; the middleware must not redo it. + + ``rotate()`` used to leave the pending-rotation flag set, so the + middleware rotated a second time at response start. That deleted the key + ``rotate()`` had just written and made the ID it returned to the caller a + stale value - a handler that returned it, or revoked it, was operating on + a session that no longer existed. + """ + + def test_the_returned_id_is_the_one_in_the_cookie(self, client: TestClient) -> None: + client.post("/login") + response = client.post("/step-up") + returned = response.json()["sid"] + in_cookie = _set_cookies(response)["session"]["session"].value + assert returned == in_cookie + + async def test_the_returned_id_names_a_live_session( + self, client: TestClient, app: FastAPI, fake_async_redis + ) -> None: + store = app.state._store + client.post("/login") + returned = client.post("/step-up").json()["sid"] + assert await fake_async_redis.exists(store.session_key(returned)) == 1 + + def test_the_session_survives_an_explicit_rotation( + self, client: TestClient + ) -> None: + client.post("/login") + client.post("/step-up") + assert client.get("/read").json() == {"user_id": 42} diff --git a/tests/unit/test_session_setup.py b/tests/unit/test_session_setup.py new file mode 100644 index 0000000..a3f2228 --- /dev/null +++ b/tests/unit/test_session_setup.py @@ -0,0 +1,229 @@ +"""Tests for the session setup surface: the builder, DI, and the sync facade.""" + +from __future__ import annotations + +import pytest +from fastapi import FastAPI, Request +from fastapi.testclient import TestClient + +from redis_fastapi.config import get_settings +from redis_fastapi.deps import ( + SessionDep, + SessionStoreDep, + SyncSessionStoreDep, + get_session_store, + get_sync_session_store, +) +from redis_fastapi.session_backend import RedisSessionStore, SyncSessionStore +from redis_fastapi.sessions import ( + SessionConfigurationError, + SessionMiddleware, + add_redis_sessions, +) +from redis_fastapi.setup import FastAPIRedis + + +@pytest.fixture(autouse=True) +def _plain_http(monkeypatch): + get_settings.cache_clear() + monkeypatch.setenv("REDIS_SESSION_COOKIE_HTTPS_ONLY", "false") + get_settings.cache_clear() + yield + get_settings.cache_clear() + + +class TestTheBuilder: + def test_sessions_registers_the_middleware(self) -> None: + app = FastAPI() + FastAPIRedis(app).sessions() + assert any(m.cls is SessionMiddleware for m in app.user_middleware) + + def test_calling_it_twice_is_a_no_op(self) -> None: + """Two middlewares would load and save the session twice per request.""" + app = FastAPI() + FastAPIRedis(app).sessions().sessions() + count = sum(1 for m in app.user_middleware if m.cls is SessionMiddleware) + assert count == 1 + + def test_it_chains(self) -> None: + app = FastAPI() + assert isinstance(FastAPIRedis(app).sessions(), FastAPIRedis) + + def test_principal_keys_reach_the_middleware(self) -> None: + app = FastAPI() + FastAPIRedis(app).sessions(principal_keys=["user_id", "role"]) + middleware = next(m for m in app.user_middleware if m.cls is SessionMiddleware) + assert middleware.kwargs["principal_of"] is not None + + +class TestConfigurationIsChecked: + def test_samesite_none_without_secure_is_refused(self, monkeypatch) -> None: + """Browsers reject such a cookie outright. + + Left unchecked the session would simply never be stored, which + presents as "login does nothing" with no error anywhere. + """ + get_settings.cache_clear() + monkeypatch.setenv("REDIS_SESSION_COOKIE_SAME_SITE", "none") + monkeypatch.setenv("REDIS_SESSION_COOKIE_HTTPS_ONLY", "false") + get_settings.cache_clear() + with pytest.raises(SessionConfigurationError, match="Secure"): + add_redis_sessions(FastAPI()) + + +class TestSessionOf: + def test_a_missing_middleware_says_so(self) -> None: + """Otherwise this surfaces as a KeyError deep inside a handler.""" + app = FastAPI() + + @app.get("/x") + async def x(session: SessionDep) -> dict: + return {} + + with ( + TestClient(app) as client, + pytest.raises(SessionConfigurationError, match="No session"), + ): + client.get("/x") + + +class TestDependencies: + async def test_get_session_store_reuses_the_pool_capability_cache( + self, fake_async_redis + ) -> None: + """Probing once per process, not once per request.""" + from redis_fastapi.deps import _get_pool_state + + app = FastAPI() + state = _get_pool_state(app) + state.async_pool = fake_async_redis.connection_pool + + class _Req: + def __init__(self, application: FastAPI) -> None: + self.app = application + + first = await get_session_store(_Req(app)) # type: ignore[arg-type] + second = await get_session_store(_Req(app)) # type: ignore[arg-type] + assert isinstance(first, RedisSessionStore) + assert first._caps is second._caps + + async def test_get_sync_session_store_wraps_the_async_one( + self, fake_async_redis + ) -> None: + from redis_fastapi.deps import _get_pool_state + + app = FastAPI() + _get_pool_state(app).async_pool = fake_async_redis.connection_pool + + class _Req: + def __init__(self, application: FastAPI) -> None: + self.app = application + + store = await get_sync_session_store(_Req(app)) # type: ignore[arg-type] + assert isinstance(store, SyncSessionStore) + + +class TestAnnotationsResolveUnderPep563: + """A regression test for a defect this file's own suite exposed. + + FastAPI resolves an endpoint's annotations with ``get_type_hints``, which + evaluates the forward reference inside ``Annotated[...]`` against + ``deps.py``'s namespace. While the session types were imported there only + under ``TYPE_CHECKING``, that raised ``NameError`` and FastAPI silently + demoted the parameter to a query parameter - so any endpoint in a module + using ``from __future__ import annotations`` answered 422 instead of + receiving its session. Nothing type-checks this; only a running endpoint + catches it. + """ + + @pytest.mark.parametrize( + "alias", [SessionDep, SessionStoreDep, SyncSessionStoreDep] + ) + def test_the_inner_type_is_a_class_not_a_string(self, alias) -> None: + import typing + + inner = typing.get_args(alias)[0] + assert isinstance(inner, type), ( + f"{inner!r} is still a forward reference; FastAPI will not be able " + "to resolve it and will treat the parameter as a query parameter" + ) + + +class TestSyncEndpoint: + def test_a_def_endpoint_can_use_the_store(self, fake_async_redis) -> None: + """The anyio bridge only works from a FastAPI worker thread. + + Driving it through a real ``def`` endpoint is the only way to test it + honestly - calling it directly from the main thread raises. + """ + app = FastAPI() + add_redis_sessions(app) + store = RedisSessionStore(fake_async_redis, idle_ttl=60, absolute_ttl=600) + + async def _async_store(request: Request) -> RedisSessionStore: + return store + + async def _sync_store(request: Request) -> SyncSessionStore: + return SyncSessionStore(store) + + for mw in app.user_middleware: + if "store_factory" in mw.kwargs: + mw.kwargs["store_factory"] = _async_store + app.dependency_overrides[get_sync_session_store] = _sync_store + + @app.post("/login") + def login(session: SessionDep) -> dict: + session["user_id"] = 42 + return {"ok": True} + + @app.get("/count") + def count(store: SyncSessionStoreDep) -> dict: + return {"n": store.count_for_subject("42")} + + @app.get("/devices") + def devices(session: SessionDep, store: SyncSessionStoreDep) -> dict: + return { + "n": len(store.list_for_subject("42")), + "current": store.session_id(session) is not None, + } + + @app.post("/rotate") + def rotate(session: SessionDep, store: SyncSessionStoreDep) -> dict: + return {"sid": store.rotate(session, subject="42")} + + @app.post("/step-up") + def step_up(session: SessionDep, store: SyncSessionStoreDep) -> dict: + store.reauthenticate(session) + return {"ok": True} + + @app.post("/revoke-one") + def revoke_one(sid: str, store: SyncSessionStoreDep) -> dict: + return {"ended": store.revoke_id(sid, subject="42")} + + @app.post("/revoke-all") + def revoke_all(store: SyncSessionStoreDep) -> dict: + return {"n": store.revoke_all("42")} + + @app.post("/logout") + def logout(session: SessionDep, store: SyncSessionStoreDep) -> dict: + store.revoke(session) + return {"ok": True} + + with TestClient(app) as client: + assert client.post("/login").json() == {"ok": True} + assert client.get("/count").json() == {"n": 1} + assert client.get("/devices").json() == {"n": 1, "current": True} + + # An explicit rotate must not be followed by a second rotation + # from the middleware, or the ID returned here is already stale. + rotated = client.post("/rotate").json()["sid"] + assert client.post("/revoke-one", params={"sid": rotated}).json() == { + "ended": True + }, "store.rotate() returned an ID that was no longer current" + + client.post("/login") + assert client.post("/step-up").json() == {"ok": True} + assert client.post("/logout").json() == {"ok": True} + + client.post("/login") + assert client.post("/revoke-all").json()["n"] >= 1 From 208ca04cdeda16f7d0fe761069b9e029c14ccaf0 Mon Sep 17 00:00:00 2001 From: Tihomir Mateev Date: Fri, 4 Sep 2026 17:43:03 +0300 Subject: [PATCH 05/11] Addressed first round of adversarial reviews --- docs/api/reference.md | 165 ++++ docs/guide/sessions.md | 372 ++++++++- docs/specs/session-design.md | 5 +- src/redis_fastapi/__init__.py | 22 +- src/redis_fastapi/cache.py | 119 ++- src/redis_fastapi/config.py | 59 +- src/redis_fastapi/deps.py | 86 +- src/redis_fastapi/exceptions.py | 36 + src/redis_fastapi/lifespan.py | 40 + src/redis_fastapi/session_backend.py | 572 ++++++++++--- src/redis_fastapi/session_events.py | 11 +- src/redis_fastapi/sessions.py | 580 ++++++++++---- src/redis_fastapi/setup.py | 12 +- src/redis_fastapi/types.py | 19 + .../test_session_cache_invariants.py | 322 ++++++++ tests/integration/test_session_integration.py | 23 +- tests/unit/test_session.py | 4 +- tests/unit/test_session_backend.py | 37 +- tests/unit/test_session_failures.py | 40 +- tests/unit/test_session_index.py | 2 +- tests/unit/test_session_middleware.py | 45 +- tests/unit/test_session_outcome.py | 231 ++++++ tests/unit/test_session_regressions.py | 755 ++++++++++++++++++ tests/unit/test_session_setup.py | 184 ++++- 24 files changed, 3351 insertions(+), 390 deletions(-) create mode 100644 src/redis_fastapi/exceptions.py create mode 100644 tests/integration/test_session_cache_invariants.py create mode 100644 tests/unit/test_session_outcome.py create mode 100644 tests/unit/test_session_regressions.py diff --git a/docs/api/reference.md b/docs/api/reference.md index 52e8f82..39b4855 100644 --- a/docs/api/reference.md +++ b/docs/api/reference.md @@ -10,6 +10,7 @@ app = FastAPI() FastAPIRedis(app).lifespan() # connection pools only FastAPIRedis(app).lifespan().caching() # + DI caching support FastAPIRedis(app).lifespan().rate_limiting() # + DI rate limiting support +FastAPIRedis(app).lifespan().sessions() # + server-side sessions ``` Or compose the lifespan directly: @@ -331,3 +332,167 @@ Returned by `hit()` / `peek()`, and stashed on `request.state` so the middleware `RateLimitExceeded(Exception)` - control-flow exception raised by `rate_limit()` when a request is over the limit; carries the prebuilt 429 `Response`. It is turned into that response automatically once `add_redis_rate_limiting` (or `.rate_limiting()`) has registered its handler. `RateLimitMiddleware` - ASGI middleware that injects `X-RateLimit-*` headers on allowed responses and, when a global limit is configured, enforces it before routing. Registered for you by `add_redis_rate_limiting`; you rarely construct it directly. + + +--- + +## Session dependencies + +### `SessionDep` + +```python +from redis_fastapi import SessionDep + +@app.post("/login") +async def login(session: SessionDep): + session["user_id"] = 42 # writing the identity rotates the ID +``` + +`Annotated[Session, Depends(get_session)]` - the session payload as a `dict` +subclass. Loaded before the application runs, so this performs no I/O. +`request.session` returns the same object. + +### `SessionStateDep` + +```python +from redis_fastapi import SessionStateDep, SessionStoreDep + +@app.post("/logout") +async def logout(state: SessionStateDep, store: SessionStoreDep): + await store.revoke(state) +``` + +`Annotated[SessionState, Depends(get_session_state)]` - everything about the +session that is not its data: `session_id`, `subject`, `created`, +`absolute_remaining`, and the `revoked` / `rotated` flags. Operations that act +on the session rather than its contents take this rather than the payload. + +### `SessionStoreDep` / `SyncSessionStoreDep` + +```python +async def handler(store: SessionStoreDep): ... # async endpoints +def handler(store: SyncSessionStoreDep): ... # def endpoints +``` + +`Annotated[SessionStore, Depends(get_session_store)]`. Typed as the abstract +base, not the Redis implementation, so a substituted backend type-checks. +`SyncSessionStore` bridges each call via `anyio.from_thread.run` and is usable +only from FastAPI worker threads. + +### `get_session()` / `get_session_state()` / `get_session_store()` / `get_sync_session_store()` + +```python +async def get_session_store(request: Request) -> SessionStore +``` + +`get_session_store` resolves in three steps: an entry in +`app.dependency_overrides`, then a `store` or `store_factory` given to +`add_redis_sessions`, then a `RedisSessionStore` built from the remaining +options. It consults the override map itself, so one override covers both the +middleware and your handlers. + +--- + +## Session setup + +### `add_redis_sessions()` / `FastAPIRedis.sessions()` + +```python +FastAPIRedis(app).lifespan().sessions( + principal_keys=["user_id", "role"], + subject_of=lambda s: s.get("tenant_id"), + descriptor_of=lambda req, s: {"ip": req.client.host}, + cookie_builder=my_builder, + skip=lambda req: req.url.path.startswith("/health"), + store=None, store_factory=None, + coder=JsonCoder, encryptor=None, id_factory=None, key_prefix=None, + idle_ttl=None, absolute_ttl=None, gc_ttl=None, +) +``` + +| Argument | Purpose | +|---|---| +| `principal_of` / `principal_keys` | What a change to triggers a rotation. Must be pure and return a verified identity. | +| `subject_of` | What the reverse index is keyed on. `None` leaves the session out of it. | +| `descriptor_of` | What a device listing shows. Stored beside the session, never inside it. | +| `cookie_builder` | Renders `Set-Cookie`, for setting **and** clearing. | +| `skip` | Requests needing no session, at zero Redis cost. | +| `store` / `store_factory` | Supply a whole store. Mutually exclusive. | +| `coder`, `encryptor`, `id_factory`, `key_prefix`, `idle_ttl`, `absolute_ttl`, `gc_ttl` | Passed to the store constructor. TTLs accept `int` seconds or `timedelta`. | + +--- + +## `SessionStore` + +Abstract base owning the lifecycle. `RedisSessionStore` is the shipped +implementation; `SessionStoreProtocol` is the structural type for substituting +one without inheritance. + +| Method | Purpose | +|---|---| +| `load(session_id, *, refresh=True)` | Read a session and restart its idle clock. `None` for every way it can be absent. | +| `create(session_id, record)` | Write a session that does not exist yet. The only method that writes the absolute deadline. | +| `save(session_id, record)` | Update the payload, and only the payload. | +| `touch(session_id)` | Restart the idle clock alone. | +| `delete(session_id)` | Remove the session. | +| `rotate(state, *, subject=None, descriptor=None)` | New identifier, old key deleted first. Returns the new ID. | +| `reauthenticate(state)` | Ask the middleware to rotate on the way out. | +| `revoke(state, *, subject=None)` | End this session and drop its index entry. | +| `revoke_id(session_id, *, subject)` | End one session, refusing an ID not indexed under that subject. | +| `revoke_all(subject)` | End every session of a subject. Returns keys removed. | +| `list_for_subject(subject)` | Live sessions, verified before being reported. | +| `count_for_subject(subject, *, limit=None)` | Upper-bound count; exact only at or above `limit`. Raises rather than failing open. | +| `index(subject, session_id, record, *, absolute_remaining, extra=None)` | Record a session under its subject. | +| `new_id()` / `is_valid_id(value)` | Generate and validate identifiers. | +| `new_record(data, *, created=None)` | Build an envelope, carrying `created` forward. | +| `session_id(state)` | The current identifier, or `None`. | + +A new backend implements the abstract primitives: `_read`, `_write`, +`_expire`, `_delete`, `_delete_many`, `_index_add`, `_index_remove`, +`_index_clear`, `_index_members`, `_alive`. + +--- + +## Session data types + +| Type | Contents | +|---|---| +| `Session` | `dict` subclass with `accessed` / `modified` flags and `raw()`. Nothing else. | +| `SessionState` | `data`, `session_id`, `subject`, `created`, `absolute_remaining`, `revoked`, `rotated`. | +| `SessionRecord` | `data` plus `SessionMetadata`. | +| `SessionMetadata` | `created`, `last_access`, `lifetime`. | +| `SessionInfo` | One listing row: `session_id`, `created`, `last_access`, `descriptor`. | +| `LoadedSession` | `record` plus `absolute_remaining`, returned by `load()`. | +| `CookieSpec` | Cookie attributes; `cleared()` returns the deletion form. | +| `Outcome` | What a response owes the session. Eight members. | +| `Deadline` | `ABSENT` / `UNBOUNDED` — a deadline that is not a number. | + +--- + +## Session errors + +``` +SessionError +├── SessionConfigurationError a missing or invalid setting +└── SessionStoreError the store failed; wraps the driver error +``` + +A failed **read** yields an empty session unless `session_fail_closed` is set. +A failed **write** always raises. `count_for_subject` always raises, because +there the store is the authorization answer. + +--- + +## `SessionEvents` + +```python +events = SessionEvents(redis, key_prefix="redis:fastapi", db=0) + +@events.on_session_end +async def _(session_id: str, cause: Cause) -> None: ... +``` + +Started and stopped by the lifespan when `session_events_enabled` is set. +`events.tier` is `"field"` when the server can deliver events and `"none"` +otherwise — in which case handlers never fire. Requires Redis 8.8 and +`notify-keyspace-events` including a subkey flag plus `h`. diff --git a/docs/guide/sessions.md b/docs/guide/sessions.md index 6579048..142a406 100644 --- a/docs/guide/sessions.md +++ b/docs/guide/sessions.md @@ -88,7 +88,8 @@ timeout cannot expire a session an attacker is actively using, because the attacker's own requests keep refreshing it — OWASP says so directly. The absolute deadline is the only clock that fires on a live compromise. -Both settings accept an `int` or a `timedelta`. Setting either to `0` disables +Both accept an `int` number of seconds. The store constructor also accepts a +`timedelta`; the settings and their environment variables take seconds. Setting either to `0` disables that clock; setting both gives a cookie-only session that the browser drops when it closes. @@ -102,11 +103,11 @@ explanation. ## Signing out, everywhere ```python -from redis_fastapi import SessionStoreDep +from redis_fastapi import SessionStateDep, SessionStoreDep @app.post("/logout") -async def logout(session: SessionDep, store: SessionStoreDep) -> dict: - await store.revoke(session) +async def logout(state: SessionStateDep, store: SessionStoreDep) -> dict: + await store.revoke(state) return {"ok": True} @app.post("/logout-everywhere") @@ -114,8 +115,10 @@ async def logout_all(session: SessionDep, store: SessionStoreDep) -> dict: return {"ended": await store.revoke_all(str(session["user_id"]))} @app.get("/devices") -async def devices(session: SessionDep, store: SessionStoreDep) -> list[dict]: - current = store.session_id(session) +async def devices( + session: SessionDep, state: SessionStateDep, store: SessionStoreDep +) -> list[dict]: + current = store.session_id(state) return [ {"id": info.session_id, "this_device": info.session_id == current, "last_seen": info.last_access} @@ -123,6 +126,19 @@ async def devices(session: SessionDep, store: SessionStoreDep) -> list[dict]: ] ``` +`SessionDep` is the data — a `dict` you read and write. `SessionStateDep` is +the handle: the identifier, the subject and the timestamps. Operations that +act on the *session* rather than its contents take the handle. + +Simply emptying the session signs the user out too, and is often all you need: + +```python +@app.post("/logout") +async def logout(session: SessionDep) -> dict: + session.clear() # key deleted, index entry dropped, cookie cleared + return {"ok": True} +``` + `revoke_id` requires the subject and refuses an ID that is not indexed under it, so a handler taking an ID from a request cannot end a stranger's session. @@ -211,6 +227,314 @@ every event needs one subscriber per node. --- +## Caching an endpoint that uses the session + +A cached response is stored once and served many times. A session-dependent +response is different for every user. Put those two together without saying +which you meant and the first user's body is served to the second — so +`cache()` asks you to say. + +One parameter, three answers: + +```python +cache(ttl=300, vary_on_session=True) # depends on who is asking +cache(ttl=300, vary_on_session=False) # reads the session, body is the same +cache(ttl=300) # you have not said +``` + +### `vary_on_session=True` — the body differs per user + +Use it when the response contains the user's own data. + +```python +@app.get( + "/me", + dependencies=[Depends(cache(ttl=300, vary_on_session=True))], +) +async def me(session: SessionDep) -> dict: + return {"user_id": session["user_id"], "cart": session.get("cart", [])} +``` + +Each user gets their own cache entry and their own hits — Alice's second +request is a `HIT` on Alice's copy. The key is built from the **subject**, not +the session ID, so it survives a rotation (a privilege change does not throw +the entry away) and is shared across that user's devices. + +The response carries `Cache-Control: private, max-age=300`. The `private` is +not optional and is added for you: our entry is per user, so a CDN told it may +keep one copy for everyone would recreate the leak one hop further out. + +### `vary_on_session=False` — reads the session, same body for everyone + +Use it when an auth dependency reads the session but the payload does not +depend on who asked. + +```python +async def require_user(session: SessionDep) -> int: + if "user_id" not in session: + raise HTTPException(401) + return session["user_id"] + +@app.get( + "/catalogue", + dependencies=[ + Depends(require_user), # reads the session + Depends(cache(ttl=300, vary_on_session=False)), # body does not + ], +) +async def catalogue() -> list[dict]: + return await load_products() +``` + +One shared entry serves every signed-in user, and the `Vary: Cookie` the +session middleware would otherwise add is suppressed — keeping it would force a +CDN to store one identical copy per user and destroy the hit rate this +declaration exists to protect. + +**This is an assertion, and the library takes your word for it.** If the body +does depend on the session, you have re-enabled the leak deliberately. + +### Saying nothing + +If the endpoint never touches the session, say nothing — there is nothing to +declare and caching behaves exactly as it always has. + +If it *does* touch the session and you have not declared anything, the response +is served normally but **not stored**, and the route is named once in a log +line: + +``` +WARNING GET /me read the session but is cached without vary_on_session set; + the response was not stored. Pass vary_on_session=True to cache it + per user, or False if the response does not depend on the session. +``` + +Losing caching is a visible, recoverable problem. Serving Alice's account page +to Bob is not, which is why the default errs this way. + +Note that these last two are the *same* declaration — you write nothing in both +cases. The library tells them apart by whether the endpoint actually read the +session, which it can only know after the endpoint has run. That is also why +the choice cannot be inferred for you: the cache key is needed *before* the +endpoint runs, and reading the session does not tell us whether the response +depends on it. + +### At a glance + +| Your endpoint | Declaration | Cached as | Response headers | +|---|---|---|---| +| Body depends on the user | `vary_on_session=True` | one entry per user | `private, max-age=N` + `Vary: Cookie` | +| Reads the session, body identical | `vary_on_session=False` | one shared entry | `max-age=N`, no `Vary` | +| Never touches the session | *(nothing)* | one shared entry | `max-age=N` | +| Touches it, nothing declared | *(nothing)* | **not cached** | `private, no-store` + `Vary: Cookie` | + +The rule underneath all four rows: **what a response tells other caches they +may do is never more permissive than what this library does itself.** If we +key per user, we say `private`. If we refuse to store, we say `no-store`. + +### A session route with no caching on it + +Reading the session on an uncached route emits `Cache-Control: private` and +`Vary: Cookie` on its own, since nothing else will. Where `cache()` is present +it owns the header outright — one writer, so you never see two contradictory +`Cache-Control` values on one response. + +--- + +## Limitation: WebSockets have no session in this release + +The middleware handles `http` scopes only. In a WebSocket handler, +`websocket.session` raises `AssertionError`, and `SessionDep` / +`SessionStateDep` raise `SessionConfigurationError`. + +This is a deliberate boundary for the first release, not an oversight, and it +is a difference from Starlette's own `SessionMiddleware`, which handles both +scopes. If you are migrating from it and read the session inside a WebSocket +handler, that code needs changing. + +**Why it is not simply switched on.** The read half is easy; the write half has +nowhere to go. A WebSocket has no `http.response.start`, so there is no point +at which a cookie can be set — which means no rotation, no save, and no +idle-clock refresh for the life of the connection. Half a session object, +silently read-only, invites exactly the bug the rest of this design works to +prevent: an application writes to it, sees no error, and loses the write. + +**What to do instead.** Authenticate during the HTTP handshake, where the +cookie *is* available, and pass what the socket needs into the handler: + +```python +@app.websocket("/ws") +async def ws(websocket: WebSocket) -> None: + # The handshake is an HTTP request, so the cookie arrives with it. + raw = websocket.cookies.get("session") + store = await get_session_store(websocket) # type: ignore[arg-type] + loaded = await store.load(raw) if raw and store.is_valid_id(raw) else None + if loaded is None: + await websocket.close(code=1008) + return + + user_id = loaded.record.data.get("user_id") + await websocket.accept() + ... +``` + +Load once at accept time and hold the identity for the connection. If you need +the socket to close when the session ends, pair it with +[real-time session events](#real-time-session-events-optional-redis-88) — that +is the case those exist for. + +--- + +## CSRF: what a cookie session reintroduces + +A cookie is attached by the browser to **every** request to your origin, +including one triggered by a form on somebody else's page. That is what makes +a session cookie convenient and it is also the whole of CSRF: an attacker +cannot read your session, but they can make the browser spend it. + +A bearer token in an `Authorization` header does not have this problem, because +nothing attaches it automatically. Moving to cookies gets you revocation, +rotation and server-side expiry, and it hands this back. + +**`SameSite=Lax` is the default here and it is most of the remedy.** The browser +withholds the cookie on cross-site `POST`, `PUT`, `PATCH` and `DELETE`. It does +*not* withhold it on a cross-site top-level `GET`, so: + +- **Never change state in a `GET`.** A `GET /account/delete` is exploitable + under `Lax` and no cookie attribute will save it. +- **Add a CSRF token for anything a browser form can reach.** `SameSite` is a + defence in depth, not a substitute — it is unenforced on some older browsers, + and `Lax` has a two-minute exemption window for top-level POSTs in some + Chromium versions. +- **`SameSite=Strict`** closes the top-level `GET` hole too, at the cost of the + cookie being withheld when a user follows a link into your site from + anywhere else — including their own email. + +The session is the natural place to keep the token: + +```python +import secrets +from fastapi import HTTPException + +@app.get("/form") +async def form(session: SessionDep) -> dict: + token = session.setdefault("csrf", secrets.token_urlsafe(32)) + return {"csrf_token": token} + +@app.post("/transfer") +async def transfer(session: SessionDep, csrf_token: str = Form(...)) -> dict: + expected = session.get("csrf") + if not expected or not secrets.compare_digest(csrf_token, expected): + raise HTTPException(403, "CSRF token mismatch") + ... +``` + +`secrets.compare_digest` rather than `==`, and the token rotates with the +session — a sign-in issues a new session ID, so the next `setdefault` mints a +fresh token. + +--- + +## Encrypting the payload at rest + +Anyone with `redis-cli` access can read a session. That is usually acceptable — +the guidance is to keep identifiers in the session and entities outside it — +but if you must store something sensitive, supply an `Encryptor`: + +```python +import os +from cryptography.hazmat.primitives.ciphers.aead import AESGCM + +class AesGcmEncryptor: + """Ten lines, and yours to audit.""" + + def __init__(self, key: bytes) -> None: + self._aes = AESGCM(key) # 16, 24 or 32 bytes + + def encrypt(self, data: bytes) -> bytes: + nonce = os.urandom(12) + return nonce + self._aes.encrypt(nonce, data, None) + + def decrypt(self, data: bytes) -> bytes: + return self._aes.decrypt(data[:12], data[12:], None) +``` + +Encryption **wraps** serialization, never the reverse: the coder turns your +value into text, and only then does the encryptor see it. A coder is never +handed ciphertext. + +This package ships the seam and not the implementation, deliberately. AES-GCM +in your codebase is a smaller liability for everyone than a cryptographic +primitive maintained here — and it means a key rotation is your decision, on +your schedule. + +Two things to know before you turn it on. A record that will not decrypt raises +`SessionStoreError` rather than looking like an empty session, so rotating a key +without a re-encryption pass signs everybody out loudly rather than silently. +And the index descriptor is **not** encrypted — it holds only what your +`descriptor_of` returns, so do not put anything sensitive there. + +--- + +## Migrating from something else + +Sessions do not survive the switch, by decision: existing cookies reference +records this store cannot read, so every user signs in again once. Deploy +accordingly. + +### From Starlette's `SessionMiddleware` + +```python +# before +from starlette.middleware.sessions import SessionMiddleware +app.add_middleware(SessionMiddleware, secret_key="…") + +# after +FastAPIRedis(app).lifespan().sessions() +``` + +| Their behaviour | Here | +|---|---| +| `request.session["user_id"] = 42` | unchanged — and it now rotates the ID | +| `request.session.clear()` | unchanged — and it now deletes the record too | +| payload capped at ~4 KB by the cookie | no cap; the cookie carries an ID | +| data signed but readable by the client | never leaves the server | +| `max_age` | `session_idle_ttl` plus `session_absolute_ttl` | +| no revocation | `revoke`, `revoke_id`, `revoke_all` | + +Handler code does not change. `secret_key` has no equivalent because nothing is +signed: the cookie is an opaque 256-bit identifier. + +### From `starsessions` + +| Theirs | Here | +|---|---| +| `lifetime=N, rolling=True` | `session_idle_ttl=N` | +| `lifetime=N, rolling=False` | `session_absolute_ttl=N` | +| both behaviours at once | not expressible for them; set both settings here | +| `load_session(request)` | nothing — the load is automatic and eager | +| `regenerate_session_id(request)` | delete the call; writing the identity rotates | +| `get_session_metadata(request)` | `store.list_for_subject()` for the fields | +| `RedisStore(...)` | `FastAPIRedis(app).lifespan().sessions()` | + +Their `lifetime` accepts a `timedelta`; so does this store's constructor. + +### From `fastapi-users`' `RedisStrategy` + +`RedisStrategy` is a token store, not a session store: it maps an opaque token +to a user ID and nothing else. Keep `fastapi-users` for registration, password +reset and OAuth linking — this package does not replace it. Swap the strategy +for the session and read the identity from `request.session` instead of from +the strategy's token. + +### From an in-process store + +A dict keyed by session ID, or a `TTLCache`. The behaviour you gain is that it +survives a restart and is shared across workers; the behaviour you lose is +none. Delete the store and call `.sessions()`. + +--- + ## Running it in production **A session store is not a cache, and `maxmemory-policy` must say so.** Under @@ -240,6 +564,42 @@ def logout(session: SessionDep, store: SyncSessionStoreDep) -> dict: --- +## Replacing the pieces + +Every seam is a keyword argument to `.sessions()`: + +```python +FastAPIRedis(app).lifespan().sessions( + coder=pydantic_model_coder(MySession), # serialization + encryptor=AesGcmEncryptor(key), # encryption at rest + id_factory=my_id_factory, # identifier generation (validated) + key_prefix="myapp", # key namespace + idle_ttl=timedelta(minutes=15), # the two clocks + absolute_ttl=timedelta(hours=8), + descriptor_of=lambda req, s: {"ip": req.client.host}, + principal_keys=["user_id", "role"], # what rotates the ID + subject_of=lambda s: s.get("tenant_id"), # what the index is keyed on + cookie_builder=my_cookie_builder, # Set-Cookie rendering + skip=lambda req: req.url.path.startswith("/health"), +) +``` + +To supply a whole store — another backend, or a test double — pass `store` or +`store_factory`: + +```python +FastAPIRedis(app).lifespan().sessions(store=MyPostgresSessionStore(pool)) +``` + +In tests, `dependency_overrides` reaches the middleware as well as your +handlers, so one override covers the whole request: + +```python +app.dependency_overrides[get_session_store] = lambda request: fake_store +``` + +--- + ## Settings Every setting is an environment variable prefixed `REDIS_`, so diff --git a/docs/specs/session-design.md b/docs/specs/session-design.md index fe1e113..56dfb58 100644 --- a/docs/specs/session-design.md +++ b/docs/specs/session-design.md @@ -25,7 +25,7 @@ item is excluded. Each row carries an ID so a commit, a test or a review comment | F-7 | Reverse lookup | A session may be bound to a subject, which need not be a user. List and count a subject's live sessions, each with a descriptor, which keeps all the needed data, without having to consult the straight-record. | | F-8 | Reverse lookup | A listing never reports a session that has already died. Reads to the session remove dead entries; writes re-assert lost ones. | | F-9 | Transport | The cookie carries a signed opaque identifier and no session data. An invalid value is rejected and yields a new session. | -| F-10 | Transport | Cookie name, `Domain`, `Path`, `SameSite`, `Secure` and `HttpOnly` are configurable. `Vary: Cookie` is emitted whenever the session was accessed. | +| F-10 | Transport | Cookie name, `Domain`, `Path`, `SameSite`, `Secure` and `HttpOnly` are configurable. `Vary: Cookie` is emitted whenever the response **may vary by cookie** — the session was accessed and the application has not declared the response independent of it. Merged into any existing `Vary`, never appended as a second header. | | F-11 | API | One call enables the feature. A dict-like dependency needs no load or save, and `request.session` behaves as before, so existing code and Authlib run unchanged. | | F-12 | API | The store exposes rotate, revoke, revoke-by-ID, revoke-all, list and count. Both dependencies resolve through `Depends`, so `dependency_overrides` works. | | F-13 | API | Every part of the feature works from a `def` endpoint as well as an `async def` one, with no second pattern to learn. | @@ -51,7 +51,7 @@ item is excluded. Each row carries an ID so a commit, a test or a review comment | N-7 | Correctness | Multi-key sequences are correct by ordering rather than by transaction, because Cluster forbids one across them. An interrupted rotation signs the user out and never leaves two valid IDs. | | N-8 | Correctness | No interleaving or partial failure leaves a session that revoke-all cannot find. Where a guarantee cannot be given, its limit is documented rather than implied away. | | N-9 | Security | Identifiers are opaque, carry no data, and come from a CSPRNG with at least 128 bits. `HttpOnly`, `SameSite=Lax` and `Secure` are on unless deliberately relaxed. | -| N-10 | Security | No session ID or subject appears in any log line, span attribute or metric label. Session-bearing responses are not stored by shared caches. | +| N-10 | Security | No session ID or subject appears in any log line, span attribute or metric label. A response whose content **depends on** the session is not stored by any shared cache — this library's own or one downstream. A response that merely *touched* the session is not covered; see N-18. | | N-11 | Security | The CSRF exposure that a cookie session reintroduces is documented with a remedy. | | N-12 | Operability | Documented guidance for running it: eviction policy, key legibility during an incident, and the per-subject key as a contention point with the seam that shards it. | | N-13 | Testability | The unit suite runs against `fakeredis` with no Redis process, exercising the real key schema, TTL commands and index rather than a substitute. | @@ -59,6 +59,7 @@ item is excluded. Each row carries an ID so a commit, a test or a review comment | N-15 | Maintainability | Nothing ships gated on an unmerged upstream change. | | N-16 | Maintainability | File split, dependency injection, settings and telemetry follow the existing cache and rate-limit code. An abstract base owns the lifecycle; a protocol bounds what callers touch. | | N-17 | Correctness | No correctness claim rests on a notification. Every guarantee holds with events switched off, because Pub/Sub delivery can be dropped and an expiry event can lag the deadline it reports. | +| N-18 | Correctness | The cache directives a response carries are never more permissive than the caching this library itself performs for that response. Keyed per user ⇒ `private`; refused entirely ⇒ `no-store`; one shared entry ⇒ a shared directive is equally permissive and nothing more is required. | ### 0.3 Out of scope diff --git a/src/redis_fastapi/__init__.py b/src/redis_fastapi/__init__.py index e4e720d..04d4c3f 100644 --- a/src/redis_fastapi/__init__.py +++ b/src/redis_fastapi/__init__.py @@ -30,6 +30,11 @@ get_sync_rate_limit_backend, get_sync_session_store, ) +from redis_fastapi.exceptions import ( + SessionConfigurationError, + SessionError, + SessionStoreError, +) from redis_fastapi.lifespan import redis_lifespan from redis_fastapi.rate import Rate, parse_rate from redis_fastapi.ratelimit import ( @@ -47,21 +52,22 @@ SyncRateLimitBackend, ) from redis_fastapi.session_backend import ( + LoadedSession, RedisSessionStore, SessionInfo, SessionMetadata, SessionRecord, + SessionState, SessionStore, + SessionStoreProtocol, SyncSessionStore, ) -from redis_fastapi.session_events import SessionEvents +from redis_fastapi.session_events import Cause, SessionEvents, Tier from redis_fastapi.sessions import ( CookieSpec, + Outcome, Session, - SessionConfigurationError, - SessionError, SessionMiddleware, - SessionStoreError, add_redis_sessions, build_cookie, ) @@ -69,6 +75,7 @@ from redis_fastapi.telemetry import disable_telemetry, enable_telemetry from redis_fastapi.types import ( Coder, + Encryptor, JsonCoder, KeyBuilder, pydantic_model_coder, @@ -80,12 +87,16 @@ "CacheBackendDep", "CacheHitException", "CannotIdentifyClient", + "Cause", "Coder", "CookieSpec", + "Encryptor", "FastAPIRedis", "Identifier", "JsonCoder", "KeyBuilder", + "LoadedSession", + "Outcome", "Rate", "RateLimitBackend", "RateLimitBackendDep", @@ -103,15 +114,18 @@ "SessionMetadata", "SessionMiddleware", "SessionRecord", + "SessionState", "SessionStore", "SessionStoreDep", "SessionStoreError", + "SessionStoreProtocol", "SyncCacheBackend", "SyncCacheBackendDep", "SyncRateLimitBackend", "SyncRateLimitBackendDep", "SyncSessionStore", "SyncSessionStoreDep", + "Tier", "add_redis_caching", "add_redis_rate_limiting", "add_redis_sessions", diff --git a/src/redis_fastapi/cache.py b/src/redis_fastapi/cache.py index 7d9e6b6..832a781 100644 --- a/src/redis_fastapi/cache.py +++ b/src/redis_fastapi/cache.py @@ -33,7 +33,12 @@ async def get_items(): from starlette.status import HTTP_304_NOT_MODIFIED from starlette.types import ASGIApp, Message, Receive, Scope, Send -from redis_fastapi.config import CACHE_STATUS_HEADER, get_settings +from redis_fastapi.config import ( + CACHE_ROUTE_SCOPE_KEY, + CACHE_STATUS_HEADER, + CACHE_SUPPRESS_VARY_SCOPE_KEY, + get_settings, +) from redis_fastapi.deps import AsyncClient, _get_pool_state, get_async_redis from redis_fastapi.telemetry import ( cache_span, @@ -238,6 +243,36 @@ async def cache_hit_exception_handler(request: Request, exc: Exception) -> Respo # --------------------------------------------------------------------------- +# Mirrors ``sessions.STATE_SCOPE_KEY``. Duplicated deliberately: caching must +# work with the session feature absent, and importing it would couple them. +_SESSION_SCOPE_KEY = "session" +_SESSION_STATE_SCOPE_KEY = "redis_session_state" + +# Routes already warned about, so the safety net says it once rather than once +# per request. +_WARNED_ROUTES: set[str] = set() + + +def _session_subject(request: Request) -> str | None: + """Who this request is, for a per-user cache key. + + The **subject** rather than the session ID: it survives rotation, so a + privilege change does not throw the entry away, and it is shared across a + user's devices. Falls back to the session ID for an anonymous session that + still holds data, and to ``None`` when there is no session at all. + """ + state = request.scope.get(_SESSION_STATE_SCOPE_KEY) + if state is None: + return None + return getattr(state, "subject", None) or getattr(state, "session_id", None) + + +def _session_was_read(request: Request) -> bool: + """Whether the endpoint actually touched the session on this request.""" + session = request.scope.get(_SESSION_SCOPE_KEY) + return bool(getattr(session, "accessed", False)) + + @dataclass class CachePending: """Signals :class:`CacheResponseCaptureMiddleware` to store the response. @@ -253,6 +288,12 @@ class CachePending: private: bool = False redis: Any = field(default=None) write_through: bool = False + vary_on_session: bool | None = None + """What the route declared about session dependence. ``None`` means the + developer did not say, which is what arms the safety net in + :func:`_store_cache_entry`.""" + route: str = "" + """For the one-time warning, so it names something useful.""" # --------------------------------------------------------------------------- @@ -342,6 +383,7 @@ def cache( cache_prefix: str | None = None, key_builder: KeyBuilder | None = None, private: bool = False, + vary_on_session: bool | None = None, ) -> Any: """Return a ``Depends()``-compatible dependency for response caching. @@ -362,6 +404,19 @@ def cache( key_builder: Custom key builder (sync or async). Defaults to :func:`default_key_builder`. private: Emit ``Cache-Control: private, max-age=N``. + vary_on_session: Whether this response depends on the session. + + * ``True`` - key the entry per user, and emit ``private``. Use it + when the body differs by who is asking. + * ``False`` - one shared entry, and suppress the ``Vary: Cookie`` + the session middleware would otherwise add. Use it when the + endpoint reads the session - an auth dependency, say - but the + body is identical for everyone. + * ``None`` (default) - undeclared. If the endpoint turns out to + have read the session the response is served but **not stored**, + and the route is named once in a warning. Without that guard the + entry is shared across users, which is a cross-user data leak + rather than a performance problem. Returns: An async generator dependency suitable for use with ``Depends()``. @@ -375,6 +430,9 @@ def cache( cache_prefix if cache_prefix is not None else _settings.pattern_prefix("cache") ) _key_builder: KeyBuilder = key_builder or default_key_builder + # A per-user entry must not be stored by a shared cache downstream either, + # or we fix our own cache and poison the CDN one hop out (N-18). + _private: bool = private or vary_on_session is True # Flow: bypass → read cache → HIT (raise) or MISS (yield to endpoint) async def _dependency( @@ -383,6 +441,13 @@ async def _dependency( ) -> AsyncGenerator[None, None]: cc = _parse_cache_control(request.headers.get("Cache-Control")) + # Tell the session middleware this route owns its Cache-Control, and + # - when the response is declared session-independent - that it does + # not vary by cookie after all. + request.scope[CACHE_ROUTE_SCOPE_KEY] = True + if vary_on_session is False: + request.scope[CACHE_SUPPRESS_VARY_SCOPE_KEY] = True + # 1. Bypass: skip caching for non-GET or no-store requests if request.method != "GET" or "no-store" in cc: record_cache_request(result="bypass", eviction_group=eviction_group) @@ -394,6 +459,15 @@ async def _dependency( if isawaitable(cache_key): cache_key = await cache_key + # 2b. Per-user entry when the route says the body depends on who is + # asking. This has to happen here, before the endpoint runs, + # because the key is needed for the *lookup* - which is exactly why + # the library cannot infer it from whether the session was read. + if vary_on_session is True: + subject = _session_subject(request) + if subject is not None: + cache_key = f"{cache_key}:u:{subject}" + with cache_span( "cache.get", attributes={ @@ -420,7 +494,7 @@ async def _dependency( try: raise CacheHitException( _build_hit_response( - cached_data, remaining_ttl, request, private + cached_data, remaining_ttl, request, _private ) ) except (json.JSONDecodeError, KeyError) as exc: @@ -432,7 +506,12 @@ async def _dependency( span.set_attribute("cache.hit", False) request.state.redis_cache_pending = CachePending( - key=cache_key, ttl=_ttl, private=private, redis=redis + key=cache_key, + ttl=_ttl, + private=_private, + redis=redis, + vary_on_session=vary_on_session, + route=f"{request.method} {request.url.path}", ) yield @@ -721,6 +800,33 @@ async def _store_cache_entry( return extra_headers +def _leaks_across_users(pending: CachePending, request: Request) -> bool: + """Whether storing this response would share one user's body with another. + + True only when the endpoint actually read the session *and* the route said + nothing about whether the body depends on it. Both halves matter: the read + is what makes a leak possible, and the silence is what makes it unintended. + + Decided here rather than at lookup time because ``accessed`` is not known + until the endpoint has run - which is also why the per-user key has to be + declared rather than inferred. + """ + if pending.vary_on_session is not None: + return False + if not _session_was_read(request): + return False + if pending.route not in _WARNED_ROUTES: + _WARNED_ROUTES.add(pending.route) + logger.warning( + "%s read the session but is cached without vary_on_session set; " + "the response was not stored. Pass vary_on_session=True to cache " + "it per user, or False if the response does not depend on the " + "session.", + pending.route or "This route", + ) + return True + + class CacheResponseCaptureMiddleware: """ASGI middleware that intercepts responses and stores them in Redis. @@ -807,7 +913,12 @@ async def capture_send(message: Message) -> None: # 5. Final chunk: write to Redis (on 2xx) then send the full response body_bytes = bytes(response_body) extra_headers: list[tuple[bytes, bytes]] = [] - if pending is not None and 200 <= response_status < 300: + if pending is not None and _leaks_across_users(pending, request): + # The endpoint read the session and nobody said whether the + # response depends on it. Storing a shared entry here is a + # cross-user data leak, so serve it and store nothing. + extra_headers = [(b"cache-control", b"private, no-store")] + elif pending is not None and 200 <= response_status < 300: extra_headers = await _store_cache_entry( pending, body_bytes, diff --git a/src/redis_fastapi/config.py b/src/redis_fastapi/config.py index c4f8961..714ffbb 100644 --- a/src/redis_fastapi/config.py +++ b/src/redis_fastapi/config.py @@ -6,12 +6,13 @@ from __future__ import annotations +import re import warnings from functools import lru_cache from importlib.metadata import PackageNotFoundError, version from typing import Any, Literal -from pydantic import Field, SecretStr, model_validator +from pydantic import Field, SecretStr, field_validator, model_validator from pydantic_settings import BaseSettings, SettingsConfigDict from redis.driver_info import DriverInfo @@ -24,6 +25,21 @@ DRIVER_INFO: DriverInfo = DriverInfo().add_upstream_driver(LIB_NAME, LIB_VERSION) CACHE_STATUS_HEADER: str = "X-Redis-Cache" +# Scope keys the caching and session features use to agree about a response, +# rather than each appending headers independently. They live here because +# neither feature may import the other: caching must work with sessions absent, +# and deps.py already imports sessions, so cache -> sessions would be a cycle. +CACHE_ROUTE_SCOPE_KEY: str = "redis_cache_route" +"""Set by ``cache()``: this route owns its ``Cache-Control``.""" +CACHE_SUPPRESS_VARY_SCOPE_KEY: str = "redis_cache_no_vary" +"""Set by ``cache(vary_on_session=False)``: the body does not vary by cookie.""" + +# Cookie attributes are interpolated into a response header, so each is +# constrained to characters that cannot terminate or split one. +_COOKIE_NAME_RE = re.compile(r"[A-Za-z0-9_-]+") +_COOKIE_DOMAIN_RE = re.compile(r"[A-Za-z0-9.-]+") +_CTL_RE = re.compile(r"[\x00-\x1f\x7f]") + class RedisSettings(BaseSettings): """Central configuration for the FastAPI Redis integration. @@ -292,6 +308,47 @@ class RedisSettings(BaseSettings): description="Also initialize redis-py native OTel (connection/command metrics)", ) + # -- Cookie attribute validation ------------------------------------------- + # + # These three are interpolated straight into a ``Set-Cookie`` header. The + # session *value* has been charset-checked since the first version + # precisely because an unvalidated one is a header-injection vector; the + # name, path and domain reach the same header by the same route and were + # not checked at all. A CR or LF in any of them splits the header. + + @field_validator("session_cookie_name") + @classmethod + def _check_cookie_name(cls, value: str) -> str: + """RFC 6265 token characters, narrowed to what a cookie name needs.""" + if not value or not _COOKIE_NAME_RE.fullmatch(value): + raise ValueError( + "session_cookie_name must be one or more of letters, digits, " + f"'-' and '_'; got {value!r}" + ) + return value + + @field_validator("session_cookie_path") + @classmethod + def _check_cookie_path(cls, value: str) -> str: + if not value.startswith("/") or _CTL_RE.search(value) or ";" in value: + raise ValueError( + "session_cookie_path must start with '/' and contain no " + f"control characters or ';'; got {value!r}" + ) + return value + + @field_validator("session_cookie_domain") + @classmethod + def _check_cookie_domain(cls, value: str | None) -> str | None: + if value is None: + return None + if not value or not _COOKIE_DOMAIN_RE.fullmatch(value): + raise ValueError( + "session_cookie_domain must be a hostname of letters, digits, " + f"'-' and '.'; got {value!r}" + ) + return value + # -- KV fields that are silently ignored when url is set ----------------- _KV_FIELDS: frozenset[str] = frozenset( {"host", "port", "db", "username", "password"} diff --git a/src/redis_fastapi/deps.py b/src/redis_fastapi/deps.py index 71fbb64..a206589 100644 --- a/src/redis_fastapi/deps.py +++ b/src/redis_fastapi/deps.py @@ -3,7 +3,10 @@ from __future__ import annotations import logging -from typing import TYPE_CHECKING, Annotated, TypeAlias +from collections.abc import Callable +from dataclasses import dataclass, field +from inspect import isawaitable +from typing import TYPE_CHECKING, Annotated, Any, TypeAlias, cast if TYPE_CHECKING: from redis_fastapi.cache_backend import CacheBackend, SyncCacheBackend @@ -30,7 +33,12 @@ # Under ``from __future__ import annotations`` in the caller's module this is # the only spelling that works. There is no import cycle: session_backend # never imports deps at module level. -from redis_fastapi.session_backend import RedisSessionStore, SyncSessionStore +from redis_fastapi.session_backend import ( + RedisSessionStore, + SessionState, + SessionStore, + SyncSessionStore, +) from redis_fastapi.sessions import Session logger = logging.getLogger(__name__) @@ -202,20 +210,66 @@ async def get_sync_rate_limit_backend(request: Request) -> SyncRateLimitBackend: return SyncRateLimitBackend(backend) -async def get_session_store(request: Request) -> RedisSessionStore: - """Return a :class:`RedisSessionStore` backed by the shared async pool. +async def get_session_store(request: Request) -> SessionStore: + """Return the session store for this request. - Built per request, but its server-capability cache lives on the pool - state, so ``HSETEX`` detection is paid once per process rather than + Three things can decide what comes back, in order: + + 1. ``app.dependency_overrides[get_session_store]``, consulted here rather + than only by FastAPI. The middleware calls this function directly - it + runs before dependency resolution - so without this check an override + reached handlers and not the load path, and the two halves of a request + used different stores. That gap is why the test suite used to reach + into ``app.user_middleware`` and rewrite a private kwarg. + 2. A ``store`` or ``store_factory`` passed to ``add_redis_sessions``. + 3. Otherwise a :class:`RedisSessionStore` built from the options given to + ``add_redis_sessions`` - coder, encryptor, id_factory, key_prefix and + the two clocks. + + Built per request in case 3, but its server-capability cache lives on the + pool state, so ``HSETEX`` detection is paid once per process rather than re-probed on every request. """ - from redis_fastapi.session_backend import RedisSessionStore, _StoreCapabilities + override = request.app.dependency_overrides.get(get_session_store) + if override is not None: + result = override(request) + return cast("SessionStore", await result if isawaitable(result) else result) + + options: _SessionStoreOptions | None = getattr( + request.app.state, "_redis_session_options", None + ) + if options is not None: + if options.store is not None: + return options.store + if options.store_factory is not None: + built = options.store_factory(request) + return cast("SessionStore", await built if isawaitable(built) else built) + + from redis_fastapi.session_backend import _StoreCapabilities state = _get_pool_state(request.app) if state.session_capabilities is None: state.session_capabilities = _StoreCapabilities() client = await get_async_redis(request) - return RedisSessionStore(client, capabilities=state.session_capabilities) + return RedisSessionStore( + client, + capabilities=state.session_capabilities, + **(options.kwargs if options is not None else {}), + ) + + +@dataclass +class _SessionStoreOptions: + """How ``add_redis_sessions`` was asked to build or supply the store. + + Stashed on ``app.state`` at setup time and read here, so every seam the + store constructor offers is reachable from the supported entry point + rather than only by replacing the dependency wholesale. + """ + + store: SessionStore | None = None + store_factory: Callable[[Request], Any] | None = None + kwargs: dict[str, Any] = field(default_factory=dict) async def get_sync_session_store(request: Request) -> SyncSessionStore: @@ -230,6 +284,19 @@ async def get_sync_session_store(request: Request) -> SyncSessionStore: return SyncSessionStore(store) +async def get_session_state(request: Request) -> SessionState: + """Return the session handle the middleware built for this request. + + Performs no I/O. Pair it with ``SessionStoreDep`` to rotate or revoke:: + + async def logout(state: SessionStateDep, store: SessionStoreDep): + await store.revoke(state) + """ + from redis_fastapi.sessions import session_state_of + + return session_state_of(request) + + async def get_session(request: Request) -> Session: """Return the session the middleware already loaded for this request. @@ -248,6 +315,7 @@ async def get_session(request: Request) -> Session: SyncRateLimitBackendDep = Annotated[ "SyncRateLimitBackend", Depends(get_sync_rate_limit_backend) ] -SessionStoreDep = Annotated[RedisSessionStore, Depends(get_session_store)] +SessionStoreDep = Annotated[SessionStore, Depends(get_session_store)] SyncSessionStoreDep = Annotated[SyncSessionStore, Depends(get_sync_session_store)] SessionDep = Annotated[Session, Depends(get_session)] +SessionStateDep = Annotated[SessionState, Depends(get_session_state)] diff --git a/src/redis_fastapi/exceptions.py b/src/redis_fastapi/exceptions.py new file mode 100644 index 0000000..b70e9a2 --- /dev/null +++ b/src/redis_fastapi/exceptions.py @@ -0,0 +1,36 @@ +"""Exceptions for the session feature. + +A module of their own so that ``session_backend`` can raise them without +importing ``sessions``. That import used to run the wrong way - the storage +half depended on the request-facing half purely so it could mutate a +``Session`` - and removing it is what turns the two-file split into a real +layering boundary rather than a mutual-friend arrangement. +""" + +from __future__ import annotations + + +class SessionError(Exception): + """Base for every error this feature raises. + + Catching this catches the whole feature. A driver exception never reaches + application code - the store wraps it in :class:`SessionStoreError`. + """ + + +class SessionConfigurationError(SessionError): + """A session setting is missing, invalid, or contradicts another one.""" + + +class SessionStoreError(SessionError): + """The store could not complete an operation. + + Raised for every failed **write**, whatever ``session_fail_closed`` is set + to, because losing a login or a rotation must never be silent. Failed + reads raise this only when ``session_fail_closed`` is set; otherwise they + yield an empty session. Section 7 of the design explains the asymmetry. + + The one read that never fails open is ``count_for_subject``: there the + store *is* the authorization answer, and a permissive default would wave + logins past a concurrent-session cap exactly when Redis is unhealthy. + """ diff --git a/src/redis_fastapi/lifespan.py b/src/redis_fastapi/lifespan.py index d581f79..f35a11d 100644 --- a/src/redis_fastapi/lifespan.py +++ b/src/redis_fastapi/lifespan.py @@ -166,6 +166,42 @@ async def _check_cache_eviction_safety(ps: _PoolState) -> None: ) +async def _start_session_events(app: FastAPI, ps: _PoolState) -> Any: + """Start the session-event subscriber, when one was asked for. + + Runs only when sessions are wired **and** ``session_events_enabled`` is + set, so no deployment pays for a feature it did not ask for. + + Never raises. On a server that cannot supply the events the subscriber + probes, logs one warning and does nothing further - the documented + best-effort contract. Returns the object to stop at shutdown, or + ``None`` when nothing was started. + """ + settings = get_settings() + if not settings.session_events_enabled: + return None + if not getattr(app.state, "_redis_sessions", False): + logger.warning( + "session_events_enabled is set but no session middleware is " + "registered, so nothing will be delivered. Call " + "FastAPIRedis(app).sessions()." + ) + return None + + from redis_fastapi.session_events import SessionEvents + + try: + events = SessionEvents( + ps.get_async_client(), key_prefix=settings.prefix, db=settings.db + ) + await events.start() + except Exception: + logger.warning("Session events could not be started", exc_info=True) + return None + app.state._redis_session_events = events + return events + + @asynccontextmanager async def redis_lifespan(app: FastAPI) -> AsyncIterator[None]: """Manage Redis connection pools across the application lifecycle. @@ -211,9 +247,13 @@ async def redis_lifespan(app: FastAPI) -> AsyncIterator[None]: if getattr(app.state, "_redis_caching", False): await _check_cache_eviction_safety(ps) + events = await _start_session_events(app, ps) + try: yield finally: + if events is not None: + await events.stop() ps.clear() if settings.cluster: await ps.async_cluster.aclose() # type: ignore[union-attr] diff --git a/src/redis_fastapi/session_backend.py b/src/redis_fastapi/session_backend.py index 8594510..03fab2c 100644 --- a/src/redis_fastapi/session_backend.py +++ b/src/redis_fastapi/session_backend.py @@ -21,18 +21,18 @@ import secrets import time from abc import ABC, abstractmethod -from collections.abc import Callable +from collections.abc import Callable, MutableMapping from dataclasses import dataclass from datetime import timedelta -from typing import Any +from enum import Enum, auto +from typing import Any, Protocol, runtime_checkable from redis.asyncio import Redis as AsyncRedis from redis.asyncio.cluster import RedisCluster as AsyncRedisCluster -from redis.exceptions import RedisError +from redis.exceptions import RedisClusterException, RedisError from redis_fastapi.config import get_settings -from redis_fastapi.sessions import ( - Session, +from redis_fastapi.exceptions import ( SessionConfigurationError, SessionStoreError, ) @@ -41,10 +41,25 @@ session_span, timed_session, ) -from redis_fastapi.types import Coder, JsonCoder +from redis_fastapi.types import Coder, Encryptor, JsonCoder logger = logging.getLogger(__name__) +# Every driver failure this store is willing to interpret. +# +# ``RedisClusterException`` is **not** a subclass of ``RedisError`` - it +# descends straight from ``Exception`` - and its subclass +# ``SlotNotCoveredError`` is raised on the ordinary command path during a +# resharding or a failover. Catching only ``RedisError`` therefore lets the +# commonest cluster failure escape every policy in this module: the read would +# not fail open, and a raw driver exception would reach application code that +# was told ``SessionError`` catches the whole feature. +STORE_ERRORS: tuple[type[BaseException], ...] = ( + RedisError, + RedisClusterException, + OSError, +) + # Hash field names. Two characters, and identical in every session key. # Section 13.2: a uniform schema is what a future compact-hash encoding would # reward, and a short name is fewer bytes on the wire meanwhile. @@ -54,10 +69,32 @@ # The value of field ``a`` is never read - the field exists for its TTL alone. _ABSOLUTE_MARKER = "1" -# ``HTTL`` answers -2 for "no such field, or no such key" and -1 for "the field -# exists with no expiry". Naming them stops either being read as a duration. -TTL_NO_FIELD = -2 -TTL_NO_EXPIRY = -1 + +class Deadline(Enum): + """What ``_read`` says when the absolute deadline is not a number. + + A backend reports a live deadline as an ``int`` of seconds remaining, and + anything else as one of these. The two cases are not interchangeable and + the base class branches on both, so neither may be smuggled through as a + negative integer. + + An earlier version passed Redis's own ``HTTL`` sentinels - ``-2`` and + ``-1`` - straight through, which quietly made "reproduce this Redis + encoding" part of the contract every other backend had to satisfy, without + the ``_read`` docstring ever saying so. + """ + + ABSENT = auto() + """No deadline field, or no key at all. The session does not exist.""" + + UNBOUNDED = auto() + """The field exists with no expiry. Unreachable by construction - this + store always gives it one - so it means something else wrote the key.""" + + +# Redis's own answers, mapped to the above by ``_ttl``. +_HTTL_NO_FIELD = -2 +_HTTL_NO_EXPIRY = -1 # Characters a session ID may contain: the alphabet of ``secrets.token_urlsafe``. _ID_ALPHABET = frozenset( @@ -168,7 +205,7 @@ async def probe_hsetex_support( # entry for a command the server does not know. That is the # "unsupported" answer, not a broken probe. return False - except (RedisError, OSError): + except STORE_ERRORS: return None if not reply: return False @@ -176,6 +213,42 @@ async def probe_hsetex_support( return first is not None +@dataclass +class SessionState: + """Everything about one session that is not its data. + + The store reads and writes this; the middleware owns it and puts it on + ``request.state``. Keeping it separate is what lets ``Session`` go back to + being a ``dict`` with two flags - exactly Starlette's contract and nothing + more - and it is why this module imports nothing from ``sessions``. + + The type of ``data`` is a plain ``MutableMapping`` on purpose: the store + has no business knowing about a web session object, and a second carrier + (an agent or MCP session, say) can reuse the store without one. + + Attributes: + data: The payload the application sees. + session_id: Where it is stored, or ``None`` before the first write. + subject: What it is indexed under. Captured at load time, because by + the time ``revoke`` runs the payload it would be derived from is + already gone. + created: The original creation time, carried forward so a write does + not restamp it. + absolute_remaining: What the server says is left of the deadline. + revoked: The store ended this session; the middleware owes a clearing + cookie. + rotated: A handler asked for a rotation the middleware still owes. + """ + + data: MutableMapping[str, Any] + session_id: str | None = None + subject: str | None = None + created: float | None = None + absolute_remaining: int | None = None + revoked: bool = False + rotated: bool = False + + @dataclass(frozen=True) class LoadedSession: """A live session, plus the deadline Redis is counting down for it. @@ -190,13 +263,85 @@ class LoadedSession: absolute_remaining: int +@runtime_checkable +class SessionStoreProtocol(Protocol): + """The surface the middleware and the dependencies actually call. + + Much smaller than :class:`SessionStore`, and structural rather than + inherited, so a test double or another package can supply a store without + subclassing anything. :class:`SessionStore` satisfies it by construction. + + Implement this when you want to *substitute* a store; inherit + :class:`SessionStore` when you want to *write* one, because the base class + already owns the lifecycle rules that are a security control. + """ + + @property + def idle_seconds(self) -> int: ... # pragma: no cover + + @property + def absolute_seconds(self) -> int: ... # pragma: no cover + + def is_valid_id(self, value: str) -> bool: ... # pragma: no cover + + def new_id(self) -> str: ... # pragma: no cover + + def new_record( + self, + data: dict[str, Any], + *, + created: float | None = ..., + ) -> SessionRecord: ... # pragma: no cover + + def session_id(self, state: SessionState) -> str | None: ... # pragma: no cover + + async def load( + self, session_id: str, *, refresh: bool = ... + ) -> LoadedSession | None: ... # pragma: no cover + + async def create( + self, session_id: str, record: SessionRecord + ) -> None: ... # pragma: no cover + + async def save( + self, session_id: str, record: SessionRecord + ) -> None: ... # pragma: no cover + + async def touch(self, session_id: str) -> None: ... # pragma: no cover + + async def delete(self, session_id: str) -> None: ... # pragma: no cover + + async def rotate( + self, + state: SessionState, + *, + subject: str | None = ..., + descriptor: dict[str, Any] | None = ..., + ) -> str: ... # pragma: no cover + + async def revoke( + self, state: SessionState, *, subject: str | None = ... + ) -> None: ... # pragma: no cover + + async def index( + self, + subject: str, + session_id: str, + record: SessionRecord, + *, + absolute_remaining: int, + extra: dict[str, Any] | None = ..., + ) -> None: ... # pragma: no cover + + class SessionStore(ABC): """Owns the session lifecycle; a subclass owns only the storage. The lifecycle is a security control, so it is written once here rather - than once per backend. A new backend implements the seven abstract - primitives at the bottom of this class and inherits the identifier rules, - the two-clock policy, the envelope format and the error handling. + than once per backend. A new backend implements the abstract primitives + at the bottom of this class - ten of them, listed there - and inherits the + identifier rules, the two-clock policy, the envelope format and the error + handling. Section 7 of ``session-mgmt.md`` gives the reason this is an abstract base class and not a bare protocol. ``SessionStoreProtocol`` describes the much @@ -208,6 +353,7 @@ def __init__( self, *, coder: type[Coder] = JsonCoder, + encryptor: Encryptor | None = None, idle_ttl: int | timedelta | None = None, absolute_ttl: int | timedelta | None = None, gc_ttl: int | timedelta | None = None, @@ -215,6 +361,7 @@ def __init__( ) -> None: settings = get_settings() self._coder = coder + self._encryptor = encryptor self._id_factory = id_factory self._idle_ttl = _seconds( idle_ttl if idle_ttl is not None else settings.session_idle_ttl @@ -322,6 +469,8 @@ def encode(self, record: SessionRecord) -> str: "d": record.data, } ) + if self._encryptor is not None: + return self._encryptor.encrypt(encoded.encode()).decode("latin-1") return encoded def decode(self, raw: str | bytes) -> SessionRecord: @@ -331,7 +480,14 @@ def decode(self, raw: str | bytes) -> SessionRecord: session, because the two mean different things: the caller decides whether to sign the user out or to fail the request. """ - text = raw if isinstance(raw, str) else raw.decode() + if self._encryptor is not None: + blob = raw.encode("latin-1") if isinstance(raw, str) else raw + try: + text = self._encryptor.decrypt(blob).decode() + except Exception as exc: + raise SessionStoreError(f"Unreadable session record: {exc}") from exc + else: + text = raw if isinstance(raw, str) else raw.decode() try: envelope = self._coder.decode(text) meta = envelope["m"] @@ -346,13 +502,29 @@ def decode(self, raw: str | bytes) -> SessionRecord: except Exception as exc: raise SessionStoreError(f"Unreadable session record: {exc}") from exc - def new_record(self, data: dict[str, Any]) -> SessionRecord: - """Build a record for a session that does not exist yet.""" + def new_record( + self, data: dict[str, Any], *, created: float | None = None + ) -> SessionRecord: + """Build a record to write. + + *created* carries the original creation time forward on an update. + Omitting it stamps "now", which is correct only for a session that + does not exist yet: leave it out on a create, pass the loaded value on + every subsequent write. Without it ``created`` silently becomes "time + of last write", and a device listing reports every active session as + having been signed in seconds ago. + + ``lifetime`` records the deadline actually in force, so a session with + the absolute clock disabled reports the ``gc_ttl`` backstop rather + than a misleading zero. + """ now = time.time() return SessionRecord( data=data, metadata=SessionMetadata( - created=now, last_access=now, lifetime=self._absolute_ttl + created=created if created is not None else now, + last_access=now, + lifetime=self.absolute_seconds, ), ) @@ -388,12 +560,12 @@ async def load( raw, absolute_ttl = await self._read( session_id, refresh_idle=self.idle_seconds if refresh else None ) - except (RedisError, OSError) as exc: + except STORE_ERRORS as exc: record_session_operation(operation="load", result="error") self._read_failed(exc) return None - if absolute_ttl == TTL_NO_EXPIRY: + if absolute_ttl is Deadline.UNBOUNDED: # Unreachable by construction: every write gives field "a" a TTL. # Reaching it means something wrote the key outside this store, so # say so loudly and treat the session as absent rather than guess. @@ -405,46 +577,82 @@ async def load( await self._safe_delete(session_id) return None - if raw is None or absolute_ttl <= 0: + alive_until = absolute_ttl if isinstance(absolute_ttl, int) else 0 + if raw is None or alive_until <= 0: # Rows two and four of the state table: a half-dead key, alive on # one clock and dead on the other. Delete it so the index entry can # follow, rather than leaving a candidate that every later # verification has to reject. Row three - dead on both - is simply # absent, and there is nothing to remove. - if raw is not None or absolute_ttl > 0: + half_dead = raw is not None or alive_until > 0 + if half_dead: await self._safe_delete(session_id) record_session_operation( - operation="load", - result="expired" if raw is not None or absolute_ttl > 0 else "miss", + operation="load", result="expired" if half_dead else "miss" ) return None record_session_operation(operation="load", result="hit") - return LoadedSession(record=self.decode(raw), absolute_remaining=absolute_ttl) + return LoadedSession(record=self.decode(raw), absolute_remaining=alive_until) - async def save(self, session_id: str, record: SessionRecord) -> None: - """Write the payload, leaving the absolute deadline untouched. + async def create(self, session_id: str, record: SessionRecord) -> None: + """Write a session that does not exist yet, starting both clocks. - Field ``a`` is written **if it does not already exist**, so one call - covers both creating a session and updating one, and no number of - updates can extend the absolute deadline. That conditional write is - the whole of N-6: outliving the deadline is not a bug to avoid here, - it is unreachable. + The only method that ever writes field ``a``. Use it for a first + write and for the new half of a rotation; use :meth:`save` for every + subsequent write. + + Raises: + SessionStoreError: On any store failure. + """ + await self._save(session_id, record, absolute=self.absolute_seconds) + + async def save(self, session_id: str, record: SessionRecord) -> None: + """Update an existing session's payload, and only its payload. + + **This is N-6, and the method split is what makes it structural.** + There is no argument to this method that could write field ``a``, so + an update cannot extend the absolute deadline and - the case that + matters - cannot bring it back after it has expired. + + An earlier version wrote ``a`` conditionally on every save, reasoning + that ``FNX`` protects an existing field. It does, but an *expired* + field is an absent field, so a request whose load saw ``a`` alive and + whose write landed after it lapsed recreated the deadline with a full + fresh lifetime. The window is one request long and it recurs every + cycle, so an actively-used session never died. Now such a write + leaves a key holding ``d`` with no ``a``, which the next load reads as + row two of the state table and deletes. The session ends, which is + the correct outcome: its deadline passed. Raises: SessionStoreError: On any store failure. A write never fails quietly, whatever ``session_fail_closed`` says - losing a login or a rotation is the worst outcome in this design. """ + await self._save(session_id, record, absolute=None) + + async def _save( + self, session_id: str, record: SessionRecord, *, absolute: int | None + ) -> None: + """Shared body of :meth:`create` and :meth:`save`. + + **This is N-6, and the flag is what makes it structural.** An update + writes field ``d`` and nothing else, so it cannot extend field ``a`` + and - the case that matters - it cannot bring ``a`` back after it has + expired. + + *absolute* is the deadline TTL on a create, and ``None`` on an update. + """ with session_span("session.save"), timed_session("save"): try: await self._write( session_id, self.encode(record), idle=self.idle_seconds, - absolute=self.absolute_seconds, + absolute=absolute, ) - except (RedisError, OSError) as exc: + except STORE_ERRORS as exc: record_session_operation(operation="save", result="error") raise SessionStoreError(f"Could not save session: {exc}") from exc record_session_operation(operation="save", result="hit") @@ -457,7 +665,7 @@ async def touch(self, session_id: str) -> None: """ try: await self._expire(session_id, self.idle_seconds) - except (RedisError, OSError) as exc: + except STORE_ERRORS as exc: raise SessionStoreError(f"Could not refresh session: {exc}") from exc async def delete(self, session_id: str) -> None: @@ -469,22 +677,22 @@ async def delete(self, session_id: str) -> None: """ try: await self._delete(session_id) - except (RedisError, OSError) as exc: + except STORE_ERRORS as exc: raise SessionStoreError(f"Could not delete session: {exc}") from exc # -- rotation and revocation --------------------------------------------- - def session_id(self, session: Session) -> str | None: + def session_id(self, state: SessionState) -> str | None: """The identifier this session is stored under, if it has one yet. ``None`` for a session that has never been written. Useful for marking "this device" in a listing. """ - return session.sid + return state.session_id async def rotate( self, - session: Session, + state: SessionState, *, subject: str | None = None, descriptor: dict[str, Any] | None = None, @@ -513,27 +721,38 @@ async def rotate( Raises: SessionStoreError: On any store failure. """ - old_id = session.sid + subject = subject if subject is not None else state.subject + old_id = state.session_id if old_id is not None: await self.delete(old_id) - if subject is not None: - await self._index_drop(subject, old_id) + # The entry lives under the subject it was *written* with, which is + # not always the one being written now - an account switch changes + # it mid-request. + if state.subject: + await self._index_drop(state.subject, old_id) new_id = self.new_id() - record = self.new_record(session.raw()) - await self.save(new_id, record) - session.sid = new_id + record = self.new_record(dict(state.data)) + await self.create(new_id, record) + state.session_id = new_id + state.created = record.metadata.created + state.absolute_remaining = self.absolute_seconds # Cleared, not set. ``rotated`` means "the middleware still owes this # session a rotation"; we have just performed one. Leaving it set made # the middleware rotate a second time at response start, which threw # away the key written here and turned the ID returned to the caller # into a stale value. The middleware notices the new ID by comparing it # with the one it loaded, so the cookie still goes out. - session.rotated = False - session.revoked = False + state.rotated = False + state.revoked = False + state.subject = subject if subject is not None: await self.index( - subject, new_id, record, absolute_remaining=self.absolute_seconds + subject, + new_id, + record, + absolute_remaining=self.absolute_seconds, + extra=descriptor, ) # The runtime backstop for a misconfigured principal resolver: sign-ins # with no rotations is a visible anomaly on a dashboard, and a silent @@ -542,7 +761,7 @@ async def rotate( record_session_operation(operation="rotate", result="hit") return new_id - async def reauthenticate(self, session: Session) -> None: + async def reauthenticate(self, state: SessionState) -> None: """Force a rotation on the way out of this request. For a privilege change the principal cannot see - an impersonation @@ -551,21 +770,34 @@ async def reauthenticate(self, session: Session) -> None: ``http.response.start`` so it lands in the same response as the new cookie. """ - session.rotated = True - session.mark_modified() + state.rotated = True - async def revoke(self, session: Session) -> None: + async def revoke(self, state: SessionState, *, subject: str | None = None) -> None: """End the session in hand and clear its cookie. Safe to call on a session that was never written. + + *subject* drops the index entry alongside the key. The middleware + supplies it from the subject captured at load time, so a handler + calling ``store.revoke(session)`` need not pass anything; pass it + explicitly only when using the store outside a request. + + Leaving the entry behind is not cosmetic: ``list_for_subject`` prunes + it on the next read but ``count_for_subject`` does not, so a + concurrent-session cap counts sessions the user has already signed out + of and eventually refuses a legitimate login. """ - old_id = session.sid + old_id = state.session_id if old_id is not None: await self.delete(old_id) - session.clear() - session.sid = None - session.revoked = True - session.rotated = False + resolved = subject if subject is not None else state.subject + if resolved: + await self._index_drop(resolved, old_id) + state.data.clear() + state.session_id = None + state.subject = None + state.revoked = True + state.rotated = False async def revoke_id(self, session_id: str, *, subject: str) -> bool: """End one session by ID, and refuse an ID not indexed under *subject*. @@ -583,7 +815,7 @@ async def revoke_id(self, session_id: str, *, subject: str) -> bool: return False try: members = await self._index_members(subject) - except (RedisError, OSError) as exc: + except STORE_ERRORS as exc: raise SessionStoreError(f"Could not read the session index: {exc}") from exc if session_id not in members: return False @@ -594,20 +826,23 @@ async def revoke_id(self, session_id: str, *, subject: str) -> bool: async def revoke_all(self, subject: str) -> int: """End every session belonging to *subject*. + Two round trips whatever the session count: one to read the index, + one pipeline that deletes every key and then drops the index itself. + Returns: - How many session keys were removed. The index is an upper bound, - so this can be lower than the number of entries it held - the - difference is sessions that had already died. + How many session **keys** were removed, counted from the server's + own ``DEL`` replies. The index is an upper bound, so this is + lower than the number of entries it held whenever some of those + sessions had already died. """ try: members = await self._index_members(subject) - removed = 0 - for session_id in members: - await self._delete(session_id) - removed += 1 - for session_id in members: - await self._index_remove(subject, session_id) - except (RedisError, OSError) as exc: + if not members: + record_session_operation(operation="revoke_all", result="miss") + return 0 + removed = await self._delete_many(list(members)) + await self._index_clear(subject) + except STORE_ERRORS as exc: record_session_operation(operation="revoke_all", result="error") raise SessionStoreError(f"Could not revoke sessions: {exc}") from exc record_session_operation(operation="revoke_all", result="hit") @@ -622,6 +857,7 @@ async def index( record: SessionRecord, *, absolute_remaining: int, + extra: dict[str, Any] | None = None, ) -> None: """Record a session under its subject, expiring with it. @@ -630,24 +866,30 @@ async def index( **remaining** absolute time, never a fresh lifetime: a relative full lifetime restarts the entry's clock on every write and lets the index outlive the session it points at. + + *extra* is whatever the application wants a device listing to show - + an IP, a user agent, a device name. It is stored **beside** the + session payload, never inside it, so it can never appear in the + application's own ``request.session``. The middleware fills it from + the ``descriptor_of`` seam. """ descriptor = self._coder.encode( { "c": record.metadata.created, "l": record.metadata.last_access, - "d": record.data.get("__descriptor__", {}), + "d": dict(extra or {}), } ) try: await self._index_add(subject, session_id, descriptor, absolute_remaining) - except (RedisError, OSError) as exc: + except STORE_ERRORS as exc: raise SessionStoreError(f"Could not index the session: {exc}") from exc async def _index_drop(self, subject: str, session_id: str) -> None: """Remove one index entry, wrapping driver errors.""" try: await self._index_remove(subject, session_id) - except (RedisError, OSError) as exc: + except STORE_ERRORS as exc: raise SessionStoreError( f"Could not update the session index: {exc}" ) from exc @@ -667,7 +909,7 @@ async def list_for_subject(self, subject: str) -> list[SessionInfo]: """ try: members = await self._index_members(subject) - except (RedisError, OSError) as exc: + except STORE_ERRORS as exc: self._read_failed(exc) return [] if not members: @@ -677,7 +919,7 @@ async def list_for_subject(self, subject: str) -> list[SessionInfo]: infos: list[SessionInfo] = [] dead: list[str] = [] for session_id, raw in members.items(): - if session_id not in live: + if live is not None and session_id not in live: dead.append(session_id) continue infos.append(self._to_info(session_id, raw)) @@ -686,7 +928,7 @@ async def list_for_subject(self, subject: str) -> list[SessionInfo]: # because we could not prune a stale row helps nobody. try: await self._index_remove(subject, session_id) - except (RedisError, OSError) as exc: + except STORE_ERRORS as exc: logger.warning("Could not prune a dead index entry: %s", exc) infos.sort(key=lambda info: info.last_access, reverse=True) return infos @@ -699,28 +941,53 @@ async def count_for_subject(self, subject: str, *, limit: int | None = None) -> *limit* to make the count exact **only when it matters**: below the limit the fast answer is returned, and the verification round trip is paid solely by the request that is about to be refused. + + **This one does not fail open.** Section 7's argument for an empty + session on a failed read is that the application's own authorization + still runs; here the store *is* the answer, and ``0`` is the + permissive one - a concurrent-session cap would wave every login + through exactly when Redis is unhealthy. + + Raises: + SessionStoreError: If the count could not be established. """ try: members = await self._index_members(subject) - except (RedisError, OSError) as exc: - self._read_failed(exc) - return 0 + except STORE_ERRORS as exc: + raise SessionStoreError( + f"Could not count sessions for the subject: {exc}" + ) from exc upper = len(members) if limit is None or upper < limit: return upper - return len(await self._verify(list(members))) + live = await self._verify(list(members)) + if live is None: + raise SessionStoreError( + "Could not verify session liveness while counting; refusing to " + "answer rather than under-count." + ) + return len(live) - async def _verify(self, session_ids: list[str]) -> set[str]: - """Return the subset of *session_ids* whose sessions are still alive. + async def _verify(self, session_ids: list[str]) -> set[str] | None: + """Which of *session_ids* are alive, or ``None`` if we could not ask. One batch, not one call per session, so a subject with fifty devices costs the same round trip as one with two. + + **``None`` and the empty set mean different things, and conflating + them destroys the index.** An earlier version returned an empty set + on failure; ``list_for_subject`` then read "absent from the live set" + as proof of death and ``HDEL``-ed every entry for the subject. One + transient pipeline error - the ordinary case on a cluster mid-failover + - left every live session of that user invisible to ``revoke_all`` + until its absolute deadline. Callers must treat ``None`` as "report + everything, prune nothing". """ try: return await self._alive(session_ids) - except (RedisError, OSError) as exc: + except STORE_ERRORS as exc: logger.warning("Could not verify session liveness: %s", exc) - return set() + return None def _to_info(self, session_id: str, raw: bytes | str) -> SessionInfo: """Build a listing row from an index descriptor alone. @@ -745,10 +1012,6 @@ def _to_info(self, session_id: str, raw: bytes | str) -> SessionInfo: session_id=session_id, created=0.0, last_access=0.0, descriptor={} ) - @abstractmethod - async def _alive(self, session_ids: list[str]) -> set[str]: - """Return which of *session_ids* still have a live session.""" - # -- failure policy ------------------------------------------------------ def _read_failed(self, exc: BaseException) -> None: @@ -776,7 +1039,7 @@ async def _safe_delete(self, session_id: str) -> None: """ try: await self._delete(session_id) - except (RedisError, OSError) as exc: + except STORE_ERRORS as exc: logger.warning("Could not remove a dead session key: %s", exc) # -- the whole surface a new backend implements -------------------------- @@ -784,8 +1047,12 @@ async def _safe_delete(self, session_id: str) -> None: @abstractmethod async def _read( self, session_id: str, *, refresh_idle: int | None - ) -> tuple[bytes | str | None, int]: - """Return ``(payload or None, seconds left on the absolute clock)``. + ) -> tuple[bytes | str | None, int | Deadline]: + """Return ``(payload or None, the absolute deadline)``. + + The deadline is the number of seconds left, or a :class:`Deadline` + member when it is not a number. Do not invent negative sentinels - + the base class does not interpret them. *refresh_idle* is the idle window in seconds, or ``None`` to read without refreshing. A backend that can do both in one round trip @@ -794,12 +1061,13 @@ async def _read( @abstractmethod async def _write( - self, session_id: str, payload: str, *, idle: int, absolute: int + self, session_id: str, payload: str, *, idle: int, absolute: int | None ) -> None: """Write the payload with an idle TTL. - Must give the absolute deadline its TTL **only when it does not - already exist**, so that repeated writes cannot extend it. + *absolute* is ``None`` on an update, and the deadline field must then + be left completely alone - neither refreshed nor recreated. When it + is an int this is a create, and the deadline field takes that TTL. """ @abstractmethod @@ -825,6 +1093,18 @@ async def _index_add( async def _index_remove(self, subject: str, session_id: str) -> None: """Drop one session from a subject's index.""" + @abstractmethod + async def _delete_many(self, session_ids: list[str]) -> int: + """Remove many sessions in one round trip; return how many existed.""" + + @abstractmethod + async def _index_clear(self, subject: str) -> None: + """Drop a subject's whole index in one command.""" + + @abstractmethod + async def _alive(self, session_ids: list[str]) -> set[str]: + """Return which of *session_ids* still have a live session.""" + @abstractmethod async def _index_members(self, subject: str) -> dict[str, bytes | str]: """Return ``{session_id: descriptor}`` for a subject. @@ -835,7 +1115,7 @@ async def _index_members(self, subject: str) -> dict[str, bytes | str]: class RedisSessionStore(SessionStore): - """The Redis implementation of the seven storage primitives. + """The Redis implementation of the storage primitives. Everything here is one pipelined round trip per operation. Both keys are flat, with no hash tag, for the reason ``ratelimit_backend.py`` already @@ -893,7 +1173,7 @@ async def _has_hsetex(self) -> bool: async def _read( self, session_id: str, *, refresh_idle: int | None - ) -> tuple[bytes | str | None, int]: + ) -> tuple[bytes | str | None, int | Deadline]: key = self.session_key(session_id) pipe = self._redis.pipeline(transaction=False) if refresh_idle is None: @@ -916,37 +1196,38 @@ async def _read( return _first(replies[0]), _ttl(replies[reads]) async def _write( - self, session_id: str, payload: str, *, idle: int, absolute: int + self, session_id: str, payload: str, *, idle: int, absolute: int | None ) -> None: key = self.session_key(session_id) + modern = await self._has_hsetex() pipe = self._redis.pipeline(transaction=False) - if await self._has_hsetex(): - # FNX: set only if the field does not already exist. On an update - # the whole command is a no-op, so field "a" keeps the deadline it - # was created with. - pipe.execute_command( - "HSETEX", - key, - "FNX", - "EX", - absolute, - "FIELDS", - 1, - FIELD_ABSOLUTE, - _ABSOLUTE_MARKER, - ) + if absolute is not None: + # A create. FNX still guards against two concurrent creations of + # the same ID racing; it is not what keeps the deadline absolute. + # The caller not passing an absolute on an update is what does. + if modern: + pipe.execute_command( + "HSETEX", + key, + "FNX", + "EX", + absolute, + "FIELDS", + 1, + FIELD_ABSOLUTE, + _ABSOLUTE_MARKER, + ) + else: + pipe.execute_command("HSETNX", key, FIELD_ABSOLUTE, _ABSOLUTE_MARKER) + pipe.execute_command( + "HEXPIRE", key, absolute, "NX", "FIELDS", 1, FIELD_ABSOLUTE + ) + if modern: pipe.execute_command( "HSETEX", key, "EX", idle, "FIELDS", 1, FIELD_DATA, payload ) else: - # The 7.4 spelling of the same two writes. HSETNX will not touch an - # existing field, and HEXPIRE's NX only sets an expiry on a field - # that has none - which, given the line above, is only ever a field - # this call just created. - pipe.execute_command("HSETNX", key, FIELD_ABSOLUTE, _ABSOLUTE_MARKER) - pipe.execute_command( - "HEXPIRE", key, absolute, "NX", "FIELDS", 1, FIELD_ABSOLUTE - ) + # HSET clears a field's TTL, so the idle clock is reapplied here. pipe.execute_command("HSET", key, FIELD_DATA, payload) pipe.execute_command("HEXPIRE", key, idle, "FIELDS", 1, FIELD_DATA) await pipe.execute() @@ -985,6 +1266,20 @@ async def _index_add( ) await pipe.execute() + async def _delete_many(self, session_ids: list[str]) -> int: + if not session_ids: + return 0 + pipe = self._redis.pipeline(transaction=False) + for session_id in session_ids: + pipe.delete(self.session_key(session_id)) + # One DEL per key rather than one variadic DEL: on a cluster the keys + # span slots, and redis-py fans a pipeline out per node while a single + # multi-key DEL would be refused. Still one round trip per node. + return sum(int(reply or 0) for reply in await pipe.execute()) + + async def _index_clear(self, subject: str) -> None: + await self._redis.delete(self.index_key(subject)) + async def _index_remove(self, subject: str, session_id: str) -> None: await self._redis.hdel(self.index_key(subject), session_id) @@ -1057,12 +1352,21 @@ def _all_ttls_positive(reply: Any) -> bool: return all(value is not None and int(value) > 0 for value in reply) -def _ttl(reply: Any) -> int: - """Unwrap ``HTTL``'s one-element array reply into an int.""" +def _ttl(reply: Any) -> int | Deadline: + """Map ``HTTL``'s one-element array reply onto the deadline contract. + + This is the only place that knows what ``-1`` and ``-2`` mean, which is + the point: the encoding stays inside the Redis backend. + """ value = reply[0] if isinstance(reply, (list, tuple)) and reply else reply if value is None: - return TTL_NO_FIELD - return int(value) + return Deadline.ABSENT + seconds = int(value) + if seconds == _HTTL_NO_EXPIRY: + return Deadline.UNBOUNDED + if seconds == _HTTL_NO_FIELD: + return Deadline.ABSENT + return seconds class SyncSessionStore: @@ -1088,30 +1392,36 @@ def _run(func: Any) -> Any: return anyio.from_thread.run(func) - def session_id(self, session: Session) -> str | None: + def session_id(self, state: SessionState) -> str | None: """The identifier this session is stored under, if any (no I/O).""" - return self._store.session_id(session) + return self._store.session_id(state) def rotate( self, - session: Session, + state: SessionState, *, subject: str | None = None, descriptor: dict[str, Any] | None = None, ) -> str: """Issue a new identifier, deleting the old key first (blocking).""" result: str = self._run( - lambda: self._store.rotate(session, subject=subject, descriptor=descriptor) + lambda: self._store.rotate(state, subject=subject, descriptor=descriptor) ) return result - def reauthenticate(self, session: Session) -> None: - """Force a rotation on the way out of this request (blocking).""" - self._run(lambda: self._store.reauthenticate(session)) + def reauthenticate(self, state: SessionState) -> None: + """Force a rotation on the way out of this request. + + No I/O, so no bridge: the async original only sets a flag the + middleware reads at response time. Routing that through + ``anyio.from_thread.run`` burnt a worker thread and made the call + raise outside one, for two attribute writes. + """ + state.rotated = True - def revoke(self, session: Session) -> None: + def revoke(self, state: SessionState, *, subject: str | None = None) -> None: """End the session in hand and clear its cookie (blocking).""" - self._run(lambda: self._store.revoke(session)) + self._run(lambda: self._store.revoke(state, subject=subject)) def revoke_id(self, session_id: str, *, subject: str) -> bool: """End one session by ID, scoped to *subject* (blocking).""" diff --git a/src/redis_fastapi/session_events.py b/src/redis_fastapi/session_events.py index 4cd34ce..8385ef7 100644 --- a/src/redis_fastapi/session_events.py +++ b/src/redis_fastapi/session_events.py @@ -36,9 +36,12 @@ from redis.asyncio import Redis as AsyncRedis from redis.asyncio.cluster import RedisCluster as AsyncRedisCluster -from redis.exceptions import RedisError -from redis_fastapi.session_backend import FIELD_ABSOLUTE, FIELD_DATA +from redis_fastapi.session_backend import ( + FIELD_ABSOLUTE, + FIELD_DATA, + STORE_ERRORS, +) from redis_fastapi.telemetry import record_session_event logger = logging.getLogger(__name__) @@ -132,7 +135,7 @@ async def probe(self) -> Tier: if not await self._version_ok(): return "none" return "field" if await self._config_ok() else "none" - except (RedisError, OSError) as exc: + except STORE_ERRORS as exc: logger.info("Could not probe session-event support: %s", exc) return "none" @@ -216,7 +219,7 @@ async def _run(self) -> None: await self._dispatch(message.get("data")) except asyncio.CancelledError: raise - except (RedisError, OSError) as exc: + except STORE_ERRORS as exc: # Losing the subscription is not an application error. Say so once # and stop; nothing downstream depends on this stream. logger.warning("Session event subscription ended: %s", exc) diff --git a/src/redis_fastapi/sessions.py b/src/redis_fastapi/sessions.py index 1290f7c..c9c6588 100644 --- a/src/redis_fastapi/sessions.py +++ b/src/redis_fastapi/sessions.py @@ -11,46 +11,32 @@ from __future__ import annotations -from collections.abc import Awaitable, Callable, Iterable, Mapping +import dataclasses +from collections.abc import Awaitable, Callable, Iterable, Mapping, MutableMapping from dataclasses import dataclass -from typing import TYPE_CHECKING, Any +from datetime import timedelta +from enum import Enum, auto +from typing import Any, cast from fastapi import FastAPI from starlette.requests import Request from starlette.types import ASGIApp, Message, Receive, Scope, Send -from redis_fastapi.config import get_settings - -if TYPE_CHECKING: - from redis_fastapi.session_backend import SessionStore +from redis_fastapi.config import ( + CACHE_ROUTE_SCOPE_KEY, + CACHE_SUPPRESS_VARY_SCOPE_KEY, + get_settings, +) +from redis_fastapi.exceptions import ( + SessionConfigurationError, +) +from redis_fastapi.session_backend import SessionState, SessionStore +from redis_fastapi.types import Coder, Encryptor # Sentinel for ``pop``/``setdefault`` so that ``None`` stays a usable default. _MISSING: Any = object() -class SessionError(Exception): - """Base for every error this feature raises. - - Catching this catches the whole feature. A ``redis.RedisError`` never - reaches application code - the store wraps it in - :class:`SessionStoreError`. - """ - - -class SessionConfigurationError(SessionError): - """A session setting is missing, invalid, or contradicts another one.""" - - -class SessionStoreError(SessionError): - """The store could not complete an operation. - - Raised for every failed **write**, whatever ``session_fail_closed`` is set - to, because losing a login or a rotation must never be silent. Failed - reads raise this only when ``session_fail_closed`` is true; otherwise they - yield an empty session. Section 7 of the design explains the asymmetry. - """ - - class Session(dict): # type: ignore[type-arg] """The mapping an application sees as ``request.session``. @@ -74,7 +60,7 @@ class Session(dict): # type: ignore[type-arg] top-level key, call ``save()``, or set ``session_always_save=True``. """ - __slots__ = ("accessed", "modified", "sid", "revoked", "rotated") + __slots__ = ("accessed", "modified") def __init__(self, *args: Any, **kwargs: Any) -> None: super().__init__(*args, **kwargs) @@ -85,13 +71,6 @@ def __init__(self, *args: Any, **kwargs: Any) -> None: # Set by the middleware after a load, and by the store after a # rotation. ``None`` means this session has never been written, so # there is no key to delete and no cookie to replace. - self.sid: str | None = None - # The store sets these; the middleware acts on them at - # ``http.response.start``. They are the only channel between the two - # halves of the feature, so they are attributes rather than a side - # table keyed on the request. - self.revoked = False - self.rotated = False # -- flags --------------------------------------------------------------- @@ -253,29 +232,33 @@ class CookieSpec: http_only: bool same_site: str + def cleared(self) -> CookieSpec: + """The same cookie, as a deletion. -def build_cookie(spec: CookieSpec) -> str: - """Render a ``Set-Cookie`` value. The default ``cookie_builder``.""" - parts = [f"{spec.name}={spec.value}", f"Path={spec.path}"] - if spec.max_age is not None: - parts.append(f"Max-Age={spec.max_age}") - if spec.domain: - parts.append(f"Domain={spec.domain}") - if spec.secure: - parts.append("Secure") - if spec.http_only: - parts.append("HttpOnly") - parts.append(f"SameSite={spec.same_site.capitalize()}") - return "; ".join(parts) + An empty value and ``Max-Age=0``, with every scoping attribute + untouched - which is the part that matters, because a browser removes + a cookie only when the clearing header repeats them exactly. + + Deleting is therefore not a second rendering path with its own + function to keep in step; it is one field change to the spec the + builder was going to receive anyway. A ``cookie_builder`` added to + emit ``Partitioned`` or a ``__Host-`` prefix applies to both without + knowing this method exists. + """ + return dataclasses.replace(self, value="", max_age=0) -def clear_cookie(spec: CookieSpec) -> str: - """Render a ``Set-Cookie`` that deletes the cookie. +def build_cookie(spec: CookieSpec) -> str: + """Render a ``Set-Cookie`` value. The default ``cookie_builder``. - An empty value and ``Max-Age=0``. Every attribute that scopes the cookie - must match the one that set it, or the browser keeps the original. + Renders a deletion too, when handed ``spec.cleared()``: an empty value and + ``Max-Age=0`` are what a deletion *is*. There is deliberately no second + function for it, because two renderers that must stay in lockstep are two + renderers that will not. """ - parts = [f"{spec.name}=", f"Path={spec.path}", "Max-Age=0"] + parts = [f"{spec.name}={spec.value}", f"Path={spec.path}"] + if spec.max_age is not None: + parts.append(f"Max-Age={spec.max_age}") if spec.domain: parts.append(f"Domain={spec.domain}") if spec.secure: @@ -291,20 +274,144 @@ def clear_cookie(spec: CookieSpec) -> str: # --------------------------------------------------------------------------- SCOPE_KEY = "session" +STATE_SCOPE_KEY = "redis_session_state" _STATE_ATTR = "_redis_session" # What ``principal_of`` returns when there is no identity to speak of. _NO_PRINCIPAL: Any = None +class Outcome(Enum): + """What a response owes the session, decided once. + + Eight mutually exclusive answers. Naming them is not decoration: three + separate defects in this middleware were the same shape - overlapping + boolean conditions evaluated in an order where an earlier branch silently + shadowed a later one - and an ordered list of ``if ... return`` statements + cannot show that overlap to a reader or to a test. Computing the answer + first, in one pure function, turns "these branches happen to be in the + right order" into a property that :func:`decide_outcome` states and the + suite enumerates. + + The three defects, for the record: ``session.clear()`` read as a privilege + change and minted a new session instead of signing the user out; a handler + that wrote the identity and then rotated was rotated a second time and got + back a dead identifier; and a step-up on a failed response wrote the new + state under the old identifier. + """ + + NOTHING = auto() + """Not touched, or nothing left to do.""" + + CLEAR_COOKIE = auto() + """The store already ended it; the browser's copy is all that is left.""" + + SUPPRESS = auto() + """The principal changed on a failed response. Persist nothing at all.""" + + SIGN_OUT = auto() + """The session was emptied. Delete it, unindex it, clear the cookie.""" + + COOKIE_ONLY = auto() + """A handler rotated for itself. Only the cookie is outstanding.""" + + ROTATE = auto() + """The principal changed, or a rotation was requested and not performed.""" + + WRITE = auto() + """An ordinary save.""" + + TOUCH = auto() + """Read-only, and the load did not advance the idle clock.""" + + +@dataclass(frozen=True) +class _Signals: + """The flags :func:`decide_outcome` reads. + + A record rather than ten positional arguments, so the decision can be + exercised over its whole input space without a store, a request or Redis. + """ + + revoked: bool + rotated: bool + changed: bool + accessed: bool + modified: bool + empty: bool + stored: bool + """The session has an identifier - it has been written at least once.""" + id_changed: bool + """That identifier differs from the one the request arrived with.""" + failed: bool + """The response status is 400 or more.""" + always_save: bool + refresh_on_load: bool + + +def decide_outcome(signals: _Signals) -> Outcome: + """Reduce the request's signals to exactly one :class:`Outcome`. + + Pure, and total: every combination of inputs maps to one answer. The + order of the tests below is the whole of the middleware's correctness, so + each one that must precede another says why. + """ + # The store has already done the work; nothing may undo or repeat it. + if signals.revoked: + return Outcome.CLEAR_COOKIE + + # A response the client saw fail must not hand out an authenticated + # session. Writing the payload while skipping the rotation would store + # the new identity against the *old* identifier, which is the fixation + # this design exists to prevent, arrived at by being helpful. + if signals.failed and (signals.changed or signals.rotated): + return Outcome.SUPPRESS + + # Before ROTATE: a handler that rotated for itself has already replaced + # the key. Rotating again would delete what it just wrote and hand the + # caller back an identifier naming nothing. + if signals.id_changed: + return Outcome.COOKIE_ONLY + + # Before ROTATE: emptying a session drops the principal to None, which is + # a change - so without this test a sign-out reads as a privilege change + # and mints the user a brand-new valid session. + if signals.modified and signals.empty and signals.stored: + return Outcome.SIGN_OUT + + if signals.changed or signals.rotated: + return Outcome.ROTATE + + # After every branch above, because each of them implies the session was + # touched even when the application never read it. + if not signals.accessed: + return Outcome.NOTHING + + if signals.modified or signals.always_save: + return Outcome.WRITE + + # The load was a plain read under this setting, so this is the only place + # left that can advance the idle clock. Without it the clock freezes and + # every session is immortal until its absolute deadline. + if not signals.refresh_on_load and signals.stored: + return Outcome.TOUCH + + return Outcome.NOTHING + + @dataclass class _RequestState: """Per-request session bookkeeping, kept off the ``Session`` itself.""" session: Session + # Everything about the session that is not its data. The store reads and + # writes this; ``store_state.data`` *is* the ``Session`` above, so a + # ``revoke`` that clears the data clears what the application holds. + store_state: SessionState loaded_id: str | None principal_before: Any - absolute_remaining: int | None + # Only for the ``descriptor_of`` seam, which is given the live request. + request: Request | None = None class SessionMiddleware: @@ -331,6 +438,7 @@ def __init__( principal_of: Callable[[Session], Any] | None = None, subject_of: Callable[[Session], str | None] | None = None, cookie_builder: Callable[[CookieSpec], str] | None = None, + descriptor_of: Callable[[Request, Session], dict[str, Any]] | None = None, skip: Callable[[Request], bool] | None = None, ) -> None: self.app = app @@ -338,6 +446,7 @@ def __init__( self._principal_of = principal_of or _default_principal_of self._subject_of = subject_of or _default_subject_of self._cookie_builder = cookie_builder or build_cookie + self._descriptor_of = descriptor_of self._skip = skip async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: @@ -347,7 +456,9 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: request = Request(scope, receive=receive) if self._skip is not None and self._skip(request): - scope[SCOPE_KEY] = Session() + empty = Session() + scope[SCOPE_KEY] = empty + scope[STATE_SCOPE_KEY] = SessionState(data=empty) await self.app(scope, receive, send) return @@ -356,6 +467,7 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: state = await self._load(request, store, settings) scope[SCOPE_KEY] = state.session setattr(request.state, _STATE_ATTR, state) + scope[STATE_SCOPE_KEY] = state.store_state started = False @@ -379,8 +491,8 @@ async def _load( """Read the cookie, load the record, take the first principal snapshot.""" raw_cookie = request.cookies.get(settings.session_cookie_name) session = Session() + store_state = SessionState(data=session) loaded_id: str | None = None - absolute_remaining: int | None = None # Validate before use. This is not cosmetic: the value is written back # into a Set-Cookie header, so an unvalidated one is a header-injection @@ -391,15 +503,21 @@ async def _load( ) if loaded is not None: session = Session(loaded.record.data) - session.sid = raw_cookie loaded_id = raw_cookie - absolute_remaining = loaded.absolute_remaining + store_state = SessionState( + data=session, + session_id=raw_cookie, + subject=self._subject_of_quietly(session), + created=loaded.record.metadata.created, + absolute_remaining=loaded.absolute_remaining, + ) return _RequestState( session=session, + store_state=store_state, loaded_id=loaded_id, principal_before=self._snapshot(session), - absolute_remaining=absolute_remaining, + request=request, ) def _snapshot(self, session: Session) -> Any: @@ -427,106 +545,192 @@ async def _on_response_start( status: int, headers: list[tuple[bytes, bytes]], ) -> list[tuple[bytes, bytes]]: - """Apply the write rule, then the cookie rule.""" + """Decide once what this response owes, then do exactly that. + + The decision is :func:`decide_outcome`, which is pure and lives apart + from the I/O so its branch order can be read, tested and argued about + on its own. This method only carries it out. + """ session = state.session + store_state = state.store_state if session.accessed: - # Without this a shared cache can serve one user's page to another. - headers.append((b"vary", b"Cookie")) - - if session.revoked: - headers.append( - (b"set-cookie", clear_cookie(self._spec(settings, "", None)).encode()) + self._apply_cache_headers(scope_of(state), headers) + + # ``principal_of`` is user code and must not mark the session, so the + # snapshot is shielded. It is the one signal that costs anything. + changed = self._snapshot(session) != state.principal_before + + outcome = decide_outcome( + _Signals( + revoked=store_state.revoked, + rotated=store_state.rotated, + changed=changed, + accessed=session.accessed, + modified=session.modified, + empty=not session, + stored=state.loaded_id is not None, + id_changed=( + store_state.session_id is not None + and store_state.session_id != state.loaded_id + ), + failed=status >= 400, + always_save=bool(settings.session_always_save), + refresh_on_load=bool(settings.session_refresh_on_load), ) - return headers - - principal_after = self._snapshot(session) - changed = principal_after != state.principal_before - rotating = (session.rotated or changed) and status < 400 + ) - if changed and status >= 400: - # A response the client saw fail must not hand out an - # authenticated session. Persisting the data while skipping the - # rotation would store the new identity against the *old*, - # unrotated ID - precisely the fixation this design prevents, - # arrived at by being helpful. So this request writes nothing. + if outcome is Outcome.NOTHING or outcome is Outcome.SUPPRESS: return headers - if rotating: + if outcome is Outcome.CLEAR_COOKIE: + return self._with_cookie(headers, settings, clear=True) + + if outcome is Outcome.SIGN_OUT: + await store.revoke(store_state) + return self._with_cookie(headers, settings, clear=True) + + if outcome is Outcome.COOKIE_ONLY: + # Any payload change the handler made *after* its rotate call is + # not in the record ``rotate`` wrote, so catch it here. + if session.modified: + await self._write(store, state, settings, create=False) + return self._with_cookie( + headers, + settings, + value=store_state.session_id or "", + absolute=store.absolute_seconds, + ) + + if outcome is Outcome.ROTATE: new_id = await store.rotate( - session, subject=self._subject_of(session) or None + store_state, + subject=self._subject_of_quietly(session) or None, + descriptor=self._descriptor(state), ) - headers.append( - ( - b"set-cookie", - self._cookie_builder( - self._spec(settings, new_id, store.absolute_seconds) - ).encode(), - ) + return self._with_cookie( + headers, settings, value=new_id, absolute=store.absolute_seconds ) - return headers - if session.sid is not None and session.sid != state.loaded_id: - # A handler called ``store.rotate()`` itself, so the work is done - # and only the cookie is outstanding. Without this branch the - # browser would keep an identifier whose key the handler deleted, - # and the user would be signed out by their own step-up. - headers.append( - ( - b"set-cookie", - self._cookie_builder( - self._spec(settings, session.sid, store.absolute_seconds) - ).encode(), - ) + if outcome is Outcome.WRITE: + await self._write( + store, state, settings, create=store_state.session_id is None + ) + return self._with_cookie( + headers, + settings, + value=store_state.session_id or "", + absolute=store_state.absolute_remaining, ) - return headers - if not session.accessed: + if outcome is Outcome.TOUCH: + await store.touch(cast(str, state.loaded_id)) return headers - if session.modified or settings.session_always_save: - await self._write(store, state, settings) - headers.append( - ( - b"set-cookie", - self._cookie_builder( - self._spec( - settings, session.sid or "", state.absolute_remaining - ) - ).encode(), - ) - ) - elif not settings.session_refresh_on_load and state.loaded_id is not None: - # The load was a plain read under this setting, so this is the only - # place left that can advance the idle clock. Omitting this branch - # freezes the clock and makes every session immortal until its - # absolute deadline. - await store.touch(state.loaded_id) + raise AssertionError(f"unhandled outcome {outcome!r}") # pragma: no cover + + @staticmethod + def _apply_cache_headers( + scope: MutableMapping[str, Any], headers: list[tuple[bytes, bytes]] + ) -> None: + """Say how this response varies, and who may store it. + + Two rules, and both exist because the caching feature and this + middleware must agree rather than each appending a header of its own. + + ``Vary: Cookie`` is **merged** into any existing value rather than + appended, so a response that already varies on ``Accept-Encoding`` + ends up with one header listing both. Two ``Vary`` lines are legal + but proxies handle them inconsistently. + + It is **omitted** entirely when a ``cache(vary_on_session=False)`` + route has said the body does not depend on the session. The session + was read - by an auth dependency, typically - but the answer is the + same for everyone, and claiming otherwise forces a shared cache to + keep one copy per user of an identical payload. + + ``Cache-Control: private`` is emitted only when no ``cache()`` owns + the route. Where one does, it sets the directive itself from the + route's declaration, and a second writer here is what produced + ``max-age=300, private, no-store`` in one response. + """ + if not scope.get(CACHE_SUPPRESS_VARY_SCOPE_KEY): + _merge_header(headers, b"vary", b"Cookie") + if not scope.get(CACHE_ROUTE_SCOPE_KEY): + _merge_header(headers, b"cache-control", b"private") + + def _with_cookie( + self, + headers: list[tuple[bytes, bytes]], + settings: Any, + *, + value: str = "", + absolute: int | None = None, + clear: bool = False, + ) -> list[tuple[bytes, bytes]]: + """Append one ``Set-Cookie``, always through the configured builder. + Deletion goes through the same seam as creation. A browser removes a + cookie only when the clearing header repeats every scoping attribute, + so a ``cookie_builder`` added to emit ``Partitioned`` or a ``__Host-`` + prefix - the seam's whole purpose - has to be consulted here too. It + was not, and the result was a sign-out that left the cookie in place. + """ + spec = self._spec(settings, value, absolute) + if clear: + spec = spec.cleared() + headers.append((b"set-cookie", self._cookie_builder(spec).encode())) return headers + def _descriptor(self, state: _RequestState) -> dict[str, Any] | None: + """Evaluate the ``descriptor_of`` seam, if one was supplied.""" + if self._descriptor_of is None or state.request is None: + return None + return self._descriptor_of(state.request, state.session) + + def _subject_of_quietly(self, session: Session) -> str | None: + """Call ``subject_of`` without letting it mark the session. + + Same reason as :meth:`_snapshot`: the middleware calls it on requests + the application never touched, and a read that sets ``accessed`` would + put ``Vary: Cookie`` on responses that do not vary by cookie. + """ + accessed, modified = session.accessed, session.modified + try: + return self._subject_of(session) + finally: + session.accessed, session.modified = accessed, modified + async def _write( - self, store: SessionStore, state: _RequestState, settings: Any + self, store: SessionStore, state: _RequestState, settings: Any, *, create: bool ) -> None: """Persist the payload, and re-assert the index entry beside it.""" session = state.session - if session.sid is None: - session.sid = store.new_id() - state.absolute_remaining = store.absolute_seconds - record = store.new_record(session.raw()) - await store.save(session.sid, record) + store_state = state.store_state + if store_state.session_id is None: + store_state.session_id = store.new_id() + store_state.absolute_remaining = store.absolute_seconds + create = True + record = store.new_record(session.raw(), created=store_state.created) + if create: + await store.create(store_state.session_id, record) + store_state.created = record.metadata.created + else: + await store.save(store_state.session_id, record) - subject = self._subject_of(session) + subject = self._subject_of_quietly(session) + store_state.subject = subject if subject: - # Re-asserted on every write, not only at login: HSETEX is - # idempotent, it costs nothing extra here, and it repairs an entry - # that a partial failure lost. The remaining absolute time, never a - # fresh lifetime. + # Re-asserted on every write, not only at login: it costs nothing + # extra here, and it repairs an entry that a partial failure lost. + # The remaining absolute time, never a fresh lifetime. await store.index( subject, - session.sid, + store_state.session_id, record, - absolute_remaining=state.absolute_remaining or store.absolute_seconds, + absolute_remaining=store_state.absolute_remaining + or store.absolute_seconds, + extra=self._descriptor(state), ) def _spec(self, settings: Any, value: str, absolute: int | None) -> CookieSpec: @@ -597,7 +801,17 @@ def add_redis_sessions( principal_keys: list[str] | None = None, subject_of: Callable[[Session], str | None] | None = None, cookie_builder: Callable[[CookieSpec], str] | None = None, + descriptor_of: Callable[[Request, Session], dict[str, Any]] | None = None, skip: Callable[[Request], bool] | None = None, + store: SessionStore | None = None, + store_factory: Callable[[Request], Any] | None = None, + coder: type[Coder] | None = None, + encryptor: Encryptor | None = None, + id_factory: Callable[[], str] | None = None, + key_prefix: str | None = None, + idle_ttl: int | timedelta | None = None, + absolute_ttl: int | timedelta | None = None, + gc_ttl: int | timedelta | None = None, ) -> None: """Register :class:`SessionMiddleware` on *app*. @@ -619,9 +833,34 @@ def add_redis_sessions( anonymous one. The subject need not be a user - it can be a tenant, a device, or an API client. cookie_builder: Renders the ``Set-Cookie`` value, for attributes this - package does not know about. + package does not know about. Used for clearing the cookie as + well as setting it. + descriptor_of: What a device listing should show for this session - + an IP, a user agent, a device name. Stored beside the session in + the index, never inside the payload, so it cannot appear in the + application's own ``request.session``. skip: Predicate for requests that need no session at all. A request it returns true for costs zero Redis calls. + store: A ready-made store, used for every request. The escape hatch + for a backend that is not Redis, and for a test double. + store_factory: Called per request to build one, when a single instance + will not do. May be sync or async. + coder: Serializer for the envelope. Defaults to ``JsonCoder``. + encryptor: Encrypts the serialized envelope at rest. Encryption wraps + serialization, so the coder never sees ciphertext. + id_factory: Generates session IDs. Its output is validated on every + call, because a seam supplying a security-critical value has to be + checked rather than trusted. + key_prefix: Overrides the key namespace. + idle_ttl: Idle clock, overriding ``session_idle_ttl``. Accepts a + ``timedelta``. + absolute_ttl: Absolute clock, overriding ``session_absolute_ttl``. + gc_ttl: Backstop TTL, overriding ``session_gc_ttl``. + + These last eight exist because the store constructor has always accepted + them and nothing reachable from here passed them on: the only way to change + a coder was to replace the whole dependency, and that did not reach the + middleware at all. Raises: SessionConfigurationError: If the cookie settings contradict each @@ -647,16 +886,64 @@ def resolver(session: Session) -> Any: # noqa: F811 from redis_fastapi.deps import get_session_store + # Tells the lifespan that a session-event subscriber may be worth starting; + # cache-only apps never set it. Safe before startup: the builder runs at + # app-construction time and the lifespan reads it later. + app.state._redis_sessions = True + + from redis_fastapi.deps import _SessionStoreOptions + + kwargs: dict[str, Any] = { + name: value + for name, value in ( + ("coder", coder), + ("encryptor", encryptor), + ("id_factory", id_factory), + ("key_prefix", key_prefix), + ("idle_ttl", idle_ttl), + ("absolute_ttl", absolute_ttl), + ("gc_ttl", gc_ttl), + ) + if value is not None + } + if store is not None and store_factory is not None: + raise SessionConfigurationError("Pass either store or store_factory, not both.") + app.state._redis_session_options = _SessionStoreOptions( + store=store, store_factory=store_factory, kwargs=kwargs + ) + app.add_middleware( SessionMiddleware, store_factory=get_session_store, principal_of=resolver, subject_of=subject_of, cookie_builder=cookie_builder, + descriptor_of=descriptor_of, skip=skip, ) +def session_state_of(request: Request) -> SessionState: + """Return the :class:`SessionState` the middleware built for *request*. + + The handle the store operates on: the identifier, the subject, the + timestamps and the pending rotation or revocation. Handlers need it to + call ``rotate``, ``revoke`` or ``session_id``; ``request.session`` remains + the way to reach the data. + + Raises: + SessionConfigurationError: If no middleware is registered. + """ + state = request.scope.get(STATE_SCOPE_KEY) + if not isinstance(state, SessionState): + raise SessionConfigurationError( + "No session was loaded for this request. Call " + "FastAPIRedis(app).lifespan().sessions() during setup, or " + "add_redis_sessions(app) directly." + ) + return state + + def session_of(request: Request) -> Session: """Return the :class:`Session` the middleware loaded for *request*. @@ -675,3 +962,26 @@ def session_of(request: Request) -> Session: "add_redis_sessions(app) directly." ) return session + + +def _merge_header( + headers: list[tuple[bytes, bytes]], name: bytes, value: bytes +) -> None: + """Add *value* to an existing header of that name, or append a new one. + + Idempotent: a value already present is not repeated. + """ + for index, (existing_name, existing_value) in enumerate(headers): + if existing_name.lower() != name: + continue + parts = [p.strip() for p in existing_value.split(b",") if p.strip()] + if value in parts: + return + headers[index] = (existing_name, b", ".join([*parts, value])) + return + headers.append((name, value)) + + +def scope_of(state: _RequestState) -> MutableMapping[str, Any]: + """The ASGI scope behind a request state, for reading cross-feature flags.""" + return state.request.scope if state.request is not None else {} diff --git a/src/redis_fastapi/setup.py b/src/redis_fastapi/setup.py index 7bc5aac..d6bb249 100644 --- a/src/redis_fastapi/setup.py +++ b/src/redis_fastapi/setup.py @@ -147,7 +147,9 @@ def sessions( principal_keys: list[str] | None = None, subject_of: Callable[[Session], str | None] | None = None, cookie_builder: Callable[[CookieSpec], str] | None = None, + descriptor_of: Callable[[Request, Session], dict[str, Any]] | None = None, skip: Callable[[Request], bool] | None = None, + **store_options: Any, ) -> FastAPIRedis: """Register the session middleware. @@ -169,7 +171,13 @@ def sessions( where a function is overkill. subject_of: Which subject a session is indexed under; ``None`` leaves it out of the index. - cookie_builder: Renders the ``Set-Cookie`` value. + cookie_builder: Renders the ``Set-Cookie`` value, for setting and + for clearing it. + descriptor_of: What a device listing shows for this session - an + IP, a user agent, a device name. + **store_options: Passed to :func:`add_redis_sessions` - ``store``, + ``store_factory``, ``coder``, ``encryptor``, ``id_factory``, + ``key_prefix``, ``idle_ttl``, ``absolute_ttl``, ``gc_ttl``. skip: Requests that need no session at all, at zero Redis cost. """ from redis_fastapi.sessions import SessionMiddleware, add_redis_sessions @@ -182,7 +190,9 @@ def sessions( principal_keys=principal_keys, subject_of=subject_of, cookie_builder=cookie_builder, + descriptor_of=descriptor_of, skip=skip, + **store_options, ) return self diff --git a/src/redis_fastapi/types.py b/src/redis_fastapi/types.py index 8d41bd4..6d14608 100644 --- a/src/redis_fastapi/types.py +++ b/src/redis_fastapi/types.py @@ -60,5 +60,24 @@ def decode(cls, value: str) -> ModelT: return _PydanticModelCoder +@runtime_checkable +class Encryptor(Protocol): + """Protocol for encrypting a serialized payload at rest. + + Encryption **wraps** serialization, never the reverse: the ``Coder`` turns + the value into bytes, and only then does the encryptor see them. A coder + must never be handed ciphertext. + + This package ships the seam and not an implementation, deliberately - see + the recipe in the sessions guide. Ten lines of ``AESGCM`` in your own + codebase is a smaller liability for everyone than a cryptographic + primitive maintained here. + """ + + def encrypt(self, data: bytes) -> bytes: ... # pragma: no cover + + def decrypt(self, data: bytes) -> bytes: ... # pragma: no cover + + # A key builder receives (request, eviction_group, prefix) and returns a cache key. KeyBuilder: TypeAlias = Callable[..., str | Awaitable[str]] diff --git a/tests/integration/test_session_cache_invariants.py b/tests/integration/test_session_cache_invariants.py new file mode 100644 index 0000000..9cc04a0 --- /dev/null +++ b/tests/integration/test_session_cache_invariants.py @@ -0,0 +1,322 @@ +"""Integration tests for the caching / session cross-section, against real Redis. + +Four cases, one per row of the declaration table, each asserting **both** +halves: what this library stores, and what the response tells everything +downstream it may store. The two must agree, which is the whole point. + +The invariants under test: + +* **N-18** - the emitted directives are never more permissive than the caching + this library performs for that response. +* **N-10 (revised)** - a response whose content *depends* on the session is not + stored by any shared cache, ours or downstream. +* **F-10 (revised)** - ``Vary: Cookie`` is emitted whenever the response may + vary by cookie; not whenever the session merely happened to be read. +* **No cross-user leak** - the property all three exist to protect. + +These are integration rather than unit tests because the leak is only +observable end to end: it needs a real store, two real clients, two real +cookies and a real second request. +""" + +import pytest +import redis as sync_redis +from fastapi import Depends, FastAPI +from fastapi.testclient import TestClient + +from redis_fastapi.cache import cache +from redis_fastapi.config import get_settings +from redis_fastapi.deps import SessionDep +from redis_fastapi.setup import FastAPIRedis +from tests.conftest import requires_redis + +pytestmark = [pytest.mark.integration, requires_redis] + + +@pytest.fixture() +def app(real_redis: sync_redis.Redis, test_prefix: str, monkeypatch): + """A real app on a real pool. + + The pool is built by the real lifespan rather than injected, because + ``TestClient`` runs its own event loop and an async client created on the + fixture's loop cannot be shared with it. + """ + get_settings.cache_clear() + monkeypatch.setenv("REDIS_SESSION_COOKIE_HTTPS_ONLY", "false") + monkeypatch.setenv("REDIS_PREFIX", test_prefix) + get_settings.cache_clear() + + application = FastAPI() + FastAPIRedis(application).lifespan().caching().sessions() + + async def require_user(session: SessionDep) -> int | None: + """An auth guard: reads the session, contributes nothing to the body.""" + return session.get("user_id") + + @application.post("/login/{uid}") + async def login(uid: int, session: SessionDep) -> dict: + session["user_id"] = uid + return {"ok": True} + + # Row 1 - the body depends on who is asking. + @application.get( + "/me", + dependencies=[Depends(cache(ttl=300, vary_on_session=True))], + ) + async def me(session: SessionDep) -> dict: + return {"user_id": session.get("user_id")} + + # Row 2 - reads the session, body identical for everyone. + @application.get( + "/catalogue", + dependencies=[ + Depends(require_user), + Depends(cache(ttl=300, vary_on_session=False)), + ], + ) + async def catalogue() -> dict: + return {"products": ["a", "b"]} + + # Row 3 - never touches the session. + @application.get("/status", dependencies=[Depends(cache(ttl=300))]) + async def status() -> dict: + return {"ok": True} + + # Row 4 - reads the session, declares nothing. + @application.get("/undeclared", dependencies=[Depends(cache(ttl=300))]) + async def undeclared(session: SessionDep) -> dict: + return {"user_id": session.get("user_id")} + + # No cache() at all, but the session is read. + @application.get("/profile") + async def profile(session: SessionDep) -> dict: + return {"user_id": session.get("user_id")} + + yield application + get_settings.cache_clear() + + +class _User: + """One signed-in user, sharing a single ``TestClient``. + + Two clients would mean two lifespans on one app, each replacing the + other's connection pool - and the pool is bound to the loop that created + it. So the users are cookie jars, not clients. + """ + + def __init__(self, client: TestClient, cookies: dict[str, str]) -> None: + self._client = client + self._cookies = cookies + + def get(self, path: str): + # Set on the client rather than per request: httpx deprecated the + # per-request form because its persistence behaviour is ambiguous. + self._client.cookies.clear() + self._client.cookies.update(self._cookies) + return self._client.get(path) + + +@pytest.fixture() +def client(app: FastAPI): + with TestClient(app) as test_client: + yield test_client + + +def _sign_in(client: TestClient, uid: int) -> _User: + client.cookies.clear() + client.post(f"/login/{uid}") + cookies = {"session": client.cookies["session"]} + client.cookies.clear() + return _User(client, cookies) + + +@pytest.fixture() +def alice(client: TestClient) -> _User: + return _sign_in(client, 1) + + +@pytest.fixture() +def bob(client: TestClient) -> _User: + return _sign_in(client, 2) + + +def _directives(response) -> set[str]: + raw = response.headers.get("cache-control", "") + return {part.strip() for part in raw.split(",") if part.strip()} + + +class TestRow1PerUser: + """`vary_on_session=True` - the body depends on who is asking.""" + + def test_each_user_gets_their_own_body(self, alice, bob) -> None: + assert alice.get("/me").json() == {"user_id": 1} + assert bob.get("/me").json() == {"user_id": 2} + + def test_each_user_still_gets_a_cache_hit(self, alice, bob) -> None: + """Per-user keying must not mean per-user cache misses forever.""" + alice.get("/me") + bob.get("/me") + assert alice.get("/me").headers["x-redis-cache"] == "HIT" + assert bob.get("/me").headers["x-redis-cache"] == "HIT" + assert alice.get("/me").json() == {"user_id": 1} + + def test_n18_the_directive_is_no_more_permissive_than_our_key(self, alice) -> None: + """We store per subject, so a shared cache must not store at all. + + Without `private` a CDN keeps one copy for everyone and recreates the + leak the per-user key just closed, one hop further out. + """ + for response in (alice.get("/me"), alice.get("/me")): # miss, then hit + assert "private" in _directives(response) + assert "max-age=300" in _directives(response) + + def test_n18_holds_on_the_hit_path_too(self, alice) -> None: + """The hit path is where a session-emitted header could not reach. + + A hit short-circuits before the endpoint runs, so the session + middleware contributes nothing; the directive has to come from the + route's own declaration. + """ + alice.get("/me") + hit = alice.get("/me") + assert hit.headers["x-redis-cache"] == "HIT" + assert "private" in _directives(hit) + + def test_f10_vary_is_emitted(self, alice) -> None: + """`private` stops shared caches; `Vary` stops the browser reusing a + previous user's copy across a sign-out and sign-in.""" + assert "Cookie" in alice.get("/me").headers.get("vary", "") + + +class TestRow2SharedButSessionReading: + """`vary_on_session=False` - reads the session, body identical to all.""" + + def test_one_shared_entry_serves_everyone(self, alice, bob) -> None: + first = alice.get("/catalogue") + second = bob.get("/catalogue") + assert first.headers["x-redis-cache"] == "MISS" + assert second.headers["x-redis-cache"] == "HIT" + assert first.json() == second.json() + + def test_n18_public_is_exactly_as_permissive_as_our_own_entry(self, alice) -> None: + """The invariant must not push us to `private` here. + + We keep one shared entry, so a shared cache keeping one is an equal + permission, not a greater one. A blanket "sessions imply private" rule + would fail this row and cost hit rate for no safety gain. + """ + directives = _directives(alice.get("/catalogue")) + assert "private" not in directives + assert "no-store" not in directives + assert "max-age=300" in directives + + def test_f10_vary_is_suppressed(self, alice) -> None: + """The session was read, so the middleware would add `Vary: Cookie`. + + Keeping it would force a shared cache to store one copy per user of a + payload identical to all of them - which is exactly the cost this + declaration exists to avoid. + """ + assert "Cookie" not in alice.get("/catalogue").headers.get("vary", "") + + def test_n10_revised_this_row_is_deliberately_out_of_scope( + self, alice, bob + ) -> None: + """A session-*bearing* response that is not session-*dependent*. + + Under N-10's original wording this row was forbidden. The revision is + what allows it, and this is the test that pins the distinction. + """ + assert alice.get("/catalogue").json() == bob.get("/catalogue").json() + assert "no-store" not in _directives(bob.get("/catalogue")) + + +class TestRow3NoSessionAtAll: + def test_shared_and_public_as_before(self, alice, bob) -> None: + assert alice.get("/status").headers["x-redis-cache"] == "MISS" + assert bob.get("/status").headers["x-redis-cache"] == "HIT" + + def test_no_vary_and_no_private(self, alice) -> None: + """Nothing accessed the session, so nothing adds either header.""" + response = alice.get("/status") + assert "Cookie" not in response.headers.get("vary", "") + assert "private" not in _directives(response) + + def test_declaring_nothing_is_the_same_declaration_as_row_four(self, alice) -> None: + """Rows 3 and 4 omit the parameter; the runtime tells them apart. + + The difference is not configuration, it is whether the endpoint turned + out to read the session. + """ + assert alice.get("/status").headers["x-redis-cache"] == "MISS" + assert "x-redis-cache" not in alice.get("/undeclared").headers + + +class TestRow4UndeclaredIsNotStored: + """The safety net. The only behaviour change to existing code.""" + + def test_no_cross_user_leak(self, alice, bob) -> None: + """The property the whole design exists for. + + Before the guard, Bob received Alice's body from our own Redis for the + full TTL. + """ + assert alice.get("/undeclared").json() == {"user_id": 1} + assert bob.get("/undeclared").json() == {"user_id": 2} + + def test_nothing_is_stored(self, alice) -> None: + alice.get("/undeclared") + second = alice.get("/undeclared") + assert second.headers.get("x-redis-cache") != "HIT" + assert "etag" not in second.headers + + def test_the_entry_never_reaches_redis( + self, alice, real_redis: sync_redis.Redis, test_prefix: str + ) -> None: + alice.get("/undeclared") + assert real_redis.keys(f"*{test_prefix}*undeclared*") == [] + + def test_n18_the_directive_matches_our_refusal_to_store(self, alice) -> None: + """We store nothing, so nothing downstream may store it either. + + Emitting `max-age=300` here would say "too dangerous for me to cache, + but you go ahead" - the starkest possible violation of N-18. + """ + directives = _directives(alice.get("/undeclared")) + assert "no-store" in directives + assert "private" in directives + assert not any(d.startswith("max-age") for d in directives) + + def test_f10_vary_is_kept(self, alice) -> None: + """We do not know whether the body varies, so we say it might.""" + assert "Cookie" in alice.get("/undeclared").headers.get("vary", "") + + def test_it_warns_once_naming_the_route(self, alice, caplog) -> None: + from redis_fastapi.cache import _WARNED_ROUTES + + _WARNED_ROUTES.clear() + with caplog.at_level("WARNING"): + alice.get("/undeclared") + alice.get("/undeclared") + warnings = [r for r in caplog.records if "vary_on_session" in r.message] + assert len(warnings) == 1 + assert "/undeclared" in warnings[0].getMessage() + + +class TestASessionRouteWithNoCacheAtAll: + def test_the_middleware_emits_private_itself(self, alice) -> None: + """Nothing else will, on a route `cache()` does not own.""" + response = alice.get("/profile") + assert "private" in _directives(response) + assert "Cookie" in response.headers.get("vary", "") + + def test_only_one_cache_control_header_is_emitted(self, alice) -> None: + """Two writers produced `max-age=300, private, no-store` in one + response. There must be exactly one.""" + for path in ("/profile", "/me", "/catalogue", "/undeclared"): + response = alice.get(path) + assert len(response.headers.get_list("cache-control")) <= 1, path + + def test_vary_is_merged_not_duplicated(self, alice) -> None: + for path in ("/profile", "/me"): + assert len(alice.get(path).headers.get_list("vary")) == 1, path diff --git a/tests/integration/test_session_integration.py b/tests/integration/test_session_integration.py index 27559ab..0477e37 100644 --- a/tests/integration/test_session_integration.py +++ b/tests/integration/test_session_integration.py @@ -39,7 +39,7 @@ async def test_round_trip( ) -> None: store = _store(real_async_redis, test_prefix) sid = store.new_id() - await store.save(sid, store.new_record({"user_id": 42})) + await store.create(sid, store.new_record({"user_id": 42})) loaded = await store.load(sid) assert loaded is not None assert loaded.record.data == {"user_id": 42} @@ -51,7 +51,7 @@ async def test_redis_enforces_the_idle_clock( """The server expires the field; nothing here counts down.""" store = _store(real_async_redis, test_prefix, idle_ttl=1, absolute_ttl=600) sid = store.new_id() - await store.save(sid, store.new_record({"user_id": 42})) + await store.create(sid, store.new_record({"user_id": 42})) assert await store.load(sid) is not None await asyncio.sleep(1.5) @@ -68,7 +68,7 @@ async def test_redis_enforces_the_absolute_clock_despite_activity( """ store = _store(real_async_redis, test_prefix, idle_ttl=60, absolute_ttl=2) sid = store.new_id() - await store.save(sid, store.new_record({"user_id": 42})) + await store.create(sid, store.new_record({"user_id": 42})) for _ in range(4): await asyncio.sleep(0.6) @@ -95,7 +95,7 @@ async def test_the_index_prunes_itself_with_no_help_from_us( store = _store(real_async_redis, test_prefix, idle_ttl=60, absolute_ttl=1) sid = store.new_id() record = store.new_record({"user_id": "42"}) - await store.save(sid, record) + await store.create(sid, record) await store.index("42", sid, record, absolute_remaining=1) assert len(await store.list_for_subject("42")) == 1 @@ -107,15 +107,14 @@ async def test_the_index_prunes_itself_with_no_help_from_us( async def test_rotation_deletes_the_old_key_before_writing_the_new_one( real_async_redis: async_redis.Redis, test_prefix: str ) -> None: - from redis_fastapi.sessions import Session + from redis_fastapi.session_backend import SessionState store = _store(real_async_redis, test_prefix) - session = Session({"user_id": 42}) - session.sid = store.new_id() - await store.save(session.sid, store.new_record(session.raw())) - old = session.sid + state = SessionState(data={"user_id": 42}, session_id=store.new_id()) + await store.create(state.session_id, store.new_record(dict(state.data))) + old = state.session_id - new = await store.rotate(session, subject="42") + new = await store.rotate(state, subject="42") assert new != old assert await real_async_redis.exists(store.session_key(old)) == 0 assert await real_async_redis.exists(store.session_key(new)) == 1 @@ -127,7 +126,7 @@ async def test_writing_the_payload_leaves_the_deadline_alone( store = _store(real_async_redis, test_prefix) sid = store.new_id() key = store.session_key(sid) - await store.save(sid, store.new_record({"n": 0})) + await store.create(sid, store.new_record({"n": 0})) before = ( await real_async_redis.execute_command("HTTL", key, "FIELDS", 1, FIELD_ABSOLUTE) @@ -153,7 +152,7 @@ async def test_revoke_all_ends_every_session( for _ in range(3): sid = store.new_id() record = store.new_record({"user_id": "42"}) - await store.save(sid, record) + await store.create(sid, record) await store.index("42", sid, record, absolute_remaining=600) ids.append(sid) diff --git a/tests/unit/test_session.py b/tests/unit/test_session.py index 3eb0ce2..c86fe34 100644 --- a/tests/unit/test_session.py +++ b/tests/unit/test_session.py @@ -10,12 +10,12 @@ import pytest -from redis_fastapi.sessions import ( - Session, +from redis_fastapi.exceptions import ( SessionConfigurationError, SessionError, SessionStoreError, ) +from redis_fastapi.sessions import Session class TestFlagsStartClean: diff --git a/tests/unit/test_session_backend.py b/tests/unit/test_session_backend.py index e1418bf..c0522cd 100644 --- a/tests/unit/test_session_backend.py +++ b/tests/unit/test_session_backend.py @@ -10,6 +10,10 @@ import pytest from redis_fastapi.config import get_settings +from redis_fastapi.exceptions import ( + SessionConfigurationError, + SessionStoreError, +) from redis_fastapi.session_backend import ( FIELD_ABSOLUTE, FIELD_DATA, @@ -18,7 +22,6 @@ SessionRecord, _StoreCapabilities, ) -from redis_fastapi.sessions import SessionConfigurationError, SessionStoreError @pytest.fixture() @@ -91,7 +94,7 @@ async def test_save_writes_both_fields_with_their_own_ttls( self, store: RedisSessionStore, fake_async_redis ) -> None: sid = store.new_id() - await store.save(sid, store.new_record({"user_id": 42})) + await store.create(sid, store.new_record({"user_id": 42})) key = store.session_key(sid) assert _about(await _httl(fake_async_redis, key, FIELD_ABSOLUTE), 600) @@ -108,7 +111,7 @@ async def test_writing_the_payload_never_extends_the_deadline( """ sid = store.new_id() key = store.session_key(sid) - await store.save(sid, store.new_record({"n": 1})) + await store.create(sid, store.new_record({"n": 1})) # Age the absolute clock, then write the payload many times over. await fake_async_redis.execute_command( @@ -130,7 +133,7 @@ async def test_field_a_always_has_a_ttl( ) -> None: """The ``-1`` row of the state table must be unreachable.""" sid = store.new_id() - await store.save(sid, store.new_record({})) + await store.create(sid, store.new_record({})) assert await _httl(fake_async_redis, store.session_key(sid), FIELD_ABSOLUTE) > 0 async def test_zero_ttls_fall_back_to_gc_ttl(self, fake_async_redis) -> None: @@ -139,7 +142,7 @@ async def test_zero_ttls_fall_back_to_gc_ttl(self, fake_async_redis) -> None: fake_async_redis, idle_ttl=0, absolute_ttl=0, gc_ttl=1234 ) sid = store.new_id() - await store.save(sid, store.new_record({})) + await store.create(sid, store.new_record({})) key = store.session_key(sid) assert _about(await _httl(fake_async_redis, key, FIELD_ABSOLUTE), 1234) assert _about(await _httl(fake_async_redis, key, FIELD_DATA), 1234) @@ -150,7 +153,7 @@ class TestLoadStateTable: async def test_alive(self, store: RedisSessionStore) -> None: sid = store.new_id() - await store.save(sid, store.new_record({"user_id": 42})) + await store.create(sid, store.new_record({"user_id": 42})) loaded = await store.load(sid) assert loaded is not None assert loaded.record.data == {"user_id": 42} @@ -161,7 +164,7 @@ async def test_absolute_deadline_passed_deletes_the_key( ) -> None: sid = store.new_id() key = store.session_key(sid) - await store.save(sid, store.new_record({"user_id": 42})) + await store.create(sid, store.new_record({"user_id": 42})) await fake_async_redis.execute_command( "HDEL", key, FIELD_ABSOLUTE ) # simulate 'a' expiring @@ -178,7 +181,7 @@ async def test_idle_expired_deletes_the_key( ) -> None: sid = store.new_id() key = store.session_key(sid) - await store.save(sid, store.new_record({"user_id": 42})) + await store.create(sid, store.new_record({"user_id": 42})) await fake_async_redis.execute_command("HDEL", key, FIELD_DATA) assert await store.load(sid) is None assert await fake_async_redis.exists(key) == 0 @@ -195,7 +198,7 @@ async def test_an_absolute_limit_of_zero_still_loads( """ store = RedisSessionStore(fake_async_redis, idle_ttl=60, absolute_ttl=0) sid = store.new_id() - await store.save(sid, store.new_record({"user_id": 7})) + await store.create(sid, store.new_record({"user_id": 7})) loaded = await store.load(sid) assert loaded is not None assert loaded.record.data == {"user_id": 7} @@ -212,7 +215,7 @@ async def test_load_restarts_the_idle_clock( ) -> None: sid = store.new_id() key = store.session_key(sid) - await store.save(sid, store.new_record({})) + await store.create(sid, store.new_record({})) await fake_async_redis.execute_command( "HEXPIRE", key, 5, "FIELDS", 1, FIELD_DATA ) @@ -224,7 +227,7 @@ async def test_refresh_false_leaves_the_idle_clock_alone( ) -> None: sid = store.new_id() key = store.session_key(sid) - await store.save(sid, store.new_record({})) + await store.create(sid, store.new_record({})) await fake_async_redis.execute_command( "HEXPIRE", key, 5, "FIELDS", 1, FIELD_DATA ) @@ -241,7 +244,7 @@ async def test_touch_advances_the_idle_clock( """ sid = store.new_id() key = store.session_key(sid) - await store.save(sid, store.new_record({})) + await store.create(sid, store.new_record({})) await fake_async_redis.execute_command( "HEXPIRE", key, 5, "FIELDS", 1, FIELD_DATA ) @@ -253,7 +256,7 @@ async def test_touch_does_not_extend_the_absolute_clock( ) -> None: sid = store.new_id() key = store.session_key(sid) - await store.save(sid, store.new_record({})) + await store.create(sid, store.new_record({})) await fake_async_redis.execute_command( "HEXPIRE", key, 100, "FIELDS", 1, FIELD_ABSOLUTE ) @@ -279,7 +282,7 @@ async def test_round_trip( self, old_store: RedisSessionStore, fake_async_redis ) -> None: sid = old_store.new_id() - await old_store.save(sid, old_store.new_record({"user_id": 42})) + await old_store.create(sid, old_store.new_record({"user_id": 42})) loaded = await old_store.load(sid) assert loaded is not None assert loaded.record.data == {"user_id": 42} @@ -288,7 +291,7 @@ async def test_same_ttls_as_the_modern_path( self, old_store: RedisSessionStore, fake_async_redis ) -> None: sid = old_store.new_id() - await old_store.save(sid, old_store.new_record({})) + await old_store.create(sid, old_store.new_record({})) key = old_store.session_key(sid) assert _about(await _httl(fake_async_redis, key, FIELD_ABSOLUTE), 600) assert _about(await _httl(fake_async_redis, key, FIELD_DATA), 60) @@ -298,7 +301,7 @@ async def test_repeated_writes_still_never_extend_the_deadline( ) -> None: sid = old_store.new_id() key = old_store.session_key(sid) - await old_store.save(sid, old_store.new_record({})) + await old_store.create(sid, old_store.new_record({})) await fake_async_redis.execute_command( "HEXPIRE", key, 100, "FIELDS", 1, FIELD_ABSOLUTE ) @@ -335,7 +338,7 @@ async def test_delete_removes_the_key( self, store: RedisSessionStore, fake_async_redis ) -> None: sid = store.new_id() - await store.save(sid, store.new_record({})) + await store.create(sid, store.new_record({})) await store.delete(sid) assert await fake_async_redis.exists(store.session_key(sid)) == 0 assert await store.load(sid) is None diff --git a/tests/unit/test_session_failures.py b/tests/unit/test_session_failures.py index 6f6901c..2a350d6 100644 --- a/tests/unit/test_session_failures.py +++ b/tests/unit/test_session_failures.py @@ -18,8 +18,8 @@ from redis.exceptions import ConnectionError as RedisConnectionError from redis_fastapi.config import get_settings -from redis_fastapi.session_backend import RedisSessionStore -from redis_fastapi.sessions import Session, SessionStoreError +from redis_fastapi.exceptions import SessionStoreError +from redis_fastapi.session_backend import RedisSessionStore, SessionState class _BrokenPipeline: @@ -91,8 +91,18 @@ async def test_a_failed_listing_returns_empty( ) -> None: assert await broken.list_for_subject("42") == [] - async def test_a_failed_count_returns_zero(self, broken: RedisSessionStore) -> None: - assert await broken.count_for_subject("42") == 0 + async def test_a_failed_count_raises_rather_than_answering_zero( + self, broken: RedisSessionStore + ) -> None: + """The one read that must **not** fail open. + + Section 7's argument for an empty session is that the application's + own authorization still runs. For a count the store *is* the answer, + and ``0`` is the permissive one - a concurrent-session cap would wave + every login through exactly when Redis is unhealthy. + """ + with pytest.raises(SessionStoreError, match="Could not count sessions"): + await broken.count_for_subject("42") class TestFailClosedFlipsReadsOnly: @@ -168,10 +178,16 @@ async def test_a_failed_cleanup_delete_is_swallowed( await broken._safe_delete(broken.new_id()) assert "Could not remove a dead session key" in caplog.text - async def test_a_failed_liveness_check_reports_nothing_alive( + async def test_a_failed_liveness_check_answers_unknown_not_empty( self, broken: RedisSessionStore ) -> None: - assert await broken._verify(["a" * 30]) == set() + """``None`` and the empty set must stay distinguishable. + + Conflating them let one transient pipeline error prune every index + entry for a subject, leaving live sessions that ``revoke_all`` could + no longer reach. + """ + assert await broken._verify(["a" * 30]) is None class TestUnreachableStates: @@ -198,7 +214,7 @@ async def test_a_corrupt_record_raises_rather_than_signing_the_user_out( ) -> None: store = RedisSessionStore(fake_async_redis, idle_ttl=60, absolute_ttl=600) sid = store.new_id() - await store.save(sid, store.new_record({"user_id": 42})) + await store.create(sid, store.new_record({"user_id": 42})) await fake_async_redis.execute_command( "HSETEX", store.session_key(sid), "KEEPTTL", "FIELDS", 1, "d", "{not json" ) @@ -231,13 +247,11 @@ async def execute(self) -> object: class TestRevokeOnABrokenStore: async def test_revoke_surfaces_the_failure(self, broken: RedisSessionStore) -> None: """Revocation that quietly does nothing is the worst kind.""" - session = Session({"user_id": 42}) - session.sid = "a" * 30 + state = SessionState(data={"user_id": 42}, session_id="a" * 30) with pytest.raises(SessionStoreError): - await broken.revoke(session) + await broken.revoke(state) async def test_rotate_surfaces_the_failure(self, broken: RedisSessionStore) -> None: - session = Session({"user_id": 42}) - session.sid = "a" * 30 + state = SessionState(data={"user_id": 42}, session_id="a" * 30) with pytest.raises(SessionStoreError): - await broken.rotate(session, subject="42") + await broken.rotate(state, subject="42") diff --git a/tests/unit/test_session_index.py b/tests/unit/test_session_index.py index 9b06502..82e530e 100644 --- a/tests/unit/test_session_index.py +++ b/tests/unit/test_session_index.py @@ -27,7 +27,7 @@ async def _make(store: RedisSessionStore, subject: str, **data) -> str: """Create a session and index it under *subject*.""" sid = store.new_id() record = store.new_record({"user_id": subject, **data}) - await store.save(sid, record) + await store.create(sid, record) await store.index(subject, sid, record, absolute_remaining=600) return sid diff --git a/tests/unit/test_session_middleware.py b/tests/unit/test_session_middleware.py index bf28650..5220c4c 100644 --- a/tests/unit/test_session_middleware.py +++ b/tests/unit/test_session_middleware.py @@ -14,7 +14,12 @@ from fastapi.testclient import TestClient from redis_fastapi.config import get_settings -from redis_fastapi.deps import SessionDep, SessionStoreDep, get_session_store +from redis_fastapi.deps import ( + SessionDep, + SessionStateDep, + SessionStoreDep, + get_session_store, +) from redis_fastapi.session_backend import RedisSessionStore from redis_fastapi.sessions import add_redis_sessions @@ -49,9 +54,6 @@ async def _store(request: Request) -> RedisSessionStore: application.dependency_overrides[get_session_store] = _store # The middleware resolves the store directly, not through DI, so point it # at the same fake instance. - for mw in application.user_middleware: - if "store_factory" in mw.kwargs: - mw.kwargs["store_factory"] = _store @application.get("/read") async def read(session: SessionDep) -> dict: @@ -78,13 +80,13 @@ async def write(session: SessionDep) -> dict: return {"counter": session["counter"]} @application.post("/logout") - async def logout(session: SessionDep, store: SessionStoreDep) -> dict: - await store.revoke(session) + async def logout(state: SessionStateDep, store: SessionStoreDep) -> dict: + await store.revoke(state) return {"ok": True} @application.post("/step-up") - async def step_up(session: SessionDep, store: SessionStoreDep) -> dict: - return {"sid": await store.rotate(session, subject="42")} + async def step_up(state: SessionStateDep, store: SessionStoreDep) -> dict: + return {"sid": await store.rotate(state, subject="42")} @application.post("/promote") async def promote(session: SessionDep) -> dict: @@ -239,10 +241,7 @@ async def _store(request: Request) -> RedisSessionStore: return store application = FastAPI() - add_redis_sessions(application, skip=lambda request: True) - for mw in application.user_middleware: - if "store_factory" in mw.kwargs: - mw.kwargs["store_factory"] = _store + add_redis_sessions(application, skip=lambda request: True, store_factory=_store) @application.get("/x") async def x(session: SessionDep) -> dict: @@ -298,16 +297,9 @@ def test_no_max_age_is_emitted(self, fake_async_redis, monkeypatch) -> None: monkeypatch.setenv("REDIS_SESSION_ABSOLUTE_TTL", "0") get_settings.cache_clear() - application = FastAPI() - add_redis_sessions(application) store = RedisSessionStore(fake_async_redis, idle_ttl=0, absolute_ttl=0) - - async def _store(request: Request) -> RedisSessionStore: - return store - - for mw in application.user_middleware: - if "store_factory" in mw.kwargs: - mw.kwargs["store_factory"] = _store + application = FastAPI() + add_redis_sessions(application, store=store) @application.post("/w") async def w(session: SessionDep) -> dict: @@ -329,16 +321,9 @@ def test_secure_and_domain_are_emitted_when_configured( monkeypatch.setenv("REDIS_SESSION_COOKIE_DOMAIN", "example.com") get_settings.cache_clear() - application = FastAPI() - add_redis_sessions(application) store = RedisSessionStore(fake_async_redis) - - async def _store(request: Request) -> RedisSessionStore: - return store - - for mw in application.user_middleware: - if "store_factory" in mw.kwargs: - mw.kwargs["store_factory"] = _store + application = FastAPI() + add_redis_sessions(application, store=store) @application.post("/w") async def w(session: SessionDep) -> dict: diff --git a/tests/unit/test_session_outcome.py b/tests/unit/test_session_outcome.py new file mode 100644 index 0000000..77a8a4c --- /dev/null +++ b/tests/unit/test_session_outcome.py @@ -0,0 +1,231 @@ +"""Tests for the response decision, exercised as a pure function. + +``decide_outcome`` takes no store, no request and no Redis, so its whole input +space fits in a loop. That is the point of naming the outcomes: three defects +in this middleware were overlapping conditions in the wrong order, and an +ordered chain of ``if ... return`` statements cannot be enumerated. +""" + +from __future__ import annotations + +import itertools + +import pytest + +from redis_fastapi.sessions import Outcome, _Signals, decide_outcome + +_FLAGS = ( + "revoked", + "rotated", + "changed", + "accessed", + "modified", + "empty", + "stored", + "id_changed", + "failed", + "always_save", + "refresh_on_load", +) + + +def _signals(**overrides: bool) -> _Signals: + """A quiet request, with the named flags flipped on.""" + base = dict.fromkeys(_FLAGS, False) + base["refresh_on_load"] = True + base["accessed"] = overrides.pop("accessed", True) + base.update(overrides) + return _Signals(**base) # type: ignore[arg-type] + + +def _every_combination(): + for values in itertools.product([False, True], repeat=len(_FLAGS)): + yield _Signals(**dict(zip(_FLAGS, values, strict=True))) # type: ignore[arg-type] + + +class TestItIsTotalAndSingleValued: + def test_every_input_maps_to_exactly_one_outcome(self) -> None: + """2048 combinations, no gaps and no exceptions. + + The property an ordered ``if`` chain cannot assert about itself. + """ + for signals in _every_combination(): + outcome = decide_outcome(signals) + assert isinstance(outcome, Outcome) + + def test_every_outcome_is_reachable(self) -> None: + """A branch nothing can reach is a branch that is wrong.""" + reached = {decide_outcome(s) for s in _every_combination()} + assert reached == set(Outcome), f"unreachable: {set(Outcome) - reached}" + + def test_the_decision_is_deterministic(self) -> None: + for signals in _every_combination(): + assert decide_outcome(signals) is decide_outcome(signals) + + +class TestPrecedence: + """The orderings that are load-bearing, each asserted against the case it + would otherwise be shadowed by.""" + + def test_revocation_beats_everything(self) -> None: + assert ( + decide_outcome( + _signals(revoked=True, changed=True, rotated=True, modified=True) + ) + is Outcome.CLEAR_COOKIE + ) + + def test_a_failed_response_suppresses_a_rotation(self) -> None: + assert ( + decide_outcome(_signals(changed=True, modified=True, failed=True)) + is Outcome.SUPPRESS + ) + + def test_a_failed_response_suppresses_an_explicit_reauthentication( + self, + ) -> None: + """The half the guard used to miss. + + ``reauthenticate()`` leaves no trace in the principal, so a guard + keyed on ``changed`` alone let the payload be written under the old, + unrotated identifier. + """ + assert ( + decide_outcome(_signals(rotated=True, modified=True, failed=True)) + is Outcome.SUPPRESS + ) + + def test_a_failed_response_still_persists_ordinary_data(self) -> None: + """The other half: a failed-login counter has to increment.""" + assert decide_outcome(_signals(modified=True, failed=True)) is Outcome.WRITE + + def test_a_self_rotation_is_not_repeated(self) -> None: + """Even when the principal changed too - the login-then-rotate idiom.""" + assert ( + decide_outcome(_signals(id_changed=True, changed=True, modified=True)) + is Outcome.COOKIE_ONLY + ) + + def test_emptying_signs_out_rather_than_rotating(self) -> None: + """Emptying drops the principal to None, which *is* a change.""" + assert ( + decide_outcome( + _signals(modified=True, empty=True, stored=True, changed=True) + ) + is Outcome.SIGN_OUT + ) + + def test_emptying_a_session_that_was_never_stored_is_not_a_sign_out( + self, + ) -> None: + assert ( + decide_outcome(_signals(modified=True, empty=True, stored=False)) + is Outcome.WRITE + ) + + +class TestTheOrdinaryCases: + def test_an_untouched_request_owes_nothing(self) -> None: + assert decide_outcome(_signals(accessed=False)) is Outcome.NOTHING + + def test_a_read_only_request_owes_nothing_by_default(self) -> None: + assert decide_outcome(_signals()) is Outcome.NOTHING + + def test_a_read_only_request_touches_when_the_load_did_not(self) -> None: + assert ( + decide_outcome(_signals(refresh_on_load=False, stored=True)) + is Outcome.TOUCH + ) + + def test_there_is_nothing_to_touch_without_a_stored_session(self) -> None: + assert ( + decide_outcome(_signals(refresh_on_load=False, stored=False)) + is Outcome.NOTHING + ) + + def test_a_modification_writes(self) -> None: + assert decide_outcome(_signals(modified=True)) is Outcome.WRITE + + def test_always_save_writes_without_a_modification(self) -> None: + assert decide_outcome(_signals(always_save=True)) is Outcome.WRITE + + def test_a_sign_in_rotates(self) -> None: + assert decide_outcome(_signals(changed=True, modified=True)) is Outcome.ROTATE + + +class TestInvariantsOverTheWholeSpace: + """Properties that must hold for *every* input, not just the examples.""" + + def test_a_failed_response_never_rotates(self) -> None: + """§4.3: a response the client saw fail must not issue a new identity.""" + for signals in _every_combination(): + if signals.failed and not signals.revoked: + assert decide_outcome(signals) is not Outcome.ROTATE + + def test_a_failed_response_never_signs_out_an_identified_session(self) -> None: + """The narrower true statement, found by the exhaustive sweep. + + An explicit ``clear()`` on a failed response *is* honoured - but only + for a session that had no principal to lose. The moment emptying one + changes the principal, ``SUPPRESS`` takes precedence and nothing is + written at all. Signing out grants nothing, so honouring the + handler's explicit intent is the safe direction here. + """ + for signals in _every_combination(): + if signals.failed and not signals.revoked: + if decide_outcome(signals) is Outcome.SIGN_OUT: + assert not signals.changed and not signals.rotated + + def test_an_untouched_unflagged_request_never_does_i_o(self) -> None: + """N-1: a request that never used the session costs nothing.""" + writes = {Outcome.WRITE, Outcome.ROTATE, Outcome.SIGN_OUT, Outcome.TOUCH} + for signals in _every_combination(): + quiet = not ( + signals.accessed + or signals.modified + or signals.revoked + or signals.rotated + or signals.changed + or signals.id_changed + ) + if quiet: + assert decide_outcome(signals) not in writes + + def test_revocation_always_clears_the_cookie(self) -> None: + for signals in _every_combination(): + if signals.revoked: + assert decide_outcome(signals) is Outcome.CLEAR_COOKIE + + def test_a_changed_identifier_is_never_rotated_again(self) -> None: + for signals in _every_combination(): + if signals.id_changed and not signals.revoked and not signals.failed: + assert decide_outcome(signals) is not Outcome.ROTATE + + +class TestTheDispatchHandlesEveryOutcome: + def test_no_outcome_is_missing_from_the_middleware(self) -> None: + """The dispatch ends in an ``AssertionError`` for an unhandled member. + + Adding an ``Outcome`` without a branch is then a loud failure rather + than a silent fall-through, but this test catches it first. + """ + import inspect + + from redis_fastapi.sessions import SessionMiddleware + + source = inspect.getsource(SessionMiddleware._on_response_start) + for member in Outcome: + assert f"Outcome.{member.name}" in source, f"{member.name} not handled" + + +@pytest.mark.parametrize("member", list(Outcome)) +def test_every_outcome_is_documented(member: Outcome) -> None: + """Each member carries its own docstring in the enum body.""" + assert Outcome.__doc__ + import inspect + + source = inspect.getsource(Outcome) + marker = f"{member.name} = auto()" + assert marker in source + after = source.split(marker, 1)[1].lstrip() + assert after.startswith('"""'), f"{member.name} has no docstring" diff --git a/tests/unit/test_session_regressions.py b/tests/unit/test_session_regressions.py new file mode 100644 index 0000000..0d5f423 --- /dev/null +++ b/tests/unit/test_session_regressions.py @@ -0,0 +1,755 @@ +"""Regression tests for the defects a review panel found in the first cut. + +Each test here fails against the implementation as it was before the fix, so +none may be dropped as redundant. The design's §11 calls this out as a rule: +a refuted claim earns a permanent test. +""" + +from __future__ import annotations + +import time +from http.cookies import SimpleCookie + +import pytest +from fastapi import FastAPI, Request, Response +from fastapi.testclient import TestClient +from redis.exceptions import ConnectionError as RedisConnectionError + +from redis_fastapi.config import get_settings +from redis_fastapi.deps import ( + SessionDep, + SessionStateDep, + SessionStoreDep, + get_session_store, +) +from redis_fastapi.exceptions import SessionStoreError +from redis_fastapi.session_backend import ( + FIELD_ABSOLUTE, + RedisSessionStore, + SessionStoreProtocol, +) +from redis_fastapi.sessions import add_redis_sessions + + +def _cookie(response) -> SimpleCookie: + jar: SimpleCookie = SimpleCookie() + jar.load(response.headers["set-cookie"]) + return jar + + +@pytest.fixture() +def store(fake_async_redis) -> RedisSessionStore: + get_settings.cache_clear() + return RedisSessionStore(fake_async_redis, idle_ttl=60, absolute_ttl=600) + + +def _app(store: RedisSessionStore, **kwargs) -> FastAPI: + get_settings.cache_clear() + app = FastAPI() + add_redis_sessions(app, **kwargs) + + async def _factory(request: Request) -> RedisSessionStore: + return store + + app.dependency_overrides[get_session_store] = _factory + + @app.post("/login") + async def login(session: SessionDep) -> dict: + session["user_id"] = 42 + return {} + + @app.post("/logout-clear") + async def logout_clear(session: SessionDep) -> dict: + session.clear() + return {} + + @app.post("/logout-revoke") + async def logout_revoke(state: SessionStateDep, st: SessionStoreDep) -> dict: + await st.revoke(state) + return {} + + @app.post("/login-and-rotate") + async def login_and_rotate( + session: SessionDep, state: SessionStateDep, st: SessionStoreDep + ) -> dict: + session["user_id"] = 42 + return {"sid": await st.rotate(state, subject="42")} + + @app.get("/me") + async def me(session: SessionDep) -> dict: + return {"user_id": session.get("user_id")} + + return app + + +@pytest.fixture(autouse=True) +def _plain_http(monkeypatch): + get_settings.cache_clear() + monkeypatch.setenv("REDIS_SESSION_COOKIE_HTTPS_ONLY", "false") + get_settings.cache_clear() + yield + get_settings.cache_clear() + + +class TestTheAbsoluteDeadlineCannotBeResurrected: + """N-6, the case ``FNX`` does not cover. + + An expired field is an *absent* field, so a conditional write recreated + the deadline with a full fresh lifetime whenever a request straddled it. + The window is one request long and recurs every cycle, so an actively + used session never died. + """ + + async def test_a_save_after_the_deadline_lapsed_does_not_restore_it( + self, store: RedisSessionStore, fake_async_redis + ) -> None: + sid = store.new_id() + key = store.session_key(sid) + await store.create(sid, store.new_record({"user_id": 42})) + + await fake_async_redis.execute_command("HDEL", key, FIELD_ABSOLUTE) + await store.save(sid, store.new_record({"user_id": 42})) + + reply = await fake_async_redis.execute_command( + "HTTL", key, "FIELDS", 1, FIELD_ABSOLUTE + ) + assert int(reply[0]) == -2, "the absolute deadline was recreated" + + async def test_such_a_session_is_dead_on_the_next_load( + self, store: RedisSessionStore, fake_async_redis + ) -> None: + sid = store.new_id() + await store.create(sid, store.new_record({"user_id": 42})) + await fake_async_redis.execute_command( + "HDEL", store.session_key(sid), FIELD_ABSOLUTE + ) + await store.save(sid, store.new_record({"user_id": 42})) + assert await store.load(sid) is None + + async def test_save_has_no_argument_that_could_write_the_deadline(self) -> None: + """The guarantee is structural, not a runtime check.""" + import inspect + + params = set(inspect.signature(RedisSessionStore.save).parameters) + assert params == {"self", "session_id", "record"} + + +class TestAFailedVerifyDoesNotDestroyTheIndex: + """N-8. One transient pipeline error used to prune every entry.""" + + @pytest.fixture() + def flaky(self, fake_async_redis) -> RedisSessionStore: + class _Flaky(RedisSessionStore): + async def _alive(self, session_ids: list[str]) -> set[str]: + raise RedisConnectionError("verify pipeline failed") + + return _Flaky(fake_async_redis, idle_ttl=60, absolute_ttl=600) + + async def _seed(self, store: RedisSessionStore, n: int) -> list[str]: + ids = [] + for _ in range(n): + sid = store.new_id() + record = store.new_record({"user_id": "42"}) + await store.create(sid, record) + await store.index("42", sid, record, absolute_remaining=600) + ids.append(sid) + return ids + + async def test_unknown_liveness_reports_everything_and_prunes_nothing( + self, flaky: RedisSessionStore, fake_async_redis + ) -> None: + await self._seed(flaky, 3) + listed = await flaky.list_for_subject("42") + assert len(listed) == 3 + assert len(await fake_async_redis.hgetall(flaky.index_key("42"))) == 3 + + async def test_the_sessions_stay_revocable( + self, flaky: RedisSessionStore, fake_async_redis + ) -> None: + """The consequence that made this blocking, asserted end to end.""" + await self._seed(flaky, 3) + await flaky.list_for_subject("42") + + healthy = RedisSessionStore(fake_async_redis, idle_ttl=60, absolute_ttl=600) + assert await healthy.revoke_all("42") == 3 + + +class TestLogout: + def test_clearing_the_session_signs_the_user_out( + self, store: RedisSessionStore, fake_async_redis + ) -> None: + """§4.2 row 4. + + Emptying a session changes the principal, so before the fix it read as + a privilege change and minted the user a brand-new valid session. + """ + with TestClient(_app(store)) as client: + client.post("/login") + morsel = _cookie(client.post("/logout-clear"))["session"] + assert morsel.value == "" + assert morsel["max-age"] == "0" + assert client.get("/me").json() == {"user_id": None} + + async def test_clearing_removes_the_key_and_the_index_entry( + self, store: RedisSessionStore, fake_async_redis + ) -> None: + with TestClient(_app(store)) as client: + client.post("/login") + client.post("/logout-clear") + assert await fake_async_redis.keys(f"{store._session_prefix}*") == [] + assert await fake_async_redis.hgetall(store.index_key("42")) == {} + + async def test_revoke_removes_the_index_entry( + self, store: RedisSessionStore, fake_async_redis + ) -> None: + """Otherwise ``count_for_subject`` over-counts until the deadline and a + concurrent-session cap refuses a legitimate login.""" + with TestClient(_app(store)) as client: + client.post("/login") + client.post("/logout-revoke") + assert await fake_async_redis.hgetall(store.index_key("42")) == {} + assert await store.count_for_subject("42") == 0 + + def test_the_clearing_cookie_goes_through_the_builder( + self, store: RedisSessionStore + ) -> None: + """The seam exists for ``Partitioned`` and ``__Host-``. + + A browser deletes a cookie only when the clearing header repeats every + scoping attribute, so a builder that applies to the set and not the + clear leaves the cookie in place - defeating the seam's whole purpose. + """ + from redis_fastapi.sessions import build_cookie + + def custom(spec) -> str: + return build_cookie(spec) + "; Partitioned" + + with TestClient(_app(store, cookie_builder=custom)) as client: + client.post("/login") + header = client.post("/logout-revoke").headers["set-cookie"] + assert "Partitioned" in header + assert "Max-Age=0" in header + + +class TestAnExplicitRotateIsNeverRepeated: + """The branch-ordering fix, on the case the first attempt missed. + + ``rotate()`` clears the pending-rotation flag but cannot clear the + principal change, so the ordinary sign-in-then-rotate idiom still rotated + twice and returned an identifier naming a deleted key. + """ + + def test_login_then_rotate_rotates_once(self, store: RedisSessionStore) -> None: + calls: list[int] = [] + original = store.rotate + + async def counting(session, **kwargs): + calls.append(1) + return await original(session, **kwargs) + + store.rotate = counting # type: ignore[method-assign] + with TestClient(_app(store)) as client: + client.post("/login-and-rotate") + assert calls == [1] + + async def test_the_returned_id_is_the_live_one( + self, store: RedisSessionStore, fake_async_redis + ) -> None: + with TestClient(_app(store)) as client: + response = client.post("/login-and-rotate") + returned = response.json()["sid"] + assert returned == _cookie(response)["session"].value + assert await fake_async_redis.exists(store.session_key(returned)) == 1 + + +class TestMetadataSurvivesAWrite: + async def test_created_is_not_restamped(self, store: RedisSessionStore) -> None: + """Otherwise a device listing reports every session as seconds old.""" + sid = store.new_id() + first = store.new_record({"n": 1}) + await store.create(sid, first) + time.sleep(0.01) + + loaded = await store.load(sid) + assert loaded is not None + second = store.new_record({"n": 2}, created=loaded.record.metadata.created) + await store.save(sid, second) + + again = await store.load(sid) + assert again is not None + assert again.record.metadata.created == pytest.approx( + first.metadata.created, abs=1e-6 + ) + assert again.record.metadata.last_access > first.metadata.last_access + + def test_lifetime_records_the_deadline_actually_in_force( + self, fake_async_redis + ) -> None: + store = RedisSessionStore(fake_async_redis, absolute_ttl=0, gc_ttl=1234) + assert store.new_record({}).metadata.lifetime == 1234 + + +class TestDescriptor: + async def test_the_seam_populates_a_listing(self, store: RedisSessionStore) -> None: + """F-7. Before the fix the only channel was an undocumented magic key + inside the payload, so ``SessionInfo.descriptor`` was always empty.""" + app = _app(store, descriptor_of=lambda request, session: {"ua": "pytest"}) + with TestClient(app) as client: + client.post("/login") + info = (await store.list_for_subject("42"))[0] + assert info.descriptor == {"ua": "pytest"} + + async def test_it_never_reaches_the_payload(self, store: RedisSessionStore) -> None: + app = _app(store, descriptor_of=lambda request, session: {"ua": "pytest"}) + with TestClient(app) as client: + client.post("/login") + assert client.get("/me").json() == {"user_id": 42} + sid = (await store.list_for_subject("42"))[0].session_id + loaded = await store.load(sid) + assert loaded is not None + assert loaded.record.data == {"user_id": 42} + + +class TestRevokeAllCostAndCount: + async def test_it_counts_keys_removed_not_index_entries( + self, store: RedisSessionStore + ) -> None: + live = store.new_id() + record = store.new_record({"user_id": "7"}) + await store.create(live, record) + await store.index("7", live, record, absolute_remaining=600) + # An entry whose session has already died. + await store.index("7", store.new_id(), record, absolute_remaining=600) + + assert await store.revoke_all("7") == 1 + + async def test_it_is_two_round_trips_whatever_the_count( + self, store: RedisSessionStore + ) -> None: + """N-2. It was 1+2N sequential awaits.""" + for _ in range(5): + sid = store.new_id() + record = store.new_record({"user_id": "9"}) + await store.create(sid, record) + await store.index("9", sid, record, absolute_remaining=600) + + trips = 0 + original_members = store._index_members + original_many = store._delete_many + original_clear = store._index_clear + + async def c1(subject): + nonlocal trips + trips += 1 + return await original_members(subject) + + async def c2(ids): + nonlocal trips + trips += 1 + return await original_many(ids) + + async def c3(subject): + nonlocal trips + trips += 1 + return await original_clear(subject) + + store._index_members = c1 # type: ignore[method-assign] + store._delete_many = c2 # type: ignore[method-assign] + store._index_clear = c3 # type: ignore[method-assign] + + assert await store.revoke_all("9") == 5 + assert trips == 3, "read the index, delete the keys, drop the index" + + +class TestCountDoesNotFailOpen: + async def test_an_unreadable_index_raises(self, fake_async_redis) -> None: + class _Broken(RedisSessionStore): + async def _index_members(self, subject: str) -> dict[str, bytes | str]: + raise RedisConnectionError("down") + + store = _Broken(fake_async_redis) + with pytest.raises(SessionStoreError, match="Could not count sessions"): + await store.count_for_subject("42") + + async def test_an_unverifiable_count_at_the_limit_raises( + self, fake_async_redis + ) -> None: + class _Flaky(RedisSessionStore): + async def _alive(self, session_ids: list[str]) -> set[str]: + raise RedisConnectionError("verify failed") + + store = _Flaky(fake_async_redis, idle_ttl=60, absolute_ttl=600) + record = store.new_record({"user_id": "42"}) + for _ in range(2): + await store.index("42", store.new_id(), record, absolute_remaining=600) + with pytest.raises(SessionStoreError, match="refusing to answer"): + await store.count_for_subject("42", limit=2) + + +class TestClusterErrorsAreHandled: + """``RedisClusterException`` is not a ``RedisError``. + + ``SlotNotCoveredError`` is raised on the ordinary command path during a + resharding, so catching only ``RedisError`` let the commonest cluster + failure escape every policy in the store. + """ + + def test_the_boundary_covers_the_cluster_hierarchy(self) -> None: + from redis.exceptions import RedisError, SlotNotCoveredError + + from redis_fastapi.session_backend import STORE_ERRORS + + assert not issubclass(SlotNotCoveredError, RedisError) + assert issubclass(SlotNotCoveredError, STORE_ERRORS) + + async def test_a_slot_error_fails_the_read_open(self, fake_async_redis) -> None: + from redis.exceptions import SlotNotCoveredError + + class _Resharding(RedisSessionStore): + async def _read(self, session_id: str, *, refresh_idle: int | None): + raise SlotNotCoveredError('Slot "42" is not covered') + + store = _Resharding(fake_async_redis) + assert await store.load("a" * 30) is None + + async def test_a_slot_error_on_a_write_raises_session_store_error( + self, fake_async_redis + ) -> None: + from redis.exceptions import SlotNotCoveredError + + class _Resharding(RedisSessionStore): + async def _write(self, session_id, payload, *, idle, absolute): + raise SlotNotCoveredError('Slot "42" is not covered') + + store = _Resharding(fake_async_redis) + with pytest.raises(SessionStoreError): + await store.create("a" * 30, store.new_record({})) + + +class TestTheProtocolExists: + def test_the_store_satisfies_it(self, store: RedisSessionStore) -> None: + """The docstring advertised it to users before it existed.""" + assert isinstance(store, SessionStoreProtocol) + + def test_it_is_exported(self) -> None: + import redis_fastapi + + assert "SessionStoreProtocol" in redis_fastapi.__all__ + + +class TestCookieAttributesAreValidated: + @pytest.mark.parametrize("name", ["sess\r\nX-Evil: 1", "", "a;b", "a b", "a=b"]) + def test_a_dangerous_cookie_name_is_refused(self, name: str) -> None: + from pydantic import ValidationError + + from redis_fastapi.config import RedisSettings + + with pytest.raises(ValidationError): + RedisSettings(session_cookie_name=name) + + @pytest.mark.parametrize("path", ["/x\r\nEvil: 1", "no-leading-slash", "/a;b"]) + def test_a_dangerous_cookie_path_is_refused(self, path: str) -> None: + from pydantic import ValidationError + + from redis_fastapi.config import RedisSettings + + with pytest.raises(ValidationError): + RedisSettings(session_cookie_path=path) + + def test_a_dangerous_cookie_domain_is_refused(self) -> None: + from pydantic import ValidationError + + from redis_fastapi.config import RedisSettings + + with pytest.raises(ValidationError): + RedisSettings(session_cookie_domain="ex.com\r\nEvil: 1") + + def test_ordinary_values_are_accepted(self) -> None: + from redis_fastapi.config import RedisSettings + + settings = RedisSettings( + session_cookie_name="my_session", + session_cookie_path="/admin", + session_cookie_domain="example.com", + ) + assert settings.session_cookie_name == "my_session" + + +class TestEncryptionSeam: + async def test_a_supplied_encryptor_wraps_the_payload( + self, fake_async_redis + ) -> None: + """F-15. Encryption wraps serialization; the coder never sees ciphertext.""" + + class Rot13: + def encrypt(self, data: bytes) -> bytes: + return bytes(b ^ 0x5A for b in data) + + def decrypt(self, data: bytes) -> bytes: + return bytes(b ^ 0x5A for b in data) + + store = RedisSessionStore( + fake_async_redis, encryptor=Rot13(), idle_ttl=60, absolute_ttl=600 + ) + sid = store.new_id() + await store.create(sid, store.new_record({"user_id": 42})) + + stored = await fake_async_redis.hget(store.session_key(sid), "d") + assert b"user_id" not in stored, "the payload reached Redis in the clear" + + loaded = await store.load(sid) + assert loaded is not None + assert loaded.record.data == {"user_id": 42} + + async def test_a_record_that_will_not_decrypt_is_unreadable_not_empty( + self, fake_async_redis + ) -> None: + class Exploding: + def encrypt(self, data: bytes) -> bytes: + return data + + def decrypt(self, data: bytes) -> bytes: + raise ValueError("bad tag") + + store = RedisSessionStore(fake_async_redis, encryptor=Exploding()) + sid = store.new_id() + await store.create(sid, store.new_record({"user_id": 42})) + with pytest.raises(SessionStoreError, match="Unreadable session record"): + await store.load(sid) + + +class TestCookieMaxAgeInEveryBranch: + """§11 requires ``min(idle, HTTL(a))`` asserted in every branch. + + Only ``idle < absolute`` was covered, so replacing the whole computation + with ``settings.session_idle_ttl`` kept the suite green. + """ + + @pytest.mark.parametrize( + ("idle", "absolute", "expected"), + [ + (60, 600, "60"), # idle is nearer + (1800, 60, "60"), # the absolute remainder truncates it + (0, 600, "600"), # no idle clock + (0, 0, ""), # cookie-only: the browser decides + ], + ) + def test_max_age_takes_the_nearer_deadline( + self, fake_async_redis, monkeypatch, idle, absolute, expected + ) -> None: + get_settings.cache_clear() + monkeypatch.setenv("REDIS_SESSION_COOKIE_HTTPS_ONLY", "false") + monkeypatch.setenv("REDIS_SESSION_IDLE_TTL", str(idle)) + monkeypatch.setenv("REDIS_SESSION_ABSOLUTE_TTL", str(absolute)) + get_settings.cache_clear() + + store = RedisSessionStore( + fake_async_redis, idle_ttl=idle, absolute_ttl=absolute + ) + with TestClient(_app(store)) as client: + morsel = _cookie(client.post("/login"))["session"] + assert morsel["max-age"] == expected + get_settings.cache_clear() + + +class TestFailedResponsesPersistOrdinaryData: + """§4.3 has two halves and only one was tested. + + Dropping the ``changed and`` from the guard left the suite green and + silently stopped every failed-login counter from incrementing. + """ + + def test_an_unchanged_principal_still_writes_on_a_4xx( + self, store: RedisSessionStore + ) -> None: + app = _app(store) + + @app.post("/count-failure") + async def count_failure(session: SessionDep, response: Response) -> dict: + session["failed_attempts"] = session.get("failed_attempts", 0) + 1 + response.status_code = 401 + return {"n": session["failed_attempts"]} + + with TestClient(app) as client: + assert client.post("/count-failure").json()["n"] == 1 + assert client.post("/count-failure").json()["n"] == 2, ( + "the counter did not persist across a failed response, so " + "lockout silently stops working" + ) + + def test_a_changed_principal_writes_nothing_on_a_4xx( + self, store: RedisSessionStore + ) -> None: + app = _app(store) + + @app.post("/failed-login") + async def failed_login(session: SessionDep, response: Response) -> dict: + session["user_id"] = 42 + response.status_code = 401 + return {} + + with TestClient(app) as client: + response = client.post("/failed-login") + assert "set-cookie" not in response.headers + assert client.get("/me").json() == {"user_id": None} + + +class TestPrincipalKeysActuallyDrivesRotation: + """Asserting the resolver is not ``None`` proved nothing. + + A resolver hard-wired to return ``None`` passed the old assertion while + disabling rotation entirely. + """ + + def _app_with_role(self, store: RedisSessionStore) -> FastAPI: + app = _app(store, principal_keys=["user_id", "role"]) + + @app.post("/set-role/{role}") + async def set_role(role: str, session: SessionDep) -> dict: + session["user_id"] = 42 + session["role"] = role + return {} + + return app + + def test_a_privilege_change_rotates(self, store: RedisSessionStore) -> None: + with TestClient(self._app_with_role(store)) as client: + first = _cookie(client.post("/set-role/user"))["session"].value + second = _cookie(client.post("/set-role/admin"))["session"].value + assert second != first + + def test_a_de_escalation_rotates_too(self, store: RedisSessionStore) -> None: + """The case that is easiest to forget: dropping back down.""" + with TestClient(self._app_with_role(store)) as client: + client.post("/set-role/user") + up = _cookie(client.post("/set-role/admin"))["session"].value + down = _cookie(client.post("/set-role/user"))["session"].value + assert down != up + + def test_an_unchanged_role_does_not_rotate(self, store: RedisSessionStore) -> None: + with TestClient(self._app_with_role(store)) as client: + first = _cookie(client.post("/set-role/user"))["session"].value + second = _cookie(client.post("/set-role/user"))["session"].value + assert first == second + + +class TestRefreshOnLoadFalseThroughTheMiddleware: + """§11 names this branch because an earlier draft omitted it. + + Deleting the response-time ``touch`` left the suite green and froze the + idle clock, making every session immortal until its absolute deadline. + """ + + async def test_the_middleware_advances_the_idle_clock( + self, fake_async_redis, monkeypatch + ) -> None: + from redis_fastapi.session_backend import FIELD_DATA + + get_settings.cache_clear() + monkeypatch.setenv("REDIS_SESSION_COOKIE_HTTPS_ONLY", "false") + monkeypatch.setenv("REDIS_SESSION_REFRESH_ON_LOAD", "false") + get_settings.cache_clear() + + store = RedisSessionStore(fake_async_redis, idle_ttl=60, absolute_ttl=600) + with TestClient(_app(store)) as client: + client.post("/login") + sid = client.cookies["session"] + key = store.session_key(sid) + await fake_async_redis.execute_command( + "HEXPIRE", key, 5, "FIELDS", 1, FIELD_DATA + ) + client.get("/me") + reply = await fake_async_redis.execute_command( + "HTTL", key, "FIELDS", 1, FIELD_DATA + ) + assert int(reply[0]) > 5, "the idle clock never advanced" + get_settings.cache_clear() + + async def test_an_untouched_request_does_not_advance_it( + self, fake_async_redis, monkeypatch + ) -> None: + from redis_fastapi.session_backend import FIELD_DATA + + get_settings.cache_clear() + monkeypatch.setenv("REDIS_SESSION_COOKIE_HTTPS_ONLY", "false") + monkeypatch.setenv("REDIS_SESSION_REFRESH_ON_LOAD", "false") + get_settings.cache_clear() + + store = RedisSessionStore(fake_async_redis, idle_ttl=60, absolute_ttl=600) + app = _app(store) + + @app.get("/untouched") + async def untouched() -> dict: + return {} + + with TestClient(app) as client: + client.post("/login") + key = store.session_key(client.cookies["session"]) + await fake_async_redis.execute_command( + "HEXPIRE", key, 5, "FIELDS", 1, FIELD_DATA + ) + client.get("/untouched") + reply = await fake_async_redis.execute_command( + "HTTL", key, "FIELDS", 1, FIELD_DATA + ) + assert int(reply[0]) <= 5, ( + "a request that never used the session counted as activity" + ) + get_settings.cache_clear() + + +class TestTelemetryCarriesNoIdentifiers: + """§10 states a test asserts this. None did. + + Adding ``session_id`` to the metric labels kept the suite green. + """ + + def test_no_session_id_or_subject_reaches_a_metric_label(self, monkeypatch) -> None: + from redis_fastapi import telemetry + + recorded: list[dict] = [] + + class _Instrument: + def add(self, amount, attributes=None): + recorded.append(dict(attributes or {})) + + def record(self, value, attributes=None): + recorded.append(dict(attributes or {})) + + monkeypatch.setattr(telemetry._state, "enabled", True) + monkeypatch.setattr(telemetry._state, "session_operations", _Instrument()) + monkeypatch.setattr(telemetry._state, "session_latency", _Instrument()) + monkeypatch.setattr(telemetry._state, "session_events", _Instrument()) + + telemetry.record_session_operation(operation="load", result="hit") + telemetry.record_session_latency(duration=0.01, operation="load") + telemetry.record_session_event(cause="idle", result="delivered") + + allowed = {"operation", "result", "cause"} + for attributes in recorded: + assert set(attributes) <= allowed, f"unexpected label: {attributes}" + + async def test_the_store_never_passes_an_id_to_a_span( + self, store: RedisSessionStore, monkeypatch + ) -> None: + from redis_fastapi import telemetry + + seen: list[tuple[str, dict]] = [] + real = telemetry.session_span + + import contextlib + + @contextlib.contextmanager + def spy(name, attributes=None): + seen.append((name, dict(attributes or {}))) + with real(name, attributes): + yield None + + monkeypatch.setattr("redis_fastapi.session_backend.session_span", spy) + sid = store.new_id() + await store.create(sid, store.new_record({"user_id": 42})) + await store.load(sid) + + assert seen, "no span was opened" + for name, attributes in seen: + assert attributes == {}, f"{name} carried {attributes}" diff --git a/tests/unit/test_session_setup.py b/tests/unit/test_session_setup.py index a3f2228..33ccde3 100644 --- a/tests/unit/test_session_setup.py +++ b/tests/unit/test_session_setup.py @@ -2,6 +2,8 @@ from __future__ import annotations +from datetime import timedelta + import pytest from fastapi import FastAPI, Request from fastapi.testclient import TestClient @@ -9,17 +11,16 @@ from redis_fastapi.config import get_settings from redis_fastapi.deps import ( SessionDep, + SessionStateDep, SessionStoreDep, SyncSessionStoreDep, + _get_pool_state, get_session_store, get_sync_session_store, ) +from redis_fastapi.exceptions import SessionConfigurationError from redis_fastapi.session_backend import RedisSessionStore, SyncSessionStore -from redis_fastapi.sessions import ( - SessionConfigurationError, - SessionMiddleware, - add_redis_sessions, -) +from redis_fastapi.sessions import SessionMiddleware, add_redis_sessions from redis_fastapi.setup import FastAPIRedis @@ -156,9 +157,9 @@ def test_a_def_endpoint_can_use_the_store(self, fake_async_redis) -> None: Driving it through a real ``def`` endpoint is the only way to test it honestly - calling it directly from the main thread raises. """ - app = FastAPI() - add_redis_sessions(app) store = RedisSessionStore(fake_async_redis, idle_ttl=60, absolute_ttl=600) + app = FastAPI() + add_redis_sessions(app, store=store) async def _async_store(request: Request) -> RedisSessionStore: return store @@ -166,9 +167,6 @@ async def _async_store(request: Request) -> RedisSessionStore: async def _sync_store(request: Request) -> SyncSessionStore: return SyncSessionStore(store) - for mw in app.user_middleware: - if "store_factory" in mw.kwargs: - mw.kwargs["store_factory"] = _async_store app.dependency_overrides[get_sync_session_store] = _sync_store @app.post("/login") @@ -181,19 +179,19 @@ def count(store: SyncSessionStoreDep) -> dict: return {"n": store.count_for_subject("42")} @app.get("/devices") - def devices(session: SessionDep, store: SyncSessionStoreDep) -> dict: + def devices(state: SessionStateDep, store: SyncSessionStoreDep) -> dict: return { "n": len(store.list_for_subject("42")), - "current": store.session_id(session) is not None, + "current": store.session_id(state) is not None, } @app.post("/rotate") - def rotate(session: SessionDep, store: SyncSessionStoreDep) -> dict: - return {"sid": store.rotate(session, subject="42")} + def rotate(state: SessionStateDep, store: SyncSessionStoreDep) -> dict: + return {"sid": store.rotate(state, subject="42")} @app.post("/step-up") - def step_up(session: SessionDep, store: SyncSessionStoreDep) -> dict: - store.reauthenticate(session) + def step_up(state: SessionStateDep, store: SyncSessionStoreDep) -> dict: + store.reauthenticate(state) return {"ok": True} @app.post("/revoke-one") @@ -205,8 +203,8 @@ def revoke_all(store: SyncSessionStoreDep) -> dict: return {"n": store.revoke_all("42")} @app.post("/logout") - def logout(session: SessionDep, store: SyncSessionStoreDep) -> dict: - store.revoke(session) + def logout(state: SessionStateDep, store: SyncSessionStoreDep) -> dict: + store.revoke(state) return {"ok": True} with TestClient(app) as client: @@ -227,3 +225,153 @@ def logout(session: SessionDep, store: SyncSessionStoreDep) -> dict: client.post("/login") assert client.post("/revoke-all").json()["n"] >= 1 + + +class TestTheStoreIsInjectable: + """Every seam the store constructor offers must be reachable from setup. + + Before this, ``get_session_store`` built a ``RedisSessionStore`` with no + options and the middleware held the raw function, so a ``coder`` could only + be supplied by replacing the whole dependency - and that did not reach the + middleware at all. The proof it was wrong is that this suite used to + rewrite ``app.user_middleware[i].kwargs`` in five places. + """ + + def test_a_supplied_store_is_used_by_the_middleware(self, fake_async_redis) -> None: + store = RedisSessionStore(fake_async_redis, idle_ttl=60, absolute_ttl=600) + app = FastAPI() + add_redis_sessions(app, store=store) + + seen: list[object] = [] + + @app.post("/login") + async def login(session: SessionDep, injected: SessionStoreDep) -> dict: + session["user_id"] = 42 + seen.append(injected) + return {} + + with TestClient(app) as client: + assert client.post("/login").status_code == 200 + assert seen == [store], "the handler and the middleware disagreed" + + def test_dependency_overrides_reaches_the_middleware( + self, fake_async_redis + ) -> None: + """The middleware runs before dependency resolution, so it has to + consult the override map itself.""" + store = RedisSessionStore(fake_async_redis, idle_ttl=60, absolute_ttl=600) + app = FastAPI() + add_redis_sessions(app) + + async def _override(request: Request) -> RedisSessionStore: + return store + + app.dependency_overrides[get_session_store] = _override + + @app.post("/login") + async def login(session: SessionDep) -> dict: + session["user_id"] = 42 + return {} + + with TestClient(app) as client: + assert client.post("/login").status_code == 200 + # No lifespan ran, so reaching the real pool would have raised. + + def test_a_store_factory_is_called_per_request(self, fake_async_redis) -> None: + calls: list[int] = [] + + async def factory(request: Request) -> RedisSessionStore: + calls.append(1) + return RedisSessionStore(fake_async_redis, idle_ttl=60, absolute_ttl=600) + + app = FastAPI() + add_redis_sessions(app, store_factory=factory) + + @app.get("/read") + async def read(session: SessionDep) -> dict: + return {"n": session.get("n")} + + with TestClient(app) as client: + client.get("/read") + client.get("/read") + assert len(calls) >= 2 + + def test_constructor_options_reach_the_built_store( + self, fake_async_redis, monkeypatch + ) -> None: + get_settings.cache_clear() + monkeypatch.setenv("REDIS_SESSION_COOKIE_HTTPS_ONLY", "false") + get_settings.cache_clear() + + app = FastAPI() + add_redis_sessions( + app, + store_factory=None, + idle_ttl=timedelta(minutes=5), + key_prefix="custom", + id_factory=lambda: "z" * 40, + ) + _get_pool_state(app).async_pool = fake_async_redis.connection_pool + + captured: list[RedisSessionStore] = [] + + @app.get("/probe") + async def probe(store: SessionStoreDep) -> dict: + captured.append(store) # type: ignore[arg-type] + return {} + + with TestClient(app) as client: + client.get("/probe") + + store = captured[0] + assert store.idle_seconds == 300, "timedelta was not honoured" + assert store.session_key("x").startswith("custom:session:") + assert store.new_id() == "z" * 40 + get_settings.cache_clear() + + def test_store_and_store_factory_together_are_refused(self) -> None: + with pytest.raises(SessionConfigurationError, match="not both"): + add_redis_sessions(FastAPI(), store=object(), store_factory=lambda r: None) + + +class TestSessionCarriesNoTransportState: + """Proposal 2: ``Session`` is a dict with two flags, and nothing else. + + The store used to write ``sid``/``subject``/``revoked``/``rotated`` onto + the object the application holds, which made those four a public mutable + contract and forced ``session_backend`` to import ``sessions`` purely so it + could mutate it. + """ + + def test_only_the_two_flags_remain(self) -> None: + from redis_fastapi.sessions import Session + + assert set(Session.__slots__) == {"accessed", "modified"} + + def test_the_backend_does_not_import_the_request_half(self) -> None: + """The layering boundary, asserted rather than assumed.""" + import pathlib + + source = pathlib.Path("src/redis_fastapi/session_backend.py").read_text() + assert "from redis_fastapi.sessions import" not in source + assert "import redis_fastapi.sessions" not in source + + def test_the_handle_is_reachable_from_a_handler(self, fake_async_redis) -> None: + store = RedisSessionStore(fake_async_redis, idle_ttl=60, absolute_ttl=600) + app = FastAPI() + add_redis_sessions(app, store=store) + + @app.post("/login") + async def login(session: SessionDep) -> dict: + session["user_id"] = 42 + return {} + + @app.get("/whoami") + async def whoami(state: SessionStateDep, st: SessionStoreDep) -> dict: + return {"sid": st.session_id(state), "subject": state.subject} + + with TestClient(app) as client: + client.post("/login") + body = client.get("/whoami").json() + assert body["sid"] + assert body["subject"] == "42" From 03f28c85cef1866ae8230742ceea1a98530393d6 Mon Sep 17 00:00:00 2001 From: Tihomir Mateev Date: Wed, 9 Sep 2026 18:47:02 +0300 Subject: [PATCH 06/11] Addressed second round of adversarial reviews --- docs/api/reference.md | 13 +- docs/guide/sessions.md | 302 ++++++++++------ docs/specs/session-design.md | 108 +++++- pyproject.toml | 4 +- src/redis_fastapi/__init__.py | 3 +- src/redis_fastapi/config.py | 76 +++- src/redis_fastapi/session_backend.py | 279 ++++++++++----- src/redis_fastapi/session_events.py | 63 +++- src/redis_fastapi/sessions.py | 16 +- src/redis_fastapi/telemetry.py | 31 +- tests/conftest.py | 18 + tests/integration/test_session_integration.py | 196 +++++++++- tests/unit/test_config.py | 109 ++++++ tests/unit/test_otel_sessions.py | 337 ++++++++++++++++++ tests/unit/test_session_backend.py | 48 ++- tests/unit/test_session_events.py | 298 +++++++++++++++- tests/unit/test_session_failures.py | 3 + tests/unit/test_session_index.py | 39 +- tests/unit/test_session_middleware.py | 102 ++++++ tests/unit/test_session_outcome.py | 42 +++ tests/unit/test_session_regressions.py | 20 +- tests/unit/test_session_setup.py | 43 +++ uv.lock | 2 +- 23 files changed, 1879 insertions(+), 273 deletions(-) create mode 100644 tests/unit/test_otel_sessions.py diff --git a/docs/api/reference.md b/docs/api/reference.md index 39b4855..c59acf4 100644 --- a/docs/api/reference.md +++ b/docs/api/reference.md @@ -447,10 +447,6 @@ one without inheritance. | `new_record(data, *, created=None)` | Build an envelope, carrying `created` forward. | | `session_id(state)` | The current identifier, or `None`. | -A new backend implements the abstract primitives: `_read`, `_write`, -`_expire`, `_delete`, `_delete_many`, `_index_add`, `_index_remove`, -`_index_clear`, `_index_members`, `_alive`. - --- ## Session data types @@ -492,7 +488,14 @@ events = SessionEvents(redis, key_prefix="redis:fastapi", db=0) async def _(session_id: str, cause: Cause) -> None: ... ``` +| Type | Values | +|---|---| +| `Cause` | `"idle"`, `"absolute"`. No third member — a revocation is a `DEL`, which publishes no subkey notification. | +| `Tier` | `"field"`, `"none"`. | +| `Handler` | `Callable[[str, Cause], Awaitable[None]]`, for annotating what you register. | + Started and stopped by the lifespan when `session_events_enabled` is set. `events.tier` is `"field"` when the server can deliver events and `"none"` otherwise — in which case handlers never fire. Requires Redis 8.8 and -`notify-keyspace-events` including a subkey flag plus `h`. +`notify-keyspace-events` including `Th` — `T` for the `__subkeyevent@` +channel, `h` for hash events. diff --git a/docs/guide/sessions.md b/docs/guide/sessions.md index 142a406..d58a685 100644 --- a/docs/guide/sessions.md +++ b/docs/guide/sessions.md @@ -22,7 +22,8 @@ async def me(session: SessionDep) -> dict: ``` `request.session` works too, so code written against Starlette's signed-cookie -middleware runs unchanged. +[`SessionMiddleware`](https://starlette.dev/middleware/#sessionmiddleware) runs +unchanged. --- @@ -30,13 +31,15 @@ middleware runs unchanged. Writing the identity rotates the session ID. There is no `rotate()` to call and therefore none to forget, which matters because forgetting it is the one -mistake in this API that is a vulnerability — session fixation. +mistake in this API that is a vulnerability - +[session fixation](https://owasp.org/www-community/attacks/Session_fixation). The middleware evaluates a **principal** twice per request, once before the application runs and once at `http.response.start`. If the two differ and the response status is below 400, it issues a new ID and deletes the old key first. -Watch more than the user ID to get OWASP's privilege-change rotation: +Watch more than the user ID to get OWASP's +[privilege-change rotation](https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html#renew-the-session-id-after-any-privilege-level-change): ```python FastAPIRedis(app).lifespan().sessions(principal_keys=["user_id", "role"]) @@ -51,8 +54,8 @@ FastAPIRedis(app).lifespan().sessions( ) ``` -`principal_of` must be **pure, cheap and deterministic** — it runs twice per -request and the two results are compared by value — and it must return a +`principal_of` must be **pure, cheap and deterministic** - it runs twice per +request and the two results are compared by value - and it must return a *verified* identity, never something the client set. !!! warning "A misconfigured resolver is silent" @@ -78,25 +81,30 @@ request and the two results are compared by value — and it must return a | `session_idle_ttl` | 30 min | Time since the last request carrying the cookie | | `session_absolute_ttl` | 8 h | Time since the session was created, however active the user | -They live on two separate hash fields with their own expirations, so **Redis +They live on two separate hash fields with their own +[expirations](https://redis.io/docs/latest/commands/hexpire/), so **Redis enforces both and this library computes neither**. Writing the payload touches one field and never the other, so no number of writes can extend the absolute deadline. -The absolute clock is the one that matters against a stolen session. An idle -timeout cannot expire a session an attacker is actively using, because the -attacker's own requests keep refreshing it — OWASP says so directly. The -absolute deadline is the only clock that fires on a live compromise. +The absolute clock is the one that matters against a stolen session. An +[idle timeout](https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html#idle-timeout) +cannot expire a session an attacker is actively using, because the attacker's +own requests keep refreshing it - OWASP says so directly under +[absolute timeout](https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html#absolute-timeout). +The absolute deadline is the only clock that fires on a live compromise. Both accept an `int` number of seconds. The store constructor also accepts a -`timedelta`; the settings and their environment variables take seconds. Setting either to `0` disables -that clock; setting both gives a cookie-only session that the browser drops -when it closes. +[`timedelta`](https://docs.python.org/3/library/datetime.html#datetime.timedelta); +the settings and their environment variables take seconds. Setting either to +`0` disables that clock; setting both gives a cookie-only session that the +browser drops when it closes. -The cookie's `max-age` is `min(idle, absolute remaining)`, and both numbers come -from Redis rather than from your process — so a container with a skewed clock -cannot produce a cookie that outlives its record and signs a user out with no -explanation. +The cookie's +[`Max-Age`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Set-Cookie#max-agenumber) +is `min(idle, absolute remaining)`, and both numbers come from Redis rather than +from your process - so a container with a skewed clock cannot produce a cookie +that outlives its record and signs a user out with no explanation. --- @@ -126,7 +134,7 @@ async def devices( ] ``` -`SessionDep` is the data — a `dict` you read and write. `SessionStateDep` is +`SessionDep` is the data - a `dict` you read and write. `SessionStateDep` is the handle: the identifier, the subject and the timestamps. Operations that act on the *session* rather than its contents take the handle. @@ -152,7 +160,7 @@ are not signed in on and a sign-out button that does nothing. ## When Redis is unreachable -The default is asymmetric, and the asymmetry is the point. +The default is asymmetric by design: - **A failed read yields an empty session.** The user looks anonymous, your own authorization dependency finds no user, and a protected route stays protected @@ -161,7 +169,9 @@ The default is asymmetric, and the asymmetry is the point. the worst outcome here and must never be silent. Set `session_fail_closed=True` to turn the failed read into an error too, for a -deployment that prefers a 503 to an anonymous page. Writes raise either way. +deployment that prefers a +[503](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Status/503) +to an anonymous page. Writes raise either way. --- @@ -176,6 +186,18 @@ No `dict` subclass in any language can see a change inside a value it holds. Reassign the top-level key, or set `session_always_save=True` to write on every request that touched the session. +!!! warning "`session_always_save` writes on every request that *read* the session" + "Touched" includes a plain read - `session.get("user_id")` is enough - so + with the setting on, a route that only inspects the session writes it back + on every request. Prefer reassigning the top-level key, and reach for the + setting only where you cannot. + + An **empty** session is exempt: reading one does not create it. Without + that exemption every anonymous visitor to a session-touching route - every + crawler, health check and preflight - would be minted an identifier, a + Redis key and a cookie. Nothing the setting exists for is lost, because a + nested mutation needs a top-level key already holding the nested value. + --- ## Real-time session events (optional, Redis 8.8+) @@ -184,65 +206,82 @@ Close a WebSocket the moment a session ends, instead of finding out on the next HTTP request: ```python -from redis_fastapi import SessionEvents +from redis_fastapi import Cause, SessionEvents events = SessionEvents(redis, key_prefix="redis:fastapi") @events.on_session_end -async def _(session_id: str, cause: str) -> None: # "idle" or "absolute" +async def _(session_id: str, cause: Cause) -> None: # "idle" or "absolute" await close_sockets_for(session_id) await events.start() ``` -This needs Redis 8.8 for hash subkey notifications, and it needs the server -configured for them: +`Cause` has exactly those two members, so an exhaustive `match` over it stays +exhaustive. A revocation is not among them: it is a `DEL`, and `DEL` publishes +no subkey notification. Sign a user out through `revoke()` and you already +know it happened - the event stream is for the deaths nobody asked for. +`Handler` is exported too, for annotating the callable you register. + +This needs Redis 8.8 for hash subkey +[keyspace notifications](https://redis.io/docs/latest/develop/pubsub/keyspace-notifications/), +and it needs the server configured for them with +[`CONFIG SET`](https://redis.io/docs/latest/commands/config-set/): ``` CONFIG SET notify-keyspace-events Th ``` -The subkey flags `S`, `T`, `I`, `V` are **independent of `K` and `E`** — setting -`KEA` enables every standard keyspace event and still delivers none of these. +Both characters matter. `h` is the hash class, and **`T`** is the +`__subkeyevent@` channel - the one this library subscribes to. Redis 8.8 adds +four subkey channels, `S`, `T`, `I` and `V`, and the other three deliver +elsewhere: on a server set to `Sh` the subscription succeeds and no event ever +arrives, so `events.tier` reports `"none"` and says why. + +All four are **independent of `K` and `E`** - setting +[`KEA`](https://redis.io/docs/latest/develop/pubsub/keyspace-notifications/#configuration) +enables every standard keyspace event and still delivers none of these. This library will never set the option for you: it is server-wide and affects every other application on the instance. !!! danger "The callback is best-effort, and silence is a possible outcome" - On a server below 8.8, one without the flags, or one where `CONFIG GET` is - unavailable — which is common on managed Redis — `events.tier` is `"none"`, + On a server below 8.8, one without the flags, or one where + [`CONFIG GET`](https://redis.io/docs/latest/commands/config-get/) is + unavailable - which is common on managed Redis - `events.tier` is `"none"`, one warning is logged at startup, and **your handlers never run**. Startup still succeeds and every request still works. A revocation handler that never fires looks exactly like one that works. If prompt closure matters, check `events.tier` and add a periodic sweep as - well. Redis Pub/Sub is fire-and-forget: events sent while no subscriber is - connected are lost, and an expiry event fires when Redis removes the field - rather than when the deadline passed. + well. [Redis Pub/Sub](https://redis.io/docs/latest/develop/pubsub/) is + fire-and-forget: events sent while no subscriber is connected are lost, and + an [expiry event](https://redis.io/docs/latest/develop/pubsub/keyspace-notifications/#timing-of-expired-events) + fires when Redis removes the field rather than when the deadline passed. Nothing else depends on this. Expiry, revocation and the index all work identically with events switched off. -On a cluster, keyspace events are node-local and are not broadcast, so seeing -every event needs one subscriber per node. +On a [cluster](https://redis.io/docs/latest/develop/pubsub/keyspace-notifications/#events-in-a-cluster), +keyspace events are node-local and are not broadcast, so seeing every event +needs one subscriber per node. --- ## Caching an endpoint that uses the session A cached response is stored once and served many times. A session-dependent -response is different for every user. Put those two together without saying -which you meant and the first user's body is served to the second — so -`cache()` asks you to say. +response is different for every user. Without handling this in a special way +the first user's body could end up being served to the second. -One parameter, three answers: +Using the `vary_on_session` parameter you can control three distinct situations: ```python cache(ttl=300, vary_on_session=True) # depends on who is asking -cache(ttl=300, vary_on_session=False) # reads the session, body is the same -cache(ttl=300) # you have not said +cache(ttl=300, vary_on_session=False) # reads the session but the body is the same for everyone +cache(ttl=300) # unknown, reading the session is potentially dangerous ``` -### `vary_on_session=True` — the body differs per user +### `vary_on_session=True` - cache per user Use it when the response contains the user's own data. @@ -255,16 +294,18 @@ async def me(session: SessionDep) -> dict: return {"user_id": session["user_id"], "cart": session.get("cart", [])} ``` -Each user gets their own cache entry and their own hits — Alice's second +Each user gets their own cache entry and their own hits - Alice's second request is a `HIT` on Alice's copy. The key is built from the **subject**, not the session ID, so it survives a rotation (a privilege change does not throw the entry away) and is shared across that user's devices. -The response carries `Cache-Control: private, max-age=300`. The `private` is -not optional and is added for you: our entry is per user, so a CDN told it may -keep one copy for everyone would recreate the leak one hop further out. +The response carries +[`Cache-Control: private, max-age=300`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Cache-Control#private). +The `private` is not optional and is added for you: our entry is per user, so a +CDN told it may keep one copy for everyone would recreate the leak one hop +further out. -### `vary_on_session=False` — reads the session, same body for everyone +### `vary_on_session=False` - reads the session, but cache is shared Use it when an auth dependency reads the session but the payload does not depend on who asked. @@ -286,9 +327,10 @@ async def catalogue() -> list[dict]: return await load_products() ``` -One shared entry serves every signed-in user, and the `Vary: Cookie` the -session middleware would otherwise add is suppressed — keeping it would force a -CDN to store one identical copy per user and destroy the hit rate this +One shared entry serves every signed-in user, and the +[`Vary: Cookie`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Vary) +the session middleware would otherwise add is suppressed - keeping it would +force a CDN to store one identical copy per user and destroy the hit rate this declaration exists to protect. **This is an assertion, and the library takes your word for it.** If the body @@ -296,7 +338,7 @@ does depend on the session, you have re-enabled the leak deliberately. ### Saying nothing -If the endpoint never touches the session, say nothing — there is nothing to +If the endpoint never touches the session, say nothing - there is nothing to declare and caching behaves exactly as it always has. If it *does* touch the session and you have not declared anything, the response @@ -312,7 +354,7 @@ WARNING GET /me read the session but is cached without vary_on_session set; Losing caching is a visible, recoverable problem. Serving Alice's account page to Bob is not, which is why the default errs this way. -Note that these last two are the *same* declaration — you write nothing in both +Note that these last two are the *same* declaration - you write nothing in both cases. The library tells them apart by whether the endpoint actually read the session, which it can only know after the endpoint has run. That is also why the choice cannot be inferred for you: the cache key is needed *before* the @@ -329,14 +371,17 @@ depends on it. | Touches it, nothing declared | *(nothing)* | **not cached** | `private, no-store` + `Vary: Cookie` | The rule underneath all four rows: **what a response tells other caches they -may do is never more permissive than what this library does itself.** If we -key per user, we say `private`. If we refuse to store, we say `no-store`. +may do is never more permissive than what this library does itself.** If we key +per user, we say +[`private`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/Caching#private_caches). +If we refuse to store, we say +[`no-store`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Cache-Control#no-store). ### A session route with no caching on it Reading the session on an uncached route emits `Cache-Control: private` and `Vary: Cookie` on its own, since nothing else will. Where `cache()` is present -it owns the header outright — one writer, so you never see two contradictory +it owns the header outright - one writer, so you never see two contradictory `Cache-Control` values on one response. --- @@ -354,12 +399,13 @@ handler, that code needs changing. **Why it is not simply switched on.** The read half is easy; the write half has nowhere to go. A WebSocket has no `http.response.start`, so there is no point -at which a cookie can be set — which means no rotation, no save, and no +at which a cookie can be set - which means no rotation, no save, and no idle-clock refresh for the life of the connection. Half a session object, silently read-only, invites exactly the bug the rest of this design works to prevent: an application writes to it, sees no error, and loses the write. -**What to do instead.** Authenticate during the HTTP handshake, where the +**What to do instead.** Authenticate during the +[HTTP handshake](https://fastapi.tiangolo.com/advanced/websockets/), where the cookie *is* available, and pass what the socket needs into the handler: ```python @@ -370,7 +416,7 @@ async def ws(websocket: WebSocket) -> None: store = await get_session_store(websocket) # type: ignore[arg-type] loaded = await store.load(raw) if raw and store.is_valid_id(raw) else None if loaded is None: - await websocket.close(code=1008) + await websocket.close(code=1008) # policy violation, RFC 6455 §7.4.1 return user_id = loaded.record.data.get("user_id") @@ -380,37 +426,44 @@ async def ws(websocket: WebSocket) -> None: Load once at accept time and hold the identity for the connection. If you need the socket to close when the session ends, pair it with -[real-time session events](#real-time-session-events-optional-redis-88) — that +[real-time session events](#real-time-session-events-optional-redis-88) - that is the case those exist for. --- ## CSRF: what a cookie session reintroduces -A cookie is attached by the browser to **every** request to your origin, -including one triggered by a form on somebody else's page. That is what makes -a session cookie convenient and it is also the whole of CSRF: an attacker -cannot read your session, but they can make the browser spend it. +A [cookie](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/Cookies) is +attached by the browser to **every** request to your origin, including one +triggered by a form on somebody else's page. That is what makes a session cookie +convenient and it is also the whole of +[CSRF](https://owasp.org/www-community/attacks/csrf): an attacker cannot read +your session, but they can make the browser spend it. -A bearer token in an `Authorization` header does not have this problem, because -nothing attaches it automatically. Moving to cookies gets you revocation, -rotation and server-side expiry, and it hands this back. +A bearer token in an +[`Authorization`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Authorization) +header does not have this problem, because nothing attaches it automatically. +Moving to cookies gets you revocation, rotation and server-side expiry, and it +hands this back. -**`SameSite=Lax` is the default here and it is most of the remedy.** The browser -withholds the cookie on cross-site `POST`, `PUT`, `PATCH` and `DELETE`. It does -*not* withhold it on a cross-site top-level `GET`, so: +**[`SameSite=Lax`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Set-Cookie#samesitesamesite-value) +is the default here and it is most of the remedy.** The browser withholds the +cookie on cross-site `POST`, `PUT`, `PATCH` and `DELETE`. It does *not* withhold +it on a cross-site top-level `GET`, so: - **Never change state in a `GET`.** A `GET /account/delete` is exploitable under `Lax` and no cookie attribute will save it. - **Add a CSRF token for anything a browser form can reach.** `SameSite` is a - defence in depth, not a substitute — it is unenforced on some older browsers, - and `Lax` has a two-minute exemption window for top-level POSTs in some - Chromium versions. + [defence in depth, not a substitute](https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html#limitations-of-samesite) + - it is unenforced on some older browsers, and `Lax` has a + [two-minute exemption window](https://www.chromium.org/updates/same-site/faq/) + for top-level POSTs in some Chromium versions. - **`SameSite=Strict`** closes the top-level `GET` hole too, at the cost of the cookie being withheld when a user follows a link into your site from - anywhere else — including their own email. + anywhere else - including their own email. -The session is the natural place to keep the token: +The session is the natural place to keep the token - this is OWASP's +[synchronizer token pattern](https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html#synchronizer-token-pattern): ```python import secrets @@ -429,17 +482,20 @@ async def transfer(session: SessionDep, csrf_token: str = Form(...)) -> dict: ... ``` -`secrets.compare_digest` rather than `==`, and the token rotates with the -session — a sign-in issues a new session ID, so the next `setdefault` mints a -fresh token. +[`secrets.compare_digest`](https://docs.python.org/3/library/secrets.html#secrets.compare_digest) +rather than `==`, and +[`secrets.token_urlsafe`](https://docs.python.org/3/library/secrets.html#secrets.token_urlsafe) +for the token itself. The token rotates with the session - a sign-in issues a +new session ID, so the next `setdefault` mints a fresh token. --- ## Encrypting the payload at rest -Anyone with `redis-cli` access can read a session. That is usually acceptable — -the guidance is to keep identifiers in the session and entities outside it — -but if you must store something sensitive, supply an `Encryptor`: +Anyone with [`redis-cli`](https://redis.io/docs/latest/develop/tools/cli/) +access can read a session. That is usually acceptable - the guidance is to keep +identifiers in the session and entities outside it - but if you must store +something sensitive, supply an `Encryptor`: ```python import os @@ -463,15 +519,17 @@ Encryption **wraps** serialization, never the reverse: the coder turns your value into text, and only then does the encryptor see it. A coder is never handed ciphertext. -This package ships the seam and not the implementation, deliberately. AES-GCM +This package ships the seam and not the implementation, deliberately. +[AES-GCM](https://csrc.nist.gov/pubs/sp/800/38/d/final), via +[`AESGCM`](https://cryptography.io/en/latest/hazmat/primitives/aead/#cryptography.hazmat.primitives.ciphers.aead.AESGCM), in your codebase is a smaller liability for everyone than a cryptographic -primitive maintained here — and it means a key rotation is your decision, on +primitive maintained here - and it means a key rotation is your decision, on your schedule. Two things to know before you turn it on. A record that will not decrypt raises `SessionStoreError` rather than looking like an empty session, so rotating a key without a re-encryption pass signs everybody out loudly rather than silently. -And the index descriptor is **not** encrypted — it holds only what your +And the index descriptor is **not** encrypted - it holds only what your `descriptor_of` returns, so do not put anything sensitive there. --- @@ -495,24 +553,25 @@ FastAPIRedis(app).lifespan().sessions() | Their behaviour | Here | |---|---| -| `request.session["user_id"] = 42` | unchanged — and it now rotates the ID | -| `request.session.clear()` | unchanged — and it now deletes the record too | +| `request.session["user_id"] = 42` | unchanged - and it now rotates the ID | +| `request.session.clear()` | unchanged - and it now deletes the record too | | payload capped at ~4 KB by the cookie | no cap; the cookie carries an ID | | data signed but readable by the client | never leaves the server | | `max_age` | `session_idle_ttl` plus `session_absolute_ttl` | | no revocation | `revoke`, `revoke_id`, `revoke_all` | Handler code does not change. `secret_key` has no equivalent because nothing is -signed: the cookie is an opaque 256-bit identifier. +signed: the cookie is an opaque 256-bit identifier, well past OWASP's +[64-bit entropy floor](https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html#session-id-entropy). -### From `starsessions` +### From [`starsessions`](https://github.com/alex-oleshkevich/starsessions) | Theirs | Here | |---|---| | `lifetime=N, rolling=True` | `session_idle_ttl=N` | | `lifetime=N, rolling=False` | `session_absolute_ttl=N` | | both behaviours at once | not expressible for them; set both settings here | -| `load_session(request)` | nothing — the load is automatic and eager | +| `load_session(request)` | nothing - the load is automatic and eager | | `regenerate_session_id(request)` | delete the call; writing the identity rotates | | `get_session_metadata(request)` | `store.list_for_subject()` for the fields | | `RedisStore(...)` | `FastAPIRedis(app).lifespan().sessions()` | @@ -521,15 +580,17 @@ Their `lifetime` accepts a `timedelta`; so does this store's constructor. ### From `fastapi-users`' `RedisStrategy` -`RedisStrategy` is a token store, not a session store: it maps an opaque token +[`RedisStrategy`](https://fastapi-users.github.io/fastapi-users/latest/configuration/authentication/strategies/redis/) +is a token store, not a session store: it maps an opaque token to a user ID and nothing else. Keep `fastapi-users` for registration, password -reset and OAuth linking — this package does not replace it. Swap the strategy +reset and OAuth linking - this package does not replace it. Swap the strategy for the session and read the identity from `request.session` instead of from the strategy's token. ### From an in-process store -A dict keyed by session ID, or a `TTLCache`. The behaviour you gain is that it +A dict keyed by session ID, or a +[`TTLCache`](https://cachetools.readthedocs.io/en/latest/#cachetools.TTLCache). The behaviour you gain is that it survives a restart and is shared across workers; the behaviour you lose is none. Delete the store and call `.sessions()`. @@ -537,16 +598,19 @@ none. Delete the store and call `.sessions()`. ## Running it in production -**A session store is not a cache, and `maxmemory-policy` must say so.** Under -`allkeys-lru`, `allkeys-lfu` or `allkeys-random`, Redis will evict live -sessions to make room, and every evicted session is a user signed out mid-task -with nothing in any log to explain it. Use `volatile-ttl` or `noeviction`, or -give sessions their own instance or logical database. +**A session store is not a cache, and +[`maxmemory-policy`](https://redis.io/docs/latest/develop/reference/eviction/#eviction-policies) +must say so.** Under `allkeys-lru`, `allkeys-lfu` or `allkeys-random`, Redis +will evict live sessions to make room, and every evicted session is a user +signed out mid-task with nothing in any log to explain it. Use `volatile-ttl` or +`noeviction`, or give sessions their own instance or +[logical database](https://redis.io/docs/latest/commands/select/). This is the single most likely production incident with this feature. A large tenant's index key is a single key that every login and logout writes, -which makes it a candidate hot spot. `HOTKEYS START METRICS 2 CPU NET SAMPLE 100` +which makes it a candidate hot spot. +[`HOTKEYS START METRICS 2 CPU NET SAMPLE 100`](https://redis.io/docs/latest/commands/hotkeys-start/) finds it; the `subject_of` seam is what shards it. --- @@ -584,15 +648,17 @@ FastAPIRedis(app).lifespan().sessions( ) ``` -To supply a whole store — another backend, or a test double — pass `store` or -`store_factory`: +To supply the store object yourself - one you built and configured, or a test +double - pass `store` or `store_factory`: ```python -FastAPIRedis(app).lifespan().sessions(store=MyPostgresSessionStore(pool)) +store = RedisSessionStore(redis, key_prefix="tenant-a", absolute_ttl=3600) +FastAPIRedis(app).lifespan().sessions(store=store) ``` -In tests, `dependency_overrides` reaches the middleware as well as your -handlers, so one override covers the whole request: +In tests, +[`dependency_overrides`](https://fastapi.tiangolo.com/advanced/testing-dependencies/) +reaches the middleware as well as your handlers, so one override covers the whole request: ```python app.dependency_overrides[get_session_store] = lambda request: fake_store @@ -603,18 +669,44 @@ app.dependency_overrides[get_session_store] = lambda request: fake_store ## Settings Every setting is an environment variable prefixed `REDIS_`, so -`session_idle_ttl` is `REDIS_SESSION_IDLE_TTL`. +`session_idle_ttl` is `REDIS_SESSION_IDLE_TTL`. See +[Configuration](configuration.md) for how these are loaded. | Setting | Default | Notes | |---|---|---| | `session_cookie_name` | `session` | Matches Starlette and `starsessions` | -| `session_cookie_https_only` | `True` | Adds `Secure`. Turn it off for local HTTP only | -| `session_cookie_same_site` | `lax` | `none` requires `https_only=True` | +| `session_cookie_https_only` | `True` | Adds [`Secure`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Set-Cookie#secure). Turn it off for local HTTP only | +| `session_cookie_same_site` | `lax` | [`none`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Set-Cookie#samesitesamesite-value) requires `https_only=True` | | `session_idle_ttl` | `1800` | `0` disables the idle clock | | `session_absolute_ttl` | `28800` | `0` disables the absolute clock | | `session_gc_ttl` | `2592000` | Backstop so Redis can always collect an abandoned key | | `session_refresh_on_load` | `True` | `False`: only a request that *used* the session counts as activity | | `session_fail_closed` | `False` | Read behaviour when Redis is down | -| `session_always_save` | `False` | Escape route for nested mutation | -| `session_principal_keys` | `["user_id"]` | What a change to rotates the ID | +| `session_always_save` | `False` | Escape route for nested mutation. Writes on every request that **read** the session; an empty session is exempt | +| `session_principal_keys` | `["user_id"]` | What a change to rotates the ID. Comma-separated in the environment: `REDIS_SESSION_PRINCIPAL_KEYS=user_id,role` | | `session_events_enabled` | `False` | Opt in to real-time events | + +--- + +## References + +The standards and specifications this design follows: + +- [OWASP Session Management Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html) + - identifier entropy, rotation on privilege change, and the two timeouts +- [OWASP Cross-Site Request Forgery Prevention Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html) + - the token patterns and the limits of `SameSite` +- [OWASP ASVS, chapter 3: Session Management](https://owasp.org/www-project-application-security-verification-standard/) + - the verifiable requirements behind the cheat sheets +- [RFC 6265](https://datatracker.ietf.org/doc/html/rfc6265) and + [RFC 6265bis](https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-rfc6265bis) + - HTTP cookies, and the draft that defines `SameSite` +- [MDN: HTTP caching](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/Caching) + and [`Cache-Control`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Cache-Control) + - what `private`, `no-store` and `Vary` mean to a shared cache +- [Redis key eviction](https://redis.io/docs/latest/develop/reference/eviction/) + - why `maxmemory-policy` decides whether sessions survive +- [Redis keyspace notifications](https://redis.io/docs/latest/develop/pubsub/keyspace-notifications/) + - the delivery guarantees behind `SessionEvents` +- [Redis hash field expiration](https://redis.io/docs/latest/commands/hexpire/) + - the mechanism the two clocks are built on diff --git a/docs/specs/session-design.md b/docs/specs/session-design.md index 56dfb58..abb21eb 100644 --- a/docs/specs/session-design.md +++ b/docs/specs/session-design.md @@ -255,8 +255,9 @@ absolute limit without any test noticing. With two fields that outcome is not a must avoid — it is unreachable. For a security control, the difference is the whole point. -Both settings accept an `int` or a `timedelta`, as `cache()` already does in this -package. With both at zero the session is cookie-only: no `max-age` on the cookie, so the +Both settings are an `int` number of seconds. The store constructor and `.sessions()` +also accept a `timedelta`; Section 9 gives the convention and why a settings field cannot +sensibly take one. With both at zero the session is cookie-only: no `max-age` on the cookie, so the browser drops it when it closes, and **both** fields get `gc_ttl` so Redis eventually collects what the browser abandoned. @@ -1033,7 +1034,12 @@ call: — so a listing needs no read of the session records themselves. **Abstract, and this is the whole surface a new backend implements:** `_read`, `_write`, -`_expire`, `_delete`, `_index_add`, `_index_remove`, `_index_members`. +`_expire`, `_delete`, `_delete_many`, `_alive`, `_index_add`, `_index_remove`, +`_index_clear`, `_index_members`, `_index_size`. + +`_index_size` is separate from `_index_members` for one reason: `count_for_subject` runs +on every login under a concurrent-session cap, and counting through the members would +transfer every identifier and every descriptor to compute their length. `SessionStoreProtocol` describes only what the middleware and the dependencies call, so a test or another package can supply an object with no inheritance. @@ -1398,14 +1404,14 @@ Fields on `RedisSettings`, beside the existing `rate_limit_*` ones and following | `session_cookie_path` | `str` | `"/"` | `Path` attribute. Narrow it (e.g. `"/admin"`) and other paths neither send nor receive the cookie. | | `session_cookie_same_site` | `"lax" \| "strict" \| "none"` | `"lax"` | `SameSite` attribute. `"none"` requires `session_cookie_https_only=True`; the two are checked together and a contradiction raises `SessionConfigurationError`. | | `session_cookie_https_only` | `bool` | `True` | Adds `Secure`, so the browser sends the cookie over HTTPS only. **On by default**; turn it off for local development over plain HTTP and nowhere else. | -| `session_idle_ttl` | `int \| timedelta` | `1800` (30 min) | The idle clock. The session dies this long after the last request that carried its cookie. Stored as the TTL of field `d`. `0` disables the idle clock. | -| `session_absolute_ttl` | `int \| timedelta` | `28800` (8 h) | The absolute clock. The session dies this long after creation however active the user is. Stored as the TTL of field `a`. `0` disables it, in which case `a` takes `session_gc_ttl` — see Section 3.2, which explains why `a` is never left unexpiring. | -| `session_gc_ttl` | `int \| timedelta` | `2592000` (30 days) | Backstop TTL for a key whose real deadline is unknown: cookie-only mode, or `session_absolute_ttl=0`. Never reached in normal operation; it exists so Redis can always collect an abandoned key. | +| `session_idle_ttl` | `int` (seconds) | `1800` (30 min) | The idle clock. The session dies this long after the last request that carried its cookie. Stored as the TTL of field `d`. `0` disables the idle clock. | +| `session_absolute_ttl` | `int` (seconds) | `28800` (8 h) | The absolute clock. The session dies this long after creation however active the user is. Stored as the TTL of field `a`. `0` disables it, in which case `a` takes `session_gc_ttl` — see Section 3.2, which explains why `a` is never left unexpiring. | +| `session_gc_ttl` | `int` (seconds) | `2592000` (30 days) | Backstop TTL for a key whose real deadline is unknown: cookie-only mode, or `session_absolute_ttl=0`. Never reached in normal operation; it exists so Redis can always collect an abandoned key. | | `session_refresh_on_load` | `bool` | `True` | `True`: the load uses `HGETEX`, so any request carrying the cookie restarts the idle clock in the same round trip. `False`: the load uses `HGET` and only a request that touched `request.session` refreshes it, at the cost of a second round trip. Section 4.2 gives both branches. | | `session_fail_closed` | `bool` | `False` | Behaviour when Redis is unreachable **on read**. `False` yields an empty session, so the caller looks anonymous and the application's own authorization rejects them. `True` raises `SessionStoreError` instead, for a deployment that prefers a 503 to an anonymous page. **Writes always raise, whatever this is set to** — Section 7 explains the asymmetry. | -| `session_always_save` | `bool` | `False` | Write the payload on every request that touched the session, even when no mutation was detected. The escape route for the one fault no `dict` subclass can see: a change inside a nested value, `session["a"]["b"] = 1` (Section 8). Costs a write per request; prefer reassigning the top-level key. | -| `session_principal_keys` | `list[str]` | `["user_id"]` | Session keys the rotation trigger watches. A change to any of them on a successful response rotates the ID. Add `"role"` or `"scopes"` for OWASP's privilege-change rotation. Section 5.1; use `principal_of` when a list of keys cannot express it. | -| `session_events_enabled` | `bool` | `False` | Subscribe to Redis notifications and call registered handlers when a session ends (Section 13.4, F-21). **Best-effort.** On a server below 8.8, or one where `notify-keyspace-events` lacks the subkey flags, or where `CONFIG GET` is unavailable, the store logs one warning at startup and the handlers never fire. Never enable the server setting on the operator's behalf. | +| `session_always_save` | `bool` | `False` | Write the payload on every request that touched the session, even when no mutation was detected. The escape route for the one fault no `dict` subclass can see: a change inside a nested value, `session["a"]["b"] = 1` (Section 8). Costs a write on every request that **read** the session - `accessed` is set by reading - so prefer reassigning the top-level key. **An empty session is exempt**: `WRITE` requires `not empty`, because without it every anonymous visitor to a session-touching route would be minted an identifier, a key and a cookie. Nothing is lost, since a nested mutation implies a top-level key already holding the value. | +| `session_principal_keys` | `list[str]` | `["user_id"]` | Session keys the rotation trigger watches. A change to any of them on a successful response rotates the ID. Add `"role"` or `"scopes"` for OWASP's privilege-change rotation. **Comma-separated from the environment** — `REDIS_SESSION_PRINCIPAL_KEYS=user_id,role`; a JSON array is also accepted. Section 5.1; use `principal_of` when a list of keys cannot express it. | +| `session_events_enabled` | `bool` | `False` | Subscribe to Redis notifications and call registered handlers when a session ends (Section 13.4, F-21). **Best-effort.** On a server below 8.8, or one where `notify-keyspace-events` lacks `Th`, or where `CONFIG GET` is unavailable, the store logs one warning at startup and the handlers never fire. Never enable the server setting on the operator's behalf. | | `session_key_prefix` | `str \| None` | `None` | Overrides the key namespace. `None` uses `settings.pattern_prefix()`, giving `redis:fastapi:session:` and `redis:fastapi:sessions-of:`. A callable prefix is a constructor argument rather than a setting, since an environment variable cannot carry one (Section 9, extension points). | #### Three things the §5 list in `session-mgmt.md` names that are deliberately not settings @@ -1434,8 +1440,45 @@ a vulnerability that nobody reads the documentation to discover. **The two TTL defaults are deliberately different from each other**, because they are different controls. 30 minutes of idle and 8 hours absolute is the shape OWASP describes for an application someone uses through a working day: inactivity signs you out quickly, -and no session survives past the day regardless. Both accept a `timedelta`, as `cache()` -already does in this package. +and no session survives past the day regardless. + +**The one list-typed setting takes a comma-separated value.** `session_principal_keys` +is the only non-scalar field in the package, and pydantic-settings `json.loads` any such +field's raw environment value — so `REDIS_SESSION_PRINCIPAL_KEYS=user_id,role` was not a +validation failure, it was a `SettingsError` and the application did not start. The field +carries `NoDecode` and a `mode="before"` validator that splits on commas and still +accepts a JSON array. `NoDecode` is what makes this reachable at all: without it the +environment source raises before any validator on the class runs, so a `mode="before"` +validator alone never sees the value. That is why the floor on `pydantic-settings` is +2.7.0. + +An empty result is **refused** rather than accepted. With no keys, +`_default_principal_of` returns the same sentinel on every request, so no sign-in and no +privilege change is ever detected — the fixation Section 5.1 exists to prevent, reached +through a blank line in a `.env` file. Whoever wants rotation decided some other way +passes `principal_of`. + +**Settings take `int` seconds; runtime Python takes either.** This is the convention the +rest of the SDK already follows — the `cache()`, `cache_evict()` and `cache_put()` +dependency factories take an `int`, while `CacheBackend.set()` takes `int | timedelta` — +and the session code follows it exactly: the three settings above are `int`, and +`RedisSessionStore(...)`, `add_redis_sessions(...)` and `.sessions(...)` all accept a +`timedelta`. An earlier draft of the table above said `int | timedelta` for the settings. +It was written before the convention was checked, and the code was right. + +A settings field could not take a `timedelta` cleanly in any case. The value arrives from +the environment as a string, and pydantic reads `"1800"` as 1800 seconds but also accepts +ISO-8601 `"PT30M"` — one field, two syntaxes, and `PT30M` in a `.env` file is worse for an +operator than `1800`, not better. Whoever wants a `timedelta` for readability has it on +the path where readability matters: + +```python +FastAPIRedis(app).lifespan().sessions(idle_ttl=timedelta(minutes=30)) +RedisSessionStore(client, absolute_ttl=timedelta(hours=8)) +``` + +This costs the `starsessions` migration nothing: their `lifetime=timedelta(...)` maps to +`sessions(absolute_ttl=timedelta(...))` unchanged. An earlier draft also carried `refresh_threshold`, to suppress a second round trip that only existed because the idle refresh was a separate `EXPIRE`. `HGETEX` folds that refresh @@ -1453,9 +1496,19 @@ nothing when the import failed, and `disable_telemetry()` resets the state. | Instrument | Type | Attributes | |-------------------------------------|-----------|-------------------------------------------------------------------------------------------| -| `redis_fastapi.sessions.operations` | counter | `operation` = load/save/touch/rotate/revoke/revoke_all; `result` = hit/miss/expired/error | -| `redis_fastapi.sessions.latency` | histogram | `operation` | -| `redis_fastapi.sessions.events` | counter | `cause` = idle/absolute/revoked; `result` = delivered/dropped | +| `redis_fastapi.sessions.operations` | counter | `operation` = load/create/save/touch/rotate/revoke/revoke_id/revoke_all/list/count; `result` = hit/miss/expired/error | +| `redis_fastapi.sessions.latency` | histogram | `operation`, the same set less `touch` | +| `redis_fastapi.sessions.events` | counter | `cause` = idle/absolute; `result` = delivered/dropped | + +`create` and `save` carry separate labels although they share one code path: a create +is a session that did not exist a moment ago - the sign-in rate - and reporting it as a +save understated one series and polluted the other. A rotation therefore counts as both +a `rotate` and a `create`, which is what makes the Section 5.1 backstop readable: sign-ins +with no rotations is the anomaly. + +`delete` and `index` are deliberately uninstrumented. Both are always part of another +operation and already inside its span, so counting them would double-count that +operation. **No session ID may become a span attribute or a metric label.** Neither may a subject identifier, which is usually a user ID. Section 5 of `session-mgmt.md` makes this an @@ -1753,7 +1806,7 @@ is easy to miss: |---------|------------------------------------------|------------------------------------------|-----------------------------| | `none` | — | — | — | | `key` | 7.4, plus `Eghx` in `notify-keyspace-events` | `__keyevent@__:del` | **No** | -| `field` | **8.8**, plus `h` and one of `S`/`T`/`I`/`V` | `__subkeyevent@__:hexpired`, whose payload names the field | **Yes** — `d` is idle, `a` is absolute | +| `field` | **8.8**, plus `h` and **`T`** | `__subkeyevent@__:hexpired`, whose payload names the field | **Yes** — `d` is idle, `a` is absolute | At the `key` tier a subscriber learns that a session key went away and nothing else. It cannot separate an idle death from an absolute one, and it cannot separate either from a @@ -1771,7 +1824,15 @@ Two different questions, and the code must ask both: 1. **Can the server do it?** Read `redis_version` from `INFO server`. 2. **Is it switched on?** Read `notify-keyspace-events` with `CONFIG GET` and look for `h` - together with one of `S`, `T`, `I`, `V`. + together with **`T`**, not one of `S`/`T`/`I`/`V`. + +**`T` specifically, because 8.8 adds four subkey channels and this subscribes to one.** +`S` is `__subkeyspace@`, `I` is `__subkeyspaceitem@`, `V` is `__subkeyspaceevent@`, and +only `T` is `__subkeyevent@`. Redis accepts a subscription to any channel name, so on a +server set to `Sh` the subscribe succeeds and no event ever arrives. Accepting any of the +four therefore made `tier` report `"field"` where nothing could be delivered, which +defeats the `events.tier` check this section tells callers to rely on. An earlier draft of +this list said "one of", and the code followed it. **The four subkey flags are independent of `K` and `E`.** Enabling standard keyspace notifications does not enable subkey notifications, and the reverse holds too. This will @@ -1831,7 +1892,7 @@ A `SessionEvents` object built in the lifespan, holding one subscriber task per events = store.events() # tier probed once, at startup @events.on_session_end -async def _(sid: str, cause: Literal["idle", "absolute", "revoked"]) -> None: +async def _(sid: str, cause: Cause) -> None: # Literal["idle", "absolute"] await close_sockets_for(sid) print(events.tier) # "field" or "none" @@ -1840,6 +1901,19 @@ print(events.tier) # "field" or "none" `cause` is what the `field` tier buys and the `key` tier cannot give. At tier `none` the handler is held and never called. +**`Cause` has two members and must not have three.** A revocation is a `DEL`, and `DEL` +emits no subkey notification at any version: it is not among the commands that do, and the +mechanism forbids it, because a subkey event is published only when at least one subkey is +present and a deleted key has none left to name. An earlier draft included `"revoked"`. +A `Literal` in a public callback signature is a promise about the inhabited set, so a +member nothing can produce leaves a caller's exhaustive `match` with an arm that never +runs and that a type checker will not let them delete. If the `key` tier is ever built, +widening the union then is the ordinary cost of widening any union. + +`Cause`, `Tier` and `Handler` are all exported. `Handler` in particular, because a caller +under `mypy --strict` has to be able to name the type of the callable `on_session_end` +requires. + ### 13.5 Considered, and not in version 1 **Compare-and-set on the session payload.** There is a real gap here and it should be diff --git a/pyproject.toml b/pyproject.toml index 5ddeebe..d4f1361 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,7 +30,9 @@ classifiers = [ dependencies = [ "redis>=6.0.0", "fastapi>=0.115.0", - "pydantic-settings>=2.0.0", + # 2.7.0 for the NoDecode annotation, which config.py needs to accept a + # comma-separated REDIS_SESSION_PRINCIPAL_KEYS. + "pydantic-settings>=2.7.0", "pydantic>=2.12.5", "anyio>=4.13.0", ] diff --git a/src/redis_fastapi/__init__.py b/src/redis_fastapi/__init__.py index 04d4c3f..6b9c28d 100644 --- a/src/redis_fastapi/__init__.py +++ b/src/redis_fastapi/__init__.py @@ -62,7 +62,7 @@ SessionStoreProtocol, SyncSessionStore, ) -from redis_fastapi.session_events import Cause, SessionEvents, Tier +from redis_fastapi.session_events import Cause, Handler, SessionEvents, Tier from redis_fastapi.sessions import ( CookieSpec, Outcome, @@ -92,6 +92,7 @@ "CookieSpec", "Encryptor", "FastAPIRedis", + "Handler", "Identifier", "JsonCoder", "KeyBuilder", diff --git a/src/redis_fastapi/config.py b/src/redis_fastapi/config.py index 714ffbb..baa2688 100644 --- a/src/redis_fastapi/config.py +++ b/src/redis_fastapi/config.py @@ -6,14 +6,15 @@ from __future__ import annotations +import json import re import warnings from functools import lru_cache from importlib.metadata import PackageNotFoundError, version -from typing import Any, Literal +from typing import Annotated, Any, Literal from pydantic import Field, SecretStr, field_validator, model_validator -from pydantic_settings import BaseSettings, SettingsConfigDict +from pydantic_settings import BaseSettings, NoDecode, SettingsConfigDict from redis.driver_info import DriverInfo LIB_NAME: str = "fastapi-redis-sdk" @@ -277,15 +278,21 @@ class RedisSettings(BaseSettings): description=( "Write the payload on every request that touched the session, " "even when no mutation was detected. The escape route for a " - "change inside a nested value, which no dict subclass can see." + "change inside a nested value, which no dict subclass can see. " + "Costs a write on every request that read the session, so prefer " + "reassigning the top-level key. An empty session is exempt: " + "reading one does not create it, because 'touched' includes a " + "plain read and that would mint a key per anonymous visitor." ), ) - session_principal_keys: list[str] = Field( + session_principal_keys: Annotated[list[str], NoDecode] = Field( default_factory=lambda: ["user_id"], description=( "Session keys the rotation trigger watches. A change to any of " "them on a successful response rotates the session ID. Add 'role' " - "or 'scopes' for OWASP's privilege-change rotation." + "or 'scopes' for OWASP's privilege-change rotation. From the " + "environment, comma-separated: REDIS_SESSION_PRINCIPAL_KEYS=" + "user_id,role. A JSON array is still accepted." ), ) session_events_enabled: bool = Field( @@ -316,6 +323,65 @@ class RedisSettings(BaseSettings): # name, path and domain reach the same header by the same route and were # not checked at all. A CR or LF in any of them splits the header. + @field_validator("session_principal_keys", mode="before") + @classmethod + def _split_principal_keys(cls, value: Any) -> Any: + """Accept ``user_id,role`` from the environment, and JSON as well. + + The only list-typed setting in this package, so this is the precedent + rather than a break from one. Comma-separated is what an operator + will type, what every other tool in a ``.env`` file accepts, and what + Django, Rails and Spring Boot all take for the same kind of setting. + JSON-in-an-environment-variable is the outlier - and it was not a + choice we made, it is what pydantic-settings does with any field whose + annotation is not a scalar. + + ``NoDecode`` on the annotation is what makes this reachable. Without + it ``EnvSettingsSource`` calls ``json.loads`` on the raw string and + raises ``SettingsError`` **before** any validator on this class runs, + so a comma-separated value did not fail validation - the application + did not start. A ``mode="before"`` validator alone does not help; it + never sees the value. + + The JSON branch is kept so that no existing configuration breaks. + + The one cost: a session key containing a comma cannot be named from + the environment. Session keys are Python identifiers in every + realistic case, and the JSON form is still there for one that is not. + + **Empty is refused**, and this check is why splitting is not purely a + convenience. Before this validator, ``REDIS_SESSION_PRINCIPAL_KEYS=`` + left blank could not get through at all - ``json.loads("")`` fails and + the application does not start. Splitting a blank value yields ``[]`` + instead, and an empty list makes ``_default_principal_of`` return the + same sentinel on every request, so no privilege change and no sign-in + ever rotates the ID. That is the fixation this feature exists to + prevent, arrived at through a typo in a ``.env`` file. + """ + if isinstance(value, str): + text = value.strip() + if text.startswith("["): + try: + value = json.loads(text) + except json.JSONDecodeError as exc: + raise ValueError( + "session_principal_keys looked like a JSON array but " + f"could not be parsed ({exc}); comma-separated is " + "simpler: user_id,role" + ) from exc + else: + # Empty parts dropped, so a trailing comma is not a key "". + value = [part.strip() for part in text.split(",") if part.strip()] + if isinstance(value, (list, tuple, set)) and not value: + raise ValueError( + "session_principal_keys must name at least one session key. " + "An empty list means no sign-in and no privilege change ever " + "rotates the session ID, which is the fixation this feature " + "exists to prevent. Pass principal_of=... to decide " + "rotation some other way." + ) + return value + @field_validator("session_cookie_name") @classmethod def _check_cookie_name(cls, value: str) -> str: diff --git a/src/redis_fastapi/session_backend.py b/src/redis_fastapi/session_backend.py index 03fab2c..d7fed1a 100644 --- a/src/redis_fastapi/session_backend.py +++ b/src/redis_fastapi/session_backend.py @@ -17,11 +17,12 @@ from __future__ import annotations +import contextlib import logging import secrets import time from abc import ABC, abstractmethod -from collections.abc import Callable, MutableMapping +from collections.abc import Callable, Iterator, MutableMapping from dataclasses import dataclass from datetime import timedelta from enum import Enum, auto @@ -60,6 +61,11 @@ OSError, ) +# What a *composite* operation can fail with: a driver error from a primitive +# it calls directly, or this module's own wrapper around one raised deeper +# down. ``_observe`` counts both as one failed operation. +OBSERVED_ERRORS: tuple[type[BaseException], ...] = (SessionStoreError, *STORE_ERRORS) + # Hash field names. Two characters, and identical in every session key. # Section 13.2: a uniform schema is what a future compact-hash encoding would # reward, and a short name is fewer bytes on the wire meanwhile. @@ -339,9 +345,17 @@ class SessionStore(ABC): The lifecycle is a security control, so it is written once here rather than once per backend. A new backend implements the abstract primitives - at the bottom of this class - ten of them, listed there - and inherits the - identifier rules, the two-clock policy, the envelope format and the error - handling. + at the bottom of this class - eleven of them, listed there - and inherits + the identifier rules, the two-clock policy, the envelope format and the + error handling. + + **The underscore on those eleven means "applications never call this", + not "do not override this".** The prefix is what separates policy from + mechanism - ``delete()`` applies the failure policy, ``_delete()`` removes + the key - and the two could not share a name. Overriding them is + supported and is how this class is meant to be extended; it is simply not + a documented, supported product surface, so the published guide does not + describe it and no compatibility promise attaches to it. Section 7 of ``session-mgmt.md`` gives the reason this is an abstract base class and not a bare protocol. ``SessionStoreProtocol`` describes the much @@ -528,6 +542,30 @@ def new_record( ), ) + # -- instrumentation ------------------------------------------------------ + + @contextlib.contextmanager + def _observe(self, operation: str) -> Iterator[None]: + """Span, latency and the ``error`` count for one store operation. + + The outcome count is the caller's, because only the caller knows + whether nothing-to-do is a ``miss`` or a ``hit``. Failure is not: + every operation that raises counts the same way, and doing it here is + what keeps the ``error`` series from depending on someone remembering + an ``except`` arm. + + A composite operation records **twice** on failure, once for itself + and once for the inner operation that failed - a rotation whose + ``create`` fails is both a failed create and a failed rotation, and a + dashboard wants to see both. The spans nest for the same reason. + """ + with session_span(f"session.{operation}"), timed_session(operation): + try: + yield + except OBSERVED_ERRORS: + record_session_operation(operation=operation, result="error") + raise + # -- lifecycle ----------------------------------------------------------- async def load( @@ -643,8 +681,14 @@ async def _save( expired. *absolute* is the deadline TTL on a create, and ``None`` on an update. + It also names the operation for telemetry: the two report separately + because a create is a session that did not exist a moment ago - the + sign-in rate, and the most useful single number a session dashboard + can show - while a save is a session being updated. Reporting both + under ``save`` understated one and polluted the other. """ - with session_span("session.save"), timed_session("save"): + operation = "create" if absolute is not None else "save" + with session_span(f"session.{operation}"), timed_session(operation): try: await self._write( session_id, @@ -653,20 +697,26 @@ async def _save( absolute=absolute, ) except STORE_ERRORS as exc: - record_session_operation(operation="save", result="error") + record_session_operation(operation=operation, result="error") raise SessionStoreError(f"Could not save session: {exc}") from exc - record_session_operation(operation="save", result="hit") + record_session_operation(operation=operation, result="hit") async def touch(self, session_id: str) -> None: """Restart the idle clock without rewriting the payload. Only needed under ``session_refresh_on_load=False``; the default load already refreshed in the same round trip as the read. + + A counter and no span: one command is not worth a span, but the count + is the only way to confirm that ``refresh_on_load=False`` is doing + anything at all. """ try: await self._expire(session_id, self.idle_seconds) except STORE_ERRORS as exc: + record_session_operation(operation="touch", result="error") raise SessionStoreError(f"Could not refresh session: {exc}") from exc + record_session_operation(operation="touch", result="hit") async def delete(self, session_id: str) -> None: """Remove a session outright. @@ -721,6 +771,17 @@ async def rotate( Raises: SessionStoreError: On any store failure. """ + with self._observe("rotate"): + return await self._rotate(state, subject=subject, descriptor=descriptor) + + async def _rotate( + self, + state: SessionState, + *, + subject: str | None, + descriptor: dict[str, Any] | None, + ) -> str: + """Body of :meth:`rotate`, so the span wraps the whole four trips.""" subject = subject if subject is not None else state.subject old_id = state.session_id if old_id is not None: @@ -788,16 +849,22 @@ async def revoke(self, state: SessionState, *, subject: str | None = None) -> No of and eventually refuses a legitimate login. """ old_id = state.session_id - if old_id is not None: - await self.delete(old_id) - resolved = subject if subject is not None else state.subject - if resolved: - await self._index_drop(resolved, old_id) - state.data.clear() - state.session_id = None - state.subject = None - state.revoked = True - state.rotated = False + with self._observe("revoke"): + if old_id is not None: + await self.delete(old_id) + resolved = subject if subject is not None else state.subject + if resolved: + await self._index_drop(resolved, old_id) + state.data.clear() + state.session_id = None + state.subject = None + state.revoked = True + state.rotated = False + # ``miss`` is a session that was never written: there was no key to + # delete and no cookie to replace, so nothing reached Redis. + record_session_operation( + operation="revoke", result="hit" if old_id is not None else "miss" + ) async def revoke_id(self, session_id: str, *, subject: str) -> bool: """End one session by ID, and refuse an ID not indexed under *subject*. @@ -812,16 +879,24 @@ async def revoke_id(self, session_id: str, *, subject: str) -> bool: subject's - including when it has already expired. """ if not self.is_valid_id(session_id): + record_session_operation(operation="revoke_id", result="miss") return False - try: - members = await self._index_members(subject) - except STORE_ERRORS as exc: - raise SessionStoreError(f"Could not read the session index: {exc}") from exc - if session_id not in members: - return False - await self.delete(session_id) - await self._index_drop(subject, session_id) - return True + with self._observe("revoke_id"): + try: + members = await self._index_members(subject) + except STORE_ERRORS as exc: + raise SessionStoreError( + f"Could not read the session index: {exc}" + ) from exc + if session_id not in members: + # Worth its own label: a run of these is either a broken UI or + # somebody trying identifiers that are not theirs. + record_session_operation(operation="revoke_id", result="miss") + return False + await self.delete(session_id) + await self._index_drop(subject, session_id) + record_session_operation(operation="revoke_id", result="hit") + return True async def revoke_all(self, subject: str) -> int: """End every session belonging to *subject*. @@ -835,18 +910,18 @@ async def revoke_all(self, subject: str) -> int: lower than the number of entries it held whenever some of those sessions had already died. """ - try: - members = await self._index_members(subject) - if not members: - record_session_operation(operation="revoke_all", result="miss") - return 0 - removed = await self._delete_many(list(members)) - await self._index_clear(subject) - except STORE_ERRORS as exc: - record_session_operation(operation="revoke_all", result="error") - raise SessionStoreError(f"Could not revoke sessions: {exc}") from exc - record_session_operation(operation="revoke_all", result="hit") - return removed + with self._observe("revoke_all"): + try: + members = await self._index_members(subject) + if not members: + record_session_operation(operation="revoke_all", result="miss") + return 0 + removed = await self._delete_many(list(members)) + await self._index_clear(subject) + except STORE_ERRORS as exc: + raise SessionStoreError(f"Could not revoke sessions: {exc}") from exc + record_session_operation(operation="revoke_all", result="hit") + return removed # -- the reverse lookup --------------------------------------------------- @@ -907,40 +982,55 @@ async def list_for_subject(self, subject: str) -> list[SessionInfo]: entries are pruned on the way past. Two round trips whatever the session count: one to read the index, one to verify the batch. """ - try: - members = await self._index_members(subject) - except STORE_ERRORS as exc: - self._read_failed(exc) - return [] - if not members: - return [] - - live = await self._verify(list(members)) - infos: list[SessionInfo] = [] - dead: list[str] = [] - for session_id, raw in members.items(): - if live is not None and session_id not in live: - dead.append(session_id) - continue - infos.append(self._to_info(session_id, raw)) - for session_id in dead: - # Best-effort tidy-up on a read path: failing a device listing - # because we could not prune a stale row helps nobody. + # Deliberately not ``_observe``: this read fails **open**, so the + # error has to be counted where the exception is swallowed. Under + # ``fail_closed`` it escapes as well, and ``_observe`` would then + # count the same failure a second time. + with session_span("session.list"), timed_session("list"): try: - await self._index_remove(subject, session_id) + members = await self._index_members(subject) except STORE_ERRORS as exc: - logger.warning("Could not prune a dead index entry: %s", exc) - infos.sort(key=lambda info: info.last_access, reverse=True) - return infos + record_session_operation(operation="list", result="error") + self._read_failed(exc) + return [] + if not members: + record_session_operation(operation="list", result="miss") + return [] + + live = await self._verify(list(members)) + infos: list[SessionInfo] = [] + dead: list[str] = [] + for session_id, raw in members.items(): + if live is not None and session_id not in live: + dead.append(session_id) + continue + infos.append(self._to_info(session_id, raw)) + for session_id in dead: + # Best-effort tidy-up on a read path: failing a device listing + # because we could not prune a stale row helps nobody. + try: + await self._index_remove(subject, session_id) + except STORE_ERRORS as exc: + logger.warning("Could not prune a dead index entry: %s", exc) + infos.sort(key=lambda info: info.last_access, reverse=True) + record_session_operation( + operation="list", result="hit" if infos else "miss" + ) + return infos async def count_for_subject(self, subject: str, *, limit: int | None = None) -> int: """How many sessions *subject* has, as an upper bound by default. - Counting the index is ``O(1)`` and needs no verification, which is what - makes a "cap concurrent sessions" check cheap on every login. Pass - *limit* to make the count exact **only when it matters**: below the - limit the fast answer is returned, and the verification round trip is - paid solely by the request that is about to be refused. + The fast path is ``_index_size`` - one ``HLEN`` - and it transfers a + single integer. That is what makes a "cap concurrent sessions" check + cheap on every login: reading the members instead would ship every + identifier **and every descriptor** across the wire to compute their + length, and a descriptor holds whatever ``descriptor_of`` returns. + + Pass *limit* to make the count exact **only when it matters**: below + the limit the fast answer is returned, and both the members read and + the verification round trip are paid solely by the request that is + about to be refused. **This one does not fail open.** Section 7's argument for an empty session on a failed read is that the application's own authorization @@ -951,22 +1041,25 @@ async def count_for_subject(self, subject: str, *, limit: int | None = None) -> Raises: SessionStoreError: If the count could not be established. """ - try: - members = await self._index_members(subject) - except STORE_ERRORS as exc: - raise SessionStoreError( - f"Could not count sessions for the subject: {exc}" - ) from exc - upper = len(members) - if limit is None or upper < limit: - return upper - live = await self._verify(list(members)) - if live is None: - raise SessionStoreError( - "Could not verify session liveness while counting; refusing to " - "answer rather than under-count." - ) - return len(live) + with self._observe("count"): + try: + upper = await self._index_size(subject) + if limit is None or upper < limit: + record_session_operation(operation="count", result="hit") + return upper + members = await self._index_members(subject) + except STORE_ERRORS as exc: + raise SessionStoreError( + f"Could not count sessions for the subject: {exc}" + ) from exc + live = await self._verify(list(members)) + if live is None: + raise SessionStoreError( + "Could not verify session liveness while counting; refusing to " + "answer rather than under-count." + ) + record_session_operation(operation="count", result="hit") + return len(live) async def _verify(self, session_ids: list[str]) -> set[str] | None: """Which of *session_ids* are alive, or ``None`` if we could not ask. @@ -1043,6 +1136,13 @@ async def _safe_delete(self, session_id: str) -> None: logger.warning("Could not remove a dead session key: %s", exc) # -- the whole surface a new backend implements -------------------------- + # + # Eleven methods. Underscore-prefixed because no application calls them, + # *not* because a backend may not override them - overriding them is the + # only way to write one, and Python refuses to instantiate a subclass that + # leaves one out. Each docstring below carries the rule that is easy to + # get wrong, because these docstrings are the only statement of the + # contract: it is deliberately not described in the published docs. @abstractmethod async def _read( @@ -1113,6 +1213,18 @@ async def _index_members(self, subject: str) -> dict[str, bytes | str]: a caller must verify before reporting them. """ + @abstractmethod + async def _index_size(self, subject: str) -> int: + """How many entries a subject's index holds. + + The same **upper bound** as ``_index_members``, and it exists so that + ``count_for_subject`` need not read the descriptors to count them. + Implement it with whatever counts without transferring the values - + ``HLEN`` on a hash, ``SCARD`` on a set. Falling back to + ``len(await self._index_members(subject))`` is correct but gives up + the only reason this method is separate. + """ + class RedisSessionStore(SessionStore): """The Redis implementation of the storage primitives. @@ -1287,6 +1399,9 @@ async def _index_members(self, subject: str) -> dict[str, bytes | str]: raw = await self._redis.hgetall(self.index_key(subject)) return {(k.decode() if isinstance(k, bytes) else k): v for k, v in raw.items()} + async def _index_size(self, subject: str) -> int: + return int(await self._redis.hlen(self.index_key(subject))) + async def _alive(self, session_ids: list[str]) -> set[str]: """One pipelined ``HTTL`` per candidate, sent as a single batch. diff --git a/src/redis_fastapi/session_events.py b/src/redis_fastapi/session_events.py index 8385ef7..1790a10 100644 --- a/src/redis_fastapi/session_events.py +++ b/src/redis_fastapi/session_events.py @@ -47,7 +47,27 @@ logger = logging.getLogger(__name__) Tier = Literal["none", "field"] -Cause = Literal["idle", "absolute", "revoked"] + +# Two members, and there is no third. ``Cause`` is a ``Literal`` in a public +# callback signature, so it is a promise about which values a handler can be +# called with: a caller writing the exhaustive ``match`` that a type checker +# rewards must not be left with an arm that can never run and that mypy will +# not let them delete. +# +# **Revocation is deliberately absent, and it is not a version problem.** +# ``revoke`` is a ``DEL`` of the whole key, and ``DEL`` emits no subkey +# notification at any Redis version - it is not among the commands that do, +# and the mechanism forbids it structurally, because a subkey event is +# published only when at least one subkey is present and a deleted key has +# none left to name. Observing a revocation needs a second subscription to +# the key-level ``__keyevent@__:del`` under different flags, which Section +# 13.4 declined to build. If that tier is ever added, widening this union is +# the ordinary cost of widening any union - smaller than shipping a member +# nothing can produce. +Cause = Literal["idle", "absolute"] + +# Exported, so a caller under mypy --strict can name the type of the callable +# ``on_session_end`` requires them to pass. Handler = Callable[[str, Cause], Awaitable[None]] # Subkey notifications arrived in Redis 8.8. There is no key-level tier here @@ -60,11 +80,22 @@ # The channel that names both the key and the field. _CHANNEL = "__subkeyevent@{db}__:hexpired" -# Flags that must be present in ``notify-keyspace-events``. The four subkey -# channels are S, T, I and V, and they are **independent of K and E** - -# enabling standard keyspace notifications does not enable these, and the -# reverse holds too. This is the commonest configuration mistake. -_SUBKEY_FLAGS = frozenset("STIV") +# Flags that must be present in ``notify-keyspace-events``. +# +# Redis 8.8 adds four subkey channels with a flag each - S for +# ``__subkeyspace@``, T for ``__subkeyevent@``, I for ``__subkeyspaceitem@`` +# and V for ``__subkeyspaceevent@`` - and all four are **independent of K and +# E**: enabling standard keyspace notifications does not enable these, and the +# reverse holds too. That is the commonest configuration mistake. +# +# **Only T counts here, not any of the four.** This module subscribes to +# ``__subkeyevent@__:hexpired`` and nothing else, so a server with S, I or +# V but no T publishes to channels nobody is listening on. Accepting any of +# the four made ``tier`` report ``"field"`` on such a server while no event +# could ever arrive - which defeats the one check the guide offers against +# exactly that ("if prompt closure matters, check ``events.tier``"). Redis +# accepts a subscription to any channel name, so nothing else notices. +_SUBKEY_FLAG = "T" _HASH_FLAG = "h" REQUIRED_CONFIG = "Th" @@ -79,7 +110,7 @@ class SessionEvents: events = SessionEvents(redis, key_prefix="redis:fastapi") @events.on_session_end - async def _(session_id: str, cause: str) -> None: + async def _(session_id: str, cause: Cause) -> None: await close_sockets_for(session_id) await events.start() @@ -111,7 +142,11 @@ def on_session_end(self, handler: Handler) -> Handler: The handler receives the session ID and the cause: ``"idle"`` when the user went quiet, ``"absolute"`` when the deadline passed however active they were. Distinguishing the two is the whole reason this - needs Redis 8.8. + needs Redis 8.8, and they are the only two values - a revocation is a + ``DEL``, which publishes no subkey event. See :data:`Cause`. + + Returns: + *handler*, so this works as a decorator. """ self._handlers.append(handler) return handler @@ -163,13 +198,15 @@ async def _version_ok(self) -> bool: async def _config_ok(self) -> bool: config = await self._redis.config_get("notify-keyspace-events") flags = str(config.get("notify-keyspace-events", "")) - if not (set(flags) & _SUBKEY_FLAGS) or _HASH_FLAG not in flags: + if _SUBKEY_FLAG not in flags or _HASH_FLAG not in flags: logger.warning( "Session events need notify-keyspace-events to include '%s' " - "(a subkey channel plus hash events); this server has %r. " - "Handlers will not fire. Note that the subkey flags S/T/I/V " - "are independent of K and E. This library will not set the " - "option for you: it is server-wide and affects every other " + "(the __subkeyevent@ channel plus hash events); this server " + "has %r. Handlers will not fire. Note that 'T' specifically: " + "S, I and V enable the other three subkey channels, which " + "this module does not subscribe to, and all four are " + "independent of K and E. This library will not set the option " + "for you: it is server-wide and affects every other " "application on the instance.", REQUIRED_CONFIG, flags, diff --git a/src/redis_fastapi/sessions.py b/src/redis_fastapi/sessions.py index c9c6588..fedcf28 100644 --- a/src/redis_fastapi/sessions.py +++ b/src/redis_fastapi/sessions.py @@ -387,7 +387,21 @@ def decide_outcome(signals: _Signals) -> Outcome: if not signals.accessed: return Outcome.NOTHING - if signals.modified or signals.always_save: + # ``always_save`` is qualified by ``not empty``, and the qualifier is the + # whole difference between a setting and a footgun. ``accessed`` is set + # by *reading* - including Starlette's own ``mark_accessed()`` when a + # handler touches ``request.session`` at all - so the unqualified test + # wrote a key and set a cookie for every anonymous visitor to any route + # that so much as asked ``session.get("user_id")``. On a public page that + # is one Redis key per crawler, per health check, per preflight, held for + # ``gc_ttl``. + # + # Nothing is lost that the setting exists for. Its purpose is nested + # mutation - ``session["a"]["b"] = 1``, which no ``dict`` subclass can see + # - and that implies a top-level key already holding the nested value, so + # the session is not empty. What it no longer does is create a session + # out of an empty one, which no nested mutation could have produced. + if signals.modified or (signals.always_save and not signals.empty): return Outcome.WRITE # The load was a plain read under this setting, so this is the only place diff --git a/src/redis_fastapi/telemetry.py b/src/redis_fastapi/telemetry.py index 960eb55..8765158 100644 --- a/src/redis_fastapi/telemetry.py +++ b/src/redis_fastapi/telemetry.py @@ -1,9 +1,9 @@ -"""OpenTelemetry instrumentation for fastapi-redis-sdk cache operations. +"""OpenTelemetry instrumentation for fastapi-redis-sdk. -Provides spans and metrics for cache(), cache_evict(), cache_put(), -and CacheBackend operations. All OTel imports are guarded - when the -``opentelemetry`` packages are not installed every helper is a silent -no-op. +Provides spans and metrics for cache(), cache_evict(), cache_put() and +CacheBackend operations, for rate-limit checks, and for the session store. +All OTel imports are guarded - when the ``opentelemetry`` packages are not +installed every helper is a silent no-op. Enable via:: @@ -346,9 +346,22 @@ def session_span( def record_session_operation(*, operation: str, result: str) -> None: """Count a session operation. + The label sets below are exhaustive, and they are the emitted ones rather + than the intended ones: a dashboard filtering on a label the store never + sends shows a permanently empty series, which reads as "nothing is + happening" instead of "nothing is measured". + Args: - operation: load, save, touch, rotate, revoke, revoke_all, list, count. - result: hit, miss, expired or error. + operation: ``load``, ``create``, ``save``, ``touch``, ``rotate``, + ``revoke``, ``revoke_id``, ``revoke_all``, ``list`` or ``count``. + ``create`` and ``save`` are separate because a create is a new + session - the sign-in rate - while a save updates an existing one. + ``delete`` and ``index`` are deliberately absent: both are always + part of one of the above and counting them would double-count it. + result: ``hit``, ``miss``, ``expired`` or ``error``. ``miss`` means + there was nothing to do - no such session, an empty index, an + identifier that is not this subject's. ``expired`` is emitted by + ``load`` alone. """ if not _state.enabled or _state.session_operations is None: return @@ -372,7 +385,9 @@ def record_session_event(*, cause: str, result: str) -> None: """Count a session-end notification. Args: - cause: idle, absolute or revoked. + cause: ``idle`` or ``absolute``. There is no third value: a + revocation is a ``DEL``, which publishes no subkey notification, + so no handler is ever called with one. result: delivered or dropped. """ if not _state.enabled or _state.session_events is None: diff --git a/tests/conftest.py b/tests/conftest.py index cd601ba..e634f49 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -63,6 +63,24 @@ def _is_redis_available() -> bool: ) +def about(actual: int, expected: int) -> bool: + """TTL equality, allowing for a second boundary crossing mid-test. + + Redis counts down in whole seconds, so a TTL set to N reads back as N or + N-1 depending on where the call landed. Asserting equality makes the + suite flaky for no gain; the guarantees under test are all about which + clock moved, not about sub-second precision. + + **Use this, not ``<= N``, for "the clock did not move".** ``HTTL`` + returns ``-2`` for a missing field and ``-1`` for a field with no expiry, + and both satisfy ``<= N``. A one-sided bound is therefore happiest of all + about the strongest possible failure - the field being deleted outright - + and it also passes when the deadline was *shortened*, which for the + absolute clock is as wrong as extending it. + """ + return expected - 1 <= actual <= expected + + # --------------------------------------------------------------------------- # Real Redis fixtures (integration) # --------------------------------------------------------------------------- diff --git a/tests/integration/test_session_integration.py b/tests/integration/test_session_integration.py index 0477e37..d10de9b 100644 --- a/tests/integration/test_session_integration.py +++ b/tests/integration/test_session_integration.py @@ -14,9 +14,11 @@ FIELD_ABSOLUTE, FIELD_DATA, RedisSessionStore, + _StoreCapabilities, probe_hsetex_support, ) -from tests.conftest import requires_redis +from redis_fastapi.session_events import REQUIRED_CONFIG, SessionEvents +from tests.conftest import about, requires_redis pytestmark = [pytest.mark.integration, requires_redis, pytest.mark.asyncio] @@ -27,6 +29,11 @@ def _store(redis, prefix: str, **kwargs) -> RedisSessionStore: return RedisSessionStore(redis, key_prefix=prefix, **kwargs) +async def _httl(redis, key: str, field: str) -> int: + reply = await redis.execute_command("HTTL", key, "FIELDS", 1, field) + return int(reply[0]) + + async def test_capability_probe_answers_definitively( real_async_redis: async_redis.Redis, ) -> None: @@ -34,6 +41,119 @@ async def test_capability_probe_answers_definitively( assert await probe_hsetex_support(real_async_redis) in {True, False} +async def test_the_probe_matches_the_server( + real_async_redis: async_redis.Redis, +) -> None: + """The answer, not merely that there is one. + + ``in {True, False}`` cannot fail for anything that returns a bool - + including ``return False`` unconditionally, which is the answer that + routes every operation down the 7.4 fallback. Nothing else in either + suite would have noticed: ``fakeredis`` implements both tiers, so the + unit suite is green either way, and this file is the only place a real + server's answer is involved. + """ + info = await real_async_redis.info("server") + raw = str(info["redis_version"]) + major, minor = (int(part) for part in raw.split(".")[:2]) + expected = (major, minor) >= (8, 0) + assert await probe_hsetex_support(real_async_redis) is expected, ( + f"the probe disagrees with the server it is probing ({raw})" + ) + + +@pytest.mark.parametrize("supports_hsetex", [True, False]) +async def test_both_command_tiers_write_the_same_fields( + real_async_redis: async_redis.Redis, test_prefix: str, supports_hsetex: bool +) -> None: + """Both tiers, against a real server, on the same assertions. + + The two paths are not the same commands. The 8.0 path sends + ``HSETEX key FNX EX n FIELDS 1 a 1``; the 7.4 path sends ``HSETNX`` then + ``HEXPIRE key n NX FIELDS 1 a``. Argument order, the ``FNX``/``NX`` + semantics and the ``FIELDS numfields`` framing all differ, and + ``fakeredis``'s argument parser is order-insensitive - so it accepts an + option order a real server rejects. + + Forcing the fallback on an 8.x server is legitimate: the 7.4 commands + still work there, and it is the only way to exercise that tier without + provisioning a 7.4 instance. + """ + if supports_hsetex and not await probe_hsetex_support(real_async_redis): + pytest.skip("server has no HSETEX; the 8.0 tier cannot be forced on") + + store = _store( + real_async_redis, + test_prefix, + capabilities=_StoreCapabilities(supports_hsetex=supports_hsetex), + ) + key = store.session_key(sid := store.new_id()) + + await store.create(sid, store.new_record({"user_id": 42})) + assert about(await _httl(real_async_redis, key, FIELD_ABSOLUTE), 600) + assert about(await _httl(real_async_redis, key, FIELD_DATA), 60) + + loaded = await store.load(sid) + assert loaded is not None + assert loaded.record.data == {"user_id": 42} + assert 0 < loaded.absolute_remaining <= 600 + + # Age the absolute clock, then update: the deadline must not move, and + # the idle clock must be reapplied. Both are properties of the tier. + await real_async_redis.execute_command( + "HEXPIRE", key, 100, "FIELDS", 1, FIELD_ABSOLUTE + ) + await store.save(sid, store.new_record({"user_id": 42, "n": 1})) + assert about(await _httl(real_async_redis, key, FIELD_ABSOLUTE), 100), ( + "an update moved the absolute deadline" + ) + assert about(await _httl(real_async_redis, key, FIELD_DATA), 60), ( + "an update did not reapply the idle clock" + ) + + await store.touch(sid) + assert about(await _httl(real_async_redis, key, FIELD_ABSOLUTE), 100) + assert about(await _httl(real_async_redis, key, FIELD_DATA), 60) + + +@pytest.mark.parametrize("supports_hsetex", [True, False]) +async def test_both_index_tiers_expire_the_entry( + real_async_redis: async_redis.Redis, test_prefix: str, supports_hsetex: bool +) -> None: + """``_index_add``'s fallback had no test in either suite. + + Unlike the session write it uses no ``NX``, so nothing about it followed + from the session-path tests. The entry carries the *remainder* of the + absolute clock, never the full lifetime - a full lifetime would restart + the entry's clock on every write and let the index outlive the session it + names, which is unrevocable rather than merely untidy. + """ + if supports_hsetex and not await probe_hsetex_support(real_async_redis): + pytest.skip("server has no HSETEX; the 8.0 tier cannot be forced on") + + store = _store( + real_async_redis, + test_prefix, + capabilities=_StoreCapabilities(supports_hsetex=supports_hsetex), + ) + sid = store.new_id() + record = store.new_record({"user_id": "42"}) + await store.create(sid, record) + await store.index("42", sid, record, absolute_remaining=90) + + index_key = store.index_key("42") + assert about(await _httl(real_async_redis, index_key, sid), 90) + + # Re-assert with a smaller remainder, as a later write would. + await store.index("42", sid, record, absolute_remaining=80) + assert about(await _httl(real_async_redis, index_key, sid), 80), ( + "the entry's clock is not the remainder it was re-asserted with" + ) + + assert [info.session_id for info in await store.list_for_subject("42")] == [sid] + assert await store.count_for_subject("42") == 1 + + async def test_round_trip( real_async_redis: async_redis.Redis, test_prefix: str ) -> None: @@ -137,7 +257,13 @@ async def test_writing_the_payload_leaves_the_deadline_alone( await real_async_redis.execute_command("HTTL", key, "FIELDS", 1, FIELD_ABSOLUTE) )[0] - assert int(after) <= int(before) + # A band, not a ceiling: HTTL returns -2 for a deleted field and -1 for + # one with no expiry, and both satisfy ``after <= before``. Five real + # round trips can cross a second boundary, so allow a little slack below. + assert int(after) > 0, "the absolute deadline was deleted or unset" + assert int(before) - 5 <= int(after) <= int(before), ( + "field 'a' moved on a payload write" + ) idle = ( await real_async_redis.execute_command("HTTL", key, "FIELDS", 1, FIELD_DATA) )[0] @@ -159,3 +285,69 @@ async def test_revoke_all_ends_every_session( assert await store.revoke_all("42") == 3 for sid in ids: assert await store.load(sid) is None + + +async def test_a_field_expiry_reaches_a_handler( + real_async_redis: async_redis.Redis, test_prefix: str +) -> None: + """The one thing a scripted Pub/Sub cannot prove: the channel is real. + + ``test_session_events.py`` drives ``_run`` deterministically against a + fake, which covers the subscribe, the frame filtering and the dispatch. + What it cannot check is that ``__subkeyevent@__:hexpired`` is the name + a real server publishes on, or that the payload really arrives in the + documented length-prefixed shape. + + **Needs Redis 8.8.** Subkey notifications arrived there, and earlier + servers reject the ``T`` flag outright - 8.7 answers ``CONFIG SET`` with + "Invalid event class character. Use 'Ag$lshzxeKEtmdnocr'". So this + configures the server itself and skips when that fails, rather than + requiring a CI service-container flag: passing ``--notify-keyspace-events + Th`` to the 7.4 leg of the matrix would stop that container booting at + all. + + ``notify-keyspace-events`` is server-wide, so the original value is + restored afterwards. The library never sets it; a test on a throwaway + server may. + """ + original = (await real_async_redis.config_get("notify-keyspace-events")).get( + "notify-keyspace-events", "" + ) + try: + try: + await real_async_redis.config_set("notify-keyspace-events", REQUIRED_CONFIG) + except async_redis.RedisError as exc: + pytest.skip(f"server will not take {REQUIRED_CONFIG!r}: {exc}") + + events = SessionEvents(real_async_redis, key_prefix=test_prefix) + if await events.probe() == "none": + pytest.skip("server cannot supply subkey notifications") + + delivered: asyncio.Queue = asyncio.Queue() + + @events.on_session_end + async def _(session_id: str, cause: str) -> None: + await delivered.put((session_id, cause)) + + await events.start() + try: + # A one-second idle clock, so the idle field expires on its own. + store = _store(real_async_redis, test_prefix, idle_ttl=1, absolute_ttl=600) + sid = store.new_id() + await store.create(sid, store.new_record({"user_id": 42})) + + # Expiry notifications fire when Redis removes the field, which + # for a field nobody touches waits on the active-expiry cycle. A + # read after the deadline forces the lazy path, so this does not + # depend on that cycle's timing. + await asyncio.sleep(1.5) + assert await store.load(sid) is None + + session_id, cause = await asyncio.wait_for(delivered.get(), timeout=10) + finally: + await events.stop() + + assert session_id == sid + assert cause == "idle" + finally: + await real_async_redis.config_set("notify-keyspace-events", original) diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py index 80c7a8c..0caef01 100644 --- a/tests/unit/test_config.py +++ b/tests/unit/test_config.py @@ -7,6 +7,7 @@ from unittest.mock import patch import pytest +from pydantic import ValidationError from redis_fastapi.config import DRIVER_INFO, RedisSettings @@ -170,6 +171,114 @@ def test_pool_values_included(self) -> None: @pytest.mark.unit +class TestPrincipalKeysFromTheEnvironment: + """``REDIS_SESSION_PRINCIPAL_KEYS=user_id,role`` must work. + + It used to be a hard startup failure. ``session_principal_keys`` is the + only list-typed field in the package, so pydantic-settings called + ``json.loads`` on the raw string and raised ``SettingsError`` - not a + fallback, not a warning: the application did not start. Comma-separated is + what an operator types, and what Django, Rails and Spring Boot all accept + for the same kind of setting. + """ + + def _keys(self, raw: str) -> list[str]: + with patch.dict(os.environ, {"REDIS_SESSION_PRINCIPAL_KEYS": raw}, clear=True): + return RedisSettings().session_principal_keys + + def test_a_comma_separated_list(self) -> None: + assert self._keys("user_id,role") == ["user_id", "role"] + + def test_a_single_key_needs_no_punctuation(self) -> None: + assert self._keys("user_id") == ["user_id"] + + def test_whitespace_and_empty_parts_are_tidied(self) -> None: + """A trailing comma is a typo, not a key named ``""``.""" + assert self._keys(" user_id , role ,, scopes ") == [ + "user_id", + "role", + "scopes", + ] + + def test_a_json_array_still_works(self) -> None: + """The old form stays valid, so no existing configuration breaks.""" + assert self._keys('["user_id","role"]') == ["user_id", "role"] + + def test_malformed_json_says_what_to_do_instead(self) -> None: + with pytest.raises(ValidationError, match="comma-separated is simpler"): + self._keys('["user_id",') + + def test_the_default_survives_an_unset_variable(self) -> None: + with patch.dict(os.environ, {}, clear=True): + assert RedisSettings().session_principal_keys == ["user_id"] + + def test_a_list_passed_in_python_is_untouched(self) -> None: + settings = RedisSettings(session_principal_keys=["a", "b"]) + assert settings.session_principal_keys == ["a", "b"] + + +class TestPrincipalKeysRefusesEmpty: + """An empty list would silently disable rotation altogether. + + ``_default_principal_of`` over no keys returns the same sentinel on every + request, so no sign-in and no privilege change is ever detected - the + fixation the feature exists to prevent. Before the comma split a blank + value could not get through, because ``json.loads("")`` fails and the + application does not start. Splitting one yields ``[]``, so the refusal + has to be explicit or the convenience would open the hole. + """ + + @pytest.mark.parametrize("raw", ["", " ", ",", ",,,", " , , "]) + def test_a_blank_or_comma_only_value_is_refused(self, raw: str) -> None: + with patch.dict(os.environ, {"REDIS_SESSION_PRINCIPAL_KEYS": raw}, clear=True): + with pytest.raises(ValidationError, match="at least one session key"): + RedisSettings() + + def test_an_explicit_empty_json_array_is_refused_too(self) -> None: + """Reachable before this change, and just as broken then.""" + with patch.dict(os.environ, {"REDIS_SESSION_PRINCIPAL_KEYS": "[]"}, clear=True): + with pytest.raises(ValidationError, match="at least one session key"): + RedisSettings() + + def test_an_empty_list_in_python_is_refused_too(self) -> None: + with pytest.raises(ValidationError, match="at least one session key"): + RedisSettings(session_principal_keys=[]) + + def test_the_message_names_the_way_out(self) -> None: + """Whoever wants no key-based rotation passes their own function.""" + with pytest.raises(ValidationError, match="principal_of"): + RedisSettings(session_principal_keys=[]) + + +class TestTheTtlConvention: + """Settings take `int` seconds; runtime Python calls take either. + + The design's §9 table said `int | timedelta` for these three fields while + the code said `int`, and nothing here pinned either side. The code was + right and the table is now corrected, so these tests exist to stop the + fields being widened to "match" a document that no longer says that. + + The reason the fields cannot sensibly be widened is the second test: from + the environment every value is a string, and pydantic reads `"1800"` as + 1800 seconds but `"PT30M"` as ISO-8601 - one field with two syntaxes, + where the operator-friendly one is the plain integer. + """ + + def test_the_three_session_ttls_are_integer_seconds(self) -> None: + for field in ("session_idle_ttl", "session_absolute_ttl", "session_gc_ttl"): + annotation = RedisSettings.model_fields[field].annotation + assert annotation is int, f"{field} is {annotation}, not int" + + def test_a_duration_string_is_refused_rather_than_guessed(self) -> None: + with patch.dict(os.environ, {"REDIS_SESSION_IDLE_TTL": "PT30M"}, clear=True): + with pytest.raises(ValidationError): + RedisSettings() + + def test_seconds_from_the_environment_are_read_as_seconds(self) -> None: + with patch.dict(os.environ, {"REDIS_SESSION_IDLE_TTL": "1800"}, clear=True): + assert RedisSettings().session_idle_ttl == 1800 + + class TestFromEnv: def test_defaults_no_env(self) -> None: with patch.dict(os.environ, {}, clear=True): diff --git a/tests/unit/test_otel_sessions.py b/tests/unit/test_otel_sessions.py new file mode 100644 index 0000000..2072c51 --- /dev/null +++ b/tests/unit/test_otel_sessions.py @@ -0,0 +1,337 @@ +"""OTel coverage of the session store — F-18, "every store operation". + +Before this file the store emitted spans and metrics for ``load`` and ``save`` +only, while ``telemetry.record_session_operation`` documented eight labels. +Half of them were never sent, so a dashboard filtering on them showed a +permanently empty series — which reads as "nothing is happening" rather than +"nothing is measured". Nothing in the suite noticed, because nothing asserted +on the emitted set. + +These tests assert the set, not a sample of it: the documented labels and the +emitted labels are compared as whole sets, so adding a label to the docstring +without emitting it fails here, and so does the reverse. +""" + +from __future__ import annotations + +import pytest +from opentelemetry.sdk.trace import TracerProvider +from opentelemetry.sdk.trace.export import ( + SimpleSpanProcessor, + SpanExporter, + SpanExportResult, +) + +import redis_fastapi.telemetry as tel +from redis_fastapi.config import get_settings +from redis_fastapi.exceptions import SessionStoreError +from redis_fastapi.session_backend import RedisSessionStore, SessionState + +# Every operation the store instruments, and how. Kept here as data because +# the point of these tests is the set, not any one member of it. +COUNTED = { + "load", + "create", + "save", + "touch", + "rotate", + "revoke", + "revoke_id", + "revoke_all", + "list", + "count", +} +# ``touch`` is one command: a counter confirms the setting does something, +# and a span would cost more than it tells anyone. +TIMED = COUNTED - {"touch"} +# Always part of another operation. Instrumenting them double-counts it. +NEVER = {"delete", "index"} + + +class _Recorder: + """Stands in for a counter and a histogram at once.""" + + def __init__(self) -> None: + self.labels: list[dict] = [] + + def add(self, amount, attributes=None) -> None: + self.labels.append(dict(attributes or {})) + + def record(self, value, attributes=None) -> None: + self.labels.append(dict(attributes or {})) + + def operations(self) -> set[str]: + return {row["operation"] for row in self.labels} + + def results(self, operation: str) -> list[str]: + return [ + row["result"] + for row in self.labels + if row["operation"] == operation and "result" in row + ] + + +@pytest.fixture() +def metrics(monkeypatch) -> tuple[_Recorder, _Recorder]: + """Enable telemetry with recording instruments in place of real ones.""" + operations, latency = _Recorder(), _Recorder() + monkeypatch.setattr(tel._state, "enabled", True) + monkeypatch.setattr(tel._state, "session_operations", operations) + monkeypatch.setattr(tel._state, "session_latency", latency) + return operations, latency + + +class _Exporter(SpanExporter): + def __init__(self) -> None: + self.spans: list = [] + + def export(self, spans): # type: ignore[override] + self.spans.extend(spans) + return SpanExportResult.SUCCESS + + def shutdown(self) -> None: + pass + + +@pytest.fixture() +def spans() -> _Exporter: + """A dedicated in-memory tracer, bypassing the set-once global provider.""" + exporter = _Exporter() + provider = TracerProvider() + provider.add_span_processor(SimpleSpanProcessor(exporter)) + original = tel._state + tel.disable_telemetry() + tel._state.tracer = provider.get_tracer(tel.TRACER_NAME) + tel._state.enabled = True + try: + yield exporter + finally: + tel._state = original + + +@pytest.fixture() +def store(fake_async_redis) -> RedisSessionStore: + get_settings.cache_clear() + return RedisSessionStore(fake_async_redis, idle_ttl=60, absolute_ttl=600) + + +async def _exercise_every_operation(store: RedisSessionStore) -> None: + """Call each of the ten instrumented operations exactly once.""" + record = store.new_record({"user_id": "42"}) + + first = store.new_id() + await store.create(first, record) + await store.load(first) + await store.save(first, record) + await store.touch(first) + await store.index("42", first, record, absolute_remaining=600) + + await store.list_for_subject("42") + await store.count_for_subject("42") + + rotated = SessionState(data={"user_id": "42"}, session_id=first, subject="42") + await store.rotate(rotated, subject="42") + await store.revoke_id(rotated.session_id or "", subject="42") + + second = store.new_id() + await store.create(second, record) + await store.index("42", second, record, absolute_remaining=600) + await store.revoke(SessionState(data={}, session_id=second, subject="42")) + + third = store.new_id() + await store.create(third, record) + await store.index("42", third, record, absolute_remaining=600) + await store.revoke_all("42") + + +@pytest.mark.unit +class TestEveryOperationIsCounted: + async def test_the_emitted_labels_are_exactly_the_documented_ones( + self, store: RedisSessionStore, metrics + ) -> None: + operations, _ = metrics + await _exercise_every_operation(store) + assert operations.operations() == COUNTED + + async def test_the_docstring_lists_what_is_emitted(self) -> None: + """The finding was a docstring promising labels nothing sent.""" + documented = tel.record_session_operation.__doc__ or "" + for operation in COUNTED: + assert f"``{operation}``" in documented, f"{operation} is undocumented" + for operation in NEVER: + assert f"``{operation}`` and" in documented or ( + f"and ``{operation}``" in documented + ), f"{operation} should be documented as deliberately absent" + + async def test_latency_covers_every_operation_but_touch( + self, store: RedisSessionStore, metrics + ) -> None: + _, latency = metrics + await _exercise_every_operation(store) + assert latency.operations() == TIMED + assert "touch" not in latency.operations() + + +@pytest.mark.unit +class TestCreateAndSaveReportSeparately: + """A create is the sign-in rate; a save is an update of one that exists. + + They shared ``_save`` and therefore shared the ``save`` label, which + understated create and polluted save. + """ + + async def test_a_create_is_not_counted_as_a_save( + self, store: RedisSessionStore, metrics + ) -> None: + operations, latency = metrics + sid = store.new_id() + await store.create(sid, store.new_record({"user_id": "42"})) + assert operations.operations() == {"create"} + assert latency.operations() == {"create"} + + async def test_a_save_is_not_counted_as_a_create( + self, store: RedisSessionStore, metrics + ) -> None: + operations, _ = metrics + sid = store.new_id() + record = store.new_record({"user_id": "42"}) + await store.create(sid, record) + operations.labels.clear() + await store.save(sid, record) + assert operations.operations() == {"save"} + + async def test_a_rotation_reports_its_own_create( + self, store: RedisSessionStore, metrics + ) -> None: + """The §5.1 backstop needs both: sign-ins with no rotations is the + anomaly, so creates and rotations must be separately visible.""" + operations, _ = metrics + sid = store.new_id() + record = store.new_record({"user_id": "42"}) + await store.create(sid, record) + operations.labels.clear() + await store.rotate(SessionState(data={"user_id": "42"}, session_id=sid)) + assert operations.operations() == {"rotate", "create"} + + +@pytest.mark.unit +class TestOutcomeLabels: + async def test_a_refused_revoke_id_is_a_miss_not_a_hit( + self, store: RedisSessionStore, metrics + ) -> None: + """A run of these is a broken UI or somebody probing identifiers.""" + operations, _ = metrics + assert await store.revoke_id(store.new_id(), subject="42") is False + assert operations.results("revoke_id") == ["miss"] + + async def test_a_malformed_id_never_reaches_redis_and_still_counts( + self, store: RedisSessionStore, metrics + ) -> None: + operations, latency = metrics + assert await store.revoke_id("not-an-id", subject="42") is False + assert operations.results("revoke_id") == ["miss"] + assert latency.operations() == set(), "no round trip, no latency" + + async def test_revoking_a_session_never_written_is_a_miss( + self, store: RedisSessionStore, metrics + ) -> None: + operations, _ = metrics + await store.revoke(SessionState(data={})) + assert operations.results("revoke") == ["miss"] + + async def test_an_empty_listing_and_count_are_a_miss_and_a_hit( + self, store: RedisSessionStore, metrics + ) -> None: + operations, _ = metrics + assert await store.list_for_subject("nobody") == [] + assert await store.count_for_subject("nobody") == 0 + assert operations.results("list") == ["miss"] + assert operations.results("count") == ["hit"], "zero is an answer" + + async def test_a_failed_composite_counts_itself_and_the_inner_failure( + self, store: RedisSessionStore, metrics, monkeypatch + ) -> None: + """Both series matter: which operation the user lost, and where.""" + operations, _ = metrics + + async def _fail(*args, **kwargs): + raise ConnectionError("down") + + monkeypatch.setattr(store, "_write", _fail) + with pytest.raises(SessionStoreError): + await store.rotate(SessionState(data={"user_id": "42"})) + assert operations.results("create") == ["error"] + assert operations.results("rotate") == ["error"] + + +@pytest.mark.unit +class TestSpans: + async def test_every_timed_operation_opens_a_span( + self, store: RedisSessionStore, spans: _Exporter + ) -> None: + await _exercise_every_operation(store) + opened = {span.name for span in spans.spans} + assert opened == {f"session.{operation}" for operation in TIMED} + + async def test_no_span_carries_an_identifier( + self, store: RedisSessionStore, spans: _Exporter + ) -> None: + """§10: a session ID is a bearer credential and a subject is a user ID. + + Neither may reach a trace, which leaves the vendor's storage far + outside this store's threat model. + """ + await _exercise_every_operation(store) + for span in spans.spans: + assert not span.attributes, f"{span.name} carried {dict(span.attributes)}" + + async def test_a_rotation_nests_its_create( + self, store: RedisSessionStore, spans: _Exporter + ) -> None: + """The rotate span measures all four round trips, the create span one.""" + sid = store.new_id() + await store.create(sid, store.new_record({"user_id": "42"})) + await store.rotate(SessionState(data={"user_id": "42"}, session_id=sid)) + + rotate = next(s for s in spans.spans if s.name == "session.rotate") + nested = [ + s + for s in spans.spans + if s.name == "session.create" + and s.parent is not None + and s.parent.span_id == rotate.context.span_id + ] + assert nested, "the create inside a rotation was not a child of it" + + +@pytest.mark.unit +class TestUninstrumentedOnPurpose: + async def test_delete_and_index_emit_nothing_of_their_own( + self, store: RedisSessionStore, metrics, spans: _Exporter + ) -> None: + """Both are always part of another operation, already inside its span.""" + operations, latency = metrics + sid = store.new_id() + record = store.new_record({"user_id": "42"}) + await store.create(sid, record) + operations.labels.clear() + latency.labels.clear() + spans.spans.clear() + + await store.index("42", sid, record, absolute_remaining=600) + await store.delete(sid) + + assert operations.operations() == set() + assert latency.operations() == set() + assert {s.name for s in spans.spans} == set() + + async def test_the_pure_helpers_emit_nothing( + self, store: RedisSessionStore, metrics + ) -> None: + operations, latency = metrics + record = store.new_record({"user_id": "42"}) + store.is_valid_id(store.new_id()) + store.decode(store.encode(record)) + store.session_id(SessionState(data={})) + assert operations.labels == [] + assert latency.labels == [] diff --git a/tests/unit/test_session_backend.py b/tests/unit/test_session_backend.py index c0522cd..46ce246 100644 --- a/tests/unit/test_session_backend.py +++ b/tests/unit/test_session_backend.py @@ -22,6 +22,7 @@ SessionRecord, _StoreCapabilities, ) +from tests.conftest import about @pytest.fixture() @@ -35,17 +36,6 @@ async def _httl(redis, key: str, field: str) -> int: return int(reply[0]) -def _about(actual: int, expected: int) -> bool: - """TTL equality, allowing for a second boundary crossing mid-test. - - Redis counts down in whole seconds, so a TTL set to N reads back as N or - N-1 depending on where the call landed. Asserting equality makes the - suite flaky for no gain; the guarantees under test are all about which - clock moved, not about sub-second precision. - """ - return expected - 1 <= actual <= expected - - class TestKeySchema: def test_the_two_prefixes_cannot_collide(self, store: RedisSessionStore) -> None: """Structural, not conventional. @@ -97,8 +87,8 @@ async def test_save_writes_both_fields_with_their_own_ttls( await store.create(sid, store.new_record({"user_id": 42})) key = store.session_key(sid) - assert _about(await _httl(fake_async_redis, key, FIELD_ABSOLUTE), 600) - assert _about(await _httl(fake_async_redis, key, FIELD_DATA), 60) + assert about(await _httl(fake_async_redis, key, FIELD_ABSOLUTE), 600) + assert about(await _httl(fake_async_redis, key, FIELD_DATA), 60) async def test_writing_the_payload_never_extends_the_deadline( self, store: RedisSessionStore, fake_async_redis @@ -120,11 +110,11 @@ async def test_writing_the_payload_never_extends_the_deadline( for n in range(5): await store.save(sid, store.new_record({"n": n})) - assert await _httl(fake_async_redis, key, FIELD_ABSOLUTE) <= 100, ( - "field 'a' was refreshed by a payload write - the absolute " - "deadline is no longer absolute" + assert about(await _httl(fake_async_redis, key, FIELD_ABSOLUTE), 100), ( + "field 'a' moved on a payload write - refreshed, shortened or " + "deleted; the absolute deadline is no longer absolute" ) - assert _about(await _httl(fake_async_redis, key, FIELD_DATA), 60), ( + assert about(await _httl(fake_async_redis, key, FIELD_DATA), 60), ( "field 'd' should have been refreshed by the write" ) @@ -144,8 +134,8 @@ async def test_zero_ttls_fall_back_to_gc_ttl(self, fake_async_redis) -> None: sid = store.new_id() await store.create(sid, store.new_record({})) key = store.session_key(sid) - assert _about(await _httl(fake_async_redis, key, FIELD_ABSOLUTE), 1234) - assert _about(await _httl(fake_async_redis, key, FIELD_DATA), 1234) + assert about(await _httl(fake_async_redis, key, FIELD_ABSOLUTE), 1234) + assert about(await _httl(fake_async_redis, key, FIELD_DATA), 1234) class TestLoadStateTable: @@ -220,7 +210,7 @@ async def test_load_restarts_the_idle_clock( "HEXPIRE", key, 5, "FIELDS", 1, FIELD_DATA ) await store.load(sid) - assert _about(await _httl(fake_async_redis, key, FIELD_DATA), 60) + assert about(await _httl(fake_async_redis, key, FIELD_DATA), 60) async def test_refresh_false_leaves_the_idle_clock_alone( self, store: RedisSessionStore, fake_async_redis @@ -232,7 +222,9 @@ async def test_refresh_false_leaves_the_idle_clock_alone( "HEXPIRE", key, 5, "FIELDS", 1, FIELD_DATA ) await store.load(sid, refresh=False) - assert await _httl(fake_async_redis, key, FIELD_DATA) <= 5 + assert about(await _httl(fake_async_redis, key, FIELD_DATA), 5), ( + "a plain read moved the idle clock" + ) async def test_touch_advances_the_idle_clock( self, store: RedisSessionStore, fake_async_redis @@ -249,7 +241,7 @@ async def test_touch_advances_the_idle_clock( "HEXPIRE", key, 5, "FIELDS", 1, FIELD_DATA ) await store.touch(sid) - assert _about(await _httl(fake_async_redis, key, FIELD_DATA), 60) + assert about(await _httl(fake_async_redis, key, FIELD_DATA), 60) async def test_touch_does_not_extend_the_absolute_clock( self, store: RedisSessionStore, fake_async_redis @@ -261,7 +253,9 @@ async def test_touch_does_not_extend_the_absolute_clock( "HEXPIRE", key, 100, "FIELDS", 1, FIELD_ABSOLUTE ) await store.touch(sid) - assert await _httl(fake_async_redis, key, FIELD_ABSOLUTE) <= 100 + assert about(await _httl(fake_async_redis, key, FIELD_ABSOLUTE), 100), ( + "touch moved the absolute clock" + ) class TestSevenFourFallback: @@ -293,8 +287,8 @@ async def test_same_ttls_as_the_modern_path( sid = old_store.new_id() await old_store.create(sid, old_store.new_record({})) key = old_store.session_key(sid) - assert _about(await _httl(fake_async_redis, key, FIELD_ABSOLUTE), 600) - assert _about(await _httl(fake_async_redis, key, FIELD_DATA), 60) + assert about(await _httl(fake_async_redis, key, FIELD_ABSOLUTE), 600) + assert about(await _httl(fake_async_redis, key, FIELD_DATA), 60) async def test_repeated_writes_still_never_extend_the_deadline( self, old_store: RedisSessionStore, fake_async_redis @@ -306,7 +300,9 @@ async def test_repeated_writes_still_never_extend_the_deadline( "HEXPIRE", key, 100, "FIELDS", 1, FIELD_ABSOLUTE ) await old_store.save(sid, old_store.new_record({"n": 2})) - assert await _httl(fake_async_redis, key, FIELD_ABSOLUTE) <= 100 + assert about(await _httl(fake_async_redis, key, FIELD_ABSOLUTE), 100), ( + "the 7.4 write path moved the absolute clock" + ) class TestEnvelope: diff --git a/tests/unit/test_session_events.py b/tests/unit/test_session_events.py index 49391d4..f2d62bc 100644 --- a/tests/unit/test_session_events.py +++ b/tests/unit/test_session_events.py @@ -7,10 +7,21 @@ from __future__ import annotations +import asyncio +from typing import get_args + import pytest from redis.exceptions import RedisError -from redis_fastapi.session_events import REQUIRED_CONFIG, SessionEvents +from redis_fastapi.session_events import ( + _CHANNEL, + _HASH_FLAG, + _SUBKEY_FLAG, + REQUIRED_CONFIG, + Cause, + Handler, + SessionEvents, +) class _FakeRedis: @@ -55,6 +66,37 @@ async def test_missing_flags_give_none(self, flags: str) -> None: """ assert await _events(_FakeRedis(flags=flags)).probe() == "none" + @pytest.mark.parametrize("flags", ["Sh", "Ih", "Vh", "SIVh", "Sah"]) + async def test_a_subkey_flag_that_is_not_t_gives_none(self, flags: str) -> None: + """Redis 8.8 has four subkey channels; this module listens on one. + + ``S``, ``I`` and ``V`` enable ``__subkeyspace@``, + ``__subkeyspaceitem@`` and ``__subkeyspaceevent@``. Only ``T`` enables + ``__subkeyevent@``, which is where ``listen()`` subscribes. Redis + accepts a subscription to any channel name, so on ``Sh`` the + subscription succeeds and no event ever arrives - and the probe used + to report ``"field"`` for it, which defeats the ``events.tier`` check + the guide offers against precisely this. + """ + assert await _events(_FakeRedis(flags=flags)).probe() == "none" + + @pytest.mark.parametrize("flags", ["Th", "ATh", "KEATh", "hT", "STIVh"]) + async def test_t_plus_the_hash_class_gives_the_field_tier(self, flags: str) -> None: + """Order does not matter, and extra flags are somebody else's business.""" + assert await _events(_FakeRedis(flags=flags)).probe() == "field" + + async def test_the_warning_names_t_specifically(self, caplog) -> None: + """An operator reading it must not conclude that any subkey flag does.""" + with caplog.at_level("WARNING"): + assert await _events(_FakeRedis(flags="Sh")).probe() == "none" + assert "'Th'" in caplog.text + assert "__subkeyevent@" in caplog.text + + def test_the_documented_config_is_the_one_that_is_checked(self) -> None: + """``REQUIRED_CONFIG`` is what the guide tells operators to set.""" + assert REQUIRED_CONFIG == "Th" + assert set(REQUIRED_CONFIG) == {_SUBKEY_FLAG, _HASH_FLAG} + async def test_config_being_forbidden_degrades_rather_than_raising(self) -> None: """Managed Redis routinely restricts or renames CONFIG. @@ -177,3 +219,257 @@ async def working(session_id: str, cause: str) -> None: assert seen == ["abc"], ( "a bad handler took down the subscriber and with it every other handler" ) + + +class TestTheCauseUnionIsInhabited: + """``Cause`` is a ``Literal`` in a public callback signature. + + That makes it a promise about which values a handler can be called with. + It used to include ``"revoked"``, which nothing could produce: ``revoke`` + is a ``DEL``, and ``DEL`` emits no subkey notification at any Redis + version - it is not among the commands that do, and the mechanism forbids + it, because a subkey event is published only when at least one subkey is + present and a deleted key has none left to name. A caller writing the + exhaustive ``match`` a type checker rewards was left with an arm that + could never run and that mypy would not let them delete. + """ + + def test_cause_has_exactly_the_two_values_parse_can_return(self) -> None: + assert set(get_args(Cause)) == {"idle", "absolute"} + + def test_every_member_is_reachable_from_a_real_payload(self) -> None: + """The union and the parser are checked against each other. + + Adding a member without a payload that produces it fails here, which + is the regression that let ``"revoked"`` survive. + """ + events = _events(_FakeRedis()) + key = "redis:fastapi:session:" + "a" * 40 + payloads = { + f"{len(key)}:{key}|1:d": "idle", + f"{len(key)}:{key}|1:a": "absolute", + } + produced = set() + for payload, expected in payloads.items(): + parsed = events._parse(payload) + assert parsed is not None, payload + assert parsed[1] == expected + produced.add(parsed[1]) + assert produced == set(get_args(Cause)), ( + "a Cause member no payload can produce, or a payload the union " + "does not cover" + ) + + def test_a_revocation_produces_no_event_to_parse(self) -> None: + """A ``DEL`` is not an ``hexpired`` with no fields - it is no message. + + Asserted through the parser rather than the wire: a notification whose + field list names neither of this store's two fields is not a session + death, and must not be reported as one. + """ + events = _events(_FakeRedis()) + key = "redis:fastapi:session:" + "a" * 40 + assert events._parse(f"{len(key)}:{key}|") is None + assert events._parse(f"{len(key)}:{key}|5:other") is None + + def test_the_handler_type_is_exported(self) -> None: + """A mypy --strict caller must be able to name what they must pass.""" + import redis_fastapi + + assert "Handler" in redis_fastapi.__all__ + assert redis_fastapi.Handler is Handler + + def test_the_handler_type_refers_to_cause_rather_than_respelling_it( + self, + ) -> None: + """Otherwise the two could drift and only one would be corrected.""" + parameters, _ = get_args(Handler) + assert parameters == [str, Cause] + + +class _FakePubSub: + """Records what was subscribed to, then yields a scripted message stream.""" + + def __init__(self, messages: list[dict]) -> None: + self.messages = messages + self.subscribed: list[str] = [] + self.closed = False + + async def subscribe(self, channel: str) -> None: + self.subscribed.append(channel) + + async def listen(self): + for message in self.messages: + yield message + # A real ``listen()`` blocks for the life of the subscription; end + # here instead so the task completes and the test can assert. + + async def aclose(self) -> None: + self.closed = True + + +class _ScriptedRedis(_FakeRedis): + """A capable, configured server whose Pub/Sub replays fixed messages.""" + + def __init__(self, messages: list[dict]) -> None: + super().__init__() + self.pubsub_obj = _FakePubSub(messages) + + def pubsub(self) -> _FakePubSub: + return self.pubsub_obj + + +def _hexpired(session_id: str, field: str, prefix: str = "redis:fastapi") -> dict: + """One notification in the documented `__subkeyevent@` payload format.""" + key = f"{prefix}:session:{session_id}" + return {"type": "message", "data": f"{len(key)}:{key}|{len(field)}:{field}"} + + +class TestTheDeliveryPath: + """N-14: the documented recipe has to be executable code under test. + + The probe was covered thoroughly and ``_parse`` and ``_dispatch`` were + covered directly, but not the path between them - subscribe, receive a + message, fire a handler. That is where the channel name, the ``pubsub()`` + lifecycle and the message-shape filtering live, so a typo in ``_CHANNEL`` + passed every test in this file. + + Driven through a scripted Pub/Sub rather than a live server: the real + thing needs Redis 8.8 with ``notify-keyspace-events Th``, which is a + timing-dependent integration test (see ``test_session_integration.py``). + The logic is deterministic and belongs here. + """ + + def test_the_channel_matches_the_documented_format(self) -> None: + """The cheap half, and it needs no server at all.""" + assert _CHANNEL.format(db=0) == "__subkeyevent@0__:hexpired" + assert _CHANNEL.format(db=3) == "__subkeyevent@3__:hexpired" + + async def test_it_subscribes_to_the_channel_for_its_own_database(self) -> None: + redis = _ScriptedRedis([]) + events = SessionEvents(redis, key_prefix="redis:fastapi", db=7) + await events.start() + await asyncio.wait_for(events._task, timeout=5) + await events.stop() + assert redis.pubsub_obj.subscribed == ["__subkeyevent@7__:hexpired"] + + async def test_a_field_expiry_reaches_a_handler(self) -> None: + """The whole point of the feature, end to end through ``_run``.""" + sid = "a" * 40 + redis = _ScriptedRedis([_hexpired(sid, "d")]) + events = SessionEvents(redis, key_prefix="redis:fastapi") + + seen: list[tuple[str, str]] = [] + + @events.on_session_end + async def _(session_id: str, cause: Cause) -> None: + seen.append((session_id, cause)) + + await events.start() + assert events.tier == "field" + await asyncio.wait_for(events._task, timeout=5) + await events.stop() + + assert seen == [(sid, "idle")] + + async def test_both_causes_arrive_on_the_one_channel(self) -> None: + first, second = "a" * 40, "b" * 40 + redis = _ScriptedRedis([_hexpired(first, "d"), _hexpired(second, "a")]) + events = SessionEvents(redis, key_prefix="redis:fastapi") + + seen: list[tuple[str, str]] = [] + events.on_session_end( + lambda session_id, cause: _record(seen, session_id, cause) + ) + + await events.start() + await asyncio.wait_for(events._task, timeout=5) + await events.stop() + + assert seen == [(first, "idle"), (second, "absolute")] + + async def test_non_message_frames_are_ignored(self) -> None: + """Only ``type == "message"`` is an event, whatever the frame carries. + + Redis's own ``subscribe`` confirmation carries a subscription count, + which would not parse as a payload anyway - so the frames below carry + a **valid** payload deliberately. Otherwise the assertion holds even + with the type filter deleted, and the filter is what makes the rule + "one channel, one frame type" true rather than incidental. + """ + sid = "a" * 40 + valid = _hexpired(sid, "d")["data"] + redis = _ScriptedRedis( + [ + {"type": "subscribe", "data": 1}, + {"type": "psubscribe", "data": valid}, + {"type": "pmessage", "data": valid}, + _hexpired(sid, "d"), + ] + ) + events = SessionEvents(redis, key_prefix="redis:fastapi") + seen: list[tuple[str, str]] = [] + events.on_session_end( + lambda session_id, cause: _record(seen, session_id, cause) + ) + await events.start() + await asyncio.wait_for(events._task, timeout=5) + await events.stop() + assert seen == [(sid, "idle")] + + async def test_another_applications_hash_is_ignored(self) -> None: + """The channel is server-wide; most traffic on it is not ours.""" + redis = _ScriptedRedis( + [ + _hexpired("x" * 40, "d", prefix="someone-else"), + {"type": "message", "data": "9:other:key|1:d"}, + ] + ) + events = SessionEvents(redis, key_prefix="redis:fastapi") + seen: list[tuple[str, str]] = [] + events.on_session_end( + lambda session_id, cause: _record(seen, session_id, cause) + ) + await events.start() + await asyncio.wait_for(events._task, timeout=5) + await events.stop() + assert seen == [] + + async def test_a_lost_subscription_ends_quietly(self) -> None: + """Nothing downstream depends on this stream, so it must not raise.""" + + class _Dropping(_ScriptedRedis): + def pubsub(self): + raise RedisError("connection reset") + + events = SessionEvents(_Dropping([]), key_prefix="redis:fastapi") + await events.start() + await asyncio.wait_for(events._task, timeout=5) + await events.stop() + + async def test_stop_cancels_a_live_subscription(self) -> None: + """``listen()`` normally never returns, so cancellation is the exit.""" + + class _Blocking(_ScriptedRedis): + def pubsub(self): + pubsub = _FakePubSub([]) + + async def _forever(): + while True: + await asyncio.sleep(0.01) + yield {"type": "subscribe", "data": 1} + + pubsub.listen = _forever # type: ignore[method-assign] + return pubsub + + events = SessionEvents(_Blocking([]), key_prefix="redis:fastapi") + await events.start() + task = events._task + assert task is not None and not task.done() + await events.stop() + assert task.cancelled() or task.done() + assert events._task is None + + +async def _record(sink: list, session_id: str, cause: str) -> None: + sink.append((session_id, cause)) diff --git a/tests/unit/test_session_failures.py b/tests/unit/test_session_failures.py index 2a350d6..c1c917a 100644 --- a/tests/unit/test_session_failures.py +++ b/tests/unit/test_session_failures.py @@ -48,6 +48,9 @@ async def hdel(self, key: str, *fields: str) -> int: async def hgetall(self, key: str) -> dict: raise RedisConnectionError("Redis is down") + async def hlen(self, key: str) -> int: + raise RedisConnectionError("Redis is down") + async def info(self, section: str) -> dict: raise RedisConnectionError("Redis is down") diff --git a/tests/unit/test_session_index.py b/tests/unit/test_session_index.py index 82e530e..2bab5c8 100644 --- a/tests/unit/test_session_index.py +++ b/tests/unit/test_session_index.py @@ -15,6 +15,7 @@ FIELD_DATA, RedisSessionStore, ) +from tests.conftest import about @pytest.fixture() @@ -136,6 +137,37 @@ async def test_below_the_limit_takes_the_fast_path( await _make(store, "42") assert await store.count_for_subject("42", limit=5) == 1 + async def test_the_fast_path_never_transfers_the_descriptors( + self, store: RedisSessionStore + ) -> None: + """Counting is ``HLEN``, not ``HGETALL`` and ``len()``. + + The descriptors hold whatever ``descriptor_of`` returns - the guide + suggests an IP and a user agent - so counting a subject with two + hundred sessions through the members would ship kilobytes to compute + one integer, on every login. + """ + for _ in range(3): + await _make(store, "42", agent="a" * 200) + + async def _refuse(subject: str) -> dict[str, bytes | str]: + raise AssertionError("the fast path read the index members") + + store._index_members = _refuse # type: ignore[method-assign] + assert await store.count_for_subject("42") == 3 + assert await store.count_for_subject("42", limit=9) == 3 + + async def test_the_size_and_the_members_agree( + self, store: RedisSessionStore + ) -> None: + """The two primitives are one upper bound read two ways.""" + for _ in range(4): + await _make(store, "42") + await _make(store, "99") + assert await store._index_size("42") == len(await store._index_members("42")) + assert await store._index_size("99") == 1 + assert await store._index_size("nobody") == 0 + class TestRevokeById: async def test_revokes_a_session_of_the_right_subject( @@ -227,9 +259,10 @@ async def test_re_assertion_never_extends_the_entry( ) # the remainder, not 600 reply = await fake_async_redis.execute_command("HTTL", key, "FIELDS", 1, sid) - assert int(reply[0]) <= 100, ( - "the index entry's clock was restarted - it can now outlive the " - "session it names" + assert about(int(reply[0]), 90), ( + "the entry's clock is not the remainder it was re-asserted with: " + "restarted (600) means it can outlive the session it names, and " + "-2 means the entry was dropped and the session is unrevocable" ) async def test_an_expired_remainder_writes_no_entry( diff --git a/tests/unit/test_session_middleware.py b/tests/unit/test_session_middleware.py index 5220c4c..19c6af8 100644 --- a/tests/unit/test_session_middleware.py +++ b/tests/unit/test_session_middleware.py @@ -369,3 +369,105 @@ def test_the_session_survives_an_explicit_rotation( client.post("/login") client.post("/step-up") assert client.get("/read").json() == {"user_id": 42} + + +class TestAlwaysSaveDoesNotMintAnonymousSessions: + """``session_always_save=True`` used to write a key for every reader. + + ``accessed`` is set by *reading* - Starlette's own ``mark_accessed()`` + fires when a handler touches ``request.session`` at all - and the write + test was ``modified or always_save``. So a public route calling + ``session.get("user_id")`` minted an identifier, a Redis key and a + ``Set-Cookie`` for every crawler, health check and preflight, held for + ``gc_ttl``. Nobody turning on a nested-mutation workaround expects to + start persisting anonymous traffic. + """ + + @pytest.fixture() + def always_save_app(self, fake_async_redis, monkeypatch) -> FastAPI: + get_settings.cache_clear() + monkeypatch.setenv("REDIS_SESSION_COOKIE_HTTPS_ONLY", "false") + monkeypatch.setenv("REDIS_SESSION_ALWAYS_SAVE", "true") + monkeypatch.setenv("REDIS_SESSION_IDLE_TTL", "60") + monkeypatch.setenv("REDIS_SESSION_ABSOLUTE_TTL", "600") + get_settings.cache_clear() + + application = FastAPI() + add_redis_sessions(application) + store = RedisSessionStore(fake_async_redis, idle_ttl=60, absolute_ttl=600) + + async def _store(request: Request) -> RedisSessionStore: + return store + + application.dependency_overrides[get_session_store] = _store + + @application.get("/read") + async def read(session: SessionDep) -> dict: + return {"signed_in": session.get("user_id") is not None} + + @application.post("/login") + async def login(session: SessionDep) -> dict: + session["user_id"] = 42 + return {"ok": True} + + @application.post("/nested") + async def nested(session: SessionDep) -> dict: + """The case the setting exists for: no method of ``Session`` sees it.""" + session["prefs"]["theme"] = "dark" + return {"ok": True} + + application.state._store = store + yield application + get_settings.cache_clear() + + async def test_an_anonymous_read_writes_nothing( + self, always_save_app: FastAPI, fake_async_redis + ) -> None: + with TestClient(always_save_app) as client: + response = client.get("/read") + assert response.json() == {"signed_in": False} + assert "set-cookie" not in response.headers, "an anonymous visitor got an ID" + assert await fake_async_redis.keys("*") == [], "a key was written for nobody" + + async def test_a_crawler_hitting_it_a_hundred_times_leaves_no_keys( + self, always_save_app: FastAPI, fake_async_redis + ) -> None: + """The cost was per unique visitor, so the count is the point.""" + with TestClient(always_save_app) as client: + for _ in range(100): + assert client.get("/read").status_code == 200 + assert await fake_async_redis.keys("*") == [] + + async def test_a_real_sign_in_still_writes(self, always_save_app: FastAPI) -> None: + """The guard must not cost the ordinary path anything.""" + with TestClient(always_save_app) as client: + response = client.post("/login") + assert "session" in _set_cookies(response) + + async def test_a_nested_mutation_still_persists( + self, always_save_app: FastAPI, fake_async_redis + ) -> None: + """What the setting is for, and the reason (a) is safe. + + A nested mutation needs a top-level key already holding the nested + value, so the session is never empty when it happens. + """ + with TestClient(always_save_app) as client: + client.post("/login") + sid = client.cookies["session"] + # Seed a nested value through the ordinary path. + client.post("/login") + store = always_save_app.state._store + loaded = await store.load(sid) + record = store.new_record( + {**loaded.record.data, "prefs": {"theme": "light"}} + ) + await store.save(sid, record) + + assert client.post("/nested").status_code == 200 + + after = await store.load(sid) + assert after is not None + assert after.record.data["prefs"]["theme"] == "dark", ( + "the one fault the setting exists to cover was not written" + ) diff --git a/tests/unit/test_session_outcome.py b/tests/unit/test_session_outcome.py index 77a8a4c..86ff75b 100644 --- a/tests/unit/test_session_outcome.py +++ b/tests/unit/test_session_outcome.py @@ -149,6 +149,36 @@ def test_a_modification_writes(self) -> None: def test_always_save_writes_without_a_modification(self) -> None: assert decide_outcome(_signals(always_save=True)) is Outcome.WRITE + def test_always_save_does_not_write_an_empty_session(self) -> None: + """The anonymous-reader trap, decided here rather than at the store. + + ``accessed`` is set by reading, so without the ``empty`` qualifier + every crawler that reached a route calling ``session.get(...)`` was + minted an identifier, a Redis key and a cookie. + """ + assert decide_outcome(_signals(always_save=True, empty=True)) is Outcome.NOTHING + + def test_always_save_still_touches_an_empty_stored_session(self) -> None: + """The idle clock must not freeze as a side effect of the guard.""" + assert ( + decide_outcome( + _signals( + always_save=True, + empty=True, + stored=True, + refresh_on_load=False, + ) + ) + is Outcome.TOUCH + ) + + def test_always_save_writes_the_case_it_exists_for(self) -> None: + """A nested mutation implies a top-level key holding it, so non-empty.""" + assert ( + decide_outcome(_signals(always_save=True, empty=False, stored=True)) + is Outcome.WRITE + ) + def test_a_sign_in_rotates(self) -> None: assert decide_outcome(_signals(changed=True, modified=True)) is Outcome.ROTATE @@ -156,6 +186,18 @@ def test_a_sign_in_rotates(self) -> None: class TestInvariantsOverTheWholeSpace: """Properties that must hold for *every* input, not just the examples.""" + def test_always_save_never_creates_a_session_from_nothing(self) -> None: + """Over all 2048 inputs: an empty, never-stored session stays unwritten. + + ``WRITE`` on a session with no identifier is a create, so this is the + property that keeps ``always_save`` from minting a key per anonymous + visitor. A handler that emptied a *stored* session is a different + case - that is a sign-out, and ``SIGN_OUT`` handles it above. + """ + for signals in _every_combination(): + if signals.empty and not signals.stored and not signals.modified: + assert decide_outcome(signals) is not Outcome.WRITE + def test_a_failed_response_never_rotates(self) -> None: """§4.3: a response the client saw fail must not issue a new identity.""" for signals in _every_combination(): diff --git a/tests/unit/test_session_regressions.py b/tests/unit/test_session_regressions.py index 0d5f423..e4ce508 100644 --- a/tests/unit/test_session_regressions.py +++ b/tests/unit/test_session_regressions.py @@ -362,15 +362,31 @@ async def c3(subject): class TestCountDoesNotFailOpen: - async def test_an_unreadable_index_raises(self, fake_async_redis) -> None: + async def test_an_uncountable_index_raises(self, fake_async_redis) -> None: class _Broken(RedisSessionStore): - async def _index_members(self, subject: str) -> dict[str, bytes | str]: + async def _index_size(self, subject: str) -> int: raise RedisConnectionError("down") store = _Broken(fake_async_redis) with pytest.raises(SessionStoreError, match="Could not count sessions"): await store.count_for_subject("42") + async def test_an_unreadable_index_at_the_limit_raises( + self, fake_async_redis + ) -> None: + """The members read only happens at the limit, and it must raise too.""" + + class _Broken(RedisSessionStore): + async def _index_members(self, subject: str) -> dict[str, bytes | str]: + raise RedisConnectionError("down") + + store = _Broken(fake_async_redis, idle_ttl=60, absolute_ttl=600) + record = store.new_record({"user_id": "42"}) + for _ in range(2): + await store.index("42", store.new_id(), record, absolute_remaining=600) + with pytest.raises(SessionStoreError, match="Could not count sessions"): + await store.count_for_subject("42", limit=2) + async def test_an_unverifiable_count_at_the_limit_raises( self, fake_async_redis ) -> None: diff --git a/tests/unit/test_session_setup.py b/tests/unit/test_session_setup.py index 33ccde3..6ab8307 100644 --- a/tests/unit/test_session_setup.py +++ b/tests/unit/test_session_setup.py @@ -296,6 +296,49 @@ async def read(session: SessionDep) -> dict: client.get("/read") assert len(calls) >= 2 + def test_the_store_constructor_takes_a_timedelta_on_every_clock( + self, fake_async_redis + ) -> None: + """Half of the convention the §9 settings table used to contradict. + + The other half is in ``test_config.py``: the settings are ``int`` + seconds, and this is the path where a ``timedelta`` reads better and + is accepted. + """ + store = RedisSessionStore( + fake_async_redis, + idle_ttl=timedelta(minutes=15), + absolute_ttl=timedelta(hours=8), + gc_ttl=timedelta(days=30), + ) + assert store.idle_seconds == 900 + assert store.absolute_seconds == 28800 + + def test_the_builder_forwards_a_timedelta_to_the_store( + self, fake_async_redis + ) -> None: + """``.sessions(**store_options)`` is the documented path, so pin it.""" + app = FastAPI() + FastAPIRedis(app).sessions( + store_factory=None, + idle_ttl=timedelta(minutes=45), + absolute_ttl=timedelta(hours=2), + ) + _get_pool_state(app).async_pool = fake_async_redis.connection_pool + + captured: list[RedisSessionStore] = [] + + @app.get("/probe") + async def probe(store: SessionStoreDep) -> dict: + captured.append(store) # type: ignore[arg-type] + return {} + + with TestClient(app) as client: + client.get("/probe") + + assert captured[0].idle_seconds == 2700 + assert captured[0].absolute_seconds == 7200 + def test_constructor_options_reach_the_built_store( self, fake_async_redis, monkeypatch ) -> None: diff --git a/uv.lock b/uv.lock index 122f62a..9b753b0 100644 --- a/uv.lock +++ b/uv.lock @@ -775,7 +775,7 @@ requires-dist = [ { name = "opentelemetry-api", marker = "extra == 'otel'", specifier = ">=1.20" }, { name = "opentelemetry-sdk", marker = "extra == 'otel'", specifier = ">=1.20" }, { name = "pydantic", specifier = ">=2.12.5" }, - { name = "pydantic-settings", specifier = ">=2.0.0" }, + { name = "pydantic-settings", specifier = ">=2.7.0" }, { name = "redis", specifier = ">=6.0.0" }, ] provides-extras = ["otel"] From c02b3c04ab7839b91c9e8adfd4afe04177d3fa0a Mon Sep 17 00:00:00 2001 From: Tihomir Mateev Date: Fri, 25 Sep 2026 15:12:50 +0300 Subject: [PATCH 07/11] A user who only reads now stays signed in _spec in sessions.py now sets the cookie's Max-Age to the time left on the absolute clock. Before, it used the shorter of that and the idle timeout, which is what caused the bug. The absolute clock never slides, so no read has to resend the cookie. Redis still enforces the idle timeout. The _spec docstring now explains why the idle timeout is left out, and the LoadedSession docstring drops the old rule. --- docs/guide/caching.md | 9 + docs/guide/sessions.md | 8 +- docs/specs/session-design.md | 40 ++-- docs/specs/session-mgmt.md | 7 +- src/redis_fastapi/session_backend.py | 3 +- src/redis_fastapi/sessions.py | 20 +- .../integration/test_session_cookie_expiry.py | 174 ++++++++++++++++++ tests/unit/test_session_middleware.py | 5 +- tests/unit/test_session_regressions.py | 13 +- 9 files changed, 241 insertions(+), 38 deletions(-) create mode 100644 tests/integration/test_session_cookie_expiry.py diff --git a/docs/guide/caching.md b/docs/guide/caching.md index 0e788e7..c3c54b7 100644 --- a/docs/guide/caching.md +++ b/docs/guide/caching.md @@ -226,6 +226,15 @@ async def my_profile(user: User = Depends(get_current_user)): return user.profile ``` +!!! info "Session cookies and cached responses" + A response that only reads the session carries no `Set-Cookie` header, so a + cache that stores it cannot hand one user's session cookie to another. The + session cookie's `Max-Age` therefore follows the absolute lifetime, not the + idle timeout, so reads never need to resend the cookie to keep it valid + ([Two clocks](sessions.md#two-clocks-both-enforced-by-redis)). Redis still + enforces the idle timeout, so a cookie that outlives an idle session grants + nothing. + ### Testing The DI factories integrate with FastAPI's `dependency_overrides`, so diff --git a/docs/guide/sessions.md b/docs/guide/sessions.md index d58a685..45d0ec2 100644 --- a/docs/guide/sessions.md +++ b/docs/guide/sessions.md @@ -102,9 +102,11 @@ browser drops when it closes. The cookie's [`Max-Age`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Set-Cookie#max-agenumber) -is `min(idle, absolute remaining)`, and both numbers come from Redis rather than -from your process - so a container with a skewed clock cannot produce a cookie -that outlives its record and signs a user out with no explanation. +is the time left on the absolute clock, and the number comes from Redis rather +than from your process. It does not follow the idle clock: a read-only request +sends no cookie, so an idle-sized cookie would expire while the user is still +active. After an idle timeout the browser keeps a cookie that no longer names a +session, and the next request with it gets an empty session. --- diff --git a/docs/specs/session-design.md b/docs/specs/session-design.md index abb21eb..e73d83a 100644 --- a/docs/specs/session-design.md +++ b/docs/specs/session-design.md @@ -24,7 +24,7 @@ item is excluded. Each row carries an ID so a commit, a test or a review comment | F-6 | Expiry | Cookie `max-age` derives from the same server-side numbers as the record. Redis can eventually reclaim what a closed browser abandoned. | | F-7 | Reverse lookup | A session may be bound to a subject, which need not be a user. List and count a subject's live sessions, each with a descriptor, which keeps all the needed data, without having to consult the straight-record. | | F-8 | Reverse lookup | A listing never reports a session that has already died. Reads to the session remove dead entries; writes re-assert lost ones. | -| F-9 | Transport | The cookie carries a signed opaque identifier and no session data. An invalid value is rejected and yields a new session. | +| F-9 | Transport | The cookie carries an unsigned opaque identifier and no session data. An invalid value is rejected and yields a new session. | | F-10 | Transport | Cookie name, `Domain`, `Path`, `SameSite`, `Secure` and `HttpOnly` are configurable. `Vary: Cookie` is emitted whenever the response **may vary by cookie** — the session was accessed and the application has not declared the response independent of it. Merged into any existing `Vary`, never appended as a second header. | | F-11 | API | One call enables the feature. A dict-like dependency needs no load or save, and `request.session` behaves as before, so existing code and Authlib run unchanged. | | F-12 | API | The store exposes rotate, revoke, revoke-by-ID, revoke-all, list and count. Both dependencies resolve through `Depends`, so `dependency_overrides` works. | @@ -599,8 +599,8 @@ middleware is the last place in the chain that can still perform an `await`. The application never sees the difference. An expired session, a revoked one and an absent one are the same thing to a caller. -The cookie `max-age` for the response is `min(idle, remaining a)`, and both numbers came -from Redis rather than from our own clock. +The cookie `max-age` for the response is the remaining `a`, which came from Redis rather +than from our own clock. Section 6 explains why the idle clock is left out. ### 4.2 After the application, at `http.response.start` @@ -925,21 +925,31 @@ strictly linearizable, and the store's abstract primitives leave room to add it. Section 3.2 puts each clock on its own hash field, so **Redis enforces both and the store computes neither**. What remains here is the cookie, which Redis cannot enforce. -The cookie `max-age` must agree with whichever clock will fire first: +The cookie `max-age` follows the absolute clock only: ``` -max_age = min(idle_ttl, HTTL(key, "a")) +max_age = HTTL(key, "a") ``` -Both numbers come from Redis — the configured idle window, and the absolute remainder -that the server itself is counting down. Nothing is derived from the application's own -clock, so a container with a skewed clock cannot produce a cookie that disagrees with the -record. +The number comes from Redis, which is counting it down. Nothing is derived from the +application's own clock, so a container with a skewed clock cannot produce a cookie that +disagrees with the record. -**If the cookie and the record ever disagree, the browser deletes a cookie whose session -is still alive, and the user is signed out with no cause and no log line.** Section 8.3.2 -of `session-mgmt.md` records that failure. Deriving both from the same two server-side -numbers is what prevents it. +**The cookie must never expire before its session.** If it does, the browser deletes a +cookie whose session is still alive, and the user is signed out with no cause and no log +line. Section 8.3.2 of `session-mgmt.md` records that failure. + +**Why not `min(idle, HTTL(key, "a"))`.** An earlier version used it, and it caused exactly +that failure. The idle clock slides on every request, but a read-only response sends no +cookie (Section 4.2). So a cookie sized by the idle clock expired `idle` seconds after the +last *write*, while the record was still alive, and a user who only read was signed out +while active. The absolute clock never slides, so a cookie sent on any write stays correct +until the record's last possible moment, and no read has to resend it. Research §6 of +`session-di-factory-research.md` has the evidence and the options that were weighed. + +The price: after an idle timeout the browser keeps a dead cookie until the absolute +deadline. Redis still enforces the idle clock, so the dead ID grants nothing; each request +that carries it costs one round trip, and the first one deletes the half-dead key. In cookie-only mode the cookie carries no `max-age` at all and the browser decides. @@ -1542,7 +1552,9 @@ not support them either, so they could not have been covered here in any case. keeps the absolute deadline absolute, so assert it directly rather than inferring it. - Both clocks, independently: idle expiry while the absolute clock still has time, and absolute expiry despite continuous activity. -- Cookie `max-age` equal to `min(idle, HTTL(a))`, in every branch. +- Cookie `max-age` equal to `HTTL(a)`, in every branch, never the idle clock. +- Read-only requests inside the idle window, for longer than the idle window in total, + keep the user signed in (`tests/integration/test_session_cookie_expiry.py`). - Session-only mode: no `max-age`, and `gc_ttl` on **both** fields. - `refresh_on_load=False` restoring the second round trip and refreshing only on access. **Assert that the idle clock actually advances under this setting** — an earlier draft diff --git a/docs/specs/session-mgmt.md b/docs/specs/session-mgmt.md index b258f19..09ecba3 100644 --- a/docs/specs/session-mgmt.md +++ b/docs/specs/session-mgmt.md @@ -358,8 +358,9 @@ records that the maintainers declined it. The parameter is absent on purpose. - The construction of the cookie flags (`httponly; samesite=…; secure`), the `add_vary_header("Cookie")` call, and the method to clear a cookie (the value `null`, with an `expires` date in 1970). -- `itsdangerous.TimestampSigner`. We still sign, but we sign the ID and not the - payload. +- Not `itsdangerous.TimestampSigner`. The cookie carries an unsigned 256-bit + identifier: a forged value names no record in Redis, so a signature would add + nothing to the rejection. - `MutableHeaders`, `HTTPConnection`, and `Secret`. **Starlette permits this. It is a connection point, not a workaround.** The `session` @@ -1037,7 +1038,7 @@ our help? #### Core, P0. The release means nothing without these. - `SessionStore` (ABC) and `RedisSessionStore` -- our `SessionMiddleware`, with a signed opaque ID in the cookie +- our `SessionMiddleware`, with an unsigned opaque ID in the cookie - our own `Session` class, which tracks `popitem()` and `|=` - a load that happens with no call from the user, and only when the request carries a session cookie. **An earlier draft said "only when code touches the session". No diff --git a/src/redis_fastapi/session_backend.py b/src/redis_fastapi/session_backend.py index d7fed1a..7157983 100644 --- a/src/redis_fastapi/session_backend.py +++ b/src/redis_fastapi/session_backend.py @@ -261,8 +261,7 @@ class LoadedSession: ``absolute_remaining`` comes from ``HTTL`` on field ``a`` - the server's own number, never one this process computed. Section 6 needs it to size - the cookie's ``max-age`` as ``min(idle, absolute_remaining)`` so the cookie - and the record cannot disagree. + the cookie's ``max-age``, so the cookie cannot expire before the record. """ record: SessionRecord diff --git a/src/redis_fastapi/sessions.py b/src/redis_fastapi/sessions.py index fedcf28..68ec6b3 100644 --- a/src/redis_fastapi/sessions.py +++ b/src/redis_fastapi/sessions.py @@ -748,13 +748,15 @@ async def _write( ) def _spec(self, settings: Any, value: str, absolute: int | None) -> CookieSpec: - """Build the cookie spec, sizing ``max-age`` from the server's clocks. - - ``min(idle, absolute remaining)`` - whichever deadline fires first, and - both numbers come from Redis rather than from this process. A cookie - that outlives its record gets the user signed out with no cause and no - log line; deriving both from the same two server-side numbers is what - prevents that. + """Build the cookie spec, sizing ``max-age`` from the absolute clock. + + The absolute remainder, from Redis rather than from this process. Not + ``min(idle, absolute remaining)``: the idle clock slides on every + request, but a read-only response sends no cookie, so a cookie sized by + the idle clock expires while the record is alive and signs a reading + user out with no cause and no log line. The absolute clock never + slides, so a cookie sent on any write stays correct until the record's + last possible moment. Redis still enforces the idle clock. """ idle = settings.session_idle_ttl max_age: int | None @@ -762,10 +764,8 @@ def _spec(self, settings: Any, value: str, absolute: int | None) -> CookieSpec: max_age = None # cookie-only mode: the browser decides elif absolute is None: max_age = idle or None - elif not idle: - max_age = absolute else: - max_age = min(idle, absolute) + max_age = absolute return CookieSpec( name=settings.session_cookie_name, value=value, diff --git a/tests/integration/test_session_cookie_expiry.py b/tests/integration/test_session_cookie_expiry.py new file mode 100644 index 0000000..2af7377 --- /dev/null +++ b/tests/integration/test_session_cookie_expiry.py @@ -0,0 +1,174 @@ +"""The cookie must not expire before its record, against a real server. + +Research §6 of ``session-di-factory-research.md``: the load refreshes the idle +clock in Redis on every request, but the cookie is sent only on writes. When +the cookie's ``Max-Age`` followed the idle clock, a user who only read lost the +cookie while the record was alive. ``Max-Age`` now follows the absolute clock, +which never slides, and Redis alone enforces the idle clock. + +``TestClient`` keeps cookies in an ``http.cookiejar`` jar, which drops a cookie +once its ``Max-Age`` has passed on the real clock - as a browser does. So these +tests use short TTLs and real sleeps rather than a controlled clock. +""" + +import time + +import pytest +import redis as sync_redis +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from redis_fastapi.config import get_settings +from redis_fastapi.deps import SessionDep +from redis_fastapi.session_backend import FIELD_ABSOLUTE, FIELD_DATA +from redis_fastapi.setup import FastAPIRedis +from tests.conftest import requires_redis + +pytestmark = [pytest.mark.integration, requires_redis] + +IDLE = 6 +ABSOLUTE = 600 + + +@pytest.fixture() +def app(real_redis: sync_redis.Redis, test_prefix: str, monkeypatch) -> FastAPI: + get_settings.cache_clear() + monkeypatch.setenv("REDIS_SESSION_COOKIE_HTTPS_ONLY", "false") + monkeypatch.setenv("REDIS_PREFIX", test_prefix) + monkeypatch.setenv("REDIS_SESSION_IDLE_TTL", str(IDLE)) + monkeypatch.setenv("REDIS_SESSION_ABSOLUTE_TTL", str(ABSOLUTE)) + get_settings.cache_clear() + + application = FastAPI() + FastAPIRedis(application).lifespan().sessions() + + @application.post("/login") + async def login(session: SessionDep) -> dict: + session["user_id"] = 42 + return {} + + @application.get("/read") + async def read(session: SessionDep) -> dict: + return {"user_id": session.get("user_id")} + + @application.post("/write") + async def write(session: SessionDep) -> dict: + session["n"] = session.get("n", 0) + 1 + return {"user_id": session.get("user_id")} + + yield application + get_settings.cache_clear() + + +def _session_key(redis: sync_redis.Redis, session_id: str) -> str: + keys = list(redis.scan_iter(match=f"*session:{session_id}")) + assert len(keys) == 1, keys + return keys[0] + + +def _httl(redis: sync_redis.Redis, key: str, field: str) -> int: + return int(redis.execute_command("HTTL", key, "FIELDS", 1, field)[0]) + + +def _max_age(response) -> int: + for part in response.headers["set-cookie"].split(";"): + name, _, value = part.strip().partition("=") + if name.lower() == "max-age": + return int(value) + raise AssertionError("no Max-Age on the cookie") + + +def _sign_in(client: TestClient) -> str: + response = client.post("/login") + # Rotation starts a new absolute clock, so the cookie gets all of it. + assert _max_age(response) == ABSOLUTE + session_id = client.cookies.get("session") + assert session_id + return session_id + + +def test_a_reading_user_stays_signed_in( + app: FastAPI, real_redis: sync_redis.Redis +) -> None: + """Read-only requests, each well inside the idle window. + + Every request arrives less than ``IDLE`` seconds after the previous one, + so the user was never idle and must still be signed in at the end. + """ + with TestClient(app) as client: + session_id = _sign_in(client) + key = _session_key(real_redis, session_id) + + for _ in range(4): + time.sleep(1) + response = client.get("/read") + assert response.json() == {"user_id": 42} + assert "set-cookie" not in response.headers + # The load reset the idle clock in Redis. + assert _httl(real_redis, key, FIELD_DATA) >= IDLE - 1 + + # Last request was 4 s after sign-in; wait 3 s more. The user has been + # idle for 3 s, inside the 6 s window, but 7 s have passed since the + # only Set-Cookie. + time.sleep(3) + + # The record is alive in Redis ... + assert _httl(real_redis, key, FIELD_DATA) > 0 + assert _httl(real_redis, key, FIELD_ABSOLUTE) > 0 + + response = client.get("/read") + sent_cookie = response.request.headers.get("cookie") + + # ... and the same ID still works when sent by hand ... + by_hand = client.get("/read", headers={"cookie": f"session={session_id}"}) + assert by_hand.json() == {"user_id": 42}, "the session itself is alive" + + # ... so the user must still be signed in. + assert sent_cookie is not None, ( + "the client dropped the cookie while the record was alive" + ) + assert response.json() == {"user_id": 42} + + +def test_control_a_writing_user_stays_signed_in( + app: FastAPI, real_redis: sync_redis.Redis +) -> None: + """The same schedule with writes: each write resends the cookie. + + Shows that the harness honors ``Max-Age`` correctly, so a failure of the + test above comes from the middleware and not from the client. + """ + with TestClient(app) as client: + _sign_in(client) + for _ in range(4): + time.sleep(1) + response = client.post("/write") + # The absolute remainder: never refreshed, so it only shrinks. + assert ABSOLUTE - 10 <= _max_age(response) <= ABSOLUTE + time.sleep(3) + response = client.get("/read") + assert response.request.headers.get("cookie") is not None + assert response.json() == {"user_id": 42} + + +def test_an_idle_user_is_signed_out_although_the_cookie_lives( + app: FastAPI, real_redis: sync_redis.Redis +) -> None: + """The cookie now outlives the idle clock, so Redis must enforce it. + + After ``IDLE`` seconds with no request the client still holds the cookie + and sends it, but the record's idle field has expired: the session is + empty, and the load deletes the half-dead key. + """ + with TestClient(app) as client: + session_id = _sign_in(client) + key = _session_key(real_redis, session_id) + + time.sleep(IDLE + 1) + assert _httl(real_redis, key, FIELD_DATA) == -2 + assert _httl(real_redis, key, FIELD_ABSOLUTE) > 0 + + response = client.get("/read") + assert response.request.headers.get("cookie") == f"session={session_id}" + assert response.json() == {"user_id": None} + assert real_redis.exists(key) == 0, "the half-dead key was not deleted" diff --git a/tests/unit/test_session_middleware.py b/tests/unit/test_session_middleware.py index 19c6af8..eec8896 100644 --- a/tests/unit/test_session_middleware.py +++ b/tests/unit/test_session_middleware.py @@ -149,8 +149,9 @@ def test_cookie_attributes_follow_the_settings(self, client: TestClient) -> None assert morsel["httponly"] is True assert morsel["samesite"] == "Lax" assert morsel["path"] == "/" - # min(idle, absolute remaining) - the idle clock is the shorter one. - assert int(morsel["max-age"]) == 60 + # The absolute remainder, not the shorter idle clock: a read-only + # response sends no cookie, so an idle-sized one would expire early. + assert int(morsel["max-age"]) == 600 class TestAutomaticRotation: diff --git a/tests/unit/test_session_regressions.py b/tests/unit/test_session_regressions.py index e4ce508..db533aa 100644 --- a/tests/unit/test_session_regressions.py +++ b/tests/unit/test_session_regressions.py @@ -535,22 +535,27 @@ def decrypt(self, data: bytes) -> bytes: class TestCookieMaxAgeInEveryBranch: - """§11 requires ``min(idle, HTTL(a))`` asserted in every branch. + """§11 requires the cookie's ``max-age`` asserted in every branch. Only ``idle < absolute`` was covered, so replacing the whole computation with ``settings.session_idle_ttl`` kept the suite green. + + The value is the absolute remainder, never the idle clock. The idle clock + slides on every request, but read-only responses send no cookie, so a + cookie sized by it expired while the record was alive (research §6 of + ``session-di-factory-research.md``). """ @pytest.mark.parametrize( ("idle", "absolute", "expected"), [ - (60, 600, "60"), # idle is nearer - (1800, 60, "60"), # the absolute remainder truncates it + (60, 600, "600"), # the idle clock is ignored, even when nearer + (1800, 60, "60"), # the absolute remainder (0, 600, "600"), # no idle clock (0, 0, ""), # cookie-only: the browser decides ], ) - def test_max_age_takes_the_nearer_deadline( + def test_max_age_follows_the_absolute_deadline( self, fake_async_redis, monkeypatch, idle, absolute, expected ) -> None: get_settings.cache_clear() From 5179760e82e3f7bbd02f19bd69b04006babfe0f9 Mon Sep 17 00:00:00 2001 From: Tihomir Mateev Date: Fri, 25 Sep 2026 17:50:42 +0300 Subject: [PATCH 08/11] The absolute deadline is now the session key's TTL, and field a is gone. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit • layout: a create writes d, then sets EXPIRE . A save writes only d, so it can never move the deadline. At the deadline, Redis deletes the whole key, so no code path can serve a session past it. • at load: reads d and TTL in one round trip. A key with no TTL is deleted and treated as no session. That happens when a save lands just after the session expired or was revoked. The old code logged an error for this case; it no longer does, because it is a normal race. • events now work on Redis 7.4 with notify-keyspace-events Ehx, instead of needing Redis 8.8. • every live cookie now takes its Max-Age from store_state.absolute_remaining --- docs/api/reference.md | 13 +- docs/guide/sessions.md | 53 +-- docs/specs/session-design.md | 5 +- src/redis_fastapi/config.py | 6 +- src/redis_fastapi/session_backend.py | 192 ++++----- src/redis_fastapi/session_events.py | 177 ++++---- src/redis_fastapi/sessions.py | 49 +-- src/redis_fastapi/telemetry.py | 4 +- .../integration/test_session_cookie_expiry.py | 19 +- tests/integration/test_session_integration.py | 169 +++++--- tests/unit/test_session_backend.py | 95 +++-- tests/unit/test_session_events.py | 401 ++++++++---------- tests/unit/test_session_failures.py | 19 +- tests/unit/test_session_index.py | 16 +- tests/unit/test_session_regressions.py | 29 +- 15 files changed, 598 insertions(+), 649 deletions(-) diff --git a/docs/api/reference.md b/docs/api/reference.md index c59acf4..ff19f4f 100644 --- a/docs/api/reference.md +++ b/docs/api/reference.md @@ -490,12 +490,13 @@ async def _(session_id: str, cause: Cause) -> None: ... | Type | Values | |---|---| -| `Cause` | `"idle"`, `"absolute"`. No third member — a revocation is a `DEL`, which publishes no subkey notification. | -| `Tier` | `"field"`, `"none"`. | +| `Cause` | `"idle"`, `"absolute"`. No third member — a revocation is a `DEL`, whose event an idle expiry also sends, so it is not observed. | +| `Tier` | `"key"`, `"none"`. | | `Handler` | `Callable[[str, Cause], Awaitable[None]]`, for annotating what you register. | Started and stopped by the lifespan when `session_events_enabled` is set. -`events.tier` is `"field"` when the server can deliver events and `"none"` -otherwise — in which case handlers never fire. Requires Redis 8.8 and -`notify-keyspace-events` including `Th` — `T` for the `__subkeyevent@` -channel, `h` for hash events. +`events.tier` is `"key"` when the server can deliver events and `"none"` +otherwise — in which case handlers never fire. Requires +`notify-keyspace-events` including `Ehx` — `E` for the `__keyevent@` +channels, `h` for hash events and `x` for expiry events. `A` covers `h` and +`x`. An idle timeout arrives as `hexpired`, an absolute timeout as `expired`. diff --git a/docs/guide/sessions.md b/docs/guide/sessions.md index 45d0ec2..f324582 100644 --- a/docs/guide/sessions.md +++ b/docs/guide/sessions.md @@ -81,11 +81,13 @@ request and the two results are compared by value - and it must return a | `session_idle_ttl` | 30 min | Time since the last request carrying the cookie | | `session_absolute_ttl` | 8 h | Time since the session was created, however active the user | -They live on two separate hash fields with their own -[expirations](https://redis.io/docs/latest/commands/hexpire/), so **Redis -enforces both and this library computes neither**. Writing the payload touches -one field and never the other, so no number of writes can extend the absolute -deadline. +The idle clock is the [expiration](https://redis.io/docs/latest/commands/hexpire/) +of the hash field that holds the payload, and the absolute clock is the +[expiration](https://redis.io/docs/latest/commands/expire/) of the session key +itself. So **Redis enforces both and this library computes neither**. At the +absolute deadline Redis deletes the whole key, however recently the payload +was refreshed. Writing the payload never touches the key's expiration, so no +number of writes can extend the absolute deadline. The absolute clock is the one that matters against a stolen session. An [idle timeout](https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html#idle-timeout) @@ -202,7 +204,7 @@ request that touched the session. --- -## Real-time session events (optional, Redis 8.8+) +## Real-time session events (optional) Close a WebSocket the moment a session ends, instead of finding out on the next HTTP request: @@ -220,34 +222,31 @@ await events.start() ``` `Cause` has exactly those two members, so an exhaustive `match` over it stays -exhaustive. A revocation is not among them: it is a `DEL`, and `DEL` publishes -no subkey notification. Sign a user out through `revoke()` and you already -know it happened - the event stream is for the deaths nobody asked for. -`Handler` is exported too, for annotating the callable you register. +exhaustive. A revocation is not among them: it is a `DEL`, and Redis publishes +the same `del` event when an idle expiry empties the key, so the two cannot be +told apart. Sign a user out through `revoke()` and you already know it +happened - the event stream is for the deaths nobody asked for. `Handler` is +exported too, for annotating the callable you register. -This needs Redis 8.8 for hash subkey +This uses [keyspace notifications](https://redis.io/docs/latest/develop/pubsub/keyspace-notifications/), -and it needs the server configured for them with +which work on every supported Redis version, and it needs the server +configured for them with [`CONFIG SET`](https://redis.io/docs/latest/commands/config-set/): ``` -CONFIG SET notify-keyspace-events Th +CONFIG SET notify-keyspace-events Ehx ``` -Both characters matter. `h` is the hash class, and **`T`** is the -`__subkeyevent@` channel - the one this library subscribes to. Redis 8.8 adds -four subkey channels, `S`, `T`, `I` and `V`, and the other three deliver -elsewhere: on a server set to `Sh` the subscription succeeds and no event ever -arrives, so `events.tier` reports `"none"` and says why. - -All four are **independent of `K` and `E`** - setting -[`KEA`](https://redis.io/docs/latest/develop/pubsub/keyspace-notifications/#configuration) -enables every standard keyspace event and still delivers none of these. +`E` enables the `__keyevent@` channels, `h` the hash events and `x` the +expiry events. An idle timeout arrives as `hexpired` - the payload field +expired - and an absolute timeout as `expired` - the key expired. `A` covers +both `h` and `x`, so a server already set to `KEA` needs nothing more. This library will never set the option for you: it is server-wide and affects every other application on the instance. !!! danger "The callback is best-effort, and silence is a possible outcome" - On a server below 8.8, one without the flags, or one where + On a server without the flags, or one where [`CONFIG GET`](https://redis.io/docs/latest/commands/config-get/) is unavailable - which is common on managed Redis - `events.tier` is `"none"`, one warning is logged at startup, and **your handlers never run**. Startup @@ -258,7 +257,8 @@ every other application on the instance. well. [Redis Pub/Sub](https://redis.io/docs/latest/develop/pubsub/) is fire-and-forget: events sent while no subscriber is connected are lost, and an [expiry event](https://redis.io/docs/latest/develop/pubsub/keyspace-notifications/#timing-of-expired-events) - fires when Redis removes the field rather than when the deadline passed. + fires when Redis removes the field or the key rather than when the + deadline passed. Nothing else depends on this. Expiry, revocation and the index all work identically with events switched off. @@ -710,5 +710,6 @@ The standards and specifications this design follows: - why `maxmemory-policy` decides whether sessions survive - [Redis keyspace notifications](https://redis.io/docs/latest/develop/pubsub/keyspace-notifications/) - the delivery guarantees behind `SessionEvents` -- [Redis hash field expiration](https://redis.io/docs/latest/commands/hexpire/) - - the mechanism the two clocks are built on +- [Redis key expiration](https://redis.io/docs/latest/commands/expire/) and + [hash field expiration](https://redis.io/docs/latest/commands/hexpire/) + - the mechanisms the two clocks are built on diff --git a/docs/specs/session-design.md b/docs/specs/session-design.md index e73d83a..c11ae23 100644 --- a/docs/specs/session-design.md +++ b/docs/specs/session-design.md @@ -944,8 +944,9 @@ that failure. The idle clock slides on every request, but a read-only response s cookie (Section 4.2). So a cookie sized by the idle clock expired `idle` seconds after the last *write*, while the record was still alive, and a user who only read was signed out while active. The absolute clock never slides, so a cookie sent on any write stays correct -until the record's last possible moment, and no read has to resend it. Research §6 of -`session-di-factory-research.md` has the evidence and the options that were weighed. +until the record's last possible moment, and no read has to resend it. +`tests/integration/test_session_cookie_expiry.py` reproduces the failure against a real +server and guards the fix. The price: after an idle timeout the browser keeps a dead cookie until the absolute deadline. Redis still enforces the idle clock, so the dead ID grants nothing; each request diff --git a/src/redis_fastapi/config.py b/src/redis_fastapi/config.py index baa2688..fa68ef9 100644 --- a/src/redis_fastapi/config.py +++ b/src/redis_fastapi/config.py @@ -240,9 +240,9 @@ class RedisSettings(BaseSettings): ge=0, description=( "Absolute clock, in seconds. The session dies this long after " - "creation however active the user is. Stored as the TTL of hash " - "field 'a', which is written once and never refreshed. 0 disables " - "it, and the field then takes session_gc_ttl." + "creation however active the user is. Stored as the TTL of the " + "session key, which is set once and never refreshed. 0 disables " + "it, and the key then takes session_gc_ttl." ), ) session_gc_ttl: int = Field( diff --git a/src/redis_fastapi/session_backend.py b/src/redis_fastapi/session_backend.py index 7157983..c6419f7 100644 --- a/src/redis_fastapi/session_backend.py +++ b/src/redis_fastapi/session_backend.py @@ -1,16 +1,16 @@ """Redis-backed session store. -Two keys, both hashes with a time-to-live on each field:: +Two keys, both hashes:: - redis:fastapi:session: field "a" = "1" TTL = absolute + redis:fastapi:session: key TTL = absolute field "d" = TTL = idle redis:fastapi:sessions-of: field = TTL = absolute -Splitting the two clocks across two fields is what makes "a session outlives -its absolute deadline" unreachable rather than merely unlikely: Redis enforces -both deadlines and this module computes neither. Writing the payload touches -field ``d`` alone, so no number of writes can extend field ``a``. +Each clock has its own TTL, and Redis enforces both: this module computes +neither. At the absolute deadline Redis deletes the whole key, so no reader +can serve a session past it, whatever it asks for. Writing the payload +touches field ``d`` alone, so no number of writes can extend the key's TTL. See ``docs/specs/session-design.md`` Sections 3 and 4. """ @@ -66,15 +66,11 @@ # down. ``_observe`` counts both as one failed operation. OBSERVED_ERRORS: tuple[type[BaseException], ...] = (SessionStoreError, *STORE_ERRORS) -# Hash field names. Two characters, and identical in every session key. -# Section 13.2: a uniform schema is what a future compact-hash encoding would -# reward, and a short name is fewer bytes on the wire meanwhile. -FIELD_ABSOLUTE = "a" +# The payload's hash field name. One character, and identical in every +# session key. Section 13.2: a uniform schema is what a future compact-hash +# encoding would reward, and a short name is fewer bytes on the wire meanwhile. FIELD_DATA = "d" -# The value of field ``a`` is never read - the field exists for its TTL alone. -_ABSOLUTE_MARKER = "1" - class Deadline(Enum): """What ``_read`` says when the absolute deadline is not a number. @@ -84,23 +80,25 @@ class Deadline(Enum): the base class branches on both, so neither may be smuggled through as a negative integer. - An earlier version passed Redis's own ``HTTL`` sentinels - ``-2`` and + An earlier version passed Redis's own ``TTL`` sentinels - ``-2`` and ``-1`` - straight through, which quietly made "reproduce this Redis encoding" part of the contract every other backend had to satisfy, without the ``_read`` docstring ever saying so. """ ABSENT = auto() - """No deadline field, or no key at all. The session does not exist.""" + """No key at all. The session does not exist.""" UNBOUNDED = auto() - """The field exists with no expiry. Unreachable by construction - this - store always gives it one - so it means something else wrote the key.""" + """The key exists with no expiry. A create always sets one, so this is a + key recreated by a write that landed after the session ended - a save + racing the deadline or a revocation - or one written by something else. + Either way the session is over.""" -# Redis's own answers, mapped to the above by ``_ttl``. -_HTTL_NO_FIELD = -2 -_HTTL_NO_EXPIRY = -1 +# Redis's own ``TTL`` answers, mapped to the above by ``_ttl``. +_TTL_NO_KEY = -2 +_TTL_NO_EXPIRY = -1 # Characters a session ID may contain: the alphabet of ``secrets.token_urlsafe``. _ID_ALPHABET = frozenset( @@ -259,7 +257,7 @@ class SessionState: class LoadedSession: """A live session, plus the deadline Redis is counting down for it. - ``absolute_remaining`` comes from ``HTTL`` on field ``a`` - the server's + ``absolute_remaining`` comes from ``TTL`` on the session key - the server's own number, never one this process computed. Section 6 needs it to size the cookie's ``max-age``, so the cookie cannot expire before the record. """ @@ -408,16 +406,16 @@ def idle_seconds(self) -> int: """TTL for field ``d``. ``session_idle_ttl=0`` disables the idle clock, and the field then - falls back to ``gc_ttl`` rather than being left unexpiring. Section - 3.2: a field with no TTL breaks the meaning of ``HTTL``'s ``-2``. + falls back to ``gc_ttl`` rather than being left unexpiring, so Redis + can always collect an abandoned key. """ return self._idle_ttl or self._gc_ttl @property def absolute_seconds(self) -> int: - """TTL for field ``a``, set once at creation and never refreshed. + """TTL for the session key, set once at creation and never refreshed. - ``session_absolute_ttl=0`` disables the absolute clock, and the field + ``session_absolute_ttl=0`` disables the absolute clock, and the key then falls back to ``gc_ttl`` for the same reason as above. """ return self._absolute_ttl or self._gc_ttl @@ -581,10 +579,9 @@ async def load( ``session_refresh_on_load=False``. The idle clock then advances only when the response writes. - The two answers are read **as a pair**. Neither one is a sentinel on - its own: ``HTTL`` returns ``-2`` both for "this field expired" and for - "there is no such key", so reading it alone marks every session in a - deployment with no absolute limit as already dead. + The session is alive only when **both** answers say so: the payload is + present and the key has time left. Anything else that finds a key + deletes it, so the index entry can follow. Raises: SessionStoreError: If the record exists but cannot be decoded, or @@ -602,26 +599,14 @@ async def load( self._read_failed(exc) return None - if absolute_ttl is Deadline.UNBOUNDED: - # Unreachable by construction: every write gives field "a" a TTL. - # Reaching it means something wrote the key outside this store, so - # say so loudly and treat the session as absent rather than guess. - logger.error( - "Session key has a field 'a' with no expiry, which this store " - "never writes. Treating the session as absent. Key was " - "written by something else, or by an older version." - ) - await self._safe_delete(session_id) - return None - alive_until = absolute_ttl if isinstance(absolute_ttl, int) else 0 if raw is None or alive_until <= 0: - # Rows two and four of the state table: a half-dead key, alive on - # one clock and dead on the other. Delete it so the index entry can + # A key that exists but is not a live session: a payload with no + # deadline, which a save recreated after the session ended, or a + # deadline with no payload. Delete it so the index entry can # follow, rather than leaving a candidate that every later - # verification has to reject. Row three - dead on both - is simply - # absent, and there is nothing to remove. - half_dead = raw is not None or alive_until > 0 + # verification has to reject. No key at all is simply absent. + half_dead = raw is not None or absolute_ttl is not Deadline.ABSENT if half_dead: await self._safe_delete(session_id) record_session_operation( @@ -635,7 +620,7 @@ async def load( async def create(self, session_id: str, record: SessionRecord) -> None: """Write a session that does not exist yet, starting both clocks. - The only method that ever writes field ``a``. Use it for a first + The only method that ever sets the key's TTL. Use it for a first write and for the new half of a rotation; use :meth:`save` for every subsequent write. @@ -648,18 +633,13 @@ async def save(self, session_id: str, record: SessionRecord) -> None: """Update an existing session's payload, and only its payload. **This is N-6, and the method split is what makes it structural.** - There is no argument to this method that could write field ``a``, so + There is no argument to this method that could set the key's TTL, so an update cannot extend the absolute deadline and - the case that matters - cannot bring it back after it has expired. - An earlier version wrote ``a`` conditionally on every save, reasoning - that ``FNX`` protects an existing field. It does, but an *expired* - field is an absent field, so a request whose load saw ``a`` alive and - whose write landed after it lapsed recreated the deadline with a full - fresh lifetime. The window is one request long and it recurs every - cycle, so an actively-used session never died. Now such a write - leaves a key holding ``d`` with no ``a``, which the next load reads as - row two of the state table and deletes. The session ends, which is + A write that lands after the key expired, or after it was revoked, + recreates the key with ``d`` and no TTL. The next load reads that as + :attr:`Deadline.UNBOUNDED` and deletes it. The session ends, which is the correct outcome: its deadline passed. Raises: @@ -675,8 +655,8 @@ async def _save( """Shared body of :meth:`create` and :meth:`save`. **This is N-6, and the flag is what makes it structural.** An update - writes field ``d`` and nothing else, so it cannot extend field ``a`` - and - the case that matters - it cannot bring ``a`` back after it has + writes field ``d`` and nothing else, so it cannot extend the key's TTL + and - the case that matters - it cannot bring it back after it has expired. *absolute* is the deadline TTL on a create, and ``None`` on an update. @@ -1164,9 +1144,9 @@ async def _write( ) -> None: """Write the payload with an idle TTL. - *absolute* is ``None`` on an update, and the deadline field must then - be left completely alone - neither refreshed nor recreated. When it - is an int this is a create, and the deadline field takes that TTL. + *absolute* is ``None`` on an update, and the deadline must then be + left completely alone - neither refreshed nor recreated. When it is + an int this is a create, and the session takes that deadline. """ @abstractmethod @@ -1302,7 +1282,7 @@ async def _read( pipe.execute_command("HGET", key, FIELD_DATA) pipe.execute_command("HEXPIRE", key, refresh_idle, "FIELDS", 1, FIELD_DATA) reads = 2 - pipe.execute_command("HTTL", key, "FIELDS", 1, FIELD_ABSOLUTE) + pipe.execute_command("TTL", key) replies = await pipe.execute() return _first(replies[0]), _ttl(replies[reads]) @@ -1312,27 +1292,6 @@ async def _write( key = self.session_key(session_id) modern = await self._has_hsetex() pipe = self._redis.pipeline(transaction=False) - if absolute is not None: - # A create. FNX still guards against two concurrent creations of - # the same ID racing; it is not what keeps the deadline absolute. - # The caller not passing an absolute on an update is what does. - if modern: - pipe.execute_command( - "HSETEX", - key, - "FNX", - "EX", - absolute, - "FIELDS", - 1, - FIELD_ABSOLUTE, - _ABSOLUTE_MARKER, - ) - else: - pipe.execute_command("HSETNX", key, FIELD_ABSOLUTE, _ABSOLUTE_MARKER) - pipe.execute_command( - "HEXPIRE", key, absolute, "NX", "FIELDS", 1, FIELD_ABSOLUTE - ) if modern: pipe.execute_command( "HSETEX", key, "EX", idle, "FIELDS", 1, FIELD_DATA, payload @@ -1341,6 +1300,12 @@ async def _write( # HSET clears a field's TTL, so the idle clock is reapplied here. pipe.execute_command("HSET", key, FIELD_DATA, payload) pipe.execute_command("HEXPIRE", key, idle, "FIELDS", 1, FIELD_DATA) + if absolute is not None: + # A create. EXPIRE on a missing key does nothing, so the deadline + # follows the write that creates the key. A pipeline cut between + # the two leaves a key with no TTL, which the load treats as over; + # the write raised, so no cookie names it anyway. + pipe.execute_command("EXPIRE", key, absolute) await pipe.execute() async def _expire(self, session_id: str, idle: int) -> None: @@ -1402,40 +1367,28 @@ async def _index_size(self, subject: str) -> int: return int(await self._redis.hlen(self.index_key(subject))) async def _alive(self, session_ids: list[str]) -> set[str]: - """One pipelined ``HTTL`` per candidate, sent as a single batch. - - **Both clocks are checked, not one.** A session is alive only while - field ``d`` and field ``a`` both have time left, and the two die for - different reasons: ``d`` when the user goes idle, ``a`` when the - absolute deadline passes however active they were. - - Asking about ``d`` alone would report a session past its absolute - deadline as live, because ``d`` may have been refreshed minutes ago and - still hold most of the idle window. It is tempting to argue that the - case cannot arise - the index entry carries the same absolute deadline - as field ``a``, so it should expire at the same moment and never become - a candidate. That is true today and it is not a guarantee: it holds - only while every write re-asserts the entry with the *remaining* - absolute time, which is one refactor away from being wrong. Asking - about both fields costs nothing and does not depend on the argument. + """One pipelined ``HTTL`` and ``TTL`` per candidate, in a single batch. + + A session is alive only while field ``d`` and the key both have time + left - the same rule as the load. Redis deletes the key at the + absolute deadline, so ``d`` alone is usually enough; the key's TTL + also catches a key a late save recreated with no deadline, which the + load treats as over. One more command in the same round trip. """ if not session_ids: return set() pipe = self._redis.pipeline(transaction=False) for session_id in session_ids: - pipe.execute_command( - "HTTL", - self.session_key(session_id), - "FIELDS", - 2, - FIELD_DATA, - FIELD_ABSOLUTE, - ) + key = self.session_key(session_id) + pipe.execute_command("HTTL", key, "FIELDS", 1, FIELD_DATA) + pipe.execute_command("TTL", key) replies = await pipe.execute() return { session_id - for session_id, reply in zip(session_ids, replies, strict=True) - if _all_ttls_positive(reply) + for session_id, idle, deadline in zip( + session_ids, replies[0::2], replies[1::2], strict=True + ) + if _has_time_left(idle) and _has_time_left(deadline) } @@ -1454,31 +1407,32 @@ def _first(reply: Any) -> bytes | str | None: return str(first) -def _all_ttls_positive(reply: Any) -> bool: - """Whether every TTL in a multi-field ``HTTL`` reply has time left. +def _has_time_left(reply: Any) -> bool: + """Whether a ``TTL`` reply, or a one-field ``HTTL`` reply, is positive. An empty or malformed reply reads as "not alive": the safe answer for a liveness check is to omit the session rather than to report one that may already be gone. """ - if not isinstance(reply, (list, tuple)) or not reply: + value = reply[0] if isinstance(reply, (list, tuple)) and reply else reply + try: + return int(value) > 0 + except (TypeError, ValueError): return False - return all(value is not None and int(value) > 0 for value in reply) def _ttl(reply: Any) -> int | Deadline: - """Map ``HTTL``'s one-element array reply onto the deadline contract. + """Map the session key's ``TTL`` reply onto the deadline contract. This is the only place that knows what ``-1`` and ``-2`` mean, which is the point: the encoding stays inside the Redis backend. """ - value = reply[0] if isinstance(reply, (list, tuple)) and reply else reply - if value is None: + if reply is None: return Deadline.ABSENT - seconds = int(value) - if seconds == _HTTL_NO_EXPIRY: + seconds = int(reply) + if seconds == _TTL_NO_EXPIRY: return Deadline.UNBOUNDED - if seconds == _HTTL_NO_FIELD: + if seconds == _TTL_NO_KEY: return Deadline.ABSENT return seconds diff --git a/src/redis_fastapi/session_events.py b/src/redis_fastapi/session_events.py index 1790a10..0f5028e 100644 --- a/src/redis_fastapi/session_events.py +++ b/src/redis_fastapi/session_events.py @@ -10,11 +10,10 @@ *Nothing depends on it.* Pub/Sub is fire-and-forget, events sent while no subscriber is connected are lost, and an expiry event fires when Redis removes -the field rather than when the deadline passed. So the index still prunes -itself through field expiry and the load-time state table is still the -authority on whether a session is alive. Every guarantee in the store holds -with this module switched off, which is why it is off by default and why it is -the last thing to build. +the field or the key rather than when the deadline passed. So the index still +prunes itself through field expiry and the load is still the authority on +whether a session is alive. Every guarantee in the store holds with this +module switched off, which is why it is off by default. *It degrades to silence.* On a server that cannot supply the events, handlers are registered and never called. That is a deliberate choice with a sharp @@ -37,16 +36,12 @@ from redis.asyncio import Redis as AsyncRedis from redis.asyncio.cluster import RedisCluster as AsyncRedisCluster -from redis_fastapi.session_backend import ( - FIELD_ABSOLUTE, - FIELD_DATA, - STORE_ERRORS, -) +from redis_fastapi.session_backend import STORE_ERRORS from redis_fastapi.telemetry import record_session_event logger = logging.getLogger(__name__) -Tier = Literal["none", "field"] +Tier = Literal["none", "key"] # Two members, and there is no third. ``Cause`` is a ``Literal`` in a public # callback signature, so it is a promise about which values a handler can be @@ -54,51 +49,48 @@ # rewards must not be left with an arm that can never run and that mypy will # not let them delete. # -# **Revocation is deliberately absent, and it is not a version problem.** -# ``revoke`` is a ``DEL`` of the whole key, and ``DEL`` emits no subkey -# notification at any Redis version - it is not among the commands that do, -# and the mechanism forbids it structurally, because a subkey event is -# published only when at least one subkey is present and a deleted key has -# none left to name. Observing a revocation needs a second subscription to -# the key-level ``__keyevent@__:del`` under different flags, which Section -# 13.4 declined to build. If that tier is ever added, widening this union is -# the ordinary cost of widening any union - smaller than shipping a member -# nothing can produce. +# **Revocation is deliberately absent.** ``revoke`` is a ``DEL`` of the whole +# key, which publishes a ``del`` event - the same event Redis publishes when an +# idle expiry empties the hash. So a ``del`` cannot say whether the session +# was revoked or went idle, and this module does not subscribe to it. If a +# revocation event is ever needed, widening this union is the ordinary cost of +# widening any union - smaller than shipping a member nothing can produce. Cause = Literal["idle", "absolute"] # Exported, so a caller under mypy --strict can name the type of the callable # ``on_session_end`` requires them to pass. Handler = Callable[[str, Cause], Awaitable[None]] -# Subkey notifications arrived in Redis 8.8. There is no key-level tier here -# on purpose: our session key has no key-level TTL - it dies as a side effect -# of its last field expiring - so a key-level event carries no field name and -# cannot separate an idle death from an absolute one. That is most of what a -# subscriber wants to know, so the ladder has two rungs, not three. -_MIN_VERSION = (8, 8) +# Hash-field expiry, and with it the ``hexpired`` event, arrived in Redis 7.4, +# which is also this package's floor. +_MIN_VERSION = (7, 4) -# The channel that names both the key and the field. -_CHANNEL = "__subkeyevent@{db}__:hexpired" - -# Flags that must be present in ``notify-keyspace-events``. +# Two key-level channels, one for each clock. The payload of both is the key +# name, and nothing else. +# +# * ``hexpired``: a hash field expired. Field ``d`` is the only field of a +# session key that has a TTL, so on a session key this is the idle clock. +# * ``expired``: a key expired. The absolute deadline is the session key's +# TTL, so on a session key this is the absolute clock. # -# Redis 8.8 adds four subkey channels with a flag each - S for -# ``__subkeyspace@``, T for ``__subkeyevent@``, I for ``__subkeyspaceitem@`` -# and V for ``__subkeyspaceevent@`` - and all four are **independent of K and -# E**: enabling standard keyspace notifications does not enable these, and the -# reverse holds too. That is the commonest configuration mistake. +# Neither fires for the other clock. Checked against Redis 8.7: an idle +# expiry publishes ``hexpired`` and then ``del`` - the hash is empty - and no +# ``expired``; an absolute expiry publishes ``expired`` and no ``hexpired``. +_IDLE_CHANNEL = "__keyevent@{db}__:hexpired" +_ABSOLUTE_CHANNEL = "__keyevent@{db}__:expired" + +# Flags that must be present in ``notify-keyspace-events``: ``E`` for the +# ``__keyevent@`` channels, ``h`` for hash events, which include ``hexpired``, +# and ``x`` for ``expired``. # -# **Only T counts here, not any of the four.** This module subscribes to -# ``__subkeyevent@__:hexpired`` and nothing else, so a server with S, I or -# V but no T publishes to channels nobody is listening on. Accepting any of -# the four made ``tier`` report ``"field"`` on such a server while no event -# could ever arrive - which defeats the one check the guide offers against -# exactly that ("if prompt closure matters, check ``events.tier``"). Redis -# accepts a subscription to any channel name, so nothing else notices. -_SUBKEY_FLAG = "T" -_HASH_FLAG = "h" +# ``A`` is Redis's alias for every event class, ``h`` and ``x`` included, and +# ``CONFIG GET`` reports it in place of the classes it covers: a server set to +# ``KEA`` answers ``AKE``, with no literal ``h`` or ``x`` in it. +_KEYEVENT_FLAG = "E" +_EVENT_CLASSES = "hx" +_ALL_CLASSES_ALIAS = "A" -REQUIRED_CONFIG = "Th" +REQUIRED_CONFIG = "Ehx" class SessionEvents: @@ -118,7 +110,7 @@ async def _(session_id: str, cause: Cause) -> None: await events.stop() Attributes: - tier: ``"field"`` when the server can deliver events, ``"none"`` when + tier: ``"key"`` when the server can deliver events, ``"none"`` when it cannot. Read it to decide whether a handler will ever run. """ @@ -141,9 +133,8 @@ def on_session_end(self, handler: Handler) -> Handler: The handler receives the session ID and the cause: ``"idle"`` when the user went quiet, ``"absolute"`` when the deadline passed however - active they were. Distinguishing the two is the whole reason this - needs Redis 8.8, and they are the only two values - a revocation is a - ``DEL``, which publishes no subkey event. See :data:`Cause`. + active they were. They are the only two values - a revocation is a + ``DEL``, which this module does not observe. See :data:`Cause`. Returns: *handler*, so this works as a decorator. @@ -169,7 +160,7 @@ async def probe(self) -> Tier: try: if not await self._version_ok(): return "none" - return "field" if await self._config_ok() else "none" + return "key" if await self._config_ok() else "none" except STORE_ERRORS as exc: logger.info("Could not probe session-event support: %s", exc) return "none" @@ -185,8 +176,8 @@ async def _version_ok(self) -> bool: parts.append(0) if tuple(parts) < _MIN_VERSION: logger.warning( - "Session events need Redis %d.%d or later for hash subkey " - "notifications; this server reports %s. Handlers will not " + "Session events need Redis %d.%d or later for hash-field " + "expiry events; this server reports %s. Handlers will not " "fire. Everything else works unchanged.", _MIN_VERSION[0], _MIN_VERSION[1], @@ -198,16 +189,16 @@ async def _version_ok(self) -> bool: async def _config_ok(self) -> bool: config = await self._redis.config_get("notify-keyspace-events") flags = str(config.get("notify-keyspace-events", "")) - if _SUBKEY_FLAG not in flags or _HASH_FLAG not in flags: + classes_ok = _ALL_CLASSES_ALIAS in flags or all( + flag in flags for flag in _EVENT_CLASSES + ) + if _KEYEVENT_FLAG not in flags or not classes_ok: logger.warning( "Session events need notify-keyspace-events to include '%s' " - "(the __subkeyevent@ channel plus hash events); this server " - "has %r. Handlers will not fire. Note that 'T' specifically: " - "S, I and V enable the other three subkey channels, which " - "this module does not subscribe to, and all four are " - "independent of K and E. This library will not set the option " - "for you: it is server-wide and affects every other " - "application on the instance.", + "(the __keyevent@ channels, plus hash and expired events); " + "this server has %r. Handlers will not fire. This library " + "will not set the option for you: it is server-wide and " + "affects every other application on the instance.", REQUIRED_CONFIG, flags, ) @@ -246,14 +237,19 @@ async def _run(self) -> None: subscriber per node - which the caller composes, because only the caller knows the topology. """ - channel = _CHANNEL.format(db=self._db) + channels: dict[str, Cause] = { + _IDLE_CHANNEL.format(db=self._db): "idle", + _ABSOLUTE_CHANNEL.format(db=self._db): "absolute", + } try: pubsub = self._redis.pubsub() - await pubsub.subscribe(channel) + await pubsub.subscribe(*channels) async for message in pubsub.listen(): if message.get("type") != "message": continue - await self._dispatch(message.get("data")) + cause = channels.get(_text(message.get("channel"))) + if cause is not None: + await self._dispatch(message.get("data"), cause) except asyncio.CancelledError: raise except STORE_ERRORS as exc: @@ -261,11 +257,10 @@ async def _run(self) -> None: # and stop; nothing downstream depends on this stream. logger.warning("Session event subscription ended: %s", exc) - async def _dispatch(self, data: Any) -> None: - parsed = self._parse(data) - if parsed is None: + async def _dispatch(self, data: Any, cause: Cause) -> None: + session_id = self._session_id(data) + if session_id is None: return - session_id, cause = parsed for handler in self._handlers: try: await handler(session_id, cause) @@ -277,42 +272,20 @@ async def _dispatch(self, data: Any) -> None: else: record_session_event(cause=cause, result="delivered") - def _parse(self, data: Any) -> tuple[str, Cause] | None: - """Pull the session ID and the cause out of one notification. + def _session_id(self, data: Any) -> str | None: + """The session ID in one notification, or ``None`` if it names none. - The payload is ``:|:[,...]`` - length - prefixed so a key or field containing the delimiters stays parseable. - Anything that does not match this store's key prefix is another - application's hash and is ignored. + The payload is the key name. Anything that does not start with this + store's session prefix is another application's key - or this store's + index, whose entries expire constantly and are not session deaths - and + is ignored. """ - text = data.decode() if isinstance(data, bytes) else str(data) - key_part, _, field_part = text.partition("|") - key = _strip_length(key_part) - if key is None or not key.startswith(self._session_prefix): + key = _text(data) + if not key.startswith(self._session_prefix): return None - session_id = key[len(self._session_prefix) :] + return key[len(self._session_prefix) :] or None - fields = { - stripped - for chunk in field_part.split(",") - if (stripped := _strip_length(chunk)) is not None - } - # Both fields expiring at once is the absolute deadline arriving: the - # idle clock is the shorter one, so it only ever expires alone. - if FIELD_ABSOLUTE in fields: - return session_id, "absolute" - if FIELD_DATA in fields: - return session_id, "idle" - return None - -def _strip_length(chunk: str) -> str | None: - """Turn ``"7:field1"`` into ``"field1"``. - - Returns ``None`` for a chunk that carries no length prefix, which means - the payload is not the shape this version of Redis documents. - """ - length, sep, value = chunk.partition(":") - if not sep or not length.isdigit(): - return None - return value +def _text(value: Any) -> str: + """A Pub/Sub frame field as text, whether the client decodes or not.""" + return value.decode() if isinstance(value, bytes) else str(value) diff --git a/src/redis_fastapi/sessions.py b/src/redis_fastapi/sessions.py index 68ec6b3..3f0aeb8 100644 --- a/src/redis_fastapi/sessions.py +++ b/src/redis_fastapi/sessions.py @@ -609,33 +609,21 @@ async def _on_response_start( # not in the record ``rotate`` wrote, so catch it here. if session.modified: await self._write(store, state, settings, create=False) - return self._with_cookie( - headers, - settings, - value=store_state.session_id or "", - absolute=store.absolute_seconds, - ) + return self._with_cookie(headers, settings, store_state) if outcome is Outcome.ROTATE: - new_id = await store.rotate( + await store.rotate( store_state, subject=self._subject_of_quietly(session) or None, descriptor=self._descriptor(state), ) - return self._with_cookie( - headers, settings, value=new_id, absolute=store.absolute_seconds - ) + return self._with_cookie(headers, settings, store_state) if outcome is Outcome.WRITE: await self._write( store, state, settings, create=store_state.session_id is None ) - return self._with_cookie( - headers, - settings, - value=store_state.session_id or "", - absolute=store_state.absolute_remaining, - ) + return self._with_cookie(headers, settings, store_state) if outcome is Outcome.TOUCH: await store.touch(cast(str, state.loaded_id)) @@ -677,20 +665,29 @@ def _with_cookie( self, headers: list[tuple[bytes, bytes]], settings: Any, + store_state: SessionState | None = None, *, - value: str = "", - absolute: int | None = None, clear: bool = False, ) -> list[tuple[bytes, bytes]]: """Append one ``Set-Cookie``, always through the configured builder. + The value and ``max-age`` both come from *store_state*: its identifier, + and what the server says is left of its absolute clock. A create, a + rotation and a load each leave that number there, so one source serves + every live cookie. + Deletion goes through the same seam as creation. A browser removes a cookie only when the clearing header repeats every scoping attribute, so a ``cookie_builder`` added to emit ``Partitioned`` or a ``__Host-`` prefix - the seam's whole purpose - has to be consulted here too. It was not, and the result was a sign-out that left the cookie in place. """ - spec = self._spec(settings, value, absolute) + if store_state is None: + spec = self._spec(settings, "", None) + else: + spec = self._spec( + settings, store_state.session_id or "", store_state.absolute_remaining + ) if clear: spec = spec.cleared() headers.append((b"set-cookie", self._cookie_builder(spec).encode())) @@ -758,18 +755,14 @@ def _spec(self, settings: Any, value: str, absolute: int | None) -> CookieSpec: slides, so a cookie sent on any write stays correct until the record's last possible moment. Redis still enforces the idle clock. """ - idle = settings.session_idle_ttl - max_age: int | None - if not idle and not settings.session_absolute_ttl: - max_age = None # cookie-only mode: the browser decides - elif absolute is None: - max_age = idle or None - else: - max_age = absolute + cookie_only = ( + not settings.session_idle_ttl and not settings.session_absolute_ttl + ) return CookieSpec( name=settings.session_cookie_name, value=value, - max_age=max_age, + # Cookie-only mode sends no max-age: the browser decides. + max_age=None if cookie_only else absolute, path=settings.session_cookie_path, domain=settings.session_cookie_domain, secure=settings.session_cookie_https_only, diff --git a/src/redis_fastapi/telemetry.py b/src/redis_fastapi/telemetry.py index 8765158..45e3e0f 100644 --- a/src/redis_fastapi/telemetry.py +++ b/src/redis_fastapi/telemetry.py @@ -386,8 +386,8 @@ def record_session_event(*, cause: str, result: str) -> None: Args: cause: ``idle`` or ``absolute``. There is no third value: a - revocation is a ``DEL``, which publishes no subkey notification, - so no handler is ever called with one. + revocation is a ``DEL``, which the session-event subscriber does + not observe, so no handler is ever called with one. result: delivered or dropped. """ if not _state.enabled or _state.session_events is None: diff --git a/tests/integration/test_session_cookie_expiry.py b/tests/integration/test_session_cookie_expiry.py index 2af7377..e931d6a 100644 --- a/tests/integration/test_session_cookie_expiry.py +++ b/tests/integration/test_session_cookie_expiry.py @@ -1,9 +1,9 @@ """The cookie must not expire before its record, against a real server. -Research §6 of ``session-di-factory-research.md``: the load refreshes the idle -clock in Redis on every request, but the cookie is sent only on writes. When -the cookie's ``Max-Age`` followed the idle clock, a user who only read lost the -cookie while the record was alive. ``Max-Age`` now follows the absolute clock, +§6 of ``session-design.md``: the load refreshes the idle clock in Redis on +every request, but the cookie is sent only on writes. When the cookie's +``Max-Age`` followed the idle clock, a user who only read lost the cookie while +the record was alive. ``Max-Age`` now follows the absolute clock, which never slides, and Redis alone enforces the idle clock. ``TestClient`` keeps cookies in an ``http.cookiejar`` jar, which drops a cookie @@ -20,7 +20,7 @@ from redis_fastapi.config import get_settings from redis_fastapi.deps import SessionDep -from redis_fastapi.session_backend import FIELD_ABSOLUTE, FIELD_DATA +from redis_fastapi.session_backend import FIELD_DATA from redis_fastapi.setup import FastAPIRedis from tests.conftest import requires_redis @@ -114,7 +114,7 @@ def test_a_reading_user_stays_signed_in( # The record is alive in Redis ... assert _httl(real_redis, key, FIELD_DATA) > 0 - assert _httl(real_redis, key, FIELD_ABSOLUTE) > 0 + assert real_redis.ttl(key) > 0 response = client.get("/read") sent_cookie = response.request.headers.get("cookie") @@ -157,8 +157,8 @@ def test_an_idle_user_is_signed_out_although_the_cookie_lives( """The cookie now outlives the idle clock, so Redis must enforce it. After ``IDLE`` seconds with no request the client still holds the cookie - and sends it, but the record's idle field has expired: the session is - empty, and the load deletes the half-dead key. + and sends it, but the record's idle field has expired - and with it the + key, which held nothing else. The session is empty. """ with TestClient(app) as client: session_id = _sign_in(client) @@ -166,9 +166,8 @@ def test_an_idle_user_is_signed_out_although_the_cookie_lives( time.sleep(IDLE + 1) assert _httl(real_redis, key, FIELD_DATA) == -2 - assert _httl(real_redis, key, FIELD_ABSOLUTE) > 0 response = client.get("/read") assert response.request.headers.get("cookie") == f"session={session_id}" assert response.json() == {"user_id": None} - assert real_redis.exists(key) == 0, "the half-dead key was not deleted" + assert real_redis.exists(key) == 0 diff --git a/tests/integration/test_session_integration.py b/tests/integration/test_session_integration.py index d10de9b..02ea7ec 100644 --- a/tests/integration/test_session_integration.py +++ b/tests/integration/test_session_integration.py @@ -11,7 +11,6 @@ import redis.asyncio as async_redis from redis_fastapi.session_backend import ( - FIELD_ABSOLUTE, FIELD_DATA, RedisSessionStore, _StoreCapabilities, @@ -69,9 +68,9 @@ async def test_both_command_tiers_write_the_same_fields( """Both tiers, against a real server, on the same assertions. The two paths are not the same commands. The 8.0 path sends - ``HSETEX key FNX EX n FIELDS 1 a 1``; the 7.4 path sends ``HSETNX`` then - ``HEXPIRE key n NX FIELDS 1 a``. Argument order, the ``FNX``/``NX`` - semantics and the ``FIELDS numfields`` framing all differ, and + ``HSETEX key EX n FIELDS 1 d ``; the 7.4 path sends ``HSET`` then + ``HEXPIRE key n FIELDS 1 d``. Both then set the key's TTL with ``EXPIRE``. + Argument order and the ``FIELDS numfields`` framing differ, and ``fakeredis``'s argument parser is order-insensitive - so it accepts an option order a real server rejects. @@ -90,7 +89,7 @@ async def test_both_command_tiers_write_the_same_fields( key = store.session_key(sid := store.new_id()) await store.create(sid, store.new_record({"user_id": 42})) - assert about(await _httl(real_async_redis, key, FIELD_ABSOLUTE), 600) + assert about(await real_async_redis.ttl(key), 600) assert about(await _httl(real_async_redis, key, FIELD_DATA), 60) loaded = await store.load(sid) @@ -100,11 +99,9 @@ async def test_both_command_tiers_write_the_same_fields( # Age the absolute clock, then update: the deadline must not move, and # the idle clock must be reapplied. Both are properties of the tier. - await real_async_redis.execute_command( - "HEXPIRE", key, 100, "FIELDS", 1, FIELD_ABSOLUTE - ) + await real_async_redis.expire(key, 100) await store.save(sid, store.new_record({"user_id": 42, "n": 1})) - assert about(await _httl(real_async_redis, key, FIELD_ABSOLUTE), 100), ( + assert about(await real_async_redis.ttl(key), 100), ( "an update moved the absolute deadline" ) assert about(await _httl(real_async_redis, key, FIELD_DATA), 60), ( @@ -112,7 +109,7 @@ async def test_both_command_tiers_write_the_same_fields( ) await store.touch(sid) - assert about(await _httl(real_async_redis, key, FIELD_ABSOLUTE), 100) + assert about(await real_async_redis.ttl(key), 100) assert about(await _httl(real_async_redis, key, FIELD_DATA), 60) @@ -181,7 +178,7 @@ async def test_redis_enforces_the_idle_clock( async def test_redis_enforces_the_absolute_clock_despite_activity( real_async_redis: async_redis.Redis, test_prefix: str ) -> None: - """The guarantee the two-field layout exists for. + """The guarantee the absolute clock exists for. The session is loaded continuously - each load refreshes the idle clock - and it must still die on schedule. @@ -199,10 +196,62 @@ async def test_redis_enforces_the_absolute_clock_despite_activity( else: pytest.fail( "the session outlived its absolute deadline under continuous " - "activity - writing the payload extended field 'a'" + "activity - writing the payload extended the key's TTL" ) +async def test_no_reader_can_see_a_session_past_its_absolute_deadline( + real_async_redis: async_redis.Redis, test_prefix: str +) -> None: + """Redis deletes the whole key, so the rule lives in no reader. + + The payload was refreshed a moment before the deadline, so its own TTL + still has most of a minute left. A raw ``HGET`` - which knows nothing + about deadlines - must still find nothing. + """ + store = _store(real_async_redis, test_prefix, idle_ttl=60, absolute_ttl=3) + sid = store.new_id() + key = store.session_key(sid) + await store.create(sid, store.new_record({"user_id": 42})) + + await asyncio.sleep(1.0) + assert await store.load(sid) is not None # refreshes d to 60 s + + await asyncio.sleep(2.5) + assert await real_async_redis.hget(key, FIELD_DATA) is None + assert await real_async_redis.exists(key) == 0 + + +@pytest.mark.parametrize("ended_by", ["expiry", "revocation"]) +async def test_a_save_after_the_session_ended_does_not_revive_it( + real_async_redis: async_redis.Redis, test_prefix: str, ended_by: str +) -> None: + """A request that loaded a live session and saved it after it ended. + + The save recreates the key with ``d`` and no TTL. On a real server that + is what happens - ``fakeredis`` keeps the expired key's old TTL instead, + which is why this case is tested here. The next load must treat the key + as over, and delete it. + """ + store = _store(real_async_redis, test_prefix, idle_ttl=60, absolute_ttl=1) + sid = store.new_id() + key = store.session_key(sid) + await store.create(sid, store.new_record({"user_id": 42})) + assert await store.load(sid) is not None + + if ended_by == "expiry": + await asyncio.sleep(1.5) + assert await real_async_redis.exists(key) == 0 + else: + await store.delete(sid) + + await store.save(sid, store.new_record({"user_id": 42})) + assert await real_async_redis.ttl(key) == -1, "the save set a deadline" + + assert await store.load(sid) is None + assert await real_async_redis.exists(key) == 0 + + async def test_the_index_prunes_itself_with_no_help_from_us( real_async_redis: async_redis.Redis, test_prefix: str ) -> None: @@ -248,22 +297,16 @@ async def test_writing_the_payload_leaves_the_deadline_alone( key = store.session_key(sid) await store.create(sid, store.new_record({"n": 0})) - before = ( - await real_async_redis.execute_command("HTTL", key, "FIELDS", 1, FIELD_ABSOLUTE) - )[0] + before = await real_async_redis.ttl(key) for n in range(5): await store.save(sid, store.new_record({"n": n})) - after = ( - await real_async_redis.execute_command("HTTL", key, "FIELDS", 1, FIELD_ABSOLUTE) - )[0] + after = await real_async_redis.ttl(key) - # A band, not a ceiling: HTTL returns -2 for a deleted field and -1 for - # one with no expiry, and both satisfy ``after <= before``. Five real - # round trips can cross a second boundary, so allow a little slack below. - assert int(after) > 0, "the absolute deadline was deleted or unset" - assert int(before) - 5 <= int(after) <= int(before), ( - "field 'a' moved on a payload write" - ) + # A band, not a ceiling: TTL returns -2 for a deleted key and -1 for one + # with no expiry, and both satisfy ``after <= before``. Five real round + # trips can cross a second boundary, so allow a little slack below. + assert after > 0, "the absolute deadline was deleted or unset" + assert before - 5 <= after <= before, "the key's TTL moved on a payload write" idle = ( await real_async_redis.execute_command("HTTL", key, "FIELDS", 1, FIELD_DATA) )[0] @@ -287,41 +330,27 @@ async def test_revoke_all_ends_every_session( assert await store.load(sid) is None -async def test_a_field_expiry_reaches_a_handler( - real_async_redis: async_redis.Redis, test_prefix: str -) -> None: - """The one thing a scripted Pub/Sub cannot prove: the channel is real. - - ``test_session_events.py`` drives ``_run`` deterministically against a - fake, which covers the subscribe, the frame filtering and the dispatch. - What it cannot check is that ``__subkeyevent@__:hexpired`` is the name - a real server publishes on, or that the payload really arrives in the - documented length-prefixed shape. - - **Needs Redis 8.8.** Subkey notifications arrived there, and earlier - servers reject the ``T`` flag outright - 8.7 answers ``CONFIG SET`` with - "Invalid event class character. Use 'Ag$lshzxeKEtmdnocr'". So this - configures the server itself and skips when that fails, rather than - requiring a CI service-container flag: passing ``--notify-keyspace-events - Th`` to the 7.4 leg of the matrix would stop that container booting at - all. +async def _first_event( + redis: async_redis.Redis, prefix: str, *, idle_ttl: int, absolute_ttl: int +) -> tuple[str, str, str]: + """Create one session with the given clocks and wait for its end event. ``notify-keyspace-events`` is server-wide, so the original value is restored afterwards. The library never sets it; a test on a throwaway server may. """ - original = (await real_async_redis.config_get("notify-keyspace-events")).get( + original = (await redis.config_get("notify-keyspace-events")).get( "notify-keyspace-events", "" ) try: try: - await real_async_redis.config_set("notify-keyspace-events", REQUIRED_CONFIG) + await redis.config_set("notify-keyspace-events", REQUIRED_CONFIG) except async_redis.RedisError as exc: pytest.skip(f"server will not take {REQUIRED_CONFIG!r}: {exc}") - events = SessionEvents(real_async_redis, key_prefix=test_prefix) + events = SessionEvents(redis, key_prefix=prefix) if await events.probe() == "none": - pytest.skip("server cannot supply subkey notifications") + pytest.skip("server cannot supply keyspace notifications") delivered: asyncio.Queue = asyncio.Queue() @@ -331,23 +360,49 @@ async def _(session_id: str, cause: str) -> None: await events.start() try: - # A one-second idle clock, so the idle field expires on its own. - store = _store(real_async_redis, test_prefix, idle_ttl=1, absolute_ttl=600) + store = _store(redis, prefix, idle_ttl=idle_ttl, absolute_ttl=absolute_ttl) sid = store.new_id() await store.create(sid, store.new_record({"user_id": 42})) - # Expiry notifications fire when Redis removes the field, which - # for a field nobody touches waits on the active-expiry cycle. A - # read after the deadline forces the lazy path, so this does not - # depend on that cycle's timing. + # Expiry notifications fire when Redis removes the field or the + # key, which for one nobody touches waits on the active-expiry + # cycle. A read after the deadline forces the lazy path, so this + # does not depend on that cycle's timing. await asyncio.sleep(1.5) assert await store.load(sid) is None session_id, cause = await asyncio.wait_for(delivered.get(), timeout=10) finally: await events.stop() - - assert session_id == sid - assert cause == "idle" + return sid, session_id, cause finally: - await real_async_redis.config_set("notify-keyspace-events", original) + await redis.config_set("notify-keyspace-events", original) + + +async def test_an_idle_expiry_reaches_a_handler_as_idle( + real_async_redis: async_redis.Redis, test_prefix: str +) -> None: + """The one thing a scripted Pub/Sub cannot prove: the channels are real. + + ``test_session_events.py`` drives ``_run`` deterministically against a + fake. What it cannot check is that a real server publishes an idle expiry + on ``__keyevent@__:hexpired`` with the key as the payload. + """ + sid, session_id, cause = await _first_event( + real_async_redis, test_prefix, idle_ttl=1, absolute_ttl=600 + ) + assert (session_id, cause) == (sid, "idle") + + +async def test_an_absolute_expiry_reaches_a_handler_as_absolute( + real_async_redis: async_redis.Redis, test_prefix: str +) -> None: + """The absolute deadline is the key's TTL, so it arrives as ``expired``. + + And not as ``hexpired`` too: the payload's own TTL is far away, so the + first event must name the absolute clock. + """ + sid, session_id, cause = await _first_event( + real_async_redis, test_prefix, idle_ttl=60, absolute_ttl=1 + ) + assert (session_id, cause) == (sid, "absolute") diff --git a/tests/unit/test_session_backend.py b/tests/unit/test_session_backend.py index 46ce246..699c05a 100644 --- a/tests/unit/test_session_backend.py +++ b/tests/unit/test_session_backend.py @@ -1,12 +1,14 @@ """Unit tests for :class:`RedisSessionStore`, against ``fakeredis``. -These run the real key schema, the real TTL commands and the real two-field +These run the real key schema, the real TTL commands and the real two-clock layout - not a substitute - which is the whole reason the design refuses an in-memory store. """ from __future__ import annotations +import asyncio + import pytest from redis_fastapi.config import get_settings @@ -15,7 +17,6 @@ SessionStoreError, ) from redis_fastapi.session_backend import ( - FIELD_ABSOLUTE, FIELD_DATA, RedisSessionStore, SessionMetadata, @@ -36,6 +37,11 @@ async def _httl(redis, key: str, field: str) -> int: return int(reply[0]) +async def _deadline(redis, key: str) -> int: + """The absolute clock: the session key's own TTL.""" + return int(await redis.ttl(key)) + + class TestKeySchema: def test_the_two_prefixes_cannot_collide(self, store: RedisSessionStore) -> None: """Structural, not conventional. @@ -79,67 +85,68 @@ def test_a_good_id_factory_is_accepted(self, fake_async_redis) -> None: assert store.new_id() == "a" * 30 -class TestTwoFieldsTwoClocks: - async def test_save_writes_both_fields_with_their_own_ttls( +class TestTwoClocks: + async def test_create_sets_the_key_deadline_and_the_idle_field( self, store: RedisSessionStore, fake_async_redis ) -> None: sid = store.new_id() await store.create(sid, store.new_record({"user_id": 42})) key = store.session_key(sid) - assert about(await _httl(fake_async_redis, key, FIELD_ABSOLUTE), 600) + assert about(await _deadline(fake_async_redis, key), 600) assert about(await _httl(fake_async_redis, key, FIELD_DATA), 60) + assert await fake_async_redis.hkeys(key) == [FIELD_DATA.encode()], ( + "the session key holds the payload and nothing else" + ) async def test_writing_the_payload_never_extends_the_deadline( self, store: RedisSessionStore, fake_async_redis ) -> None: """N-6, asserted directly rather than inferred. - This is the guarantee the whole two-field layout exists for, so it is - checked by watching field ``a``'s TTL rather than by reasoning about - which command was sent. + This is the guarantee the two-clock layout exists for, so it is checked + by watching the key's TTL rather than by reasoning about which command + was sent. """ sid = store.new_id() key = store.session_key(sid) await store.create(sid, store.new_record({"n": 1})) # Age the absolute clock, then write the payload many times over. - await fake_async_redis.execute_command( - "HEXPIRE", key, 100, "FIELDS", 1, FIELD_ABSOLUTE - ) + await fake_async_redis.expire(key, 100) for n in range(5): await store.save(sid, store.new_record({"n": n})) - assert about(await _httl(fake_async_redis, key, FIELD_ABSOLUTE), 100), ( - "field 'a' moved on a payload write - refreshed, shortened or " - "deleted; the absolute deadline is no longer absolute" + assert about(await _deadline(fake_async_redis, key), 100), ( + "the key's TTL moved on a payload write - refreshed, shortened or " + "removed; the absolute deadline is no longer absolute" ) assert about(await _httl(fake_async_redis, key, FIELD_DATA), 60), ( "field 'd' should have been refreshed by the write" ) - async def test_field_a_always_has_a_ttl( + async def test_a_created_key_always_has_a_ttl( self, store: RedisSessionStore, fake_async_redis ) -> None: - """The ``-1`` row of the state table must be unreachable.""" + """A create never leaves the key without a deadline.""" sid = store.new_id() await store.create(sid, store.new_record({})) - assert await _httl(fake_async_redis, store.session_key(sid), FIELD_ABSOLUTE) > 0 + assert await _deadline(fake_async_redis, store.session_key(sid)) > 0 async def test_zero_ttls_fall_back_to_gc_ttl(self, fake_async_redis) -> None: - """Cookie-only mode: both fields still expire eventually.""" + """Cookie-only mode: both clocks still expire eventually.""" store = RedisSessionStore( fake_async_redis, idle_ttl=0, absolute_ttl=0, gc_ttl=1234 ) sid = store.new_id() await store.create(sid, store.new_record({})) key = store.session_key(sid) - assert about(await _httl(fake_async_redis, key, FIELD_ABSOLUTE), 1234) + assert about(await _deadline(fake_async_redis, key), 1234) assert about(await _httl(fake_async_redis, key, FIELD_DATA), 1234) class TestLoadStateTable: - """All five rows of the table in Section 4.1.""" + """Every state a load can find, from Section 4.1.""" async def test_alive(self, store: RedisSessionStore) -> None: sid = store.new_id() @@ -149,18 +156,33 @@ async def test_alive(self, store: RedisSessionStore) -> None: assert loaded.record.data == {"user_id": 42} assert 0 < loaded.absolute_remaining <= 600 - async def test_absolute_deadline_passed_deletes_the_key( + async def test_the_absolute_deadline_ends_a_fresh_session( self, store: RedisSessionStore, fake_async_redis ) -> None: + """Redis deletes the whole key, however fresh the idle clock is.""" sid = store.new_id() key = store.session_key(sid) await store.create(sid, store.new_record({"user_id": 42})) - await fake_async_redis.execute_command( - "HDEL", key, FIELD_ABSOLUTE - ) # simulate 'a' expiring + await fake_async_redis.pexpire(key, 20) + await asyncio.sleep(0.05) + assert await store.load(sid) is None + + async def test_a_key_with_no_deadline_is_deleted( + self, store: RedisSessionStore, fake_async_redis + ) -> None: + """What a save leaves when it lands after the session ended. + + The save recreates the key with ``d`` and no TTL. That is not a live + session - its deadline passed, or it was revoked - so the load deletes + it, and the index entry can follow. + """ + sid = store.new_id() + key = store.session_key(sid) + await store.create(sid, store.new_record({"user_id": 42})) + await fake_async_redis.persist(key) assert await store.load(sid) is None assert await fake_async_redis.exists(key) == 0, ( - "a half-dead key must be removed so the index entry can follow" + "a key with no deadline must be removed so the index entry can follow" ) async def test_no_such_session(self, store: RedisSessionStore) -> None: @@ -181,10 +203,9 @@ async def test_an_absolute_limit_of_zero_still_loads( ) -> None: """The bug that made every such session dead on arrival. - An earlier design read ``HTTL`` returning ``-2`` as "the absolute - deadline passed" and wrote no field ``a`` at all when the limit was - disabled, so every session in such a deployment was unreadable the - moment it was created. + An earlier design wrote no deadline at all when the limit was + disabled, and read the missing deadline as "passed", so every session + in such a deployment was unreadable the moment it was created. """ store = RedisSessionStore(fake_async_redis, idle_ttl=60, absolute_ttl=0) sid = store.new_id() @@ -249,17 +270,15 @@ async def test_touch_does_not_extend_the_absolute_clock( sid = store.new_id() key = store.session_key(sid) await store.create(sid, store.new_record({})) - await fake_async_redis.execute_command( - "HEXPIRE", key, 100, "FIELDS", 1, FIELD_ABSOLUTE - ) + await fake_async_redis.expire(key, 100) await store.touch(sid) - assert about(await _httl(fake_async_redis, key, FIELD_ABSOLUTE), 100), ( + assert about(await _deadline(fake_async_redis, key), 100), ( "touch moved the absolute clock" ) class TestSevenFourFallback: - """The 7.4 path writes the same fields with the same expirations. + """The 7.4 path writes the same data with the same expirations. Section 13.1: the fallback costs an extra command and never changes behaviour, so every assertion above must hold here too. @@ -287,7 +306,7 @@ async def test_same_ttls_as_the_modern_path( sid = old_store.new_id() await old_store.create(sid, old_store.new_record({})) key = old_store.session_key(sid) - assert about(await _httl(fake_async_redis, key, FIELD_ABSOLUTE), 600) + assert about(await _deadline(fake_async_redis, key), 600) assert about(await _httl(fake_async_redis, key, FIELD_DATA), 60) async def test_repeated_writes_still_never_extend_the_deadline( @@ -296,11 +315,9 @@ async def test_repeated_writes_still_never_extend_the_deadline( sid = old_store.new_id() key = old_store.session_key(sid) await old_store.create(sid, old_store.new_record({})) - await fake_async_redis.execute_command( - "HEXPIRE", key, 100, "FIELDS", 1, FIELD_ABSOLUTE - ) + await fake_async_redis.expire(key, 100) await old_store.save(sid, old_store.new_record({"n": 2})) - assert about(await _httl(fake_async_redis, key, FIELD_ABSOLUTE), 100), ( + assert about(await _deadline(fake_async_redis, key), 100), ( "the 7.4 write path moved the absolute clock" ) diff --git a/tests/unit/test_session_events.py b/tests/unit/test_session_events.py index f2d62bc..8358c93 100644 --- a/tests/unit/test_session_events.py +++ b/tests/unit/test_session_events.py @@ -14,9 +14,11 @@ from redis.exceptions import RedisError from redis_fastapi.session_events import ( - _CHANNEL, - _HASH_FLAG, - _SUBKEY_FLAG, + _ABSOLUTE_CHANNEL, + _ALL_CLASSES_ALIAS, + _EVENT_CLASSES, + _IDLE_CHANNEL, + _KEYEVENT_FLAG, REQUIRED_CONFIG, Cause, Handler, @@ -27,7 +29,7 @@ class _FakeRedis: """Just enough Redis to answer a probe.""" - def __init__(self, version: str = "8.8.0", flags: str = REQUIRED_CONFIG) -> None: + def __init__(self, version: str = "7.4.0", flags: str = REQUIRED_CONFIG) -> None: self._version = version self._flags = flags self.raise_on_config = False @@ -49,53 +51,52 @@ def _events(redis) -> SessionEvents: class TestProbe: - async def test_a_capable_and_configured_server_gives_the_field_tier(self) -> None: - assert await _events(_FakeRedis()).probe() == "field" + async def test_a_capable_and_configured_server_gives_the_key_tier(self) -> None: + assert await _events(_FakeRedis()).probe() == "key" - @pytest.mark.parametrize("version", ["7.4.0", "8.0.3", "8.6.1"]) - async def test_a_server_below_8_8_gives_none(self, version: str) -> None: + @pytest.mark.parametrize("version", ["6.2.14", "7.2.5"]) + async def test_a_server_below_7_4_gives_none(self, version: str) -> None: + """Hash-field expiry, and the ``hexpired`` event, arrived in 7.4.""" assert await _events(_FakeRedis(version=version)).probe() == "none" - @pytest.mark.parametrize("flags", ["", "KEA", "AKE", "Kh", "ST"]) - async def test_missing_flags_give_none(self, flags: str) -> None: - """The subkey flags are independent of K and E. + @pytest.mark.parametrize("version", ["7.4.0", "8.0.3", "8.8.0"]) + async def test_7_4_and_later_give_the_key_tier(self, version: str) -> None: + assert await _events(_FakeRedis(version=version)).probe() == "key" - ``KEA`` enables every standard keyspace event and still delivers no - subkey notification, which is the commonest configuration mistake. - ``ST`` picks subkey channels but omits the hash class. - """ - assert await _events(_FakeRedis(flags=flags)).probe() == "none" + @pytest.mark.parametrize("flags", ["", "K", "Kh", "Ex", "Eh", "hx", "KA"]) + async def test_missing_flags_give_none(self, flags: str) -> None: + """``E`` for the channel, and both ``h`` and ``x`` for the two events. - @pytest.mark.parametrize("flags", ["Sh", "Ih", "Vh", "SIVh", "Sah"]) - async def test_a_subkey_flag_that_is_not_t_gives_none(self, flags: str) -> None: - """Redis 8.8 has four subkey channels; this module listens on one. - - ``S``, ``I`` and ``V`` enable ``__subkeyspace@``, - ``__subkeyspaceitem@`` and ``__subkeyspaceevent@``. Only ``T`` enables - ``__subkeyevent@``, which is where ``listen()`` subscribes. Redis - accepts a subscription to any channel name, so on ``Sh`` the - subscription succeeds and no event ever arrives - and the probe used - to report ``"field"`` for it, which defeats the ``events.tier`` check - the guide offers against precisely this. + ``KA`` enables every event class but only on ``__keyspace@``, not on + the ``__keyevent@`` channels this module subscribes to. """ assert await _events(_FakeRedis(flags=flags)).probe() == "none" - @pytest.mark.parametrize("flags", ["Th", "ATh", "KEATh", "hT", "STIVh"]) - async def test_t_plus_the_hash_class_gives_the_field_tier(self, flags: str) -> None: + @pytest.mark.parametrize("flags", ["Ehx", "hxE", "xhE", "Eghxl", "KEhx"]) + async def test_e_plus_both_classes_gives_the_key_tier(self, flags: str) -> None: """Order does not matter, and extra flags are somebody else's business.""" - assert await _events(_FakeRedis(flags=flags)).probe() == "field" + assert await _events(_FakeRedis(flags=flags)).probe() == "key" + + @pytest.mark.parametrize("flags", ["AKE", "EA", "AE"]) + async def test_the_all_classes_alias_counts_as_both(self, flags: str) -> None: + """``CONFIG GET`` reports ``KEA`` as ``AKE``, with no ``h`` or ``x``. + + Checked against Redis 8.7. A probe that looked for the literal + letters refused the commonest configuration there is. + """ + assert await _events(_FakeRedis(flags=flags)).probe() == "key" - async def test_the_warning_names_t_specifically(self, caplog) -> None: - """An operator reading it must not conclude that any subkey flag does.""" + async def test_the_warning_names_the_required_config(self, caplog) -> None: with caplog.at_level("WARNING"): - assert await _events(_FakeRedis(flags="Sh")).probe() == "none" - assert "'Th'" in caplog.text - assert "__subkeyevent@" in caplog.text + assert await _events(_FakeRedis(flags="Eh")).probe() == "none" + assert "'Ehx'" in caplog.text + assert "__keyevent@" in caplog.text def test_the_documented_config_is_the_one_that_is_checked(self) -> None: """``REQUIRED_CONFIG`` is what the guide tells operators to set.""" - assert REQUIRED_CONFIG == "Th" - assert set(REQUIRED_CONFIG) == {_SUBKEY_FLAG, _HASH_FLAG} + assert REQUIRED_CONFIG == "Ehx" + assert set(REQUIRED_CONFIG) == {_KEYEVENT_FLAG, *_EVENT_CLASSES} + assert _ALL_CLASSES_ALIAS not in REQUIRED_CONFIG async def test_config_being_forbidden_degrades_rather_than_raising(self) -> None: """Managed Redis routinely restricts or renames CONFIG. @@ -118,7 +119,7 @@ async def test_an_unparseable_version_degrades(self) -> None: class TestSilentFallback: async def test_start_succeeds_at_tier_none(self) -> None: - events = _events(_FakeRedis(version="7.4.0")) + events = _events(_FakeRedis(version="7.2.0")) await events.start() assert events.tier == "none" await events.stop() @@ -131,7 +132,7 @@ async def test_a_handler_at_tier_none_is_never_called(self) -> None: exactly why the guide has to warn about it. """ called: list[str] = [] - events = _events(_FakeRedis(version="7.4.0")) + events = _events(_FakeRedis(flags="")) @events.on_session_end async def _(session_id: str, cause: str) -> None: @@ -142,49 +143,36 @@ async def _(session_id: str, cause: str) -> None: assert called == [] async def test_one_warning_is_logged_and_startup_continues(self, caplog) -> None: - events = _events(_FakeRedis(version="7.4.0")) + events = _events(_FakeRedis(version="7.2.0")) with caplog.at_level("WARNING"): await events.start() assert len(caplog.records) == 1 - assert "8.8" in caplog.text + assert "7.4" in caplog.text -class TestParsingNotifications: +class TestSessionIdFromNotification: @pytest.fixture() def events(self) -> SessionEvents: return _events(_FakeRedis()) - def test_an_expired_idle_field_reads_as_idle(self, events: SessionEvents) -> None: - payload = "26:redis:fastapi:session:abc|1:d" - assert events._parse(payload) == ("abc", "idle") + def test_a_session_key_gives_its_id(self, events: SessionEvents) -> None: + assert events._session_id("redis:fastapi:session:abc") == "abc" - def test_an_expired_deadline_reads_as_absolute(self, events: SessionEvents) -> None: - payload = "26:redis:fastapi:session:abc|1:a" - assert events._parse(payload) == ("abc", "absolute") - - def test_both_fields_at_once_read_as_absolute(self, events: SessionEvents) -> None: - """The idle clock is the shorter one, so it only expires alone.""" - payload = "26:redis:fastapi:session:abc|1:d,1:a" - assert events._parse(payload) == ("abc", "absolute") + def test_bytes_are_accepted(self, events: SessionEvents) -> None: + assert events._session_id(b"redis:fastapi:session:abc") == "abc" - def test_another_application_s_hash_is_ignored(self, events: SessionEvents) -> None: - assert events._parse("9:other:key|1:d") is None + def test_another_application_s_key_is_ignored(self, events: SessionEvents) -> None: + assert events._session_id("other:key") is None def test_the_index_key_is_ignored(self, events: SessionEvents) -> None: """Index entries expire constantly and are not session deaths.""" - assert events._parse("29:redis:fastapi:sessions-of:42|3:abc") is None + assert events._session_id("redis:fastapi:sessions-of:42") is None - def test_bytes_are_accepted(self, events: SessionEvents) -> None: - assert events._parse(b"26:redis:fastapi:session:abc|1:d") == ("abc", "idle") - - @pytest.mark.parametrize( - "payload", - ["garbage", "", "nolengthprefix|1:d", "26:redis:fastapi:session:abc|nope"], - ) - def test_a_malformed_payload_is_dropped_not_raised( - self, events: SessionEvents, payload: str + @pytest.mark.parametrize("payload", ["", "redis:fastapi:session:", 1]) + def test_a_payload_naming_no_session_is_dropped_not_raised( + self, events: SessionEvents, payload: object ) -> None: - assert events._parse(payload) is None + assert events._session_id(payload) is None class TestDispatch: @@ -200,7 +188,7 @@ async def first(session_id: str, cause: str) -> None: async def second(session_id: str, cause: str) -> None: seen.append(("second", cause)) - await events._dispatch("26:redis:fastapi:session:abc|1:d") + await events._dispatch("redis:fastapi:session:abc", "idle") assert seen == [("first", "idle"), ("second", "idle")] async def test_one_raising_handler_does_not_stop_the_others(self) -> None: @@ -215,78 +203,12 @@ async def broken(session_id: str, cause: str) -> None: async def working(session_id: str, cause: str) -> None: seen.append(session_id) - await events._dispatch("26:redis:fastapi:session:abc|1:d") + await events._dispatch("redis:fastapi:session:abc", "idle") assert seen == ["abc"], ( "a bad handler took down the subscriber and with it every other handler" ) -class TestTheCauseUnionIsInhabited: - """``Cause`` is a ``Literal`` in a public callback signature. - - That makes it a promise about which values a handler can be called with. - It used to include ``"revoked"``, which nothing could produce: ``revoke`` - is a ``DEL``, and ``DEL`` emits no subkey notification at any Redis - version - it is not among the commands that do, and the mechanism forbids - it, because a subkey event is published only when at least one subkey is - present and a deleted key has none left to name. A caller writing the - exhaustive ``match`` a type checker rewards was left with an arm that - could never run and that mypy would not let them delete. - """ - - def test_cause_has_exactly_the_two_values_parse_can_return(self) -> None: - assert set(get_args(Cause)) == {"idle", "absolute"} - - def test_every_member_is_reachable_from_a_real_payload(self) -> None: - """The union and the parser are checked against each other. - - Adding a member without a payload that produces it fails here, which - is the regression that let ``"revoked"`` survive. - """ - events = _events(_FakeRedis()) - key = "redis:fastapi:session:" + "a" * 40 - payloads = { - f"{len(key)}:{key}|1:d": "idle", - f"{len(key)}:{key}|1:a": "absolute", - } - produced = set() - for payload, expected in payloads.items(): - parsed = events._parse(payload) - assert parsed is not None, payload - assert parsed[1] == expected - produced.add(parsed[1]) - assert produced == set(get_args(Cause)), ( - "a Cause member no payload can produce, or a payload the union " - "does not cover" - ) - - def test_a_revocation_produces_no_event_to_parse(self) -> None: - """A ``DEL`` is not an ``hexpired`` with no fields - it is no message. - - Asserted through the parser rather than the wire: a notification whose - field list names neither of this store's two fields is not a session - death, and must not be reported as one. - """ - events = _events(_FakeRedis()) - key = "redis:fastapi:session:" + "a" * 40 - assert events._parse(f"{len(key)}:{key}|") is None - assert events._parse(f"{len(key)}:{key}|5:other") is None - - def test_the_handler_type_is_exported(self) -> None: - """A mypy --strict caller must be able to name what they must pass.""" - import redis_fastapi - - assert "Handler" in redis_fastapi.__all__ - assert redis_fastapi.Handler is Handler - - def test_the_handler_type_refers_to_cause_rather_than_respelling_it( - self, - ) -> None: - """Otherwise the two could drift and only one would be corrected.""" - parameters, _ = get_args(Handler) - assert parameters == [str, Cause] - - class _FakePubSub: """Records what was subscribed to, then yields a scripted message stream.""" @@ -295,8 +217,8 @@ def __init__(self, messages: list[dict]) -> None: self.subscribed: list[str] = [] self.closed = False - async def subscribe(self, channel: str) -> None: - self.subscribed.append(channel) + async def subscribe(self, *channels: str) -> None: + self.subscribed.extend(channels) async def listen(self): for message in self.messages: @@ -319,120 +241,153 @@ def pubsub(self) -> _FakePubSub: return self.pubsub_obj -def _hexpired(session_id: str, field: str, prefix: str = "redis:fastapi") -> dict: - """One notification in the documented `__subkeyevent@` payload format.""" - key = f"{prefix}:session:{session_id}" - return {"type": "message", "data": f"{len(key)}:{key}|{len(field)}:{field}"} +def _event( + event: str, session_id: str, *, prefix: str = "redis:fastapi", db: int = 0 +) -> dict: + """One notification in the documented ``__keyevent@`` format.""" + return { + "type": "message", + "channel": f"__keyevent@{db}__:{event}", + "data": f"{prefix}:session:{session_id}", + } -class TestTheDeliveryPath: - """N-14: the documented recipe has to be executable code under test. +async def _run(redis: _ScriptedRedis, **kwargs) -> list[tuple[str, str]]: + """Start a subscriber on *redis*, let it drain, and return what it saw.""" + events = SessionEvents(redis, key_prefix="redis:fastapi", **kwargs) + seen: list[tuple[str, str]] = [] + events.on_session_end(lambda session_id, cause: _record(seen, session_id, cause)) + await events.start() + assert events.tier == "key" + await asyncio.wait_for(events._task, timeout=5) + await events.stop() + return seen - The probe was covered thoroughly and ``_parse`` and ``_dispatch`` were - covered directly, but not the path between them - subscribe, receive a - message, fire a handler. That is where the channel name, the ``pubsub()`` - lifecycle and the message-shape filtering live, so a typo in ``_CHANNEL`` - passed every test in this file. - Driven through a scripted Pub/Sub rather than a live server: the real - thing needs Redis 8.8 with ``notify-keyspace-events Th``, which is a - timing-dependent integration test (see ``test_session_integration.py``). - The logic is deterministic and belongs here. +class TestTheCauseUnionIsInhabited: + """``Cause`` is a ``Literal`` in a public callback signature. + + That makes it a promise about which values a handler can be called with. + It used to include ``"revoked"``, which nothing could produce. A caller + writing the exhaustive ``match`` a type checker rewards was left with an + arm that could never run and that mypy would not let them delete. """ - def test_the_channel_matches_the_documented_format(self) -> None: - """The cheap half, and it needs no server at all.""" - assert _CHANNEL.format(db=0) == "__subkeyevent@0__:hexpired" - assert _CHANNEL.format(db=3) == "__subkeyevent@3__:hexpired" + def test_cause_has_exactly_the_two_values(self) -> None: + assert set(get_args(Cause)) == {"idle", "absolute"} - async def test_it_subscribes_to_the_channel_for_its_own_database(self) -> None: - redis = _ScriptedRedis([]) - events = SessionEvents(redis, key_prefix="redis:fastapi", db=7) - await events.start() - await asyncio.wait_for(events._task, timeout=5) - await events.stop() - assert redis.pubsub_obj.subscribed == ["__subkeyevent@7__:hexpired"] + async def test_every_member_is_reachable_from_a_real_event(self) -> None: + """The union and the channels are checked against each other. - async def test_a_field_expiry_reaches_a_handler(self) -> None: - """The whole point of the feature, end to end through ``_run``.""" + Adding a member without an event that produces it fails here, which + is the regression that let ``"revoked"`` survive. + """ sid = "a" * 40 - redis = _ScriptedRedis([_hexpired(sid, "d")]) - events = SessionEvents(redis, key_prefix="redis:fastapi") + seen = await _run( + _ScriptedRedis([_event("hexpired", sid), _event("expired", sid)]) + ) + assert {cause for _, cause in seen} == set(get_args(Cause)), ( + "a Cause member no event can produce, or an event the union does not cover" + ) - seen: list[tuple[str, str]] = [] + async def test_a_deletion_produces_no_event(self) -> None: + """A ``del`` is either a revocation or an idle expiry emptying the hash. - @events.on_session_end - async def _(session_id: str, cause: Cause) -> None: - seen.append((session_id, cause)) + It cannot say which, so it is not subscribed to - and if one arrives + anyway, it is not reported. + """ + seen = await _run(_ScriptedRedis([_event("del", "a" * 40)])) + assert seen == [] - await events.start() - assert events.tier == "field" - await asyncio.wait_for(events._task, timeout=5) - await events.stop() + def test_the_handler_type_is_exported(self) -> None: + """A mypy --strict caller must be able to name what they must pass.""" + import redis_fastapi - assert seen == [(sid, "idle")] + assert "Handler" in redis_fastapi.__all__ + assert redis_fastapi.Handler is Handler - async def test_both_causes_arrive_on_the_one_channel(self) -> None: - first, second = "a" * 40, "b" * 40 - redis = _ScriptedRedis([_hexpired(first, "d"), _hexpired(second, "a")]) - events = SessionEvents(redis, key_prefix="redis:fastapi") + def test_the_handler_type_refers_to_cause_rather_than_respelling_it( + self, + ) -> None: + """Otherwise the two could drift and only one would be corrected.""" + parameters, _ = get_args(Handler) + assert parameters == [str, Cause] - seen: list[tuple[str, str]] = [] - events.on_session_end( - lambda session_id, cause: _record(seen, session_id, cause) - ) - await events.start() - await asyncio.wait_for(events._task, timeout=5) - await events.stop() +class TestTheDeliveryPath: + """N-14: the documented recipe has to be executable code under test. + + Subscribe, receive a message, fire a handler. That is where the channel + names, the ``pubsub()`` lifecycle and the message-shape filtering live, + so a typo in a channel name must fail here. + + Driven through a scripted Pub/Sub rather than a live server, because the + logic is deterministic. ``test_session_integration.py`` checks the same + path against a real server. + """ + + def test_the_channels_match_the_documented_format(self) -> None: + assert _IDLE_CHANNEL.format(db=0) == "__keyevent@0__:hexpired" + assert _ABSOLUTE_CHANNEL.format(db=3) == "__keyevent@3__:expired" + + async def test_it_subscribes_to_both_channels_for_its_own_database( + self, + ) -> None: + redis = _ScriptedRedis([]) + await _run(redis, db=7) + assert sorted(redis.pubsub_obj.subscribed) == [ + "__keyevent@7__:expired", + "__keyevent@7__:hexpired", + ] + + async def test_a_field_expiry_reads_as_idle(self) -> None: + sid = "a" * 40 + assert await _run(_ScriptedRedis([_event("hexpired", sid)])) == [(sid, "idle")] - assert seen == [(first, "idle"), (second, "absolute")] + async def test_a_key_expiry_reads_as_absolute(self) -> None: + sid = "a" * 40 + seen = await _run(_ScriptedRedis([_event("expired", sid)])) + assert seen == [(sid, "absolute")] + + async def test_another_database_s_channel_is_ignored(self) -> None: + """The cause comes from the channel, so only our own two count.""" + seen = await _run(_ScriptedRedis([_event("expired", "a" * 40, db=1)])) + assert seen == [] async def test_non_message_frames_are_ignored(self) -> None: """Only ``type == "message"`` is an event, whatever the frame carries. - Redis's own ``subscribe`` confirmation carries a subscription count, - which would not parse as a payload anyway - so the frames below carry - a **valid** payload deliberately. Otherwise the assertion holds even - with the type filter deleted, and the filter is what makes the rule - "one channel, one frame type" true rather than incidental. + The frames below carry a **valid** channel and payload deliberately. + Otherwise the assertion holds even with the type filter deleted. """ sid = "a" * 40 - valid = _hexpired(sid, "d")["data"] - redis = _ScriptedRedis( - [ - {"type": "subscribe", "data": 1}, - {"type": "psubscribe", "data": valid}, - {"type": "pmessage", "data": valid}, - _hexpired(sid, "d"), - ] + valid = _event("hexpired", sid) + seen = await _run( + _ScriptedRedis( + [ + {**valid, "type": "subscribe"}, + {**valid, "type": "psubscribe"}, + {**valid, "type": "pmessage"}, + valid, + ] + ) ) - events = SessionEvents(redis, key_prefix="redis:fastapi") - seen: list[tuple[str, str]] = [] - events.on_session_end( - lambda session_id, cause: _record(seen, session_id, cause) - ) - await events.start() - await asyncio.wait_for(events._task, timeout=5) - await events.stop() assert seen == [(sid, "idle")] - async def test_another_applications_hash_is_ignored(self) -> None: - """The channel is server-wide; most traffic on it is not ours.""" - redis = _ScriptedRedis( - [ - _hexpired("x" * 40, "d", prefix="someone-else"), - {"type": "message", "data": "9:other:key|1:d"}, - ] - ) - events = SessionEvents(redis, key_prefix="redis:fastapi") - seen: list[tuple[str, str]] = [] - events.on_session_end( - lambda session_id, cause: _record(seen, session_id, cause) + async def test_another_applications_key_is_ignored(self) -> None: + """The channels are server-wide; most traffic on them is not ours.""" + seen = await _run( + _ScriptedRedis( + [ + _event("expired", "x" * 40, prefix="someone-else"), + { + "type": "message", + "channel": "__keyevent@0__:expired", + "data": "other:key", + }, + ] + ) ) - await events.start() - await asyncio.wait_for(events._task, timeout=5) - await events.stop() assert seen == [] async def test_a_lost_subscription_ends_quietly(self) -> None: diff --git a/tests/unit/test_session_failures.py b/tests/unit/test_session_failures.py index c1c917a..dfd9697 100644 --- a/tests/unit/test_session_failures.py +++ b/tests/unit/test_session_failures.py @@ -193,23 +193,22 @@ async def test_a_failed_liveness_check_answers_unknown_not_empty( assert await broken._verify(["a" * 30]) is None -class TestUnreachableStates: - async def test_a_field_with_no_expiry_is_treated_as_absent( - self, fake_async_redis, caplog +class TestUnexpectedStates: + async def test_a_key_with_no_expiry_is_treated_as_absent( + self, fake_async_redis ) -> None: - """The ``-1`` row of the state table, which must be unreachable. + """A session key with no TTL has no deadline, so it is not a session. - Reaching it means something outside this store wrote the key, so the - store says so loudly and refuses the session rather than guessing. + A save that lands after the session ended leaves one; so does a key + written by something outside this store. The store refuses the + session rather than guessing, and deletes the key. """ store = RedisSessionStore(fake_async_redis, idle_ttl=60, absolute_ttl=600) sid = store.new_id() key = store.session_key(sid) - await fake_async_redis.hset(key, mapping={"a": "1", "d": "{}"}) + await fake_async_redis.hset(key, mapping={"d": "{}"}) - with caplog.at_level("ERROR"): - assert await store.load(sid) is None - assert "no expiry" in caplog.text + assert await store.load(sid) is None assert await fake_async_redis.exists(key) == 0 async def test_a_corrupt_record_raises_rather_than_signing_the_user_out( diff --git a/tests/unit/test_session_index.py b/tests/unit/test_session_index.py index 2bab5c8..00822f8 100644 --- a/tests/unit/test_session_index.py +++ b/tests/unit/test_session_index.py @@ -11,7 +11,6 @@ from redis_fastapi.config import get_settings from redis_fastapi.session_backend import ( - FIELD_ABSOLUTE, FIELD_DATA, RedisSessionStore, ) @@ -83,15 +82,18 @@ async def test_an_idle_dead_session_is_not_reported( listed = {info.session_id for info in await store.list_for_subject("42")} assert listed == {live} - async def test_a_session_past_its_absolute_deadline_is_not_reported( + async def test_a_key_with_no_deadline_is_not_reported( self, store: RedisSessionStore, fake_async_redis ) -> None: - """Why liveness checks both clocks, not just the idle one.""" + """Why liveness checks the key's TTL, not just the idle field. + + A save that lands after the session ended recreates the key with a + fresh ``d`` and no deadline. The load treats that as over, so the + listing must too. + """ live = await _make(store, "42") - absolute_dead = await _make(store, "42") - await fake_async_redis.execute_command( - "HDEL", store.session_key(absolute_dead), FIELD_ABSOLUTE - ) + no_deadline = await _make(store, "42") + await fake_async_redis.persist(store.session_key(no_deadline)) listed = {info.session_id for info in await store.list_for_subject("42")} assert listed == {live} diff --git a/tests/unit/test_session_regressions.py b/tests/unit/test_session_regressions.py index db533aa..576a768 100644 --- a/tests/unit/test_session_regressions.py +++ b/tests/unit/test_session_regressions.py @@ -24,7 +24,6 @@ ) from redis_fastapi.exceptions import SessionStoreError from redis_fastapi.session_backend import ( - FIELD_ABSOLUTE, RedisSessionStore, SessionStoreProtocol, ) @@ -92,12 +91,13 @@ def _plain_http(monkeypatch): class TestTheAbsoluteDeadlineCannotBeResurrected: - """N-6, the case ``FNX`` does not cover. + """N-6: a save that lands after the deadline must not restore it. - An expired field is an *absent* field, so a conditional write recreated - the deadline with a full fresh lifetime whenever a request straddled it. - The window is one request long and recurs every cycle, so an actively - used session never died. + A request can load a live session and write it back after the key + expired. If that write set a deadline, it would set a full fresh + lifetime; the window is one request long and recurs every cycle, so an + actively used session would never die. An expired key is an absent key, + so the case is reproduced with ``DEL``. """ async def test_a_save_after_the_deadline_lapsed_does_not_restore_it( @@ -107,24 +107,23 @@ async def test_a_save_after_the_deadline_lapsed_does_not_restore_it( key = store.session_key(sid) await store.create(sid, store.new_record({"user_id": 42})) - await fake_async_redis.execute_command("HDEL", key, FIELD_ABSOLUTE) + await fake_async_redis.delete(key) await store.save(sid, store.new_record({"user_id": 42})) - reply = await fake_async_redis.execute_command( - "HTTL", key, "FIELDS", 1, FIELD_ABSOLUTE + assert await fake_async_redis.ttl(key) == -1, ( + "the absolute deadline was recreated" ) - assert int(reply[0]) == -2, "the absolute deadline was recreated" async def test_such_a_session_is_dead_on_the_next_load( self, store: RedisSessionStore, fake_async_redis ) -> None: sid = store.new_id() + key = store.session_key(sid) await store.create(sid, store.new_record({"user_id": 42})) - await fake_async_redis.execute_command( - "HDEL", store.session_key(sid), FIELD_ABSOLUTE - ) + await fake_async_redis.delete(key) await store.save(sid, store.new_record({"user_id": 42})) assert await store.load(sid) is None + assert await fake_async_redis.exists(key) == 0 async def test_save_has_no_argument_that_could_write_the_deadline(self) -> None: """The guarantee is structural, not a runtime check.""" @@ -542,8 +541,8 @@ class TestCookieMaxAgeInEveryBranch: The value is the absolute remainder, never the idle clock. The idle clock slides on every request, but read-only responses send no cookie, so a - cookie sized by it expired while the record was alive (research §6 of - ``session-di-factory-research.md``). + cookie sized by it expired while the record was alive (§6 of + ``session-design.md``). """ @pytest.mark.parametrize( From ef6250dc1737c1876118144fb687279c72514018 Mon Sep 17 00:00:00 2001 From: Tihomir Mateev Date: Fri, 25 Sep 2026 23:41:43 +0300 Subject: [PATCH 09/11] Add valid_session() to gate routes on a valid session It rejects a request whose cookie names no live session, with the reason missing, expired or unavailable, and a 401 or an on_reject response. issued_within=N also requires the session ID to be at most N seconds old, from the key's TTL, for step-up before sensitive actions. Gated cached routes send private, recent-session routes send no-store, and listing the gate after cache() fails on the first request. --- docs/api/reference.md | 66 +- docs/guide/caching.md | 9 +- docs/guide/sessions.md | 203 ++++- docs/specs/session-design.md | 388 ++++---- src/redis_fastapi/__init__.py | 12 + src/redis_fastapi/cache.py | 42 +- src/redis_fastapi/config.py | 6 + src/redis_fastapi/exceptions.py | 20 + src/redis_fastapi/session_backend.py | 63 +- src/redis_fastapi/sessions.py | 321 ++++++- src/redis_fastapi/setup.py | 6 + src/redis_fastapi/telemetry.py | 12 +- src/redis_fastapi/types.py | 56 +- .../test_session_cache_invariants.py | 42 + .../test_valid_session_integration.py | 170 ++++ tests/unit/test_valid_session.py | 852 ++++++++++++++++++ 16 files changed, 2050 insertions(+), 218 deletions(-) create mode 100644 tests/integration/test_valid_session_integration.py create mode 100644 tests/unit/test_valid_session.py diff --git a/docs/api/reference.md b/docs/api/reference.md index ff19f4f..a5c5999 100644 --- a/docs/api/reference.md +++ b/docs/api/reference.md @@ -122,7 +122,9 @@ On a **cache hit** the endpoint is skipped (response served from Redis). On a ** | `eviction_group` | `str` | `""` | Namespace segment in the cache key | | `cache_prefix` | `str \| None` | `settings.pattern_prefix("cache")` | Key prefix override | | `key_builder` | `KeyBuilder \| None` | `default_key_builder` | Custom key builder | -| `private` | `bool` | `False` | Emit `Cache-Control: private` | +| `private` | `bool` | `False` | Emit `Cache-Control: private`. Added automatically when `valid_session()` gated the request | +| `vary_on_session` | `bool \| None` | `None` | `True`: one entry per user, `private`. `False`: one shared entry, no `Vary: Cookie`. `None`: a response that read the session is served but not stored | +| `no_store` | `bool` | `False` | Emit `Cache-Control: no-store` on the miss and every hit; the entry is still kept in Redis. Added automatically under `valid_session(issued_within=...)` | ### `cache_evict()` @@ -393,6 +395,60 @@ middleware and your handlers. --- +## `valid_session()` + +```python +from fastapi import Depends +from redis_fastapi import valid_session + +@app.post("/checkout", dependencies=[Depends(valid_session())]) +async def checkout(): ... + +@app.post("/account/email", dependencies=[ + Depends(current_user), + Depends(valid_session(issued_within=600, on_reject=my_rejection)), +]) +async def change_email(): ... +``` + +A dependency that rejects a request without a valid session: the cookie must +name a record that was live in Redis when the request arrived, and nothing +earlier in the request may have ended it. It does not ask who the session +belongs to. + +| Parameter | Type | Default | Description | +|---|---|---|---| +| `issued_within` | `int \| timedelta \| None` | `None` | Also require the session ID to have been issued at most this long ago. Adds the reason `"stale"` and makes the response `Cache-Control: no-store`. Not an authentication check | +| `on_reject` | `OnReject[SessionRejection]`, or `OnReject[RecencyRejection]` with `issued_within` | `None` | Receives the request and the reason, returns the response (sync or async). Default: `401` | + +| Reason | Meaning | +|---|---| +| `"missing"` | No usable session: no cookie, a malformed one, or one an earlier dependency revoked or emptied | +| `"expired"` | A well-formed cookie naming no live session | +| `"unavailable"` | The read failed and the request continued without a session | +| `"stale"` | Only with `issued_within`: the session ID was issued longer ago than that | + +The default rejection is `HTTPException(401, "No valid session")`, with a +`WWW-Authenticate` header only when the `challenge` setting is configured. For +`"stale"` it is a `401` with the body +`{"detail": ..., "error": "stale_session", "issued_within": N}`. + +The gate marks the session as read. When it passes, a following +`cache(vary_on_session=False)` adds `private`. Listed **after** such a +`cache()`, it raises `SessionConfigurationError` on the first request. It also +raises that error on a route excluded by `skip`, and raises `TypeError` when +`on_reject` returns something other than a `Response`. + +| Type | Values | +|---|---| +| `SessionRejection` | `Literal["missing", "expired", "unavailable"]` | +| `RecencyRejection` | `SessionRejection` plus `"stale"` | +| `OnReject[R]` | `Callable[[Request, R], Response \| Awaitable[Response]]` | +| `Challenge` | `str \| SecurityBase \| Callable[[Request, str], str \| None]` | +| `SessionRejected` | The exception that carries a rejection response out of the dependency. Control flow, not a `SessionError` | + +--- + ## Session setup ### `add_redis_sessions()` / `FastAPIRedis.sessions()` @@ -404,6 +460,7 @@ FastAPIRedis(app).lifespan().sessions( descriptor_of=lambda req, s: {"ip": req.client.host}, cookie_builder=my_builder, skip=lambda req: req.url.path.startswith("/health"), + challenge=None, store=None, store_factory=None, coder=JsonCoder, encryptor=None, id_factory=None, key_prefix=None, idle_ttl=None, absolute_ttl=None, gc_ttl=None, @@ -417,6 +474,7 @@ FastAPIRedis(app).lifespan().sessions( | `descriptor_of` | What a device listing shows. Stored beside the session, never inside it. | | `cookie_builder` | Renders `Set-Cookie`, for setting **and** clearing. | | `skip` | Requests needing no session, at zero Redis cost. | +| `challenge` | The `WWW-Authenticate` header on a `valid_session()` rejection: a FastAPI security scheme, a string, or a callable of the request and the reason. Omitted by default; `Basic` is refused. | | `store` / `store_factory` | Supply a whole store. Mutually exclusive. | | `coder`, `encryptor`, `id_factory`, `key_prefix`, `idle_ttl`, `absolute_ttl`, `gc_ttl` | Passed to the store constructor. TTLs accept `int` seconds or `timedelta`. | @@ -431,6 +489,8 @@ one without inheritance. | Method | Purpose | |---|---| | `load(session_id, *, refresh=True)` | Read a session and restart its idle clock. `None` for every way it can be absent. | +| `load_with_status(session_id, *, refresh=True)` | `load()`, plus whether the read itself failed. | +| `session_age(state)` | Seconds since the session ID was issued, from the key's TTL. `None` without a stored session, or for one created under a longer `session_absolute_ttl`. | | `create(session_id, record)` | Write a session that does not exist yet. The only method that writes the absolute deadline. | | `save(session_id, record)` | Update the payload, and only the payload. | | `touch(session_id)` | Restart the idle clock alone. | @@ -473,6 +533,10 @@ SessionError └── SessionStoreError the store failed; wraps the driver error ``` +`SessionRejected` is not a `SessionError`: it carries a `valid_session()` +rejection out of the dependency, and the handler that `.sessions()` registers +returns its response. + A failed **read** yields an empty session unless `session_fail_closed` is set. A failed **write** always raises. `count_for_subject` always raises, because there the store is the authorization answer. diff --git a/docs/guide/caching.md b/docs/guide/caching.md index c3c54b7..8ec9dfb 100644 --- a/docs/guide/caching.md +++ b/docs/guide/caching.md @@ -83,6 +83,8 @@ Depends(cache( prefix="custom:prefix", # override the default key prefix key_builder=my_key_builder, # custom key function (sync or async) private=True, # emit Cache-Control: private (see below) + vary_on_session=True, # the body depends on the session (see the sessions guide) + no_store=True, # emit Cache-Control: no-store; Redis still keeps the entry )) ``` @@ -217,7 +219,12 @@ client are respected: `Cache-Control: private, max-age=…`. This tells CDNs and shared proxies **not** to store the response - only the end-user's browser may cache it. See [MDN: Cache-Control: private](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control#private) and -[MDN: Private caches](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/Caching#private_caches) +[MDN: Private caches](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/Caching#private_caches). +`private` is added for you when +[`valid_session()`](sessions.md#requiring-a-valid-session) gates the route. +Use `no_store=True` to send `Cache-Control: no-store` instead, so that no +cache keeps the response, the browser's included; the entry is still kept in +Redis. ```python # User-specific data - must not be cached by a CDN diff --git a/docs/guide/sessions.md b/docs/guide/sessions.md index f324582..4d4760e 100644 --- a/docs/guide/sessions.md +++ b/docs/guide/sessions.md @@ -162,6 +162,158 @@ are not signed in on and a sign-out button that does nothing. --- +## Requiring a valid session + +```python +from fastapi import Depends +from redis_fastapi import valid_session + +@app.post("/checkout", dependencies=[Depends(valid_session())]) +async def checkout(session: SessionDep) -> dict: ... +``` + +`valid_session()` rejects a request that did not arrive with a session this +application created in an earlier response. A value a client makes up finds no +record in Redis, so it fails, whatever its shape. + +It checks nothing else. It does not ask who the session belongs to: an +anonymous session that holds a basket passes. Identity and roles stay with your +own authentication. + +The default rejection is a `401`. To build your own, pass `on_reject`. It +receives the request and a reason, and returns the response: + +```python +from fastapi import Request +from fastapi.responses import JSONResponse, RedirectResponse, Response +from redis_fastapi import SessionRejection + +def start_over(request: Request, reason: SessionRejection) -> Response: + if reason == "unavailable": + return JSONResponse({"detail": "Try again shortly"}, status_code=503) + return RedirectResponse("/?expired=1" if reason == "expired" else "/", status_code=303) + +@app.get("/basket", dependencies=[Depends(valid_session(on_reject=start_over))]) +async def basket(session: SessionDep) -> dict: ... +``` + +| Reason | What happened | +|---|---| +| `"missing"` | No usable session: no cookie, a malformed one, or a session that an earlier dependency revoked or emptied | +| `"expired"` | A well-formed cookie that names no live session: expired, revoked on another device, evicted or forged. The server cannot tell these apart. | +| `"unavailable"` | Redis could not be read, and the request continued without a session. Answer `503`, not a sign-in page. | + +`on_reject` may be sync or async. It **returns** the response instead of +raising it, so a callback that forgets to return fails with a `500` instead of +letting the request through. + +The gate also: + +- **marks the session as read**, so the response carries `Vary: Cookie` and + `Cache-Control: private`; +- **counts malformed cookies**, on every request, gated or not, as the session + operation metric with `result="malformed"`. Every identifier the store issues + passes the format check, so a malformed value was never ours. The middleware + neither logs it nor clears it: the cookie may belong to another application + on the same domain; +- **refuses a route excluded by `skip`** with `SessionConfigurationError`, + because such a route can never pass. + +### The `WWW-Authenticate` header + +By default a rejection sends none, as most frameworks do for cookie sessions: +the browser shows the body, and no password dialog. To send one, set +`challenge` once for the application: + +```python +from fastapi.security import APIKeyCookie + +# The security scheme your application already declares. +FastAPIRedis(app).lifespan().sessions(challenge=APIKeyCookie(name="session")) + +# A fixed value. +FastAPIRedis(app).lifespan().sessions( + challenge='Cookie realm="shop" form-action="/login" cookie-name=session', +) + +# A value that depends on the request or the reason; None sends no header. +FastAPIRedis(app).lifespan().sessions( + challenge=lambda request, reason: None if reason == "unavailable" else "APIKey", +) +``` + +`Basic` is refused: every browser answers it with a password dialog, on every +gated page. The header goes on the default rejection only; a response from +`on_reject` is your own. + +### Requiring a recently issued session + +```python +@app.post( + "/account/email", + dependencies=[ + Depends(current_user), # your authentication + Depends(valid_session(issued_within=600)), # the session ID is at most 10 minutes old + ], +) +async def change_email() -> dict: ... + +@app.post("/confirm-password") +async def confirm( + form: PasswordForm, state: SessionStateDep, store: SessionStoreDep +) -> dict: + if not await verify(form.password): + raise HTTPException(401) + await store.reauthenticate(state) # issues a new session ID + return {"ok": True} +``` + +`issued_within` asks one more question: was this session's ID issued within the +last N seconds? A session gets a new ID when it is created, when the principal +changes - a sign-in, a role change - and when you call `store.rotate()` or +`store.reauthenticate()`. Reads and writes keep the ID. The age comes from the +session key's TTL in Redis, so a container with a wrong clock cannot make an old +session look recent. + +This is the building block for a step-up before a sensitive action, as +[OWASP ASVS V7.5](https://github.com/OWASP/ASVS) asks: your route checks the +password and calls `reauthenticate()`, and for the next `issued_within` seconds +the sensitive routes pass. + +!!! warning "`issued_within` is not an authentication check" + - **A new anonymous session is recent.** Pair it with your own + authentication dependency, as in the example. + - **Every new ID counts.** If your principal includes something a user can + change without a password - an active tenant, say - changing it makes the + session recent. + - **A handler that calls `store.rotate()` makes the session recent too.** + +A session that is too old gets the reason `"stale"`. The default response is: + +```json +401 +{"detail": "A recently issued session is required", + "error": "stale_session", + "issued_within": 600} +``` + +Branch on `error` in the client: open a password prompt, then retry. Every +response from such a route, passed or rejected, says `Cache-Control: no-store`, +so no cache keeps it, the browser's included. For a "confirm your password to +continue" banner, `store.session_age(state)` returns the age in seconds. + +Two side effects to know: + +- **A step-up changes the cookie**, because `reauthenticate()` rotates the + session. A form open in another tab, carrying a CSRF token from the old + session, fails after it. +- **Lowering `session_absolute_ttl` makes older sessions stale** until they + expire. The age is the configured lifetime minus the time left, and a session + created under the longer lifetime has more time left than the new one allows. + It is reported as `"stale"`, never as recent. + +--- + ## When Redis is unreachable The default is asymmetric by design: @@ -338,6 +490,34 @@ declaration exists to protect. **This is an assertion, and the library takes your word for it.** If the body does depend on the session, you have re-enabled the leak deliberately. +With [`valid_session()`](#requiring-a-valid-session) as the gate, two things +change: + +```python +@app.get( + "/members/catalogue", + dependencies=[ + Depends(valid_session()), # first + Depends(cache(ttl=300, vary_on_session=False)), # then the cache + ], +) +async def members_catalogue() -> list[dict]: + return await load_products() +``` + +- **The gate goes first.** FastAPI resolves `dependencies=[...]` in order, and + a cache hit ends the resolution, so in the other order a hit would be served + before the gate runs - to anyone. `valid_session()` detects that order and + raises `SessionConfigurationError` on the first request, before anything is + stored. +- **The response says `private`.** Redis still keeps one shared entry, because + the gate runs before it on every request. A CDN cannot check a session, so it + would serve that entry to anyone; `private` keeps it out. + +A hand-written gate like `require_user` gets neither. List it before `cache()` +yourself, and pass `cache(..., private=True)` when the route sits behind a CDN +or another shared cache. + ### Saying nothing If the endpoint never touches the session, say nothing - there is nothing to @@ -369,12 +549,14 @@ depends on it. |---|---|---|---| | Body depends on the user | `vary_on_session=True` | one entry per user | `private, max-age=N` + `Vary: Cookie` | | Reads the session, body identical | `vary_on_session=False` | one shared entry | `max-age=N`, no `Vary` | +| Gated by `valid_session()`, body identical | `vary_on_session=False` | one shared entry | `private, max-age=N`, no `Vary` | +| Gated by `valid_session(issued_within=...)` | any | as declared | `no-store` | | Never touches the session | *(nothing)* | one shared entry | `max-age=N` | | Touches it, nothing declared | *(nothing)* | **not cached** | `private, no-store` + `Vary: Cookie` | -The rule underneath all four rows: **what a response tells other caches they -may do is never more permissive than what this library does itself.** If we key -per user, we say +The rule underneath every row: **what a response tells other caches they may do +is never more permissive than what this library does itself.** If we key per +user, or serve our entry only to callers who pass the gate, we say [`private`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/Caching#private_caches). If we refuse to store, we say [`no-store`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Cache-Control#no-store). @@ -386,6 +568,21 @@ Reading the session on an uncached route emits `Cache-Control: private` and it owns the header outright - one writer, so you never see two contradictory `Cache-Control` values on one response. +For a sensitive page - an order history, a bank statement - set +`Cache-Control: no-store` in the handler. The middleware then adds nothing, so +the browser does not keep the page and cannot show it again from history after +a sign-out: + +```python +@app.get("/orders") +async def orders(session: SessionDep, response: Response) -> dict: + response.headers["Cache-Control"] = "no-store" + return {"orders": await db.orders_for(session["user_id"])} +``` + +On a cached route, pass `cache(..., no_store=True)` instead: the entry is still +kept in Redis, and every miss and hit says `no-store`. + --- ## Limitation: WebSockets have no session in this release diff --git a/docs/specs/session-design.md b/docs/specs/session-design.md index c11ae23..8e737e7 100644 --- a/docs/specs/session-design.md +++ b/docs/specs/session-design.md @@ -59,7 +59,7 @@ item is excluded. Each row carries an ID so a commit, a test or a review comment | N-15 | Maintainability | Nothing ships gated on an unmerged upstream change. | | N-16 | Maintainability | File split, dependency injection, settings and telemetry follow the existing cache and rate-limit code. An abstract base owns the lifecycle; a protocol bounds what callers touch. | | N-17 | Correctness | No correctness claim rests on a notification. Every guarantee holds with events switched off, because Pub/Sub delivery can be dropped and an expiry event can lag the deadline it reports. | -| N-18 | Correctness | The cache directives a response carries are never more permissive than the caching this library itself performs for that response. Keyed per user ⇒ `private`; refused entirely ⇒ `no-store`; one shared entry ⇒ a shared directive is equally permissive and nothing more is required. | +| N-18 | Correctness | The cache directives a response carries are never more permissive than the caching this library itself performs for that response. Keyed per user ⇒ `private`; refused entirely ⇒ `no-store`; one shared entry served to anyone ⇒ a shared directive is equally permissive and nothing more is required; one shared entry served only after `valid_session()` passes ⇒ `private`, because a shared cache would serve it to callers the gate refuses (`session-di-factory-research.md`, S-1.6). | ### 0.3 Out of scope @@ -77,7 +77,7 @@ item is excluded. Each row carries an ID so a commit, a test or a review comment | X-10 | Deferred | Re-rotating a live session on a schedule (OWASP's renewal timeout). | No timer in v1. Rotation on authentication and privilege change is automatic (Section 5.1); only the time-based variety is absent. A genuine gap rather than a boundary. | | X-11 | Recipe, not code | A shipped AES-GCM encryptor. | Ship the seam and document the ten lines, rather than owning cryptographic code and its vulnerabilities. | | X-12 | Recipe, not code | Hijack detection by IP and User-Agent; `Clear-Site-Data`, `Partitioned` and `__Host-` cookies; a Stream-backed audit log; per-tenant key namespaces and Cluster hash tags. | Each is reachable through a seam that already exists, so none needs code from us. | -| X-13 | Deferred | The `HIMPORT` family (Redis 8.10) for writing session keys. | `HIMPORT SET` takes no expiration option and overwrites the key, so it would destroy field `a` and its absolute deadline on every write — the one thing N-6 forbids. What it saves is field names on the wire, and ours are `a` and `d`. Section 13.5 gives the full reckoning. | +| X-13 | Deferred | The `HIMPORT` family (Redis 8.10) for writing session keys. | `HIMPORT SET` takes no expiration option and overwrites the key, so it would destroy the key's TTL - the absolute deadline - on every write, the one thing N-6 forbids. What it saves is field names on the wire, and ours is `d`. Section 13.5 gives the full reckoning. | ## 1. Three corrections to `session-mgmt.md` @@ -134,7 +134,7 @@ Two new files, following the split that `cache.py` / `cache_backend.py` and |----------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------| | `src/redis_fastapi/session_backend.py` | `SessionStore` (ABC, owns the lifecycle), `RedisSessionStore`, **`SyncSessionStore`**, `SessionStoreProtocol`, `SessionMetadata`, `SessionRecord` | | `src/redis_fastapi/session_events.py` | `SessionEvents`, the tier probe, and the per-node subscriber task. Separate because it owns a background task and a Pub/Sub connection, which neither of the other two files does. Section 13.4 | -| `src/redis_fastapi/sessions.py` | `Session`, `SessionMiddleware`, the `session()` dependency factory, `add_redis_sessions()`, the cookie builder, the exceptions | +| `src/redis_fastapi/sessions.py` | `Session`, `SessionMiddleware`, the `valid_session()` dependency factory and its `issued_within` parameter (see `session-di-factory-research.md`), `add_redis_sessions()`, the cookie builder, the exceptions | Changes to some of the existing files include : @@ -167,8 +167,7 @@ Two keys, and they answer two different questions. ``` # "Given this session ID from the cookie, what is the session?" -redis:fastapi:session: HASH - field "a" = "1" TTL = absolute the deadline marker +redis:fastapi:session: HASH, key TTL = absolute field "d" = TTL = idle the payload # "Given this user, which sessions do they have open?" <-- the reverse direction @@ -180,11 +179,13 @@ The first key is the one every request uses. The second exists **only** because things in Section 3 of `session-mgmt.md` need to go the other way — from a user to their sessions — and the cookie cannot answer that. Section 3.3 explains it. -Both are hashes with a TTL on each field, from -`settings.pattern_prefix("session")` and `settings.pattern_prefix("sessions-of")`. +Both are hashes, from `settings.pattern_prefix("session")` and +`settings.pattern_prefix("sessions-of")`. The session key carries the absolute deadline +as its own TTL, and its one field carries the idle clock. The index has a TTL on each +field and none on the key. -**Field `a` is always written, and always carries a TTL.** Section 3.2 explains why: a -missing `a` must mean one thing only. +**A session key always has a TTL.** Section 3.2 explains why: a key without one is not a +live session. #### The two keys cannot collide, and not only by convention @@ -211,54 +212,59 @@ shard. Section 5 explains how correctness survives without co-location. `docs/getting-started/installation.md:29`), and hash-field expiration arrived in 7.4. So every design below sits inside the support range we already promise. -### 3.2 Two fields, two clocks, no arithmetic +### 3.2 Two clocks, two TTLs, no arithmetic Section 3 of `session-mgmt.md` requires an idle timeout **and** an absolute timeout, and -requires that *"expiration must be enforced server-side"*. Two fields with two TTLs do -exactly that: - -| Field | TTL | Refreshed? | -|---|---|---| -| `a` | the absolute lifetime, set once at creation | **never** | -| `d` | the idle timeout | on every access | - -When `d` expires the session went idle. When `a` expires the absolute deadline passed, -whatever the user was doing. **Redis enforces both, and we compute neither.** - -#### Field `a` is always written, and always has a TTL - -Not an implementation detail — a correctness requirement, and getting it wrong disables -the whole feature for one supported configuration. - -`HTTL` answers with `-2` when a field is absent **or** its key is absent, and with `-1` -when the field exists with no expiry. An earlier draft read `-2` as "the absolute deadline -passed" and did not write `a` at all when `absolute_ttl` was `0`. Section 3.2 permits that -setting, and Section 9.4 of `session-mgmt.md` maps `starsessions`' `lifetime=0` onto it — -so for those deployments `HTTL` answered `-2` on every load and **every session was read -as expired the moment it was created.** - -Two rules remove the ambiguity: - -1. **Write `a` on every session creation, without exception.** This also keeps every - session key to one schema, which Section 13.2 depends on. -2. **Give `a` a TTL even when no absolute limit is configured.** With `absolute_ttl = 0` - it gets `gc_ttl`. Never leave it unexpiring: if `d` later expires and `a` does not, the - key survives with nobody to collect it. So `-1` never occurs, and `-2` carries exactly - one meaning. - -Section 4.1 reads the resulting state as a two-by-two, not as a single sentinel. - -This matters more than the round trip it saves. If we instead kept one key and set its -TTL to `min(absolute_remaining, idle)`, then the absolute deadline would be a number our -code recalculates on every write, and one arithmetic bug would let a session outlive its -absolute limit without any test noticing. With two fields that outcome is not a bug we -must avoid — it is unreachable. For a security control, the difference is the whole -point. +requires that *"expiration must be enforced server-side"*. Two TTLs do exactly that: + +| Clock | Where | TTL | Refreshed? | +|---|---|---|---| +| absolute | the session key | the absolute lifetime, set once at creation | **never** | +| idle | field `d` | the idle timeout | on every access | + +When `d` expires the session went idle, and the key - which then holds nothing - goes +with it. When the key expires the absolute deadline passed, whatever the user was doing, +and Redis deletes the payload with it. **Redis enforces both, and we compute neither.** + +#### Why the deadline is on the key + +An earlier design kept the deadline in a second field, `a`, with its own TTL. Redis then +deleted only `a` at the deadline; `d` stayed alive as long as requests refreshed it. The +deadline held only because every reader asked about `a` too and rejected the session when +it was gone - the load, and the index verification - and a new reader that forgot would +have served sessions past their absolute limit. On the key, the deadline needs no reader: +at the deadline nothing is left to read. Section 13 notes the second gain, in keyspace +notifications. + +#### A session key always has a TTL + +Not an implementation detail - a correctness requirement. + +1. **Every create sets the key's TTL, without exception.** `EXPIRE` on a missing key does + nothing, so the create writes `d` first and then sets the TTL. A pipeline cut between + the two leaves a key with no TTL - but the write raised, so no cookie names it. +2. **The TTL is set even when no absolute limit is configured.** With `absolute_ttl = 0` + the key gets `gc_ttl`. An earlier design wrote no deadline at all in that case and read + the missing deadline as "passed", so **every session was read as expired the moment it + was created** - in exactly the configuration Section 9.4 of `session-mgmt.md` maps + `starsessions`' `lifetime=0` onto. + +So a key with no TTL is never a live session. A save that lands after the key expired or +was revoked recreates the key with `d` and no TTL; the load reads that as over and deletes +it (Section 4.1). A save must never set the TTL itself: if it did, a save landing just +after the deadline would restore the session with a full fresh lifetime, and an actively +used session would never die (N-6). + +This matters more than the round trip it saves. If we instead kept one TTL and set it to +`min(absolute_remaining, idle)`, then the absolute deadline would be a number our code +recalculates on every write, and one arithmetic bug would let a session outlive its +absolute limit without any test noticing. With two TTLs that outcome is not a bug we must +avoid - it is unreachable. For a security control, the difference is the whole point. Both settings are an `int` number of seconds. The store constructor and `.sessions()` also accept a `timedelta`; Section 9 gives the convention and why a settings field cannot sensibly take one. With both at zero the session is cookie-only: no `max-age` on the cookie, so the -browser drops it when it closes, and **both** fields get `gc_ttl` so Redis eventually +browser drops it when it closes, and **both** clocks get `gc_ttl` so Redis eventually collects what the browser abandoned. ### 3.3 The subject index: from a user back to their sessions @@ -339,9 +345,11 @@ destroy the single-round-trip read in Section 4.2. which are.** `list_for_subject` and `revoke_all` both: 1. `HGETALL` the index — one round trip, giving candidates and their descriptors. -2. Pipeline one `HTTL FIELDS 1 d` for each candidate — a second round trip, - whatever the number of candidates. -3. Drop the candidates whose `d` is gone, and `HDEL` them from the index. +2. Pipeline `HTTL FIELDS 1 d` and `TTL ` for each candidate — + a second round trip, whatever the number of candidates. +3. Drop the candidates where either answer has no time left, and `HDEL` them from the + index. The key's `TTL` catches a key a late save recreated with no deadline, which the + load also treats as over. That is two round trips instead of one, on two operations that a user triggers by hand — opening a screen, or changing a password. The request path is untouched. In exchange the @@ -361,7 +369,7 @@ Three properties survive, and neither a set nor a sorted set gives all three: 2. **The key bounds itself.** When the last field expires, Redis deletes the hash. A user who never returns leaves nothing behind, and there is no TTL to maintain on the key. 3. **The value carries a descriptor**, so the listing needs no read of the session records - themselves — only the cheap `HTTL` liveness check. Put the creation time, the client + themselves — only the cheap `HTTL` and `TTL` liveness check. Put the creation time, the client address and a device label in it, and the "your active sessions" screen that Section 3 of `session-mgmt.md` asks for costs two round trips regardless of session count. @@ -375,8 +383,8 @@ an entry at 98 seconds of a 100-second lifetime went back to 100 on re-assert. A used session would therefore hold an index entry that never expires and outlives the session it describes — reintroducing the phantom this section exists to prevent. -Use the remaining absolute time. Section 4.1 already reads it, as `HTTL` on field `a`, so -it costs no extra command. `EXAT` against the stored deadline is equivalent; what must not +Use the remaining absolute time. Section 4.1 already reads it, as `TTL` on the session +key, so it costs no extra command. `EXAT` against the stored deadline is equivalent; what must not happen is a fresh full lifetime. #### Why not the query engine instead? @@ -565,32 +573,35 @@ middleware is the last place in the chain that can still perform an `await`. ``` HGETEX EX FIELDS 1 d -> the payload, and the idle clock restarts - HTTL FIELDS 1 a -> what remains of the absolute deadline + TTL -> what remains of the absolute deadline ``` `HGETEX` reads a field **and** sets its expiration in one command, so the load *is* the idle refresh. There is no second command at response time and nothing to optimise away. -5. **Read the two answers as a pair, never one as a sentinel.** Section 3.2 guarantees - that `a` is always written and always carries a TTL, which is what makes this table - total: +5. **The session is alive only when both answers say so.** Section 3.2 guarantees that + every create gives the key a TTL, which is what makes this table total: + + | `d` | `TTL` | Meaning | Action | + |---------|--------|-------------------------------------------------------------------------------------------|---------------------------------------------| + | present | `> 0` | alive | serve it; absolute remaining is that number | + | absent | `-2` | no such session — never existed, idle, past its deadline, or revoked | empty `Session` | + | present | `-1` | a key with no deadline: a save that landed after the key expired or was revoked recreated it | empty `Session`; `DEL` the key | + | absent | `> 0` | the key holds something other than `d`; cannot happen while `d` is its only field | empty `Session`; `DEL` the key | - | `d` | `HTTL a` | Meaning | Action | - |---------|----------|----------------------------------------------------------------------------------------------------------|---------------------------------------------| - | present | `> 0` | alive | serve it; absolute remaining is that number | - | present | `-2` | the absolute deadline passed | empty `Session`; `DEL` the key | - | absent | `-2` | no such session — never existed, expired outright, or revoked | empty `Session` | - | absent | `> 0` | the idle clock ran out, absolute has time left | empty `Session`; `DEL` the key | - | any | `-1` | **cannot happen** — Section 3.2 forbids an unexpiring `a`. Treat as a bug: log and handle as no session. | | + Redis deletes the whole key at the deadline, and deletes it too when `d` expires and + leaves it empty. So both clocks usually end in row two, with nothing to clean up. - An earlier draft collapsed this into "`HTTL` returning `-2` means the absolute deadline - passed". `-2` is also what Redis answers for a field that was never written and for a - key that does not exist, so that reading broke every deployment with no absolute limit. - The pair disambiguates; a single value cannot. + The third row is reachable, and no longer a bug to log. A request can load a live + session and write it back just after the key expired; the write recreates the key with + `d` and no TTL. Deleting it lets the index entry follow, rather than leaving a + candidate that every later verification has to reject. - Deleting the key on rows two and four matters: it lets the index entry follow, rather - than leaving a candidate that every later verification has to reject. + An earlier design kept the deadline in a field and read `HTTL` on it. `-2` then meant + both "the deadline passed" and "the field was never written", and reading it alone + broke every deployment with no absolute limit. The key's `TTL` has the same two + sentinels, but Section 3.2 makes every create set it, so `-2` means one thing: no key. 6. **Take the principal snapshot.** Evaluate `principal_of(session)` and keep the result for the response. Section 5.1 explains what it is for; here it costs one call of a pure @@ -599,8 +610,8 @@ middleware is the last place in the chain that can still perform an `await`. The application never sees the difference. An expired session, a revoked one and an absent one are the same thing to a caller. -The cookie `max-age` for the response is the remaining `a`, which came from Redis rather -than from our own clock. Section 6 explains why the idle clock is left out. +The cookie `max-age` for the response is the key's remaining TTL, which came from Redis +rather than from our own clock. Section 6 explains why the idle clock is left out. ### 4.2 After the application, at `http.response.start` @@ -631,7 +642,7 @@ so it costs nothing, and it repairs an index entry that a partial failure lost. constraints on it, both from Section 3.3: the session key is written **before** the index entry, and the entry takes the **remaining** absolute time — never a fresh full lifetime, which would let the entry outlive the session. Section 4.1 has already read that remainder -from `HTTL a`. +from the key's `TTL`. Add `Vary: Cookie` whenever `accessed` is true, so a cache never serves one user's page to another. @@ -642,7 +653,7 @@ there is no extra command to suppress. An earlier draft carried that setting and it to `0.1`, which traded up to ten per cent of idle-timeout precision for a saving that `HGETEX` gives for free. The setting is gone. -Writing field `d` never disturbs field `a`, so an active session keeps counting down to +Writing field `d` never touches the key's TTL, so an active session keeps counting down to its absolute deadline no matter how often it is written. **One semantic to state plainly.** Because the refresh happens at load, the idle clock @@ -744,7 +755,7 @@ Client SessionMiddleware principal_of SessionStore Red │ Cookie: sess=OLD │ │ │ │ │ ├─ validate charset │ │ │ │ ├────────────────────┼────────────────┼──────────────▶│ - │ │ pipeline: HGETEX sess:OLD … d / HTTL … a │ + │ │ pipeline: HGETEX sess:OLD … d / TTL sess:OLD │ │ │◀───────────────────┼────────────────┼───────────────┤ │ ├─ build Session(d) │ │ │ │ ├───────────────────▶│ │ │ @@ -807,20 +818,21 @@ We already hold the payload in memory, so no read is needed. The order is: ``` 1. DEL redis:fastapi:session: 2. HDEL redis:fastapi:sessions-of: -3. HSETEX redis:fastapi:session: EX FIELDS 1 a 1 -4. HSETEX redis:fastapi:session: EX FIELDS 1 d +3. HSETEX redis:fastapi:session: EX FIELDS 1 d +4. EXPIRE redis:fastapi:session: 5. HSETEX redis:fastapi:sessions-of: EX FIELDS 1 6. Set-Cookie with ``` -Step 3 restarts the absolute clock, which is correct: rotation follows authentication or -a change of privilege, so a new session begins. Step 5 is therefore the one place where -the index entry legitimately takes the **full** absolute lifetime rather than a remainder -— the session it describes was created in step 3, one command earlier. Every other write +Step 4 restarts the absolute clock, which is correct: rotation follows authentication or +a change of privilege, so a new session begins. It comes after step 3 because `EXPIRE` on +a missing key does nothing. Step 5 is therefore the one place where the index entry +legitimately takes the **full** absolute lifetime rather than a remainder — the session it +describes was created in steps 3 and 4, a command earlier. Every other write of that entry uses the remainder, for the reason in Section 3.3. -When `absolute_ttl` is `0`, step 3 still runs and `a` takes `gc_ttl`, per Section 3.2. -There is no branch in which `a` goes unwritten. +When `absolute_ttl` is `0`, step 4 still runs and the key takes `gc_ttl`, per Section +3.2. There is no branch in which the key is left without a TTL. **Delete before write, and the order is the security control.** A crash between steps 1 and 3 signs the user out, and they sign in again. A crash in the other order would leave @@ -886,7 +898,7 @@ persisting for hours. **Re-assert with the remaining absolute time, not a fresh lifetime.** Section 3.3 shows what a relative `EX ` does here: it restarts the entry's clock on every write, so a session in constant use holds an entry that never expires. Section 4.1 has already -read the remainder from `HTTL a`, so the correct value is in hand at no cost. This is the +read the remainder from the key's `TTL`, so the correct value is in hand at no cost. This is the easiest of the three measures to get subtly wrong, because the wrong version looks identical and fails only on long-lived sessions. @@ -922,13 +934,13 @@ strictly linearizable, and the store's abstract primitives leave room to add it. ## 6. The two clocks -Section 3.2 puts each clock on its own hash field, so **Redis enforces both and the store -computes neither**. What remains here is the cookie, which Redis cannot enforce. +Section 3.2 gives each clock its own TTL - the idle clock on field `d`, the absolute clock +on the key - so **Redis enforces both and the store computes neither**. What remains here is the cookie, which Redis cannot enforce. The cookie `max-age` follows the absolute clock only: ``` -max_age = HTTL(key, "a") +max_age = TTL(key) ``` The number comes from Redis, which is counting it down. Nothing is derived from the @@ -939,7 +951,7 @@ disagrees with the record. cookie whose session is still alive, and the user is signed out with no cause and no log line. Section 8.3.2 of `session-mgmt.md` records that failure. -**Why not `min(idle, HTTL(key, "a"))`.** An earlier version used it, and it caused exactly +**Why not `min(idle, TTL(key))`.** An earlier version used it, and it caused exactly that failure. The idle clock slides on every request, but a read-only response sends no cookie (Section 4.2). So a cookie sized by the idle clock expired `idle` seconds after the last *write*, while the record was still alive, and a user who only read was signed out @@ -949,8 +961,9 @@ until the record's last possible moment, and no read has to resend it. server and guards the fix. The price: after an idle timeout the browser keeps a dead cookie until the absolute -deadline. Redis still enforces the idle clock, so the dead ID grants nothing; each request -that carries it costs one round trip, and the first one deletes the half-dead key. +deadline. Redis still enforces the idle clock - the key went when `d` expired - so the +dead ID grants nothing; each request that carries it costs one round trip and finds no +session. In cookie-only mode the cookie carries no `max-age` at all and the browser decides. @@ -958,7 +971,7 @@ In cookie-only mode the cookie carries no `max-age` at all and the browser decid Their `rolling=True` extends the cookie and the record by the full lifetime on every response: that is our idle clock, field `d`. Their `rolling=False` keeps the original -expiry: that is our absolute clock, field `a`. We can express both, and we can run the +expiry: that is our absolute clock, the key's TTL. We can express both, and we can run the two together, which their single clock cannot. --- @@ -1416,20 +1429,20 @@ Fields on `RedisSettings`, beside the existing `rate_limit_*` ones and following | `session_cookie_same_site` | `"lax" \| "strict" \| "none"` | `"lax"` | `SameSite` attribute. `"none"` requires `session_cookie_https_only=True`; the two are checked together and a contradiction raises `SessionConfigurationError`. | | `session_cookie_https_only` | `bool` | `True` | Adds `Secure`, so the browser sends the cookie over HTTPS only. **On by default**; turn it off for local development over plain HTTP and nowhere else. | | `session_idle_ttl` | `int` (seconds) | `1800` (30 min) | The idle clock. The session dies this long after the last request that carried its cookie. Stored as the TTL of field `d`. `0` disables the idle clock. | -| `session_absolute_ttl` | `int` (seconds) | `28800` (8 h) | The absolute clock. The session dies this long after creation however active the user is. Stored as the TTL of field `a`. `0` disables it, in which case `a` takes `session_gc_ttl` — see Section 3.2, which explains why `a` is never left unexpiring. | +| `session_absolute_ttl` | `int` (seconds) | `28800` (8 h) | The absolute clock. The session dies this long after creation however active the user is. Stored as the TTL of the session key. `0` disables it, in which case the key takes `session_gc_ttl` — see Section 3.2, which explains why the key is never left unexpiring. | | `session_gc_ttl` | `int` (seconds) | `2592000` (30 days) | Backstop TTL for a key whose real deadline is unknown: cookie-only mode, or `session_absolute_ttl=0`. Never reached in normal operation; it exists so Redis can always collect an abandoned key. | | `session_refresh_on_load` | `bool` | `True` | `True`: the load uses `HGETEX`, so any request carrying the cookie restarts the idle clock in the same round trip. `False`: the load uses `HGET` and only a request that touched `request.session` refreshes it, at the cost of a second round trip. Section 4.2 gives both branches. | | `session_fail_closed` | `bool` | `False` | Behaviour when Redis is unreachable **on read**. `False` yields an empty session, so the caller looks anonymous and the application's own authorization rejects them. `True` raises `SessionStoreError` instead, for a deployment that prefers a 503 to an anonymous page. **Writes always raise, whatever this is set to** — Section 7 explains the asymmetry. | | `session_always_save` | `bool` | `False` | Write the payload on every request that touched the session, even when no mutation was detected. The escape route for the one fault no `dict` subclass can see: a change inside a nested value, `session["a"]["b"] = 1` (Section 8). Costs a write on every request that **read** the session - `accessed` is set by reading - so prefer reassigning the top-level key. **An empty session is exempt**: `WRITE` requires `not empty`, because without it every anonymous visitor to a session-touching route would be minted an identifier, a key and a cookie. Nothing is lost, since a nested mutation implies a top-level key already holding the value. | | `session_principal_keys` | `list[str]` | `["user_id"]` | Session keys the rotation trigger watches. A change to any of them on a successful response rotates the ID. Add `"role"` or `"scopes"` for OWASP's privilege-change rotation. **Comma-separated from the environment** — `REDIS_SESSION_PRINCIPAL_KEYS=user_id,role`; a JSON array is also accepted. Section 5.1; use `principal_of` when a list of keys cannot express it. | -| `session_events_enabled` | `bool` | `False` | Subscribe to Redis notifications and call registered handlers when a session ends (Section 13.4, F-21). **Best-effort.** On a server below 8.8, or one where `notify-keyspace-events` lacks `Th`, or where `CONFIG GET` is unavailable, the store logs one warning at startup and the handlers never fire. Never enable the server setting on the operator's behalf. | +| `session_events_enabled` | `bool` | `False` | Subscribe to Redis notifications and call registered handlers when a session ends (Section 13.4, F-21). **Best-effort.** On a server where `notify-keyspace-events` lacks `Ehx` (`A` counts as `hx`), or where `CONFIG GET` is unavailable, the store logs one warning at startup and the handlers never fire. Never enable the server setting on the operator's behalf. | | `session_key_prefix` | `str \| None` | `None` | Overrides the key namespace. `None` uses `settings.pattern_prefix()`, giving `redis:fastapi:session:` and `redis:fastapi:sessions-of:`. A callable prefix is a constructor argument rather than a setting, since an environment variable cannot carry one (Section 9, extension points). | #### Three things the §5 list in `session-mgmt.md` names that are deliberately not settings - **Cookie-only mode** is not a flag. It is what you get with `session_idle_ttl=0` **and** `session_absolute_ttl=0`: no `max-age` on the cookie, so the browser drops it when it - closes, and `session_gc_ttl` on both fields so Redis can still collect the key + closes, and `session_gc_ttl` on both clocks so Redis can still collect the key (Section 3.2). A separate flag would be a second way to say the same thing, and the two could disagree. - **Encryption** is configured by passing an `Encryptor`, not by an environment variable. @@ -1537,9 +1550,12 @@ Unit tests in `tests/unit/`, against the existing `fake_async_redis` fixture at `noxfile.py:102` already runs the unit suite with no Redis server. **`fakeredis 2.36.2` supports every command this design uses** — `HSETEX`, `HGETEX`, -`HEXPIRE`, `HTTL`, `HGETDEL` and `GETEX` all behave correctly against it, including field -expiry and the empty-key deletion in Section 3.3. That was verified before the design was -settled. Section 13.5 refuses `IFEQ` and `DELEX` on a stronger ground than tooling — they +`HEXPIRE`, `HTTL`, `EXPIRE`, `TTL`, `HGETDEL` and `GETEX` all behave correctly against it, +including field expiry and the empty-key deletion in Section 3.3. That was verified before +the design was settled. **One divergence matters:** when a hash is deleted because its +last field expired and the key is then written again, `fakeredis` keeps the old key TTL, +while Redis starts the new key with none. The late-save case in Section 4.1 is therefore +tested against a real server. Section 13.5 refuses `IFEQ` and `DELEX` on a stronger ground than tooling — they are string commands and cannot address a hash field at all — but note that `fakeredis` does not support them either, so they could not have been covered here in any case. @@ -1549,14 +1565,14 @@ not support them either, so they could not have been covered here in any case. flags, and a nested change surviving through `save()`. - The response rule in Section 4.2: no command when untouched, **no command when read** because the load already refreshed, `HSETEX` when modified. -- **Writing field `d` leaves the TTL of field `a` alone.** This is the guarantee that - keeps the absolute deadline absolute, so assert it directly rather than inferring it. +- **Writing field `d` leaves the key's TTL alone.** This is the guarantee that keeps the + absolute deadline absolute, so assert it directly rather than inferring it. - Both clocks, independently: idle expiry while the absolute clock still has time, and absolute expiry despite continuous activity. -- Cookie `max-age` equal to `HTTL(a)`, in every branch, never the idle clock. +- Cookie `max-age` equal to the key's `TTL`, in every branch, never the idle clock. - Read-only requests inside the idle window, for longer than the idle window in total, keep the user signed in (`tests/integration/test_session_cookie_expiry.py`). -- Session-only mode: no `max-age`, and `gc_ttl` on **both** fields. +- Session-only mode: no `max-age`, and `gc_ttl` on **both** clocks. - `refresh_on_load=False` restoring the second round trip and refreshing only on access. **Assert that the idle clock actually advances under this setting** — an earlier draft of Section 4.2 omitted the branch, which would have left the clock frozen and every @@ -1567,10 +1583,16 @@ fails against the earlier design and passes against this one, so none may be dro redundant: - **`absolute_ttl = 0` produces a usable session.** Create one, load it, and assert the - application receives the data. Under the earlier design `HTTL a` answered `-2`, which was - read as "absolute deadline passed", so every such session was dead on arrival. -- **All five rows of the Section 4.1 state table**, including the `-1` row, which must be - unreachable — assert that a session is never written with an unexpiring `a`. + application receives the data. Under the earlier design the missing deadline answered + `-2`, which was read as "absolute deadline passed", so every such session was dead on + arrival. +- **Every row of the Section 4.1 state table**, including the `-1` row: a key with no TTL + is deleted and reads as no session. Assert too that a create never leaves the key + without a TTL, and - against a real server, since `fakeredis` keeps an expired key's old + TTL when the key is recreated - that a save after the key expired or was revoked leaves + `TTL = -1`, which the next load deletes. +- **No reader can see a session past its absolute deadline.** Refresh `d` just before the + deadline, and assert a raw `HGET` of `d` finds nothing after it. - **The index does not report an idle-dead session.** Set idle far below absolute, let the idle clock lapse, then assert `list_for_subject` omits the session and that the entry has been removed from the index by the read. @@ -1620,6 +1642,9 @@ redundant: - **The index prunes itself with no help from us**: add a session, let its absolute TTL pass, then confirm `HGETALL` omits it and `HLEN` has dropped — without any prune call. Then remove the last field and confirm Redis deleted the key. +- **Session events on a real server**: with `notify-keyspace-events Ehx`, an idle expiry + reaches a handler as `idle` and an absolute expiry as `absolute` - and not as `idle` + too. This needs no Redis 8.8, so it runs on every leg of the matrix. - `list_for_subject` answers in one round trip and returns the descriptor for each session. - `revoke_all` ends every session for one subject and leaves another subject untouched. @@ -1646,7 +1671,7 @@ Run `nox`: lint, mypy, bandit and coverage all gate. 1. `Session`, and the tests for its four rows. It has no dependencies and it pins the contract everything else uses. -2. `SessionStore` and `RedisSessionStore`: keys, the two fields, the envelope, +2. `SessionStore` and `RedisSessionStore`: keys, the two clocks, the envelope, `load`/`save`/`touch`/`delete`. 3. `SessionMiddleware`: the eager load, the response rule, the cookie. 4. Settings, `deps.py`, `.sessions()` — the feature is usable at the end of this step. @@ -1698,8 +1723,8 @@ all. Redis expires the fields itself, `HGETALL` returns exactly the live session The two clocks are the second case. An idle timeout and an absolute timeout are two independent deadlines on one piece of state, and Section 3.2 explains why holding them as -two field TTLs makes "a session outlives its absolute deadline" unreachable rather than -merely unlikely. +two TTLs - the idle clock on field `d`, the absolute clock on the key - makes "a session +outlives its absolute deadline" unreachable rather than merely unlikely. A note on version range. `HEXPIRE` is 7.4, which is our floor, but `HSETEX` and `HGETEX` are 8.0. Against a 7.4 to 7.x server, fall back to `HSET` + `HEXPIRE` and to `HGET` + @@ -1711,7 +1736,7 @@ than the INCREX case: there is no correctness cliff to guard. ### 13.2 What a newer server adds, with no code from us The design targets 7.4, and everything above works there. But later releases improve it -without a line of our code. One of them may also reward the two-field shape we chose for an +without a line of our code. One of them may also reward the uniform shape we chose for an unrelated reason, and the paragraph below says plainly why we do not yet know. Say this in the guide: **the same application gets cheaper and faster by upgrading the server.** @@ -1719,15 +1744,15 @@ the guide: **the same application gets cheaper and faster by upgrading the serve |---------|--------------------------------------------------------------------|--------------------------------------------------------------------| | 8.6 | hash memory footprint down up to 16.7%, hash latency down up to 7% | every session key and every index key, for free | | 8.8 | `HGETALL` up to 25% faster on hashes with 1K+ fields | `list_for_subject` for a tenant with many live sessions | -| 8.8 | **hash subkey notifications** | a new capability, not only a speed-up. See below. | +| 8.8 | hash subkey notifications | nothing new here: key-level events already name both clocks. See below. | | 8.10 | **compact hashes** | a large memory win **if** field expiry does not disqualify us. Open. See below. | | 8.10 | wide `HSET` on a fresh hash batched into one listpack append | session creation | **Compact hashes (8.10) suit the shape of our record, and may still exclude it.** The encoding stores field names **once** across every key that shares a schema. A session store -looks like the ideal case: a million session keys, each a hash with exactly the fields `a` -and `d`, identical in every one, so the names are held once for the deployment instead of -once per session. Section 3.2 chose two fields to make the absolute deadline structurally +looks like the ideal case: a million session keys, each a hash with exactly one field, `d`, +identical in every one, so the name is held once for the deployment instead of once per +session. Section 3.2 put the absolute deadline on the key to make it structurally unbreakable, which is a security argument; the uniform schema is a by-product. If the encoding does reward it, say plainly in the guide that this was luck and not foresight. @@ -1738,7 +1763,7 @@ has a problem for us. The first is automatic conversion, driven by `hash-min-template-entries`, and the documentation excludes us by name: *"A hash is not converted if it uses field expiration, even when its field count meets the minimum."* Every session key here uses field expiration -on both fields. That is Section 3.2 and it is not negotiable, so on this path our keys are +on `d`. That is Section 3.2 and it is not negotiable, so on this path our keys are ineligible whatever their schema. The second is `HIMPORT`, which hints Redis to store the new key as a compact hash at @@ -1759,10 +1784,10 @@ field names short, and **keep them identical in every session**. Never write an field into some sessions and not others. A divergent schema forfeits the template if we ever qualify for one, and a short name is fewer bytes on the wire meanwhile. -**Hash subkey notifications (8.8) are a new capability, and the one row here that does -need code from us.** Redis 7.4 gave fields a TTL, but key-level notifications carry no -field name, so nothing could say *which* field expired. Redis 8.8 adds field-level events -across four channel types. Section 13.4 designs the feature that consumes them. +**Hash subkey notifications (8.8) name the field that expired.** An earlier design kept +both clocks in fields and needed them to tell an idle death from an absolute one. With the +absolute deadline on the key, key-level events already tell them apart on 7.4 (Section +13.4), so this row gives the session store nothing it lacks. ### 13.3 Operational guidance that only a Redis vendor will write @@ -1782,7 +1807,7 @@ sessions at all.** **Sessions are small, and Redis stores small hashes as a listpack.** Below `hash-max-listpack-entries` and `hash-max-listpack-value` a hash is a flat array, not a -hash table, so a two-field session and a short index cost far less than the per-key +hash table, so a one-field session and a short index cost far less than the per-key overhead suggests. Note the thresholds so an operator sizing a deployment finds which side of them a typical session falls. @@ -1809,47 +1834,43 @@ is the case that pays for it. The feature is off by default, degrades to nothing on a server that cannot supply it, and carries no guarantee. The rest of this section says exactly what that means. -#### The two-field design pushes this to 8.8 +#### Two key-level events name the two clocks -Our session key has **no key-level TTL**. It dies as a side effect of its last field -expiring, which is the whole of Section 3.2. That has a consequence for notifications that -is easy to miss: +Field `d` is the only field of a session key with a TTL, and the absolute deadline is the +key's own TTL. So each clock has its own key-level event, and both exist from Redis 7.4: -| Tier | Needs | Channel | Names the clock that fired? | -|---------|------------------------------------------|------------------------------------------|-----------------------------| -| `none` | — | — | — | -| `key` | 7.4, plus `Eghx` in `notify-keyspace-events` | `__keyevent@__:del` | **No** | -| `field` | **8.8**, plus `h` and **`T`** | `__subkeyevent@__:hexpired`, whose payload names the field | **Yes** — `d` is idle, `a` is absolute | +| Event | Channel | On a session key it means | +|---|---|---| +| `hexpired` | `__keyevent@__:hexpired` | field `d` expired: the **idle** clock | +| `expired` | `__keyevent@__:expired` | the key expired: the **absolute** clock | + +The payload of both is the key name, so the session ID is the key minus the prefix. +**Neither fires for the other clock**, checked against Redis 8.7: an idle expiry publishes +`hexpired` and then `del`, because the hash is now empty, and no `expired`; an absolute +expiry publishes `expired` and no `hexpired`. The subscriber ignores `del`, which a +revocation also publishes. -At the `key` tier a subscriber learns that a session key went away and nothing else. It -cannot separate an idle death from an absolute one, and it cannot separate either from a -revocation. That is most of what a caller wants to know, so **the useful ladder is two -rungs, not three: `field` or `none`.** Implement the `key` tier only if a concrete recipe -needs it; do not add it speculatively. +An earlier design kept the absolute deadline in a second field. Both clocks were then +field expiries, a key-level `hexpired` could not say which field expired, and telling them +apart needed Redis 8.8's subkey notifications with the `T` flag. Moving the deadline to +the key removed that requirement. -One detail to confirm against a real 8.8 server before the guide claims it: whether field -expiry that empties a hash also emits a key-level `del`. The `field` tier does not depend -on the answer, which is another reason to build that tier and not the other. +The tiers are therefore two: `key` when the server delivers these events, `none` when it +cannot. #### Probe, and never configure Two different questions, and the code must ask both: -1. **Can the server do it?** Read `redis_version` from `INFO server`. -2. **Is it switched on?** Read `notify-keyspace-events` with `CONFIG GET` and look for `h` - together with **`T`**, not one of `S`/`T`/`I`/`V`. - -**`T` specifically, because 8.8 adds four subkey channels and this subscribes to one.** -`S` is `__subkeyspace@`, `I` is `__subkeyspaceitem@`, `V` is `__subkeyspaceevent@`, and -only `T` is `__subkeyevent@`. Redis accepts a subscription to any channel name, so on a -server set to `Sh` the subscribe succeeds and no event ever arrives. Accepting any of the -four therefore made `tier` report `"field"` where nothing could be delivered, which -defeats the `events.tier` check this section tells callers to rely on. An earlier draft of -this list said "one of", and the code followed it. +1. **Can the server do it?** Read `redis_version` from `INFO server`. Hash-field expiry, + and with it `hexpired`, arrived in 7.4, which is also this package's floor. +2. **Is it switched on?** Read `notify-keyspace-events` with `CONFIG GET` and look for `E` + - the `__keyevent@` channels - together with `h` and `x`, the hash and expiry classes. -**The four subkey flags are independent of `K` and `E`.** Enabling standard keyspace -notifications does not enable subkey notifications, and the reverse holds too. This will -be the commonest support question; say it in the guide in those words. +**`A` counts as `h` and `x`.** `A` is Redis's alias for every event class, and `CONFIG GET` +reports it in place of the classes it covers: a server set to `KEA` answers `AKE`, with no +literal `h` or `x`. A probe that looked for the letters would refuse the commonest +configuration there is. **Both probes can fail, and failure is an answer.** Managed Redis often restricts, renames or forbids `CONFIG`, and an ACL that omits `@admin` does the same. Treat any failure as @@ -1886,8 +1907,8 @@ it. driven this way needs deduplication or a single designated subscriber. - **Pub/Sub is fire-and-forget.** Events sent while no subscriber is connected are lost, and the connection has to be re-established after a disconnect with no replay. -- **`hexpired` fires when Redis removes the field, not when the TTL reaches zero.** With - many keys carrying a TTL, the lag can be significant. +- **Expiry events fire when Redis removes the field or the key, not when the TTL reaches + zero.** With many keys carrying a TTL, the lag can be significant. #### The rule that does not move @@ -1908,20 +1929,20 @@ events = store.events() # tier probed once, at startup async def _(sid: str, cause: Cause) -> None: # Literal["idle", "absolute"] await close_sockets_for(sid) -print(events.tier) # "field" or "none" +print(events.tier) # "key" or "none" ``` -`cause` is what the `field` tier buys and the `key` tier cannot give. At tier `none` the -handler is held and never called. +The channel an event arrives on gives the `cause`. At tier `none` the handler is held and +never called. -**`Cause` has two members and must not have three.** A revocation is a `DEL`, and `DEL` -emits no subkey notification at any version: it is not among the commands that do, and the -mechanism forbids it, because a subkey event is published only when at least one subkey is -present and a deleted key has none left to name. An earlier draft included `"revoked"`. -A `Literal` in a public callback signature is a promise about the inhabited set, so a -member nothing can produce leaves a caller's exhaustive `match` with an arm that never -runs and that a type checker will not let them delete. If the `key` tier is ever built, -widening the union then is the ordinary cost of widening any union. +**`Cause` has two members and must not have three.** A revocation is a `DEL`, which +publishes `del` - the same event an idle expiry publishes when it empties the key. A `del` +cannot say which of the two happened, so the subscriber does not listen for it. An earlier +draft included `"revoked"`. A `Literal` in a public callback signature is a promise about +the inhabited set, so a member nothing can produce leaves a caller's exhaustive `match` +with an arm that never runs and that a type checker will not let them delete. If a +revocation event is ever needed, widening the union then is the ordinary cost of widening +any union. `Cause`, `Tier` and `Handler` are all exported. `Handler` in particular, because a caller under `mypy --strict` has to be able to name the type of the callable `on_session_end` @@ -1943,20 +1964,20 @@ compare-and-swap of any kind. The draft also called `IFDEQ` an `O(1)` digest com `DELEX` documentation gives `O(1)` for `IFEQ`/`IFNE` and **`O(N)` for `IFDEQ`/`IFDNE`**. `IFDEQ` saves bytes on the wire against `IFEQ`. It does not save server time. -Reaching those commands would mean splitting the record: the payload into a string key, the -two clocks into a hash. That is a second key, a `{sid}` hash tag to keep Cluster in one +Reaching those commands would mean splitting the record: the payload into a string key, +the idle clock into a hash beside it. That is a second key, a `{sid}` hash tag to keep Cluster in one slot, and a schema change — to buy a command that is no cheaper than the alternative below. **The mechanism that would work is Lua, and it works at the 7.4 floor.** A script reads field `d`, compares `redis.sha1hex` of the stored bytes against a digest the client computed -from what it loaded, and writes only on a match. It touches field `d` and never field `a`, -so N-6 survives untouched. +from what it loaded, and writes only on a match. It touches field `d` and never the key's +TTL, so N-6 survives untouched. The cost is the part worth recording, because it is the question that gets asked: | Path | Today | With a Lua compare-and-set | |------|----------------------------------------------|----------------------------| -| Load | 1 round trip, 2 commands (`HGETEX d`, `HTTL a`) | **unchanged** — the client hashes bytes it already received | +| Load | 1 round trip, 2 commands (`HGETEX d`, `TTL`) | **unchanged** — the client hashes bytes it already received | | Save | 1 round trip, 2 commands (`HSETEX d`, index `HSETEX`) | 1 round trip, 2 commands — `EVALSHA` replaces the first | **Identical round trips and identical command counts.** The new cost is CPU: one SHA-1 over @@ -1985,17 +2006,18 @@ that reason. **One objection ends it.** `HIMPORT SET key fieldset-name value [value ...]` takes no expiration option of any kind, and its documentation states that an existing key is -overwritten. Field `a` and its absolute deadline would be destroyed on every save. -Rebuilding them costs `HIMPORT SET`, then `HEXPIRE a`, then `HEXPIRE d` — three commands +overwritten. The key's TTL - the absolute deadline - would be destroyed on every save. +Rebuilding it costs `HIMPORT SET`, then `EXPIREAT` from a stored deadline, then +`HEXPIRE d` — three commands where Section 4.2 issues one `HSETEX`, and between them a window where the session carries no expiry at all. N-6 asks that outliving the absolute deadline be unreachable by construction. This design makes it reachable by a crash. Four more, each sufficient on its own: -- **There is nothing to save.** What HIMPORT saves is the field names, and ours are `a` and - `d`. Redis measured 11% on a pipelined import of a million three-field records named - `_uid`, `score` and `tag`. Our write path is one two-field write per HTTP request. +- **There is nothing to save.** What HIMPORT saves is the field names, and ours is `d`. + Redis measured 11% on a pipelined import of a million three-field records named `_uid`, + `score` and `tag`. Our write path is one one-field write per HTTP request. - **The index cannot use it at all.** `sessions-of:` carries session IDs as field names — unique per key, unknown until write time. A fieldset is fixed and shared by definition. @@ -2020,10 +2042,10 @@ load path in Section 4.1 is `HGETEX`, which is a *write* command — it changes redis-py refuses to cache writes. Verified against `redis.cache.DefaultCache.is_cachable` in redis-py 8.0.1: -| Command | Cacheable | -|-----------------------------------------|-----------| -| `GET`, `HGET`, `HGETALL`, `HLEN` | yes | -| **`HGETEX`, `HSETEX`, `GETEX`, `HTTL`** | **no** | +| Command | Cacheable | +|-----------------------------------------------|-----------| +| `GET`, `HGET`, `HGETALL`, `HLEN` | yes | +| **`HGETEX`, `HSETEX`, `GETEX`, `HTTL`, `TTL`** | **no** | So the authentication path is never served from a client cache, whatever the configuration. The refresh-on-read that Section 4.2 buys with `HGETEX` costs us the @@ -2060,7 +2082,7 @@ let the feature settle before this package leans on it. requirement to log the session lifecycle, using a salted hash of the session ID. `XADD` with `MAXLEN` gives a bounded, ordered, replica-safe log that any instance can read, and 8.6's idempotent production (`XADD ... IDMP`) means a producer that retries after a crash -cannot double-write an audit entry. Pair it with the subkey notifications in Section 13.2: +cannot double-write an audit entry. Pair it with the session events in Section 13.4: the notification is the trigger, the stream is the record. This belongs in the recipe list in Section 9.6 of `session-mgmt.md` rather than in the store. @@ -2115,7 +2137,7 @@ a vulnerability (Section 5.1). A change on a 4xx persists nothing (Section 4.3). | Starlette |
max_age=1209600        # cookie only; the server enforces nothing
| | `starsessions` |
lifetime=3600, rolling=True    # one clock, refreshed or not
| | `fastapi-users` |
lifetime_seconds=3600  # absolute only; None means it never expires
| -| **This SDK** |
REDIS_SESSION_IDLE_TTL=1800        # field d, refreshed on access
REDIS_SESSION_ABSOLUTE_TTL=28800 # field a, never refreshed
| +| **This SDK** |
REDIS_SESSION_IDLE_TTL=1800        # field d, refreshed on access
REDIS_SESSION_ABSOLUTE_TTL=28800 # key TTL, never refreshed
| Two clocks, both enforced by Redis, neither computed by us (Section 3.2). Set them equal to keep the single-clock behaviour of the row above. Section 9.4 of `session-mgmt.md` maps diff --git a/src/redis_fastapi/__init__.py b/src/redis_fastapi/__init__.py index 6b9c28d..3f04784 100644 --- a/src/redis_fastapi/__init__.py +++ b/src/redis_fastapi/__init__.py @@ -33,6 +33,7 @@ from redis_fastapi.exceptions import ( SessionConfigurationError, SessionError, + SessionRejected, SessionStoreError, ) from redis_fastapi.lifespan import redis_lifespan @@ -70,14 +71,19 @@ SessionMiddleware, add_redis_sessions, build_cookie, + valid_session, ) from redis_fastapi.setup import FastAPIRedis from redis_fastapi.telemetry import disable_telemetry, enable_telemetry from redis_fastapi.types import ( + Challenge, Coder, Encryptor, JsonCoder, KeyBuilder, + OnReject, + RecencyRejection, + SessionRejection, pydantic_model_coder, ) @@ -88,6 +94,7 @@ "CacheHitException", "CannotIdentifyClient", "Cause", + "Challenge", "Coder", "CookieSpec", "Encryptor", @@ -97,6 +104,7 @@ "JsonCoder", "KeyBuilder", "LoadedSession", + "OnReject", "Outcome", "Rate", "RateLimitBackend", @@ -104,6 +112,7 @@ "RateLimitExceeded", "RateLimitMiddleware", "RateLimitResult", + "RecencyRejection", "RedisSessionStore", "RedisSettings", "Session", @@ -115,6 +124,8 @@ "SessionMetadata", "SessionMiddleware", "SessionRecord", + "SessionRejected", + "SessionRejection", "SessionState", "SessionStore", "SessionStoreDep", @@ -151,4 +162,5 @@ "pydantic_model_coder", "rate_limit", "redis_lifespan", + "valid_session", ] diff --git a/src/redis_fastapi/cache.py b/src/redis_fastapi/cache.py index 832a781..d94cb87 100644 --- a/src/redis_fastapi/cache.py +++ b/src/redis_fastapi/cache.py @@ -37,6 +37,8 @@ async def get_items(): CACHE_ROUTE_SCOPE_KEY, CACHE_STATUS_HEADER, CACHE_SUPPRESS_VARY_SCOPE_KEY, + SESSION_GATED_SCOPE_KEY, + SESSION_NO_STORE_SCOPE_KEY, get_settings, ) from redis_fastapi.deps import AsyncClient, _get_pool_state, get_async_redis @@ -167,7 +169,7 @@ def _is_stale_for_client( return age >= max_age -def _cache_control_value(max_age: int, private: bool) -> str: +def _cache_control_value(max_age: int, private: bool, no_store: bool = False) -> str: """Build a ``Cache-Control`` response header value. When *max_age* is ``0`` (no TTL), no ``max-age`` directive is emitted; @@ -176,10 +178,15 @@ def _cache_control_value(max_age: int, private: bool) -> str: Args: max_age: The ``max-age`` value in seconds. ``0`` means no expiry. private: Whether to include the ``private`` directive. + no_store: Send ``no-store`` alone. No cache may keep the response, + the browser's included, so ``private`` and ``max-age`` would say + nothing. Returns: The formatted header value string. """ + if no_store: + return "no-store" if max_age <= 0: base = "no-cache" else: @@ -286,6 +293,7 @@ class CachePending: key: str ttl: int private: bool = False + no_store: bool = False redis: Any = field(default=None) write_through: bool = False vary_on_session: bool | None = None @@ -338,6 +346,7 @@ def _build_hit_response( remaining_ttl: int, request: Request, private: bool, + no_store: bool = False, ) -> Response: """Deserialize a cache entry and return a ready-to-send ``Response``. @@ -353,7 +362,7 @@ def _build_hit_response( entry["body"].encode() if isinstance(entry["body"], str) else entry["body"] ) etag: str = entry["etag"] - cc_value = _cache_control_value(remaining_ttl, private) + cc_value = _cache_control_value(remaining_ttl, private, no_store) if request.headers.get("if-none-match") == etag: return Response( @@ -384,6 +393,7 @@ def cache( key_builder: KeyBuilder | None = None, private: bool = False, vary_on_session: bool | None = None, + no_store: bool = False, ) -> Any: """Return a ``Depends()``-compatible dependency for response caching. @@ -403,7 +413,13 @@ def cache( ``settings.pattern_prefix("cache")``. key_builder: Custom key builder (sync or async). Defaults to :func:`default_key_builder`. - private: Emit ``Cache-Control: private, max-age=N``. + private: Emit ``Cache-Control: private, max-age=N``. Added + automatically when ``valid_session()`` gated the request, because + only callers with a valid session reach the response. + no_store: Emit ``Cache-Control: no-store`` on the miss and on every + hit. The entry is still kept in Redis; no cache outside the + application may keep the response. Added automatically when + ``valid_session(issued_within=...)`` gated the request. vary_on_session: Whether this response depends on the session. * ``True`` - key the entry per user, and emit ``private``. Use it @@ -486,7 +502,14 @@ async def _dependency( force_refresh="no-cache" in cc, ) - # 4. HIT: short-circuit via exception — endpoint never runs + # 4. HIT: short-circuit via exception — endpoint never runs. + # A gate that ran first decides the directives too: a gated + # body is private, and a route that asked for a recent session + # is sensitive (valid_session(), S-1.6 and S-2). + private_here = _private or bool(request.scope.get(SESSION_GATED_SCOPE_KEY)) + no_store_here = no_store or bool( + request.scope.get(SESSION_NO_STORE_SCOPE_KEY) + ) if cached_data: record_cache_request(result="hit", eviction_group=eviction_group) if span is not None: @@ -494,7 +517,11 @@ async def _dependency( try: raise CacheHitException( _build_hit_response( - cached_data, remaining_ttl, request, _private + cached_data, + remaining_ttl, + request, + private_here, + no_store_here, ) ) except (json.JSONDecodeError, KeyError) as exc: @@ -508,7 +535,8 @@ async def _dependency( request.state.redis_cache_pending = CachePending( key=cache_key, ttl=_ttl, - private=_private, + private=private_here, + no_store=no_store_here, redis=redis, vary_on_session=vary_on_session, route=f"{request.method} {request.url.path}", @@ -766,7 +794,7 @@ async def _store_cache_entry( outgoing response (``X-Redis-Cache``, ``ETag``, ``Cache-Control``). """ etag = f'W/"{hashlib.blake2b(body_bytes, digest_size=16).hexdigest()}"' - cc_value = _cache_control_value(pending.ttl, pending.private) + cc_value = _cache_control_value(pending.ttl, pending.private, pending.no_store) extra_headers: list[tuple[bytes, bytes]] = [ (CACHE_STATUS_HEADER.lower().encode(), b"MISS"), (b"etag", etag.encode()), diff --git a/src/redis_fastapi/config.py b/src/redis_fastapi/config.py index fa68ef9..a9e28d4 100644 --- a/src/redis_fastapi/config.py +++ b/src/redis_fastapi/config.py @@ -34,6 +34,12 @@ """Set by ``cache()``: this route owns its ``Cache-Control``.""" CACHE_SUPPRESS_VARY_SCOPE_KEY: str = "redis_cache_no_vary" """Set by ``cache(vary_on_session=False)``: the body does not vary by cookie.""" +SESSION_GATED_SCOPE_KEY: str = "redis_session_gated" +"""Set by ``valid_session()`` when it passes: only callers with a valid +session reach this response, so a shared cache must not store it.""" +SESSION_NO_STORE_SCOPE_KEY: str = "redis_session_no_store" +"""Set by ``valid_session(issued_within=...)``: the route is sensitive, and +the response must say ``no-store``.""" # Cookie attributes are interpolated into a response header, so each is # constrained to characters that cannot terminate or split one. diff --git a/src/redis_fastapi/exceptions.py b/src/redis_fastapi/exceptions.py index b70e9a2..b76b289 100644 --- a/src/redis_fastapi/exceptions.py +++ b/src/redis_fastapi/exceptions.py @@ -9,6 +9,11 @@ from __future__ import annotations +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from starlette.responses import Response + class SessionError(Exception): """Base for every error this feature raises. @@ -34,3 +39,18 @@ class SessionStoreError(SessionError): store *is* the authorization answer, and a permissive default would wave logins past a concurrent-session cap exactly when Redis is unhealthy. """ + + +class SessionRejected(Exception): + """Carries the rejection response out of ``valid_session()``. + + Intentional control flow, not an error, so it subclasses ``Exception`` and + not :class:`SessionError`: a handler that catches ``SessionError`` to + report store failures must not catch a rejected request. The handler + ``add_redis_sessions`` registers returns the carried response. + """ + + def __init__(self, response: Response) -> None: + super().__init__() + self.response = response + self.__suppress_context__ = True diff --git a/src/redis_fastapi/session_backend.py b/src/redis_fastapi/session_backend.py index c6419f7..1998569 100644 --- a/src/redis_fastapi/session_backend.py +++ b/src/redis_fastapi/session_backend.py @@ -570,10 +570,27 @@ async def load( ) -> LoadedSession | None: """Read a session, and restart its idle clock in the same round trip. - Returns ``None`` for every way a session can fail to exist: never - created, idle-expired, past its absolute deadline, or revoked. The - caller cannot tell those apart, and must not: to an application they - are all "no session". + See :meth:`load_with_status` for the contract; this is the same call + without the second answer. + """ + loaded, _ = await self.load_with_status(session_id, refresh=refresh) + return loaded + + async def load_with_status( + self, session_id: str, *, refresh: bool = True + ) -> tuple[LoadedSession | None, bool]: + """:meth:`load`, plus whether the read itself failed. + + The second value is ``True`` only when Redis could not be read and + ``session_fail_closed`` let the request continue. Without it, an + outage looks exactly like an unknown identifier, and + ``valid_session()`` would send users to sign in when it should say + "try again". + + The first value is ``None`` for every way a session can fail to + exist: never created, idle-expired, past its absolute deadline, or + revoked. The caller cannot tell those apart, and must not: to an + application they are all "no session". *refresh* false makes this a plain read, for ``session_refresh_on_load=False``. The idle clock then advances only @@ -588,7 +605,7 @@ async def load( if the read failed and ``session_fail_closed`` is set. """ if not self.is_valid_id(session_id): - return None + return None, False with session_span("session.load"), timed_session("load"): try: raw, absolute_ttl = await self._read( @@ -597,7 +614,7 @@ async def load( except STORE_ERRORS as exc: record_session_operation(operation="load", result="error") self._read_failed(exc) - return None + return None, True alive_until = absolute_ttl if isinstance(absolute_ttl, int) else 0 if raw is None or alive_until <= 0: @@ -612,10 +629,11 @@ async def load( record_session_operation( operation="load", result="expired" if half_dead else "miss" ) - return None + return None, False record_session_operation(operation="load", result="hit") - return LoadedSession(record=self.decode(raw), absolute_remaining=alive_until) + loaded = LoadedSession(record=self.decode(raw), absolute_remaining=alive_until) + return loaded, False async def create(self, session_id: str, record: SessionRecord) -> None: """Write a session that does not exist yet, starting both clocks. @@ -711,6 +729,22 @@ async def delete(self, session_id: str) -> None: # -- rotation and revocation --------------------------------------------- + def session_age(self, state: SessionState) -> int | None: + """Seconds since this session's ID was issued, or ``None``. + + A new ID is a new key, and a new key starts its TTL - the absolute + clock - at full length, so the age is that length minus what is left. + Both numbers come from Redis, so a skewed container clock cannot make + an old session look recent. + + ``None`` when the request has no stored session, or when the key has + more time left than the configured length allows - a session created + before ``session_absolute_ttl`` was lowered. A caller must treat + ``None`` as "not recent": that is what keeps a configuration change + from passing old sessions off as new. + """ + return issued_ago(self.absolute_seconds, state) + def session_id(self, state: SessionState) -> str | None: """The identifier this session is stored under, if it has one yet. @@ -1392,6 +1426,19 @@ async def _alive(self, session_ids: list[str]) -> set[str]: } +def issued_ago(absolute_seconds: int, state: SessionState) -> int | None: + """:meth:`SessionStore.session_age`, for any store with an absolute clock. + + A module function so the session gate can measure a store that implements + only :class:`SessionStoreProtocol`, which has ``absolute_seconds`` but no + ``session_age``. + """ + if state.session_id is None or state.absolute_remaining is None: + return None + age = absolute_seconds - state.absolute_remaining + return age if age >= 0 else None + + def _first(reply: Any) -> bytes | str | None: """Unwrap a one-element array reply. diff --git a/src/redis_fastapi/sessions.py b/src/redis_fastapi/sessions.py index 3f0aeb8..89a709d 100644 --- a/src/redis_fastapi/sessions.py +++ b/src/redis_fastapi/sessions.py @@ -16,22 +16,42 @@ from dataclasses import dataclass from datetime import timedelta from enum import Enum, auto -from typing import Any, cast +from inspect import isawaitable +from typing import Any, cast, overload -from fastapi import FastAPI +from fastapi import FastAPI, HTTPException +from fastapi.security.base import SecurityBase from starlette.requests import Request +from starlette.responses import JSONResponse, Response +from starlette.status import HTTP_401_UNAUTHORIZED from starlette.types import ASGIApp, Message, Receive, Scope, Send from redis_fastapi.config import ( CACHE_ROUTE_SCOPE_KEY, CACHE_SUPPRESS_VARY_SCOPE_KEY, + SESSION_GATED_SCOPE_KEY, + SESSION_NO_STORE_SCOPE_KEY, get_settings, ) from redis_fastapi.exceptions import ( SessionConfigurationError, + SessionRejected, +) +from redis_fastapi.session_backend import ( + SessionState, + SessionStore, + _seconds, + issued_ago, +) +from redis_fastapi.telemetry import record_session_operation +from redis_fastapi.types import ( + Challenge, + Coder, + Encryptor, + OnReject, + RecencyRejection, + SessionRejection, ) -from redis_fastapi.session_backend import SessionState, SessionStore -from redis_fastapi.types import Coder, Encryptor # Sentinel for ``pop``/``setdefault`` so that ``None`` stays a usable default. _MISSING: Any = object() @@ -276,6 +296,26 @@ def build_cookie(spec: CookieSpec) -> str: SCOPE_KEY = "session" STATE_SCOPE_KEY = "redis_session_state" _STATE_ATTR = "_redis_session" +# How the load went, and the store it used - both for valid_session(), which +# runs after the middleware and must judge the request by what it recorded. +_LOAD_SCOPE_KEY = "redis_session_load" +_STORE_SCOPE_KEY = "redis_session_store" + + +class _Load(Enum): + """How the middleware's load went. Private: ``valid_session()`` maps it + to a reason.""" + + LOADED = auto() + MISSING = auto() + """No cookie, or one that fails ``is_valid_id``.""" + NOT_FOUND = auto() + """A well-formed cookie with no live record.""" + FAILED = auto() + """The read raised, and ``session_fail_closed`` let the request continue.""" + SKIPPED = auto() + """The ``skip`` predicate matched, so nothing was loaded.""" + # What ``principal_of`` returns when there is no identity to speak of. _NO_PRINCIPAL: Any = None @@ -426,6 +466,7 @@ class _RequestState: principal_before: Any # Only for the ``descriptor_of`` seam, which is given the live request. request: Request | None = None + load: _Load = _Load.MISSING class SessionMiddleware: @@ -473,12 +514,15 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: empty = Session() scope[SCOPE_KEY] = empty scope[STATE_SCOPE_KEY] = SessionState(data=empty) + scope[_LOAD_SCOPE_KEY] = _Load.SKIPPED await self.app(scope, receive, send) return settings = get_settings() store = await self._store_factory(request) + scope[_STORE_SCOPE_KEY] = store state = await self._load(request, store, settings) + scope[_LOAD_SCOPE_KEY] = state.load scope[SCOPE_KEY] = state.session setattr(request.state, _STATE_ATTR, state) scope[STATE_SCOPE_KEY] = state.store_state @@ -507,15 +551,22 @@ async def _load( session = Session() store_state = SessionState(data=session) loaded_id: str | None = None + load = _Load.MISSING # Validate before use. This is not cosmetic: the value is written back # into a Set-Cookie header, so an unvalidated one is a header-injection # vector. Anything unexpected is treated as no session at all. - if raw_cookie and store.is_valid_id(raw_cookie): - loaded = await store.load( - raw_cookie, refresh=settings.session_refresh_on_load + if raw_cookie and not store.is_valid_id(raw_cookie): + # The one certain sign of injection: every identifier this store + # issues passes is_valid_id. Counted on every request, gated or + # not; never logged, so a scanner cannot flood the log. + record_session_operation(operation="load", result="malformed") + elif raw_cookie: + loaded, failed = await _load_with_status( + store, raw_cookie, refresh=settings.session_refresh_on_load ) if loaded is not None: + load = _Load.LOADED session = Session(loaded.record.data) loaded_id = raw_cookie store_state = SessionState( @@ -525,6 +576,8 @@ async def _load( created=loaded.record.metadata.created, absolute_remaining=loaded.absolute_remaining, ) + else: + load = _Load.FAILED if failed else _Load.NOT_FOUND return _RequestState( session=session, @@ -532,6 +585,7 @@ async def _load( loaded_id=loaded_id, principal_before=self._snapshot(session), request=request, + load=load, ) def _snapshot(self, session: Session) -> Any: @@ -655,10 +709,19 @@ def _apply_cache_headers( the route. Where one does, it sets the directive itself from the route's declaration, and a second writer here is what produced ``max-age=300, private, no-store`` in one response. + + ``no-store`` wins over ``private``. A route gated by + ``valid_session(issued_within=...)`` gets ``no-store`` instead, and a + response whose handler already said ``no-store`` gets nothing added: + ``no-store, private`` is valid, but the ``private`` says nothing. """ if not scope.get(CACHE_SUPPRESS_VARY_SCOPE_KEY): _merge_header(headers, b"vary", b"Cookie") - if not scope.get(CACHE_ROUTE_SCOPE_KEY): + if scope.get(CACHE_ROUTE_SCOPE_KEY): + return + if scope.get(SESSION_NO_STORE_SCOPE_KEY): + _merge_header(headers, b"cache-control", b"no-store") + elif not _has_directive(headers, b"cache-control", b"no-store"): _merge_header(headers, b"cache-control", b"private") def _with_cookie( @@ -810,6 +873,7 @@ def add_redis_sessions( cookie_builder: Callable[[CookieSpec], str] | None = None, descriptor_of: Callable[[Request, Session], dict[str, Any]] | None = None, skip: Callable[[Request], bool] | None = None, + challenge: Challenge | None = None, store: SessionStore | None = None, store_factory: Callable[[Request], Any] | None = None, coder: type[Coder] | None = None, @@ -848,6 +912,12 @@ def add_redis_sessions( application's own ``request.session``. skip: Predicate for requests that need no session at all. A request it returns true for costs zero Redis calls. + challenge: What the default rejection of ``valid_session()`` sends as + ``WWW-Authenticate``: a FastAPI security scheme, whose own + challenge is used; a fixed string; or a callable receiving the + request and the reason. ``None``, the default, sends no header. + ``Basic`` is refused, because it makes every browser open its + password dialog on a gated page. store: A ready-made store, used for every request. The escape hatch for a backend that is not Redis, and for a test double. store_factory: Called per request to build one, when a single instance @@ -871,7 +941,7 @@ def add_redis_sessions( Raises: SessionConfigurationError: If the cookie settings contradict each - other. + other, or *challenge* names ``Basic``. """ settings = get_settings() if settings.session_cookie_same_site == "none" and not ( @@ -915,6 +985,10 @@ def resolver(session: Session) -> Any: # noqa: F811 } if store is not None and store_factory is not None: raise SessionConfigurationError("Pass either store or store_factory, not both.") + if isinstance(challenge, (str, SecurityBase)): + _refuse_basic(_scheme_challenge(challenge)) + app.state._redis_session_challenge = challenge + app.add_exception_handler(SessionRejected, session_rejected_handler) app.state._redis_session_options = _SessionStoreOptions( store=store, store_factory=store_factory, kwargs=kwargs ) @@ -971,6 +1045,19 @@ def session_of(request: Request) -> Session: return session +def _has_directive( + headers: list[tuple[bytes, bytes]], name: bytes, directive: bytes +) -> bool: + """Whether a header of that name already lists *directive*.""" + for existing_name, existing_value in headers: + if existing_name.lower() != name: + continue + parts = [p.strip().lower() for p in existing_value.split(b",")] + if directive in parts: + return True + return False + + def _merge_header( headers: list[tuple[bytes, bytes]], name: bytes, value: bytes ) -> None: @@ -992,3 +1079,219 @@ def _merge_header( def scope_of(state: _RequestState) -> MutableMapping[str, Any]: """The ASGI scope behind a request state, for reading cross-feature flags.""" return state.request.scope if state.request is not None else {} + + +# --------------------------------------------------------------------------- +# valid_session() - the session gate +# --------------------------------------------------------------------------- + +_REASON: dict[_Load, SessionRejection] = { + _Load.MISSING: "missing", + _Load.NOT_FOUND: "expired", + _Load.FAILED: "unavailable", +} + + +@overload +def valid_session( + *, on_reject: OnReject[SessionRejection] | None = None +) -> Callable[[Request], Awaitable[None]]: ... + + +@overload +def valid_session( + *, + issued_within: int | timedelta, + on_reject: OnReject[RecencyRejection] | None = None, +) -> Callable[[Request], Awaitable[None]]: ... + + +def valid_session( + *, + issued_within: int | timedelta | None = None, + on_reject: OnReject[Any] | None = None, +) -> Callable[[Request], Awaitable[None]]: + """Return a dependency that rejects a request without a valid session. + + Valid means the cookie named a record that was live in Redis when the + request arrived, and nothing earlier in the request has ended it. Says + nothing about who the session belongs to: an anonymous session passes. + + Marks the session as read, so the response carries ``Vary: Cookie`` and + is treated as depending on the session - which it does. On a route + cached with ``cache(vary_on_session=False)`` the response says + ``private``, so a shared cache cannot serve a gated page to a caller the + gate refuses. List it **before** ``cache()``: in the other order it + raises on the first request, because a cache hit would skip it. + + Args: + issued_within: Also require the session's ID to have been issued at + most this long ago - by a sign-in, a rotation or + ``store.reauthenticate()``. Adds the reason ``"stale"``, and makes + the response ``Cache-Control: no-store``. **Not an + authentication check:** a new anonymous session is recent, so pair + it with the application's own authentication. + on_reject: Build the rejection. Receives the request and the reason, + and returns the response - sync or async. Default: 401, with a + ``WWW-Authenticate`` header only if the ``challenge`` setting is + configured. + + Raises: + SessionConfigurationError: At request time, if sessions are not set + up, the route is excluded by ``skip``, or ``cache()`` runs before + this gate. + TypeError: At request time, if *on_reject* returns something that is + not a ``Response``. + """ + limit = None if issued_within is None else _seconds(issued_within) + + async def _dependency(request: Request) -> None: + if request.scope.get(CACHE_SUPPRESS_VARY_SCOPE_KEY): + # cache() already ran, so on a later request its hit would be + # served before this gate. Refusing here keeps the first response + # from ever being stored, so no hit can exist. + raise SessionConfigurationError( + f"{request.url.path}: valid_session() must come before " + "cache(vary_on_session=False) in dependencies=[...]" + ) + session = session_of(request) + state = session_state_of(request) + load = request.scope.get(_LOAD_SCOPE_KEY, _Load.MISSING) + if load is _Load.SKIPPED: + raise SessionConfigurationError( + f"{request.url.path} is excluded by skip and gated by " + "valid_session(); it can never pass." + ) + session.mark_accessed() + if limit is not None: + request.scope[SESSION_NO_STORE_SCOPE_KEY] = True + + reason: RecencyRejection + if load is _Load.LOADED: + # Emptied earlier in this request: the middleware will sign it + # out on the way out, so it is already ended. ``dict.__len__`` + # does not mark the session. + cleared = session.modified and dict.__len__(session) == 0 + if state.session_id is not None and not state.revoked and not cleared: + request.scope[SESSION_GATED_SCOPE_KEY] = True + if limit is None: + return + age = _age_of(request, state) + if age is not None and age <= limit: + return + reason = "stale" + else: + reason = "missing" + else: + reason = _REASON[load] + + if on_reject is None: + raise _default_rejection(request, reason, limit) + response = on_reject(request, reason) + if isawaitable(response): + response = await response + if not isinstance(response, Response): + # Fail closed, and name the bug: a callback that returned nothing + # must not let the request through. + raise TypeError( + f"on_reject must return a Response, got {type(response).__name__}" + ) + raise SessionRejected(response) + + return _dependency + + +async def session_rejected_handler(request: Request, exc: Exception) -> Response: + """Return the response carried by :class:`SessionRejected`.""" + return cast(SessionRejected, exc).response + + +def _age_of(request: Request, state: SessionState) -> int | None: + """Seconds since the session's ID was issued, measured with the store the + middleware loaded it with - the one whose ``absolute_seconds`` sized the + key's TTL.""" + store = request.scope.get(_STORE_SCOPE_KEY) + if store is None: + return None + return issued_ago(store.absolute_seconds, state) + + +def _default_rejection( + request: Request, reason: RecencyRejection, limit: int | None +) -> Exception: + """The 401 ``valid_session()`` raises when no ``on_reject`` is given.""" + headers = challenge_headers(request, reason) + if reason == "stale": + return SessionRejected( + JSONResponse( + { + "detail": "A recently issued session is required", + "error": "stale_session", + "issued_within": limit, + }, + status_code=HTTP_401_UNAUTHORIZED, + headers=headers or None, + ) + ) + return HTTPException( + HTTP_401_UNAUTHORIZED, "No valid session", headers=headers or None + ) + + +def challenge_headers(request: Request, reason: str) -> dict[str, str]: + """The ``WWW-Authenticate`` header for a default rejection, or none. + + Read from the ``challenge`` setting at request time, so it does not matter + whether routes are declared before or after ``.sessions()``. + """ + challenge = getattr(request.app.state, "_redis_session_challenge", None) + if challenge is None: + return {} + if isinstance(challenge, (str, SecurityBase)): + value = _scheme_challenge(challenge) + else: + value = challenge(request, reason) + if value is not None: + _refuse_basic(value) + return {"WWW-Authenticate": value} if value else {} + + +def _scheme_challenge(challenge: str | SecurityBase) -> str | None: + """The challenge a string or a FastAPI security scheme stands for.""" + if isinstance(challenge, str): + return challenge + # Every class in fastapi.security builds its own 401 with its challenge. + # An older FastAPI without the method sends no header. + make_error = getattr(challenge, "make_not_authenticated_error", None) + if make_error is None: + return None + headers = getattr(make_error(), "headers", None) or {} + return cast("str | None", headers.get("WWW-Authenticate")) + + +def _refuse_basic(value: str | None) -> None: + """Refuse a ``Basic`` challenge: every browser answers it with a native + password dialog, on every gated page.""" + if value and value.split(maxsplit=1)[0].lower() == "basic": + raise SessionConfigurationError( + "challenge must not be Basic: browsers answer it with a password " + "dialog on every gated page. Use the application's own scheme, " + "such as APIKeyCookie, or a string like 'Cookie realm=...'." + ) + + +async def _load_with_status( + store: SessionStore, session_id: str, *, refresh: bool +) -> tuple[Any, bool]: + """``store.load_with_status``, or ``load`` for a store that lacks it. + + A store implementing only ``SessionStoreProtocol`` cannot say that its + read failed, so a failure reads as an unknown identifier: the gate then + says "expired" during an outage - the wrong word, but still a rejection. + """ + load_with_status = getattr(store, "load_with_status", None) + if load_with_status is not None: + return cast( + "tuple[Any, bool]", await load_with_status(session_id, refresh=refresh) + ) + return await store.load(session_id, refresh=refresh), False diff --git a/src/redis_fastapi/setup.py b/src/redis_fastapi/setup.py index d6bb249..5a7afdf 100644 --- a/src/redis_fastapi/setup.py +++ b/src/redis_fastapi/setup.py @@ -30,6 +30,7 @@ from redis_fastapi.rate import Rate from redis_fastapi.ratelimit import Identifier, OnLimitExceeded, SkipWhen from redis_fastapi.sessions import CookieSpec, Session + from redis_fastapi.types import Challenge class FastAPIRedis: @@ -149,6 +150,7 @@ def sessions( cookie_builder: Callable[[CookieSpec], str] | None = None, descriptor_of: Callable[[Request, Session], dict[str, Any]] | None = None, skip: Callable[[Request], bool] | None = None, + challenge: Challenge | None = None, **store_options: Any, ) -> FastAPIRedis: """Register the session middleware. @@ -179,6 +181,9 @@ def sessions( ``store_factory``, ``coder``, ``encryptor``, ``id_factory``, ``key_prefix``, ``idle_ttl``, ``absolute_ttl``, ``gc_ttl``. skip: Requests that need no session at all, at zero Redis cost. + challenge: The ``WWW-Authenticate`` header on a ``valid_session()`` + rejection: a FastAPI security scheme, a string, or a callable. + Omitted by default. """ from redis_fastapi.sessions import SessionMiddleware, add_redis_sessions @@ -192,6 +197,7 @@ def sessions( cookie_builder=cookie_builder, descriptor_of=descriptor_of, skip=skip, + challenge=challenge, **store_options, ) return self diff --git a/src/redis_fastapi/telemetry.py b/src/redis_fastapi/telemetry.py index 45e3e0f..957f386 100644 --- a/src/redis_fastapi/telemetry.py +++ b/src/redis_fastapi/telemetry.py @@ -358,10 +358,14 @@ def record_session_operation(*, operation: str, result: str) -> None: session - the sign-in rate - while a save updates an existing one. ``delete`` and ``index`` are deliberately absent: both are always part of one of the above and counting them would double-count it. - result: ``hit``, ``miss``, ``expired`` or ``error``. ``miss`` means - there was nothing to do - no such session, an empty index, an - identifier that is not this subject's. ``expired`` is emitted by - ``load`` alone. + result: ``hit``, ``miss``, ``expired``, ``malformed`` or ``error``. + ``miss`` means there was nothing to do - no such session, an empty + index, an identifier that is not this subject's. ``expired`` and + ``malformed`` are emitted by ``load`` alone. ``malformed`` is a + cookie that fails the identifier check: every identifier the store + issues passes it, so this is the one certain sign of an injected + value - or of another application on the domain using the same + cookie name. """ if not _state.enabled or _state.session_operations is None: return diff --git a/src/redis_fastapi/types.py b/src/redis_fastapi/types.py index 6d14608..e6d323f 100644 --- a/src/redis_fastapi/types.py +++ b/src/redis_fastapi/types.py @@ -4,9 +4,19 @@ import json from collections.abc import Awaitable, Callable -from typing import Any, Protocol, TypeAlias, TypeVar, runtime_checkable - +from typing import ( + Any, + Literal, + Protocol, + TypeAlias, + TypeVar, + runtime_checkable, +) + +from fastapi.security.base import SecurityBase from pydantic import BaseModel +from starlette.requests import Request +from starlette.responses import Response ModelT = TypeVar("ModelT", bound=BaseModel) @@ -81,3 +91,45 @@ def decrypt(self, data: bytes) -> bytes: ... # pragma: no cover # A key builder receives (request, eviction_group, prefix) and returns a cache key. KeyBuilder: TypeAlias = Callable[..., str | Awaitable[str]] + + +# --------------------------------------------------------------------------- +# valid_session() - the session gate +# --------------------------------------------------------------------------- + +R = TypeVar("R", bound=str) + +OnReject: TypeAlias = Callable[[Request, R], Response | Awaitable[Response]] +"""Builds the response for a rejected request, from the request and the reason. + +Generic over the reason, so ``valid_session()`` and +``valid_session(issued_within=...)`` share one alias while each promises the +exact set of reasons it can produce. It returns the response rather than +raising it: a callback that forgets to raise must not let the request through. +""" + +SessionRejection: TypeAlias = Literal["missing", "expired", "unavailable"] +"""Why ``valid_session()`` refused a request. + +* ``"missing"`` - no usable session: no cookie, a malformed one, or a session + an earlier dependency revoked or emptied. +* ``"expired"`` - a well-formed cookie naming no live record: expired, revoked + elsewhere, evicted, or forged. Nothing can tell those apart. +* ``"unavailable"`` - the read failed and the request continued without a + session. Return 503, not a sign-in page. +""" + +RecencyRejection: TypeAlias = Literal["missing", "expired", "unavailable", "stale"] +"""Why ``valid_session(issued_within=...)`` refused a request. + +The three reasons of :data:`SessionRejection`, plus ``"stale"``: the session +is valid, but its ID was issued longer ago than ``issued_within``. +""" + +Challenge: TypeAlias = str | SecurityBase | Callable[[Request, str], str | None] +"""What the default rejection sends as ``WWW-Authenticate``. + +A FastAPI security scheme, whose own challenge is used; a fixed string; or a +callable that receives the request and the reason and returns a value, or +``None`` for no header. +""" diff --git a/tests/integration/test_session_cache_invariants.py b/tests/integration/test_session_cache_invariants.py index 9cc04a0..a131a48 100644 --- a/tests/integration/test_session_cache_invariants.py +++ b/tests/integration/test_session_cache_invariants.py @@ -24,6 +24,7 @@ from fastapi import Depends, FastAPI from fastapi.testclient import TestClient +from redis_fastapi import valid_session from redis_fastapi.cache import cache from redis_fastapi.config import get_settings from redis_fastapi.deps import SessionDep @@ -87,6 +88,17 @@ async def status() -> dict: async def undeclared(session: SessionDep) -> dict: return {"user_id": session.get("user_id")} + # Row 2 with the library's own gate: shared in Redis, private downstream. + @application.get( + "/members-catalogue", + dependencies=[ + Depends(valid_session()), + Depends(cache(ttl=300, vary_on_session=False)), + ], + ) + async def members_catalogue() -> dict: + return {"products": ["a", "b"]} + # No cache() at all, but the session is read. @application.get("/profile") async def profile(session: SessionDep) -> dict: @@ -231,6 +243,36 @@ def test_n10_revised_this_row_is_deliberately_out_of_scope( assert "no-store" not in _directives(bob.get("/catalogue")) +class TestRow2BehindValidSession: + """Row 2 again, gated by ``valid_session()`` rather than by hand (S-1.6). + + Redis keeps the one shared entry - the gate runs before it on every + request. A shared cache downstream would serve it to anyone, gate or no + gate, so the response says ``private``: N-18's case of one shared entry + served only after ``valid_session()`` passes. + """ + + def test_one_shared_entry_serves_every_valid_session(self, alice, bob) -> None: + first = alice.get("/members-catalogue") + second = bob.get("/members-catalogue") + assert first.headers["x-redis-cache"] == "MISS" + assert second.headers["x-redis-cache"] == "HIT" + assert first.json() == second.json() + + def test_the_miss_and_the_hit_are_private(self, alice, bob) -> None: + assert "private" in _directives(alice.get("/members-catalogue")) + assert "private" in _directives(bob.get("/members-catalogue")) + + def test_a_caller_without_a_session_never_reaches_the_entry( + self, client: TestClient, alice + ) -> None: + alice.get("/members-catalogue") + client.cookies.clear() + anonymous = client.get("/members-catalogue") + assert anonymous.status_code == 401 + assert "x-redis-cache" not in anonymous.headers + + class TestRow3NoSessionAtAll: def test_shared_and_public_as_before(self, alice, bob) -> None: assert alice.get("/status").headers["x-redis-cache"] == "MISS" diff --git a/tests/integration/test_valid_session_integration.py b/tests/integration/test_valid_session_integration.py new file mode 100644 index 0000000..ad52f32 --- /dev/null +++ b/tests/integration/test_valid_session_integration.py @@ -0,0 +1,170 @@ +"""``valid_session()`` against a real Redis server. + +The unit suite drives every branch against ``fakeredis``. These tests prove +what only a real server can: that the reasons follow Redis's own expiry, that +``issued_within`` measures the server's countdown, and that the gate and +``cache()`` agree about what reaches Redis. +""" + +from __future__ import annotations + +import time +from collections.abc import Iterator +from typing import Any + +import pytest +import redis as sync_redis +from fastapi import Depends, FastAPI, Request +from fastapi.responses import JSONResponse, Response +from fastapi.testclient import TestClient + +from redis_fastapi import cache, valid_session +from redis_fastapi.config import get_settings +from redis_fastapi.deps import SessionDep, SessionStateDep, SessionStoreDep +from redis_fastapi.exceptions import SessionConfigurationError +from redis_fastapi.setup import FastAPIRedis +from tests.conftest import requires_redis + +pytestmark = [pytest.mark.integration, requires_redis] + + +def _reason_of(request: Request, reason: str) -> Response: + return JSONResponse({"reason": reason}, status_code=401) + + +def _build(monkeypatch, prefix: str, *, idle: int, absolute: int) -> FastAPI: + get_settings.cache_clear() + monkeypatch.setenv("REDIS_SESSION_COOKIE_HTTPS_ONLY", "false") + monkeypatch.setenv("REDIS_PREFIX", prefix) + monkeypatch.setenv("REDIS_SESSION_IDLE_TTL", str(idle)) + monkeypatch.setenv("REDIS_SESSION_ABSOLUTE_TTL", str(absolute)) + get_settings.cache_clear() + + app = FastAPI() + FastAPIRedis(app).lifespan().caching().sessions() + + @app.post("/login") + async def login(session: SessionDep) -> dict: + session["user_id"] = 42 + return {} + + @app.post("/reauth") + async def reauth(state: SessionStateDep, store: SessionStoreDep) -> dict: + await store.reauthenticate(state) + return {} + + @app.get("/gated", dependencies=[Depends(valid_session(on_reject=_reason_of))]) + async def gated() -> dict: + return {"ok": True} + + @app.get( + "/recent", + dependencies=[Depends(valid_session(issued_within=1, on_reject=_reason_of))], + ) + async def recent() -> dict: + return {"ok": True} + + @app.get( + "/right-order", + dependencies=[ + Depends(valid_session()), + Depends(cache(ttl=300, vary_on_session=False)), + ], + ) + async def right_order() -> dict: + return {"items": [1]} + + @app.get( + "/wrong-order", + dependencies=[ + Depends(cache(ttl=300, vary_on_session=False)), + Depends(valid_session()), + ], + ) + async def wrong_order() -> dict: + return {"items": [1]} + + return app + + +@pytest.fixture() +def client(real_redis, test_prefix: str, monkeypatch) -> Iterator[TestClient]: + app = _build(monkeypatch, test_prefix, idle=2, absolute=600) + with TestClient(app) as test_client: + yield test_client + get_settings.cache_clear() + + +def _cache_keys(redis: sync_redis.Redis, prefix: str) -> list[Any]: + return list(redis.scan_iter(match=f"{prefix}:cache*")) + + +def test_a_live_session_passes(client: TestClient) -> None: + client.post("/login") + response = client.get("/gated") + assert response.status_code == 200 + assert response.headers["cache-control"] == "private" + + +def test_no_cookie_is_missing(client: TestClient) -> None: + assert client.get("/gated").json() == {"reason": "missing"} + + +def test_the_server_s_idle_expiry_reads_as_expired(client: TestClient) -> None: + """Redis removed the payload on its own; the cookie still names it.""" + client.post("/login") + time.sleep(3) + assert client.get("/gated").json() == {"reason": "expired"} + + +def test_the_server_s_countdown_makes_a_session_stale( + real_redis, test_prefix: str, monkeypatch +) -> None: + """``issued_within`` reads the key's real TTL, not the application clock. + + A long idle clock, so the wait below ages the ID without ending the + session. + """ + app = _build(monkeypatch, test_prefix, idle=60, absolute=600) + with TestClient(app) as client: + client.post("/login") + assert client.get("/recent").status_code == 200 + time.sleep(2.2) + stale = client.get("/recent") + assert stale.json() == {"reason": "stale"} + assert stale.headers["cache-control"] == "no-store" + + client.post("/reauth") + assert client.get("/recent").status_code == 200, ( + "reauthenticate() issues a new ID, so the session is recent again" + ) + get_settings.cache_clear() + + +def test_the_right_order_keeps_one_shared_entry_behind_private( + client: TestClient, real_redis: sync_redis.Redis, test_prefix: str +) -> None: + client.post("/login") + first = client.get("/right-order") + second = client.get("/right-order") + assert first.headers["x-redis-cache"] == "MISS" + assert second.headers["x-redis-cache"] == "HIT" + assert first.headers["cache-control"].startswith("private") + assert second.headers["cache-control"].startswith("private") + assert len(_cache_keys(real_redis, test_prefix)) == 1 + + client.cookies.clear() + anonymous = client.get("/right-order") + assert anonymous.status_code == 401 + assert "x-redis-cache" not in anonymous.headers + + +def test_the_wrong_order_never_reaches_redis( + client: TestClient, real_redis: sync_redis.Redis, test_prefix: str +) -> None: + """The misordered route never succeeds, so no entry exists for a hit.""" + client.post("/login") + for _ in range(2): + with pytest.raises(SessionConfigurationError, match="must come before"): + client.get("/wrong-order") + assert _cache_keys(real_redis, test_prefix) == [] diff --git a/tests/unit/test_valid_session.py b/tests/unit/test_valid_session.py new file mode 100644 index 0000000..16924e4 --- /dev/null +++ b/tests/unit/test_valid_session.py @@ -0,0 +1,852 @@ +"""Unit tests for ``valid_session()``, against ``fakeredis``. + +The gate answers one question - did this request arrive with a session this +application created in an earlier response? - and, with ``issued_within``, a +second: was that session's ID issued recently? Each test below pins one line +of ``docs/specs/session-di-factory-research.md`` (S-1, S-1.5, S-1.6, S-2 and +Section 4). +""" + +from __future__ import annotations + +import asyncio +import time +from typing import Any + +import pytest +from fastapi import Depends, FastAPI, HTTPException, Request +from fastapi.responses import JSONResponse, Response +from fastapi.security import APIKeyCookie, HTTPBasic, OAuth2PasswordBearer +from fastapi.testclient import TestClient +from redis.exceptions import ConnectionError as RedisConnectionError + +import redis_fastapi.sessions as sessions_module +from redis_fastapi import cache, valid_session +from redis_fastapi.config import get_settings +from redis_fastapi.deps import ( + SessionDep, + SessionStateDep, + SessionStoreDep, + get_async_redis, +) +from redis_fastapi.exceptions import SessionConfigurationError +from redis_fastapi.session_backend import FIELD_DATA, RedisSessionStore, SessionState +from redis_fastapi.sessions import add_redis_sessions +from redis_fastapi.setup import FastAPIRedis + + +@pytest.fixture(autouse=True) +def _plain_http(monkeypatch): + get_settings.cache_clear() + monkeypatch.setenv("REDIS_SESSION_COOKIE_HTTPS_ONLY", "false") + get_settings.cache_clear() + yield + get_settings.cache_clear() + + +@pytest.fixture() +def store(fake_async_redis) -> RedisSessionStore: + return RedisSessionStore(fake_async_redis, idle_ttl=60, absolute_ttl=600) + + +def _reason_of(request: Request, reason: str) -> Response: + """An ``on_reject`` that reports the reason, so tests can assert it.""" + return JSONResponse({"reason": reason}, status_code=401) + + +def _app( + store: Any, + *, + gate: Any = None, + before: list[Any] | None = None, + **session_kwargs: Any, +) -> FastAPI: + """An app with a gated route, and routes that create and change sessions.""" + app = FastAPI() + add_redis_sessions(app, store=store, **session_kwargs) + gate = gate if gate is not None else valid_session(on_reject=_reason_of) + + @app.post("/login") + async def login(session: SessionDep) -> dict: + session["user_id"] = 42 + return {} + + @app.post("/login/{uid}") + async def login_as(uid: int, session: SessionDep) -> dict: + session["user_id"] = uid + return {} + + @app.post("/basket") + async def basket(session: SessionDep) -> dict: + session["basket"] = [1] + return {} + + @app.post("/reauth") + async def reauth(state: SessionStateDep, st: SessionStoreDep) -> dict: + await st.reauthenticate(state) + return {} + + @app.get("/public") + async def public() -> dict: + return {"ok": True} + + deps = [*(before or []), Depends(gate)] + + @app.get("/gated", dependencies=deps) + async def gated(session: SessionDep) -> dict: + return {"ok": True, "user_id": session.get("user_id")} + + return app + + +def _cookie(sid: str) -> dict[str, str]: + return {"cookie": f"session={sid}"} + + +class _FailingStore(RedisSessionStore): + """A store whose every read fails, as an unreachable server does.""" + + async def _read(self, session_id: str, *, refresh_idle: int | None) -> Any: + raise RedisConnectionError("Redis is down") + + +class _ProtocolOnlyStore: + """A store that implements the protocol but not ``load_with_status``.""" + + def __init__(self, inner: RedisSessionStore) -> None: + self._inner = inner + + def __getattr__(self, name: str) -> Any: + if name == "load_with_status": + raise AttributeError(name) + return getattr(self._inner, name) + + +# --------------------------------------------------------------------------- +# S-1: the three reasons +# --------------------------------------------------------------------------- + + +class TestReasons: + def test_no_cookie_is_missing(self, store) -> None: + with TestClient(_app(store)) as client: + response = client.get("/gated") + assert response.status_code == 401 + assert response.json() == {"reason": "missing"} + + @pytest.mark.parametrize("value", ["x", "!" * 64, "abc;def"]) + def test_a_malformed_cookie_is_missing_and_counted( + self, store, monkeypatch, value + ) -> None: + counted: list[tuple[str, str]] = [] + monkeypatch.setattr( + sessions_module, + "record_session_operation", + lambda **kw: counted.append((kw["operation"], kw["result"])), + ) + with TestClient(_app(store)) as client: + response = client.get("/gated", headers=_cookie(value)) + client.get("/public", headers=_cookie(value)) + assert response.json() == {"reason": "missing"} + assert counted == [("load", "malformed"), ("load", "malformed")], ( + "the malformed counter must increment on gated and ungated routes" + ) + + def test_a_well_formed_id_never_issued_is_expired(self, store) -> None: + with TestClient(_app(store)) as client: + response = client.get("/gated", headers=_cookie(store.new_id())) + assert response.json() == {"reason": "expired"} + + @pytest.mark.parametrize("ending", ["idle", "absolute", "revoked"]) + def test_a_session_that_ended_is_expired( + self, store, fake_async_redis, ending + ) -> None: + with TestClient(_app(store)) as client: + client.post("/login") + sid = client.cookies["session"] + key = store.session_key(sid) + if ending == "idle": + asyncio.run(fake_async_redis.hdel(key, FIELD_DATA)) + elif ending == "absolute": + asyncio.run(fake_async_redis.pexpire(key, 1)) + time.sleep(0.01) + else: + asyncio.run(store.delete(sid)) + response = client.get("/gated") + assert response.json() == {"reason": "expired"} + + def test_a_live_session_passes_and_varies_by_cookie(self, store) -> None: + with TestClient(_app(store)) as client: + client.post("/login") + response = client.get("/gated") + assert response.status_code == 200 + assert response.json() == {"ok": True, "user_id": 42} + assert "Cookie" in response.headers["vary"] + + def test_an_anonymous_session_passes(self, store) -> None: + """S-1 is not about identity.""" + with TestClient(_app(store)) as client: + client.post("/basket") + response = client.get("/gated") + assert response.status_code == 200 + assert response.json()["user_id"] is None + + def test_a_failed_read_is_unavailable(self, fake_async_redis) -> None: + failing = _FailingStore(fake_async_redis, idle_ttl=60, absolute_ttl=600) + with TestClient(_app(failing)) as client: + response = client.get("/gated", headers=_cookie(failing.new_id())) + assert response.json() == {"reason": "unavailable"} + + def test_a_protocol_only_store_reports_a_failed_read_as_expired( + self, fake_async_redis + ) -> None: + """Without ``load_with_status`` a failure looks like an unknown ID. + + The wording is wrong during an outage, but the gate still rejects. + """ + failing = _FailingStore(fake_async_redis, idle_ttl=60, absolute_ttl=600) + with TestClient(_app(_ProtocolOnlyStore(failing))) as client: + response = client.get("/gated", headers=_cookie(failing.new_id())) + assert response.json() == {"reason": "expired"} + + +# --------------------------------------------------------------------------- +# S-1: a session ended or created earlier in the same request +# --------------------------------------------------------------------------- + + +async def _writes(session: SessionDep) -> None: + session["basket"] = [1] + + +async def _revokes(state: SessionStateDep, st: SessionStoreDep) -> None: + await st.revoke(state) + + +async def _clears(session: SessionDep) -> None: + session.clear() + + +async def _rotates(state: SessionStateDep, st: SessionStoreDep) -> None: + await st.rotate(state) + + +class TestEarlierInTheRequest: + def test_a_session_created_before_the_gate_does_not_count(self, store) -> None: + """Valid means created in an *earlier* response.""" + with TestClient(_app(store, before=[Depends(_writes)])) as client: + response = client.get("/gated") + assert response.json() == {"reason": "missing"} + + @pytest.mark.parametrize("ender", [_revokes, _clears]) + def test_a_session_ended_before_the_gate_is_missing(self, store, ender) -> None: + with TestClient(_app(store, before=[Depends(ender)])) as client: + client.post("/login") + response = client.get("/gated") + assert response.json() == {"reason": "missing"} + + def test_a_rotation_before_the_gate_passes(self, store) -> None: + with TestClient(_app(store, before=[Depends(_rotates)])) as client: + client.post("/login") + response = client.get("/gated") + assert response.status_code == 200 + + +# --------------------------------------------------------------------------- +# S-1: configuration mistakes fail loudly +# --------------------------------------------------------------------------- + + +class TestConfiguration: + def test_a_route_excluded_by_skip_raises(self, store) -> None: + app = _app(store, skip=lambda request: request.url.path == "/gated") + with TestClient(app) as client: + with pytest.raises(SessionConfigurationError, match="/gated"): + client.get("/gated") + + def test_sessions_not_set_up_raises(self) -> None: + app = FastAPI() + + @app.get("/gated", dependencies=[Depends(valid_session())]) + async def gated() -> dict: + return {} + + with TestClient(app) as client: + with pytest.raises(SessionConfigurationError): + client.get("/gated") + + +# --------------------------------------------------------------------------- +# S-1: on_reject +# --------------------------------------------------------------------------- + + +class TestOnReject: + def test_a_sync_callback_s_response_is_sent_intact(self, store) -> None: + def reject(request: Request, reason: str) -> Response: + return JSONResponse({"r": reason}, status_code=503, headers={"x-a": "1"}) + + with TestClient(_app(store, gate=valid_session(on_reject=reject))) as client: + response = client.get("/gated") + assert response.status_code == 503 + assert response.json() == {"r": "missing"} + assert response.headers["x-a"] == "1" + + def test_an_async_callback_is_awaited(self, store) -> None: + async def reject(request: Request, reason: str) -> Response: + return JSONResponse({"r": reason}, status_code=403) + + with TestClient(_app(store, gate=valid_session(on_reject=reject))) as client: + response = client.get("/gated") + assert response.status_code == 403 + + def test_a_callback_that_raises_keeps_its_exception(self, store) -> None: + def reject(request: Request, reason: str) -> Response: + raise HTTPException(418, "teapot") + + with TestClient(_app(store, gate=valid_session(on_reject=reject))) as client: + response = client.get("/gated") + assert response.status_code == 418 + + def test_a_callback_that_returns_nothing_fails_closed(self, store) -> None: + """A missing ``return`` must not let the request through.""" + ran: list[bool] = [] + + def reject(request: Request, reason: str) -> Response: + return None # type: ignore[return-value] + + app = _app(store, gate=valid_session(on_reject=reject)) + + @app.get( + "/gated-and-counted", + dependencies=[Depends(valid_session(on_reject=reject))], + ) + async def counted() -> dict: + ran.append(True) + return {} + + with TestClient(app, raise_server_exceptions=False) as client: + response = client.get("/gated-and-counted") + assert response.status_code == 500 + assert ran == [] + + def test_the_default_rejection_is_a_401(self, store) -> None: + with TestClient(_app(store, gate=valid_session())) as client: + response = client.get("/gated") + assert response.status_code == 401 + assert response.json() == {"detail": "No valid session"} + + +# --------------------------------------------------------------------------- +# S-1: the WWW-Authenticate challenge +# --------------------------------------------------------------------------- + + +class TestChallenge: + def _challenge(self, store, challenge: Any, **gate_kwargs: Any) -> Response: + app = _app(store, gate=valid_session(**gate_kwargs), challenge=challenge) + with TestClient(app) as client: + return client.get("/gated") + + def test_no_setting_sends_no_header(self, store) -> None: + assert "www-authenticate" not in self._challenge(store, None).headers + + @pytest.mark.parametrize( + ("scheme", "expected"), + [ + (APIKeyCookie(name="session"), "APIKey"), + (OAuth2PasswordBearer(tokenUrl="/token"), "Bearer"), + ( + 'Cookie realm="shop" form-action="/login"', + 'Cookie realm="shop" form-action="/login"', + ), + ], + ) + def test_a_scheme_or_a_string_sets_the_header( + self, store, scheme, expected + ) -> None: + response = self._challenge(store, scheme) + assert response.status_code == 401 + assert response.headers["www-authenticate"] == expected + + def test_a_callable_receives_the_reason(self, store) -> None: + seen: list[str] = [] + + def challenge(request: Request, reason: str) -> str | None: + seen.append(reason) + return None if reason == "unavailable" else "APIKey" + + response = self._challenge(store, challenge) + assert seen == ["missing"] + assert response.headers["www-authenticate"] == "APIKey" + + def test_a_callable_returning_none_sends_no_header(self, store) -> None: + response = self._challenge(store, lambda request, reason: None) + assert "www-authenticate" not in response.headers + + def test_on_reject_owns_its_response(self, store) -> None: + response = self._challenge(store, "APIKey", on_reject=_reason_of) + assert "www-authenticate" not in response.headers + + @pytest.mark.parametrize("challenge", ["Basic", 'basic realm="x"', HTTPBasic()]) + def test_basic_is_refused_at_setup(self, store, challenge) -> None: + with pytest.raises(SessionConfigurationError, match="Basic"): + add_redis_sessions(FastAPI(), store=store, challenge=challenge) + + def test_a_callable_returning_basic_is_refused_at_request_time(self, store) -> None: + app = _app( + store, gate=valid_session(), challenge=lambda request, reason: "Basic" + ) + with TestClient(app) as client: + with pytest.raises(SessionConfigurationError, match="Basic"): + client.get("/gated") + + def test_the_builder_passes_the_challenge_on(self, store) -> None: + app = FastAPI() + FastAPIRedis(app).sessions(store=store, challenge="APIKey") + + @app.get("/gated", dependencies=[Depends(valid_session())]) + async def gated() -> dict: + return {} + + with TestClient(app) as client: + assert client.get("/gated").headers["www-authenticate"] == "APIKey" + + +# --------------------------------------------------------------------------- +# S-1: cookies the gate refuses +# --------------------------------------------------------------------------- + + +class TestRefusedCookies: + def test_a_write_after_an_unknown_id_issues_a_new_one(self, store) -> None: + """The client's value is never adopted (S-1.7).""" + forged = store.new_id() + with TestClient(_app(store)) as client: + response = client.post("/basket", headers=_cookie(forged)) + assert response.cookies["session"] != forged + + @pytest.mark.parametrize("value", ["not valid!", "a" * 43]) + def test_a_refused_cookie_is_not_cleared(self, store, value) -> None: + """It may belong to another application on the same domain.""" + with TestClient(_app(store)) as client: + response = client.get("/gated", headers=_cookie(value)) + assert "set-cookie" not in response.headers + + +# --------------------------------------------------------------------------- +# S-1.5 and Section 4: headers on gated responses +# --------------------------------------------------------------------------- + + +class TestHeaders: + def test_a_gated_uncached_route_is_private(self, store) -> None: + with TestClient(_app(store)) as client: + client.post("/login") + response = client.get("/gated") + assert response.headers["cache-control"] == "private" + assert "Cookie" in response.headers["vary"] + + def test_a_rejection_is_private_too(self, store) -> None: + with TestClient(_app(store)) as client: + response = client.get("/gated") + assert response.headers["cache-control"] == "private" + + def test_a_handler_s_no_store_gets_nothing_added(self, store) -> None: + app = _app(store) + + @app.get("/sensitive") + async def sensitive(session: SessionDep, response: Response) -> dict: + response.headers["Cache-Control"] = "no-store" + return {"user_id": session.get("user_id")} + + with TestClient(app) as client: + client.post("/login") + response = client.get("/sensitive") + assert response.headers["cache-control"] == "no-store" + assert "Cookie" in response.headers["vary"] + + def test_another_handler_directive_is_merged_with_private(self, store) -> None: + app = _app(store) + + @app.get("/revalidate") + async def revalidate(session: SessionDep, response: Response) -> dict: + response.headers["Cache-Control"] = "max-age=0" + return {"user_id": session.get("user_id")} + + with TestClient(app) as client: + client.post("/login") + response = client.get("/revalidate") + assert response.headers["cache-control"] == "max-age=0, private" + + def test_always_save_writes_on_a_gated_request(self, store, monkeypatch) -> None: + """S-1.5 accepts this cost; the test pins it so a change is deliberate.""" + monkeypatch.setenv("REDIS_SESSION_ALWAYS_SAVE", "true") + get_settings.cache_clear() + saved: list[str] = [] + original = store.save + + async def spy(session_id: str, record: Any) -> None: + saved.append(session_id) + await original(session_id, record) + + monkeypatch.setattr(store, "save", spy) + with TestClient(_app(store)) as client: + client.post("/login") + client.get("/gated") + assert saved, "the gated read of a non-empty session wrote nothing" + + +# --------------------------------------------------------------------------- +# S-1.6: the gate and cache() +# --------------------------------------------------------------------------- + + +def _cached_app(store: RedisSessionStore, fake_async_redis: Any) -> FastAPI: + app = _app(store) + FastAPIRedis(app).caching() + + async def _redis() -> Any: + return fake_async_redis + + app.dependency_overrides[get_async_redis] = _redis + + async def require_user(session: SessionDep) -> None: + if "user_id" not in session: + raise HTTPException(401) + + @app.get( + "/wrong-order", + dependencies=[ + Depends(cache(ttl=300, vary_on_session=False)), + Depends(valid_session()), + ], + ) + async def wrong_order() -> dict: + return {"items": [1]} + + @app.get( + "/right-order", + dependencies=[ + Depends(valid_session()), + Depends(cache(ttl=300, vary_on_session=False)), + ], + ) + async def right_order() -> dict: + return {"items": [1]} + + @app.get( + "/hand-written", + dependencies=[ + Depends(require_user), + Depends(cache(ttl=300, vary_on_session=False)), + ], + ) + async def hand_written() -> dict: + return {"items": [1]} + + @app.get("/ungated", dependencies=[Depends(cache(ttl=300, vary_on_session=False))]) + async def ungated(session: SessionDep) -> dict: + return {"basket": session.get("basket")} + + @app.get( + "/undeclared", + dependencies=[Depends(valid_session()), Depends(cache(ttl=300))], + ) + async def undeclared() -> dict: + return {"items": [1]} + + @app.get( + "/recent-cached", + dependencies=[ + Depends(valid_session(issued_within=600)), + Depends(cache(ttl=300, vary_on_session=True)), + ], + ) + async def recent_cached(session: SessionDep) -> dict: + return {"user_id": session.get("user_id")} + + @app.get("/no-store", dependencies=[Depends(cache(ttl=300, no_store=True))]) + async def no_store() -> dict: + return {"items": [1]} + + return app + + +async def _cache_keys(redis: Any) -> list[bytes]: + return [key async for key in redis.scan_iter(match="*cache*")] + + +class TestTheGateAndCache: + def test_the_wrong_order_raises_and_stores_nothing( + self, store, fake_async_redis + ) -> None: + with TestClient(_cached_app(store, fake_async_redis)) as client: + client.post("/login") + with pytest.raises(SessionConfigurationError, match="must come before"): + client.get("/wrong-order") + with pytest.raises(SessionConfigurationError): + client.get("/wrong-order") + assert asyncio.run(_cache_keys(fake_async_redis)) == [], ( + "a misordered route stored a response a later hit could serve" + ) + + def test_the_right_order_is_a_private_miss_then_a_private_hit( + self, store, fake_async_redis + ) -> None: + with TestClient(_cached_app(store, fake_async_redis)) as client: + client.post("/login") + first = client.get("/right-order") + second = client.get("/right-order") + assert first.headers["x-redis-cache"] == "MISS" + assert second.headers["x-redis-cache"] == "HIT" + assert first.headers["cache-control"] == "private, max-age=300" + assert second.headers["cache-control"].startswith("private, max-age=") + assert len(asyncio.run(_cache_keys(fake_async_redis))) == 1, ( + "Redis should hold one shared entry" + ) + + def test_a_rejected_caller_never_reaches_the_entry( + self, store, fake_async_redis + ) -> None: + with TestClient(_cached_app(store, fake_async_redis)) as client: + client.post("/login") + client.get("/right-order") + anonymous = client.get("/right-order", headers={"cookie": ""}) + assert anonymous.status_code == 401 + assert "x-redis-cache" not in anonymous.headers + + @pytest.mark.parametrize("path", ["/hand-written", "/ungated"]) + def test_other_gates_keep_their_shared_hits( + self, store, fake_async_redis, path + ) -> None: + with TestClient(_cached_app(store, fake_async_redis)) as client: + client.post("/login") + client.get(path) + second = client.get(path) + assert second.headers["x-redis-cache"] == "HIT" + assert "private" not in second.headers["cache-control"] + + def test_an_undeclared_gated_route_is_served_but_not_stored( + self, store, fake_async_redis, caplog, monkeypatch + ) -> None: + # The warning names each route once per process; another test may + # already have used this route name. + import importlib + + cache_module = importlib.import_module("redis_fastapi.cache") + monkeypatch.setattr(cache_module, "_WARNED_ROUTES", set()) + with TestClient(_cached_app(store, fake_async_redis)) as client: + client.post("/login") + with caplog.at_level("WARNING"): + first = client.get("/undeclared") + second = client.get("/undeclared") + assert first.status_code == second.status_code == 200 + assert second.headers.get("x-redis-cache") != "HIT" + assert "vary_on_session" in caplog.text + + def test_issued_within_makes_a_cached_route_no_store( + self, store, fake_async_redis + ) -> None: + with TestClient(_cached_app(store, fake_async_redis)) as client: + client.post("/login") + first = client.get("/recent-cached") + second = client.get("/recent-cached") + assert first.headers["x-redis-cache"] == "MISS" + assert second.headers["x-redis-cache"] == "HIT" + assert first.headers["cache-control"] == "no-store" + assert second.headers["cache-control"] == "no-store" + + def test_cache_no_store_keeps_the_entry_but_says_no_store( + self, store, fake_async_redis + ) -> None: + with TestClient(_cached_app(store, fake_async_redis)) as client: + first = client.get("/no-store") + second = client.get("/no-store") + assert first.headers["x-redis-cache"] == "MISS" + assert second.headers["x-redis-cache"] == "HIT" + assert first.headers["cache-control"] == "no-store" + assert second.headers["cache-control"] == "no-store" + + +# --------------------------------------------------------------------------- +# S-2: issued_within +# --------------------------------------------------------------------------- + + +def _recent(limit: int = 60) -> Any: + return valid_session(issued_within=limit, on_reject=_reason_of) + + +async def _age_key(redis: Any, key: str, age: int, absolute: int = 600) -> None: + """Make the session look *age* seconds old: that much gone from its TTL.""" + await redis.expire(key, absolute - age) + + +class TestIssuedWithin: + def test_a_new_session_passes(self, store) -> None: + with TestClient(_app(store, gate=_recent())) as client: + client.post("/basket") + response = client.get("/gated") + assert response.status_code == 200 + state = SessionState( + data={}, session_id=client.cookies["session"], absolute_remaining=600 + ) + assert store.session_age(state) == 0 + + def test_an_old_session_is_stale(self, store, fake_async_redis) -> None: + with TestClient(_app(store, gate=_recent())) as client: + client.post("/login") + key = store.session_key(client.cookies["session"]) + asyncio.run(_age_key(fake_async_redis, key, age=120)) + response = client.get("/gated") + assert response.json() == {"reason": "stale"} + + def test_reads_and_writes_do_not_reset_the_age( + self, store, fake_async_redis + ) -> None: + with TestClient(_app(store, gate=_recent())) as client: + client.post("/login") + key = store.session_key(client.cookies["session"]) + asyncio.run(_age_key(fake_async_redis, key, age=120)) + client.post("/basket") + client.get("/gated") + response = client.get("/gated") + assert response.json() == {"reason": "stale"} + + @pytest.mark.parametrize("renewal", ["/reauth", "/login/7"]) + def test_a_new_id_makes_the_session_recent( + self, store, fake_async_redis, renewal + ) -> None: + """``reauthenticate()`` and a principal change both issue a new ID.""" + with TestClient(_app(store, gate=_recent())) as client: + client.post("/login") + old = client.cookies["session"] + asyncio.run(_age_key(fake_async_redis, store.session_key(old), age=120)) + client.post(renewal) + response = client.get("/gated") + assert client.cookies["session"] != old + assert response.status_code == 200 + + def test_a_handler_rotation_makes_the_session_recent( + self, store, fake_async_redis + ) -> None: + with TestClient( + _app(store, gate=_recent(), before=[Depends(_rotates)]) + ) as client: + client.post("/login") + key = store.session_key(client.cookies["session"]) + asyncio.run(_age_key(fake_async_redis, key, age=120)) + response = client.get("/gated") + assert response.status_code == 200 + + def test_a_session_from_before_a_shorter_lifetime_is_stale( + self, fake_async_redis + ) -> None: + """A negative age means the configuration changed: fail closed.""" + long_lived = RedisSessionStore(fake_async_redis, idle_ttl=60, absolute_ttl=600) + with TestClient(_app(long_lived)) as client: + client.post("/login") + sid = client.cookies["session"] + short_lived = RedisSessionStore(fake_async_redis, idle_ttl=60, absolute_ttl=300) + with TestClient(_app(short_lived, gate=_recent(600))) as client: + response = client.get("/gated", headers=_cookie(sid)) + assert response.json() == {"reason": "stale"} + state = SessionState(data={}, session_id=sid, absolute_remaining=600) + assert short_lived.session_age(state) is None + + def test_a_request_without_a_stored_session_has_no_age(self, store) -> None: + assert store.session_age(SessionState(data={})) is None + assert store.session_age(SessionState(data={}, session_id="x" * 30)) is None + + def test_the_application_clock_does_not_matter(self, store, monkeypatch) -> None: + """Moving the library's own clock a century changes nothing. + + Only the library's clock is moved: ``fakeredis`` reads the global one + to expire keys, and it stands in for the server here. + """ + import types + + import redis_fastapi.session_backend as backend + + with TestClient(_app(store, gate=_recent())) as client: + client.post("/login") + monkeypatch.setattr( + backend, "time", types.SimpleNamespace(time=lambda: 4e9) + ) + response = client.get("/gated") + assert response.status_code == 200 + + def test_no_session_gives_s_1_s_reason_first(self, store) -> None: + with TestClient(_app(store, gate=_recent())) as client: + response = client.get("/gated") + assert response.json() == {"reason": "missing"} + + def test_the_default_rejection_says_what_is_needed( + self, store, fake_async_redis + ) -> None: + with TestClient(_app(store, gate=valid_session(issued_within=60))) as client: + client.post("/login") + key = store.session_key(client.cookies["session"]) + asyncio.run(_age_key(fake_async_redis, key, age=120)) + response = client.get("/gated") + assert response.status_code == 401 + assert response.json() == { + "detail": "A recently issued session is required", + "error": "stale_session", + "issued_within": 60, + } + assert response.headers["cache-control"] == "no-store" + + def test_a_passing_response_is_no_store(self, store) -> None: + with TestClient(_app(store, gate=_recent())) as client: + client.post("/login") + response = client.get("/gated") + assert response.status_code == 200 + assert response.headers["cache-control"] == "no-store" + + def test_without_issued_within_there_is_no_stale_and_no_no_store( + self, store, fake_async_redis + ) -> None: + with TestClient(_app(store)) as client: + client.post("/login") + key = store.session_key(client.cookies["session"]) + asyncio.run(_age_key(fake_async_redis, key, age=590)) + response = client.get("/gated") + assert response.status_code == 200 + assert response.headers["cache-control"] == "private" + + def test_a_timedelta_is_accepted(self, store) -> None: + from datetime import timedelta + + with TestClient( + _app(store, gate=valid_session(issued_within=timedelta(minutes=1))) + ) as client: + client.post("/login") + assert client.get("/gated").status_code == 200 + + +class TestTypes: + """The two overloads promise exact reason types (S-1, step 3).""" + + def test_the_overloads_narrow_the_reason_type(self, tmp_path) -> None: + api = pytest.importorskip("mypy.api") + source = tmp_path / "check.py" + source.write_text( + "from starlette.requests import Request\n" + "from starlette.responses import Response\n" + "from redis_fastapi import RecencyRejection, SessionRejection, valid_session\n" + "def narrow(request: Request, reason: SessionRejection) -> Response:\n" + " raise NotImplementedError\n" + "def wide(request: Request, reason: RecencyRejection) -> Response:\n" + " raise NotImplementedError\n" + "valid_session(on_reject=narrow)\n" + "valid_session(on_reject=wide)\n" + "valid_session(issued_within=60, on_reject=wide)\n" + "valid_session(issued_within=60, on_reject=narrow) # E: stale unhandled\n" + ) + stdout, _, status = api.run([str(source), "--no-incremental"]) + errors = [line for line in stdout.splitlines() if ": error:" in line] + assert status == 1, stdout + assert len(errors) == 1, stdout + assert "check.py:11:" in errors[0], stdout From 5c5b42a815d55522a149076a25f9118ba8a8999e Mon Sep 17 00:00:00 2001 From: Tihomir Mateev Date: Tue, 29 Sep 2026 18:43:40 +0300 Subject: [PATCH 10/11] Addressed all comments by @vladvildanov 1. Signing out with no stored session (sessions.py): WRITE now also requires the session to be non-empty. session.clear() with no cookie, or with an expired one, no longer creates a new empty session and cookie. 2. Rotating to anonymous (session_backend.py): rotate() now defaults subject to an _UNSET sentinel in the async store and the sync wrapper. Leaving subject out keeps the current subject, so a handler's step-up still works. Passing None, as the middleware does, now makes the new session anonymous, and it is no longer added to the old user's index. 3. Pub/Sub leak and tier (session_events.py): a finally block now closes the Pub/Sub connection, including when stop() cancels the task. A lost subscription now sets tier to "none". I kept creating the Pub/Sub object inside the try, because an existing test covers pubsub() itself raising. There is still no reconnect. 4. Warning set growing without bound (cache.py): the warning is now keyed on the route template (GET /items/{item}), not the concrete path. 5. Dead comment (sessions.py): deleted. 6. idle_ttl/absolute_ttl overrides (sessions.py): the middleware now receives these overrides and uses them to decide whether the cookie gets a Max-Age. 7. (outside of Vlad's review) remove the GC timeout setting and use a simpler approach with a constant; apply GC timeout only in cases where absolute timeout is set to 0 --- docs/api/reference.md | 5 +- docs/guide/sessions.md | 7 +- docs/specs/session-design.md | 3 + src/redis_fastapi/cache.py | 7 +- src/redis_fastapi/config.py | 20 +- src/redis_fastapi/lifespan.py | 5 +- src/redis_fastapi/session_backend.py | 33 ++- src/redis_fastapi/session_events.py | 77 +++++-- src/redis_fastapi/sessions.py | 48 +++-- .../test_session_cache_invariants.py | 22 +- .../integration/test_session_cookie_expiry.py | 45 +++++ tests/integration/test_session_integration.py | 149 +++++++++++++- .../test_session_middleware_integration.py | 103 ++++++++++ tests/unit/test_session_backend.py | 22 ++ tests/unit/test_session_events.py | 189 +++++++++++++++++- tests/unit/test_session_middleware.py | 4 +- tests/unit/test_session_outcome.py | 6 +- tests/unit/test_session_setup.py | 2 +- tests/unit/test_valid_session.py | 31 ++- 19 files changed, 695 insertions(+), 83 deletions(-) create mode 100644 tests/integration/test_session_middleware_integration.py diff --git a/docs/api/reference.md b/docs/api/reference.md index a5c5999..8ff6d72 100644 --- a/docs/api/reference.md +++ b/docs/api/reference.md @@ -560,7 +560,10 @@ async def _(session_id: str, cause: Cause) -> None: ... Started and stopped by the lifespan when `session_events_enabled` is set. `events.tier` is `"key"` when the server can deliver events and `"none"` -otherwise — in which case handlers never fire. Requires +otherwise — in which case handlers never fire. It is also `"none"` while a +lost subscription is being restored: the subscriber tries again every second +unless `session_events_reconnect` is off, or `SessionEvents(..., +reconnect=False)` was used. Requires `notify-keyspace-events` including `Ehx` — `E` for the `__keyevent@` channels, `h` for hash events and `x` for expiry events. `A` covers `h` and `x`. An idle timeout arrives as `hexpired`, an absolute timeout as `expired`. diff --git a/docs/guide/sessions.md b/docs/guide/sessions.md index 4d4760e..d9c234e 100644 --- a/docs/guide/sessions.md +++ b/docs/guide/sessions.md @@ -402,7 +402,11 @@ every other application on the instance. [`CONFIG GET`](https://redis.io/docs/latest/commands/config-get/) is unavailable - which is common on managed Redis - `events.tier` is `"none"`, one warning is logged at startup, and **your handlers never run**. Startup - still succeeds and every request still works. + still succeeds and every request still works. If the subscription is lost + later, for example on a network error, `events.tier` changes to `"none"` + and one warning is logged. The subscriber tries again every second, with + no limit, and `events.tier` returns to `"key"` once it is back. Set + `session_events_reconnect=False` to stop at the first loss instead. A revocation handler that never fires looks exactly like one that works. If prompt closure matters, check `events.tier` and add a periodic sweep as @@ -884,6 +888,7 @@ Every setting is an environment variable prefixed `REDIS_`, so | `session_always_save` | `False` | Escape route for nested mutation. Writes on every request that **read** the session; an empty session is exempt | | `session_principal_keys` | `["user_id"]` | What a change to rotates the ID. Comma-separated in the environment: `REDIS_SESSION_PRINCIPAL_KEYS=user_id,role` | | `session_events_enabled` | `False` | Opt in to real-time events | +| `session_events_reconnect` | `True` | Subscribe again every second after a lost subscription. `False` stops at the first loss | --- diff --git a/docs/specs/session-design.md b/docs/specs/session-design.md index 8e737e7..3741749 100644 --- a/docs/specs/session-design.md +++ b/docs/specs/session-design.md @@ -266,6 +266,9 @@ also accept a `timedelta`; Section 9 gives the convention and why a settings fie sensibly take one. With both at zero the session is cookie-only: no `max-age` on the cookie, so the browser drops it when it closes, and **both** clocks get `gc_ttl` so Redis eventually collects what the browser abandoned. +With only `idle_ttl` at zero, field `d` takes the key's TTL rather than `gc_ttl`, so the +key always expires first and only the absolute clock can end the session. A `gc_ttl` on +`d` would act as a hidden idle clock whenever `absolute_ttl` is longer than `gc_ttl`. ### 3.3 The subject index: from a user back to their sessions diff --git a/src/redis_fastapi/cache.py b/src/redis_fastapi/cache.py index d94cb87..40e0fcc 100644 --- a/src/redis_fastapi/cache.py +++ b/src/redis_fastapi/cache.py @@ -299,7 +299,7 @@ class CachePending: vary_on_session: bool | None = None """What the route declared about session dependence. ``None`` means the developer did not say, which is what arms the safety net in - :func:`_store_cache_entry`.""" + :func:`_leaks_across_users`.""" route: str = "" """For the one-time warning, so it names something useful.""" @@ -505,7 +505,7 @@ async def _dependency( # 4. HIT: short-circuit via exception — endpoint never runs. # A gate that ran first decides the directives too: a gated # body is private, and a route that asked for a recent session - # is sensitive (valid_session(), S-1.6 and S-2). + # is sensitive (valid_session(), N-18). private_here = _private or bool(request.scope.get(SESSION_GATED_SCOPE_KEY)) no_store_here = no_store or bool( request.scope.get(SESSION_NO_STORE_SCOPE_KEY) @@ -539,7 +539,8 @@ async def _dependency( no_store=no_store_here, redis=redis, vary_on_session=vary_on_session, - route=f"{request.method} {request.url.path}", + route=f"{request.method} " + f"{getattr(request.scope.get('route'), 'path', request.url.path)}", ) yield diff --git a/src/redis_fastapi/config.py b/src/redis_fastapi/config.py index a9e28d4..ade5fd3 100644 --- a/src/redis_fastapi/config.py +++ b/src/redis_fastapi/config.py @@ -29,7 +29,7 @@ # Scope keys the caching and session features use to agree about a response, # rather than each appending headers independently. They live here because # neither feature may import the other: caching must work with sessions absent, -# and deps.py already imports sessions, so cache -> sessions would be a cycle. +# and deps.py already imports sessions, so sessions -> cache would be a cycle. CACHE_ROUTE_SCOPE_KEY: str = "redis_cache_route" """Set by ``cache()``: this route owns its ``Cache-Control``.""" CACHE_SUPPRESS_VARY_SCOPE_KEY: str = "redis_cache_no_vary" @@ -238,7 +238,7 @@ class RedisSettings(BaseSettings): "Idle clock, in seconds. The session dies this long after the " "last request that carried its cookie. Stored as the TTL of hash " "field 'd'. 0 disables the idle clock, and the field then takes " - "session_gc_ttl." + "the key's TTL, so only the absolute clock can end the session." ), ) session_absolute_ttl: int = Field( @@ -255,10 +255,10 @@ class RedisSettings(BaseSettings): default=2592000, gt=0, description=( - "Backstop TTL for a field whose real deadline is unknown: " - "cookie-only mode, or session_absolute_ttl=0. Never reached in " - "normal operation; it exists so Redis can always collect an " - "abandoned key." + "Backstop TTL for a key whose real deadline is unknown: " + "session_absolute_ttl=0, alone or with session_idle_ttl=0 " + "(cookie-only mode). Never reached in normal operation; it exists so Redis " + "can always collect an abandoned key." ), ) session_refresh_on_load: bool = Field( @@ -310,6 +310,14 @@ class RedisSettings(BaseSettings): "handlers never fire." ), ) + session_events_reconnect: bool = Field( + default=True, + description=( + "Subscribe again, every second and with no limit, when the " + "session-event subscription is lost. False: stop at the first " + "loss, and the handlers stay silent until the process restarts." + ), + ) # -- Telemetry ------------------------------------------------------------- otel_enabled: bool = Field( diff --git a/src/redis_fastapi/lifespan.py b/src/redis_fastapi/lifespan.py index f35a11d..03d55eb 100644 --- a/src/redis_fastapi/lifespan.py +++ b/src/redis_fastapi/lifespan.py @@ -192,7 +192,10 @@ async def _start_session_events(app: FastAPI, ps: _PoolState) -> Any: try: events = SessionEvents( - ps.get_async_client(), key_prefix=settings.prefix, db=settings.db + ps.get_async_client(), + key_prefix=settings.prefix, + db=settings.db, + reconnect=settings.session_events_reconnect, ) await events.start() except Exception: diff --git a/src/redis_fastapi/session_backend.py b/src/redis_fastapi/session_backend.py index 1998569..afb1769 100644 --- a/src/redis_fastapi/session_backend.py +++ b/src/redis_fastapi/session_backend.py @@ -109,6 +109,11 @@ class Deadline(Enum): _ID_BYTES = 32 _ID_MIN_LENGTH = 22 +# The default for ``rotate(subject=...)``: keep the session's current subject. +# ``None`` cannot mean that, because ``None`` is how the middleware says the +# session is now anonymous. +_UNSET: Any = object() + def _seconds(value: int | timedelta) -> int: """Normalise a TTL setting to whole seconds. @@ -405,18 +410,22 @@ def __init__( def idle_seconds(self) -> int: """TTL for field ``d``. - ``session_idle_ttl=0`` disables the idle clock, and the field then - falls back to ``gc_ttl`` rather than being left unexpiring, so Redis - can always collect an abandoned key. + ``session_idle_ttl=0`` disables the idle clock. The field then takes + the key's TTL, so the key always expires first and takes the field + with it. Every command on ``d`` still gets a positive TTL, and no + read can end a session the idle clock was told to leave alone. + ``gc_ttl`` here would act as a hidden idle clock whenever + ``absolute_ttl`` is longer than it. """ - return self._idle_ttl or self._gc_ttl + return self._idle_ttl or self.absolute_seconds @property def absolute_seconds(self) -> int: """TTL for the session key, set once at creation and never refreshed. ``session_absolute_ttl=0`` disables the absolute clock, and the key - then falls back to ``gc_ttl`` for the same reason as above. + then falls back to ``gc_ttl``. The load reads a key with no TTL as + a dead session, so the key must always carry one. """ return self._absolute_ttl or self._gc_ttl @@ -757,10 +766,10 @@ async def rotate( self, state: SessionState, *, - subject: str | None = None, + subject: str | None = _UNSET, descriptor: dict[str, Any] | None = None, ) -> str: - """Issue a new identifier for *session*, deleting the old key first. + """Issue a new identifier for *state*, deleting the old key first. This is the defence against session fixation, and the **middleware normally drives it** when the principal changes (Section 5.1). It is @@ -778,6 +787,9 @@ async def rotate( should run from that moment rather than from whenever the anonymous session began. + Omit *subject* to keep the session's current one. ``None`` makes the + new session anonymous: it is not indexed under any subject. + Returns: The new session ID. @@ -795,7 +807,8 @@ async def _rotate( descriptor: dict[str, Any] | None, ) -> str: """Body of :meth:`rotate`, so the span wraps the whole four trips.""" - subject = subject if subject is not None else state.subject + if subject is _UNSET: + subject = state.subject old_id = state.session_id if old_id is not None: await self.delete(old_id) @@ -853,7 +866,7 @@ async def revoke(self, state: SessionState, *, subject: str | None = None) -> No *subject* drops the index entry alongside the key. The middleware supplies it from the subject captured at load time, so a handler - calling ``store.revoke(session)`` need not pass anything; pass it + calling ``store.revoke(state)`` need not pass anything; pass it explicitly only when using the store outside a request. Leaving the entry behind is not cosmetic: ``list_for_subject`` prunes @@ -1515,7 +1528,7 @@ def rotate( self, state: SessionState, *, - subject: str | None = None, + subject: str | None = _UNSET, descriptor: dict[str, Any] | None = None, ) -> str: """Issue a new identifier, deleting the old key first (blocking).""" diff --git a/src/redis_fastapi/session_events.py b/src/redis_fastapi/session_events.py index 0f5028e..5d2edae 100644 --- a/src/redis_fastapi/session_events.py +++ b/src/redis_fastapi/session_events.py @@ -92,6 +92,11 @@ REQUIRED_CONFIG = "Ehx" +# Seconds between attempts to subscribe again after the subscription is lost. +# Fixed, not a backoff: one attempt a second costs a server nothing, and the +# events sent while no subscriber is connected are lost whatever the delay. +_RECONNECT_DELAY = 1.0 + class SessionEvents: """Calls registered handlers when a session ends. @@ -109,9 +114,14 @@ async def _(session_id: str, cause: Cause) -> None: ... await events.stop() + When the subscription is lost, the subscriber tries again every second + until it is back or :meth:`stop` is called. Pass ``reconnect=False`` to + stop at the first loss instead. + Attributes: tier: ``"key"`` when the server can deliver events, ``"none"`` when - it cannot. Read it to decide whether a handler will ever run. + it cannot or while the subscription is lost. Read it to decide + whether a handler will run. """ def __init__( @@ -120,10 +130,12 @@ def __init__( *, key_prefix: str, db: int = 0, + reconnect: bool = True, ) -> None: self._redis = redis self._session_prefix = f"{key_prefix}:session:" self._db = db + self._reconnect = reconnect self._handlers: list[Handler] = [] self._task: asyncio.Task[None] | None = None self.tier: Tier = "none" @@ -230,7 +242,7 @@ async def stop(self) -> None: await task async def _run(self) -> None: - """Subscribe and dispatch until cancelled. + """Subscribe and dispatch until cancelled, subscribing again on a loss. On a cluster this covers one node only. Keyspace events are node-local and are **not** broadcast, so a full deployment needs one @@ -241,21 +253,52 @@ async def _run(self) -> None: _IDLE_CHANNEL.format(db=self._db): "idle", _ABSOLUTE_CHANNEL.format(db=self._db): "absolute", } - try: - pubsub = self._redis.pubsub() - await pubsub.subscribe(*channels) - async for message in pubsub.listen(): - if message.get("type") != "message": - continue - cause = channels.get(_text(message.get("channel"))) - if cause is not None: - await self._dispatch(message.get("data"), cause) - except asyncio.CancelledError: - raise - except STORE_ERRORS as exc: - # Losing the subscription is not an application error. Say so once - # and stop; nothing downstream depends on this stream. - logger.warning("Session event subscription ended: %s", exc) + lost = False + while True: + pubsub = None + try: + # A new Pub/Sub on every attempt, rather than reusing one whose + # connection failed: nothing about the old one needs keeping. + pubsub = self._redis.pubsub() + await pubsub.subscribe(*channels) + if lost: + lost = False + self.tier = "key" + logger.info("Session event subscription restored") + async for message in pubsub.listen(): + if message.get("type") != "message": + continue + cause = channels.get(_text(message.get("channel"))) + if cause is not None: + await self._dispatch(message.get("data"), cause) + # ``listen()`` ends only when nothing is subscribed, which this + # class never does; there is nothing to reconnect to. + return + except asyncio.CancelledError: + raise + except STORE_ERRORS as exc: + # Losing the subscription is not an application error; nothing + # downstream depends on this stream. ``tier`` must stop + # claiming that handlers will run until it is back. Said once + # per loss, not once per attempt. + self.tier = "none" + if not self._reconnect: + logger.warning("Session event subscription ended: %s", exc) + return + if not lost: + lost = True + logger.warning( + "Session event subscription lost; trying again every %gs: %s", + _RECONNECT_DELAY, + exc, + ) + finally: + # Cancellation from ``stop()`` lands here too; without this the + # subscriber connection stays checked out of the pool. + if pubsub is not None: + with contextlib.suppress(*STORE_ERRORS): + await pubsub.aclose() # type: ignore[no-untyped-call] + await asyncio.sleep(_RECONNECT_DELAY) async def _dispatch(self, data: Any, cause: Cause) -> None: session_id = self._session_id(data) diff --git a/src/redis_fastapi/sessions.py b/src/redis_fastapi/sessions.py index 89a709d..629b251 100644 --- a/src/redis_fastapi/sessions.py +++ b/src/redis_fastapi/sessions.py @@ -4,8 +4,8 @@ nothing else. See ``docs/specs/session-design.md`` for the full design. This module holds the request-facing half of the feature: the :class:`Session` -mapping the application sees as ``request.session``, the middleware that loads -and saves it, and the exception hierarchy. The Redis half lives in +mapping the application sees as ``request.session`` and the middleware that +loads and saves it. The Redis half lives in ``session_backend.py``. """ @@ -88,9 +88,6 @@ def __init__(self, *args: Any, **kwargs: Any) -> None: # application touching anything, so both flags start clean. self.accessed = False self.modified = False - # Set by the middleware after a load, and by the store after a - # rotation. ``None`` means this session has never been written, so - # there is no key to delete and no cookie to replace. # -- flags --------------------------------------------------------------- @@ -236,7 +233,7 @@ class CookieSpec: Passed to a ``cookie_builder`` seam so an application can add attributes this package does not know about. Starlette's own middleware cannot emit - ``Partitioned`` and cannot use a ``__Host-`` prefix, and Section 2a of + ``Partitioned`` and cannot use a ``__Host-`` prefix, and Section 9.1 of ``session-mgmt.md`` records that as a common reason people abandon it. ``max_age`` of ``None`` means a session cookie: no ``Max-Age``, and the @@ -369,7 +366,7 @@ class Outcome(Enum): class _Signals: """The flags :func:`decide_outcome` reads. - A record rather than ten positional arguments, so the decision can be + A record rather than eleven positional arguments, so the decision can be exercised over its whole input space without a store, a request or Redis. """ @@ -433,15 +430,20 @@ def decide_outcome(signals: _Signals) -> Outcome: # handler touches ``request.session`` at all - so the unqualified test # wrote a key and set a cookie for every anonymous visitor to any route # that so much as asked ``session.get("user_id")``. On a public page that - # is one Redis key per crawler, per health check, per preflight, held for - # ``gc_ttl``. + # is one Redis key per crawler, per health check, per preflight, held + # until its absolute deadline. # # Nothing is lost that the setting exists for. Its purpose is nested # mutation - ``session["a"]["b"] = 1``, which no ``dict`` subclass can see # - and that implies a top-level key already holding the nested value, so # the session is not empty. What it no longer does is create a session # out of an empty one, which no nested mutation could have produced. - if signals.modified or (signals.always_save and not signals.empty): + # + # ``modified`` takes the same qualifier. An empty session that reaches + # this line was never stored - ``SIGN_OUT`` caught the stored one - so + # writing it would turn a sign-out after idle expiry into a new, empty, + # valid session with a fresh cookie. + if (signals.modified or signals.always_save) and not signals.empty: return Outcome.WRITE # The load was a plain read under this setting, so this is the only place @@ -495,6 +497,8 @@ def __init__( cookie_builder: Callable[[CookieSpec], str] | None = None, descriptor_of: Callable[[Request, Session], dict[str, Any]] | None = None, skip: Callable[[Request], bool] | None = None, + idle_ttl: int | timedelta | None = None, + absolute_ttl: int | timedelta | None = None, ) -> None: self.app = app self._store_factory = store_factory @@ -503,6 +507,10 @@ def __init__( self._cookie_builder = cookie_builder or build_cookie self._descriptor_of = descriptor_of self._skip = skip + # The overrides given to ``add_redis_sessions``, which the store also + # received. ``None`` means "use the setting", read per request. + self._idle_ttl = idle_ttl + self._absolute_ttl = absolute_ttl async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: if scope["type"] != "http": @@ -818,9 +826,15 @@ def _spec(self, settings: Any, value: str, absolute: int | None) -> CookieSpec: slides, so a cookie sent on any write stays correct until the record's last possible moment. Redis still enforces the idle clock. """ - cookie_only = ( - not settings.session_idle_ttl and not settings.session_absolute_ttl + idle = ( + self._idle_ttl if self._idle_ttl is not None else settings.session_idle_ttl + ) + lifetime = ( + self._absolute_ttl + if self._absolute_ttl is not None + else settings.session_absolute_ttl ) + cookie_only = not _seconds(idle) and not _seconds(lifetime) return CookieSpec( name=settings.session_cookie_name, value=value, @@ -934,7 +948,7 @@ def add_redis_sessions( absolute_ttl: Absolute clock, overriding ``session_absolute_ttl``. gc_ttl: Backstop TTL, overriding ``session_gc_ttl``. - These last eight exist because the store constructor has always accepted + The last seven exist because the store constructor has always accepted them and nothing reachable from here passed them on: the only way to change a coder was to replace the whole dependency, and that did not reach the middleware at all. @@ -1001,6 +1015,8 @@ def resolver(session: Session) -> Any: # noqa: F811 cookie_builder=cookie_builder, descriptor_of=descriptor_of, skip=skip, + idle_ttl=idle_ttl, + absolute_ttl=absolute_ttl, ) @@ -1133,8 +1149,8 @@ def valid_session( it with the application's own authentication. on_reject: Build the rejection. Receives the request and the reason, and returns the response - sync or async. Default: 401, with a - ``WWW-Authenticate`` header only if the ``challenge`` setting is - configured. + ``WWW-Authenticate`` header only if ``add_redis_sessions`` was + given a ``challenge``. Raises: SessionConfigurationError: At request time, if sessions are not set @@ -1241,7 +1257,7 @@ def _default_rejection( def challenge_headers(request: Request, reason: str) -> dict[str, str]: """The ``WWW-Authenticate`` header for a default rejection, or none. - Read from the ``challenge`` setting at request time, so it does not matter + Read from the ``challenge`` argument at request time, so it does not matter whether routes are declared before or after ``.sessions()``. """ challenge = getattr(request.app.state, "_redis_session_challenge", None) diff --git a/tests/integration/test_session_cache_invariants.py b/tests/integration/test_session_cache_invariants.py index a131a48..238b9be 100644 --- a/tests/integration/test_session_cache_invariants.py +++ b/tests/integration/test_session_cache_invariants.py @@ -88,6 +88,11 @@ async def status() -> dict: async def undeclared(session: SessionDep) -> dict: return {"user_id": session.get("user_id")} + # Row 4 on a parametrized route: one route, many concrete paths. + @application.get("/undeclared/{item}", dependencies=[Depends(cache(ttl=300))]) + async def undeclared_item(item: int, session: SessionDep) -> dict: + return {"item": item, "user_id": session.get("user_id")} + # Row 2 with the library's own gate: shared in Redis, private downstream. @application.get( "/members-catalogue", @@ -244,7 +249,7 @@ def test_n10_revised_this_row_is_deliberately_out_of_scope( class TestRow2BehindValidSession: - """Row 2 again, gated by ``valid_session()`` rather than by hand (S-1.6). + """Row 2 again, gated by ``valid_session()`` rather than by hand. Redis keeps the one shared entry - the gate runs before it on every request. A shared cache downstream would serve it to anyone, gate or no @@ -344,6 +349,21 @@ def test_it_warns_once_naming_the_route(self, alice, caplog) -> None: assert len(warnings) == 1 assert "/undeclared" in warnings[0].getMessage() + def test_a_parametrized_route_warns_once_under_its_template( + self, alice, caplog + ) -> None: + """Keyed on the concrete path, every item ID added an entry and a log.""" + from redis_fastapi.cache import _WARNED_ROUTES + + _WARNED_ROUTES.clear() + with caplog.at_level("WARNING"): + for item in range(1, 4): + alice.get(f"/undeclared/{item}") + warnings = [r for r in caplog.records if "vary_on_session" in r.message] + assert len(warnings) == 1 + assert "GET /undeclared/{item}" in warnings[0].getMessage() + assert _WARNED_ROUTES == {"GET /undeclared/{item}"} + class TestASessionRouteWithNoCacheAtAll: def test_the_middleware_emits_private_itself(self, alice) -> None: diff --git a/tests/integration/test_session_cookie_expiry.py b/tests/integration/test_session_cookie_expiry.py index e931d6a..32429e0 100644 --- a/tests/integration/test_session_cookie_expiry.py +++ b/tests/integration/test_session_cookie_expiry.py @@ -171,3 +171,48 @@ def test_an_idle_user_is_signed_out_although_the_cookie_lives( assert response.request.headers.get("cookie") == f"session={session_id}" assert response.json() == {"user_id": None} assert real_redis.exists(key) == 0 + + +def _override_app(monkeypatch, prefix: str, settings: tuple[int, int], **overrides): + """An app whose clocks come from ``.sessions()`` rather than the settings.""" + get_settings.cache_clear() + monkeypatch.setenv("REDIS_SESSION_COOKIE_HTTPS_ONLY", "false") + monkeypatch.setenv("REDIS_PREFIX", prefix) + monkeypatch.setenv("REDIS_SESSION_IDLE_TTL", str(settings[0])) + monkeypatch.setenv("REDIS_SESSION_ABSOLUTE_TTL", str(settings[1])) + get_settings.cache_clear() + + application = FastAPI() + FastAPIRedis(application).lifespan().sessions(**overrides) + + @application.post("/login") + async def login(session: SessionDep) -> dict: + session["user_id"] = 42 + return {} + + return application + + +def test_overrides_of_zero_give_a_browser_session_cookie( + real_redis: sync_redis.Redis, test_prefix: str, monkeypatch +) -> None: + """Both clocks disabled in ``.sessions()`` wins over the settings.""" + app = _override_app( + monkeypatch, test_prefix, (IDLE, ABSOLUTE), idle_ttl=0, absolute_ttl=0 + ) + with TestClient(app) as client: + response = client.post("/login") + assert "max-age" not in response.headers["set-cookie"].lower() + get_settings.cache_clear() + + +def test_overrides_give_a_max_age_when_the_settings_disable_both_clocks( + real_redis: sync_redis.Redis, test_prefix: str, monkeypatch +) -> None: + app = _override_app( + monkeypatch, test_prefix, (0, 0), idle_ttl=60, absolute_ttl=ABSOLUTE + ) + with TestClient(app) as client: + response = client.post("/login") + assert _max_age(response) == ABSOLUTE + get_settings.cache_clear() diff --git a/tests/integration/test_session_integration.py b/tests/integration/test_session_integration.py index 02ea7ec..4a80b97 100644 --- a/tests/integration/test_session_integration.py +++ b/tests/integration/test_session_integration.py @@ -119,11 +119,10 @@ async def test_both_index_tiers_expire_the_entry( ) -> None: """``_index_add``'s fallback had no test in either suite. - Unlike the session write it uses no ``NX``, so nothing about it followed - from the session-path tests. The entry carries the *remainder* of the - absolute clock, never the full lifetime - a full lifetime would restart - the entry's clock on every write and let the index outlive the session it - names, which is unrevocable rather than merely untidy. + The entry carries the *remainder* of the absolute clock, never the full + lifetime - a full lifetime would restart the entry's clock on every write + and let the index outlive the session it names, which is unrevocable + rather than merely untidy. """ if supports_hsetex and not await probe_hsetex_support(real_async_redis): pytest.skip("server has no HSETEX; the 8.0 tier cannot be forced on") @@ -289,6 +288,42 @@ async def test_rotation_deletes_the_old_key_before_writing_the_new_one( assert await real_async_redis.exists(store.session_key(new)) == 1 +async def test_rotation_without_a_subject_keeps_the_current_one( + real_async_redis: async_redis.Redis, test_prefix: str +) -> None: + """A handler's step-up ``rotate(state)`` must stay indexed under its user.""" + from redis_fastapi.session_backend import SessionState + + store = _store(real_async_redis, test_prefix) + state = SessionState(data={"user_id": 42}, session_id=store.new_id(), subject="42") + record = store.new_record(dict(state.data)) + await store.create(state.session_id, record) + await store.index("42", state.session_id, record, absolute_remaining=600) + + new = await store.rotate(state) + assert state.subject == "42" + assert await real_async_redis.hkeys(store.index_key("42")) == [new] + + +async def test_rotation_to_no_subject_leaves_the_old_index_alone( + real_async_redis: async_redis.Redis, test_prefix: str +) -> None: + """``subject=None`` is how the middleware says the session is anonymous.""" + from redis_fastapi.session_backend import SessionState + + store = _store(real_async_redis, test_prefix) + state = SessionState( + data={"user_id": None}, session_id=store.new_id(), subject="42" + ) + record = store.new_record(dict(state.data)) + await store.create(state.session_id, record) + await store.index("42", state.session_id, record, absolute_remaining=600) + + await store.rotate(state, subject=None) + assert state.subject is None + assert await real_async_redis.hkeys(store.index_key("42")) == [] + + async def test_writing_the_payload_leaves_the_deadline_alone( real_async_redis: async_redis.Redis, test_prefix: str ) -> None: @@ -406,3 +441,107 @@ async def test_an_absolute_expiry_reaches_a_handler_as_absolute( real_async_redis, test_prefix, idle_ttl=60, absolute_ttl=1 ) assert (session_id, cause) == (sid, "absolute") + + +async def _pubsub_clients(redis: async_redis.Redis) -> int: + return len(await redis.client_list(_type="pubsub")) + + +async def test_stop_returns_the_subscriber_connection( + real_async_redis: async_redis.Redis, test_prefix: str +) -> None: + """Cancelling the subscriber must close its Pub/Sub connection.""" + original = (await real_async_redis.config_get("notify-keyspace-events")).get( + "notify-keyspace-events", "" + ) + try: + try: + await real_async_redis.config_set("notify-keyspace-events", REQUIRED_CONFIG) + except async_redis.RedisError as exc: + pytest.skip(f"server will not take {REQUIRED_CONFIG!r}: {exc}") + + events = SessionEvents(real_async_redis, key_prefix=test_prefix) + before = await _pubsub_clients(real_async_redis) + await events.start() + if events.tier == "none": + pytest.skip("server cannot supply keyspace notifications") + for _ in range(50): + if await _pubsub_clients(real_async_redis) > before: + break + await asyncio.sleep(0.05) + assert await _pubsub_clients(real_async_redis) == before + 1 + + await events.stop() + for _ in range(50): + if await _pubsub_clients(real_async_redis) == before: + break + await asyncio.sleep(0.05) + assert await _pubsub_clients(real_async_redis) == before, ( + "stop() left the subscriber connection open" + ) + finally: + await real_async_redis.config_set("notify-keyspace-events", original) + + +async def test_a_killed_subscription_reconnects_and_still_delivers( + real_async_redis: async_redis.Redis, test_prefix: str, monkeypatch +) -> None: + """``CLIENT KILL`` is a real connection loss, not a scripted one. + + The subscriber must notice, report ``tier == "none"``, subscribe again on + a new connection, and then deliver an expiry that happens afterwards. + """ + import redis_fastapi.session_events as module + + monkeypatch.setattr(module, "_RECONNECT_DELAY", 0.1) + original = (await real_async_redis.config_get("notify-keyspace-events")).get( + "notify-keyspace-events", "" + ) + try: + try: + await real_async_redis.config_set("notify-keyspace-events", REQUIRED_CONFIG) + except async_redis.RedisError as exc: + pytest.skip(f"server will not take {REQUIRED_CONFIG!r}: {exc}") + + before = {c["id"] for c in await real_async_redis.client_list(_type="pubsub")} + events = SessionEvents(real_async_redis, key_prefix=test_prefix) + delivered: asyncio.Queue = asyncio.Queue() + + @events.on_session_end + async def _(session_id: str, cause: str) -> None: + await delivered.put((session_id, cause)) + + await events.start() + if events.tier == "none": + pytest.skip("server cannot supply keyspace notifications") + try: + + async def _ours() -> set[str]: + clients = await real_async_redis.client_list(_type="pubsub") + return {c["id"] for c in clients} - before + + for _ in range(50): + if first := await _ours(): + break + await asyncio.sleep(0.05) + assert len(first) == 1 + await real_async_redis.client_kill_filter(_id=next(iter(first))) + + for _ in range(100): + current = await _ours() + if current and current != first and events.tier == "key": + break + await asyncio.sleep(0.05) + assert current and current != first, "no new subscriber connection" + assert events.tier == "key" + + store = _store(real_async_redis, test_prefix, idle_ttl=1) + sid = store.new_id() + await store.create(sid, store.new_record({"user_id": 42})) + await asyncio.sleep(1.5) + assert await store.load(sid) is None + assert await asyncio.wait_for(delivered.get(), timeout=10) == (sid, "idle") + finally: + await events.stop() + finally: + await real_async_redis.config_set("notify-keyspace-events", original) diff --git a/tests/integration/test_session_middleware_integration.py b/tests/integration/test_session_middleware_integration.py new file mode 100644 index 0000000..a685501 --- /dev/null +++ b/tests/integration/test_session_middleware_integration.py @@ -0,0 +1,103 @@ +"""What the middleware leaves in a real Redis after a request. + +Two defects that only show as keys in the server: a sign-out after idle expiry +that minted a new empty session, and a rotation to an anonymous session that +indexed the new ID under the old user. +""" + +import time + +import pytest +import redis as sync_redis +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from redis_fastapi.config import get_settings +from redis_fastapi.deps import SessionDep +from redis_fastapi.setup import FastAPIRedis +from tests.conftest import requires_redis + +pytestmark = [pytest.mark.integration, requires_redis] + +IDLE = 1 +ABSOLUTE = 600 + + +@pytest.fixture() +def app(real_redis: sync_redis.Redis, test_prefix: str, monkeypatch) -> FastAPI: + get_settings.cache_clear() + monkeypatch.setenv("REDIS_SESSION_COOKIE_HTTPS_ONLY", "false") + monkeypatch.setenv("REDIS_PREFIX", test_prefix) + monkeypatch.setenv("REDIS_SESSION_IDLE_TTL", str(IDLE)) + monkeypatch.setenv("REDIS_SESSION_ABSOLUTE_TTL", str(ABSOLUTE)) + get_settings.cache_clear() + + application = FastAPI() + FastAPIRedis(application).lifespan().sessions() + + @application.post("/login") + async def login(session: SessionDep) -> dict: + session["user_id"] = "u1" + return {} + + @application.post("/logout") + async def logout(session: SessionDep) -> dict: + session.clear() + return {} + + @application.post("/anonymous") + async def anonymous(session: SessionDep) -> dict: + session["user_id"] = None + return {} + + yield application + get_settings.cache_clear() + + +def _session_keys(redis: sync_redis.Redis, prefix: str) -> list[str]: + return list(redis.scan_iter(match=f"{prefix}*:session:*")) + + +class TestSigningOutWithNoStoredSession: + """``session.clear()`` is the documented sign-out; it must not sign in.""" + + def test_with_no_cookie_nothing_is_written( + self, app: FastAPI, real_redis: sync_redis.Redis, test_prefix: str + ) -> None: + with TestClient(app) as client: + response = client.post("/logout") + assert "set-cookie" not in response.headers + assert _session_keys(real_redis, test_prefix) == [] + + def test_after_idle_expiry_nothing_is_written( + self, app: FastAPI, real_redis: sync_redis.Redis, test_prefix: str + ) -> None: + with TestClient(app) as client: + client.post("/login") + assert client.cookies.get("session") + time.sleep(IDLE + 1) + + response = client.post("/logout") + assert response.request.headers.get("cookie") is not None + assert "set-cookie" not in response.headers + assert _session_keys(real_redis, test_prefix) == [] + + +class TestRotatingToAnAnonymousSession: + def test_the_new_id_is_not_indexed_under_the_old_subject( + self, app: FastAPI, real_redis: sync_redis.Redis, test_prefix: str + ) -> None: + with TestClient(app) as client: + client.post("/login") + signed_in = client.cookies.get("session") + client.post("/anonymous") + anonymous = client.cookies.get("session") + + assert anonymous and anonymous != signed_in, "the ID did not rotate" + keys = _session_keys(real_redis, test_prefix) + assert len(keys) == 1 and keys[0].endswith(anonymous), keys + index = real_redis.scan_iter(match=f"{test_prefix}*:sessions-of:u1") + entries = [field for key in index for field in real_redis.hkeys(key)] + assert anonymous not in entries, ( + "the anonymous session was indexed under the user who left it" + ) diff --git a/tests/unit/test_session_backend.py b/tests/unit/test_session_backend.py index 699c05a..d1e11cc 100644 --- a/tests/unit/test_session_backend.py +++ b/tests/unit/test_session_backend.py @@ -144,6 +144,28 @@ async def test_zero_ttls_fall_back_to_gc_ttl(self, fake_async_redis) -> None: assert about(await _deadline(fake_async_redis, key), 1234) assert about(await _httl(fake_async_redis, key, FIELD_DATA), 1234) + async def test_zero_idle_ttl_disables_the_idle_clock( + self, fake_async_redis + ) -> None: + """idle_ttl=0 gives field 'd' the key's TTL, not gc_ttl. + + With gc_ttl shorter than absolute_ttl, a gc_ttl on 'd' would end a + session the idle clock was told to leave alone. + """ + store = RedisSessionStore( + fake_async_redis, idle_ttl=0, absolute_ttl=5000, gc_ttl=1234 + ) + sid = store.new_id() + await store.create(sid, store.new_record({})) + key = store.session_key(sid) + assert about(await _deadline(fake_async_redis, key), 5000) + assert about(await _httl(fake_async_redis, key, FIELD_DATA), 5000) + + assert await store.load(sid) is not None + assert about(await _httl(fake_async_redis, key, FIELD_DATA), 5000), ( + "the load's idle refresh fell back to gc_ttl" + ) + class TestLoadStateTable: """Every state a load can find, from Section 4.1.""" diff --git a/tests/unit/test_session_events.py b/tests/unit/test_session_events.py index 8358c93..dd2eecb 100644 --- a/tests/unit/test_session_events.py +++ b/tests/unit/test_session_events.py @@ -397,7 +397,9 @@ class _Dropping(_ScriptedRedis): def pubsub(self): raise RedisError("connection reset") - events = SessionEvents(_Dropping([]), key_prefix="redis:fastapi") + events = SessionEvents( + _Dropping([]), key_prefix="redis:fastapi", reconnect=False + ) await events.start() await asyncio.wait_for(events._task, timeout=5) await events.stop() @@ -425,6 +427,191 @@ async def _forever(): assert task.cancelled() or task.done() assert events._task is None + async def test_stop_closes_the_pubsub(self) -> None: + """Cancelling the task must return the subscriber connection.""" + redis = _ScriptedRedis([]) + pubsub = redis.pubsub_obj + + async def _forever(): + while True: + await asyncio.sleep(0.01) + yield {"type": "subscribe", "data": 1} + + pubsub.listen = _forever # type: ignore[method-assign] + events = SessionEvents(redis, key_prefix="redis:fastapi") + await events.start() + await asyncio.sleep(0.05) + await events.stop() + assert pubsub.closed + + async def test_a_lost_subscription_closes_the_pubsub_and_drops_the_tier( + self, + ) -> None: + """With reconnecting off, ``tier`` must stop promising delivery.""" + redis = _ScriptedRedis([]) + pubsub = redis.pubsub_obj + + async def _drop(): + raise RedisError("connection reset") + yield # pragma: no cover - makes this an async generator + + pubsub.listen = _drop # type: ignore[method-assign] + events = SessionEvents(redis, key_prefix="redis:fastapi", reconnect=False) + await events.start() + assert events.tier == "key" + await asyncio.wait_for(events._task, timeout=5) + assert events.tier == "none" + assert pubsub.closed + await events.stop() + + +class _DroppingPubSub(_FakePubSub): + """A subscription whose connection fails as soon as it is read.""" + + async def listen(self): + raise RedisError("connection reset") + yield # pragma: no cover - makes this an async generator + + +class _SequencedRedis(_FakeRedis): + """Hands out the next scripted Pub/Sub on each call to ``pubsub()``. + + ``None`` in the script stands for a server that cannot be reached, so + ``pubsub()`` raises. Once the script runs out, every call raises. + """ + + def __init__(self, script: list[_FakePubSub | None]) -> None: + super().__init__() + self.script = list(script) + self.handed_out: list[_FakePubSub] = [] + self.calls = 0 + + def pubsub(self) -> _FakePubSub: + self.calls += 1 + if not self.script or self.script[0] is None: + if self.script: + self.script.pop(0) + raise RedisError("connection refused") + pubsub = self.script.pop(0) + assert pubsub is not None + self.handed_out.append(pubsub) + return pubsub + + +class TestReconnect: + """The subscriber comes back after a loss, unless told not to.""" + + @pytest.fixture(autouse=True) + def _no_wait(self, monkeypatch) -> None: + import redis_fastapi.session_events as module + + monkeypatch.setattr(module, "_RECONNECT_DELAY", 0) + + async def test_reconnecting_is_the_default(self) -> None: + assert SessionEvents(_FakeRedis(), key_prefix="p")._reconnect is True + + async def test_a_lost_subscription_is_restored_and_delivers(self) -> None: + sid = "a" * 40 + redis = _SequencedRedis( + [_DroppingPubSub([]), None, _FakePubSub([_event("hexpired", sid)])] + ) + events = SessionEvents(redis, key_prefix="redis:fastapi") + seen: list[tuple[str, str]] = [] + events.on_session_end(lambda i, c: _record(seen, i, c)) + await events.start() + await asyncio.wait_for(events._task, timeout=5) + + assert seen == [(sid, "idle")] + assert events.tier == "key" + assert redis.calls == 3 + assert all(pubsub.closed for pubsub in redis.handed_out) + await events.stop() + + async def test_tier_is_none_while_the_subscription_is_lost( + self, monkeypatch + ) -> None: + """And ``stop()`` ends the task while it waits to try again.""" + import redis_fastapi.session_events as module + + monkeypatch.setattr(module, "_RECONNECT_DELAY", 60) + redis = _SequencedRedis([_DroppingPubSub([])]) + events = SessionEvents(redis, key_prefix="redis:fastapi") + await events.start() + task = events._task + for _ in range(100): + if events.tier == "none": + break + await asyncio.sleep(0.01) + assert events.tier == "none" + assert task is not None and not task.done() + + await asyncio.wait_for(events.stop(), timeout=1) + assert task.done() + + async def test_it_keeps_trying_with_no_limit(self) -> None: + redis = _SequencedRedis([]) + events = SessionEvents(redis, key_prefix="redis:fastapi") + await events.start() + for _ in range(200): + if redis.calls >= 20: + break + await asyncio.sleep(0.005) + assert redis.calls >= 20 + assert events._task is not None and not events._task.done() + await events.stop() + + async def test_a_loss_is_logged_once_and_the_recovery_once(self, caplog) -> None: + redis = _SequencedRedis( + [_DroppingPubSub([]), None, None, None, _FakePubSub([])] + ) + events = SessionEvents(redis, key_prefix="redis:fastapi") + with caplog.at_level("INFO", logger="redis_fastapi.session_events"): + await events.start() + await asyncio.wait_for(events._task, timeout=5) + messages = [r.getMessage() for r in caplog.records] + assert sum("subscription lost" in m for m in messages) == 1 + assert sum("subscription restored" in m for m in messages) == 1 + await events.stop() + + async def test_with_reconnect_off_it_tries_once(self) -> None: + redis = _SequencedRedis([_DroppingPubSub([]), _FakePubSub([])]) + events = SessionEvents(redis, key_prefix="redis:fastapi", reconnect=False) + await events.start() + await asyncio.wait_for(events._task, timeout=5) + assert redis.calls == 1 + assert events.tier == "none" + await events.stop() + + +class TestTheLifespanPassesTheSetting: + @pytest.mark.parametrize(("value", "expected"), [(None, True), ("false", False)]) + async def test_session_events_reconnect_reaches_the_subscriber( + self, monkeypatch, value, expected + ) -> None: + from fastapi import FastAPI + + from redis_fastapi.config import get_settings + from redis_fastapi.lifespan import _start_session_events + + monkeypatch.setenv("REDIS_SESSION_EVENTS_ENABLED", "true") + if value is not None: + monkeypatch.setenv("REDIS_SESSION_EVENTS_RECONNECT", value) + get_settings.cache_clear() + + class _Pools: + def get_async_client(self): + return _ScriptedRedis([]) + + app = FastAPI() + app.state._redis_sessions = True + try: + events = await _start_session_events(app, _Pools()) # type: ignore[arg-type] + assert events is not None + assert events._reconnect is expected + await events.stop() + finally: + get_settings.cache_clear() + async def _record(sink: list, session_id: str, cause: str) -> None: sink.append((session_id, cause)) diff --git a/tests/unit/test_session_middleware.py b/tests/unit/test_session_middleware.py index eec8896..c180d0f 100644 --- a/tests/unit/test_session_middleware.py +++ b/tests/unit/test_session_middleware.py @@ -448,7 +448,7 @@ async def test_a_real_sign_in_still_writes(self, always_save_app: FastAPI) -> No async def test_a_nested_mutation_still_persists( self, always_save_app: FastAPI, fake_async_redis ) -> None: - """What the setting is for, and the reason (a) is safe. + """What the setting is for, and why the ``not empty`` qualifier is safe. A nested mutation needs a top-level key already holding the nested value, so the session is never empty when it happens. @@ -456,7 +456,7 @@ async def test_a_nested_mutation_still_persists( with TestClient(always_save_app) as client: client.post("/login") sid = client.cookies["session"] - # Seed a nested value through the ordinary path. + # Seed a nested value directly in the store. client.post("/login") store = always_save_app.state._store loaded = await store.load(sid) diff --git a/tests/unit/test_session_outcome.py b/tests/unit/test_session_outcome.py index 86ff75b..51993db 100644 --- a/tests/unit/test_session_outcome.py +++ b/tests/unit/test_session_outcome.py @@ -115,12 +115,14 @@ def test_emptying_signs_out_rather_than_rotating(self) -> None: is Outcome.SIGN_OUT ) - def test_emptying_a_session_that_was_never_stored_is_not_a_sign_out( + def test_emptying_a_session_that_was_never_stored_owes_nothing( self, ) -> None: + # Writing it would mint a new, empty session and cookie for a user who + # signed out after idle expiry. assert ( decide_outcome(_signals(modified=True, empty=True, stored=False)) - is Outcome.WRITE + is Outcome.NOTHING ) diff --git a/tests/unit/test_session_setup.py b/tests/unit/test_session_setup.py index 6ab8307..f539a6d 100644 --- a/tests/unit/test_session_setup.py +++ b/tests/unit/test_session_setup.py @@ -378,7 +378,7 @@ def test_store_and_store_factory_together_are_refused(self) -> None: class TestSessionCarriesNoTransportState: - """Proposal 2: ``Session`` is a dict with two flags, and nothing else. + """``Session`` is a dict with two flags, and nothing else. The store used to write ``sid``/``subject``/``revoked``/``rotated`` onto the object the application holds, which made those four a public mutable diff --git a/tests/unit/test_valid_session.py b/tests/unit/test_valid_session.py index 16924e4..002141c 100644 --- a/tests/unit/test_valid_session.py +++ b/tests/unit/test_valid_session.py @@ -2,9 +2,8 @@ The gate answers one question - did this request arrive with a session this application created in an earlier response? - and, with ``issued_within``, a -second: was that session's ID issued recently? Each test below pins one line -of ``docs/specs/session-di-factory-research.md`` (S-1, S-1.5, S-1.6, S-2 and -Section 4). +second: was that session's ID issued recently? Each test below pins one rule +of the gate. """ from __future__ import annotations @@ -123,7 +122,7 @@ def __getattr__(self, name: str) -> Any: # --------------------------------------------------------------------------- -# S-1: the three reasons +# The three reasons # --------------------------------------------------------------------------- @@ -184,7 +183,7 @@ def test_a_live_session_passes_and_varies_by_cookie(self, store) -> None: assert "Cookie" in response.headers["vary"] def test_an_anonymous_session_passes(self, store) -> None: - """S-1 is not about identity.""" + """The gate checks for a session, not for an identity.""" with TestClient(_app(store)) as client: client.post("/basket") response = client.get("/gated") @@ -211,7 +210,7 @@ def test_a_protocol_only_store_reports_a_failed_read_as_expired( # --------------------------------------------------------------------------- -# S-1: a session ended or created earlier in the same request +# A session ended or created earlier in the same request # --------------------------------------------------------------------------- @@ -253,7 +252,7 @@ def test_a_rotation_before_the_gate_passes(self, store) -> None: # --------------------------------------------------------------------------- -# S-1: configuration mistakes fail loudly +# Configuration mistakes fail loudly # --------------------------------------------------------------------------- @@ -277,7 +276,7 @@ async def gated() -> dict: # --------------------------------------------------------------------------- -# S-1: on_reject +# on_reject # --------------------------------------------------------------------------- @@ -338,7 +337,7 @@ def test_the_default_rejection_is_a_401(self, store) -> None: # --------------------------------------------------------------------------- -# S-1: the WWW-Authenticate challenge +# The WWW-Authenticate challenge # --------------------------------------------------------------------------- @@ -414,13 +413,13 @@ async def gated() -> dict: # --------------------------------------------------------------------------- -# S-1: cookies the gate refuses +# Cookies the gate refuses # --------------------------------------------------------------------------- class TestRefusedCookies: def test_a_write_after_an_unknown_id_issues_a_new_one(self, store) -> None: - """The client's value is never adopted (S-1.7).""" + """The client's value is never adopted.""" forged = store.new_id() with TestClient(_app(store)) as client: response = client.post("/basket", headers=_cookie(forged)) @@ -435,7 +434,7 @@ def test_a_refused_cookie_is_not_cleared(self, store, value) -> None: # --------------------------------------------------------------------------- -# S-1.5 and Section 4: headers on gated responses +# Headers on gated responses # --------------------------------------------------------------------------- @@ -480,7 +479,7 @@ async def revalidate(session: SessionDep, response: Response) -> dict: assert response.headers["cache-control"] == "max-age=0, private" def test_always_save_writes_on_a_gated_request(self, store, monkeypatch) -> None: - """S-1.5 accepts this cost; the test pins it so a change is deliberate.""" + """The gate accepts this cost; the test pins it so a change is deliberate.""" monkeypatch.setenv("REDIS_SESSION_ALWAYS_SAVE", "true") get_settings.cache_clear() saved: list[str] = [] @@ -498,7 +497,7 @@ async def spy(session_id: str, record: Any) -> None: # --------------------------------------------------------------------------- -# S-1.6: the gate and cache() +# The gate and cache() # --------------------------------------------------------------------------- @@ -670,7 +669,7 @@ def test_cache_no_store_keeps_the_entry_but_says_no_store( # --------------------------------------------------------------------------- -# S-2: issued_within +# issued_within # --------------------------------------------------------------------------- @@ -827,7 +826,7 @@ def test_a_timedelta_is_accepted(self, store) -> None: class TestTypes: - """The two overloads promise exact reason types (S-1, step 3).""" + """The two overloads promise exact reason types.""" def test_the_overloads_narrow_the_reason_type(self, tmp_path) -> None: api = pytest.importorskip("mypy.api") From 1175c577489ad4f05e17307d6bcebf19aadb0a14 Mon Sep 17 00:00:00 2001 From: Tihomir Mateev Date: Tue, 29 Sep 2026 18:56:59 +0300 Subject: [PATCH 11/11] Adds a section to the README.md about sessions --- README.md | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/README.md b/README.md index 4d32a0a..933841b 100644 --- a/README.md +++ b/README.md @@ -90,6 +90,31 @@ Both limits count per client IP by default; a request must satisfy both, and the See the [Rate Limiting Guide](docs/guide/rate-limiting.md) for identifiers, the global limiter, custom responses, IETF headers, and the imperative backend. +## Sessions + +Keep session data in Redis. The cookie holds only an opaque session ID: + +```python +from fastapi import Depends, FastAPI +from redis_fastapi import FastAPIRedis, SessionDep, valid_session + +app = FastAPI() +FastAPIRedis(app).lifespan().sessions() + +@app.post("/login") +async def login(session: SessionDep): + session["user_id"] = 42 # sign-in rotates the session ID + return {"ok": True} + +@app.get("/me", dependencies=[Depends(valid_session())]) +async def me(session: SessionDep): + return {"user_id": session.get("user_id")} +``` + +Redis enforces an idle timeout and an absolute timeout. `valid_session()` returns `401` when a request has no live session, and `request.session` works as it does with Starlette's `SessionMiddleware`. + +See the [Sessions Guide](docs/guide/sessions.md) for timeouts, sign-out everywhere, CSRF, encryption and migration. + ## Configuration All settings are read from environment variables (prefixed `REDIS_`) or a `.env` file. Set `REDIS_URL` for the simplest setup: