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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,14 @@ Note that 0.3.3 was never released; 0.3.4 follows 0.3.2.
answered `401` to new visitors; it now uses the helper, and the migration
section warns about the difference from Starlette.
([#225](https://github.com/allen0099/FastAPI-CacheX/issues/225))
- **OAuth states can be bound to the browser that started the flow.**
`create_state(binding=...)` stores the SHA-256 of a client secret, such as a
nonce also set as a cookie, and `consume_state(state, binding=...)` rejects
the state with `InvalidStateError` unless the same value is given. Without a
binding any stored state completes the flow in any browser, which allowed
login CSRF although STATE.md described the states as CSRF protection. The
quick start now sets and checks a binding cookie.
([#226](https://github.com/allen0099/FastAPI-CacheX/issues/226))

## [0.3.7] - 2026-09-25

Expand Down
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ The library has four independent subsystems:
**4. State Management (`fastapi_cachex/state/`)**
- `StateManager` provides one-time-use state tokens for OAuth flows. States are consumed (deleted) on first successful `consume_state()` call.
- Uses the same cache backends, with key prefix `oauth_state:` by default.
- `create_state(binding=...)` / `consume_state(state, binding=...)` bind a state to the client that started the flow (SHA-256 stored, `hmac.compare_digest`); a mismatch in either direction raises `InvalidStateError`, and the state is consumed either way.

### Backends (`fastapi_cachex/backends/`)

Expand Down
60 changes: 47 additions & 13 deletions docs/STATE.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,16 @@
# State Management Extension

`fastapi_cachex.state` provides **one-time state tokens** that protect OAuth / OIDC
authorization flows against CSRF. Before starting the authorization, generate a random
state and store it in the cache backend. When the callback comes back, **consume** it.
A consumed state cannot be used a second time.
`fastapi_cachex.state` provides **one-time state tokens** for OAuth / OIDC authorization
flows. Before starting the authorization, generate a random state and store it in the cache
backend. When the callback comes back, **consume** it. A consumed state cannot be used a
second time.

A state protects the flow against CSRF (RFC 6749 §10.12) only when it is **bound to the
browser that started the flow**. Storage alone is not enough: an attacker can start a flow
in their own browser and send the victim to the callback with the attacker's state and
code, which logs the victim in to the attacker's account. Pass a `binding` (a random nonce
you also set as a cookie) when creating the state and the same value when consuming it, as
in the quick start below.

States live on the same backend as the HTTP cache but under their own key prefix
(`oauth_state:` by default), so namespaced operations such as `CacheManager.clear_prefix()`
Expand All @@ -15,7 +22,9 @@ Everything in this guide can also be imported from the top-level `fastapi_cachex
## Quick start

```python
from fastapi import FastAPI, HTTPException
import secrets

from fastapi import FastAPI, HTTPException, Request
from fastapi.responses import RedirectResponse

from fastapi_cachex import BackendProxy
Expand All @@ -25,26 +34,44 @@ from fastapi_cachex.state import StateError, StateManagerDep
app = FastAPI()
BackendProxy.set(MemoryBackend())

BINDING_COOKIE = "oauth_binding"


@app.get("/login")
async def login(states: StateManagerDep):
state = await states.create_state(metadata={"next": "/dashboard"})
return RedirectResponse(
nonce = secrets.token_urlsafe(32)
state = await states.create_state(binding=nonce, metadata={"next": "/dashboard"})
response = RedirectResponse(
f"https://provider.example.com/authorize?state={state}&client_id=..."
)
# Lax, not Strict: the callback is a cross-site navigation from the provider.
response.set_cookie(
BINDING_COOKIE, nonce, max_age=600, httponly=True, secure=True, samesite="lax"
)
return response


@app.get("/callback")
async def callback(state: str, code: str, states: StateManagerDep):
async def callback(request: Request, state: str, code: str, states: StateManagerDep):
try:
data = await states.consume_state(state) # one-time: deleted on retrieval
except StateError as e: # unknown, expired or malformed state
# One-time: deleted on retrieval. Rejected unless this browser started the flow.
data = await states.consume_state(
state, binding=request.cookies.get(BINDING_COOKIE)
)
except StateError as e: # unknown, expired, malformed or issued to another browser
raise HTTPException(status_code=400, detail="Invalid state") from e

# Exchange the code for tokens, create a session ...
return {"next": data.metadata.get("next", "/")}
response = RedirectResponse(data.metadata.get("next", "/"))
response.delete_cookie(BINDING_COOKIE)
return response
```

If the provider posts the callback (`response_mode=form_post`), a `SameSite=Lax` cookie is
not sent with that cross-site POST; use `samesite="none"` (which requires `secure=True`)
for the binding cookie. Starting a second login in another tab overwrites the cookie, so
the first tab's callback is then rejected; the user simply logs in again.

## StateManager

```python
Expand All @@ -61,15 +88,20 @@ With `backend=None` the backend is resolved **when the `StateManager` is constru
not on each call. If `BackendProxy.set(...)` has not been called yet, the constructor
raises `BackendNotFoundError`. Configure the backend first.

### `create_state(ttl=None, metadata=None) -> str`
### `create_state(ttl=None, metadata=None, *, binding=None) -> str`

Generates a state string with `secrets.token_urlsafe(32)` (256 bits of entropy), stores it
in the backend and returns it. `metadata` is an arbitrary JSON-serializable dict stored
alongside the state (for example, the path to redirect to after authorization). When `ttl`
is omitted, `default_ttl` is used. The same TTL is applied both as the backend TTL and as
the state's `expires_at`.

### `consume_state(state) -> StateData`
`binding` ties the state to the client that starts the flow: a random nonce you also set
as a cookie, or any other secret only that client presents on the callback. Only its
SHA-256 is stored. An empty string raises `ValueError`, since a missing cookie read as `""`
would bind every such client to the same value.

### `consume_state(state, *, binding=None) -> StateData`

**One-time consumption.** The entry is retrieved and removed with the backend's atomic
`get_and_delete()`, so when several concurrent calls present the same state **only one**
Expand All @@ -78,6 +110,7 @@ gets it. A replayed callback cannot pass a second time.
| Situation | Behavior |
|------|------|
| Missing, already consumed, or already evicted by the backend TTL | `InvalidStateError` |
| Created with a binding and consumed with a different one or none, or created without a binding and consumed with one | `InvalidStateError` (the entry has been deleted too) |
| Retrieved but past its `expires_at` | `StateExpiredError` (the entry has been deleted too, nothing is left behind) |
| Retrieved but the content is not valid `StateData` JSON | `StateDataError` (the entry has been deleted too) |
| Otherwise | Returns `StateData` |
Expand Down Expand Up @@ -116,6 +149,7 @@ class StateData(BaseModel):
created_at: datetime # creation time (UTC)
expires_at: datetime # expiry time (UTC)
metadata: dict[str, Any] # metadata attached at creation
binding_hash: str | None # SHA-256 of the binding, None for an unbound state
```

`expires_at` is a logical expiry stored inside the data, independent of the backend TTL.
Expand Down
50 changes: 47 additions & 3 deletions fastapi_cachex/state/manager.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""State manager for OAuth and session state handling."""

import hashlib
import hmac
import json
import logging
import secrets
Expand Down Expand Up @@ -38,6 +39,21 @@ def _state_ref(state: str) -> str:
return hashlib.sha256(state.encode("utf-8", "surrogatepass")).hexdigest()[:12]


def _binding_hash(binding: str) -> str:
return hashlib.sha256(binding.encode("utf-8", "surrogatepass")).hexdigest()


def _binding_matches(stored: str | None, binding: str | None) -> bool:
"""Whether ``binding`` is the one a state was created with.

An unbound state matches only an absent binding and a bound one only its own,
so neither side can drop the check by leaving its binding out.
"""
if stored is None or binding is None:
return stored is None and binding is None
return hmac.compare_digest(stored, _binding_hash(binding))


def _log_decode_failure(state: str) -> None:
# No traceback: pydantic validation errors echo the stored input, which
# includes the state itself.
Expand Down Expand Up @@ -131,18 +147,25 @@ async def create_state(
self,
ttl: int | None = None,
metadata: dict[str, Any] | None = None,
*,
binding: str | None = None,
) -> str:
"""Create a new random OAuth state and store it with metadata.

Args:
ttl: Time-to-live in seconds (uses default_ttl if not provided)
metadata: Additional metadata to store with the state (e.g., callback_url, user_info)
binding: A secret tied to the client that starts the flow, such as a
random nonce also set as a cookie. ``consume_state()`` then
accepts the state only with the same binding, so a state issued
to one browser cannot complete the flow in another (login
CSRF). Only its SHA-256 is stored.

Returns:
The generated state string

Raises:
ValueError: If ``ttl`` is zero or negative.
ValueError: If ``ttl`` is zero or negative, or ``binding`` is empty.

Backend errors (for example a Redis connection error) propagate
unchanged; they are not wrapped in ``StateDataError``.
Expand All @@ -153,12 +176,18 @@ async def create_state(
# Use provided TTL or default
effective_ttl = ttl if ttl is not None else self.default_ttl
validate_ttl(effective_ttl)
if binding == "":
# A missing cookie read as "" would otherwise bind every such
# client to the same value.
msg = "binding must not be empty"
raise ValueError(msg)

# Create state data model
state_data = StateData(
state=state,
expires_at=datetime.now(timezone.utc) + timedelta(seconds=effective_ttl),
metadata=metadata or {},
binding_hash=None if binding is None else _binding_hash(binding),
)

content = state_data.model_dump_json().encode("utf-8")
Expand All @@ -172,17 +201,24 @@ async def create_state(
)
return state

async def consume_state(self, state: str) -> StateData:
async def consume_state(
self, state: str, *, binding: str | None = None
) -> StateData:
"""Consume and validate an OAuth state, removing it from storage.

Args:
state: The state string to validate and consume
binding: The binding the state was created with, if any. A state
created with a binding is accepted only with that binding, and
one created without is rejected when a binding is given. The
state is consumed either way.

Returns:
StateData object containing state data and metadata

Raises:
InvalidStateError: If state is invalid or not found
InvalidStateError: If state is invalid or not found, or the binding
does not match
StateExpiredError: If state has expired
StateDataError: If state data format is invalid
"""
Expand All @@ -209,6 +245,14 @@ async def consume_state(self, state: str) -> StateData:
msg = "State has expired"
raise StateExpiredError(msg)

if not _binding_matches(state_data.binding_hash, binding):
logger.info(
"OAuth state presented with a different binding; state_ref=%s",
_state_ref(state),
)
msg = "State was issued to a different client"
raise InvalidStateError(msg)

logger.debug(
"OAuth state consumed and deleted; state_ref=%s", _state_ref(state)
)
Expand Down
7 changes: 7 additions & 0 deletions fastapi_cachex/state/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,13 @@ class StateData(BaseModel):
metadata: dict[str, Any] = Field(
default_factory=dict, description="Additional metadata associated with state"
)
binding_hash: str | None = Field(
default=None,
description=(
"SHA-256 of the binding the state was created with, or None for an "
"unbound state"
),
)

@field_serializer("created_at", "expires_at", when_used="json")
def serialize_datetime(self, value: datetime) -> str:
Expand Down
43 changes: 33 additions & 10 deletions i18n/zh-TW/docs/STATE.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
# State 管理擴充 {#state-management-extension}

`fastapi_cachex.state` 提供**一次性 state 權杖**,保護 OAuth / OIDC 授權流程免於 CSRF 攻擊。開始授權之前,先產生一個隨機 state 並存入快取後端;回呼(callback)回來時,再將它**消耗**掉。已消耗的 state 無法再使用第二次。
`fastapi_cachex.state` 為 OAuth / OIDC 授權流程提供**一次性 state 權杖**。開始授權之前,先產生一個隨機 state 並存入快取後端;回呼(callback)回來時,再將它**消耗**掉。已消耗的 state 無法再使用第二次。

只有當 state **綁定到發起流程的瀏覽器**時,它才能保護流程免於 CSRF 攻擊(RFC 6749 §10.12)。光是儲存並不夠:攻擊者可以在自己的瀏覽器中發起流程,再把受害者導向帶有攻擊者 state 與 code 的回呼,使受害者登入攻擊者的帳號。請在建立 state 時傳入 `binding`(一個同時設為 Cookie 的隨機 nonce),並在消耗時傳入相同的值,如下方快速開始所示。

State 與 HTTP 快取存放在同一個後端,但使用自己的鍵前綴(預設為 `oauth_state:`),因此像 `CacheManager.clear_prefix()` 這類依命名空間的操作不會動到它們。不過後端層級的 `clear()`(例如 `BackendProxy.get().clear()`)會移除它們,因為它會清除後端命名空間底下的所有內容。

Expand All @@ -9,7 +11,9 @@ State 與 HTTP 快取存放在同一個後端,但使用自己的鍵前綴(
## 快速開始 {#quick-start}

```python
from fastapi import FastAPI, HTTPException
import secrets

from fastapi import FastAPI, HTTPException, Request
from fastapi.responses import RedirectResponse

from fastapi_cachex import BackendProxy
Expand All @@ -19,26 +23,41 @@ from fastapi_cachex.state import StateError, StateManagerDep
app = FastAPI()
BackendProxy.set(MemoryBackend())

BINDING_COOKIE = "oauth_binding"


@app.get("/login")
async def login(states: StateManagerDep):
state = await states.create_state(metadata={"next": "/dashboard"})
return RedirectResponse(
nonce = secrets.token_urlsafe(32)
state = await states.create_state(binding=nonce, metadata={"next": "/dashboard"})
response = RedirectResponse(
f"https://provider.example.com/authorize?state={state}&client_id=..."
)
# 使用 Lax 而非 Strict:回呼是從提供者發起的跨站導覽。
response.set_cookie(
BINDING_COOKIE, nonce, max_age=600, httponly=True, secure=True, samesite="lax"
)
return response


@app.get("/callback")
async def callback(state: str, code: str, states: StateManagerDep):
async def callback(request: Request, state: str, code: str, states: StateManagerDep):
try:
data = await states.consume_state(state) # 一次性:取出時即刪除
except StateError as e: # 未知、已過期或格式錯誤的 state
# 一次性:取出時即刪除。除非是這個瀏覽器發起的流程,否則會被拒絕。
data = await states.consume_state(
state, binding=request.cookies.get(BINDING_COOKIE)
)
except StateError as e: # 未知、已過期、格式錯誤或發給其他瀏覽器的 state
raise HTTPException(status_code=400, detail="Invalid state") from e

# 以 code 換取權杖、建立 Session……
return {"next": data.metadata.get("next", "/")}
response = RedirectResponse(data.metadata.get("next", "/"))
response.delete_cookie(BINDING_COOKIE)
return response
```

若提供者以 POST 送出回呼(`response_mode=form_post`),`SameSite=Lax` 的 Cookie 不會隨這個跨站 POST 送出;此時綁定用的 Cookie 請改用 `samesite="none"`(需要 `secure=True`)。在另一個分頁再次開始登入會覆寫這個 Cookie,因此第一個分頁的回呼會被拒絕;使用者只要重新登入即可。

## StateManager {#statemanager}

```python
Expand All @@ -53,17 +72,20 @@ states = StateManager(

當 `backend=None` 時,後端是在**建構 `StateManager` 時**解析,而不是每次呼叫時才解析。若尚未呼叫 `BackendProxy.set(...)`,建構子會拋出 `BackendNotFoundError`。請先設定後端。

### `create_state(ttl=None, metadata=None) -> str` {#create_statettlnone-metadatanone-str}
### `create_state(ttl=None, metadata=None, *, binding=None) -> str` {#create_statettlnone-metadatanone-bindingnone-str}

以 `secrets.token_urlsafe(32)`(256 位元熵)產生 state 字串,存入後端並回傳。`metadata` 是與 state 一併儲存的任意可 JSON 序列化 dict(例如授權完成後要重新導向的路徑)。省略 `ttl` 時使用 `default_ttl`。同一個 TTL 會同時作為後端 TTL 與 state 的 `expires_at`。

### `consume_state(state) -> StateData` {#consume_statestate-statedata}
`binding` 將 state 綁定到發起流程的用戶端:一個同時設為 Cookie 的隨機 nonce,或任何只有該用戶端會在回呼時提交的秘密值。後端只會儲存它的 SHA-256。空字串會拋出 `ValueError`,因為把缺少的 Cookie 讀成 `""` 時,所有這類用戶端都會綁定到同一個值。

### `consume_state(state, *, binding=None) -> StateData` {#consume_statestate-bindingnone-statedata}

**一次性消耗。** 項目透過後端的原子操作 `get_and_delete()` 取出並移除,因此當多個並行呼叫提交同一個 state 時,**只有一個**會取得它。重送的回呼無法通過第二次。

| 情況 | 行為 |
|------|------|
| 不存在、已被消耗,或已因後端 TTL 而被淘汰 | `InvalidStateError` |
| 建立時有綁定、消耗時綁定不同或未提供;或建立時沒有綁定、消耗時卻提供了綁定 | `InvalidStateError`(項目也已被刪除) |
| 已取出但超過其 `expires_at` | `StateExpiredError`(項目也已被刪除,不會殘留) |
| 已取出但內容不是有效的 `StateData` JSON | `StateDataError`(項目也已被刪除) |
| 其他情況 | 回傳 `StateData` |
Expand Down Expand Up @@ -93,6 +115,7 @@ class StateData(BaseModel):
created_at: datetime # 建立時間(UTC)
expires_at: datetime # 過期時間(UTC)
metadata: dict[str, Any] # 建立時附加的 metadata
binding_hash: str | None # binding 的 SHA-256;未綁定的 state 為 None
```

`expires_at` 是儲存在資料內的邏輯過期時間,與後端 TTL 無關。後端 TTL 到期時,項目就會消失;`expires_at` 則確保後端仍保留、但在邏輯上已過期的項目同樣會被拒絕。
Expand Down
Loading
Loading