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:
@@ -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
|
||||
```
|
||||
|
||||
|
||||
+17
-10
@@ -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_<n>_PROVIDER names it in the login URL
|
||||
# (/auth/<provider>/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://<host>:<port>/auth/callback (must be allowed at the provider).
|
||||
OIDC_REDIRECT_URI: REPLACE_ME
|
||||
# http://<host>:<port>/auth/default/callback (must be allowed at the
|
||||
# provider). When unset it defaults to
|
||||
# http://localhost:8080/auth/<provider>/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
|
||||
|
||||
+8
-6
@@ -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}
|
||||
|
||||
+23
-6
@@ -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_<n>_PROVIDER
|
||||
# (required; names the provider in the login URL /auth/<provider>/login and
|
||||
# in its session state), OIDC_<n>_ISSUER, OIDC_<n>_CLIENT_ID,
|
||||
# OIDC_<n>_CLIENT_SECRET (optional: leave unset for public clients),
|
||||
# OIDC_<n>_REDIRECT_URI (defaults to
|
||||
# http://localhost:8080/auth/<provider>/callback), OIDC_<n>_POST_LOGIN_REDIRECT
|
||||
# and OIDC_<n>_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).
|
||||
|
||||
+47
-5
@@ -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_<n>_PROVIDER` | `default` ¹ | Provider name (required when OIDC is configured): used in the login URL `/auth/<provider>/login` and to namespace its session keys. Unique, `[A-Za-z0-9_-]+` |
|
||||
| `OIDC_<n>_ISSUER` | `http://localhost:8180/tavolo` ¹ | OIDC issuer URL |
|
||||
| `OIDC_<n>_CLIENT_ID` | `tavolo` ¹ | OIDC client id |
|
||||
| `OIDC_<n>_CLIENT_SECRET` | unset | OIDC client secret; leave unset for public clients |
|
||||
| `OIDC_<n>_REDIRECT_URI` | `http://localhost:8080/auth/<provider>/callback` | Login callback URL; must be registered at the provider |
|
||||
| `OIDC_<n>_POST_LOGIN_REDIRECT` | `/` | Where the browser is sent after login |
|
||||
| `OIDC_<n>_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_<n>_*` 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/<provider>/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/<provider>/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
|
||||
|
||||
+4
-3
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"}',
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
"""Authentication plumbing for the single-page app.
|
||||
|
||||
kaya-oidc registers one login/callback/logout route per provider
|
||||
(``/auth/<provider>/...``); 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})
|
||||
@@ -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")
|
||||
|
||||
@@ -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)})
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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"])
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"])
|
||||
@@ -22,6 +22,19 @@ pub async fn me() -> Result<Option<User>, 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<Vec<AuthProvider>, 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<Vec<GameTypeInfo>, String> {
|
||||
let resp = Request::get("/api/game-types")
|
||||
|
||||
@@ -344,6 +344,19 @@ pub struct GameTypesPage {
|
||||
pub results: Vec<GameTypeInfo>,
|
||||
}
|
||||
|
||||
/// 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<AuthProvider>,
|
||||
}
|
||||
|
||||
/// 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`
|
||||
|
||||
+53
-10
@@ -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<GameTypeInfo> {
|
||||
@@ -28,6 +30,15 @@ fn fallback_game_types() -> Vec<GameTypeInfo> {
|
||||
}]
|
||||
}
|
||||
|
||||
/// 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::<String>() + 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::<Option<User>>::None);
|
||||
// Configured login providers, for the logged-out card's buttons.
|
||||
let auth_providers = create_signal(Vec::<AuthProvider>::new());
|
||||
// The player's Elo ratings, one row per game type played.
|
||||
let ratings = create_signal(Vec::<PlayerRating>::new());
|
||||
let error = create_signal(Option::<String>::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<String, serde_json::Value> =
|
||||
@@ -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::<Vec<_>>())
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user