Domains: object-keyed config format, drop default domain, rename realm→domain throughout backend

This commit is contained in:
2026-09-07 01:25:29 +00:00
parent dcfb497cf2
commit 7a0737f867
34 changed files with 1062 additions and 1039 deletions
+33 -30
View File
@@ -12,14 +12,13 @@ from kanta import Kanta
from paskia.db import legacy
from paskia.db.bootstrap import bootstrap, log_reset_link
from paskia.db.paths import db_file_path
from paskia.db.structs import DB, Config, RealmConfig
from paskia.realms import build as build_registry
from paskia.realms import configure as configure_realms
from paskia.realms import validate_config
from paskia.db.structs import DB, Config, DomainConfig, OriginEntry
from paskia.domains import build as build_registry
from paskia.domains import configure as configure_domains
from paskia.domains import origin_key, validate_config
from paskia.util import startupbox
from paskia.util.constants import DEFAULT_PORT, DEVMODE
from paskia.util.hostutil import (
normalize_auth_host_and_origins,
normalize_origin,
validate_auth_host,
)
@@ -78,11 +77,11 @@ def _load_stored_config(db_path: Path) -> Config:
def cmd_init(args: argparse.Namespace) -> None:
"""Bootstrap a new paskia.kantadb database with the initial realm(s)."""
"""Bootstrap a new paskia.kantadb database with the initial domain(s)."""
db_path = db_file_path()
if db_path.exists():
raise SystemExit(
f"Database {db_path} already exists — realm configuration is "
f"Database {db_path} already exists — domain configuration is "
"managed via the admin interface, not 'paskia init'."
)
if found := legacy.find_legacy_databases():
@@ -94,33 +93,37 @@ def cmd_init(args: argparse.Namespace) -> None:
rp_ids = _split_multi(args.rp_id) or ["localhost"]
realms = []
domains = {}
for i, rp_id in enumerate(rp_ids):
realm = RealmConfig(rp_id=rp_id)
domain = DomainConfig()
if i == 0:
# Bootstrap-time naming and hosts apply to the default realm;
# Bootstrap-time naming and hosts apply to the first domain;
# everything is editable via the admin interface afterwards.
realm.rp_name = args.rp_name or None
origins = [normalize_origin(o) for o in _split_multi(args.origins)] or None
domain.rp_name = args.rp_name or None
origins = {
origin_key(normalize_origin(o)): True
for o in _split_multi(args.origins)
}
auth_host = args.auth_host or None
if auth_host:
try:
validate_auth_host(auth_host, rp_id)
except ValueError as e:
raise SystemExit(str(e)) from e
realm.auth_host, realm.origins = normalize_auth_host_and_origins(
auth_host, origins
)
realms.append(realm)
if "://" not in auth_host:
auth_host = f"https://{auth_host}"
origins[origin_key(auth_host)] = OriginEntry(auth_host=True)
domain.origins = origins
domains[rp_id] = domain
config = Config(realms=realms, listen=_split_multi(args.listen) or None)
config = Config(domains=domains, listen=_split_multi(args.listen) or None)
try:
validate_config(config)
except ValueError as e:
raise SystemExit(str(e)) from e
# Create the database; the kanta bootstrap callback seeds it (admin
# user, org, permissions, reset token, per-realm OIDC keys).
# user, org, permissions, reset token, per-domain OIDC keys).
new_db = DB()
kanta = Kanta(str(db_path), new_db)
result = {}
@@ -140,11 +143,11 @@ def cmd_init(args: argparse.Namespace) -> None:
db_path.unlink(missing_ok=True)
raise SystemExit(f"{e}") from e
configure_realms(listen=config.listen)
configure_domains(listen=config.listen)
registry = build_registry(config)
startupbox.print_startup_config(registry, listen=config.listen)
log_reset_link(
registry.default.reset_link_url(result["passphrase"]),
registry.get(rp_ids[0]).reset_link_url(result["passphrase"]),
"✅ Bootstrap completed!",
)
@@ -152,11 +155,11 @@ def cmd_init(args: argparse.Namespace) -> None:
def cmd_migrate(args: argparse.Namespace) -> None:
"""Convert a legacy <rp-id>.paskiadb database to paskia.kantadb."""
rp_id = legacy.migrate_legacy_database(args.rp_id)
print(f"✅ Converted legacy database to {db_file_path()} (realm: {rp_id})")
print(f"✅ Converted legacy database to {db_file_path()} (domain: {rp_id})")
def cmd_serve(args: argparse.Namespace) -> None:
"""Open the combined database and serve all configured realms."""
"""Open the combined database and serve all configured domains."""
db_path = db_file_path()
if not db_path.exists():
if found := legacy.find_legacy_databases():
@@ -170,14 +173,14 @@ def cmd_serve(args: argparse.Namespace) -> None:
config = _load_stored_config(db_path)
listen = _split_multi(args.listen) or config.listen
configure_realms(listen=listen)
configure_domains(listen=listen)
try:
registry = build_registry(config)
except ValueError as e:
raise SystemExit(f"Invalid stored configuration: {e}") from e
for warning in registry.warnings:
# Serving is best-effort; fixing the stored config is the admin's
# job via the admin interface on any working realm.
# job via the admin interface on any working domain.
print(
f"⚠️ Config problem (fix via the admin interface): {warning}",
file=sys.stderr,
@@ -224,13 +227,13 @@ def main():
init_parser.add_argument(
"--rp-id",
action="append",
help="Relying Party ID of the initial realm(s) (default: localhost). "
"Repeatable and comma-separated; the first is the default realm. "
"Further realms are added via the admin interface.",
help="Relying Party ID of the initial domain(s) (default: localhost). "
"Repeatable and comma-separated; the first is the default domain. "
"Further domains are added via the admin interface.",
)
init_parser.add_argument(
"--rp-name",
help="Relying Party name of the default realm (default: same as rp-id). "
help="Relying Party name of the default domain (default: same as rp-id). "
"Used by the initial admin registration; editable later via admin UI.",
)
init_parser.add_argument(
@@ -238,12 +241,12 @@ def main():
action="append",
dest="origins",
metavar="URL",
help="Allowed origin URL(s) for the default realm. May be specified "
help="Allowed origin URL(s) for the default domain. May be specified "
"multiple times; comma-separated values accepted.",
)
init_parser.add_argument(
"--auth-host",
help="Dedicated authentication site for the default realm "
help="Dedicated authentication site for the default domain "
"(optionally with scheme/port)",
)
_add_listen_option(init_parser, help_extra=" (stored in the database)")
+4 -4
View File
@@ -24,7 +24,7 @@ class OIDCCode(msgspec.Struct):
"""An OIDC authorization code pending token exchange.
PKCE uses S256 only when provided (verified at token exchange).
rp_id binds the code to the realm it was issued in; the token
rp_id binds the code to the domain it was issued in; the token
endpoint (dispatched by Host) must match.
"""
@@ -40,10 +40,10 @@ class OIDCCode(msgspec.Struct):
class CookieCode(msgspec.Struct):
"""A cookie exchange code for setting session cookie after WebSocket auth.
rp_id binds the code to the realm it was issued in; the redemption
rp_id binds the code to the domain it was issued in; the redemption
endpoint (dispatched by Host) must match. This is what allows a
remote-auth approver on one realm to mint a code for the requesting
device's realm without the code being usable on the wrong realm.
remote-auth approver on one domain to mint a code for the requesting
device's domain without the code being usable on the wrong domain.
"""
session_key: str
+14 -11
View File
@@ -5,12 +5,12 @@ The initial database seeding (admin user, organization, permissions,
registration reset token) is performed by ``paskia init`` via
:func:`paskia.db.bootstrap.bootstrap`. This module provides the serve-time
check that re-prints a registration link when the admin user still has no
passkey under the default realm.
passkey under the default domain.
"""
import logging
from paskia import authsession, db, realms
from paskia import authsession, db, domains
from paskia.db.bootstrap import log_reset_link
logger = logging.getLogger(__name__)
@@ -33,9 +33,10 @@ async def check_admin_credentials() -> bool:
"""
Check if the admin user needs credentials and create a reset link if needed.
With global users, the admin may hold passkeys under other realms only —
the check tests for a credential under the **default realm's** rp-id, so
the printed link (which points at the default realm) is usable.
With global users, the admin may hold passkeys under any configured
domain — the check passes if the admin has a credential for at least
one of them. Otherwise a reset link is printed for the first domain
(sorted by rp-id).
Returns:
bool: True if a reset link was created, False if admin already has credentials
@@ -63,13 +64,15 @@ async def check_admin_credentials() -> bool:
if not admin_users:
return False
# Check first admin user for credentials under the default realm
# Check first admin user for credentials on any configured domain
admin_user = admin_users[0]
default = realms.registry().default
reg = domains.registry()
configured = sorted(d.rp_id for d in reg.domains)
if not admin_user.credential_ids_for(default.rp_id):
# Admin exists but has no credential on the default realm
logger.info("⚠️ Admin user has no credentials on %s!", default.rp_id)
if not any(admin_user.credential_ids_for(rp_id) for rp_id in configured):
# Admin exists but has no credential on any domain
target = reg.get(configured[0])
logger.info("⚠️ Admin user has no credentials on %s!", target.rp_id)
expiry = authsession.reset_expires()
token = db.create_reset_token(
@@ -77,7 +80,7 @@ async def check_admin_credentials() -> bool:
expiry=expiry,
token_type="admin registration",
)
log_reset_link(default.reset_link_url(token))
log_reset_link(target.reset_link_url(token))
return True
return False
+8 -8
View File
@@ -28,7 +28,7 @@ from paskia.db.operations import (
create_oid_client,
create_org,
create_permission,
create_realm,
create_domain,
create_reset_token,
create_role,
create_user,
@@ -36,7 +36,7 @@ from paskia.db.operations import (
delete_oid_client,
delete_org,
delete_permission,
delete_realm,
delete_domain,
delete_reset_token,
delete_role,
delete_session,
@@ -54,7 +54,7 @@ from paskia.db.operations import (
update_oid_client,
update_org_name,
update_permission,
update_realm,
update_domain,
update_role_name,
update_session,
update_user_display_name,
@@ -69,7 +69,7 @@ from paskia.db.structs import (
Credential,
Org,
Permission,
RealmConfig,
DomainConfig,
ResetToken,
Role,
Session,
@@ -92,7 +92,7 @@ __all__ = [
"OIDC",
"Org",
"Permission",
"RealmConfig",
"DomainConfig",
"ResetToken",
"Role",
"Session",
@@ -109,14 +109,14 @@ __all__ = [
"create_credential_session",
"create_org",
"create_permission",
"create_realm",
"create_domain",
"create_reset_token",
"create_role",
"create_user",
"delete_credential",
"delete_org",
"delete_permission",
"delete_realm",
"delete_domain",
"delete_reset_token",
"delete_role",
"delete_session",
@@ -131,7 +131,7 @@ __all__ = [
"update_credential_sign_count",
"update_org_name",
"update_permission",
"update_realm",
"update_domain",
"update_role_name",
"update_session",
"update_user_display_name",
+2 -3
View File
@@ -145,9 +145,8 @@ def bootstrap(
if config is not None:
data.config = config
# Generate an OIDC signing key for each realm
rp_ids = [r.rp_id for r in data.config.realms]
data.oidc = {rp_id: OIDC(key=secret_key()) for rp_id in rp_ids}
# Generate an OIDC signing key for each domain
data.oidc = {rp_id: OIDC(key=secret_key()) for rp_id in data.config.domains}
# Store all bootstrapped objects in the live data object
data.permissions[perm_admin_uuid] = perm_admin
+14 -12
View File
@@ -29,8 +29,9 @@ from paskia.db.structs import (
Config,
Credential,
Org,
OriginEntry,
Permission,
RealmConfig,
DomainConfig,
ResetToken,
Role,
Session,
@@ -39,7 +40,7 @@ from paskia.db.structs import (
class LegacyConfig(msgspec.Struct, omit_defaults=True):
"""Pre-realms stored configuration (single rp-id per database)."""
"""Pre-domains stored configuration (single rp-id per database)."""
rp_id: str
rp_name: str | None = None
@@ -111,15 +112,16 @@ def convert_legacy_database(src: Path, dst: Path) -> Config:
old = _read_legacy(src)
rp_id = old.config.rp_id
from paskia.domains import origin_key # noqa: PLC0415 (import cycle)
origins: dict[str, bool | OriginEntry] = {}
for origin in old.config.origins or []:
origins[origin_key(origin)] = True
if old.config.auth_host:
origins[origin_key(old.config.auth_host)] = OriginEntry(auth_host=True)
new_config = Config(
realms=[
RealmConfig(
rp_id=rp_id,
rp_name=old.config.rp_name,
auth_host=old.config.auth_host,
origins=old.config.origins,
)
],
domains={rp_id: DomainConfig(rp_name=old.config.rp_name, origins=origins)},
listen=old.config.listen,
)
@@ -209,7 +211,7 @@ def migrate_legacy_database(rp_id: str | None = None) -> str:
With ``rp_id``, selects the ``<rp-id>.paskiadb`` candidate by name;
without it, exactly one candidate must exist. Returns the migrated
realm's rp-id. The converted legacy directory/file is renamed aside
domain's rp-id. The converted legacy directory/file is renamed aside
to ``<name>.converted-bak`` rather than deleted.
Raises SystemExit when ``paskia.kantadb`` already exists, when no
@@ -250,4 +252,4 @@ def migrate_legacy_database(rp_id: str | None = None) -> str:
shutil.move(str(child), str(target_users / child.name))
shutil.move(str(src), str(src.with_name(src.name + ".converted-bak")))
return config.default_realm.rp_id
return next(iter(config.domains))
+1 -1
View File
@@ -42,7 +42,7 @@ def _lookup_uuid_in_state(state: dict | None, uuid_str: str) -> str | None:
return display_name
# OIDC clients use "name" instead of "display_name"; providers are
# nested per realm rp-id.
# nested per domain rp-id.
for provider in state.get("oidc", {}).values():
if not isinstance(provider, dict):
continue
+37 -37
View File
@@ -22,8 +22,9 @@ from paskia.db.structs import (
Config,
Credential,
Org,
OriginEntry,
Permission,
RealmConfig,
DomainConfig,
ResetToken,
Role,
Session,
@@ -712,69 +713,68 @@ def create_credential_session(
# -------------------------------------------------------------------------
# Realm operations
# Domain operations
# -------------------------------------------------------------------------
def _oidc_provider(rp_id: str) -> OIDC:
"""Return the OIDC provider entry for a realm, raising if missing."""
"""Return the OIDC provider entry for a domain, raising if missing."""
provider = _db.oidc.get(rp_id)
if provider is None:
raise ValueError(f"Realm {rp_id} not found")
raise ValueError(f"Domain {rp_id} not found")
return provider
def create_realm(realm: RealmConfig, *, ctx: SessionContext | None = None) -> None:
"""Add a new realm (rp-id) to the stored configuration.
def create_domain(
rp_id: str, domain: DomainConfig, *, ctx: SessionContext | None = None
) -> None:
"""Add a new domain (rp-id) to the stored configuration.
Seeds an OIDC provider entry (with a fresh signing key) for the realm.
Seeds an OIDC provider entry (with a fresh signing key) for the domain.
The caller must validate the resulting combined configuration.
"""
if _db.config.find_realm(realm.rp_id) is not None:
raise ValueError(f"Realm {realm.rp_id} already exists")
with _transaction("admin:create_realm", ctx):
_db.config.realms.append(realm)
_db.oidc[realm.rp_id] = OIDC(key=secret_key())
if rp_id in _db.config.domains:
raise ValueError(f"Domain {rp_id} already exists")
with _transaction("admin:create_domain", ctx):
_db.config.domains[rp_id] = domain
_db.oidc[rp_id] = OIDC(key=secret_key())
def update_realm(
def update_domain(
rp_id: str,
*,
rp_name: str | None = None,
auth_host: str | None = None,
origins: list[str] | None = None,
related_origins: list[str] | None = None,
origins: dict[str, bool | OriginEntry] | None = None,
related: dict[str, bool] | None = None,
ctx: SessionContext | None = None,
) -> None:
"""Update a realm's rp_name, auth_host, origins and related origins.
"""Update a domain's rp_name, origins and related origins.
The rp-id itself is immutable: credentials are stamped with it, so
changing it would orphan them — delete and recreate the realm instead.
changing it would orphan them — delete and recreate the domain instead.
The caller must validate the resulting combined configuration.
"""
realm = _db.config.find_realm(rp_id)
if realm is None:
raise ValueError(f"Realm {rp_id} not found")
with _transaction("admin:update_realm", ctx):
realm.rp_name = rp_name
realm.auth_host = auth_host
realm.origins = origins
realm.related_origins = related_origins
domain = _db.config.domains.get(rp_id)
if domain is None:
raise ValueError(f"Domain {rp_id} not found")
with _transaction("admin:update_domain", ctx):
domain.rp_name = rp_name
domain.origins = origins or {}
domain.related = related or {}
def delete_realm(rp_id: str, *, ctx: SessionContext | None = None) -> None:
"""Delete a realm. Refused for the last realm or while credentials remain."""
realm = _db.config.find_realm(rp_id)
if realm is None:
raise ValueError(f"Realm {rp_id} not found")
if len(_db.config.realms) <= 1:
raise ValueError("Cannot delete the last remaining realm")
def delete_domain(rp_id: str, *, ctx: SessionContext | None = None) -> None:
"""Delete a domain. Refused for the last domain or while credentials remain."""
if rp_id not in _db.config.domains:
raise ValueError(f"Domain {rp_id} not found")
if len(_db.config.domains) <= 1:
raise ValueError("Cannot delete the last remaining domain")
if any(c.rp_id == rp_id for c in _db.credentials.values()):
raise ValueError(
f"Cannot delete realm {rp_id}: credentials still registered under it"
f"Cannot delete domain {rp_id}: credentials still registered under it"
)
with _transaction("admin:delete_realm", ctx):
_db.config.realms.remove(realm)
with _transaction("admin:delete_domain", ctx):
del _db.config.domains[rp_id]
_db.oidc.pop(rp_id, None)
@@ -786,7 +786,7 @@ def delete_realm(rp_id: str, *, ctx: SessionContext | None = None) -> None:
def create_oid_client(
rp_id: str, client: Client, *, ctx: SessionContext | None = None
) -> None:
"""Create a new OIDC client under a realm."""
"""Create a new OIDC client under a domain."""
provider = _oidc_provider(rp_id)
if client.uuid in provider.clients:
raise ValueError(f"OIDC client {client.uuid} already exists")
+34 -32
View File
@@ -238,7 +238,7 @@ class User(msgspec.Struct, dict=True, omit_defaults=True, kw_only=True):
return [c.credential_id for c in self.credentials]
def credential_ids_for(self, rp_id: str) -> list[bytes]:
"""Get credential IDs registered under a specific realm's rp-id."""
"""Get credential IDs registered under a specific domain's rp-id."""
return [c.credential_id for c in self.credentials if c.rp_id == rp_id]
@property
@@ -297,8 +297,8 @@ class Credential(msgspec.Struct, dict=True):
Immutable fields: credential_id, user, aaguid, public_key, created_at, rp_id
uuid is derived from created_at using uuid7.
rp_id is the realm the passkey was registered under. With Related Origin
Requests it is always the realm's canonical rp-id, regardless of which
rp_id is the domain the passkey was registered under. With Related Origin
Requests it is always the domain's canonical rp-id, regardless of which
origin the registration ceremony ran on.
"""
@@ -391,7 +391,7 @@ class Session(msgspec.Struct, dict=True, omit_defaults=True):
user_agent: str
validated: datetime
client_uuid: UUID | None = msgspec.field(name="client", default=None)
rp_id: str | None = None # Owning realm (needed when no request context)
rp_id: str | None = None # Owning domain (needed when no request context)
issuer: str | None = None # OIDC issuer URL this session was created under
def __post_init__(self):
@@ -449,7 +449,7 @@ class Session(msgspec.Struct, dict=True, omit_defaults=True):
Args:
key: The hashed session key (derived from secret via hash_secret)
rp_id: Owning realm's rp-id (used when no request context exists)
rp_id: Owning domain's rp-id (used when no request context exists)
issuer: OIDC issuer URL (scheme + host) for OIDC sessions
Returns:
@@ -620,45 +620,47 @@ class OIDC(msgspec.Struct, dict=True):
key: bytes | None = None
class RealmConfig(msgspec.Struct, omit_defaults=True):
"""Configuration for one authentication realm (one WebAuthn rp-id).
class OriginEntry(msgspec.Struct, omit_defaults=True):
"""Extra properties of one allowed origin within a domain.
A realm is one rp-id with its associated hosts. ``origins`` restricts
which sites *within* the rp-id domain may authenticate (unset = the
rp-id and all its subdomains); ``related_origins`` lists *other*
domains that may assert this rp-id (WebAuthn Related Origin Requests).
Stored as the dict value for an origin key; plain ``True`` instead of an
object means presence only, nothing more to store.
"""
auth_host: bool = False # This site hosts the account/admin interface
class DomainConfig(msgspec.Struct, omit_defaults=True):
"""Configuration for one domain (one WebAuthn rp-id).
``origins`` maps sign-in sites within the rp-id domain to their
properties. Keys are hosts without the https:// scheme
("app.example.com"), wildcard patterns ("*.example.com"), or full
origins when not https ("http://localhost:8080"). An empty dict means
the rp-id and all its subdomains may sign in (the default). Ordering
carries no meaning — display order is decided by the UI.
``related`` lists other domain names that may assert this rp-id
(WebAuthn Related Origin Requests), with the same key rule.
"""
rp_id: str
rp_name: str | None = None
auth_host: str | None = None # This realm's dedicated auth host (URL)
origins: list[str] | None = None # Allow-list of in-domain sign-in sites
related_origins: list[str] | None = None # Cross-domain ROR origins
origins: dict[str, bool | OriginEntry] = {}
related: dict[str, bool] = {}
class Config(msgspec.Struct, omit_defaults=True):
"""Stored configuration for the instance.
Realms are shared by the whole administrative instance: organizations and
users are global across rp-ids. The first realm is the default realm,
used only where a default is genuinely needed (bootstrap reset-link URL,
startup display) — never for request dispatch.
Domains are keyed by rp-id and shared by the whole administrative
instance: organizations and users are global across rp-ids.
"""
realms: list[RealmConfig] = msgspec.field(
default_factory=lambda: [RealmConfig(rp_id="localhost")]
domains: dict[str, DomainConfig] = msgspec.field(
default_factory=lambda: {"localhost": DomainConfig()}
)
listen: list[str] | None = None # Process-global listen endpoints
@property
def default_realm(self) -> RealmConfig:
"""The first configured realm."""
return self.realms[0]
def find_realm(self, rp_id: str) -> RealmConfig | None:
"""Find a realm configuration by rp-id."""
return next((r for r in self.realms if r.rp_id == rp_id), None)
# -------------------------------------------------------------------------
# Database storage structure
@@ -676,7 +678,7 @@ class DB(msgspec.Struct, dict=True, omit_defaults=False):
credentials: dict[UUID, Credential] = {}
sessions: dict[str, Session] = {}
reset_tokens: dict[str, ResetToken] = {}
# OIDC provider data, keyed by realm rp-id: each realm is an independent
# OIDC provider data, keyed by rp-id: each domain is an independent
# issuer with its own signing key and clients.
oidc: dict[str, OIDC] = {}
@@ -704,7 +706,7 @@ class DB(msgspec.Struct, dict=True, omit_defaults=False):
client.uuid = uuid
def oidc_for(self, rp_id: str) -> OIDC | None:
"""Get the OIDC provider data for a realm, if it exists."""
"""Get the OIDC provider data for a domain, if it exists."""
return self.oidc.get(rp_id)
def session_ctx(
+516
View File
@@ -0,0 +1,516 @@
"""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, OIDC view) varies per request, tracked via a
contextvar set by the dispatch middleware.
"""
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.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:
"""Full origin URL for an origins-dict key (https:// is implied)."""
if hostutil.is_wildcard_pattern(key) or "://" in key:
return key
return f"https://{key}"
def origin_key(origin: str) -> str:
"""Origins-dict key for a full origin URL (https:// omitted)."""
return origin.removeprefix("https://").rstrip("/")
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):
# 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.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 config.origins] or None,
related_origins=[origin_url(k) for k in config.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]:
"""Configured related (cross-domain) origins for ROR, as URLs."""
return [origin_url(k) for k in self.config.related]
@property
def is_root_mode(self) -> bool:
"""Whether this domain's UI lives at the site root (own auth host)."""
return auth_host_url(self.config) 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 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, Domain] = {}
self._related_hosts: dict[str, Domain] = {}
self.warnings: list[str] = []
for domain in domains:
if own := domain.own_auth_host:
self._auth_hosts[hostutil.normalize_host(own) or own] = domain
for origin in domain.related_origins:
if hostname := hostutil.origin_hostname(origin):
self._related_hosts[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 effective_auth_host(self, domain: Domain) -> str | None:
"""Auth host serving WS/restricted APIs for a domain: its own, or
another domain's as a shared fallback.
Returns host[:port] suitable for URL building, or None.
"""
if domain.own_auth_host:
return domain.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) -> Domain | None:
"""Resolve a request Host header to a domain.
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 domain := self._by_rp_id.get(h):
return domain
if domain := self._auth_hosts.get(h):
return domain
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)
for key, props in domain.origins.items():
is_auth = isinstance(props, OriginEntry) and props.auth_host
if hostutil.is_wildcard_pattern(key):
base = key[2:].rstrip(".")
if not base or not hostutil.is_subdomain(base, rp_id):
raise ValueError(
f"Origin '{key}' is outside the rp-id domain "
f"'{rp_id}' — configure it as a related origin instead"
)
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:
raise ValueError(f"Invalid origin: '{key}'")
if not hostutil.is_subdomain(hn, rp_id):
raise ValueError(
f"Origin '{key}' is outside the rp-id domain "
f"'{rp_id}' — configure it as a related origin instead"
)
if is_auth:
ah = hostutil.normalize_host(
hostutil.auth_host_netloc(origin_url(key)) or ""
)
if ah in auth_hosts:
raise ValueError(
f"auth-host '{ah}' is configured for both "
f"'{auth_hosts[ah]}' and '{rp_id}'"
)
auth_hosts[ah] = rp_id
if len(domain.related) > related_origin_cap:
raise ValueError(
f"Domain '{rp_id}' has {len(domain.related)} "
f"related origins (maximum {related_origin_cap})"
)
for key in domain.related:
if hostutil.is_wildcard_pattern(key):
raise ValueError(
f"Related origin '{key}' is a wildcard — related "
"origins (ROR) must be listed individually"
)
hn = hostutil.origin_hostname(origin_url(key))
if not hn:
raise ValueError(f"Invalid related origin: '{key}'")
if hostutil.is_subdomain(hn, rp_id):
raise ValueError(
f"Related origin '{key}' is within the rp-id domain "
f"'{rp_id}' — subdomains need no related origin entry"
)
if hn in related_hosts:
raise ValueError(
f"Related origin host '{hn}' is configured for both "
f"'{related_hosts[hn]}' and '{rp_id}'"
)
related_hosts[hn] = rp_id
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:
raise ValueError(
f"auth-host '{hn}' collides with a related origin of "
f"domain '{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 domain '{owner}' "
f"falls inside domain '{other}'"
)
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] = {}
related: dict[str, bool] = dict(domain.related)
for key, props in domain.origins.items():
is_auth = isinstance(props, OriginEntry) and props.auth_host
if hostutil.is_wildcard_pattern(key):
base = key[2:].rstrip(".")
if not base:
warn(f"Domain '{rp_id}': invalid origin '{key}' dropped")
continue
if not hostutil.is_subdomain(base, rp_id):
warn(
f"Domain '{rp_id}': origin '{key}' is outside the "
"rp-id domain — dropped (wildcards cannot be "
"related origins)"
)
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:
warn(f"Domain '{rp_id}': invalid origin '{key}' dropped")
continue
if hostutil.is_subdomain(hn, rp_id):
origins[key] = props
else:
warn(
f"Domain '{rp_id}': origin '{key}' is outside the rp-id "
"domain — treating it as a related origin; fix the "
"lists in the admin interface"
)
related[origin_key(origin_url(key))] = True
related_ok: dict[str, bool] = {}
for key in related:
if hostutil.is_wildcard_pattern(key):
warn(
f"Domain '{rp_id}': related origin '{key}' is a "
"wildcard — dropped (ROR entries must be individual)"
)
continue
hn = hostutil.origin_hostname(origin_url(key))
if not hn:
warn(f"Domain '{rp_id}': invalid related origin '{key}' dropped")
continue
if hostutil.is_subdomain(hn, rp_id):
warn(
f"Domain '{rp_id}': related origin '{key}' is within the "
"rp-id domain — dropped (subdomains need no related entry)"
)
continue
related_ok[key] = True
if len(related_ok) > related_origin_cap:
warn(
f"Domain '{rp_id}': {len(related_ok)} related origins exceed "
f"the maximum of {related_origin_cap} — extras dropped"
)
related_ok = dict(sorted(related_ok.items())[:related_origin_cap])
domains[rp_id] = DomainConfig(
rp_name=domain.rp_name, origins=origins, related=related_ok
)
if not domains:
raise ValueError("No servable domain in the stored configuration")
# Cross-domain collisions: keep the first configured claimant, drop the
# rest with a warning so dispatch stays deterministic.
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 or hn in seen_auth_hosts:
warn(
f"Domain '{rp_id}': auth host '{hn}' collides with "
"another domain — mark cleared"
)
domain.origins[key] = True
else:
seen_auth_hosts[hn] = rp_id
seen_related: dict[str, str] = {}
for rp_id, domain in domains.items():
keep: dict[str, bool] = {}
for key in domain.related:
hn = hostutil.origin_hostname(origin_url(key))
if hn in rp_ids:
warn(
f"Domain '{rp_id}': related origin '{key}' collides "
"with an rp-id — dropped"
)
elif other := next(
(o for o in rp_ids if o != rp_id and hostutil.is_subdomain(hn, o)),
None,
):
warn(
f"Domain '{rp_id}': related origin '{key}' falls "
f"inside domain '{other}' — dropped"
)
elif hn in seen_auth_hosts:
warn(
f"Domain '{rp_id}': related origin '{key}' is the "
f"auth host of '{seen_auth_hosts[hn]}' — dropped"
)
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)"
)
else:
seen_related[hn] = rp_id
keep[key] = True
domain.related = keep
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 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)
)
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")
+5 -7
View File
@@ -5,20 +5,18 @@ from fastapi import FastAPI, Request
from paskia import db
from paskia.fastapi import authz
from paskia.fastapi.admin import (
domains,
oidc_clients,
orgs,
permissions,
roles,
users,
)
from paskia.fastapi.admin import (
realms as realms_admin,
)
from paskia.fastapi.admin.errors import install_error_handlers
from paskia.fastapi.front import frontend
from paskia.fastapi.response import MsgspecResponse
from paskia.fastapi.session import AUTH_COOKIE
from paskia.realms import current_realm
from paskia.domains import current_domain
from paskia.util import (
avatar,
permutil,
@@ -41,7 +39,7 @@ app.mount("/orgs", orgs.app)
app.mount("/roles", roles.app)
app.mount("/users", users.app)
app.mount("/permissions", permissions.app)
app.mount("/realms", realms_admin.app)
app.mount("/domains", domains.app)
def master_admin(ctx) -> bool:
@@ -97,10 +95,10 @@ async def admin_info(request: Request, auth=AUTH_COOKIE):
perms = db.data().permissions.values() if master_admin(ctx) else ctx.org.permissions
perms_dict = {p.uuid: ApiPermission.from_db(p) for p in perms}
# OIDC Clients (master admin only) — the current realm's provider
# OIDC Clients (master admin only) — the current domain's provider
oidc_clients_dict = {}
if master_admin(ctx):
provider = db.data().oidc_for(current_realm().rp_id)
provider = db.data().oidc_for(current_domain().rp_id)
clients = (
sorted(provider.clients.values(), key=lambda c: c.uuid) if provider else []
)
+218
View File
@@ -0,0 +1,218 @@
"""Domain (rp-id) management API — master admin only.
Each domain is one rp-id with its own rp-name, allowed in-domain sign-in
sites (origins, one of which may be marked as the auth host), and optional
related origins on unrelated domains (WebAuthn Related Origin Requests).
All changes are validated cross-domain before being persisted, and the
runtime domain registry is rebuilt after each change so it takes effect
immediately.
"""
from fastapi import Body, FastAPI, Request
from paskia import db, domains
from paskia.db.structs import Config, DomainConfig, OriginEntry
from paskia.fastapi import authz
from paskia.fastapi.admin.errors import install_error_handlers
from paskia.fastapi.response import MsgspecResponse
from paskia.fastapi.session import AUTH_COOKIE
from paskia.sansio import Passkey
from paskia.util import hostutil, oidjwt
from paskia.util.apistructs import ApiDomain
app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
install_error_handlers(app)
def _domain_to_api(domain: domains.Domain, registry: domains.DomainRegistry) -> ApiDomain:
return ApiDomain(
rp_id=domain.rp_id,
rp_name=domain.rp_name,
origins=domain.config.origins,
related=domain.config.related,
site_url=domain.site_url,
auth_site_url=domain.auth_site_url,
effective_auth_host=registry.effective_auth_host(domain),
)
def _normalize_origins_map(values: dict | None) -> dict[str, bool | OriginEntry]:
"""Normalize an origins object from the admin UI (raises on malformed).
Keys arrive as bare hosts, wildcard patterns, or full origins; they are
stored as origins-dict keys (https:// omitted).
"""
out: dict[str, bool | OriginEntry] = {}
for raw_key, raw_props in (values or {}).items():
key = raw_key.strip()
if not key:
continue
if not hostutil.is_wildcard_pattern(key):
key = domains.origin_key(hostutil.normalize_origin(key))
is_auth = raw_props is not True and bool((raw_props or {}).get("auth_host"))
out[key] = OriginEntry(auth_host=True) if is_auth else True
return out
def _normalize_related_map(values: dict | None) -> dict[str, bool]:
"""Normalize a related-origins object from the admin UI."""
out: dict[str, bool] = {}
for raw_key in (values or {}):
key = raw_key.strip()
if not key:
continue
if hostutil.is_wildcard_pattern(key):
raise ValueError(
f"Related origin '{key}' is a wildcard — related origins "
"(ROR) must be listed individually"
)
out[domains.origin_key(hostutil.normalize_origin(key))] = True
return out
def _rebuild_registry() -> None:
"""Rebuild the runtime domain registry from the stored configuration."""
domains.init_registry(db.data().config)
def _check_not_locking_self_out(
request: Request,
rp_id: str,
domain: DomainConfig,
) -> None:
"""Refuse domain changes that lock the admin out of their current host.
Applies when the admin edits the domain they are currently using and the
new config has no auth host (with an auth host, ceremonies move there
and it is always allowed). The admin's current host must remain able to
run passkey ceremonies under the new config.
"""
current: domains.Domain = request.state.domain
if rp_id != current.rp_id or domains.auth_host_url(domain):
return
raw_host = (request.headers.get("host") or "").rstrip(".")
if not raw_host:
return
probe = Passkey(
rp_id=rp_id,
origins=[domains.origin_url(k) for k in domain.origins] or None,
related_origins=[domains.origin_url(k) for k in domain.related],
)
for scheme in ("https", "http"):
try:
probe.validate_origin(f"{scheme}://{raw_host}")
return # Current host still works — no lockout
except ValueError:
pass
raise ValueError(
f"This change would lock you out: '{raw_host}' could no longer "
f"run passkey ceremonies for domain '{rp_id}'. Add it to the "
"allowed origins (or mark an auth host) before saving."
)
@app.get("/")
async def admin_list_domains(request: Request, auth=AUTH_COOKIE):
"""List all domains with derived URLs (master admin only)."""
await authz.verify(auth, ["auth:admin"], host=request.headers.get("host"))
registry = domains.registry()
return MsgspecResponse(
[_domain_to_api(domain, registry) for domain in registry.domains]
)
@app.post("/")
async def admin_create_domain(
request: Request,
payload: dict = Body(...),
auth=AUTH_COOKIE,
):
"""Add a new domain (master admin only, recent authentication required)."""
ctx = await authz.verify(
auth, ["auth:admin"], host=request.headers.get("host"), max_age="5m"
)
rp_id = (payload.get("rp_id") or "").strip().lower()
if not rp_id:
raise ValueError("rp_id is required")
new = DomainConfig(
rp_name=(payload.get("rp_name") or "").strip() or None,
origins=_normalize_origins_map(payload.get("origins")),
related=_normalize_related_map(payload.get("related")),
)
config = db.data().config
# Validate the would-be combined configuration before persisting
domains.validate_config(
Config(domains={**config.domains, rp_id: new}, listen=config.listen)
)
db.create_domain(rp_id, new, ctx=ctx)
_rebuild_registry()
return {"status": "ok"}
@app.patch("/{rp_id}")
async def admin_update_domain(
rp_id: str,
request: Request,
payload: dict = Body(...),
auth=AUTH_COOKIE,
):
"""Update a domain's rp_name, origins and related origins (replaced
wholesale).
The rp-id itself is immutable: credentials are stamped with it.
"""
ctx = await authz.verify(
auth, ["auth:admin"], host=request.headers.get("host"), max_age="5m"
)
config = db.data().config
if rp_id not in config.domains:
raise ValueError(f"Domain {rp_id} not found")
updated = DomainConfig(
rp_name=(payload.get("rp_name") or "").strip() or None,
origins=_normalize_origins_map(payload.get("origins")),
related=_normalize_related_map(payload.get("related")),
)
would_be = Config(
domains={k: updated if k == rp_id else v for k, v in config.domains.items()},
listen=config.listen,
)
domains.validate_config(would_be)
_check_not_locking_self_out(request, rp_id, updated)
db.update_domain(
rp_id,
rp_name=updated.rp_name,
origins=updated.origins,
related=updated.related,
ctx=ctx,
)
_rebuild_registry()
return {"status": "ok"}
@app.delete("/{rp_id}")
async def admin_delete_domain(
rp_id: str,
request: Request,
auth=AUTH_COOKIE,
):
"""Delete a domain (refused for the last domain or while credentials remain)."""
ctx = await authz.verify(
auth, ["auth:admin"], host=request.headers.get("host"), max_age="5m"
)
current: domains.Domain = request.state.domain
if rp_id == current.rp_id:
raise ValueError(
"Cannot delete the domain you are currently using — authenticate "
"on another domain first"
)
db.delete_domain(rp_id, ctx=ctx)
_rebuild_registry()
oidjwt.clear_key(rp_id)
return {"status": "ok"}
+5 -5
View File
@@ -8,7 +8,7 @@ from paskia.db.structs import Client
from paskia.fastapi import authz
from paskia.fastapi.admin.errors import install_error_handlers
from paskia.fastapi.session import AUTH_COOKIE
from paskia.realms import current_realm
from paskia.domains import current_domain
from paskia.util import permutil
app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
@@ -84,7 +84,7 @@ async def admin_create_oidc_client(
)
client.uuid = client_uuid
db.create_oid_client(current_realm().rp_id, client, ctx=ctx)
db.create_oid_client(current_domain().rp_id, client, ctx=ctx)
return {"status": "ok", "client_id": str(client.uuid)}
@@ -153,7 +153,7 @@ async def admin_update_oidc_client(
try:
db.update_oid_client(
current_realm().rp_id,
current_domain().rp_id,
client_uuid,
name=name,
redirect_uris=redirect_uris,
@@ -204,7 +204,7 @@ async def admin_reset_oidc_client_secret(
try:
db.reset_oid_client_secret(
current_realm().rp_id, client_uuid, secret_hash, ctx=ctx
current_domain().rp_id, client_uuid, secret_hash, ctx=ctx
)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
@@ -234,7 +234,7 @@ async def admin_delete_oidc_client(
)
try:
db.delete_oid_client(current_realm().rp_id, client_uuid, ctx=ctx)
db.delete_oid_client(current_domain().rp_id, client_uuid, ctx=ctx)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
+5 -5
View File
@@ -7,7 +7,7 @@ from paskia.db import Permission as PermDC
from paskia.fastapi import authz
from paskia.fastapi.admin.errors import install_error_handlers
from paskia.fastapi.session import AUTH_COOKIE
from paskia.realms import registry
from paskia.domains import registry
from paskia.util import hostutil, permutil, querysafe
app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
@@ -16,10 +16,10 @@ install_error_handlers(app)
def _validate_permission_domain(domain: str | None) -> None:
"""Validate that domain is a configured realm host or an OIDC client UUID.
"""Validate that domain is a configured domain host or an OIDC client UUID.
Accepted: any realm's rp-id or its subdomain, a related-origin hostname
of any realm, or the UUID of any realm's OIDC client (used for the
Accepted: any domain's rp-id or its subdomain, a related-origin hostname
of any domain, or the UUID of any domain's OIDC client (used for the
groups claim).
"""
if domain is None:
@@ -37,7 +37,7 @@ def _validate_permission_domain(domain: str | None) -> None:
if reg.resolve(domain) is not None:
return
raise ValueError(
f"Domain '{domain}' must belong to a configured realm or be an OIDC client UUID"
f"Domain '{domain}' must belong to a configured domain or be an OIDC client UUID"
)
-224
View File
@@ -1,224 +0,0 @@
"""Realm (rp-id) management API — master admin only.
Realms replace the old single-site server configuration: each realm is one
rp-id with its own rp-name, optional dedicated auth host, an optional
allow-list of in-domain sign-in sites (origins), and optional related
origins on unrelated domains (WebAuthn Related Origin Requests). All
changes are validated cross-realm before being persisted, and the runtime
realm registry is rebuilt after each change so it takes effect immediately.
"""
from fastapi import Body, FastAPI, Request
from paskia import db, realms
from paskia.db.structs import Config, RealmConfig
from paskia.fastapi import authz
from paskia.fastapi.admin.errors import install_error_handlers
from paskia.fastapi.response import MsgspecResponse
from paskia.fastapi.session import AUTH_COOKIE
from paskia.sansio import Passkey
from paskia.util import hostutil, oidjwt
from paskia.util.apistructs import ApiRealm
app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
install_error_handlers(app)
def _realm_to_api(realm: realms.Realm, registry: realms.RealmRegistry) -> ApiRealm:
return ApiRealm(
rp_id=realm.rp_id,
rp_name=realm.rp_name,
auth_host=realm.config.auth_host,
origins=list(realm.config.origins or []),
related_origins=realm.related_origins,
site_url=realm.site_url,
auth_site_url=realm.auth_site_url,
effective_auth_host=registry.effective_auth_host(realm),
is_default=realm is registry.default,
)
def _normalize_origins(values: list[str] | None) -> list[str] | None:
"""Normalize a list of origin URLs (raises ValueError on malformed)."""
return [
hostutil.normalize_origin(o.strip()) for o in values or [] if o.strip()
] or None
def _normalize_realm_fields(
rp_id: str,
auth_host: str | None,
origins: list[str] | None,
related_origins: list[str] | None,
) -> tuple[str | None, list[str] | None, list[str] | None]:
"""Normalize and validate auth_host/origins for a realm (raises ValueError)."""
if auth_host:
hostutil.validate_auth_host(auth_host, rp_id)
auth_host, origins = hostutil.normalize_auth_host_and_origins(
auth_host, _normalize_origins(origins)
)
return auth_host, origins, _normalize_origins(related_origins)
def _rebuild_registry() -> None:
"""Rebuild the runtime realm registry from the stored configuration."""
realms.init_registry(db.data().config)
def _check_not_locking_self_out(
request: Request,
realm: RealmConfig,
) -> None:
"""Refuse realm changes that lock the admin out of their current host.
Applies when the admin edits the realm they are currently using and the
new config has no auth host (with an auth host, ceremonies move there
and it is always allowed). The admin's current host must remain able to
run passkey ceremonies under the new config.
"""
current: realms.Realm = request.state.realm
if realm.rp_id != current.rp_id or realm.auth_host:
return
raw_host = (request.headers.get("host") or "").rstrip(".")
if not raw_host:
return
probe = Passkey(
rp_id=realm.rp_id,
origins=realm.origins,
related_origins=realm.related_origins,
)
for scheme in ("https", "http"):
try:
probe.validate_origin(f"{scheme}://{raw_host}")
return # Current host still works — no lockout
except ValueError:
pass
raise ValueError(
f"This change would lock you out: '{raw_host}' could no longer "
f"run passkey ceremonies for realm '{realm.rp_id}'. Add it to the "
"allowed sign-in sites (or configure an auth host) before saving."
)
@app.get("/")
async def admin_list_realms(request: Request, auth=AUTH_COOKIE):
"""List all realms with derived URLs (master admin only)."""
await authz.verify(auth, ["auth:admin"], host=request.headers.get("host"))
registry = realms.registry()
return MsgspecResponse(
[_realm_to_api(realm, registry) for realm in registry.realms]
)
@app.post("/")
async def admin_create_realm(
request: Request,
payload: dict = Body(...),
auth=AUTH_COOKIE,
):
"""Add a new realm (master admin only, recent authentication required)."""
ctx = await authz.verify(
auth, ["auth:admin"], host=request.headers.get("host"), max_age="5m"
)
rp_id = (payload.get("rp_id") or "").strip().lower()
if not rp_id:
raise ValueError("rp_id is required")
rp_name = (payload.get("rp_name") or "").strip() or None
auth_host = (payload.get("auth_host") or "").strip() or None
auth_host, origins, related_origins = _normalize_realm_fields(
rp_id, auth_host, payload.get("origins"), payload.get("related_origins")
)
config = db.data().config
new_realm = RealmConfig(
rp_id=rp_id,
rp_name=rp_name,
auth_host=auth_host,
origins=origins,
related_origins=related_origins,
)
# Validate the would-be combined configuration before persisting
realms.validate_config(
Config(realms=[*config.realms, new_realm], listen=config.listen)
)
db.create_realm(new_realm, ctx=ctx)
_rebuild_registry()
return {"status": "ok"}
@app.patch("/{rp_id}")
async def admin_update_realm(
rp_id: str,
request: Request,
payload: dict = Body(...),
auth=AUTH_COOKIE,
):
"""Update a realm's rp_name, auth_host, origins and related origins
(lists are replaced wholesale).
The rp-id itself is immutable: credentials are stamped with it.
"""
ctx = await authz.verify(
auth, ["auth:admin"], host=request.headers.get("host"), max_age="5m"
)
config = db.data().config
realm = config.find_realm(rp_id)
if realm is None:
raise ValueError(f"Realm {rp_id} not found")
rp_name = (payload.get("rp_name") or "").strip() or None
auth_host = (payload.get("auth_host") or "").strip() or None
auth_host, origins, related_origins = _normalize_realm_fields(
rp_id, auth_host, payload.get("origins"), payload.get("related_origins")
)
updated = RealmConfig(
rp_id=rp_id,
rp_name=rp_name,
auth_host=auth_host,
origins=origins,
related_origins=related_origins,
)
would_be = Config(
realms=[updated if r.rp_id == rp_id else r for r in config.realms],
listen=config.listen,
)
realms.validate_config(would_be)
_check_not_locking_self_out(request, updated)
db.update_realm(
rp_id,
rp_name=rp_name,
auth_host=auth_host,
origins=origins,
related_origins=related_origins,
ctx=ctx,
)
_rebuild_registry()
return {"status": "ok"}
@app.delete("/{rp_id}")
async def admin_delete_realm(
rp_id: str,
request: Request,
auth=AUTH_COOKIE,
):
"""Delete a realm (refused for the last realm or while credentials remain)."""
ctx = await authz.verify(
auth, ["auth:admin"], host=request.headers.get("host"), max_age="5m"
)
current: realms.Realm = request.state.realm
if rp_id == current.rp_id:
raise ValueError(
"Cannot delete the realm you are currently using — authenticate "
"on another realm first"
)
db.delete_realm(rp_id, ctx=ctx)
_rebuild_registry()
oidjwt.clear_key(rp_id)
return {"status": "ok"}
+2 -2
View File
@@ -9,7 +9,7 @@ from paskia.fastapi import authz
from paskia.fastapi.admin.errors import install_error_handlers
from paskia.fastapi.response import MsgspecResponse
from paskia.fastapi.session import AUTH_COOKIE
from paskia.realms import current_realm
from paskia.domains import current_domain
from paskia.util import avatar, hostutil, permutil
from paskia.util.apistructs import (
ApiAaguidInfo,
@@ -123,7 +123,7 @@ async def admin_create_user_registration_link(
token_type=token_type,
ctx=ctx,
)
url = current_realm().reset_link_url(token)
url = current_domain().reset_link_url(token)
return MsgspecResponse(
ApiCreateLinkResponse(
url=url,
+10 -10
View File
@@ -20,7 +20,7 @@ from paskia.authsession import EXPIRES, get_reset, session_ctx
from paskia.fastapi import authz, session, user
from paskia.fastapi.response import MsgspecResponse
from paskia.fastapi.session import AUTH_COOKIE, AUTH_COOKIE_NAME, get_client_ip
from paskia.realms import current_realm, registry
from paskia.domains import current_domain, registry
from paskia.util import hostutil, htmlutil, passphrase, permutil, userinfo
from paskia.util.apistructs import (
ApiCheckUserResponse,
@@ -300,15 +300,15 @@ async def forward_authentication(
@app.get("/settings")
async def get_settings():
realm = current_realm()
domain = current_domain()
return MsgspecResponse(
ApiSettings(
rp_id=realm.rp_id,
rp_name=realm.rp_name,
ui_base_path=realm.ui_base_path,
auth_host=registry().effective_auth_host(realm),
own_auth_host=realm.own_auth_host,
auth_site_url=realm.auth_site_url,
rp_id=domain.rp_id,
rp_name=domain.rp_name,
ui_base_path=domain.ui_base_path,
auth_host=registry().effective_auth_host(domain),
own_auth_host=domain.own_auth_host,
auth_site_url=domain.auth_site_url,
session_cookie=AUTH_COOKIE_NAME,
version=__version__,
),
@@ -407,8 +407,8 @@ async def api_set_session(
a = authcode.consume_cookie(auth.credentials)
if not a:
raise HTTPException(401, "Code expired or already used")
if a.rp_id != current_realm().rp_id:
raise HTTPException(401, "Code was issued for a different realm")
if a.rp_id != current_domain().rp_id:
raise HTTPException(401, "Code was issued for a different domain")
secret = a.session_key
+4 -4
View File
@@ -3,7 +3,7 @@
from fastapi import Request, Response
from fastapi.responses import RedirectResponse
from paskia.realms import current_realm
from paskia.domains import current_domain
from paskia.util import hostutil, passphrase
@@ -75,12 +75,12 @@ def redirect_to_root_on_auth_host(request: Request, cur: str, path: str) -> Resp
async def redirect_middleware(request: Request, call_next):
"""Middleware to handle auth host redirects.
Only the current realm's *own* auth host triggers redirects; a realm
without one serves its UI under /auth/ on its own hosts. Realms
Only the current domain's *own* auth host triggers redirects; a domain
without one serves its UI under /auth/ on its own hosts. Domains
relying on a shared (fallback) auth host use it for WS/restricted
API calls, not for redirects.
"""
cfg = current_realm().own_auth_host
cfg = current_domain().own_auth_host
if not cfg:
return await call_next(request)
+30 -30
View File
@@ -1,10 +1,10 @@
"""ASGI dispatch middleware: resolve the request Host to a realm.
"""ASGI dispatch middleware: resolve the request Host to a domain.
Every HTTP request and WebSocket connection is dispatched to exactly one
realm, resolved from the Host header via the realm registry. The resolved
realm is exposed as ``request.state.realm`` and through the
:func:`paskia.realms.current_realm` contextvar, which endpoint code uses
for all realm-dependent behavior (passkey configuration, OIDC provider,
domain, resolved from the Host header via the domain registry. The resolved
domain is exposed as ``request.state.domain`` and through the
:func:`paskia.domains.current_domain` contextvar, which endpoint code uses
for all domain-dependent behavior (passkey configuration, OIDC provider,
site URLs).
Unknown hosts are rejected before routing:
@@ -12,18 +12,18 @@ Unknown hosts are rejected before routing:
- HTTP: ``421 Misdirected Request``
- WebSocket: closed pre-accept with code 1008
For WebSocket connections the Origin header selects the realm when it
belongs to a different realm than the Host — a related-origin page using
the realm's auth host, or a realm without its own auth host using the
shared one. A cross-realm connection is only allowed when the Host is the
origin realm's effective auth host; otherwise the connection is closed
pre-accept. When the Origin is missing or unknown the Host realm applies
For WebSocket connections the Origin header selects the domain when it
belongs to a different domain than the Host — a related-origin page using
the domain's auth host, or a domain without its own auth host using the
shared one. A cross-domain connection is only allowed when the Host is the
origin domain's effective auth host; otherwise the connection is closed
pre-accept. When the Origin is missing or unknown the Host domain applies
and endpoint-side origin validation decides.
"""
from fastapi.responses import PlainTextResponse
from paskia import realms
from paskia import domains
from paskia.util import hostutil
_WS_CLOSE_POLICY_VIOLATION = 1008
@@ -39,7 +39,7 @@ def _header(scope: dict, name: str) -> str | None:
class DispatchMiddleware:
"""Pure ASGI middleware dispatching each connection to its realm."""
"""Pure ASGI middleware dispatching each connection to its domain."""
def __init__(self, app):
self.app = app
@@ -53,28 +53,28 @@ class DispatchMiddleware:
await self.app(scope, receive, send)
async def _http(self, scope, receive, send):
realm = realms.registry().resolve(_header(scope, "host"))
if realm is None:
domain = domains.registry().resolve(_header(scope, "host"))
if domain is None:
response = PlainTextResponse("Unknown host", status_code=421)
await response(scope, receive, send)
return
await self._dispatch(scope, receive, send, realm)
await self._dispatch(scope, receive, send, domain)
async def _websocket(self, scope, receive, send):
registry = realms.registry()
registry = domains.registry()
host = _header(scope, "host")
host_realm = registry.resolve(host)
if host_realm is None:
host_domain = registry.resolve(host)
if host_domain is None:
await send({"type": "websocket.close", "code": _WS_CLOSE_POLICY_VIOLATION})
return
realm = host_realm
domain = host_domain
origin = _header(scope, "origin")
origin_host = hostutil.origin_hostname(origin) if origin else None
origin_realm = registry.resolve(origin_host) if origin_host else None
if origin_realm is not None and origin_realm is not host_realm:
# Cross-realm connection: only via the origin realm's auth host.
effective = registry.effective_auth_host(origin_realm)
origin_domain = registry.resolve(origin_host) if origin_host else None
if origin_domain is not None and origin_domain is not host_domain:
# Cross-domain connection: only via the origin domain's auth host.
effective = registry.effective_auth_host(origin_domain)
if not effective or hostutil.normalize_host(
host
) != hostutil.normalize_host(effective):
@@ -82,13 +82,13 @@ class DispatchMiddleware:
{"type": "websocket.close", "code": _WS_CLOSE_POLICY_VIOLATION}
)
return
realm = origin_realm
await self._dispatch(scope, receive, send, realm)
domain = origin_domain
await self._dispatch(scope, receive, send, domain)
async def _dispatch(self, scope, receive, send, realm: realms.Realm):
scope.setdefault("state", {})["realm"] = realm
token = realms.set_current_realm(realm)
async def _dispatch(self, scope, receive, send, domain: domains.Domain):
scope.setdefault("state", {})["domain"] = domain
token = domains.set_current_domain(domain)
try:
await self.app(scope, receive, send)
finally:
realms.reset_current_realm(token)
domains.reset_current_domain(token)
+11 -11
View File
@@ -7,7 +7,7 @@ from fastapi import FastAPI, HTTPException, Request, Response
from fastapi.responses import FileResponse, RedirectResponse
from kanta.logging import configure_logging as configure_kanta_logging
from paskia import authcode, db, realms, remoteauth
from paskia import authcode, db, domains, remoteauth
from paskia.bootstrap import bootstrap_if_needed
from paskia.db.background import start_background, stop_background
from paskia.db.lifecycle import kanta
@@ -31,22 +31,22 @@ _EXAMPLES_DIR = Path(__file__).parent.parent.parent / "examples"
@asynccontextmanager
async def lifespan(app: FastAPI): # pragma: no cover - startup path
"""Application lifespan: open the combined database and build the realm registry.
"""Application lifespan: open the combined database and build the domain registry.
Process-global serve parameters (listen endpoints) are passed via the
PASKIA_CONFIG JSON env variable (set by the CLI entrypoint) so that
uvicorn reload / multiprocess workers derive site URLs the same way.
Realm configuration is read from the database.
Domain configuration is read from the database.
"""
cfg = serve_config()
realms.configure(listen=cfg.listen if cfg else None)
domains.configure(listen=cfg.listen if cfg else None)
await asyncio.to_thread(
Path(kanta.filename).parent.mkdir, parents=True, exist_ok=True
)
async with kanta:
try:
realms.init_registry(db.data().config)
domains.init_registry(db.data().config)
await remoteauth.init()
await authcode.start()
except ValueError as e:
@@ -77,8 +77,8 @@ app = FastAPI(
# Apply redirections to auth-host if configured (deny access to restricted endpoints, remove /auth/)
app.middleware("http")(auth_host.redirect_middleware)
# Realm dispatch must be the outermost application middleware: everything
# below it (including the auth-host redirects) uses the current realm.
# Domain dispatch must be the outermost application middleware: everything
# below it (including the auth-host redirects) uses the current domain.
app.add_middleware(DispatchMiddleware)
app.mount("/auth/api/admin/", admin.app)
@@ -130,11 +130,11 @@ async def openid_configuration(request: Request):
async def webauthn_related_origins(request: Request):
"""WebAuthn Related Origin Requests discovery document.
Served on the realm's rp-id site; lists the realm's related origins
(other domains) that may assert this rp-id. 404 when the realm has no
Served on the domain's rp-id site; lists the domain's related origins
(other domains) that may assert this rp-id. 404 when the domain has no
related origins.
"""
related = request.state.realm.related_origins
related = request.state.domain.related_origins
if not related:
raise HTTPException(status_code=404)
return {"origins": related}
@@ -166,7 +166,7 @@ async def frontapp(request: Request, response: Response, auth=AUTH_COOKIE):
@app.get("/auth/admin", include_in_schema=False)
async def admin_root_redirect():
return RedirectResponse(
f"{realms.current_realm().ui_base_path}admin/", status_code=307
f"{domains.current_domain().ui_base_path}admin/", status_code=307
)
+13 -13
View File
@@ -22,7 +22,7 @@ from fastapi.security import HTTPBearer
from paskia import authcode, db
from paskia.db.structs import OIDC, Session
from paskia.realms import current_realm
from paskia.domains import current_domain
from paskia.util import avatar, oidjwt
from paskia.util.crypto import hash_secret
@@ -32,17 +32,17 @@ app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
def _provider() -> OIDC:
"""Return the OIDC provider state of the current request's realm."""
provider = db.data().oidc_for(current_realm().rp_id)
if provider is None: # pragma: no cover - invariant: realms always seed OIDC
raise RuntimeError(f"No OIDC provider for realm {current_realm().rp_id}")
"""Return the OIDC provider state of the current request's domain."""
provider = db.data().oidc_for(current_domain().rp_id)
if provider is None: # pragma: no cover - invariant: domains always seed OIDC
raise RuntimeError(f"No OIDC provider for domain {current_domain().rp_id}")
return provider
@app.get("/keys")
async def keys():
"""JSON Web Key Set for token verification."""
return oidjwt.get_jwks(current_realm().rp_id)
return oidjwt.get_jwks(current_domain().rp_id)
def _oidc_session_by_token(
@@ -197,12 +197,12 @@ async def _handle_authorization_code(
status_code=400,
)
# The code is bound to the realm it was issued in (dispatched by Host)
if oidc_code.rp_id != current_realm().rp_id:
# The code is bound to the domain it was issued in (dispatched by Host)
if oidc_code.rp_id != current_domain().rp_id:
return JSONResponse(
{
"error": "invalid_grant",
"error_description": "Code was issued for a different realm",
"error_description": "Code was issued for a different domain",
},
status_code=400,
)
@@ -347,7 +347,7 @@ def _build_token_response(
credential_uuid: UUID | None = None,
):
"""Build the token response with access_token, id_token, and refresh_token."""
rp_id = current_realm().rp_id
rp_id = current_domain().rp_id
issuer = _get_issuer(request)
# Get user's permissions scoped to this OIDC client (domain == client UUID)
@@ -424,7 +424,7 @@ async def userinfo(
if not credentials:
raise HTTPException(401, "Bearer token required")
rp_id = current_realm().rp_id
rp_id = current_domain().rp_id
issuer = _get_issuer(request)
payload = oidjwt.decode_access_token(rp_id, credentials.credentials, issuer)
if not payload:
@@ -510,7 +510,7 @@ async def backchannel_logout(
)
# Decode and verify the logout token
rp_id = current_realm().rp_id
rp_id = current_domain().rp_id
issuer = _get_issuer(request)
payload = oidjwt.decode_access_token(rp_id, logout_token, issuer)
if not payload:
@@ -581,7 +581,7 @@ async def backchannel_logout(
{"error": "invalid_request", "error_description": "Invalid sub claim"},
status_code=400,
)
# Find and delete matching sessions (this realm's OIDC sessions only)
# Find and delete matching sessions (this domain's OIDC sessions only)
sessions_to_delete = [
s
for s in db.data().sessions.values()
+7 -7
View File
@@ -22,7 +22,7 @@ from paskia.authsession import expires
from paskia.fastapi.session import AUTH_COOKIE, infodict
from paskia.fastapi.wschat import authenticate_and_login
from paskia.fastapi.wsutil import validate_origin, websocket_error_handler
from paskia.realms import current_realm, registry
from paskia.domains import current_domain, registry
from paskia.util import pow, useragent
# Create a FastAPI subapp for remote auth WebSocket endpoints
@@ -95,7 +95,7 @@ async def websocket_remote_auth_request(ws: WebSocket):
host=host,
ip=metadata.get("ip") or "",
user_agent=metadata.get("user_agent") or "",
rp_id=current_realm().rp_id,
rp_id=current_domain().rp_id,
action=action,
)
@@ -335,8 +335,8 @@ async def websocket_remote_auth_permit(ws: WebSocket, auth=AUTH_COOKIE):
)
# Create exchange code for the session (don't expose raw secret)
# Stamped with the *requesting* device's realm: it redeems the
# code on its own host, which dispatches to that realm.
# Stamped with the *requesting* device's domain: it redeems the
# code on its own host, which dispatches to that domain.
exchange_code = authcode.store_cookie(
CookieCode(
session_key=secret,
@@ -446,15 +446,15 @@ async def websocket_remote_auth_permit(ws: WebSocket, auth=AUTH_COOKIE):
request.action = locked_action # Update local copy with locked value
# Send device info to the authenticating device, including the
# requesting device's realm (may differ from the approver's)
requesting_realm = registry().get(request.rp_id)
# requesting device's domain (may differ from the approver's)
requesting_domain = registry().get(request.rp_id)
await ws.send_json(
{
"status": "found",
"host": request.host,
"rp_id": request.rp_id,
"rp_name": (
requesting_realm.rp_name if requesting_realm else request.rp_id
requesting_domain.rp_name if requesting_domain else request.rp_id
),
"user_agent_pretty": useragent.compact_user_agent(
request.user_agent
+2 -2
View File
@@ -20,7 +20,7 @@ from paskia.authsession import (
from paskia.fastapi import authz, session
from paskia.fastapi.response import MsgspecResponse
from paskia.fastapi.session import AUTH_COOKIE
from paskia.realms import current_realm
from paskia.domains import current_domain
from paskia.util import avatar
from paskia.util.apistructs import ApiCreateLinkResponse
@@ -292,7 +292,7 @@ async def api_create_link(
token_type="device addition",
ctx=ctx,
)
url = current_realm().reset_link_url(token)
url = current_domain().reset_link_url(token)
return MsgspecResponse(
ApiCreateLinkResponse(
message="Registration link generated successfully",
+11 -11
View File
@@ -17,7 +17,7 @@ from paskia.fastapi.wschat import (
register_chat,
)
from paskia.fastapi.wsutil import validate_origin, websocket_error_handler
from paskia.realms import current_realm
from paskia.domains import current_domain
from paskia.util import hostutil, passphrase
from paskia.util.crypto import hash_secret
@@ -28,7 +28,7 @@ def create_exchange_code(session_key: str) -> str:
cookie_code = CookieCode(
session_key=session_key,
created=now,
rp_id=current_realm().rp_id,
rp_id=current_domain().rp_id,
)
return authcode.store_cookie(cookie_code)
@@ -56,11 +56,11 @@ async def websocket_register_add(
"""
origin = validate_origin(ws)
host = hostutil.normalize_host(origin.split("://", 1)[1])
realm = current_realm()
domain = current_domain()
if reset is not None:
if not passphrase.is_well_formed(reset):
raise ValueError(
f"The reset link for {realm.rp_name} is invalid or has expired"
f"The reset link for {domain.rp_name} is invalid or has expired"
)
s = get_reset(reset)
user_uuid = s.user_uuid
@@ -77,7 +77,7 @@ async def websocket_register_add(
stripped = name.strip()
if stripped:
user_name = stripped
credential_ids = user.credential_ids_for(realm.rp_id) or None
credential_ids = user.credential_ids_for(domain.rp_id) or None
# WebAuthn registration
credential = await register_chat(ws, user_uuid, user_name, origin, credential_ids)
@@ -125,7 +125,7 @@ async def websocket_authenticate(
):
origin = validate_origin(ws)
host = origin.split("://", 1)[1]
realm = current_realm()
domain = current_domain()
# OIDC mode: validate client before auth
oidc_client = None
@@ -136,7 +136,7 @@ async def websocket_authenticate(
await ws.send_json({"status": 400, "detail": "Invalid client_id"})
return
oidc_client = db.data().oidc_for(realm.rp_id).clients.get(client_uuid)
oidc_client = db.data().oidc_for(domain.rp_id).clients.get(client_uuid)
if not oidc_client:
await ws.send_json({"status": 400, "detail": "Unknown client_id"})
return
@@ -148,9 +148,9 @@ async def websocket_authenticate(
await ws.send_json({"status": 400, "detail": "Invalid redirect_uri"})
return
# Store as the only allowed redirect URI
db.update_oid_client(realm.rp_id, client_uuid, redirect_uris=[redirect_uri])
db.update_oid_client(domain.rp_id, client_uuid, redirect_uris=[redirect_uri])
# Reload client to get updated redirect_uris
oidc_client = db.data().oidc_for(realm.rp_id).clients.get(client_uuid)
oidc_client = db.data().oidc_for(domain.rp_id).clients.get(client_uuid)
elif redirect_uri not in oidc_client.redirect_uris:
await ws.send_json({"status": 400, "detail": "Invalid redirect_uri"})
return
@@ -224,7 +224,7 @@ async def websocket_authenticate(
user_agent=metadata["user_agent"],
validated=now,
client=oidc_client.uuid,
rp_id=realm.rp_id,
rp_id=domain.rp_id,
issuer=origin,
)
db.oidc_login(
@@ -238,7 +238,7 @@ async def websocket_authenticate(
created=now,
redirect_uri=redirect_uri,
scope=scope,
rp_id=realm.rp_id,
rp_id=domain.rp_id,
nonce=nonce,
code_challenge=code_challenge,
)
+10 -10
View File
@@ -11,7 +11,7 @@ from paskia.authsession import session_ctx
from paskia.db import Credential, SessionContext
from paskia.fastapi.session import infodict
from paskia.fastapi.wsutil import validate_origin
from paskia.realms import current_realm, registry
from paskia.domains import current_domain, registry
from paskia.util import hostutil
@@ -23,7 +23,7 @@ async def register_chat(
credential_ids: list[bytes] | None = None,
):
"""Run WebAuthn registration flow and return the verified credential."""
passkey = current_realm().passkey
passkey = current_domain().passkey
options, challenge = passkey.reg_generate_options(
user_id=user_uuid,
user_name=user_name,
@@ -43,8 +43,8 @@ async def authenticate_chat(
Returns:
tuple of (credential, new_sign_count) where new_sign_count comes from WebAuthn verification
"""
realm = current_realm()
passkey = realm.passkey
domain = current_domain()
passkey = domain.passkey
origin = validate_origin(ws)
options, challenge = passkey.auth_generate_options(credential_ids=credential_ids)
await ws.send_json({"optionsJSON": options})
@@ -54,7 +54,7 @@ async def authenticate_chat(
(
c
for c in db.data().credentials.values()
if c.credential_id == authcred.raw_id and c.rp_id == realm.rp_id
if c.credential_id == authcred.raw_id and c.rp_id == domain.rp_id
),
None,
)
@@ -81,14 +81,14 @@ async def authenticate_and_login(
ws: The WebSocket connection (used for WebAuthn and origin validation)
auth: Existing session cookie for re-auth credential restriction
session_host: Override host for the new session (defaults to ws origin);
must belong to a configured realm
must belong to a configured domain
session_ip: Override IP for the new session (defaults to ws client IP)
session_user_agent: Override user-agent for the new session (defaults to ws headers)
Returns:
Tuple of (SessionContext for the authenticated session, session secret)
"""
realm = current_realm()
domain = current_domain()
origin = validate_origin(ws)
host = origin.split("://", 1)[1]
normalized_host = hostutil.normalize_host(host)
@@ -101,7 +101,7 @@ async def authenticate_and_login(
if auth:
existing_ctx = session_ctx(auth, host)
if existing_ctx:
credential_ids = existing_ctx.user.credential_ids_for(realm.rp_id) or None
credential_ids = existing_ctx.user.credential_ids_for(domain.rp_id) or None
cred, new_sign_count = await authenticate_chat(ws, credential_ids)
@@ -114,7 +114,7 @@ async def authenticate_and_login(
if not login_host:
raise ValueError("Host required for session creation")
if session_host is not None and registry().resolve(login_host) is None:
raise ValueError(f"Host '{login_host}' does not belong to a configured realm")
raise ValueError(f"Host '{login_host}' does not belong to a configured domain")
login_ip = session_ip if session_ip is not None else metadata["ip"]
login_user_agent = (
session_user_agent if session_user_agent is not None else metadata["user_agent"]
@@ -128,7 +128,7 @@ async def authenticate_and_login(
host=login_host,
ip=login_ip,
user_agent=login_user_agent,
rp_id=realm.rp_id,
rp_id=domain.rp_id,
)
# Fetch and return the full session context (using the same host the session was created with)
+3 -3
View File
@@ -10,7 +10,7 @@ from fastapi import WebSocket, WebSocketDisconnect
from webauthn.helpers.exceptions import InvalidAuthenticationResponse
from paskia.fastapi import authz
from paskia.realms import current_realm
from paskia.domains import current_domain
from paskia.util import pow
@@ -83,9 +83,9 @@ def validate_origin(ws: WebSocket) -> str:
"""Extract and validate origin from WebSocket request headers.
Raises:
ValueError: If origin header is missing or not allowed in the current realm
ValueError: If origin header is missing or not allowed in the current domain
"""
origin = ws.headers.get("origin")
if not origin:
raise ValueError("Origin header is required for WebSocket connections")
return current_realm().passkey.validate_origin(origin)
return current_domain().passkey.validate_origin(origin)
+13 -16
View File
@@ -4,8 +4,8 @@ OIDC Back-Channel Logout notifications.
When sessions are deleted (logout, admin, expiry), this module notifies
any OIDC clients that have a backchannel_logout_uri configured.
Notifications run without request context, so the realm and issuer come
from the session itself: ``Session.rp_id`` selects the realm's signing key
Notifications run without request context, so the domain and issuer come
from the session itself: ``Session.rp_id`` selects the domain's signing key
and ``Session.issuer`` (stamped at session creation/refresh) is the `iss`.
"""
@@ -15,7 +15,7 @@ from uuid import UUID
import httpx
from paskia import db, realms
from paskia import db, domains
from paskia.util import oidjwt
_logger = logging.getLogger(__name__)
@@ -24,17 +24,14 @@ _logger = logging.getLogger(__name__)
_TIMEOUT = httpx.Timeout(10.0, connect=5.0)
def _session_realm(rp_id: str | None):
"""Resolve a session's realm, falling back to the default realm."""
def _session_domain(rp_id: str | None):
"""Resolve a session's domain by its rp-id stamp (None if unknown)."""
if not rp_id:
return None
try:
reg = realms.registry()
return domains.registry().get(rp_id)
except RuntimeError:
return None
if rp_id:
realm = reg.get(rp_id)
if realm is not None:
return realm
return reg.default
def _collect_oidc_sessions(
@@ -51,18 +48,18 @@ def _collect_oidc_sessions(
session = data.sessions.get(key)
if not session or session.client_uuid is None:
continue
realm = _session_realm(session.rp_id)
if realm is None:
domain = _session_domain(session.rp_id)
if domain is None:
continue
provider = data.oidc.get(realm.rp_id)
provider = data.oidc.get(domain.rp_id)
client = provider.clients.get(session.client_uuid) if provider else None
if not client or not client.backchannel_logout_uri:
continue
issuer = session.issuer or realm.site_url
issuer = session.issuer or domain.site_url
notifications.append(
(
client.backchannel_logout_uri,
realm.rp_id,
domain.rp_id,
issuer,
session.key,
session.client_uuid,
-493
View File
@@ -1,493 +0,0 @@
"""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 logging
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
logger = logging.getLogger(__name__)
# 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,
related_origins=config.related_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 related (cross-domain) origins for ROR."""
return list(self.config.related_origins or [])
@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] = {}
self.warnings: list[str] = []
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
for origin in realm.origins or []:
hn = hostutil.origin_hostname(origin)
if not hn:
raise ValueError(f"Invalid origin URL: '{origin}'")
if not hostutil.is_subdomain(hn, realm.rp_id):
raise ValueError(
f"Origin '{origin}' is outside the rp-id domain "
f"'{realm.rp_id}' — configure it as a related origin instead"
)
if len(realm.related_origins or []) > related_origin_cap:
raise ValueError(
f"Realm '{realm.rp_id}' has {len(realm.related_origins or [])} "
f"related origins (maximum {related_origin_cap})"
)
for origin in realm.related_origins or []:
if hostutil.is_wildcard_pattern(origin):
raise ValueError(
f"Related origin '{origin}' is a wildcard — related "
"origins (ROR) must be listed individually"
)
hn = hostutil.origin_hostname(origin)
if not hn:
raise ValueError(f"Invalid related origin URL: '{origin}'")
if hostutil.is_subdomain(hn, realm.rp_id):
raise ValueError(
f"Related origin '{origin}' is within the rp-id domain "
f"'{realm.rp_id}' — subdomains need no related origin entry"
)
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
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 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 realm 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] = []
if not config.realms:
raise ValueError("At least one realm (rp-id) is required")
def warn(msg: str) -> None:
warnings.append(msg)
realms: list[RealmConfig] = []
seen_rp_ids: set[str] = set()
for realm in config.realms:
rp_id = realm.rp_id
try:
hostutil.validate_rp_id(rp_id)
except ValueError as e:
warn(f"Realm dropped: {e}")
continue
if rp_id in seen_rp_ids:
warn(f"Duplicate realm '{rp_id}' dropped (first entry kept)")
continue
seen_rp_ids.add(rp_id)
auth_host = realm.auth_host
if auth_host:
try:
hostutil.validate_auth_host(auth_host, rp_id)
except ValueError as e:
warn(f"Realm '{rp_id}': {e} — auth host ignored")
auth_host = None
origins = []
related = list(realm.related_origins or [])
for origin in realm.origins or []:
hn = hostutil.origin_hostname(origin)
if not hn:
warn(f"Realm '{rp_id}': invalid origin '{origin}' dropped")
continue
if hostutil.is_subdomain(hn, rp_id):
origins.append(origin)
else:
warn(
f"Realm '{rp_id}': origin '{origin}' is outside the rp-id "
"domain — treating it as a related origin; fix the lists "
"in the admin interface"
)
related.append(origin)
related_ok = []
for origin in related:
if hostutil.is_wildcard_pattern(origin):
warn(
f"Realm '{rp_id}': related origin '{origin}' is a "
"wildcard — dropped (ROR entries must be individual)"
)
continue
hn = hostutil.origin_hostname(origin)
if not hn:
warn(f"Realm '{rp_id}': invalid related origin '{origin}' dropped")
continue
if hostutil.is_subdomain(hn, rp_id):
warn(
f"Realm '{rp_id}': related origin '{origin}' is within the "
"rp-id domain — dropped (subdomains need no related entry)"
)
continue
related_ok.append(origin)
related_ok = list(dict.fromkeys(related_ok))
if len(related_ok) > related_origin_cap:
warn(
f"Realm '{rp_id}': {len(related_ok)} related origins exceed the "
f"maximum of {related_origin_cap} — extras dropped"
)
del related_ok[related_origin_cap:]
realms.append(
RealmConfig(
rp_id=rp_id,
rp_name=realm.rp_name,
auth_host=auth_host,
origins=list(dict.fromkeys(origins)) or None,
related_origins=related_ok or None,
)
)
if not realms:
raise ValueError("No servable realm in the stored configuration")
# Cross-realm collisions: keep the first configured claimant, drop the
# rest with a warning so dispatch stays deterministic.
rp_ids = {r.rp_id for r in realms}
seen_auth_hosts: dict[str, str] = {}
for realm in realms:
if not realm.auth_host:
continue
hn = hostutil.normalize_host(hostutil.auth_host_netloc(realm.auth_host))
if hn in rp_ids:
warn(
f"Realm '{realm.rp_id}': auth host '{hn}' collides with an "
"rp-id — auth host ignored"
)
realm.auth_host = None
elif hn in seen_auth_hosts:
warn(
f"Realm '{realm.rp_id}': auth host '{hn}' is also used by "
f"'{seen_auth_hosts[hn]}' — auth host ignored"
)
realm.auth_host = None
else:
seen_auth_hosts[hn] = realm.rp_id
seen_related: dict[str, str] = {}
for realm in realms:
keep = []
for origin in realm.related_origins or []:
hn = hostutil.origin_hostname(origin)
if hn in rp_ids:
warn(
f"Realm '{realm.rp_id}': related origin '{origin}' "
"collides with an rp-id — dropped"
)
elif other := next(
(
o
for o in rp_ids
if o != realm.rp_id and hostutil.is_subdomain(hn, o)
),
None,
):
warn(
f"Realm '{realm.rp_id}': related origin '{origin}' falls "
f"inside realm '{other}' — dropped"
)
elif hn in seen_auth_hosts:
warn(
f"Realm '{realm.rp_id}': related origin '{origin}' is the "
f"auth host of '{seen_auth_hosts[hn]}' — dropped"
)
elif hn in seen_related:
warn(
f"Realm '{realm.rp_id}': related origin '{origin}' is also "
f"used by '{seen_related[hn]}' — dropped (first realm wins)"
)
else:
seen_related[hn] = realm.rp_id
keep.append(origin)
realm.related_origins = keep or None
return Config(realms=realms, listen=config.listen), warnings
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:
for origin in realm.origins:
if not hostutil.is_wildcard_pattern(origin):
return origin, "/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:
"""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")
realms = [
Realm(
rc,
*_derive_site(rc, listen_port=endpoint.get("port"), vite_url=vite_url),
)
for rc in config.realms
]
registry = RealmRegistry(realms)
registry.warnings = warnings
for warning in warnings:
logger.warning("Config: %s", warning)
return registry
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
+1 -1
View File
@@ -39,7 +39,7 @@ class RemoteAuthRequest:
host: str # The host where the session should be created
ip: str # IP of the requesting device
user_agent: str # User agent of the requesting device
rp_id: str # Realm of the requesting device (session/exchange codes are stamped with it)
rp_id: str # Domain of the requesting device (session/exchange codes are stamped with it)
action: str = "login" # "login" or "register"
locked: bool = False # True once the authenticating device has entered the code
# Callback to notify the requesting device when auth completes
+13 -10
View File
@@ -12,7 +12,7 @@ from uuid import UUID
import msgspec
from paskia import db
from paskia.db.structs import Credential, Org, Permission, Role, User
from paskia.db.structs import Credential, Org, OriginEntry, Permission, Role, User
from paskia.util import useragent
# -------------------------------------------------------------------------
@@ -161,10 +161,10 @@ class ApiOrgResponse(msgspec.Struct, kw_only=True):
class ApiSettings(msgspec.Struct):
"""Settings response struct (per the realm the request was dispatched to).
"""Settings response struct (per the domain the request was dispatched to).
auth_host is the realm's effective auth host (its own, or the shared
fallback of another realm); own_auth_host is set only when this realm
auth_host is the domain's effective auth host (its own, or the shared
fallback of another domain); own_auth_host is set only when this domain
has its own dedicated auth host.
"""
@@ -178,18 +178,21 @@ class ApiSettings(msgspec.Struct):
version: str
class ApiRealm(msgspec.Struct):
"""Realm entry in the admin realm list response."""
class ApiDomain(msgspec.Struct):
"""Domain entry in the admin domain list response.
origins/related mirror the stored configuration: objects keyed by host
or wildcard pattern (https:// omitted), values True or an object with
extra properties (auth_host).
"""
rp_id: str
rp_name: str
auth_host: str | None
origins: list[str]
related_origins: list[str]
origins: dict[str, bool | OriginEntry]
related: dict[str, bool]
site_url: str
auth_site_url: str
effective_auth_host: str | None
is_default: bool
class ApiTokenInfo(msgspec.Struct, omit_defaults=True):
+2 -2
View File
@@ -10,7 +10,7 @@ from uuid import UUID
from fastapi import HTTPException, UploadFile
from paskia.db.paths import users_root_path
from paskia.realms import current_realm
from paskia.domains import current_domain
MAX_UPLOAD_BYTES = 10 * 1024 * 1024
@@ -46,7 +46,7 @@ def avatar_url(user_uuid: UUID) -> str | None:
"""Return the absolute public avatar URL for a user, or None."""
if not avatar_path(user_uuid).is_file():
return None
return current_realm().api_url(f"user/{user_uuid}/profile.webp")
return current_domain().api_url(f"user/{user_uuid}/profile.webp")
def current_avatar_url(user_uuid: UUID) -> str | None:
+10 -10
View File
@@ -1,7 +1,7 @@
"""
OIDC JWT utilities for signing ID tokens and serving JWKS.
Each realm is an independent OIDC provider with its own signing key;
Each domain is an independent OIDC provider with its own signing key;
keys are cached per rp-id.
"""
@@ -21,16 +21,16 @@ from paskia.util.crypto import (
secret_key,
)
# JWT signing keys (loaded on first use), keyed by realm rp-id
# JWT signing keys (loaded on first use), keyed by domain rp-id
_keys: dict[str, tuple[object, object, str]] = {}
def _load_or_generate_key(rp_id: str) -> tuple[object, object, str]:
"""Load a realm's Ed25519 key or generate and store a new one."""
"""Load a domain's Ed25519 key or generate and store a new one."""
data = db.data()
provider = data.oidc.get(rp_id)
if provider is None:
raise RuntimeError(f"No OIDC provider for realm {rp_id}")
raise RuntimeError(f"No OIDC provider for domain {rp_id}")
store = data._store
if store is None:
raise RuntimeError("Kanta store is not initialized")
@@ -49,14 +49,14 @@ def _load_or_generate_key(rp_id: str) -> tuple[object, object, str]:
def _ensure_key(rp_id: str) -> tuple[object, object, str]:
"""Ensure a realm's key is loaded and return (private, public, kid)."""
"""Ensure a domain's key is loaded and return (private, public, kid)."""
if rp_id not in _keys:
_keys[rp_id] = _load_or_generate_key(rp_id)
return _keys[rp_id]
def clear_key(rp_id: str) -> None:
"""Drop a realm's cached key (realm deleted or key rotated)."""
"""Drop a domain's cached key (domain deleted or key rotated)."""
_keys.pop(rp_id, None)
@@ -97,7 +97,7 @@ def create_id_token(
"""Create a signed ID token (JWT).
Args:
rp_id: Realm whose signing key to use
rp_id: Domain whose signing key to use
issuer: Token issuer (site URL)
subject: User UUID (sub claim)
audience: Client ID (aud claim)
@@ -154,7 +154,7 @@ def create_access_token(
"""Create a signed access token (JWT) for userinfo endpoint.
Args:
rp_id: Realm whose signing key to use
rp_id: Domain whose signing key to use
issuer: Token issuer (site URL)
subject: User UUID
audience: Client ID
@@ -183,7 +183,7 @@ def decode_access_token(
"""Decode and verify an access token.
Args:
rp_id: Realm whose key to verify with
rp_id: Domain whose key to verify with
token: JWT string
issuer: Expected issuer
audience: Optional expected audience (client_id). If provided, aud claim must match.
@@ -226,7 +226,7 @@ def create_logout_token(
either sid (session) or sub (user), or both.
Args:
rp_id: Realm whose signing key to use
rp_id: Domain whose signing key to use
issuer: Token issuer (site URL)
audience: Client ID (aud claim)
sid: Session ID (base64url-encoded)
+1 -1
View File
@@ -1,6 +1,6 @@
"""Runtime serve configuration (process-global parameters only).
Realm configuration lives in the database (``Config.realms``); the
Domain configuration lives in the database (``Config.domains``); the
``PASKIA_CONFIG`` environment variable only carries the effective listen
endpoints so that child processes (uvicorn reload / workers) can derive
site URLs the same way the parent did.
+23 -24
View File
@@ -14,7 +14,9 @@ from paskia.util.constants import DEFAULT_PORT, DEVMODE
from paskia.util.hostutil import format_endpoint
if TYPE_CHECKING:
from paskia.realms import RealmRegistry
from paskia.domains import DomainRegistry
from paskia.domains import origin_url
BOX_WIDTH = 60 # Inner width (excluding box chars)
@@ -49,16 +51,16 @@ def bottom() -> str:
def print_startup_config(
registry: RealmRegistry, listen: list[str] | None = None
registry: DomainRegistry, listen: list[str] | None = None
) -> None:
"""Print server configuration on startup (one section per realm)."""
"""Print server configuration on startup (one section per domain)."""
# Key graphic with yellow shading (bright for highlights, dark for body)
y = YELLOW # Bright golden yellow for main body
b = BRIGHT_YELLOW # Brightest yellow for highlights/edges
w = BRIGHT_WHITE # Bold white for URL
r = RESET
default = registry.default
domains = sorted(registry.domains, key=lambda d: d.rp_id)
lines = [top()]
lines.append(line(f" {b}▄▄▄▄▄{r}"))
@@ -67,8 +69,8 @@ def print_startup_config(
lines.append(
line(
f"{b}{y} {b}{y}▀▀▀▀{b}{y}▀▀{b}{y}▀▀{b}{r} {w}"
+ default.site_url
+ default.site_path
+ domains[0].site_url
+ domains[0].site_path
+ r
)
)
@@ -86,24 +88,21 @@ def print_startup_config(
parts = [format_endpoint(ep) for ep in endpoints]
lines.append(line(f"Backend: {' '.join(parts)}"))
realms = registry.realms
for realm in realms:
# Realm line (omit name if same as id); mark the default realm
rp_name = realm.rp_name
suffix = f" ({rp_name})" if rp_name and rp_name != realm.rp_id else ""
header = "Realm: " if len(realms) > 1 else "Relying Party: "
lines.append(line(f"{header}{realm.rp_id}{suffix}"))
if len(realms) > 1:
lines.append(line(f" URL: {realm.site_url}{realm.site_path}"))
if realm.config.auth_host:
lines.append(line(f" Auth Host: {realm.config.auth_host}"))
if realm.config.origins:
for origin in sorted(realm.config.origins):
lines.append(line(f" Origin: {origin}"))
else:
lines.append(line(f" Origin: {realm.rp_id} and subdomains"))
for origin in sorted(realm.config.related_origins or []):
lines.append(line(f" Related: {origin}"))
for domain in domains:
# Domain line (omit name if same as id)
rp_name = domain.rp_name
suffix = f" ({rp_name})" if rp_name and rp_name != domain.rp_id else ""
header = "Domain: " if len(domains) > 1 else "Relying Party: "
lines.append(line(f"{header}{domain.rp_id}{suffix}"))
if len(domains) > 1:
lines.append(line(f" URL: {domain.site_url}{domain.site_path}"))
for key, props in sorted(domain.config.origins.items()):
marker = " (auth host)" if props is not True and props.auth_host else ""
lines.append(line(f" Origin: {origin_url(key)}{marker}"))
if not domain.config.origins:
lines.append(line(f" Origin: {domain.rp_id} and subdomains"))
for key in sorted(domain.config.related):
lines.append(line(f" Related: {origin_url(key)}"))
lines.append(bottom())
stderr.write("".join(lines))