Enforce server-side session idle expiry with sliding TTL

This commit is contained in:
2026-07-18 00:56:14 +00:00
parent 705771b681
commit 079e6cce4b
4 changed files with 154 additions and 19 deletions
+15
View File
@@ -24,6 +24,21 @@ async def home(ctx: HttpContext):
Sessions are created lazily: a cookie is only set when the handler modifies the Sessions are created lazily: a cookie is only set when the handler modifies the
session. session.
## Session expiry
The cookie sent to the browser has a `Max-Age` (default 14 days), but that is
only a client-side hint. The real boundary is the store's server-side TTL,
which the middleware keeps in sync with the cookie `Max-Age`.
For `InMemorySessionStore`, a session expires if it is idle for longer than
`max_age`. Active sessions have their expiry slid forward on every access, so
a user that keeps visiting stays logged in. If the client ignores the cookie's
`Max-Age` and replays an old cookie value, the store rejects the expired
session and creates a fresh empty one.
Set `max_age=None` to disable server-side expiry (and the `Max-Age` cookie
attribute) entirely.
## Features ## Features
- `Session`: dict-like session object with modification tracking - `Session`: dict-like session object with modification tracking
@@ -104,7 +104,7 @@ class SessionMiddleware:
session_id = self._extract_session_id(scope) session_id = self._extract_session_id(scope)
session: Session session: Session
if session_id is not None: if session_id is not None:
loaded = await self._store.load(session_id) loaded = await self._store.load(session_id, self._max_age)
session = loaded if loaded is not None else Session() session = loaded if loaded is not None else Session()
else: else:
session = Session() session = Session()
@@ -175,4 +175,4 @@ class SessionMiddleware:
session.set_id(self._store.new_session_id()) session.set_id(self._store.new_session_id())
if session.id is not None: if session.id is not None:
await self._store.save(session.id, session) await self._store.save(session.id, session, self._max_age)
@@ -1,6 +1,7 @@
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from secrets import token_urlsafe from secrets import token_urlsafe
from typing import Any, Optional from time import monotonic
from typing import Any, Callable, Optional
from ._session import Session from ._session import Session
@@ -9,13 +10,22 @@ class SessionStore(ABC):
"""Pluggable backend for session persistence.""" """Pluggable backend for session persistence."""
@abstractmethod @abstractmethod
async def load(self, session_id: str) -> Optional[Session]: async def load(self, session_id: str, max_age: Optional[int] = None) -> Optional[Session]:
"""Load an existing session, or return ``None`` if unknown/expired.""" """Load an existing session, or return ``None`` if unknown or expired.
``max_age`` is the idle timeout in seconds. If provided, the store may
use it to enforce a server-side expiry and to slide the expiry window
on each access.
"""
pass pass
@abstractmethod @abstractmethod
async def save(self, session_id: str, session: Session) -> None: async def save(self, session_id: str, session: Session, max_age: Optional[int] = None) -> None:
"""Persist the session data under ``session_id``.""" """Persist the session data under ``session_id``.
``max_age`` is the idle timeout in seconds. If provided, the store
should record the expiry time as ``now + max_age``.
"""
pass pass
@abstractmethod @abstractmethod
@@ -36,19 +46,40 @@ class InMemorySessionStore(SessionStore):
Suitable for development and single-process deployments. Session data is Suitable for development and single-process deployments. Session data is
lost when the process exits and is not shared between processes. lost when the process exits and is not shared between processes.
Sessions can optionally expire server-side after ``max_age`` seconds of
inactivity. Active sessions slide the expiry window on each access.
""" """
def __init__(self) -> None: def __init__(self, clock: Callable[[], float] = monotonic) -> None:
self._sessions: dict[str, dict[str, Any]] = {} self._clock = clock
self._data: dict[str, dict[str, Any]] = {}
self._expires: dict[str, float] = {}
async def load(self, session_id: str) -> Optional[Session]: async def load(self, session_id: str, max_age: Optional[int] = None) -> Optional[Session]:
data = self._sessions.get(session_id) data = self._data.get(session_id)
if data is None: if data is None:
return None return None
expires = self._expires.get(session_id)
now = self._clock()
if expires is not None and now > expires:
self._data.pop(session_id, None)
self._expires.pop(session_id, None)
return None
if max_age is not None and expires is not None:
self._expires[session_id] = now + max_age
return Session(session_id, data) return Session(session_id, data)
async def save(self, session_id: str, session: Session) -> None: async def save(self, session_id: str, session: Session, max_age: Optional[int] = None) -> None:
self._sessions[session_id] = dict(session) self._data[session_id] = dict(session)
if max_age is not None:
self._expires[session_id] = self._clock() + max_age
else:
self._expires.pop(session_id, None)
async def delete(self, session_id: str) -> None: async def delete(self, session_id: str) -> None:
self._sessions.pop(session_id, None) self._data.pop(session_id, None)
self._expires.pop(session_id, None)
+94 -5
View File
@@ -1,3 +1,4 @@
import asyncio
import unittest import unittest
from typing import Any from typing import Any
@@ -9,6 +10,17 @@ from kaya.session import InMemorySessionStore, Session, SessionMiddleware, Sessi
from kaya.session._cookie import format_set_cookie, parse_cookie_value from kaya.session._cookie import format_set_cookie, parse_cookie_value
class FakeClock:
def __init__(self, start: float = 0.0) -> None:
self._now = start
def __call__(self) -> float:
return self._now
def advance(self, seconds: float) -> None:
self._now += seconds
class SessionTest(unittest.TestCase): class SessionTest(unittest.TestCase):
app: KayaApp app: KayaApp
store: InMemorySessionStore store: InMemorySessionStore
@@ -123,7 +135,7 @@ class SessionTest(unittest.TestCase):
cookies_before = {c.name: c.value for c in client.cookies.jar} cookies_before = {c.name: c.value for c in client.cookies.jar}
old_id = cookies_before.get('session_id') old_id = cookies_before.get('session_id')
self.assertIsNotNone(old_id) self.assertIsNotNone(old_id)
self.assertIn(old_id, self.store._sessions) self.assertIn(old_id, self.store._data)
r = await client.get('/rotate') r = await client.get('/rotate')
self.assertEqual(200, r.status_code) self.assertEqual(200, r.status_code)
@@ -133,8 +145,8 @@ class SessionTest(unittest.TestCase):
new_id = cookies_after.get('session_id') new_id = cookies_after.get('session_id')
self.assertIsNotNone(new_id) self.assertIsNotNone(new_id)
self.assertNotEqual(old_id, new_id) self.assertNotEqual(old_id, new_id)
self.assertNotIn(old_id, self.store._sessions) self.assertNotIn(old_id, self.store._data)
self.assertIn(new_id, self.store._sessions) self.assertIn(new_id, self.store._data)
r = await client.get('/read') r = await client.get('/read')
self.assertEqual('visits: 1', r.text) self.assertEqual('visits: 1', r.text)
@@ -148,6 +160,37 @@ class SessionTest(unittest.TestCase):
self.assertEqual('visits: 1', r.text) self.assertEqual('visits: 1', r.text)
self.assertIn('Set-Cookie', r.headers) self.assertIn('Set-Cookie', r.headers)
@async_test
async def test_stale_cookie_cannot_access_old_data(self) -> None:
clock = FakeClock()
store = InMemorySessionStore(clock=clock)
app = KayaApp()
session_app = SessionMiddleware(app, store, max_age=60)
@session_app.GET('/')
async def home(ctx: HttpContext) -> None:
ctx.session['secret'] = 'super-sensitive'
await ctx.send_str(200, 'ok')
@session_app.GET('/read')
async def read(ctx: HttpContext) -> None:
await ctx.send_str(200, ctx.session.get('secret', 'none'))
transport = httpx.ASGITransport(app=session_app)
async with httpx.AsyncClient(transport=transport, base_url='http://127.0.0.1:80') as client:
r = await client.get('/')
self.assertEqual('ok', r.text)
old_id = client.cookies['session_id']
self.assertIn(old_id, store._data)
self.assertIn(old_id, store._expires)
clock.advance(61)
r = await client.get('/read')
self.assertEqual('none', r.text)
self.assertNotIn(old_id, store._data)
self.assertNotIn(old_id, store._expires)
class SessionUnitTest(unittest.TestCase): class SessionUnitTest(unittest.TestCase):
@@ -202,9 +245,8 @@ class SessionUnitTest(unittest.TestCase):
store = InMemorySessionStore() store = InMemorySessionStore()
session_id = store.new_session_id() session_id = store.new_session_id()
session = Session(session_id, {'a': 1}) session = Session(session_id, {'a': 1})
self.assertIsNone(store._sessions.get(session_id)) self.assertIsNone(store._data.get(session_id))
import asyncio
asyncio.run(store.save(session_id, session)) asyncio.run(store.save(session_id, session))
loaded = asyncio.run(store.load(session_id)) loaded = asyncio.run(store.load(session_id))
@@ -215,6 +257,53 @@ class SessionUnitTest(unittest.TestCase):
asyncio.run(store.delete(session_id)) asyncio.run(store.delete(session_id))
self.assertIsNone(asyncio.run(store.load(session_id))) self.assertIsNone(asyncio.run(store.load(session_id)))
def test_in_memory_store_expires_after_max_age(self) -> None:
clock = FakeClock()
store = InMemorySessionStore(clock=clock)
session_id = store.new_session_id()
asyncio.run(store.save(session_id, Session(session_id, {'a': 1}), max_age=60))
clock.advance(61)
self.assertIsNone(asyncio.run(store.load(session_id, max_age=60)))
self.assertIsNone(store._data.get(session_id))
self.assertIsNone(store._expires.get(session_id))
def test_in_memory_store_slides_expiry_on_load(self) -> None:
clock = FakeClock()
store = InMemorySessionStore(clock=clock)
session_id = store.new_session_id()
asyncio.run(store.save(session_id, Session(session_id, {'a': 1}), max_age=60))
clock.advance(30)
loaded = asyncio.run(store.load(session_id, max_age=60))
self.assertIsNotNone(loaded)
assert loaded is not None
self.assertEqual(1, loaded['a'])
# Without sliding, the session would expire at t=60. With sliding it is now valid until t=90.
clock.advance(35)
loaded = asyncio.run(store.load(session_id, max_age=60))
self.assertIsNotNone(loaded)
assert loaded is not None
self.assertEqual(1, loaded['a'])
def test_in_memory_store_no_expiry_without_max_age(self) -> None:
clock = FakeClock()
store = InMemorySessionStore(clock=clock)
session_id = store.new_session_id()
asyncio.run(store.save(session_id, Session(session_id, {'a': 1})))
clock.advance(1000000)
loaded = asyncio.run(store.load(session_id))
self.assertIsNotNone(loaded)
assert loaded is not None
self.assertEqual(1, loaded['a'])
def test_in_memory_store_invalidate_removes_expiry(self) -> None:
clock = FakeClock()
store = InMemorySessionStore(clock=clock)
session_id = store.new_session_id()
asyncio.run(store.save(session_id, Session(session_id, {'a': 1}), max_age=60))
asyncio.run(store.delete(session_id))
self.assertIsNone(store._data.get(session_id))
self.assertIsNone(store._expires.get(session_id))
class CookieUtilTest(unittest.TestCase): class CookieUtilTest(unittest.TestCase):