diff --git a/CHANGELOG.md b/CHANGELOG.md index 26c6739..95002c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/JWT_CLAIMS.md b/docs/JWT_CLAIMS.md index 7fa0b95..2574d56 100644 --- a/docs/JWT_CLAIMS.md +++ b/docs/JWT_CLAIMS.md @@ -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) | diff --git a/docs/SESSION.md b/docs/SESSION.md index c6879c1..7a1e8ba 100644 --- a/docs/SESSION.md +++ b/docs/SESSION.md @@ -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"` diff --git a/fastapi_cachex/session/manager.py b/fastapi_cachex/session/manager.py index 83a6c25..60f0fce 100644 --- a/fastapi_cachex/session/manager.py +++ b/fastapi_cachex/session/manager.py @@ -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. @@ -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: @@ -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", diff --git a/i18n/zh-TW/docs/JWT_CLAIMS.md b/i18n/zh-TW/docs/JWT_CLAIMS.md index 295f783..040dd0f 100644 --- a/i18n/zh-TW/docs/JWT_CLAIMS.md +++ b/i18n/zh-TW/docs/JWT_CLAIMS.md @@ -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` 時才會發行並驗證) | diff --git a/i18n/zh-TW/docs/SESSION.md b/i18n/zh-TW/docs/SESSION.md index dd7a242..b9bc94e 100644 --- a/i18n/zh-TW/docs/SESSION.md +++ b/i18n/zh-TW/docs/SESSION.md @@ -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} diff --git a/tests/session/test_manager.py b/tests/session/test_manager.py index c2fa9c7..9f5ea4e 100644 --- a/tests/session/test_manager.py +++ b/tests/session/test_manager.py @@ -1,5 +1,7 @@ """Tests for session manager.""" +import base64 +import json from datetime import datetime from datetime import timedelta from datetime import timezone @@ -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 @@ -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) @@ -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."""