diff --git a/packages/kaya-session/README.md b/packages/kaya-session/README.md index 5b587b9..6ee507c 100644 --- a/packages/kaya-session/README.md +++ b/packages/kaya-session/README.md @@ -24,6 +24,21 @@ async def home(ctx: HttpContext): Sessions are created lazily: a cookie is only set when the handler modifies the 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 - `Session`: dict-like session object with modification tracking diff --git a/packages/kaya-session/src/kaya/session/_middleware.py b/packages/kaya-session/src/kaya/session/_middleware.py index 896d1e3..c4f8622 100644 --- a/packages/kaya-session/src/kaya/session/_middleware.py +++ b/packages/kaya-session/src/kaya/session/_middleware.py @@ -104,7 +104,7 @@ class SessionMiddleware: session_id = self._extract_session_id(scope) session: Session 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() else: session = Session() @@ -175,4 +175,4 @@ class SessionMiddleware: session.set_id(self._store.new_session_id()) if session.id is not None: - await self._store.save(session.id, session) + await self._store.save(session.id, session, self._max_age) diff --git a/packages/kaya-session/src/kaya/session/_store.py b/packages/kaya-session/src/kaya/session/_store.py index 31a11fb..1648afe 100644 --- a/packages/kaya-session/src/kaya/session/_store.py +++ b/packages/kaya-session/src/kaya/session/_store.py @@ -1,6 +1,7 @@ from abc import ABC, abstractmethod from secrets import token_urlsafe -from typing import Any, Optional +from time import monotonic +from typing import Any, Callable, Optional from ._session import Session @@ -9,13 +10,22 @@ class SessionStore(ABC): """Pluggable backend for session persistence.""" @abstractmethod - async def load(self, session_id: str) -> Optional[Session]: - """Load an existing session, or return ``None`` if unknown/expired.""" + async def load(self, session_id: str, max_age: Optional[int] = None) -> Optional[Session]: + """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 @abstractmethod - async def save(self, session_id: str, session: Session) -> None: - """Persist the session data under ``session_id``.""" + async def save(self, session_id: str, session: Session, max_age: Optional[int] = None) -> None: + """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 @abstractmethod @@ -36,19 +46,40 @@ class InMemorySessionStore(SessionStore): Suitable for development and single-process deployments. Session data is 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: - self._sessions: dict[str, dict[str, Any]] = {} + def __init__(self, clock: Callable[[], float] = monotonic) -> None: + self._clock = clock + self._data: dict[str, dict[str, Any]] = {} + self._expires: dict[str, float] = {} - async def load(self, session_id: str) -> Optional[Session]: - data = self._sessions.get(session_id) + async def load(self, session_id: str, max_age: Optional[int] = None) -> Optional[Session]: + data = self._data.get(session_id) if data is 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) - async def save(self, session_id: str, session: Session) -> None: - self._sessions[session_id] = dict(session) + async def save(self, session_id: str, session: Session, max_age: Optional[int] = None) -> None: + 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: - self._sessions.pop(session_id, None) + self._data.pop(session_id, None) + self._expires.pop(session_id, None) diff --git a/packages/kaya-session/tests/test_session.py b/packages/kaya-session/tests/test_session.py index a35e07f..aab1244 100644 --- a/packages/kaya-session/tests/test_session.py +++ b/packages/kaya-session/tests/test_session.py @@ -1,3 +1,4 @@ +import asyncio import unittest 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 +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): app: KayaApp store: InMemorySessionStore @@ -123,7 +135,7 @@ class SessionTest(unittest.TestCase): cookies_before = {c.name: c.value for c in client.cookies.jar} old_id = cookies_before.get('session_id') self.assertIsNotNone(old_id) - self.assertIn(old_id, self.store._sessions) + self.assertIn(old_id, self.store._data) r = await client.get('/rotate') self.assertEqual(200, r.status_code) @@ -133,8 +145,8 @@ class SessionTest(unittest.TestCase): new_id = cookies_after.get('session_id') self.assertIsNotNone(new_id) self.assertNotEqual(old_id, new_id) - self.assertNotIn(old_id, self.store._sessions) - self.assertIn(new_id, self.store._sessions) + self.assertNotIn(old_id, self.store._data) + self.assertIn(new_id, self.store._data) r = await client.get('/read') self.assertEqual('visits: 1', r.text) @@ -148,6 +160,37 @@ class SessionTest(unittest.TestCase): self.assertEqual('visits: 1', r.text) 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): @@ -202,9 +245,8 @@ class SessionUnitTest(unittest.TestCase): store = InMemorySessionStore() session_id = store.new_session_id() 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)) loaded = asyncio.run(store.load(session_id)) @@ -215,6 +257,53 @@ class SessionUnitTest(unittest.TestCase): asyncio.run(store.delete(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):