Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/JWT_CLAIMS.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ A valid JWT signature is necessary but not sufficient: after decoding, `SessionM
- Reduces the impact of a leaked JWT

3. **Flexible session management**
- Supports sliding expiration: when a session is renewed, a new token with an updated `exp` is returned in the response header named by `header_name` (`X-Session-Token` by default)
- Supports sliding expiration: when a session is renewed, the middleware sends a new token with an updated `exp` through the transport the request used: the response header named by `header_name` (`X-Session-Token` by default), or `Set-Cookie` for a cookie
- Supports updating session data in real time
- Supports features such as flash messages

Expand Down
30 changes: 16 additions & 14 deletions docs/SESSION.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@ the cache backend; the client only holds a single signed token.

| Middleware | Token source | Response side | Status |
|------------|--------------|---------------|--------|
| `FastAPICacheXSessionMiddleware` | Custom header (default `X-Session-Token`) / `Authorization: Bearer` / **cookie** (default name `session`) | Routed by source: a token that arrived in a header is returned in the response header; a token that arrived in a cookie (or a brand-new session) gets `Set-Cookie` | **Recommended** |
| `SessionMiddleware` | Custom header / `Authorization: Bearer`; **no cookie support** | A renewed token is sent back in the response header | Deprecated, **removed in 0.4.0** |
| `FastAPICacheXSessionMiddleware` | Custom header (default `X-Session-Token`) / `Authorization: Bearer` / **cookie** (default name `session`) | Routed by source: a request that sent a header or bearer token (even one that no longer resolves) gets its token in the response header; otherwise (a cookie, or no token at all) it gets `Set-Cookie` | **Recommended** |
| `SessionMiddleware` | Custom header / `Authorization: Bearer`; **no cookie support** | A renewed token, or one for a regenerated ID, is sent back in the response header | Deprecated, **removed in 0.4.0** |

**Use `FastAPICacheXSessionMiddleware` for all new projects.** It covers every transport of
`SessionMiddleware` (it reads `X-Session-Token` and `Authorization: Bearer` in the same way) and
Expand Down Expand Up @@ -150,9 +150,10 @@ constructed) and will be removed in 0.4.0. Use `FastAPICacheXSessionMiddleware`
second": it reads the custom header (default `X-Session-Token`) and/or `Authorization: Bearer`
first and only falls back to the cookie when neither is present, so clients that used
`X-Session-Token` with `SessionMiddleware` keep working unchanged. The response side is routed
by source too: for a token that arrived in a header, a renewed token is sent back in the same
response header and no `Set-Cookie` is emitted; a token that arrived in a cookie (or a brand-new
anonymous session) uses `Set-Cookie`.
by source too: when the request sent a header or bearer token (even one that no longer
resolves), a new or renewed token is sent back in the `header_name` response header and no
`Set-Cookie` is emitted; a token that arrived in a cookie (or a brand-new anonymous session for a
request without a token) uses `Set-Cookie`.

Both middlewares put the loaded `Session` object into `request.state`, so the existing session
dependencies `get_session`, `get_optional_session`, `require_session` and `require_user_session`
Expand Down Expand Up @@ -212,7 +213,7 @@ async def me(session=Depends(require_user_session)):
[Authenticated endpoints](HTTP_CACHING.md#authenticated-endpoints).

The cookie is always `HttpOnly`; `Secure`, `SameSite`, `Domain`, `Path` and `Max-Age` follow the
`cookie_*` settings.
`cookie_*` settings (`cookie_max_age=None`, or `0`, omits `Max-Age`).

## Configuration

Expand Down Expand Up @@ -263,7 +264,7 @@ SessionConfig(

0.4.0 names the session cookie `__Host-session` and sets the `Secure` flag by default. Browsers accept a `__Host-` cookie only when it is `Secure`, has `Path=/` and no `Domain`, and never from a subdomain, which removes the usual way to plant a session cookie (session fixation). The new name also means every browser holding a `session` cookie is logged out once after the upgrade.

Until then, `FastAPICacheXSessionMiddleware` emits a `FutureWarning` when its config leaves `cookie_name` or `cookie_https_only` at the default. Set both to silence it:
Until then, `FastAPICacheXSessionMiddleware` emits a `FutureWarning` when it is constructed (when the app builds its middleware stack, at startup or on the first request) and its config leaves `cookie_name` or `cookie_https_only` at the default. Set both to silence it:

- `cookie_name="session", cookie_https_only=False` keeps the current cookie (and keeps working after the upgrade, e.g. for local development over plain HTTP);
- `cookie_name="__Host-session", cookie_https_only=True` switches now, over HTTPS.
Expand Down Expand Up @@ -327,8 +328,8 @@ only supports `HS256`, `HS384` and `HS512`: with an asymmetric algorithm, `Sessi
An HMAC key must be at least as long as the hash output (RFC 7518 §3.2): 32 bytes for `HS256`,
48 for `HS384` and 64 for `HS512`, counted after UTF-8 encoding. `secret_key` only has to be 32
characters, so with `HS384` or `HS512` a shorter key makes the serializer emit a `UserWarning`
once when it is built (PyJWT itself also warns with `InsecureKeyLengthWarning` whenever it signs
or verifies a token). Use a longer key, for example `secrets.token_urlsafe(64)`.
once when it is built (PyJWT 2.11 and later also warn with `InsecureKeyLengthWarning` whenever they
sign or verify a token). Use a longer key, for example `secrets.token_urlsafe(64)`.

Security notes:

Expand Down Expand Up @@ -470,9 +471,9 @@ from fastapi import Request
from fastapi_cachex.session import SessionUser, login


# LoginRequest is the body model from Basic Usage
# Credentials is the body model from Basic Usage
@app.post("/login")
async def log_in(credentials: LoginRequest, request: Request):
async def log_in(credentials: Credentials, request: Request):
... # verify credentials.password
await login(request, SessionUser(user_id=credentials.username))
return {"ok": True}
Expand Down Expand Up @@ -561,7 +562,8 @@ lifecycle: `create_session()` / `create_anonymous_session()` return
`(session, token)`; `get_session()` returns `(session, renewed_token)`, where
`renewed_token` is set only when sliding expiration renewed the token and should
be sent back to the client. `get_session()` raises a `SessionError` subclass on
failure: `SessionTokenError` (malformed token), `SessionSecurityError` (bad
failure: `SessionTokenError` (malformed token; for a JWT also a bad signature,
an expired `exp` or a wrong `iss`/`aud`), `SessionSecurityError` (bad `simple`
signature or binding mismatch), `SessionNotFoundError`, `SessionInvalidError`
(session not active) or `SessionExpiredError` (TTL or absolute timeout exceeded).
Since 0.3.8, `SessionError` derives from `CacheXError`, so `except CacheXError`
Expand Down Expand Up @@ -614,9 +616,9 @@ from fastapi_cachex.session import SessionUser
from fastapi_cachex.session.dependencies import SessionManagerDep


# LoginRequest is the body model from Basic Usage
# Credentials is the body model from Basic Usage
@app.post("/login")
async def login(credentials: LoginRequest, manager: SessionManagerDep):
async def login(credentials: Credentials, manager: SessionManagerDep):
... # verify credentials.password
user = SessionUser(user_id=credentials.username)
session, token = await manager.create_session(user=user)
Expand Down
5 changes: 3 additions & 2 deletions fastapi_cachex/session/dependencies.py
Original file line number Diff line number Diff line change
Expand Up @@ -148,8 +148,9 @@ async def login(

Warns:
FutureWarning: Once per app, if the manager the middleware registered
is not the one set with ``SessionManagerProxy.set()``. 0.4.0
resolves this dependency through ``SessionManagerProxy`` only.
is not the one set with ``SessionManagerProxy.set()`` (or none is
set there). 0.4.0 resolves this dependency through
``SessionManagerProxy`` only.
"""
state = request.app.state
manager: SessionManager | None = getattr(
Expand Down
26 changes: 22 additions & 4 deletions fastapi_cachex/session/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,12 @@ def __init__(
config: Session configuration
token_serializer: Optional custom token serializer. If provided,
overrides the built-in selection (simple/jwt).

Raises:
ValueError: If ``config.token_format`` is ``"jwt"`` with an
asymmetric ``jwt_algorithm`` and no ``token_serializer``.
ImportError: If ``config.token_format`` is ``"jwt"``, no
``token_serializer`` is given and PyJWT is not installed.
"""
self.backend = backend
self.config = config
Expand Down Expand Up @@ -123,7 +129,16 @@ async def create_anonymous_session(
user_agent: str | None = None,
**extra_data: object,
) -> tuple[Session, str]:
"""Create a new session without user information."""
"""Create a new session without user information.

Args:
ip_address: Client IP address (if IP binding enabled)
user_agent: Client User-Agent (if UA binding enabled)
**extra_data: Additional session data

Returns:
Tuple of (Session, token_string)
"""
return await self._create_session(
user=None,
ip_address=ip_address,
Expand Down Expand Up @@ -209,11 +224,14 @@ async def get_session(
propagate it to the client (e.g. via a response header).

Raises:
SessionTokenError: If token is invalid
SessionTokenError: If the token cannot be parsed (for a JWT, also
a bad signature, an expired ``exp`` or a wrong ``iss``/``aud``)
SessionNotFoundError: If session not found
SessionExpiredError: If session has expired
SessionExpiredError: If the session is past its ``expires_at`` or
``absolute_timeout``; it is saved as ``EXPIRED`` first
SessionInvalidError: If session is not active
SessionSecurityError: If security checks fail
SessionSecurityError: If a ``simple`` token's signature is wrong,
or an IP / User-Agent binding does not match
"""
# Parse and verify token
try:
Expand Down
35 changes: 28 additions & 7 deletions fastapi_cachex/session/middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -231,8 +231,17 @@ def __init__(

Args:
app: ASGI application
session_manager: Session manager instance
config: Session configuration
session_manager: Session manager instance; defaults to the one
set in ``SessionManagerProxy``
config: Session configuration; defaults to
``session_manager.config``

Raises:
ProxyNotSetError: If ``session_manager`` is omitted and
``SessionManagerProxy`` holds none.

Warns:
DeprecationWarning: Always; use ``FastAPICacheXSessionMiddleware``.
"""
warnings.warn(
"SessionMiddleware is deprecated, use FastAPICacheXSessionMiddleware. "
Expand Down Expand Up @@ -365,7 +374,10 @@ class FastAPICacheXSessionMiddleware:
session payload is persisted via the configured ``SessionManager``/cache
backend instead of being encoded into the cookie itself. Only a signed
session token is stored client-side, in the cookie named by
``SessionConfig.cookie_name``.
``SessionConfig.cookie_name``. A token in the custom header or an
``Authorization: Bearer`` header is read before the cookie, and a request
that sent one (even one that no longer resolves) gets its token back in
the ``header_name`` response header instead of ``Set-Cookie``.
"""

def __init__(
Expand All @@ -378,8 +390,14 @@ def __init__(

Args:
app: ASGI application
session_manager: Session manager instance
config: Session configuration
session_manager: Session manager instance; defaults to the one
set in ``SessionManagerProxy``
config: Session configuration; defaults to
``session_manager.config``

Raises:
ProxyNotSetError: If ``session_manager`` is omitted and
``SessionManagerProxy`` holds none.

Warns:
FutureWarning: If the configuration leaves ``cookie_name`` or
Expand Down Expand Up @@ -612,12 +630,15 @@ async def _write_session(
loaded_token: str | None,
renewed_token: str | None,
) -> tuple[str, str | None]:
"""Create-or-update the backend session for a modified, non-empty dict.
"""Create-or-update the backend session for a modified dict.

The dict is non-empty, or was emptied on a session that has a user.

Args:
session: The Starlette session dict for this request
connection: Incoming HTTP connection (for IP / User-Agent binding)
backend_session: Loaded backend session, or None for a new session
backend_session: The session the dict belongs to (loaded, or
started by ``login()``), or None to create an anonymous one
loaded_token: Token for the loaded session (None when creating anew)
renewed_token: Sliding-expiration renewed token, if any

Expand Down
26 changes: 21 additions & 5 deletions fastapi_cachex/session/token_serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,11 @@ def to_string(self, token: SessionToken) -> str: # pragma: no cover - Protocol
def from_string(
self, token_str: str
) -> SessionToken: # pragma: no cover - Protocol body
"""Parse a string into a `SessionToken` (with necessary verification)."""
"""Parse a string into a `SessionToken` (with necessary verification).

Raise ``ValueError`` for an invalid token; ``SessionManager`` turns it
into ``SessionTokenError``.
"""


class SimpleTokenSerializer:
Expand Down Expand Up @@ -99,9 +103,9 @@ def _warn_if_key_too_short(secret: str, algorithm: str) -> None:
"""Warn once when ``secret`` is shorter than ``algorithm``'s hash output.

``SessionConfig`` only requires 32 characters, enough for HS256 but not
for HS384 (48 bytes) or HS512 (64 bytes). PyJWT warns about a short key on
every token it signs or verifies; this names the setting to fix, once,
when the serializer is built.
for HS384 (48 bytes) or HS512 (64 bytes). PyJWT 2.11 and later warn about a
short key on every token they sign or verify; this names the setting to
fix, once, when the serializer is built.
"""
min_bytes = _HMAC_MIN_KEY_BYTES[algorithm]
key_bytes = len(secret.encode("utf-8"))
Expand All @@ -123,7 +127,9 @@ class JWTTokenSerializer:
Encodes the session reference into a signed JWT with claims:
- sid: session id (custom claim)
- iat: issued at (epoch seconds)
- exp: expiry (epoch seconds), derived from config.session_ttl
- exp: expiry (epoch seconds), the session's ``expires_at`` (so it follows
sliding renewal and the ``absolute_timeout`` cap), or iat +
config.session_ttl when the session has none
Optionally:
- iss: issuer (if configured)
- aud: audience (if configured)
Expand All @@ -143,6 +149,12 @@ def __init__(self, config: SessionConfig, jwt_module: Any | None = None) -> None
which only the HMAC algorithms can use; an asymmetric
algorithm needs a custom ``token_serializer`` that holds the
key pair.
ImportError: If no ``jwt_module`` is given and PyJWT (the ``jwt``
extra) is not installed.

Warns:
UserWarning: If ``secret_key`` is shorter in UTF-8 bytes than the
HMAC hash output (48 for HS384, 64 for HS512).
"""
if config.jwt_algorithm not in JWT_HMAC_ALGORITHMS:
supported = ", ".join(sorted(JWT_HMAC_ALGORITHMS))
Expand Down Expand Up @@ -205,6 +217,10 @@ def from_string(self, token_str: str) -> SessionToken:
"""Decode and verify a JWT string into a `SessionToken`.

Verifies signature, `exp`, and `iat`, and optional `iss`/`aud`.

Raises:
ValueError: If the token fails decoding or verification, or its
payload is malformed.
"""
options = {
"require": ["sid", "iat", "exp"],
Expand Down
2 changes: 1 addition & 1 deletion i18n/zh-TW/docs/JWT_CLAIMS.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ FastAPI-CacheX 採用**有狀態 Session** 模型,與純粹無狀態的 JWT
- 降低 JWT 外洩的影響

3. **彈性的 Session 管理**
- 支援滑動過期:Session 續期時,帶有更新後 `exp` 的新權杖會放在由 `header_name` 指定的回應標頭中回傳(預設為 `X-Session-Token`)
- 支援滑動過期:Session 續期時,中介軟體會透過請求使用的傳輸方式送出帶有更新後 `exp` 的新權杖:由 `header_name` 指定的回應標頭(預設為 `X-Session-Token`),或對 Cookie 使用 `Set-Cookie`
- 支援即時更新 Session 資料
- 支援 flash 訊息等功能

Expand Down
Loading
Loading