From 7a0737f867e9c8fa2949bc23f3dd1625a257246f Mon Sep 17 00:00:00 2001 From: Leo Vasanko Date: Mon, 7 Sep 2026 01:25:29 +0000 Subject: [PATCH] =?UTF-8?q?Domains:=20object-keyed=20config=20format,=20dr?= =?UTF-8?q?op=20default=20domain,=20rename=20realm=E2=86=92domain=20throug?= =?UTF-8?q?hout=20backend?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- paskia/__main__.py | 63 ++-- paskia/authcode.py | 8 +- paskia/bootstrap.py | 25 +- paskia/db/__init__.py | 16 +- paskia/db/bootstrap.py | 5 +- paskia/db/legacy.py | 26 +- paskia/db/lifecycle.py | 2 +- paskia/db/operations.py | 74 ++-- paskia/db/structs.py | 66 ++-- paskia/domains.py | 516 +++++++++++++++++++++++++++ paskia/fastapi/admin/adminapp.py | 12 +- paskia/fastapi/admin/domains.py | 218 +++++++++++ paskia/fastapi/admin/oidc_clients.py | 10 +- paskia/fastapi/admin/permissions.py | 10 +- paskia/fastapi/admin/realms.py | 224 ------------ paskia/fastapi/admin/users.py | 4 +- paskia/fastapi/api.py | 20 +- paskia/fastapi/auth_host.py | 8 +- paskia/fastapi/dispatch.py | 60 ++-- paskia/fastapi/mainapp.py | 22 +- paskia/fastapi/oid.py | 26 +- paskia/fastapi/remote.py | 14 +- paskia/fastapi/user.py | 4 +- paskia/fastapi/ws.py | 22 +- paskia/fastapi/wschat.py | 20 +- paskia/fastapi/wsutil.py | 6 +- paskia/oidc_notify.py | 29 +- paskia/realms.py | 493 ------------------------- paskia/remoteauth.py | 2 +- paskia/util/apistructs.py | 23 +- paskia/util/avatar.py | 4 +- paskia/util/oidjwt.py | 20 +- paskia/util/runtime.py | 2 +- paskia/util/startupbox.py | 47 ++- 34 files changed, 1062 insertions(+), 1039 deletions(-) create mode 100644 paskia/domains.py create mode 100644 paskia/fastapi/admin/domains.py delete mode 100644 paskia/fastapi/admin/realms.py delete mode 100644 paskia/realms.py diff --git a/paskia/__main__.py b/paskia/__main__.py index 0c214c2..105afae 100644 --- a/paskia/__main__.py +++ b/paskia/__main__.py @@ -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 .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)") diff --git a/paskia/authcode.py b/paskia/authcode.py index 4d497a3..70011dc 100644 --- a/paskia/authcode.py +++ b/paskia/authcode.py @@ -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 diff --git a/paskia/bootstrap.py b/paskia/bootstrap.py index 9ff8f19..a2bd6a0 100644 --- a/paskia/bootstrap.py +++ b/paskia/bootstrap.py @@ -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 diff --git a/paskia/db/__init__.py b/paskia/db/__init__.py index 7ad8f82..657df58 100644 --- a/paskia/db/__init__.py +++ b/paskia/db/__init__.py @@ -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", diff --git a/paskia/db/bootstrap.py b/paskia/db/bootstrap.py index fa0f434..562f7f6 100644 --- a/paskia/db/bootstrap.py +++ b/paskia/db/bootstrap.py @@ -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 diff --git a/paskia/db/legacy.py b/paskia/db/legacy.py index 1be8f7b..f190f38 100644 --- a/paskia/db/legacy.py +++ b/paskia/db/legacy.py @@ -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 ``.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 ``.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)) diff --git a/paskia/db/lifecycle.py b/paskia/db/lifecycle.py index 4aedd86..a37559c 100644 --- a/paskia/db/lifecycle.py +++ b/paskia/db/lifecycle.py @@ -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 diff --git a/paskia/db/operations.py b/paskia/db/operations.py index b00abdc..8b9dad9 100644 --- a/paskia/db/operations.py +++ b/paskia/db/operations.py @@ -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") diff --git a/paskia/db/structs.py b/paskia/db/structs.py index 4df4282..50ca763 100644 --- a/paskia/db/structs.py +++ b/paskia/db/structs.py @@ -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( diff --git a/paskia/domains.py b/paskia/domains.py new file mode 100644 index 0000000..045c5b6 --- /dev/null +++ b/paskia/domains.py @@ -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") diff --git a/paskia/fastapi/admin/adminapp.py b/paskia/fastapi/admin/adminapp.py index 09c2201..e4be429 100644 --- a/paskia/fastapi/admin/adminapp.py +++ b/paskia/fastapi/admin/adminapp.py @@ -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 [] ) diff --git a/paskia/fastapi/admin/domains.py b/paskia/fastapi/admin/domains.py new file mode 100644 index 0000000..a71e69c --- /dev/null +++ b/paskia/fastapi/admin/domains.py @@ -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"} diff --git a/paskia/fastapi/admin/oidc_clients.py b/paskia/fastapi/admin/oidc_clients.py index 6d496be..d6090aa 100644 --- a/paskia/fastapi/admin/oidc_clients.py +++ b/paskia/fastapi/admin/oidc_clients.py @@ -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)) diff --git a/paskia/fastapi/admin/permissions.py b/paskia/fastapi/admin/permissions.py index 35e373b..56c3132 100644 --- a/paskia/fastapi/admin/permissions.py +++ b/paskia/fastapi/admin/permissions.py @@ -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" ) diff --git a/paskia/fastapi/admin/realms.py b/paskia/fastapi/admin/realms.py deleted file mode 100644 index 5cf9835..0000000 --- a/paskia/fastapi/admin/realms.py +++ /dev/null @@ -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"} diff --git a/paskia/fastapi/admin/users.py b/paskia/fastapi/admin/users.py index 06c2359..ab1e894 100644 --- a/paskia/fastapi/admin/users.py +++ b/paskia/fastapi/admin/users.py @@ -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, diff --git a/paskia/fastapi/api.py b/paskia/fastapi/api.py index 5b6a6a0..594ec15 100644 --- a/paskia/fastapi/api.py +++ b/paskia/fastapi/api.py @@ -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 diff --git a/paskia/fastapi/auth_host.py b/paskia/fastapi/auth_host.py index 6f62425..af0c8b0 100644 --- a/paskia/fastapi/auth_host.py +++ b/paskia/fastapi/auth_host.py @@ -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) diff --git a/paskia/fastapi/dispatch.py b/paskia/fastapi/dispatch.py index 7081814..ad10e06 100644 --- a/paskia/fastapi/dispatch.py +++ b/paskia/fastapi/dispatch.py @@ -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) diff --git a/paskia/fastapi/mainapp.py b/paskia/fastapi/mainapp.py index 0a38d1c..8ed3367 100644 --- a/paskia/fastapi/mainapp.py +++ b/paskia/fastapi/mainapp.py @@ -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 ) diff --git a/paskia/fastapi/oid.py b/paskia/fastapi/oid.py index e600e23..257c8cf 100644 --- a/paskia/fastapi/oid.py +++ b/paskia/fastapi/oid.py @@ -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() diff --git a/paskia/fastapi/remote.py b/paskia/fastapi/remote.py index a797396..411ea61 100644 --- a/paskia/fastapi/remote.py +++ b/paskia/fastapi/remote.py @@ -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 diff --git a/paskia/fastapi/user.py b/paskia/fastapi/user.py index c87be84..c2163ea 100644 --- a/paskia/fastapi/user.py +++ b/paskia/fastapi/user.py @@ -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", diff --git a/paskia/fastapi/ws.py b/paskia/fastapi/ws.py index a164435..1d64dcb 100644 --- a/paskia/fastapi/ws.py +++ b/paskia/fastapi/ws.py @@ -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, ) diff --git a/paskia/fastapi/wschat.py b/paskia/fastapi/wschat.py index 9e3409f..242cdf8 100644 --- a/paskia/fastapi/wschat.py +++ b/paskia/fastapi/wschat.py @@ -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) diff --git a/paskia/fastapi/wsutil.py b/paskia/fastapi/wsutil.py index bc5ad6b..b8125b8 100644 --- a/paskia/fastapi/wsutil.py +++ b/paskia/fastapi/wsutil.py @@ -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) diff --git a/paskia/oidc_notify.py b/paskia/oidc_notify.py index 11dadb7..e9e5630 100644 --- a/paskia/oidc_notify.py +++ b/paskia/oidc_notify.py @@ -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, diff --git a/paskia/realms.py b/paskia/realms.py deleted file mode 100644 index f0eae7b..0000000 --- a/paskia/realms.py +++ /dev/null @@ -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 diff --git a/paskia/remoteauth.py b/paskia/remoteauth.py index 7dd0d45..d34e899 100644 --- a/paskia/remoteauth.py +++ b/paskia/remoteauth.py @@ -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 diff --git a/paskia/util/apistructs.py b/paskia/util/apistructs.py index 1d952be..b9eb5d5 100644 --- a/paskia/util/apistructs.py +++ b/paskia/util/apistructs.py @@ -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): diff --git a/paskia/util/avatar.py b/paskia/util/avatar.py index 0f36ee8..001a472 100644 --- a/paskia/util/avatar.py +++ b/paskia/util/avatar.py @@ -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: diff --git a/paskia/util/oidjwt.py b/paskia/util/oidjwt.py index ec7adea..717abe5 100644 --- a/paskia/util/oidjwt.py +++ b/paskia/util/oidjwt.py @@ -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) diff --git a/paskia/util/runtime.py b/paskia/util/runtime.py index 2d8ec72..6880a90 100644 --- a/paskia/util/runtime.py +++ b/paskia/util/runtime.py @@ -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. diff --git a/paskia/util/startupbox.py b/paskia/util/startupbox.py index 08a1e54..8a3fc50 100644 --- a/paskia/util/startupbox.py +++ b/paskia/util/startupbox.py @@ -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))