diff --git a/README.md b/README.md index 475f72a..1f5a2fa 100644 --- a/README.md +++ b/README.md @@ -78,8 +78,12 @@ Trunk proxies `/api`, `/auth` and `/ws` to `127.0.0.1:8080` (see run the backend with: ```sh -OIDC_POST_LOGIN_REDIRECT=http://localhost:8000/ \ -OIDC_POST_LOGOUT_REDIRECT=http://localhost:8000/ \ +OIDC_0_PROVIDER=default \ +OIDC_0_ISSUER=http://localhost:8180/tavolo \ +OIDC_0_CLIENT_ID=tavolo \ +OIDC_0_REDIRECT_URI=http://localhost:8080/auth/default/callback \ +OIDC_0_POST_LOGIN_REDIRECT=http://localhost:8000/ \ +OIDC_0_POST_LOGOUT_REDIRECT=http://localhost:8000/ \ .venv/bin/granian --host 127.0.0.1 --port 8080 tavolo.app:app ``` diff --git a/deploy/k8s/tavolo.yaml b/deploy/k8s/tavolo.yaml index 3618df5..ba90b37 100644 --- a/deploy/k8s/tavolo.yaml +++ b/deploy/k8s/tavolo.yaml @@ -21,7 +21,7 @@ # - This file deploys a ClusterIP Service only. Until you add an Ingress, # reach the app with: # kubectl port-forward -n tavolo svc/tavolo 8080:80 -# and set OIDC_REDIRECT_URI to match whatever URL the browser uses. +# and set OIDC_0_REDIRECT_URI to match whatever URL the browser uses. --- apiVersion: v1 @@ -81,15 +81,22 @@ data: # OTEL_EXCLUDED_PATHS: "/api/health" # default; paths skipped by tracing # OTEL_METRIC_EXPORT_INTERVAL_MILLIS: "60000" # OTEL_METRICS_POLL_INTERVAL_SECONDS: "15" - # OIDC (provider lives in another namespace). - OIDC_CLIENT_ID: tavolo - OIDC_POST_LOGIN_REDIRECT: / - OIDC_POST_LOGOUT_REDIRECT: / + # OIDC (the provider lives in another namespace). Each provider is an + # indexed block: OIDC__PROVIDER names it in the login URL + # (/auth//login) and indices must be contiguous from 0. Add + # OIDC_1_*, OIDC_2_*, ... blocks for more providers; add their secrets + # to tavolo-secrets as well. + OIDC_0_PROVIDER: default + OIDC_0_CLIENT_ID: tavolo + OIDC_0_POST_LOGIN_REDIRECT: / + OIDC_0_POST_LOGOUT_REDIRECT: / # Issuer URL as seen by the browser AND the pods (see notes above). - OIDC_ISSUER: REPLACE_ME + OIDC_0_ISSUER: REPLACE_ME # Public callback URL of this deployment, e.g. - # http://:/auth/callback (must be allowed at the provider). - OIDC_REDIRECT_URI: REPLACE_ME + # http://:/auth/default/callback (must be allowed at the + # provider). When unset it defaults to + # http://localhost:8080/auth//callback. + OIDC_0_REDIRECT_URI: REPLACE_ME --- apiVersion: v1 @@ -106,8 +113,8 @@ stringData: # The only Postgres secret: the password for DATABASE_USER at # DATABASE_HOST (both configured in the tavolo-config ConfigMap). DATABASE_PASSWORD: REPLACE_ME - # Client secret for OIDC_CLIENT_ID at the provider. - OIDC_CLIENT_SECRET: REPLACE_ME + # Client secret for OIDC_0_CLIENT_ID at the provider. + OIDC_0_CLIENT_SECRET: REPLACE_ME --- # Redis: sessions + live game state. Ephemeral by design (emptyDir): worst diff --git a/docker-compose.yml b/docker-compose.yml index c4a55f6..bd58828 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -103,13 +103,15 @@ services: DATABASE_USER: tavolo DATABASE_PASSWORD: ${DATABASE_PASSWORD:-password} # By default the app and browsers reach the mock IdP under the same - # name (see README /etc/hosts note); override OIDC_ISSUER and - # OIDC_REDIRECT_URI to use a real provider or a different host port. - OIDC_ISSUER: ${OIDC_ISSUER:-http://mockoauth:8180/tavolo} + # name (see README /etc/hosts note); override the OIDC_0_* values to + # use a real provider or a different host port. Additional providers + # take OIDC_1_*, OIDC_2_*, ... blocks (indices are contiguous from 0). + OIDC_0_PROVIDER: ${OIDC_0_PROVIDER:-default} + OIDC_0_ISSUER: ${OIDC_0_ISSUER:-http://mockoauth:8180/tavolo} # The mock OIDC server does not validate clients: any id/secret works. - OIDC_CLIENT_ID: tavolo - OIDC_CLIENT_SECRET: ${OIDC_CLIENT_SECRET:-dev-secret} - OIDC_REDIRECT_URI: ${OIDC_REDIRECT_URI:-http://localhost:8080/auth/callback} + OIDC_0_CLIENT_ID: tavolo + OIDC_0_CLIENT_SECRET: ${OIDC_0_CLIENT_SECRET:-dev-secret} + OIDC_0_REDIRECT_URI: ${OIDC_0_REDIRECT_URI:-http://localhost:8080/auth/default/callback} REDIS_URL: redis://redis:6379/0 HAND_ACK_TIMEOUT_SECONDS: ${HAND_ACK_TIMEOUT_SECONDS:-30} TURN_TIMEOUT_SECONDS: ${TURN_TIMEOUT_SECONDS:-30} diff --git a/server/.env.example b/server/.env.example index d23f701..4301628 100644 --- a/server/.env.example +++ b/server/.env.example @@ -13,12 +13,29 @@ DATABASE_OPTIONS= # test suite (sqlite://:memory:) and handy for managed-DB URLs. #DATABASE_URL=postgres://tavolo:password@localhost:5432/tavolo -# OIDC (mock-oauth2-server in dev; it does not validate clients, so any -# client id/secret works. For a real IdP like Keycloak, use its values here.) -OIDC_ISSUER=http://localhost:8180/tavolo -OIDC_CLIENT_ID=tavolo -OIDC_CLIENT_SECRET=dev-secret -OIDC_REDIRECT_URI=http://localhost:8080/auth/callback +# OIDC providers. Each provider is an indexed block: OIDC__PROVIDER +# (required; names the provider in the login URL /auth//login and +# in its session state), OIDC__ISSUER, OIDC__CLIENT_ID, +# OIDC__CLIENT_SECRET (optional: leave unset for public clients), +# OIDC__REDIRECT_URI (defaults to +# http://localhost:8080/auth//callback), OIDC__POST_LOGIN_REDIRECT +# and OIDC__POST_LOGOUT_REDIRECT (default /). Indices must be contiguous +# from 0; add a second provider with an OIDC_1_* block (and so on). +# +# The values below target the dev mock-oauth2-server (it does not validate +# clients, so any client id/secret works). For a real IdP like Keycloak, +# use its values and register the redirect URI there. +OIDC_0_PROVIDER=default +OIDC_0_ISSUER=http://localhost:8180/tavolo +OIDC_0_CLIENT_ID=tavolo +OIDC_0_CLIENT_SECRET=dev-secret +OIDC_0_REDIRECT_URI=http://localhost:8080/auth/default/callback +# A second provider, e.g. Google: +#OIDC_1_PROVIDER=google +#OIDC_1_ISSUER=https://accounts.google.com +#OIDC_1_CLIENT_ID=... +#OIDC_1_CLIENT_SECRET=... +#OIDC_1_REDIRECT_URI=http://localhost:8080/auth/google/callback # Redis (session and live-game storage). Unset to fall back to in-memory # stores (only sensible for local development with a single worker). diff --git a/server/README.md b/server/README.md index 94cec65..dec9e6f 100644 --- a/server/README.md +++ b/server/README.md @@ -43,7 +43,8 @@ Because the browser and the app both talk to the OIDC issuer at echo "127.0.0.1 mockoauth" | sudo tee -a /etc/hosts ``` -Then log in: `GET http://localhost:8080/auth/login`, type any username +Then log in: `GET http://localhost:8080/auth/login` (a provider-agnostic +alias that redirects to the first configured provider), type any username (e.g. `alice`) in the interactive login, and you are redirected back with a session cookie. That cookie authenticates both the REST API and the WebSocket endpoint. @@ -65,10 +66,13 @@ All configuration comes from environment variables (see `.env.example`): | `DATABASE_OPTIONS` | unset | Extra DSN query parameters, e.g. `ssl=require` | | `DATABASE_URL` | unset | Full-DSN override; when set, the `DATABASE_*` parts above are ignored (used for sqlite in tests and for managed-DB URLs) | | `REDIS_URL` | unset | Redis DSN for sessions + live games. Unset falls back to in-memory stores | -| `OIDC_ISSUER` | `http://localhost:8180/tavolo` | OIDC issuer URL | -| `OIDC_CLIENT_ID` | `tavolo` | OIDC client id | -| `OIDC_CLIENT_SECRET` | unset | OIDC client secret | -| `OIDC_REDIRECT_URI` | `http://localhost:8080/auth/callback` | Login callback URL | +| `OIDC__PROVIDER` | `default` ¹ | Provider name (required when OIDC is configured): used in the login URL `/auth//login` and to namespace its session keys. Unique, `[A-Za-z0-9_-]+` | +| `OIDC__ISSUER` | `http://localhost:8180/tavolo` ¹ | OIDC issuer URL | +| `OIDC__CLIENT_ID` | `tavolo` ¹ | OIDC client id | +| `OIDC__CLIENT_SECRET` | unset | OIDC client secret; leave unset for public clients | +| `OIDC__REDIRECT_URI` | `http://localhost:8080/auth//callback` | Login callback URL; must be registered at the provider | +| `OIDC__POST_LOGIN_REDIRECT` | `/` | Where the browser is sent after login | +| `OIDC__POST_LOGOUT_REDIRECT` | `/` | Where the browser is sent after logout | | `GAME_TTL_SECONDS` | `86400` | Sliding TTL of a live game in Redis | | `HAND_ACK_TIMEOUT_SECONDS` | `30` | Seconds the scopone between-hands scoring summary waits for acknowledgements (wired into the `ScoponeEngine`) | | `TURN_TIMEOUT_SECONDS` | `30` | Seconds a scopone player has to play before the server plays a random legal card for them (wired into the `ScoponeEngine`) | @@ -90,6 +94,44 @@ All configuration comes from environment variables (see `.env.example`): | `OTEL_METRICS_POLL_INTERVAL_SECONDS` | `15` | How often the session/game count gauges are refreshed | | `APP_HOST` / `APP_PORT` | `0.0.0.0` / `8080` | Bind address | +¹ When no `OIDC__*` variable is set at all, a single provider named +`default` pointing at the dev mock IdP is used. Indices must be contiguous +from 0 (`OIDC_0_*`, `OIDC_1_*`, ...). + +### Multiple OIDC providers + +Providers are configured in indexed blocks; each becomes its own login +route `/auth//login` with its own callback URL and session +namespace: + +```sh +OIDC_0_PROVIDER=google +OIDC_0_ISSUER=https://accounts.google.com +OIDC_0_CLIENT_ID=... +OIDC_0_CLIENT_SECRET=... +OIDC_0_REDIRECT_URI=https://tavolo.example.com/auth/google/callback + +OIDC_1_PROVIDER=github +OIDC_1_ISSUER=https://github.com/login/oauth +OIDC_1_CLIENT_ID=... +OIDC_1_CLIENT_SECRET=... +OIDC_1_REDIRECT_URI=https://tavolo.example.com/auth/github/callback +``` + +The login page reads `GET /api/auth/providers` and shows one button per +provider. `GET /auth/login` redirects to the first provider (useful for +bookmarks and scripts), and `GET /auth/logout` signs out whichever +provider the session is logged in with. + +Sessions hold a single identity: logging in with another provider +replaces the previous one. Each provider's `sub` is a separate account, +so match history and Elo ratings are per provider identity (someone using +two providers has two accounts). Migrating from the pre-0.0.6 unindexed +variables: rename `OIDC_ISSUER`/`OIDC_CLIENT_ID`/... to `OIDC_0_...`, add +`OIDC_0_PROVIDER`, and register the new callback path +(`/auth//callback`) at the provider. Sessions survive only when +the provider is named `default`; otherwise users log in once more. + ## Logging The application logs through the Python stdlib `logging` module, one diff --git a/server/conftest.py b/server/conftest.py index a85c7bd..c72207a 100644 --- a/server/conftest.py +++ b/server/conftest.py @@ -10,7 +10,8 @@ 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") os.environ.pop("REDIS_URL", None) diff --git a/server/packages/tavolo-app/src/tavolo/app.py b/server/packages/tavolo-app/src/tavolo/app.py index 55e8194..da2f90d 100644 --- a/server/packages/tavolo-app/src/tavolo/app.py +++ b/server/packages/tavolo-app/src/tavolo/app.py @@ -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: diff --git a/server/packages/tavolo-app/src/tavolo/config.py b/server/packages/tavolo-app/src/tavolo/config.py index 8efab05..ea1dd12 100644 --- a/server/packages/tavolo-app/src/tavolo/config.py +++ b/server/packages/tavolo-app/src/tavolo/config.py @@ -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__PROVIDER, OIDC__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__*`` variables. + + ``provider`` names the provider in URLs (``/auth//login``) + and namespaces its session state (``oidc::...``); 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__*`` 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__{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 diff --git a/server/packages/tavolo-platform/pyproject.toml b/server/packages/tavolo-platform/pyproject.toml index 85ba8d5..9f35d29 100644 --- a/server/packages/tavolo-platform/pyproject.toml +++ b/server/packages/tavolo-platform/pyproject.toml @@ -11,7 +11,7 @@ requires-python = ">=3.10" dependencies = [ "kaya-core>=0.0.6", "kaya-session", - "kaya-oidc", + "kaya-oidc>=0.0.6", "kaya-openapi", "tortoise-orm", "redis", diff --git a/server/packages/tavolo-platform/src/tavolo/platform/auth.py b/server/packages/tavolo-platform/src/tavolo/platform/auth.py index d9d4eca..7c85b20 100644 --- a/server/packages/tavolo-platform/src/tavolo/platform/auth.py +++ b/server/packages/tavolo-platform/src/tavolo/platform/auth.py @@ -9,7 +9,7 @@ from __future__ import annotations from typing import Any, Callable, Mapping, Optional from kaya.core import HttpContext, WebSocket -from kaya.oidc import OIDCMixin, OIDCUser +from kaya.oidc import OIDCProviders, OIDCUser from kaya.session import Session # kaya-oidc namespaces its session state per provider as @@ -55,7 +55,16 @@ def display_name(user: OIDCUser) -> str: return user.sub -def require_auth(oidc: OIDCMixin) -> Callable[[Callable[..., Any]], Callable[..., Any]]: +def current_user(oidc: OIDCProviders, ctx: HttpContext) -> Optional[OIDCUser]: + """Return the authenticated user of an HTTP request, if any. + + Sessions are single-identity by default, so at most one provider has + a user; if several do, the first configured provider wins. + """ + return next(iter(oidc.get_users(ctx).values()), None) + + +def require_auth(oidc: OIDCProviders) -> Callable[[Callable[..., Any]], Callable[..., Any]]: """Build a decorator gating a handler on being authenticated. Responds ``401`` with a JSON error envelope when unauthenticated — @@ -65,7 +74,7 @@ def require_auth(oidc: OIDCMixin) -> Callable[[Callable[..., Any]], Callable[... def decorator(handler: Callable[..., Any]) -> Callable[..., Any]: async def guarded(ctx: HttpContext, *args: Any, **kwargs: Any) -> None: - if oidc.get_user(ctx) is None: + if current_user(oidc, ctx) is None: await ctx.send_bytes( 401, b'{"error":"unauthenticated"}', diff --git a/server/packages/tavolo-platform/src/tavolo/platform/mixin.py b/server/packages/tavolo-platform/src/tavolo/platform/mixin.py index 011f409..2a83cf0 100644 --- a/server/packages/tavolo-platform/src/tavolo/platform/mixin.py +++ b/server/packages/tavolo-platform/src/tavolo/platform/mixin.py @@ -2,7 +2,7 @@ ``Platform`` bundles the collaborators every platform endpoint needs — the game registry, the live-game store, the deadline scheduler and the -OIDC mixin used for authentication — so route and websocket modules +OIDC providers used for authentication — so route and websocket modules never reach for module-global state. :class:`PlatformMixin` is a plain :class:`~kaya.core.KayaMixin`: applying it registers the lobby, stats and health HTTP routes plus the live-play websocket endpoint, and the @@ -14,7 +14,7 @@ from __future__ import annotations from dataclasses import dataclass from kaya.core import KayaApp, KayaMixin -from kaya.oidc import OIDCMixin +from kaya.oidc import OIDCProviders from .deadlines import DeadlineScheduler from .registry import GameRegistry @@ -28,7 +28,7 @@ class Platform: registry: GameRegistry game_store: GameStore scheduler: DeadlineScheduler - oidc: OIDCMixin + oidc: OIDCProviders class PlatformMixin(KayaMixin): @@ -41,9 +41,10 @@ class PlatformMixin(KayaMixin): # Imported here so module import order stays acyclic: the route # modules reference Platform only for typing. from . import ws - from .routes import games, health, me, stats + from .routes import auth, games, health, me, stats health.register(app, self.platform) + auth.register(app, self.platform) me.register(app, self.platform) games.register(app, self.platform) stats.register(app, self.platform) diff --git a/server/packages/tavolo-platform/src/tavolo/platform/routes/auth.py b/server/packages/tavolo-platform/src/tavolo/platform/routes/auth.py new file mode 100644 index 0000000..f0d9cfd --- /dev/null +++ b/server/packages/tavolo-platform/src/tavolo/platform/routes/auth.py @@ -0,0 +1,62 @@ +"""Authentication plumbing for the single-page app. + +kaya-oidc registers one login/callback/logout route per provider +(``/auth//...``); this module adds the provider-agnostic +pieces: + +* ``GET /api/auth/providers`` lists the configured providers with their + login URLs, so the SPA can offer a choice without hardcoding names; +* ``GET /auth/login`` redirects to the first configured provider; +* ``GET /auth/logout`` redirects to the provider this session is logged + in with (or the SPA root when not logged in), so the UI can keep a + single logout link. +""" +from __future__ import annotations + +from typing import TYPE_CHECKING + +from kaya.core import HttpContext, KayaApp +from kaya.openapi import operation + +from ..http import send_json + +if TYPE_CHECKING: + from ..mixin import Platform + + +def _login_url(platform: "Platform", provider: str) -> str: + return f"{platform.oidc.base_path}/{provider}/login" + + +def register(app: KayaApp, platform: "Platform") -> None: + providers = list(platform.oidc.providers) + + @app.GET("/api/auth/providers") + @operation(summary="Configured login providers", + description="Names and login URLs of the configured OIDC " + "providers, in configuration order.", + tags=["auth"], + responses={200: {"description": "The configured providers"}}) + async def auth_providers(ctx: HttpContext) -> None: + await send_json( + ctx, + 200, + { + "providers": [ + {"name": name, "login_url": _login_url(platform, name)} + for name in providers + ] + }, + ) + + @app.GET("/auth/login") + async def login(ctx: HttpContext) -> None: + await ctx.send_empty(302, {"Location": _login_url(platform, providers[0])}) + + @app.GET("/auth/logout") + async def logout(ctx: HttpContext) -> None: + logged_in = platform.oidc.logged_in_providers(ctx) + location = ( + f"{platform.oidc.base_path}/{logged_in[0]}/logout" if logged_in else "/" + ) + await ctx.send_empty(302, {"Location": location}) diff --git a/server/packages/tavolo-platform/src/tavolo/platform/routes/games.py b/server/packages/tavolo-platform/src/tavolo/platform/routes/games.py index 7a237ed..792f30f 100644 --- a/server/packages/tavolo-platform/src/tavolo/platform/routes/games.py +++ b/server/packages/tavolo-platform/src/tavolo/platform/routes/games.py @@ -21,7 +21,7 @@ from typing import TYPE_CHECKING, Any, Dict from kaya.core import HttpContext, KayaApp from kaya.openapi import operation -from ..auth import display_name, require_auth +from ..auth import current_user, display_name, require_auth from ..engine import GameSession, Seat from ..errors import GameError from ..http import ( @@ -183,7 +183,7 @@ def register(app: KayaApp, platform: "Platform") -> None: await send_error(ctx, 400, "options must be an object") return - user = platform.oidc.get_user(ctx) + user = current_user(platform.oidc, ctx) assert user is not None # enforced by @require_auth session = GameSession( id=str(uuid.uuid4()), @@ -244,7 +244,7 @@ def register(app: KayaApp, platform: "Platform") -> None: await send_error(ctx, 400, "code is required") return - user = platform.oidc.get_user(ctx) + user = current_user(platform.oidc, ctx) assert user is not None existing = await platform.game_store.find_by_code(code) if existing is None: @@ -325,7 +325,7 @@ def register(app: KayaApp, platform: "Platform") -> None: if session is None: await send_error(ctx, 404, "game not found") return - user = platform.oidc.get_user(ctx) + user = current_user(platform.oidc, ctx) assert user is not None if not session.seated(user.sub): await send_error(ctx, 403, "forbidden") diff --git a/server/packages/tavolo-platform/src/tavolo/platform/routes/me.py b/server/packages/tavolo-platform/src/tavolo/platform/routes/me.py index 6f37899..d53e04a 100644 --- a/server/packages/tavolo-platform/src/tavolo/platform/routes/me.py +++ b/server/packages/tavolo-platform/src/tavolo/platform/routes/me.py @@ -6,7 +6,7 @@ from typing import TYPE_CHECKING from kaya.core import HttpContext, KayaApp from kaya.openapi import operation -from ..auth import display_name, require_auth +from ..auth import current_user, display_name, require_auth from ..http import send_json if TYPE_CHECKING: @@ -25,6 +25,6 @@ def register(app: KayaApp, platform: "Platform") -> None: }) @require_auth(platform.oidc) async def me(ctx: HttpContext) -> None: - user = platform.oidc.get_user(ctx) + user = current_user(platform.oidc, ctx) assert user is not None # enforced by @require_auth await send_json(ctx, 200, {"sub": user.sub, "name": display_name(user)}) diff --git a/server/packages/tavolo-platform/src/tavolo/platform/routes/stats.py b/server/packages/tavolo-platform/src/tavolo/platform/routes/stats.py index d28884f..d416e54 100644 --- a/server/packages/tavolo-platform/src/tavolo/platform/routes/stats.py +++ b/server/packages/tavolo-platform/src/tavolo/platform/routes/stats.py @@ -15,7 +15,7 @@ from uuid import UUID from kaya.core import HttpContext, KayaApp from kaya.openapi import operation -from ..auth import require_auth +from ..auth import current_user, require_auth from ..elo import INITIAL_RATING from ..http import extract_query_params, send_error, send_json from ..models import Match, MatchPlayer, PlayerRating @@ -104,7 +104,7 @@ def register(app: KayaApp, platform: "Platform") -> None: if error is not None: await send_error(ctx, 400, error) return - user = platform.oidc.get_user(ctx) + user = current_user(platform.oidc, ctx) assert user is not None queryset = Match.filter(players__user_sub=user.sub).distinct() if game_type is not None: @@ -193,7 +193,7 @@ def register(app: KayaApp, platform: "Platform") -> None: }) @require_auth(platform.oidc) async def my_ratings(ctx: HttpContext) -> None: - user = platform.oidc.get_user(ctx) + user = current_user(platform.oidc, ctx) assert user is not None rows = await PlayerRating.filter(user_sub=user.sub).order_by("game_type") await send_json(ctx, 200, { diff --git a/server/packages/tavolo-platform/tests/helpers.py b/server/packages/tavolo-platform/tests/helpers.py index 47133b7..06841cf 100644 --- a/server/packages/tavolo-platform/tests/helpers.py +++ b/server/packages/tavolo-platform/tests/helpers.py @@ -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 diff --git a/server/packages/tavolo-platform/tests/test_auth.py b/server/packages/tavolo-platform/tests/test_auth.py index 21f6a40..daf3ffe 100644 --- a/server/packages/tavolo-platform/tests/test_auth.py +++ b/server/packages/tavolo-platform/tests/test_auth.py @@ -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) diff --git a/server/packages/tavolo-platform/tests/test_routes_auth.py b/server/packages/tavolo-platform/tests/test_routes_auth.py new file mode 100644 index 0000000..9e0ba4e --- /dev/null +++ b/server/packages/tavolo-platform/tests/test_routes_auth.py @@ -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"]) diff --git a/server/tests/__init__.py b/server/tests/__init__.py index 99fac83..09d06bd 100644 --- a/server/tests/__init__.py +++ b/server/tests/__init__.py @@ -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) diff --git a/server/tests/helpers/oidc.py b/server/tests/helpers/oidc.py index 8a5c5c6..8f866f0 100644 --- a/server/tests/helpers/oidc.py +++ b/server/tests/helpers/oidc.py @@ -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 diff --git a/server/tests/test_config.py b/server/tests/test_config.py index 3cdcdd4..3f02d29 100644 --- a/server/tests/test_config.py +++ b/server/tests/test_config.py @@ -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//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() diff --git a/server/tests/test_routes_auth.py b/server/tests/test_routes_auth.py new file mode 100644 index 0000000..8863a6d --- /dev/null +++ b/server/tests/test_routes_auth.py @@ -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"]) diff --git a/web/src/api.rs b/web/src/api.rs index 13dd9cf..3c13b0c 100644 --- a/web/src/api.rs +++ b/web/src/api.rs @@ -22,6 +22,19 @@ pub async fn me() -> Result, String> { resp.json().await.map(Some).map_err(|e| e.to_string()) } +/// Fetch the configured OIDC login providers (public endpoint). +pub async fn auth_providers() -> Result, String> { + let resp = Request::get("/api/auth/providers") + .send() + .await + .map_err(|e| e.to_string())?; + if !resp.ok() { + return Err(server_error(resp.status())); + } + let page: AuthProvidersPage = resp.json().await.map_err(|e| e.to_string())?; + Ok(page.providers) +} + /// Fetch the card games the platform can host (for the creation dropdown). pub async fn game_types() -> Result, String> { let resp = Request::get("/api/game-types") diff --git a/web/src/model.rs b/web/src/model.rs index 9010bb2..3060a14 100644 --- a/web/src/model.rs +++ b/web/src/model.rs @@ -344,6 +344,19 @@ pub struct GameTypesPage { pub results: Vec, } +/// One configured OIDC login provider (GET /api/auth/providers). +#[derive(Debug, Clone, PartialEq, Deserialize)] +pub struct AuthProvider { + pub name: String, + pub login_url: String, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct AuthProvidersPage { + #[serde(default)] + pub providers: Vec, +} + /// A join-code lookup result (GET /api/games/preview): the game's lobby /// payload plus its display name, for the pre-join summary. The options /// echoed by the engine land in `details` under their `options_schema` diff --git a/web/src/pages/lobby.rs b/web/src/pages/lobby.rs index f591310..0eea1b6 100644 --- a/web/src/pages/lobby.rs +++ b/web/src/pages/lobby.rs @@ -14,7 +14,9 @@ use sycamore_router::navigate; use crate::api; use crate::components::card::card_back; use crate::components::toast::toast; -use crate::model::{GameTypeInfo, JoinPreview, OptionField, OptionKind, PlayerRating, User}; +use crate::model::{ + AuthProvider, GameTypeInfo, JoinPreview, OptionField, OptionKind, PlayerRating, User, +}; /// Used when the game-types fetch fails: match creation must still work. fn fallback_game_types() -> Vec { @@ -28,6 +30,15 @@ fn fallback_game_types() -> Vec { }] } +/// Human-readable label for a provider name ("google" -> "Google"). +fn provider_label(name: &str) -> String { + let mut chars = name.chars(); + match chars.next() { + Some(first) => first.to_uppercase().collect::() + chars.as_str(), + None => String::new(), + } +} + fn json_to_string(value: &serde_json::Value) -> String { match value { serde_json::Value::String(s) => s.clone(), @@ -283,6 +294,8 @@ fn join_preview_panel( pub fn LobbyPage() -> View { // Outer None = still loading; Some(None) = logged out. let user = create_signal(Option::>::None); + // Configured login providers, for the logged-out card's buttons. + let auth_providers = create_signal(Vec::::new()); // The player's Elo ratings, one row per game type played. let ratings = create_signal(Vec::::new()); let error = create_signal(Option::::None); @@ -400,6 +413,12 @@ pub fn LobbyPage() -> View { } }); + spawn_local(async move { + if let Ok(list) = api::auth_providers().await { + auth_providers.set(list); + } + }); + let on_create = move |_| { let game_type = selected_game.get_clone(); let opts: serde_json::Map = @@ -462,15 +481,39 @@ pub fn LobbyPage() -> View { } p(class="text-secondary") { "Log in with your account to play." } // rel="external" opts out of the SPA router's click - // interception: this must be a full page navigation - // to the backend's OIDC login endpoint. - a( - class="btn btn-warning btn-lg w-100", - href="/auth/login", - rel="external", - ) { - "Log in" - } + // interception: these must be full page navigations + // to the backend's OIDC login endpoints. + (move || { + let providers = auth_providers.get_clone(); + if providers.is_empty() { + // Providers could not be fetched: the + // generic route redirects to the first + // configured provider. + view! { + a( + class="btn btn-warning btn-lg w-100", + href="/auth/login", + rel="external", + ) { + "Log in" + } + } + } else { + view! { + div(class="d-grid gap-2") { + (providers.iter().cloned().map(|p| view! { + a( + class="btn btn-warning btn-lg w-100", + href=p.login_url, + rel="external", + ) { + "Log in with " (provider_label(&p.name)) + } + }).collect::>()) + } + } + } + }) } } }