Upgrade kaya to 0.0.6

kaya-oidc 0.0.6 namespaces its session state per provider as
oidc:{provider}:user (the default provider is "default", so the key is
oidc:default:user). The unprefixed oidc_user key is no longer written:
left unadapted, every websocket upgrade would be rejected with 401 and
traced requests would silently lose their user.id attribute. Adopt it
with a session_user helper on tavolo.platform.auth that finds any
provider's user, used by both get_ws_user and telemetry's
session_tag_hook. Websocket and route handlers keep their signatures.

kaya-otel 0.0.6 only enriches websocket spans with standard URL, network
and peer attributes (plus error.type), which needs no application change.

Regenerate the lock: the eight kaya pins move to 0.0.6 and, the wheels'
declared metadata being unchanged, nothing else moves. tavolo-platform
now floors kaya-core>=0.0.6 (it reads the new session layout) and
tavolo-app floors kaya-otel>=0.0.6.

As kaya documents, the session-key move signs existing logged-in users
out once; they simply log in again.
This commit is contained in:
2026-10-01 01:18:05 +00:00
parent b43e074728
commit 3abbbc6b68
7 changed files with 123 additions and 21 deletions
+1 -1
View File
@@ -34,7 +34,7 @@ dev = [
"httpx-ws", "httpx-ws",
] ]
otel = [ otel = [
"kaya-otel>=0.0.5", "kaya-otel>=0.0.6",
"opentelemetry-instrumentation-asyncpg>=0.66b0", "opentelemetry-instrumentation-asyncpg>=0.66b0",
"opentelemetry-instrumentation-redis>=0.66b0", "opentelemetry-instrumentation-redis>=0.66b0",
"opentelemetry-instrumentation-system-metrics>=0.66b0", "opentelemetry-instrumentation-system-metrics>=0.66b0",
@@ -28,12 +28,13 @@ import hashlib
import logging import logging
from dataclasses import dataclass from dataclasses import dataclass
from logging import getLogger from logging import getLogger
from typing import TYPE_CHECKING, Any, Dict, Mapping, Optional, Union from typing import TYPE_CHECKING, Any, Dict, Optional, Union
from kaya.core import KayaApp, KayaMixin from kaya.core import KayaApp, KayaMixin
from kaya.session import InMemorySessionStore, SessionStore from kaya.session import InMemorySessionStore, SessionStore
from kaya.session.redis import RedisSessionStore from kaya.session.redis import RedisSessionStore
from redis.asyncio import Redis from redis.asyncio import Redis
from tavolo.platform.auth import session_user
from tavolo.platform.store import GameStore from tavolo.platform.store import GameStore
from .config import Settings from .config import Settings
@@ -108,8 +109,8 @@ def session_tag_hook(span: Any, carrier: Any) -> None:
"session.id", "session.id",
hashlib.sha256(session_id.encode()).hexdigest()[:16], hashlib.sha256(session_id.encode()).hexdigest()[:16],
) )
user = session.get("oidc_user") user = session_user(session)
if isinstance(user, Mapping): if user is not None:
sub = user.get("sub") sub = user.get("sub")
if sub: if sub:
span.set_attribute("user.id", str(sub)) span.set_attribute("user.id", str(sub))
@@ -9,7 +9,7 @@ description = "Game-independent multiplayer game platform (lobby, live play, mat
readme = "README.md" readme = "README.md"
requires-python = ">=3.10" requires-python = ">=3.10"
dependencies = [ dependencies = [
"kaya-core>=0.0.5", "kaya-core>=0.0.6",
"kaya-session", "kaya-session",
"kaya-oidc", "kaya-oidc",
"kaya-openapi", "kaya-openapi",
@@ -10,6 +10,28 @@ from typing import Any, Callable, Mapping, Optional
from kaya.core import HttpContext, WebSocket from kaya.core import HttpContext, WebSocket
from kaya.oidc import OIDCMixin, OIDCUser from kaya.oidc import OIDCMixin, OIDCUser
from kaya.session import Session
# kaya-oidc namespaces its session state per provider as
# ``oidc:{provider}:user`` (since 0.0.6), so the lookup cannot assume a
# fixed key or provider name.
_OIDC_PREFIX = "oidc:"
_USER_SUFFIX = ":user"
def session_user(session: Session) -> Optional[OIDCUser]:
"""Return the authenticated user stored in a session, if any.
The kaya-oidc mixin writes the userinfo at login under a per-provider
key, so any provider's user is accepted (the configured provider is
``default`` unless the app says otherwise).
"""
for key in session:
if key.startswith(_OIDC_PREFIX) and key.endswith(_USER_SUFFIX):
claims = session.get(key)
if isinstance(claims, Mapping):
return OIDCUser(claims)
return None
def get_ws_user(ws: WebSocket) -> Optional[OIDCUser]: def get_ws_user(ws: WebSocket) -> Optional[OIDCUser]:
@@ -21,10 +43,7 @@ def get_ws_user(ws: WebSocket) -> Optional[OIDCUser]:
session = getattr(ws, "session", None) session = getattr(ws, "session", None)
if session is None: if session is None:
return None return None
claims = session.get("oidc_user") return session_user(session)
if not isinstance(claims, Mapping):
return None
return OIDCUser(claims)
def display_name(user: OIDCUser) -> str: def display_name(user: OIDCUser) -> str:
@@ -0,0 +1,65 @@
"""Tests for the auth helpers' OIDC user lookup.
kaya-oidc (0.0.6) namespaces session state per provider as
``oidc:{provider}:user``; these tests pin that contract so a kaya
upgrade that changes it again cannot silently break websocket
authentication or span tagging.
"""
from __future__ import annotations
import unittest
from types import SimpleNamespace
from typing import Any
from kaya.session import Session
from tavolo.platform.auth import get_ws_user, session_user
def _ws(session: Any) -> Any:
return SimpleNamespace(session=session)
class SessionUserTest(unittest.TestCase):
def test_finds_default_provider_user(self) -> None:
user = session_user(Session("sid", {"oidc:default:user": {"sub": "alice"}}))
assert user is not None
self.assertEqual("alice", user.sub)
def test_finds_user_of_any_provider(self) -> None:
user = session_user(Session("sid", {"oidc:google:user": {"sub": "alice"}}))
assert user is not None
self.assertEqual("alice", user.sub)
def test_ignores_flow_state_and_tokens(self) -> None:
session = Session(
"sid",
{
"oidc:default:state": "state",
"oidc:default:id_token": "token",
"oidc:default:access_token": "token",
},
)
self.assertIsNone(session_user(session))
def test_ignores_legacy_unprefixed_user_key(self) -> None:
session = Session("sid", {"oidc_user": {"sub": "alice"}})
self.assertIsNone(session_user(session))
def test_ignores_malformed_user_claims(self) -> None:
session = Session("sid", {"oidc:default:user": "alice"})
self.assertIsNone(session_user(session))
class GetWsUserTest(unittest.TestCase):
def test_returns_user_from_provider_namespaced_session(self) -> None:
session = Session(
"sid",
{"oidc:default:user": {"sub": "alice", "preferred_username": "Alice"}},
)
user = get_ws_user(_ws(session))
assert user is not None
self.assertEqual("alice", user.sub)
def test_missing_session_returns_none(self) -> None:
self.assertIsNone(get_ws_user(SimpleNamespace()))
+8 -8
View File
@@ -63,7 +63,7 @@ idna==3.20
# httpx # httpx
iso8601==2.1.0 iso8601==2.1.0
# via tortoise-orm # via tortoise-orm
kaya-core==0.0.5 kaya-core==0.0.6
# via # via
# kaya-cors # kaya-cors
# kaya-oidc # kaya-oidc
@@ -72,22 +72,22 @@ kaya-core==0.0.5
# kaya-rsgi # kaya-rsgi
# kaya-session # kaya-session
# tavolo-app (pyproject.toml) # tavolo-app (pyproject.toml)
kaya-cors==0.0.5 kaya-cors==0.0.6
# via tavolo-app (pyproject.toml) # via tavolo-app (pyproject.toml)
kaya-oidc==0.0.5 kaya-oidc==0.0.6
# via tavolo-app (pyproject.toml) # via tavolo-app (pyproject.toml)
kaya-openapi==0.0.5 kaya-openapi==0.0.6
# via tavolo-app (pyproject.toml) # via tavolo-app (pyproject.toml)
kaya-otel==0.0.5 kaya-otel==0.0.6
# via tavolo-app (pyproject.toml) # via tavolo-app (pyproject.toml)
kaya-rsgi==0.0.5 kaya-rsgi==0.0.6
# via tavolo-app (pyproject.toml) # via tavolo-app (pyproject.toml)
kaya-session==0.0.5 kaya-session==0.0.6
# via # via
# kaya-oidc # kaya-oidc
# kaya-session-redis # kaya-session-redis
# tavolo-app (pyproject.toml) # tavolo-app (pyproject.toml)
kaya-session-redis==0.0.5 kaya-session-redis==0.0.6
# via tavolo-app (pyproject.toml) # via tavolo-app (pyproject.toml)
opentelemetry-api==1.45.0 opentelemetry-api==1.45.0
# via # via
+21 -4
View File
@@ -151,7 +151,7 @@ class SessionTagHookTest(unittest.TestCase):
def test_tags_authenticated_session(self) -> None: def test_tags_authenticated_session(self) -> None:
span = _RecordedSpan() span = _RecordedSpan()
carrier = SimpleNamespace( carrier = SimpleNamespace(
session=Session("sid", {"oidc_user": {"sub": "alice"}}) session=Session("sid", {"oidc:default:user": {"sub": "alice"}})
) )
session_tag_hook(span, carrier) session_tag_hook(span, carrier)
self.assertEqual( self.assertEqual(
@@ -160,6 +160,23 @@ class SessionTagHookTest(unittest.TestCase):
) )
self.assertEqual("alice", span.attributes["user.id"]) self.assertEqual("alice", span.attributes["user.id"])
def test_tags_user_of_any_provider(self) -> None:
span = _RecordedSpan()
carrier = SimpleNamespace(
session=Session("sid", {"oidc:google:user": {"sub": "alice"}})
)
session_tag_hook(span, carrier)
self.assertEqual("alice", span.attributes["user.id"])
def test_ignores_legacy_unprefixed_user_key(self) -> None:
span = _RecordedSpan()
carrier = SimpleNamespace(
session=Session("sid", {"oidc_user": {"sub": "alice"}})
)
session_tag_hook(span, carrier)
self.assertIn("session.id", span.attributes)
self.assertNotIn("user.id", span.attributes)
def test_tags_session_without_user(self) -> None: def test_tags_session_without_user(self) -> None:
span = _RecordedSpan() span = _RecordedSpan()
session_tag_hook(span, SimpleNamespace(session=Session("sid", {}))) session_tag_hook(span, SimpleNamespace(session=Session("sid", {})))
@@ -169,7 +186,7 @@ class SessionTagHookTest(unittest.TestCase):
def test_tags_user_when_session_id_not_yet_minted(self) -> None: def test_tags_user_when_session_id_not_yet_minted(self) -> None:
span = _RecordedSpan() span = _RecordedSpan()
carrier = SimpleNamespace( carrier = SimpleNamespace(
session=Session(None, {"oidc_user": {"sub": "alice"}}) session=Session(None, {"oidc:default:user": {"sub": "alice"}})
) )
session_tag_hook(span, carrier) session_tag_hook(span, carrier)
self.assertNotIn("session.id", span.attributes) self.assertNotIn("session.id", span.attributes)
@@ -182,7 +199,7 @@ class SessionTagHookTest(unittest.TestCase):
def test_malformed_user_is_ignored(self) -> None: def test_malformed_user_is_ignored(self) -> None:
span = _RecordedSpan() span = _RecordedSpan()
carrier = SimpleNamespace(session=Session("sid", {"oidc_user": "alice"})) carrier = SimpleNamespace(session=Session("sid", {"oidc:default:user": "alice"}))
session_tag_hook(span, carrier) session_tag_hook(span, carrier)
self.assertIn("session.id", span.attributes) self.assertIn("session.id", span.attributes)
self.assertNotIn("user.id", span.attributes) self.assertNotIn("user.id", span.attributes)
@@ -224,7 +241,7 @@ class SessionTagIntegrationTest(unittest.TestCase):
async def login(ctx: HttpContext) -> None: async def login(ctx: HttpContext) -> None:
session = ctx.session session = ctx.session
assert session is not None assert session is not None
session["oidc_user"] = {"sub": "alice"} session["oidc:default:user"] = {"sub": "alice"}
await ctx.send_str(200, "ok") await ctx.send_str(200, "ok")
@app.GET("/me") @app.GET("/me")