Support multiple OIDC providers
CI / Build and push docker image (push) Successful in 2m27s

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 20:38:51 +08:00
parent 5eccaafadc
commit a89bd06010
25 changed files with 798 additions and 119 deletions
+4 -3
View File
@@ -10,8 +10,9 @@ from __future__ import annotations
import os
os.environ.setdefault("DATABASE_URL", "sqlite://:memory:")
os.environ.setdefault("OIDC_ISSUER", "http://localhost:8180/tavolo")
os.environ.setdefault("OIDC_CLIENT_ID", "tavolo")
os.environ.setdefault("OIDC_REDIRECT_URI", "http://localhost:8080/auth/callback")
os.environ.setdefault("OIDC_0_PROVIDER", "default")
os.environ.setdefault("OIDC_0_ISSUER", "http://localhost:8180/tavolo")
os.environ.setdefault("OIDC_0_CLIENT_ID", "tavolo")
os.environ.setdefault("OIDC_0_REDIRECT_URI", "http://localhost:8080/auth/default/callback")
# Unset REDIS_URL: sessions and live games use the in-memory fallbacks.
os.environ.pop("REDIS_URL", None)
+11 -6
View File
@@ -1,8 +1,9 @@
"""Helpers for faking the OIDC authenticated user during tests.
HTTP handlers funnel through ``oidc_mixin.get_user``; websocket handlers
through :func:`tavolo.platform.auth.get_ws_user`. Patching those two entry
points lets route and websocket tests run entirely in-process with no IdP.
HTTP handlers funnel through ``OIDCProviders.get_users`` (wrapped by
``tavolo.platform.auth.current_user``); websocket handlers through
:func:`tavolo.platform.auth.get_ws_user`. Patching those two entry points
lets route and websocket tests run entirely in-process with no IdP.
"""
from __future__ import annotations
@@ -14,9 +15,13 @@ from kaya.oidc import OIDCUser
# Import the app first: it pulls in the route modules, which import
# ``tavolo.platform.auth`` themselves.
from tavolo.app import oidc_mixin
from tavolo.app import oidc
from tavolo.platform import auth
# The app's first configured provider (the test environment configures one
# named "default").
_PROVIDER = next(iter(oidc.providers))
def make_user(sub: str, name: Optional[str] = None) -> OIDCUser:
return OIDCUser({"sub": sub, "preferred_username": name or sub})
@@ -24,9 +29,9 @@ def make_user(sub: str, name: Optional[str] = None) -> OIDCUser:
@contextlib.contextmanager
def oidc_user(sub: str, name: Optional[str] = None) -> Iterator[OIDCUser]:
"""Context manager: patch ``oidc_mixin.get_user`` to return this user."""
"""Context manager: make ``oidc.get_users`` return this user."""
user = make_user(sub, name)
patcher = _mock.patch.object(oidc_mixin, "get_user", return_value=user)
patcher = _mock.patch.object(oidc, "get_users", return_value={_PROVIDER: user})
patcher.start()
try:
yield user
+135
View File
@@ -178,5 +178,140 @@ class OTelSettingsTests(unittest.TestCase):
self.assertEqual(3, settings.otel_metrics_poll_interval_seconds)
class OidcSettingsTests(unittest.TestCase):
def test_defaults_to_the_dev_mock_provider(self):
settings = _settings({})
self.assertEqual(1, len(settings.oidc_providers))
provider = settings.oidc_providers[0]
self.assertEqual("default", provider.provider)
self.assertEqual("http://localhost:8180/tavolo", provider.issuer)
self.assertEqual("tavolo", provider.client_id)
self.assertIsNone(provider.client_secret)
self.assertEqual(
"http://localhost:8080/auth/default/callback", provider.redirect_uri
)
self.assertEqual("/", provider.post_login_redirect)
self.assertEqual("/", provider.post_logout_redirect)
def test_single_indexed_provider_defaults(self):
settings = _settings({
"OIDC_0_PROVIDER": "google",
"OIDC_0_ISSUER": "https://accounts.google.com",
"OIDC_0_CLIENT_ID": "client-id",
"OIDC_0_CLIENT_SECRET": "client-secret",
})
self.assertEqual(1, len(settings.oidc_providers))
provider = settings.oidc_providers[0]
self.assertEqual("google", provider.provider)
self.assertEqual("https://accounts.google.com", provider.issuer)
self.assertEqual("client-id", provider.client_id)
self.assertEqual("client-secret", provider.client_secret)
# The callback default mirrors kaya's /auth/<provider>/callback.
self.assertEqual(
"http://localhost:8080/auth/google/callback", provider.redirect_uri
)
self.assertEqual("/", provider.post_login_redirect)
self.assertEqual("/", provider.post_logout_redirect)
def test_multiple_providers_are_ordered_by_index(self):
settings = _settings({
"OIDC_0_PROVIDER": "google",
"OIDC_0_ISSUER": "https://accounts.google.com",
"OIDC_0_CLIENT_ID": "g",
"OIDC_1_PROVIDER": "github",
"OIDC_1_ISSUER": "https://github.com/login/oauth",
"OIDC_1_CLIENT_ID": "h",
"OIDC_1_REDIRECT_URI": "https://tavolo.example/auth/github/callback",
"OIDC_1_POST_LOGIN_REDIRECT": "/lobby",
"OIDC_1_POST_LOGOUT_REDIRECT": "/bye",
})
self.assertEqual(
["google", "github"], [p.provider for p in settings.oidc_providers]
)
github = settings.oidc_providers[1]
self.assertEqual(
"https://tavolo.example/auth/github/callback", github.redirect_uri
)
self.assertEqual("/lobby", github.post_login_redirect)
self.assertEqual("/bye", github.post_logout_redirect)
def test_empty_optional_values_fall_back_to_defaults(self):
settings = _settings({
"OIDC_0_PROVIDER": "google",
"OIDC_0_ISSUER": "https://accounts.google.com",
"OIDC_0_CLIENT_ID": "g",
"OIDC_0_CLIENT_SECRET": "",
"OIDC_0_REDIRECT_URI": "",
})
provider = settings.oidc_providers[0]
self.assertIsNone(provider.client_secret)
self.assertEqual(
"http://localhost:8080/auth/google/callback", provider.redirect_uri
)
def test_missing_provider_name(self):
with self.assertRaisesRegex(RuntimeError, "OIDC_0_PROVIDER"):
_settings({"OIDC_0_ISSUER": "https://idp", "OIDC_0_CLIENT_ID": "c"})
def test_missing_issuer(self):
with self.assertRaisesRegex(RuntimeError, "OIDC_0_ISSUER"):
_settings({"OIDC_0_PROVIDER": "google", "OIDC_0_CLIENT_ID": "c"})
def test_missing_client_id(self):
with self.assertRaisesRegex(RuntimeError, "OIDC_0_CLIENT_ID"):
_settings({"OIDC_0_PROVIDER": "google", "OIDC_0_ISSUER": "https://idp"})
def test_indices_must_start_at_zero(self):
with self.assertRaisesRegex(RuntimeError, "contiguous"):
_settings({
"OIDC_3_PROVIDER": "google",
"OIDC_3_ISSUER": "https://idp",
"OIDC_3_CLIENT_ID": "c",
})
def test_indices_must_be_contiguous(self):
with self.assertRaisesRegex(RuntimeError, "OIDC_1_"):
_settings({
"OIDC_0_PROVIDER": "google",
"OIDC_0_ISSUER": "https://idp",
"OIDC_0_CLIENT_ID": "c",
"OIDC_2_PROVIDER": "github",
"OIDC_2_ISSUER": "https://idp2",
"OIDC_2_CLIENT_ID": "c2",
})
def test_duplicate_provider_names_are_rejected(self):
with self.assertRaisesRegex(RuntimeError, "duplicate"):
_settings({
"OIDC_0_PROVIDER": "google",
"OIDC_0_ISSUER": "https://idp",
"OIDC_0_CLIENT_ID": "c",
"OIDC_1_PROVIDER": "google",
"OIDC_1_ISSUER": "https://idp2",
"OIDC_1_CLIENT_ID": "c2",
})
def test_invalid_provider_name_is_rejected(self):
with self.assertRaisesRegex(RuntimeError, "OIDC_0_PROVIDER"):
_settings({
"OIDC_0_PROVIDER": "not a name",
"OIDC_0_ISSUER": "https://idp",
"OIDC_0_CLIENT_ID": "c",
})
def test_unknown_indexed_variable_is_rejected(self):
with self.assertRaisesRegex(RuntimeError, "OIDC_0_ISUER"):
_settings({
"OIDC_0_PROVIDER": "google",
"OIDC_0_ISSUER": "https://idp",
"OIDC_0_CLIENT_ID": "c",
"OIDC_0_ISUER": "typo",
})
def test_legacy_unindexed_variables_get_a_migration_hint(self):
with self.assertRaisesRegex(RuntimeError, "OIDC_ISSUER -> OIDC_0_ISSUER"):
_settings({"OIDC_ISSUER": "https://idp", "OIDC_CLIENT_ID": "c"})
if __name__ == "__main__":
unittest.main()
+35
View File
@@ -0,0 +1,35 @@
"""Auth routes on the fully wired application.
The integration suite's app is built from the indexed test environment
(one provider named ``default``); these tests pin the public surface the
SPA and bookmarks rely on.
"""
from __future__ import annotations
import unittest
from httpx import ASGITransport, AsyncClient
from tavolo.app import app
from tests.helpers import async_test
class AuthRoutesTest(unittest.TestCase):
@async_test
async def test_providers_endpoint_reports_the_configured_provider(self) -> None:
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"}]},
response.json(),
)
@async_test
async def test_generic_login_redirects_to_the_provider(self) -> None:
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/default/login", response.headers["location"])