Add OIDC back-channel logout with session indexes
CI / Build and publish packages (push) Failing after 14s

This commit is contained in:
woggioni committed 2026-11-02 12:15:41 +08:00
1 parent ec056a15b9
commit 074e48a372
20 files changed
+1594 -32

No files matched your search

+12 -2
View File
@@ -30,11 +30,21 @@ Sessions are stored under keys with the prefix `kaya:session:` (configurable
via the `prefix` argument). Server-side expiry uses Redis key TTLs and slides
on each access when the session mixin passes a `max_age`.
## Session indexes
`RedisSessionStore` supports `Session.add_index()`,
`SessionStore.find_sessions()`, and `delete_by_index()`. Reverse memberships
are Redis sets updated in the same transactional pipeline as session saves.
They are removed on deletion, and stale members are pruned during lookups.
Bucket keys use a SHA-256 digest of the index name and value.
## Serialization
Session data is serialized with `pickle` by default, so arbitrary Python
objects can be stored. A different serializer can be plugged in via the
`dumps`/`loads` arguments:
objects can be stored. The serialized value is an envelope containing the
session dictionary and index metadata; legacy plain-dict values are still
accepted. A different serializer can be plugged in via the `dumps`/`loads`
arguments:
```python
import json
@@ -1,9 +1,10 @@
import hashlib
import pickle
from typing import Any, Callable, Optional, cast
from typing import Any, Callable, Optional, Sequence, cast
from redis.asyncio import Redis
from kaya.session import Session, SessionStore
from kaya.session import IndexKey, Session, SessionStore
class RedisSessionStore(SessionStore):
@@ -20,8 +21,14 @@ class RedisSessionStore(SessionStore):
Python objects can be stored. Custom serializers can be plugged in via
the ``dumps``/``loads`` arguments. Only connect this store to a trusted
Redis server, as pickle deserialization of untrusted data is unsafe.
Reverse session indexes are stored as Redis sets. They are updated in the
same pipeline as session saves, cleaned on deletion, and pruned when stale
memberships are encountered during a lookup.
"""
supports_indexes = True
def __init__(
self,
client: Redis,
@@ -37,6 +44,10 @@ class RedisSessionStore(SessionStore):
def _key(self, session_id: str) -> str:
return f"{self._prefix}{session_id}"
def _index_key(self, name: str, value: str) -> str:
digest = hashlib.sha256(f"{name}\0{value}".encode()).hexdigest()
return f"{self._prefix}idx:{digest}"
async def load(self, session_id: str, max_age: Optional[int] = None) -> Optional[Session]:
key = self._key(session_id)
payload = await self._client.get(key)
@@ -47,11 +58,65 @@ class RedisSessionStore(SessionStore):
await self._client.expire(key, max_age)
data = self._loads(cast(bytes, payload))
return Session(session_id, data)
return Session.from_payload(session_id, data)
async def save(self, session_id: str, session: Session, max_age: Optional[int] = None) -> None:
payload = self._dumps(dict(session))
await self._client.set(self._key(session_id), payload, ex=max_age)
payload = self._dumps(session.to_payload())
pipeline = self._client.pipeline(transaction=True)
pipeline.set(self._key(session_id), payload, ex=max_age)
for name, value in session.indexes:
index_key = self._index_key(name, value)
pipeline.sadd(index_key, session_id)
if max_age is None:
pipeline.persist(index_key)
else:
pipeline.expire(index_key, max_age + 60)
for name, value in session.removed_indexes:
pipeline.srem(self._index_key(name, value), session_id)
await pipeline.execute()
async def delete(self, session_id: str) -> None:
await self._client.delete(self._key(session_id))
key = self._key(session_id)
payload = await self._client.get(key)
indexes: set[IndexKey] = set()
if payload is not None:
try:
indexes = set(Session.from_payload(
session_id,
self._loads(cast(bytes, payload)),
).indexes)
except Exception:
# A corrupt payload must not prevent session deletion.
pass
pipeline = self._client.pipeline(transaction=True)
pipeline.delete(key)
for name, value in indexes:
pipeline.srem(self._index_key(name, value), session_id)
await pipeline.execute()
async def find_sessions(self, name: str, value: str) -> Sequence[str]:
index_key = self._index_key(name, value)
members = await self._client.smembers(index_key)
session_ids = [
member.decode() if isinstance(member, bytes) else str(member)
for member in members
]
if not session_ids:
return []
pipeline = self._client.pipeline(transaction=False)
for session_id in session_ids:
pipeline.exists(self._key(session_id))
exists = await pipeline.execute()
live: list[str] = []
stale: list[str] = []
for session_id, found in zip(session_ids, exists):
if found:
live.append(session_id)
else:
stale.append(session_id)
if stale:
await self._client.srem(index_key, *stale)
return live
@@ -0,0 +1,94 @@
import pickle
import unittest
import fakeredis.aioredis
from pwo import async_test
from kaya.session import Session
from kaya.session.redis import RedisSessionStore
class RedisSessionIndexTest(unittest.TestCase):
def setUp(self) -> None:
self.client = fakeredis.aioredis.FakeRedis()
self.store = RedisSessionStore(self.client)
@async_test
async def test_save_load_and_find(self) -> None:
self.assertTrue(self.store.supports_indexes)
await self.store.save('abc', Session('abc', {'user': 'alice'}, indexes={('sub', 'alice')}))
loaded = await self.store.load('abc')
assert loaded is not None
self.assertEqual({'user': 'alice'}, dict(loaded))
self.assertEqual(frozenset({('sub', 'alice')}), loaded.indexes)
self.assertEqual(['abc'], await self.store.find_sessions('sub', 'alice'))
@async_test
async def test_rotation_preserves_index(self) -> None:
await self.store.save('old', Session('old', indexes={('sub', 'alice')}))
loaded = await self.store.load('old')
assert loaded is not None
loaded.set_id('new')
await self.store.delete('old')
await self.store.save('new', loaded)
self.assertEqual(['new'], await self.store.find_sessions('sub', 'alice'))
@async_test
async def test_delete_cleans_reverse_index(self) -> None:
await self.store.save('abc', Session('abc', indexes={('sub', 'alice')}))
index_key = self.store._index_key('sub', 'alice')
self.assertEqual({b'abc'}, await self.client.smembers(index_key))
await self.store.delete('abc')
self.assertEqual(set(), await self.client.smembers(index_key))
@async_test
async def test_delete_by_index_deletes_all_sessions(self) -> None:
await self.store.save('a', Session('a', indexes={('sub', 'alice')}))
await self.store.save('b', Session('b', indexes={('sub', 'alice')}))
await self.store.save('c', Session('c', indexes={('sub', 'bob')}))
self.assertEqual(2, await self.store.delete_by_index('sub', 'alice'))
self.assertIsNone(await self.store.load('a'))
self.assertIsNone(await self.store.load('b'))
self.assertIsNotNone(await self.store.load('c'))
@async_test
async def test_stale_member_is_pruned(self) -> None:
await self.store.save('abc', Session('abc', indexes={('sub', 'alice')}))
await self.client.delete('kaya:session:abc')
self.assertEqual([], await self.store.find_sessions('sub', 'alice'))
index_key = self.store._index_key('sub', 'alice')
self.assertEqual(set(), await self.client.smembers(index_key))
@async_test
async def test_index_ttl_tracks_session_max_age(self) -> None:
await self.store.save(
'abc',
Session('abc', indexes={('sub', 'alice')}),
max_age=60,
)
index_key = self.store._index_key('sub', 'alice')
ttl = await self.client.ttl(index_key)
self.assertGreater(ttl, 60)
self.assertLessEqual(ttl, 120)
@async_test
async def test_legacy_payload_is_upgraded_on_save(self) -> None:
await self.client.set('kaya:session:abc', pickle.dumps({'foo': 'bar'}))
loaded = await self.store.load('abc')
assert loaded is not None
self.assertEqual({'foo': 'bar'}, dict(loaded))
self.assertEqual(frozenset(), loaded.indexes)
loaded.add_index('sub', 'alice')
await self.store.save('abc', loaded)
raw = await self.client.get('kaya:session:abc')
assert raw is not None
payload = pickle.loads(raw)
self.assertEqual(1, payload['__kaya_session_v1__'])
self.assertEqual({'foo': 'bar'}, payload['data'])
self.assertEqual(['abc'], await self.store.find_sessions('sub', 'alice'))
if __name__ == '__main__':
unittest.main()
@@ -89,7 +89,13 @@ class RedisSessionStoreTest(unittest.TestCase):
loads=lambda b: json.loads(b.decode('utf-8')),
)
await store.save('abc', Session('abc', {'foo': 'bar'}))
self.assertEqual(b'{"foo": "bar"}', await self.client.get('kaya:session:abc'))
raw = await self.client.get('kaya:session:abc')
assert raw is not None
self.assertEqual({
'__kaya_session_v1__': 1,
'data': {'foo': 'bar'},
'indexes': [],
}, json.loads(raw))
loaded = await store.load('abc')
self.assertIsNotNone(loaded)
assert loaded is not None