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:
25 files changed
+798
-119
No files matched your search
@@ -6,7 +6,7 @@ platform:
|
||||
- :class:`~kaya.session.SessionMixin` (sessions persisted in Redis via
|
||||
:class:`~kaya.session.redis.RedisSessionStore` when ``REDIS_URL`` is set,
|
||||
otherwise an in-memory store — e.g. for tests)
|
||||
- :class:`~kaya.oidc.OIDCMixin` (OIDC login)
|
||||
- :class:`~kaya.oidc.OIDCProviders` (the configured OIDC providers)
|
||||
- :class:`~tavolo.platform.tortoise_mixin.TortoiseMixin` (Postgres match
|
||||
statistics; skipped for ``/api/health`` and the OpenAPI documentation
|
||||
endpoints)
|
||||
@@ -35,7 +35,7 @@ from typing import Any, Dict, Optional
|
||||
|
||||
from kaya.core import KayaApp, KayaMixin
|
||||
from kaya.cors import CorsMixin
|
||||
from kaya.oidc import OIDCConfig, OIDCMixin
|
||||
from kaya.oidc import OIDCConfig, OIDCProviders
|
||||
from kaya.openapi import OpenAPIMixin
|
||||
from kaya.session import SessionMixin
|
||||
from redis.asyncio import Redis
|
||||
@@ -156,18 +156,26 @@ else:
|
||||
log.info("REDIS_URL unset: using in-memory stores (sessions + live games)")
|
||||
|
||||
session_mixin = SessionMixin(session_store)
|
||||
oidc_mixin = OIDCMixin(
|
||||
OIDCConfig(
|
||||
issuer=settings.oidc_issuer,
|
||||
client_id=settings.oidc_client_id,
|
||||
client_secret=settings.oidc_client_secret,
|
||||
redirect_uri=settings.oidc_redirect_uri,
|
||||
post_login_redirect=settings.oidc_post_login_redirect,
|
||||
post_logout_redirect=settings.oidc_post_logout_redirect,
|
||||
fetch_userinfo=True,
|
||||
),
|
||||
oidc = OIDCProviders(
|
||||
[
|
||||
OIDCConfig(
|
||||
provider=provider.provider,
|
||||
issuer=provider.issuer,
|
||||
client_id=provider.client_id,
|
||||
client_secret=provider.client_secret,
|
||||
redirect_uri=provider.redirect_uri,
|
||||
post_login_redirect=provider.post_login_redirect,
|
||||
post_logout_redirect=provider.post_logout_redirect,
|
||||
fetch_userinfo=True,
|
||||
)
|
||||
for provider in settings.oidc_providers
|
||||
],
|
||||
session=session_mixin,
|
||||
)
|
||||
log.info(
|
||||
"OIDC providers: %s",
|
||||
", ".join(provider.provider for provider in settings.oidc_providers),
|
||||
)
|
||||
openapi_mixin = OpenAPIMixin(
|
||||
title="tavolo",
|
||||
version=_pkg_version("tavolo-app"),
|
||||
@@ -192,10 +200,10 @@ platform = Platform(
|
||||
registry=registry,
|
||||
game_store=game_store,
|
||||
scheduler=scheduler,
|
||||
oidc=oidc_mixin,
|
||||
oidc=oidc,
|
||||
)
|
||||
|
||||
mixins: list[KayaMixin] = [session_mixin, oidc_mixin, tortoise_mixin, openapi_mixin,
|
||||
mixins: list[KayaMixin] = [session_mixin, oidc, tortoise_mixin, openapi_mixin,
|
||||
PlatformMixin(platform), DeadlineSchedulerMixin(scheduler)]
|
||||
telemetry = telemetry_from_settings(settings, session_store, game_store)
|
||||
if telemetry is not None:
|
||||
|
||||
@@ -6,6 +6,7 @@ dataclass. No pydantic-settings, no settings module.
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional, Tuple
|
||||
from urllib.parse import quote
|
||||
@@ -39,6 +40,135 @@ def _env_bool(name: str, default: bool = False) -> bool:
|
||||
return value.strip().lower() in ("1", "true", "yes", "on")
|
||||
|
||||
|
||||
# Per-provider OIDC settings are indexed with a 0-based suffix:
|
||||
# OIDC_<n>_PROVIDER, OIDC_<n>_ISSUER, ... Indices must be contiguous from 0.
|
||||
_OIDC_VARIABLES = (
|
||||
"PROVIDER",
|
||||
"ISSUER",
|
||||
"CLIENT_ID",
|
||||
"CLIENT_SECRET",
|
||||
"REDIRECT_URI",
|
||||
"POST_LOGIN_REDIRECT",
|
||||
"POST_LOGOUT_REDIRECT",
|
||||
)
|
||||
_OIDC_ENV = re.compile(r"^OIDC_(\d+)_([A-Z0-9_]+)$")
|
||||
_PROVIDER_NAME = re.compile(r"^[A-Za-z0-9_-]+$")
|
||||
|
||||
# The pre-0.0.6 unindexed names, detected only to point the migration out.
|
||||
_LEGACY_OIDC_VARIABLES = (
|
||||
"OIDC_ISSUER",
|
||||
"OIDC_CLIENT_ID",
|
||||
"OIDC_CLIENT_SECRET",
|
||||
"OIDC_REDIRECT_URI",
|
||||
"OIDC_POST_LOGIN_REDIRECT",
|
||||
"OIDC_POST_LOGOUT_REDIRECT",
|
||||
)
|
||||
|
||||
_DEFAULT_OIDC_ISSUER = "http://localhost:8180/tavolo"
|
||||
_DEFAULT_OIDC_CLIENT_ID = "tavolo"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OidcProviderSettings:
|
||||
"""One OIDC provider, parsed from the ``OIDC_<n>_*`` variables.
|
||||
|
||||
``provider`` names the provider in URLs (``/auth/<provider>/login``)
|
||||
and namespaces its session state (``oidc:<provider>:...``); it must
|
||||
match kaya-oidc's ``[A-Za-z0-9_-]+`` pattern.
|
||||
"""
|
||||
|
||||
provider: str
|
||||
issuer: str
|
||||
client_id: str
|
||||
client_secret: Optional[str]
|
||||
redirect_uri: str
|
||||
post_login_redirect: str
|
||||
post_logout_redirect: str
|
||||
|
||||
|
||||
def _oidc_providers_from_env() -> Tuple[OidcProviderSettings, ...]:
|
||||
"""Parse the indexed ``OIDC_<n>_*`` provider configuration.
|
||||
|
||||
With no indexed variables at all, fall back to a single provider
|
||||
named ``default`` pointing at the dev mock IdP, mirroring the
|
||||
pre-0.0.6 defaults. The old unindexed variables are rejected with a
|
||||
migration hint: they would otherwise be silently ignored.
|
||||
"""
|
||||
legacy = [name for name in _LEGACY_OIDC_VARIABLES if os.environ.get(name)]
|
||||
if legacy:
|
||||
renamed = ", ".join(
|
||||
f"{name} -> OIDC_0_{name[len('OIDC_'):]}" for name in legacy
|
||||
)
|
||||
raise RuntimeError(
|
||||
"the OIDC variables are now indexed per provider "
|
||||
f"({renamed}; add OIDC_0_PROVIDER)"
|
||||
)
|
||||
|
||||
indices: set = set()
|
||||
for name in os.environ:
|
||||
match = _OIDC_ENV.match(name)
|
||||
if match is None:
|
||||
continue
|
||||
index, variable = match.groups()
|
||||
if variable not in _OIDC_VARIABLES:
|
||||
raise RuntimeError(
|
||||
f"unknown OIDC environment variable {name!r}; expected one of "
|
||||
+ ", ".join(f"OIDC_<n>_{v}" for v in _OIDC_VARIABLES)
|
||||
)
|
||||
indices.add(int(index))
|
||||
|
||||
if not indices:
|
||||
return (
|
||||
OidcProviderSettings(
|
||||
provider="default",
|
||||
issuer=_DEFAULT_OIDC_ISSUER,
|
||||
client_id=_DEFAULT_OIDC_CLIENT_ID,
|
||||
client_secret=None,
|
||||
redirect_uri="http://localhost:8080/auth/default/callback",
|
||||
post_login_redirect="/",
|
||||
post_logout_redirect="/",
|
||||
),
|
||||
)
|
||||
|
||||
missing = sorted(set(range(max(indices) + 1)) - indices)
|
||||
if missing:
|
||||
raise RuntimeError(
|
||||
"OIDC provider indices must be contiguous from 0; missing "
|
||||
+ ", ".join(f"OIDC_{index}_*" for index in missing)
|
||||
)
|
||||
|
||||
providers = []
|
||||
seen = set()
|
||||
for index in sorted(indices):
|
||||
prefix = f"OIDC_{index}_"
|
||||
provider = _env(prefix + "PROVIDER")
|
||||
if _PROVIDER_NAME.match(provider) is None:
|
||||
raise RuntimeError(
|
||||
f"{prefix}PROVIDER must be a non-empty string of letters, "
|
||||
f"digits, underscores or hyphens (got {provider!r})"
|
||||
)
|
||||
if provider in seen:
|
||||
raise RuntimeError(f"duplicate OIDC provider name {provider!r}")
|
||||
seen.add(provider)
|
||||
providers.append(
|
||||
OidcProviderSettings(
|
||||
provider=provider,
|
||||
issuer=_env(prefix + "ISSUER"),
|
||||
client_id=_env(prefix + "CLIENT_ID"),
|
||||
client_secret=os.environ.get(prefix + "CLIENT_SECRET") or None,
|
||||
# kaya-oidc's OIDCProviders derives the callback route from
|
||||
# the provider name; keep the default in sync with it.
|
||||
redirect_uri=_env(
|
||||
prefix + "REDIRECT_URI",
|
||||
f"http://localhost:8080/auth/{provider}/callback",
|
||||
),
|
||||
post_login_redirect=_env(prefix + "POST_LOGIN_REDIRECT", "/"),
|
||||
post_logout_redirect=_env(prefix + "POST_LOGOUT_REDIRECT", "/"),
|
||||
)
|
||||
)
|
||||
return tuple(providers)
|
||||
|
||||
|
||||
def _database_url_from_parts(engine: str,
|
||||
user: str,
|
||||
password: Optional[str],
|
||||
@@ -70,15 +200,10 @@ def _database_url_from_parts(engine: str,
|
||||
@dataclass(frozen=True)
|
||||
class Settings:
|
||||
database_url: str
|
||||
oidc_issuer: str
|
||||
oidc_client_id: str
|
||||
oidc_client_secret: Optional[str]
|
||||
oidc_redirect_uri: str
|
||||
# Where the browser is sent after login/logout. In production the SPA is
|
||||
# served by this app ("/"); in development point these at the trunk dev
|
||||
# server (e.g. "http://localhost:8000/").
|
||||
oidc_post_login_redirect: str
|
||||
oidc_post_logout_redirect: str
|
||||
# The configured OIDC providers, in index order. Each provider
|
||||
# authenticates users independently and namespaces its session state;
|
||||
# see OidcProviderSettings above.
|
||||
oidc_providers: Tuple[OidcProviderSettings, ...]
|
||||
app_host: str
|
||||
app_port: int
|
||||
redis_url: Optional[str]
|
||||
@@ -147,12 +272,7 @@ class Settings:
|
||||
# Raw DSN query string (e.g. "ssl=require"); empty = none.
|
||||
options=os.environ.get("DATABASE_OPTIONS", ""),
|
||||
),
|
||||
oidc_issuer=_env("OIDC_ISSUER", "http://localhost:8180/tavolo"),
|
||||
oidc_client_id=_env("OIDC_CLIENT_ID", "tavolo"),
|
||||
oidc_client_secret=os.environ.get("OIDC_CLIENT_SECRET"),
|
||||
oidc_redirect_uri=_env("OIDC_REDIRECT_URI", "http://localhost:8080/auth/callback"),
|
||||
oidc_post_login_redirect=_env("OIDC_POST_LOGIN_REDIRECT", "/"),
|
||||
oidc_post_logout_redirect=_env("OIDC_POST_LOGOUT_REDIRECT", "/"),
|
||||
oidc_providers=_oidc_providers_from_env(),
|
||||
app_host=_env("APP_HOST", "0.0.0.0"),
|
||||
app_port=int(_env("APP_PORT", "8080")),
|
||||
# When unset, sessions and live games fall back to in-memory
|
||||
|
||||
Reference in new issue
Block a user