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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,16 @@ Note that 0.3.3 was never released; 0.3.4 follows 0.3.2.
still differs from Redis.
([#179](https://github.com/allen0099/FastAPI-CacheX/issues/179))

- **Sessions no longer outlive `absolute_timeout`.** A sliding renewal, or a
`session_ttl` longer than `absolute_timeout`, set `expires_at`, and with it
the backend TTL and the JWT `exp`, past `created_at + absolute_timeout`. The
expiry is now capped there, and a session whose expiry already sits at the
cap is not renewed again, so it does not get a new token on every request.
Because the backend now drops the record at the cap, a token presented
after it raises `SessionNotFoundError`, as after an ordinary `session_ttl`
expiry, instead of `SessionExpiredError`.
([#164](https://github.com/allen0099/FastAPI-CacheX/issues/164))

## [0.3.7] - 2026-09-25

### Added
Expand Down
2 changes: 1 addition & 1 deletion docs/JWT_CLAIMS.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ The implementation lives in [`fastapi_cachex/session/token_serializers.py`](http
|-------|------|----------|----------|-------------|
| `sid` | Session ID | ✅ | ✅ | Custom claim that maps to the server-side session |
| `iat` | Issued At | ✅ | ✅ | Time the token was issued (RFC 7519); rejected if it lies in the future (beyond `jwt_leeway`) |
| `exp` | Expiration | ✅ | ✅ | Token expiry: the session's `expires_at` (so it follows sliding expiration), falling back to `iat + session_ttl` |
| `exp` | Expiration | ✅ | ✅ | Token expiry: the session's `expires_at` (so it follows sliding expiration and never passes `absolute_timeout`), falling back to `iat + session_ttl` |
| `iss` | Issuer | ⚠️ | ✅ | Token issuer (optional; only issued and verified when `jwt_issuer` is set) |
| `aud` | Audience | ⚠️ | ✅ | Intended audience (optional; only issued and verified when `jwt_audience` is set) |

Expand Down
4 changes: 3 additions & 1 deletion docs/SESSION.md
Original file line number Diff line number Diff line change
Expand Up @@ -421,7 +421,9 @@ less than `session_ttl * sliding_threshold` seconds remaining extends the expiry
`session_ttl` again and issues a renewed token, which the middleware sends back to the client
(response header or `Set-Cookie`, see the table above). Header/bearer clients should replace their
stored token when the response carries the `header_name` header. `absolute_timeout` ends the
session that many seconds after it was created, regardless of sliding renewals.
session that many seconds after it was created, regardless of sliding renewals: the expiry,
the backend TTL and a JWT's `exp` never go past `created_at + absolute_timeout`, and once
the expiry reaches that cap no further renewed tokens are issued.

#### `token_source_priority` accepts only `"header"` and `"bearer"`

Expand Down
21 changes: 18 additions & 3 deletions fastapi_cachex/session/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,18 @@ def __init__(
config.backend_key_prefix,
)

def _expiry_for(self, session: Session) -> datetime:
"""Return now + session_ttl, capped at the session's absolute_timeout.

Without the cap, the backend TTL and a JWT's exp would outlive the
session, which get_session rejects once absolute_timeout passes.
"""
expires_at = _now() + timedelta(seconds=self.config.session_ttl)
if self.config.absolute_timeout is not None:
cap = session.created_at + timedelta(seconds=self.config.absolute_timeout)
expires_at = min(expires_at, cap)
return expires_at

def _get_backend_key(self, session_id: str) -> str:
"""Get backend storage key for a session.

Expand Down Expand Up @@ -130,7 +142,7 @@ async def _create_session(

# Set expiry
if self.config.session_ttl:
session.expires_at = _now() + timedelta(seconds=self.config.session_ttl)
session.expires_at = self._expiry_for(session)

# Bind IP and User-Agent if configured
if self.config.ip_binding:
Expand Down Expand Up @@ -273,8 +285,11 @@ async def get_session(
time_remaining = (session.expires_at - _now()).total_seconds()
threshold = self.config.session_ttl * self.config.sliding_threshold

if time_remaining < threshold:
session.renew(self.config.session_ttl)
expires_at = self._expiry_for(session)
# Once expires_at sits at the absolute_timeout cap, renewing would
# only re-issue a token with the same expiry on every request.
if time_remaining < threshold and expires_at > session.expires_at:
session.expires_at = expires_at
renewed_token = self.issue_token(session)
logger.debug(
"Session renewed (sliding expiration); id=%s ttl=%s",
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 @@ -20,7 +20,7 @@ FastAPI-CacheX 的 JWT 權杖序列化器只實作了最小的一組 JWT claim
|-------|------|------|------|------|
| `sid` | Session ID | ✅ | ✅ | 自訂 claim,對應到伺服器端的 Session |
| `iat` | Issued At | ✅ | ✅ | 權杖的發行時間(RFC 7519);若位於未來(超出 `jwt_leeway`)則拒絕 |
| `exp` | Expiration | ✅ | ✅ | 權杖的過期時間:Session 的 `expires_at`(因此會跟著滑動過期),沒有時退回 `iat + session_ttl` |
| `exp` | Expiration | ✅ | ✅ | 權杖的過期時間:Session 的 `expires_at`(因此會跟著滑動過期,且不會超過 `absolute_timeout`),沒有時退回 `iat + session_ttl` |
| `iss` | Issuer | ⚠️ | ✅ | 權杖發行者(選用;只有設定 `jwt_issuer` 時才會發行並驗證) |
| `aud` | Audience | ⚠️ | ✅ | 預期的受眾(選用;只有設定 `jwt_audience` 時才會發行並驗證) |

Expand Down
2 changes: 1 addition & 1 deletion i18n/zh-TW/docs/SESSION.md
Original file line number Diff line number Diff line change
Expand Up @@ -373,7 +373,7 @@ SessionConfig(
)
```

Session 會在 `session_ttl` 秒後過期。啟用 `sliding_expiration` 時,每個發現剩餘時間少於 `session_ttl * sliding_threshold` 秒的請求,都會將過期時間重新延長為完整的 `session_ttl`,並發行一個更新後的權杖,由中介軟體傳回給用戶端(回應標頭或 `Set-Cookie`,見上表)。標頭/Bearer 用戶端在回應帶有 `header_name` 標頭時,應以它取代已保存的權杖。`absolute_timeout` 會在 Session 建立後經過該秒數時結束 Session,不論是否有滑動更新。
Session 會在 `session_ttl` 秒後過期。啟用 `sliding_expiration` 時,每個發現剩餘時間少於 `session_ttl * sliding_threshold` 秒的請求,都會將過期時間重新延長為完整的 `session_ttl`,並發行一個更新後的權杖,由中介軟體傳回給用戶端(回應標頭或 `Set-Cookie`,見上表)。標頭/Bearer 用戶端在回應帶有 `header_name` 標頭時,應以它取代已保存的權杖。`absolute_timeout` 會在 Session 建立後經過該秒數時結束 Session,不論是否有滑動更新:過期時間、後端 TTL 與 JWT 的 `exp` 都不會超過 `created_at + absolute_timeout`,過期時間到達這個上限後也不再發行更新後的權杖。

#### `token_source_priority` 只接受 `"header"` 與 `"bearer"` {#token_source_priority-accepts-only-header-and-bearer}

Expand Down
99 changes: 88 additions & 11 deletions tests/session/test_manager.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
"""Tests for session manager."""

import base64
import json
from datetime import datetime
from datetime import timedelta
from datetime import timezone
Expand All @@ -15,6 +17,7 @@
from fastapi_cachex.session.exceptions import SessionSecurityError
from fastapi_cachex.session.exceptions import SessionTokenError
from fastapi_cachex.session.manager import SessionManager
from fastapi_cachex.session.models import Session
from fastapi_cachex.session.models import SessionToken
from fastapi_cachex.session.models import SessionUser
from fastapi_cachex.types import CacheEntry
Expand Down Expand Up @@ -496,20 +499,22 @@ async def test_save_session_without_ttl_uses_none_expiry(
async def test_absolute_timeout_raises_session_expired_error(
backend: MemoryBackend,
) -> None:
"""absolute_timeout must expire the session even if sliding TTL would renew it."""
# absolute_timeout=1 means the session must not live beyond 1 second from creation
config = SessionConfig(secret_key="a" * 32, session_ttl=3600, absolute_timeout=1)
manager = SessionManager(backend, config)

user = SessionUser(user_id="abs-user", username="testuser")
_session, token = await manager.create_session(user=user)
"""absolute_timeout expires a record whose own expiry lies past the cap.

# Wait for the absolute timeout to pass
import asyncio
New records are capped at creation (#164), so this covers records stored
before absolute_timeout was set or lowered.
"""
uncapped = SessionManager(
backend, SessionConfig(secret_key="a" * 32, session_ttl=3600)
)
session, token = await uncapped.create_session(user=SessionUser(user_id="abs-user"))
session.created_at -= timedelta(seconds=61)
await uncapped.update_session(session)

await asyncio.sleep(1.1)
config = SessionConfig(secret_key="a" * 32, session_ttl=3600, absolute_timeout=60)
manager = SessionManager(backend, config)

with pytest.raises(SessionExpiredError):
with pytest.raises(SessionExpiredError, match="absolute timeout"):
await manager.get_session(token)


Expand Down Expand Up @@ -545,6 +550,78 @@ async def test_absolute_timeout_not_triggered_before_expiry(
assert retrieved is not None


async def _age(manager: SessionManager, session: Session, seconds: int) -> None:
"""Move a stored session `seconds` into the past."""
session.created_at -= timedelta(seconds=seconds)
assert session.expires_at is not None
session.expires_at -= timedelta(seconds=seconds)
await manager.update_session(session)


@pytest.mark.asyncio
async def test_sliding_renewal_stops_at_absolute_timeout(
backend: MemoryBackend,
) -> None:
"""Renewal must not extend expires_at, the TTL or the JWT exp past the cap (#164)."""
config = SessionConfig(
secret_key="a" * 32,
token_format="jwt",
session_ttl=100,
sliding_threshold=0.5,
absolute_timeout=120,
)
manager = SessionManager(backend, config)
session, token = await manager.create_session(user=SessionUser(user_id="u"))
await _age(manager, session, 60) # 40 s left of 100: renew

renewed, new_token = await manager.get_session(token)

cap = renewed.created_at + timedelta(seconds=120)
assert renewed.expires_at == cap
assert new_token is not None
payload = new_token.split(".")[1]
claims = json.loads(base64.urlsafe_b64decode(payload + "=" * (-len(payload) % 4)))
assert claims["exp"] == int(cap.timestamp())
item = backend.cache[manager._get_backend_key(renewed.session_id)]
assert item.expiry is not None
assert item.expiry <= cap.timestamp()


@pytest.mark.asyncio
async def test_no_renewal_once_expiry_reaches_absolute_timeout(
backend: MemoryBackend,
) -> None:
"""At the cap there is nothing to extend, so no token is re-issued."""
config = SessionConfig(
secret_key="a" * 32,
session_ttl=100,
sliding_threshold=0.5,
absolute_timeout=120,
)
manager = SessionManager(backend, config)
session, token = await manager.create_session(user=SessionUser(user_id="u"))
await _age(manager, session, 80) # the cap leaves 40 s, under the 50 s threshold
_renewed, first = await manager.get_session(token)
assert first is not None

_session, second = await manager.get_session(first)

assert second is None


@pytest.mark.asyncio
async def test_new_session_expiry_is_capped_by_absolute_timeout(
backend: MemoryBackend,
) -> None:
"""A session_ttl longer than absolute_timeout must not outlive the cap."""
config = SessionConfig(secret_key="a" * 32, session_ttl=3600, absolute_timeout=60)
manager = SessionManager(backend, config)

session, _token = await manager.create_session(user=SessionUser(user_id="u"))

assert session.expires_at == session.created_at + timedelta(seconds=60)


@pytest.mark.asyncio
async def test_delete_user_sessions_no_sessions(backend: MemoryBackend) -> None:
"""delete_user_sessions returns 0 when the user has no sessions."""
Expand Down
Loading