Support multiple OIDC providers

kaya-oidc 0.0.6 can run several providers in one app (OIDCProviders),
each with its own login route and namespaced session state. Adopt it:
providers are configured in indexed, contiguous OIDC_<n>_* blocks
(0-based) with OIDC_<n>_PROVIDER naming the provider in its login URL
and session keys. The old unindexed OIDC_ISSUER/OIDC_CLIENT_ID/... names
are no longer read; startup fails with a rename hint when they are set.
With no OIDC variables at all, the single dev mock provider named
"default" is used, as before.

The platform now authenticates through OIDCProviders: auth.current_user
returns the session's user (sessions stay single-identity, so at most one
provider is logged in) and require_auth/route handlers use it, while the
websocket path keeps scanning for any provider's user. New routes expose
the configuration to the SPA and keep old links working: GET
/api/auth/providers lists the login URLs, GET /auth/login redirects to
the first provider and GET /auth/logout to whichever provider the
session is logged in with. The lobby renders one login button per
provider from that endpoint.

Update the env examples, compose and k8s manifests to the indexed scheme,
document the multiple-provider setup and the migration, and cover it with
config, auth and route tests.
This commit is contained in:
2026-10-01 12:38:29 +00:00
parent 3abbbc6b68
commit 915a294dc8
25 changed files with 798 additions and 119 deletions
@@ -8,7 +8,7 @@ without importing any real game. Its rules: the first player to reach
``target`` plays wins the match.
:func:`make_platform` builds a throwaway :class:`~kaya.core.KayaApp`
wired with in-memory stores, a dummy OIDC mixin (patched per test) and
wired with in-memory stores, dummy OIDC providers (patched per test) and
a sqlite :class:`~tavolo.platform.tortoise_mixin.TortoiseMixin`, so
tests construct their own app instead of importing a global one.
"""
@@ -22,7 +22,7 @@ from functools import wraps
from typing import Any, Callable, Coroutine, Dict, Iterator, List, Mapping, Optional, Sequence, Tuple
from kaya.core import KayaApp
from kaya.oidc import OIDCConfig, OIDCMixin, OIDCUser
from kaya.oidc import OIDCConfig, OIDCProviders, OIDCUser
from kaya.openapi import OpenAPIMixin
from kaya.session import InMemorySessionStore, SessionMixin
@@ -221,20 +221,25 @@ class DummyEngine(GameEngine):
def make_platform(
engines: Sequence[GameEngine] = (DummyEngine(),),
heartbeat_ms: int = 50,
oidc_providers: Sequence[str] = ("default",),
) -> Tuple[KayaApp, Platform, TortoiseMixin]:
"""Build a throwaway app + platform wired with in-memory stores."""
registry = GameRegistry(engines)
game_store = InMemoryGameStore(registry)
session_mixin = SessionMixin(InMemorySessionStore())
oidc_mixin = OIDCMixin(
OIDCConfig(
issuer="http://localhost:8180/tavolo",
client_id="tavolo",
client_secret=None,
redirect_uri="http://localhost:8080/auth/callback",
post_login_redirect="/",
post_logout_redirect="/",
),
oidc = OIDCProviders(
[
OIDCConfig(
provider=name,
issuer=f"http://localhost:8180/{name}",
client_id="tavolo",
client_secret=None,
redirect_uri=f"http://localhost:8080/auth/{name}/callback",
post_login_redirect="/",
post_logout_redirect="/",
)
for name in oidc_providers
],
session=session_mixin,
)
tortoise_mixin = TortoiseMixin(
@@ -259,12 +264,12 @@ def make_platform(
registry=registry,
game_store=game_store,
scheduler=scheduler,
oidc=oidc_mixin,
oidc=oidc,
)
app = KayaApp(
mixins=[
session_mixin,
oidc_mixin,
oidc,
tortoise_mixin,
openapi_mixin,
PlatformMixin(platform),
@@ -320,10 +325,12 @@ def make_user(sub: str, name: Optional[str] = None) -> OIDCUser:
@contextlib.contextmanager
def oidc_user(oidc: OIDCMixin, sub: str, name: Optional[str] = None) -> Iterator[OIDCUser]:
"""Context manager: patch ``oidc.get_user`` to return this user."""
def oidc_user(oidc: OIDCProviders, sub: str, name: Optional[str] = None) -> Iterator[OIDCUser]:
"""Context manager: make ``oidc.get_users`` return this user for the
first configured provider."""
user = make_user(sub, name)
patcher = _mock.patch.object(oidc, "get_user", return_value=user)
provider = next(iter(oidc.providers))
patcher = _mock.patch.object(oidc, "get_users", return_value={provider: user})
patcher.start()
try:
yield user
@@ -1,9 +1,9 @@
"""Tests for the auth helpers' OIDC user lookup.
"""Tests for the auth helpers' OIDC user lookup and gating.
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.
``oidc:{provider}:user`` and tavolo wires an :class:`OIDCProviders`; these
tests pin both contracts so a kaya upgrade or a provider change cannot
silently break HTTP or websocket authentication.
"""
from __future__ import annotations
@@ -11,9 +11,31 @@ import unittest
from types import SimpleNamespace
from typing import Any
from kaya.session import Session
from kaya.oidc import OIDCConfig, OIDCProviders
from kaya.session import InMemorySessionStore, Session, SessionMixin
from tavolo.platform.auth import get_ws_user, session_user
from helpers import async_test
from tavolo.platform.auth import current_user, get_ws_user, require_auth, session_user
def _providers(*names: str) -> OIDCProviders:
session = SessionMixin(InMemorySessionStore())
return OIDCProviders(
[
OIDCConfig(
provider=name,
issuer=f"http://localhost:8180/{name}",
client_id="tavolo",
redirect_uri=f"http://localhost:8080/auth/{name}/callback",
)
for name in names
],
session=session,
)
def _ctx(data: Any) -> Any:
return SimpleNamespace(session=Session("sid", data))
def _ws(session: Any) -> Any:
@@ -63,3 +85,68 @@ class GetWsUserTest(unittest.TestCase):
def test_missing_session_returns_none(self) -> None:
self.assertIsNone(get_ws_user(SimpleNamespace()))
class CurrentUserTest(unittest.TestCase):
def test_anonymous_session_has_no_user(self) -> None:
self.assertIsNone(current_user(_providers("default"), _ctx({})))
def test_returns_the_authenticated_provider_user(self) -> None:
user = current_user(
_providers("default", "google"),
_ctx({"oidc:google:user": {"sub": "alice"}}),
)
assert user is not None
self.assertEqual("alice", user.sub)
def test_first_configured_provider_wins(self) -> None:
# Single-identity sessions never hold two users; this only pins the
# tie-break should they ever coexist.
user = current_user(
_providers("default", "google"),
_ctx(
{
"oidc:default:user": {"sub": "alice"},
"oidc:google:user": {"sub": "bob"},
}
),
)
assert user is not None
self.assertEqual("alice", user.sub)
class RequireAuthTest(unittest.TestCase):
@async_test
async def test_rejects_anonymous_requests(self) -> None:
oidc = _providers("default")
ctx = _ctx({})
sent = {}
async def send_bytes(*args: Any) -> None:
sent["status"] = args[0]
ctx.send_bytes = send_bytes
handled = []
@require_auth(oidc)
async def handler(_ctx: Any) -> None:
handled.append(True)
await handler(ctx)
self.assertEqual(401, sent["status"])
self.assertEqual([], handled)
@async_test
async def test_allows_authenticated_requests(self) -> None:
oidc = _providers("default")
ctx = _ctx({"oidc:default:user": {"sub": "alice"}})
handled = []
@require_auth(oidc)
async def handler(request_ctx: Any) -> None:
user = current_user(oidc, request_ctx)
assert user is not None
handled.append(user.sub)
await handler(ctx)
self.assertEqual(["alice"], handled)
@@ -0,0 +1,67 @@
"""Tests for the provider-agnostic auth routes.
``GET /api/auth/providers`` feeds the SPA's login buttons; ``/auth/login``
and ``/auth/logout`` are the generic aliases that redirect to the right
per-provider kaya route.
"""
from __future__ import annotations
import unittest
from unittest import mock
from httpx import ASGITransport, AsyncClient
from helpers import async_test, make_platform
class AuthProvidersRouteTest(unittest.TestCase):
@async_test
async def test_lists_providers_in_configuration_order(self) -> None:
app, _platform, _tortoise = make_platform(oidc_providers=("default", "google"))
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
response = await client.get("/api/auth/providers")
self.assertEqual(200, response.status_code)
self.assertEqual(
{
"providers": [
{"name": "default", "login_url": "/auth/default/login"},
{"name": "google", "login_url": "/auth/google/login"},
]
},
response.json(),
)
class GenericAuthRedirectTest(unittest.TestCase):
@async_test
async def test_login_redirects_to_the_first_provider(self) -> None:
app, _platform, _tortoise = make_platform(oidc_providers=("google", "github"))
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
response = await client.get("/auth/login", follow_redirects=False)
self.assertEqual(302, response.status_code)
self.assertEqual("/auth/google/login", response.headers["location"])
@async_test
async def test_logout_redirects_to_the_logged_in_provider(self) -> None:
app, platform, _tortoise = make_platform()
with mock.patch.object(
platform.oidc, "logged_in_providers", return_value=["default"]
):
transport = ASGITransport(app=app)
async with AsyncClient(
transport=transport, base_url="http://127.0.0.1"
) as client:
response = await client.get("/auth/logout", follow_redirects=False)
self.assertEqual(302, response.status_code)
self.assertEqual("/auth/default/logout", response.headers["location"])
@async_test
async def test_logout_without_a_session_redirects_home(self) -> None:
app, _platform, _tortoise = make_platform()
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
response = await client.get("/auth/logout", follow_redirects=False)
self.assertEqual(302, response.status_code)
self.assertEqual("/", response.headers["location"])