Files
paskia/paskia/domains.py
T
LeoVasanko 84985501f5 MultiSite: one instance serves authentication across many domains (#4)
- Serve multiple domains (RP IDs) from one instance: host-based dispatch,
  per-domain credentials and sessions, domains managed at runtime in the
  admin UI — previously one RP per instance
- Cross-domain sign-in via Related Origin Requests: per-domain related-origins
  list with a served .well-known/webauthn document
- Explicit per-domain origin lists with shell-glob wildcards (**. for apex +
  any subdomain depth, *. for one level), editable in the admin UI with
  validation and self-lockout guards
- Per-domain auth hosts: the account/admin UI can live on a different host
  per domain, no longer confined to subdomains of a single RP
- CLI: 'paskia init <rp-id [rp-name]' initializes or adds a domain to an
  existing database; 'paskia migrate' converts legacy databases

BREAKING CHANGES (v2.0):
- Database schema: config is now per-domain and credentials/sessions carry
  an rp_id — existing databases must be converted with 'paskia migrate'
- Origins are now explicit: main implicitly allowed every subdomain of the
  RP; configure '**.' origins to reproduce that behavior
- CLI: the flat '--rp-id/--rp-name/--origin/--auth/--save' flags are
  replaced by the 'init' and 'migrate' subcommandsReviewed-on: #4
2026-09-07 22:14:42 +00:00

540 lines
20 KiB
Python

"""Domain registry: per-rp-id runtime state and host resolution.
A **domain** 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 domain changes; request dispatch resolves hosts to
domains through it. The database itself is global — only the *current
domain* (passkey, site URLs) varies per request, tracked via a
contextvar set by the dispatch middleware.
Each domain's ``origins`` table holds both in-domain sign-in sites and
related origins (ROR), classified by the rp-id: entries within the rp-id
domain are in-domain, entries outside it are related.
"""
from __future__ import annotations
import contextvars
import logging
import os
from fastapi_vue.hostutil import parse_endpoints
from paskia.db.structs import Config, DomainConfig, OriginEntry
from paskia.sansio import Passkey
from paskia.util import hostutil
from paskia.util.constants import DEFAULT_PORT
logger = logging.getLogger(__name__)
# Maximum number of related (non-subdomain) origins per domain. WebAuthn
# Related Origin Requests require browsers to support at least 5 labels.
DEFAULT_RELATED_ORIGIN_CAP = 5
def origin_url(key: str) -> str:
"""URL form of an origins-table key (https:// is implied); wildcards
pass through unchanged."""
if hostutil.is_wildcard_pattern(key) or "://" in key:
return key
return f"https://{key}"
def origin_key(origin: str) -> str:
"""Origins-table key for a full origin URL (https:// omitted).
Keys are canonicalized: lowercased, and bare hosts/wildcards lose any
trailing dot.
"""
key = origin.removeprefix("https://").rstrip("/")
if hostutil.is_wildcard_pattern(key):
prefix = "**." if key.startswith("**.") else "*."
return prefix + key[len(prefix) :].rstrip(".").lower()
if "://" not in key:
key = key.rstrip(".")
return key.lower()
def is_related_key(rp_id: str, key: str) -> bool:
"""Whether an origins-table key lies outside the rp-id domain (a
related origin). Wildcards are never related."""
if hostutil.is_wildcard_pattern(key):
return False
hn = hostutil.origin_hostname(origin_url(key))
return bool(hn) and not hostutil.is_subdomain(hn, rp_id)
def partition_origins(
rp_id: str, origins: dict[str, bool | OriginEntry]
) -> tuple[list[str], list[str]]:
"""Split an origins table into (in-domain keys, related keys)."""
in_domain = [k for k in origins if not is_related_key(rp_id, k)]
related = [k for k in origins if is_related_key(rp_id, k)]
return in_domain, related
def auth_host_url(domain: DomainConfig) -> str | None:
"""Full URL of the domain's auth host origin, if one is marked."""
for key, props in domain.origins.items():
if isinstance(props, OriginEntry) and props.auth_host:
return origin_url(key)
return None
class Domain:
"""Runtime view of one domain: stored config plus derived values."""
def __init__(self, rp_id: str, config: DomainConfig, site_url: str, site_path: str):
in_domain, related = partition_origins(rp_id, config.origins)
self.rp_id = rp_id
self.config = config
self.site_url = site_url
self.site_path = site_path
self.passkey = Passkey(
rp_id=rp_id,
rp_name=config.rp_name,
origins=[origin_url(k) for k in in_domain],
related_origins=[origin_url(k) for k in related],
)
@property
def rp_name(self) -> str:
return self.passkey.rp_name
@property
def own_auth_host(self) -> str | None:
"""This domain's own auth host as host[:port], if configured."""
url = auth_host_url(self.config)
return hostutil.auth_host_netloc(url) if url else None
@property
def related_origins(self) -> list[str]:
"""Related (cross-domain) origins for ROR, as URLs."""
return sorted(self.passkey.related_origins)
@property
def ui_base_path(self) -> str:
"""UI base path: site root on an own auth host, /auth/ elsewhere."""
return "/" if auth_host_url(self.config) is not None else "/auth/"
@property
def auth_site_url(self) -> str:
"""Base URL of this domain'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 domain."""
return f"{self.auth_site_url}{token}"
class DomainRegistry:
"""Resolved domains and host lookup tables."""
def __init__(self, domains: list[Domain]):
self._by_rp_id = {d.rp_id: d for d in domains}
self._auth_hosts: dict[str, list[Domain]] = {}
self._related_hosts: dict[str, Domain] = {}
self.warnings: list[str] = []
for domain in domains:
if own := domain.own_auth_host:
key = hostutil.normalize_host(own) or own
self._auth_hosts.setdefault(key, []).append(domain)
for origin in domain.related_origins:
if hostname := hostutil.origin_hostname(origin):
# First claimant wins (config order); a related host that
# is another domain's rp-id never reaches this map in
# resolve() — the owning domain is matched first.
self._related_hosts.setdefault(hostname, domain)
@property
def domains(self) -> list[Domain]:
"""All domains (unordered — ordering is a display-time affair)."""
return list(self._by_rp_id.values())
def get(self, rp_id: str) -> Domain | None:
return self._by_rp_id.get(rp_id)
def resolve(self, host: str | None) -> Domain | None:
"""Resolve a request Host header to a domain.
Order: exact rp-id → auth host → exact related-origin hostname →
longest-suffix rp-id. Unknown hosts return None. When several
domains share an auth host, the best suffix match (longest rp-id
the host falls under) wins, first configured as tiebreak — so
``auth.company.com`` shared by ``company.com`` and ``app2.com``
serves ``company.com`` for plain HTTP; WebSocket logins still
follow the Origin header to the right domain.
"""
h = hostutil.normalize_host(host)
if not h:
return None
if domain := self._by_rp_id.get(h):
return domain
if claimants := self._auth_hosts.get(h):
best = None
for candidate in claimants:
if h.endswith(f".{candidate.rp_id}") and (
best is None or len(candidate.rp_id) > len(best.rp_id)
):
best = candidate
return best or claimants[0]
if domain := self._related_hosts.get(h):
return domain
best = None
for rp_id, domain 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 = domain
return best
def validate_config(
config: Config, *, related_origin_cap: int = DEFAULT_RELATED_ORIGIN_CAP
) -> None:
"""Validate a combined configuration cross-domain. Raises ValueError."""
if not config.domains:
raise ValueError("At least one domain (rp-id) is required")
auth_hosts: dict[str, str] = {} # normalized host -> owning rp_id
related_hosts: dict[str, str] = {} # hostname -> owning rp_id
for rp_id, domain in config.domains.items():
hostutil.validate_rp_id(rp_id)
domain_auth_host: str | None = None
related_count = 0
for key, props in domain.origins.items():
is_auth = isinstance(props, OriginEntry) and props.auth_host
if key == "*":
raise ValueError(
f"Origin '*' is not allowed — list '**.{rp_id}' explicitly"
)
if hostutil.is_wildcard_pattern(key):
base = hostutil.wildcard_base(key)
if not base or not hostutil.is_valid_hostname(base):
raise ValueError(f"Invalid wildcard origin: '{key}'")
if not hostutil.is_subdomain(base, rp_id):
raise ValueError(
f"Origin '{key}' is a wildcard outside the rp-id "
f"domain '{rp_id}' — related origins must be "
"individual hosts"
)
if is_auth:
raise ValueError(f"Wildcard origin '{key}' cannot be the auth host")
continue
hn = hostutil.origin_hostname(origin_url(key))
if not hn or not hostutil.is_valid_hostname(hn):
raise ValueError(f"Invalid origin: '{key}'")
if hostutil.is_subdomain(hn, rp_id):
if is_auth:
if domain_auth_host is not None:
raise ValueError(
f"Domain '{rp_id}' marks several origins as the auth "
f"host ('{domain_auth_host}' and '{key}') — only one allowed"
)
domain_auth_host = key
ah = hostutil.normalize_host(
hostutil.auth_host_netloc(origin_url(key)) or ""
)
# Several domains may share an auth host to consolidate
# logins; resolution picks the best suffix match.
auth_hosts.setdefault(ah, rp_id)
continue
# Related origin (outside the rp-id domain)
if is_auth:
raise ValueError(
f"Related origin '{key}' cannot be the auth host — the "
"auth host must be within the rp-id domain"
)
related_count += 1
# A related host may be (or fall inside) another domain's
# rp-id: a host that *is* a configured rp-id always serves its
# own domain; otherwise the related listing wins dispatch over
# suffix matching, so ROR logins from the listed host keep
# working.
covered_by_rp_id = any(
hostutil.is_subdomain(hn, other) for other in config.domains
)
if hn in related_hosts and not covered_by_rp_id:
raise ValueError(
f"Related origin host '{hn}' is configured for both "
f"'{related_hosts[hn]}' and '{rp_id}'"
)
related_hosts[hn] = rp_id
if related_count > related_origin_cap:
raise ValueError(
f"Domain '{rp_id}' has {related_count} related origins "
f"(maximum {related_origin_cap})"
)
rp_ids = set(config.domains)
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 and related_hosts[hn] != owner:
raise ValueError(
f"auth-host '{hn}' collides with a related origin of "
f"domain '{related_hosts[hn]}'"
)
def sanitize_config(
config: Config, *, related_origin_cap: int = DEFAULT_RELATED_ORIGIN_CAP
) -> tuple[Config, list[str]]:
"""Best-effort repair of a stored configuration for serving.
Serving must never fail because of stored domain config: fixing it is
the admin's job via the admin UI, which is reachable only on a running
server. Returns a sanitized copy (the stored config is left untouched)
plus a warning for every degradation made. The result always passes
``validate_config``.
"""
warnings: list[str] = []
def warn(msg: str) -> None:
warnings.append(msg)
domains: dict[str, DomainConfig] = {}
for rp_id, domain in config.domains.items():
try:
hostutil.validate_rp_id(rp_id)
except ValueError as e:
warn(f"Domain dropped: {e}")
continue
origins: dict[str, bool | OriginEntry] = {}
auth_seen = False
for key, props in domain.origins.items():
is_auth = isinstance(props, OriginEntry) and props.auth_host
if not is_auth:
props = True # canonicalize junk/empty entries to presence-only
if key == "*":
warn(
f"Domain '{rp_id}': origin '*' rewritten as '**.{rp_id}'"
+ (" — auth host mark cleared" if is_auth else "")
)
origins[f"**.{rp_id}"] = True
continue
if hostutil.is_wildcard_pattern(key):
base = hostutil.wildcard_base(key)
if not base or not hostutil.is_valid_hostname(base):
warn(f"Domain '{rp_id}': invalid wildcard origin '{key}' dropped")
continue
if not hostutil.is_subdomain(base, rp_id):
warn(
f"Domain '{rp_id}': origin '{key}' is a wildcard "
"outside the rp-id domain — dropped (related origins "
"must be individual hosts)"
)
continue
if is_auth:
warn(
f"Domain '{rp_id}': wildcard '{key}' cannot be the "
"auth host — mark cleared"
)
props = True
origins[key] = props
continue
hn = hostutil.origin_hostname(origin_url(key))
if not hn or not hostutil.is_valid_hostname(hn):
warn(f"Domain '{rp_id}': invalid origin '{key}' dropped")
continue
if is_auth:
if not hostutil.is_subdomain(hn, rp_id):
warn(
f"Domain '{rp_id}': related origin '{key}' cannot be "
"the auth host — mark cleared"
)
props = True
elif auth_seen:
warn(
f"Domain '{rp_id}': several origins marked as "
f"auth host — extra mark on '{key}' cleared"
)
props = True
else:
auth_seen = True
origins[key] = props
related = sorted(k for k in origins if is_related_key(rp_id, k))
if len(related) > related_origin_cap:
warn(
f"Domain '{rp_id}': {len(related)} related origins exceed "
f"the maximum of {related_origin_cap} — extras dropped"
)
for key in related[related_origin_cap:]:
del origins[key]
domains[rp_id] = DomainConfig(rp_name=domain.rp_name, origins=origins)
if not domains:
raise ValueError("No servable domain in the stored configuration")
# Cross-domain conflicts: an auth host equal to an rp-id is dead config
# (the rp-id always wins dispatch) — clear the mark. Sharing one auth
# host between domains is allowed (login consolidation); resolution
# picks the best suffix match. Related origins may point at or inside
# other domains' rp-ids: a host that *is* a configured rp-id serves its
# own domain; otherwise the related listing wins dispatch over suffix
# matching.
rp_ids = set(domains)
seen_auth_hosts: dict[str, str] = {}
for rp_id, domain in domains.items():
for key, props in domain.origins.items():
if not isinstance(props, OriginEntry) or not props.auth_host:
continue
hn = hostutil.normalize_host(
hostutil.auth_host_netloc(origin_url(key)) or ""
)
if hn in rp_ids:
warn(
f"Domain '{rp_id}': auth host '{hn}' collides with "
"an rp-id — mark cleared"
)
domain.origins[key] = True
elif hn:
seen_auth_hosts.setdefault(hn, rp_id)
seen_related: dict[str, str] = {}
for rp_id, domain in domains.items():
drop = []
for key in domain.origins:
if not is_related_key(rp_id, key):
continue
hn = hostutil.origin_hostname(origin_url(key))
if any(hostutil.is_subdomain(hn, o) for o in rp_ids):
continue # covered by a configured rp-id
if hn in seen_auth_hosts:
warn(
f"Domain '{rp_id}': related origin '{key}' is the "
f"auth host of '{seen_auth_hosts[hn]}' — dropped"
)
drop.append(key)
elif hn in seen_related:
warn(
f"Domain '{rp_id}': related origin '{key}' is also "
f"used by '{seen_related[hn]}' — dropped (first domain wins)"
)
drop.append(key)
else:
seen_related[hn] = rp_id
for key in drop:
del domain.origins[key]
return Config(domains=domains, listen=config.listen), warnings
def _derive_site(
rp_id: str, domain: DomainConfig, *, listen_port: int | None, vite_url: str | None
) -> tuple[str, str]:
"""Compute a domain's site_url and site_path.
Priority: auth host > exact rp-id origin key > first concrete in-domain
origin key (sorted) > PASKIA_VITE_URL (localhost domain only) >
http://localhost:port (localhost domain) > https://rp-id.
"""
if auth := auth_host_url(domain):
return auth, "/"
if rp_id in domain.origins:
return origin_url(rp_id), "/auth/"
concrete = sorted(
k
for k in domain.origins
if not hostutil.is_wildcard_pattern(k) and not is_related_key(rp_id, k)
)
if concrete:
return origin_url(concrete[0]), "/auth/"
if rp_id == "localhost":
if vite_url:
return vite_url.rstrip("/"), "/auth/"
if listen_port:
return f"http://localhost:{listen_port}", "/auth/"
return f"https://{rp_id}", "/auth/"
_registry: DomainRegistry | 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) -> DomainRegistry:
"""Build a registry from a stored configuration.
The config is sanitized best-effort (serving must not fail on stored
config problems — the admin UI fixes them on a running server);
warnings are logged and exposed on the registry.
"""
config, warnings = sanitize_config(config)
validate_config(config) # sanitize guarantees this; a raise means a bug
endpoint = next(iter(parse_endpoints(_listen, DEFAULT_PORT)), {})
vite_url = os.environ.get("PASKIA_VITE_URL")
domains = [
Domain(
rp_id,
dc,
*_derive_site(
rp_id, dc, listen_port=endpoint.get("port"), vite_url=vite_url
),
)
for rp_id, dc in config.domains.items()
]
registry = DomainRegistry(domains)
registry.warnings = warnings
for warning in warnings:
logger.warning("Config: %s", warning)
return registry
def init_registry(config: Config) -> DomainRegistry:
"""Build and install the global registry from a combined configuration."""
global _registry
_registry = build(config)
return _registry
def registry() -> DomainRegistry:
"""Return the global registry (must be initialized)."""
if _registry is None:
raise RuntimeError("Domain registry is not initialized")
return _registry
_current_domain: contextvars.ContextVar[Domain | None] = contextvars.ContextVar(
"paskia_current_domain", default=None
)
def set_current_domain(domain: Domain | None) -> contextvars.Token:
return _current_domain.set(domain)
def reset_current_domain(token: contextvars.Token) -> None:
_current_domain.reset(token)
def current_domain() -> Domain:
"""Return the request's domain.
Without request context (background jobs, CLI), the single configured
domain is returned; with several domains a request context is required.
"""
domain = _current_domain.get()
if domain is not None:
return domain
reg = registry()
if len(reg.domains) == 1:
return reg.domains[0]
raise RuntimeError("No current domain: request context required")