- conftest: bootstrap seeds a localhost realm Config; realm_registry fixture builds the runtime registry; avatar storage redirected to a per-test tmp dir; credentials/sessions stamped with the test realm. - test_cli rewritten for the init/serve split, incl. legacy adoption. - TestServerConfig replaced by TestRealms covering the realm CRUD API, cross-realm validation, delete guards and effective-auth-host fallback. - Avatar/OIDC tests updated for per-realm providers and realm-derived URLs; obsolete PASKIA_DB path tests removed.
306 lines
10 KiB
Python
306 lines
10 KiB
Python
"""Realm registry: per-rp-id runtime state and host resolution.
|
|
|
|
A **realm** is one rp-id with its associated hosts and origins. The
|
|
registry is built from the stored combined ``Config`` at startup and
|
|
rebuilt on admin realm changes; request dispatch resolves hosts to realms
|
|
through it. The database itself is global — only the *current realm*
|
|
(passkey, site URLs, OIDC view) varies per request, tracked via a
|
|
contextvar set by the dispatch middleware.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import contextvars
|
|
import os
|
|
|
|
from fastapi_vue.hostutil import parse_endpoints
|
|
|
|
from paskia.db.structs import Config, RealmConfig
|
|
from paskia.util import hostutil
|
|
from paskia.util.constants import DEFAULT_PORT
|
|
|
|
# Maximum number of related (non-subdomain) origins per realm. WebAuthn
|
|
# Related Origin Requests require browsers to support at least 5 labels.
|
|
DEFAULT_RELATED_ORIGIN_CAP = 5
|
|
|
|
|
|
class Realm:
|
|
"""Runtime view of one realm: stored config plus derived values."""
|
|
|
|
def __init__(self, config: RealmConfig, site_url: str, site_path: str):
|
|
# Lazy import: paskia.sansio depends on paskia.db, which (via
|
|
# paskia.db.operations → paskia.oidc_notify) depends on this module.
|
|
from paskia.sansio import Passkey # noqa: PLC0415
|
|
|
|
self.config = config
|
|
self.site_url = site_url
|
|
self.site_path = site_path
|
|
self.passkey = Passkey(
|
|
rp_id=config.rp_id,
|
|
rp_name=config.rp_name,
|
|
origins=config.origins,
|
|
)
|
|
|
|
@property
|
|
def rp_id(self) -> str:
|
|
return self.config.rp_id
|
|
|
|
@property
|
|
def rp_name(self) -> str:
|
|
return self.passkey.rp_name
|
|
|
|
@property
|
|
def own_auth_host(self) -> str | None:
|
|
"""This realm's own auth host as host[:port], if configured."""
|
|
if not self.config.auth_host:
|
|
return None
|
|
return hostutil.auth_host_netloc(self.config.auth_host)
|
|
|
|
@property
|
|
def related_origins(self) -> list[str]:
|
|
"""Configured origins outside the rp-id subtree (ROR origins)."""
|
|
related = []
|
|
for origin in self.config.origins or []:
|
|
hostname = hostutil.origin_hostname(origin)
|
|
if hostname and not hostutil.is_subdomain(hostname, self.rp_id):
|
|
related.append(origin)
|
|
return related
|
|
|
|
@property
|
|
def is_root_mode(self) -> bool:
|
|
"""Whether this realm's UI lives at the site root (own auth host)."""
|
|
return self.config.auth_host is not None
|
|
|
|
@property
|
|
def ui_base_path(self) -> str:
|
|
return "/" if self.is_root_mode else "/auth/"
|
|
|
|
@property
|
|
def auth_site_url(self) -> str:
|
|
"""Base URL of this realm's auth site UI."""
|
|
return self.site_url + self.site_path
|
|
|
|
def api_url(self, path: str = "") -> str:
|
|
"""Return an absolute URL under the canonical /auth/api/ prefix."""
|
|
if not path:
|
|
return f"{self.site_url}/auth/api/"
|
|
return f"{self.site_url}/auth/api/{path.lstrip('/')}"
|
|
|
|
def reset_link_url(self, token: str) -> str:
|
|
"""Generate a reset link URL for the given token on this realm."""
|
|
return f"{self.auth_site_url}{token}"
|
|
|
|
|
|
class RealmRegistry:
|
|
"""Resolved realms and host lookup tables."""
|
|
|
|
def __init__(self, realms: list[Realm]):
|
|
self._by_rp_id = {r.rp_id: r for r in realms}
|
|
self._auth_hosts: dict[str, Realm] = {}
|
|
self._related_hosts: dict[str, Realm] = {}
|
|
for realm in realms:
|
|
if own := realm.own_auth_host:
|
|
self._auth_hosts[hostutil.normalize_host(own) or own] = realm
|
|
for origin in realm.related_origins:
|
|
if hostname := hostutil.origin_hostname(origin):
|
|
self._related_hosts[hostname] = realm
|
|
|
|
@property
|
|
def realms(self) -> list[Realm]:
|
|
"""All realms, in configuration order (first is the default)."""
|
|
return list(self._by_rp_id.values())
|
|
|
|
@property
|
|
def default(self) -> Realm:
|
|
"""The default realm (first in configuration order)."""
|
|
return next(iter(self._by_rp_id.values()))
|
|
|
|
def get(self, rp_id: str) -> Realm | None:
|
|
return self._by_rp_id.get(rp_id)
|
|
|
|
def effective_auth_host(self, realm: Realm) -> str | None:
|
|
"""Auth host serving WS/restricted APIs for a realm: its own, or the
|
|
first configured auth host (in realm order) as a shared fallback.
|
|
|
|
Returns host[:port] suitable for URL building, or None.
|
|
"""
|
|
if realm.own_auth_host:
|
|
return realm.own_auth_host
|
|
for candidate in self._by_rp_id.values():
|
|
if candidate.own_auth_host:
|
|
return candidate.own_auth_host
|
|
return None
|
|
|
|
def resolve(self, host: str | None) -> Realm | None:
|
|
"""Resolve a request Host header to a realm.
|
|
|
|
Order: exact rp-id → exact auth host → exact related-origin
|
|
hostname → longest-suffix rp-id. Unknown hosts return None.
|
|
"""
|
|
h = hostutil.normalize_host(host)
|
|
if not h:
|
|
return None
|
|
if realm := self._by_rp_id.get(h):
|
|
return realm
|
|
if realm := self._auth_hosts.get(h):
|
|
return realm
|
|
if realm := self._related_hosts.get(h):
|
|
return realm
|
|
best = None
|
|
for rp_id, realm in self._by_rp_id.items():
|
|
if h.endswith(f".{rp_id}") and (
|
|
best is None or len(rp_id) > len(best.rp_id)
|
|
):
|
|
best = realm
|
|
return best
|
|
|
|
|
|
def validate_config(
|
|
config: Config, *, related_origin_cap: int = DEFAULT_RELATED_ORIGIN_CAP
|
|
) -> None:
|
|
"""Validate a combined configuration cross-realm. Raises ValueError."""
|
|
if not config.realms:
|
|
raise ValueError("At least one realm (rp-id) is required")
|
|
|
|
rp_ids: set[str] = set()
|
|
auth_hosts: dict[str, str] = {} # normalized host -> owning rp_id
|
|
related_hosts: dict[str, str] = {} # hostname -> owning rp_id
|
|
|
|
for realm in config.realms:
|
|
hostutil.validate_rp_id(realm.rp_id)
|
|
if realm.rp_id in rp_ids:
|
|
raise ValueError(f"Duplicate rp-id '{realm.rp_id}'")
|
|
rp_ids.add(realm.rp_id)
|
|
|
|
if realm.auth_host:
|
|
hostutil.validate_auth_host(realm.auth_host, realm.rp_id)
|
|
hn = hostutil.normalize_host(
|
|
hostutil.auth_host_netloc(realm.auth_host) or ""
|
|
)
|
|
if hn:
|
|
if hn in auth_hosts:
|
|
raise ValueError(
|
|
f"auth-host '{hn}' is configured for both "
|
|
f"'{auth_hosts[hn]}' and '{realm.rp_id}'"
|
|
)
|
|
auth_hosts[hn] = realm.rp_id
|
|
|
|
related = 0
|
|
for origin in realm.origins or []:
|
|
hn = hostutil.origin_hostname(origin)
|
|
if not hn:
|
|
raise ValueError(f"Invalid origin URL: '{origin}'")
|
|
if hostutil.is_subdomain(hn, realm.rp_id):
|
|
continue # Classic subtree origin
|
|
related += 1
|
|
if hn in related_hosts:
|
|
raise ValueError(
|
|
f"Related origin host '{hn}' is configured for both "
|
|
f"'{related_hosts[hn]}' and '{realm.rp_id}'"
|
|
)
|
|
related_hosts[hn] = realm.rp_id
|
|
if related > related_origin_cap:
|
|
raise ValueError(
|
|
f"Realm '{realm.rp_id}' has {related} related origins "
|
|
f"(maximum {related_origin_cap})"
|
|
)
|
|
|
|
for hn, owner in auth_hosts.items():
|
|
if hn in rp_ids:
|
|
raise ValueError(f"auth-host '{hn}' collides with an rp-id")
|
|
if hn in related_hosts:
|
|
raise ValueError(
|
|
f"auth-host '{hn}' collides with a related origin of "
|
|
f"realm '{related_hosts[hn]}'"
|
|
)
|
|
|
|
for hn, owner in related_hosts.items():
|
|
if hn in rp_ids:
|
|
raise ValueError(f"Related origin host '{hn}' collides with an rp-id")
|
|
for other in rp_ids:
|
|
if other != owner and hostutil.is_subdomain(hn, other):
|
|
raise ValueError(
|
|
f"Related origin host '{hn}' of realm '{owner}' "
|
|
f"falls inside realm '{other}'"
|
|
)
|
|
|
|
|
|
def _derive_site(
|
|
realm: RealmConfig, *, listen_port: int | None, vite_url: str | None
|
|
) -> tuple[str, str]:
|
|
"""Compute a realm's site_url and site_path.
|
|
|
|
Priority: auth_host > origins[0] > PASKIA_VITE_URL (localhost realm
|
|
only) > http://localhost:port (localhost realm) > https://rp-id.
|
|
"""
|
|
if realm.auth_host:
|
|
return realm.auth_host, "/"
|
|
if realm.origins:
|
|
return realm.origins[0], "/auth/"
|
|
if realm.rp_id == "localhost":
|
|
if vite_url:
|
|
return vite_url.rstrip("/"), "/auth/"
|
|
if listen_port:
|
|
return f"http://localhost:{listen_port}", "/auth/"
|
|
return f"https://{realm.rp_id}", "/auth/"
|
|
|
|
|
|
_registry: RealmRegistry | None = None
|
|
_listen: list[str] | None = None
|
|
|
|
|
|
def configure(*, listen: list[str] | None = None) -> None:
|
|
"""Record process-global serve parameters for site URL derivation."""
|
|
global _listen
|
|
_listen = listen
|
|
|
|
|
|
def build(config: Config) -> RealmRegistry:
|
|
"""Validate and build a registry from a combined configuration."""
|
|
validate_config(config)
|
|
endpoint = next(iter(parse_endpoints(_listen, DEFAULT_PORT)), {})
|
|
vite_url = os.environ.get("PASKIA_VITE_URL")
|
|
realms = [
|
|
Realm(
|
|
rc,
|
|
*_derive_site(rc, listen_port=endpoint.get("port"), vite_url=vite_url),
|
|
)
|
|
for rc in config.realms
|
|
]
|
|
return RealmRegistry(realms)
|
|
|
|
|
|
def init_registry(config: Config) -> RealmRegistry:
|
|
"""Build and install the global registry from a combined configuration."""
|
|
global _registry
|
|
_registry = build(config)
|
|
return _registry
|
|
|
|
|
|
def registry() -> RealmRegistry:
|
|
"""Return the global registry (must be initialized)."""
|
|
if _registry is None:
|
|
raise RuntimeError("Realm registry is not initialized")
|
|
return _registry
|
|
|
|
|
|
_current_realm: contextvars.ContextVar[Realm | None] = contextvars.ContextVar(
|
|
"paskia_current_realm", default=None
|
|
)
|
|
|
|
|
|
def set_current_realm(realm: Realm | None) -> contextvars.Token:
|
|
return _current_realm.set(realm)
|
|
|
|
|
|
def reset_current_realm(token: contextvars.Token) -> None:
|
|
_current_realm.reset(token)
|
|
|
|
|
|
def current_realm() -> Realm:
|
|
"""Return the request's realm, or the default realm without request context."""
|
|
realm = _current_realm.get()
|
|
if realm is not None:
|
|
return realm
|
|
return registry().default
|