diff --git a/paskia/__main__.py b/paskia/__main__.py index 4c4a15f..eadabd0 100644 --- a/paskia/__main__.py +++ b/paskia/__main__.py @@ -11,8 +11,13 @@ from fastapi_vue.hostutil import parse_endpoints from kanta import Kanta from paskia._version import __version__ +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 +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.util import startupbox from paskia.util.constants import DEFAULT_PORT, DEVMODE from paskia.util.hostutil import ( @@ -20,52 +25,44 @@ from paskia.util.hostutil import ( normalize_origin, validate_auth_host, ) -from paskia.util.runtime import RuntimeConfig +from paskia.util.runtime import ServeConfig EPILOG = """\ -Example: - paskia --rp-id example.com --rp-name "Example Corporation" --auth-host auth.example.com +Examples: + paskia init --rp-id example.com --rp-name "Example Corporation" --auth-host auth.example.com + paskia """ -def add_common_options(p: argparse.ArgumentParser) -> None: +def _split_multi(values: list[str] | None) -> list[str]: + """Split repeatable/comma-separated CLI values into a flat list.""" + result = [] + for value in values or []: + result.extend(part.strip() for part in value.split(",") if part.strip()) + return result + + +def _add_listen_option(p: argparse.ArgumentParser, help_extra: str = "") -> None: p.add_argument( - "--rp-id", default="localhost", help="Relying Party ID (default: localhost)" - ) - p.add_argument("--rp-name", help="Relying Party name (default: same as rp-id)") - p.add_argument( - "--origin", + "-l", + "--listen", action="append", - dest="origins", - metavar="URL", - help="Allowed origin URL(s). May be specified multiple times. If any are specified, only those origins are permitted for WebSocket authentication.", - ) - p.add_argument( - "--auth-host", - help=("Dedicated authentication site (optionally with scheme/port)"), - ) - p.add_argument( - "--save", - action="store_true", - help="Save the CLI options to database for future runs.", + metavar="LISTEN", + help=( + "Endpoint to listen on (default: localhost:4401). " + "Forms: host:port port :port [ipv6]:port unix:path /path.sock" + ) + + help_extra, ) -def _load_stored_config(db_path: Path, *, rp_id: str) -> Config: +def _load_stored_config(db_path: Path) -> Config: """Load the stored Config from disk using Kanta in read-only mode. This must not depend on PASKIA_CONFIG or the global lifecycle Kanta. - If the database file does not exist, a default config is returned. + Read-only opens never write or migrate the file. """ - if not db_path.exists(): - return Config(rp_id=rp_id) - - kanta = Kanta( - str(db_path), - DB(config=Config(rp_id=rp_id)), - migrations="paskia.db.migrations", - ) - kanta.ctx.rp_id = rp_id + kanta = Kanta(str(db_path), DB()) async def _read() -> Config: await kanta.open(readonly=True) @@ -81,6 +78,117 @@ def _load_stored_config(db_path: Path, *, rp_id: str) -> Config: raise SystemExit(f"{e}") from e +def cmd_init(args: argparse.Namespace) -> None: + """Bootstrap a new paskia.kantadb database with the initial realm(s).""" + db_path = db_file_path() + if db_path.exists(): + raise SystemExit( + f"Database {db_path} already exists — realm configuration is " + "managed via the admin interface, not 'paskia init'." + ) + if found := legacy.find_legacy_databases(): + names = ", ".join(str(p) for p in found) + raise SystemExit( + f"Legacy database(s) found ({names}) — run 'paskia' to adopt " + "and convert, not 'paskia init'." + ) + + rp_ids = _split_multi(args.rp_id) or ["localhost"] + + realms = [] + for i, rp_id in enumerate(rp_ids): + realm = RealmConfig(rp_id=rp_id) + if i == 0: + # Bootstrap-time naming and hosts apply to the default realm; + # 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 + ) + auth_host = args.auth_host or None + if auth_host: + validate_auth_host(auth_host, rp_id) + realm.auth_host, realm.origins = normalize_auth_host_and_origins( + auth_host, origins + ) + realms.append(realm) + + config = Config(realms=realms, 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). + new_db = DB() + kanta = Kanta(str(db_path), new_db) + result = {} + + @kanta.bootstrap + def _bootstrap(data: DB) -> None: + result["passphrase"] = bootstrap(data, config=config) + + async def _create() -> None: + async with kanta: + pass + + try: + asyncio.run(_create()) + except Exception as e: + logging.exception("Failed to create database") + db_path.unlink(missing_ok=True) + raise SystemExit(f"{e}") from e + + configure_realms(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"]), + "✅ Bootstrap completed!", + ) + + +def cmd_serve(args: argparse.Namespace) -> None: + """Open the combined database and serve all configured realms.""" + db_path = db_file_path() + if not db_path.exists(): + adopted = legacy.adopt_legacy_if_present() + if adopted: + print(f"✅ Converted legacy database to {db_path} (realm: {adopted})") + if not db_path.exists(): + raise SystemExit( + f"Database {db_path} not found — run 'paskia init' first." + ) + + config = _load_stored_config(db_path) + try: + validate_config(config) + except ValueError as e: + raise SystemExit(f"Invalid stored configuration: {e}") from e + + listen = _split_multi(args.listen) or config.listen + configure_realms(listen=listen) + registry = build_registry(config) + + # Pass process-global serve parameters to the server process(es) + os.environ["PASKIA_CONFIG"] = msgspec.json.encode(ServeConfig(listen=listen)).decode() + + startupbox.print_startup_config(registry, listen=listen) + + # Run the server (spawns processes in dev mode) + # tracerite, access logging and log config are handled by fastapi_vue.server; + # we print our own startup config box, so disable the built-in one. + server.run( + "paskia.fastapi.mainapp:app", + listen=listen, + default_port=DEFAULT_PORT, + server_header=False, + startup_box=None, + reload=Path(__file__).parent if DEVMODE else False, + ) + + def main(): # Configure logging to remove the "ERROR:root:" prefix logging.basicConfig(level=logging.INFO, format="%(message)s", force=True) @@ -91,91 +199,46 @@ def main(): formatter_class=argparse.RawDescriptionHelpFormatter, epilog=EPILOG, ) + _add_listen_option(parser) - parser.add_argument( - "-l", - "--listen", + init_parser = argparse.ArgumentParser( + prog="paskia init", + description="Bootstrap a new paskia.kantadb database in the current directory", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=EPILOG, + ) + init_parser.add_argument( + "--rp-id", action="append", - metavar="LISTEN", - help=( - "Endpoint to listen on (default: localhost:4401). " - "Forms: host:port port :port [ipv6]:port unix:path /path.sock" - ), + 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.", ) - add_common_options(parser) - - args = parser.parse_args() - - # Load stored config using a local read-only Kanta instance. - # This happens before PASKIA_CONFIG is set, so we must not import - # modules that initialize the global database lifecycle. - db_path = db_file_path(rp_id=args.rp_id, create_root=True) - try: - config = _load_stored_config(db_path, rp_id=args.rp_id) - except SystemExit as e: - print(f"🛑 Paskia {__version__} could not load") - sys.exit(str(e)) - - # Override stored config with CLI args, or clear with empty string - if args.rp_name is not None: - config.rp_name = args.rp_name or None - if args.auth_host is not None: - config.auth_host = args.auth_host or None - if args.origins is not None: - config.origins = None if args.origins == [""] else args.origins - if args.listen is not None: - config.listen = None if args.listen == [""] else args.listen - - # Process and normalize auth_host and origins - try: - validate_auth_host(config.auth_host, config.rp_id) if config.auth_host else None - except ValueError as e: - raise SystemExit(str(e)) - if config.origins: - config.origins = [normalize_origin(o) for o in config.origins] - config.auth_host, config.origins = normalize_auth_host_and_origins( - config.auth_host, config.origins + init_parser.add_argument( + "--rp-name", + help="Relying Party name of the default realm (default: same as rp-id). " + "Used by the initial admin registration; editable later via admin UI.", ) + init_parser.add_argument( + "--origin", + action="append", + dest="origins", + metavar="URL", + help="Allowed origin URL(s) for the default realm. May be specified " + "multiple times; comma-separated values accepted.", + ) + init_parser.add_argument( + "--auth-host", + help="Dedicated authentication site for the default realm " + "(optionally with scheme/port)", + ) + _add_listen_option(init_parser, help_extra=" (stored in the database)") - # Parse first endpoint for site_url fallback - ep = next(iter(parse_endpoints(config.listen, DEFAULT_PORT)), {}) - port = ep.get("port") - - # Compute site_url and site_path - # Priority: auth_host > origins[0] > PASKIA_VITE_URL > http://localhost:port > https://rp_id - site_path = "/auth/" - if config.auth_host: - site_url, site_path = config.auth_host, "/" - elif config.origins: - site_url = config.origins[0] - elif vite_url := os.environ.get("PASKIA_VITE_URL"): - site_url = vite_url.rstrip("/") # Devserver - elif config.rp_id == "localhost" and port: - site_url = f"http://localhost:{port}" # Backend directly if we can + argv = sys.argv[1:] + if argv and argv[0] == "init": + cmd_init(init_parser.parse_args(argv[1:])) else: - site_url = f"https://{config.rp_id}" # Assume external reverse proxy - - # Build runtime configuration for the server - runtime = RuntimeConfig( - config=config, - site_url=site_url, - site_path=site_path, - save=args.save, - ) - startupbox.print_startup_config(runtime) - os.environ["PASKIA_CONFIG"] = msgspec.json.encode(runtime).decode() - - # Run the server (spawns processes in dev mode) - # tracerite, access logging and log config are handled by fastapi_vue.server; - # we print our own startup config box, so disable the built-in one. - server.run( - "paskia.fastapi.mainapp:app", - listen=config.listen, - default_port=DEFAULT_PORT, - server_header=False, - startup_box=None, - reload=Path(__file__).parent if DEVMODE else False, - ) + cmd_serve(parser.parse_args(argv)) if __name__ == "__main__": diff --git a/paskia/bootstrap.py b/paskia/bootstrap.py index 9c05234..0d93afb 100644 --- a/paskia/bootstrap.py +++ b/paskia/bootstrap.py @@ -1,20 +1,17 @@ """ Bootstrap module for passkey authentication system. -This module handles initial system setup when a new database is created, -including creating default admin user, organization, permissions, and -generating a reset link for initial admin setup. - -The actual database seeding is performed by the module-level kanta bootstrap -callback defined in :mod:`paskia.db.bootstrap` and registered during -:func:`paskia.db.lifecycle.init`. +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. """ import logging -from paskia import authsession, db +from paskia import authsession, db, realms from paskia.db.bootstrap import log_reset_link -from paskia.db.structs import Config logger = logging.getLogger(__name__) @@ -32,15 +29,14 @@ def _configure_logger() -> None: _configure_logger() -def _log_reset_link(passphrase: str, message: str | None = None) -> str: - """Log a reset link message and return the URL.""" - return log_reset_link(passphrase, message) - - 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. + Returns: bool: True if a reset link was created, False if admin already has credentials """ @@ -67,12 +63,13 @@ async def check_admin_credentials() -> bool: if not admin_users: return False - # Check first admin user for credentials + # Check first admin user for credentials under the default realm admin_user = admin_users[0] + default = realms.registry().default - if not admin_user.credential_ids: - # Admin exists but has no credentials, create reset link - logger.info("⚠️ Admin user has no credentials!") + 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) expiry = authsession.reset_expires() token = db.create_reset_token( @@ -80,7 +77,7 @@ async def check_admin_credentials() -> bool: expiry=expiry, token_type="admin registration", ) - _log_reset_link(token) + log_reset_link(default.reset_link_url(token)) return True return False @@ -89,20 +86,12 @@ async def check_admin_credentials() -> bool: return False -async def bootstrap_if_needed(config: Config | None = None) -> bool: - """ - Check if admin needs credentials and create a reset link if needed. - - Database bootstrapping itself is now handled automatically during - ``db.init()`` via the registered kanta bootstrap callback. This function - remains as a post-init hook for credential checks. - - Args: - config: Kept for backwards compatibility; config is now applied during - ``db.init()``. +async def bootstrap_if_needed() -> bool: + """Run the serve-time admin credential check. Returns: - bool: Always returns False (bootstrapping is performed during init). + bool: Always returns False (bootstrapping is performed by ``paskia init``). """ await check_admin_credentials() return False + diff --git a/paskia/db/__init__.py b/paskia/db/__init__.py index cdafc6a..7ad8f82 100644 --- a/paskia/db/__init__.py +++ b/paskia/db/__init__.py @@ -28,6 +28,7 @@ from paskia.db.operations import ( create_oid_client, create_org, create_permission, + create_realm, create_reset_token, create_role, create_user, @@ -35,6 +36,7 @@ from paskia.db.operations import ( delete_oid_client, delete_org, delete_permission, + delete_realm, delete_reset_token, delete_role, delete_session, @@ -52,6 +54,7 @@ from paskia.db.operations import ( update_oid_client, update_org_name, update_permission, + update_realm, update_role_name, update_session, update_user_display_name, @@ -60,11 +63,13 @@ from paskia.db.operations import ( ) from paskia.db.structs import ( DB, + OIDC, Client, Config, Credential, Org, Permission, + RealmConfig, ResetToken, Role, Session, @@ -84,8 +89,10 @@ __all__ = [ "Credential", "DB", "Client", + "OIDC", "Org", "Permission", + "RealmConfig", "ResetToken", "Role", "Session", @@ -102,12 +109,14 @@ __all__ = [ "create_credential_session", "create_org", "create_permission", + "create_realm", "create_reset_token", "create_role", "create_user", "delete_credential", "delete_org", "delete_permission", + "delete_realm", "delete_reset_token", "delete_role", "delete_session", @@ -122,6 +131,7 @@ __all__ = [ "update_credential_sign_count", "update_org_name", "update_permission", + "update_realm", "update_role_name", "update_session", "update_user_display_name", diff --git a/paskia/db/bootstrap.py b/paskia/db/bootstrap.py index 44fbae3..14f9057 100644 --- a/paskia/db/bootstrap.py +++ b/paskia/db/bootstrap.py @@ -9,9 +9,8 @@ from datetime import UTC, datetime import uuid7 from paskia.authsession import reset_expires -from paskia.db.structs import DB, Config, Org, Permission, ResetToken, Role, User +from paskia.db.structs import DB, Config, OIDC, Org, Permission, ResetToken, Role, User from paskia.util.crypto import secret_key -from paskia.util.hostutil import reset_link_url _reset_link_logger = logging.getLogger("paskia.reset_link") @@ -34,13 +33,12 @@ ADMIN_RESET_MESSAGE = """ """ -def log_reset_link(passphrase: str, message: str | None = None) -> str: +def log_reset_link(url: str, message: str | None = None) -> str: """Log a reset link message and return the URL.""" - reset_link = reset_link_url(passphrase) if message: _reset_link_logger.info(message) - _reset_link_logger.info(ADMIN_RESET_MESSAGE, reset_link) - return reset_link + _reset_link_logger.info(ADMIN_RESET_MESSAGE, url) + return url def bootstrap( @@ -147,8 +145,9 @@ def bootstrap( if config is not None: data.config = config - # Generate OIDC signing key - data.oidc.key = secret_key() + # 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} # 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 new file mode 100644 index 0000000..1050c9f --- /dev/null +++ b/paskia/db/legacy.py @@ -0,0 +1,243 @@ +"""Legacy database format reader and converter. + +Retains the msgspec structs used by the old ``.paskiadb/main.db`` +format so existing databases can be opened and converted to the combined +``paskia.kantadb`` format. Only the structs whose shape differs from the +current schema are redefined here; unchanged structs are imported from +``paskia.db.structs``. + +Assumes the on-disk records are in the latest legacy format (schema +migrations were discarded together with the old format). This module will +be deleted once legacy adoption is no longer supported. +""" + +from __future__ import annotations + +import asyncio +import shutil +from datetime import datetime +from pathlib import Path +from uuid import UUID + +import msgspec +from kanta import Kanta + +from paskia.db.paths import db_file_path, users_root_path +from paskia.db.structs import ( + OIDC, + DB, + Config, + Credential, + Org, + Permission, + RealmConfig, + ResetToken, + Role, + Session, + User, +) + + +class LegacyConfig(msgspec.Struct, omit_defaults=True): + """Pre-realms stored configuration (single rp-id per database).""" + + rp_id: str + rp_name: str | None = None + auth_host: str | None = None + origins: list[str] | None = None + listen: list[str] | None = None + + +class LegacyCredential(msgspec.Struct, dict=True): + """Credential without the rp_id stamp.""" + + credential_id: bytes + user_uuid: UUID = msgspec.field(name="user") + aaguid: UUID + public_key: bytes + sign_count: int + created_at: datetime + last_used: datetime | None = None + last_verified: datetime | None = None + + +class LegacySession(msgspec.Struct, dict=True, omit_defaults=True): + """Session without the rp_id/issuer stamps.""" + + user_uuid: UUID = msgspec.field(name="user") + credential_uuid: UUID = msgspec.field(name="credential") + host: str + ip: str + user_agent: str + validated: datetime + client_uuid: UUID | None = msgspec.field(name="client", default=None) + + +class LegacyDB(msgspec.Struct, dict=True, omit_defaults=False): + """Root structure of a legacy single-rp-id database.""" + + config: LegacyConfig = msgspec.field( + default_factory=lambda: LegacyConfig(rp_id="localhost") + ) + permissions: dict[UUID, Permission] = {} + orgs: dict[UUID, Org] = {} + roles: dict[UUID, Role] = {} + users: dict[UUID, User] = {} + credentials: dict[UUID, LegacyCredential] = {} + sessions: dict[str, LegacySession] = {} + reset_tokens: dict[str, ResetToken] = {} + oidc: OIDC = msgspec.field(default_factory=OIDC) + + +def _read_legacy(path: Path) -> LegacyDB: + """Open a legacy database read-only and return its contents.""" + kanta = Kanta(str(path), LegacyDB()) + + async def _read() -> LegacyDB: + await kanta.open(readonly=True) + return kanta.data + + return asyncio.run(_read()) + + +def convert_legacy_database(src: Path, dst: Path) -> Config: + """Convert a legacy main.db file into the combined kantadb format. + + Reads the legacy database at ``src`` and writes a fresh database at + ``dst``. All credentials and sessions are stamped with the legacy + database's rp-id; the OIDC provider is moved under that rp-id key. + Returns the converted (new-format) configuration. + """ + old = _read_legacy(src) + rp_id = old.config.rp_id + + 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, + ) + ], + listen=old.config.listen, + ) + + credentials = { + uuid: Credential( + credential_id=c.credential_id, + user_uuid=c.user_uuid, + aaguid=c.aaguid, + public_key=c.public_key, + sign_count=c.sign_count, + created_at=c.created_at, + rp_id=rp_id, + last_used=c.last_used, + last_verified=c.last_verified, + ) + for uuid, c in old.credentials.items() + } + sessions = { + key: Session( + user_uuid=s.user_uuid, + credential_uuid=s.credential_uuid, + host=s.host, + ip=s.ip, + user_agent=s.user_agent, + validated=s.validated, + client_uuid=s.client_uuid, + rp_id=rp_id, + ) + for key, s in old.sessions.items() + } + + converted = DB( + config=new_config, + permissions=old.permissions, + orgs=old.orgs, + roles=old.roles, + users=old.users, + credentials=credentials, + sessions=sessions, + reset_tokens=old.reset_tokens, + oidc={rp_id: old.oidc}, + ) + + new_db = DB() + kanta = Kanta(str(dst), new_db) + + @kanta.bootstrap + def _seed(data: DB) -> None: + data.config = converted.config + data.permissions = converted.permissions + data.orgs = converted.orgs + data.roles = converted.roles + data.users = converted.users + data.credentials = converted.credentials + data.sessions = converted.sessions + data.reset_tokens = converted.reset_tokens + data.oidc = converted.oidc + + async def _write() -> None: + async with kanta: + pass + + asyncio.run(_write()) + return new_config + + +def find_legacy_databases(cwd: Path | None = None) -> list[Path]: + """Find legacy ``*.paskiadb`` databases in a directory. + + A candidate is either a directory containing ``main.db`` or a legacy + single-file database. Empty directories and non-matching files are + ignored. + """ + cwd = cwd or Path.cwd() + candidates = [] + for entry in sorted(cwd.glob("*.paskiadb")): + if entry.is_dir(): + if (entry / "main.db").is_file(): + candidates.append(entry) + elif entry.is_file(): + candidates.append(entry) + return candidates + + +def adopt_legacy_if_present() -> str | None: + """Convert a lone legacy database to ``paskia.kantadb`` if present. + + Returns the adopted realm's rp-id, or None when ``paskia.kantadb`` + already exists or no legacy database is present. The converted legacy + directory/file is renamed aside to ``.converted-bak`` rather than + deleted. + + Raises SystemExit when multiple legacy databases are found — automatic + merging is not supported. + """ + target = db_file_path() + if target.exists(): + return None + candidates = find_legacy_databases() + if not candidates: + return None + if len(candidates) > 1: + names = ", ".join(str(c) for c in candidates) + raise SystemExit( + f"Multiple legacy databases found ({names}). Automatic merging is " + "not supported — remove or rename all but the one to adopt." + ) + + src = candidates[0] + legacy_file = src / "main.db" if src.is_dir() else src + config = convert_legacy_database(legacy_file, target) + + # Move persisted user files (avatars) to the new data root + legacy_users = src / "users" if src.is_dir() else None + if legacy_users is not None and legacy_users.is_dir(): + target_users = users_root_path(create_root=True) + for child in legacy_users.iterdir(): + 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 diff --git a/paskia/db/lifecycle.py b/paskia/db/lifecycle.py index 06a4b4b..b8bb58a 100644 --- a/paskia/db/lifecycle.py +++ b/paskia/db/lifecycle.py @@ -5,6 +5,7 @@ Database lifecycle: initialization and maintenance. import asyncio import logging import os +import re import signal from datetime import UTC, datetime from pathlib import Path @@ -17,24 +18,14 @@ from kanta.exceptions import DatabaseError import paskia.db.operations as _ops from paskia import oidc_notify from paskia.authsession import EXPIRES -from paskia.db.bootstrap import bootstrap, log_reset_link from paskia.db.paths import db_file_path from paskia.db.structs import DB -from paskia.util.runtime import config as runtime_config logger = logging.getLogger(__name__) - -runtime = runtime_config() -if runtime is None: - raise RuntimeError("PASKIA_CONFIG must be defined before importing db.lifecycle") - -kanta = Kanta( - str(db_file_path(rp_id=runtime.config.rp_id, create_root=False)), - _ops._db, - migrations="paskia.db.migrations", -) -kanta.ctx.rp_id = runtime.config.rp_id +# The combined database lives at a fixed CWD-relative path; no runtime +# configuration is needed to locate it. +kanta = Kanta(str(db_file_path()), _ops._db) _ops._db._store = kanta @@ -51,12 +42,16 @@ def _lookup_uuid_in_state(state: dict | None, uuid_str: str) -> str | None: if isinstance(display_name, str) and display_name: return display_name - # OIDC clients use "name" instead of "display_name". - client = state.get("oidc", {}).get("clients", {}).get(uuid_str) - if isinstance(client, dict): - name = client.get("name") - if isinstance(name, str) and name: - return name + # OIDC clients use "name" instead of "display_name"; providers are + # nested per realm rp-id. + for provider in state.get("oidc", {}).values(): + if not isinstance(provider, dict): + continue + client = provider.get("clients", {}).get(uuid_str) + if isinstance(client, dict): + name = client.get("name") + if isinstance(name, str) and name: + return name return None @@ -89,11 +84,16 @@ def _resolve_uuid_label( return _ops._db.roles[uid].display_name if uid in _ops._db.permissions: return _ops._db.permissions[uid].display_name - if uid in _ops._db.oidc.clients: - return _ops._db.oidc.clients[uid].name + for provider in _ops._db.oidc.values(): + if uid in provider.clients: + return provider.clients[uid].name return None +# OIDC signing keys are stored at oidc..key (rp-ids contain dots). +_OIDC_KEY_PATH = re.compile(r"^oidc\..+\.key$") + + @kanta.logfmt def format_log_uuid( value: Any, @@ -104,8 +104,8 @@ def format_log_uuid( """Format UUID values/keys/actor labels and censor secrets in transaction logs.""" # Censor sensitive OIDC key material regardless of value type, but only # when formatting the value: path components are passed with the component - # itself as value and must stay visible ("oidc.key = "). - if (path == "oidc.key" or path.endswith(".oidc.key")) and value != "key": + # itself as value and must stay visible ("oidc..key = "). + if _OIDC_KEY_PATH.fullmatch(path) and value != "key": return "" if not isinstance(value, str): @@ -122,17 +122,12 @@ def terminate(error: DatabaseError) -> None: os.kill(os.getpid(), signal.SIGTERM) -@kanta.bootstrap -def bootstrap_db(data: DB) -> None: - reset_passphrase = bootstrap(data, config=runtime.config) - log_reset_link(reset_passphrase, "✅ Bootstrap completed!") - - async def init(): """Load database from JSONL file using kanta. - If the database file is empty, the configured bootstrap callback seeds it - with default permissions, organization, role, admin user and a reset token. + The database must already exist and be initialized (see ``paskia + init``); the serve command's startup checks guarantee this before the + lifespan runs. """ rootpath = Path(kanta.filename).parent try: diff --git a/paskia/db/migrations.py b/paskia/db/migrations.py deleted file mode 100644 index ff2ce33..0000000 --- a/paskia/db/migrations.py +++ /dev/null @@ -1,48 +0,0 @@ -""" -Database schema migrations. - -Migrations are applied during database load based on the version field. -Each migration should be idempotent and only run when needed. -""" - -import base64 - -from kanta import Kanta - -from paskia.util.crypto import secret_key - - -def migrate_v1(d: dict) -> None: - """Remove Org.created_at fields.""" - for org_data in d["orgs"].values(): - org_data.pop("created_at", None) - - -def migrate_v2(d: dict, kanta: Kanta) -> None: - """Add config field if missing.""" - if "config" not in d: - d["config"] = {"rp_id": kanta.ctx.rp_id} - - -def migrate_v3(d: dict) -> None: - """Ensure all users have visits field.""" - for user_data in d["users"].values(): - user_data.setdefault("visits", 0) - - -def migrate_v4(d: dict) -> None: - """OpenID Connect support and hardened session keys.""" - # Session keys changed to hashes, drop old sessions - d["sessions"] = {} - # Create OIDC structure with a generated new key - d["oidc"] = { - "clients": {}, - "key": base64.standard_b64encode(secret_key()).decode(), - } - - -def migrate_v5(d: dict) -> None: - """Convert config.listen from str to list[str] if needed.""" - listen = d["config"].get("listen") - if listen and isinstance(listen, str): - d["config"]["listen"] = [listen] diff --git a/paskia/db/operations.py b/paskia/db/operations.py index 6853ed3..a0b363e 100644 --- a/paskia/db/operations.py +++ b/paskia/db/operations.py @@ -17,18 +17,20 @@ from paskia import oidc_notify from paskia.config import SESSION_LIFETIME from paskia.db.structs import ( DB, + OIDC, Client, Config, Credential, Org, Permission, + RealmConfig, ResetToken, Role, Session, SessionContext, User, ) -from paskia.util.crypto import hash_secret +from paskia.util.crypto import hash_secret, secret_key from paskia.util.nameutil import slugify_name _logger = logging.getLogger(__name__) @@ -37,7 +39,7 @@ _logger = logging.getLogger(__name__) _UNSET = object() # Global database instance (empty until init() loads data) -_db = DB(config=Config(rp_id="uninitialized.invalid")) +_db = DB() def _store(): @@ -703,20 +705,89 @@ def create_credential_session( return token +# ------------------------------------------------------------------------- +# Realm operations +# ------------------------------------------------------------------------- + + +def _oidc_provider(rp_id: str) -> OIDC: + """Return the OIDC provider entry for a realm, raising if missing.""" + provider = _db.oidc.get(rp_id) + if provider is None: + raise ValueError(f"Realm {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. + + Seeds an OIDC provider entry (with a fresh signing key) for the realm. + 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()) + + +def update_realm( + rp_id: str, + *, + rp_name: str | None = None, + auth_host: str | None = None, + origins: list[str] | None = None, + ctx: SessionContext | None = None, +) -> None: + """Update a realm's rp_name, auth_host and origins. + + The rp-id itself is immutable: credentials are stamped with it, so + changing it would orphan them — delete and recreate the realm 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 + + +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") + 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" + ) + with _transaction("admin:delete_realm", ctx): + _db.config.realms.remove(realm) + _db.oidc.pop(rp_id, None) + + # ------------------------------------------------------------------------- # OIDC Provider operations # ------------------------------------------------------------------------- -def create_oid_client(client: Client, *, ctx: SessionContext | None = None) -> None: - """Create a new OIDC client.""" - if client.uuid in _db.oidc.clients: +def create_oid_client( + rp_id: str, client: Client, *, ctx: SessionContext | None = None +) -> None: + """Create a new OIDC client under a realm.""" + provider = _oidc_provider(rp_id) + if client.uuid in provider.clients: raise ValueError(f"OIDC client {client.uuid} already exists") with _transaction("admin:create_oid_client", ctx): - _db.oidc.clients[client.uuid] = client + provider.clients[client.uuid] = client def update_oid_client( + rp_id: str, client_uuid: UUID, name: str | None = None, redirect_uris: list[str] | None = None, @@ -726,10 +797,11 @@ def update_oid_client( ctx: SessionContext | None = None, ) -> None: """Update an OIDC client's name, redirect URIs, and/or secret.""" - if client_uuid not in _db.oidc.clients: + provider = _oidc_provider(rp_id) + if client_uuid not in provider.clients: raise ValueError(f"OIDC client {client_uuid} not found") - client = _db.oidc.clients[client_uuid] + client = provider.clients[client_uuid] changes = {} if name is not None and name != client.name: @@ -766,19 +838,21 @@ def update_oid_client( backchannel_logout_uri=new_logout_uri, ) updated_client.uuid = client.uuid - _db.oidc.clients[client_uuid] = updated_client + provider.clients[client_uuid] = updated_client def reset_oid_client_secret( + rp_id: str, client_uuid: UUID, new_secret_hash: bytes, *, ctx: SessionContext | None = None, ) -> None: """Reset an OIDC client's secret.""" - if client_uuid not in _db.oidc.clients: + provider = _oidc_provider(rp_id) + if client_uuid not in provider.clients: raise ValueError(f"OIDC client {client_uuid} not found") - client = _db.oidc.clients[client_uuid] + client = provider.clients[client_uuid] with _transaction("admin:reset_oid_client_secret", ctx): updated = Client( client_secret_hash=new_secret_hash, @@ -787,12 +861,15 @@ def reset_oid_client_secret( backchannel_logout_uri=client.backchannel_logout_uri, ) updated.uuid = client.uuid - _db.oidc.clients[client_uuid] = updated + provider.clients[client_uuid] = updated -def delete_oid_client(client_uuid: UUID, *, ctx: SessionContext | None = None) -> None: +def delete_oid_client( + rp_id: str, client_uuid: UUID, *, ctx: SessionContext | None = None +) -> None: """Delete an OIDC client.""" - if client_uuid not in _db.oidc.clients: + provider = _oidc_provider(rp_id) + if client_uuid not in provider.clients: raise ValueError(f"OIDC client {client_uuid} not found") with _transaction("admin:delete_oid_client", ctx): - del _db.oidc.clients[client_uuid] + del provider.clients[client_uuid] diff --git a/paskia/db/paths.py b/paskia/db/paths.py index 09d9452..c63ca4d 100644 --- a/paskia/db/paths.py +++ b/paskia/db/paths.py @@ -1,47 +1,33 @@ -from __future__ import annotations +"""Filesystem paths for paskia persistence. + +The combined database is a single kanta JSONL file at the fixed +CWD-relative path ``paskia.kantadb``. Auxiliary user files (avatars) live +under ``paskia.data/``. The deployment is selected by the current working +directory; there is deliberately no environment override. +""" -import os -import shutil from pathlib import Path - -def db_root_path(*, rp_id: str = "localhost") -> Path: - """Return the configured persistence root directory.""" - return Path(os.environ.get("PASKIA_DB", f"{rp_id}.paskiadb")) +DB_FILENAME = "paskia.kantadb" +DATA_DIRNAME = "paskia.data" -def db_file_path(*, rp_id: str = "localhost", create_root: bool = False) -> Path: - """Return the JSONL database file path under the persistence root.""" - root = db_root_path(rp_id=rp_id) +def db_file_path() -> Path: + """Return the combined database file path.""" + return Path(DB_FILENAME) - if root.is_file(): - _migrate_legacy_db_file(root) +def data_root_path(create_root: bool = False) -> Path: + """Return the root directory for auxiliary files (avatars etc.).""" + root = Path(DATA_DIRNAME) if create_root: root.mkdir(parents=True, exist_ok=True) - - return root / "main.db" + return root -def users_root_path(*, rp_id: str = "localhost", create_root: bool = False) -> Path: +def users_root_path(create_root: bool = False) -> Path: """Return the filesystem root for persisted user files.""" - root = db_root_path(rp_id=rp_id) - - if root.is_file(): - _migrate_legacy_db_file(root) - + root = data_root_path(create_root=create_root) / "users" if create_root: root.mkdir(parents=True, exist_ok=True) - - return root / "users" - - -def _migrate_legacy_db_file(legacy_path: Path) -> None: - """Upgrade a legacy single-file database path into a directory root.""" - temp_root = legacy_path.parent / f".{legacy_path.name}.migrating" - shutil.rmtree(temp_root, ignore_errors=True) - temp_root.unlink(missing_ok=True) - - temp_root.mkdir(parents=True) - legacy_path.replace(temp_root / "main.db") - temp_root.rename(legacy_path) + return root diff --git a/paskia/db/structs.py b/paskia/db/structs.py index d9e11a0..34e392e 100644 --- a/paskia/db/structs.py +++ b/paskia/db/structs.py @@ -237,6 +237,12 @@ class User(msgspec.Struct, dict=True, omit_defaults=True, kw_only=True): """Get credential IDs for this user (for WebAuthn exclude lists).""" 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.""" + return [ + c.credential_id for c in self.credentials if c.rp_id == rp_id + ] + @property def sessions(self) -> list[Session]: """Get all sessions for this user.""" @@ -290,8 +296,12 @@ class Credential(msgspec.Struct, dict=True): """Credential (passkey) data structure. Mutable fields: sign_count, last_used, last_verified - Immutable fields: credential_id, user, aaguid, public_key, created_at + 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 + origin the registration ceremony ran on. """ credential_id: bytes # Long binary ID from the authenticator @@ -300,6 +310,7 @@ class Credential(msgspec.Struct, dict=True): public_key: bytes sign_count: int created_at: datetime + rp_id: str last_used: datetime | None = None last_verified: datetime | None = None @@ -341,6 +352,7 @@ class Credential(msgspec.Struct, dict=True): aaguid: UUID, public_key: bytes, sign_count: int, + rp_id: str, created_at: datetime | None = None, ) -> Credential: """Create a new Credential with auto-generated uuid7.""" @@ -353,6 +365,7 @@ class Credential(msgspec.Struct, dict=True): public_key=public_key, sign_count=sign_count, created_at=now, + rp_id=rp_id, last_used=now, last_verified=now, ) @@ -380,6 +393,8 @@ 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) + issuer: str | None = None # OIDC issuer URL this session was created under def __post_init__(self): if not hasattr(self, "key"): @@ -429,11 +444,15 @@ class Session(msgspec.Struct, dict=True, omit_defaults=True): user_agent: str, validated: datetime, client: UUID | None = None, + rp_id: str | None = None, + issuer: str | None = None, ) -> Session: """Create a new Session with the provided key. 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) + issuer: OIDC issuer URL (scheme + host) for OIDC sessions Returns: Session object with key set @@ -452,6 +471,8 @@ class Session(msgspec.Struct, dict=True, omit_defaults=True): user_agent=user_agent, validated=validated, client_uuid=client, + rp_id=rp_id, + issuer=issuer, ) session.key = key return session @@ -601,14 +622,42 @@ class OIDC(msgspec.Struct, dict=True): key: bytes | None = None -class Config(msgspec.Struct, omit_defaults=True): - """Stored configuration for the instance.""" +class RealmConfig(msgspec.Struct, omit_defaults=True): + """Configuration for one authentication realm (one WebAuthn rp-id). + + A realm is one rp-id with its associated hosts and origins. Origins may + be in the rp-id subtree (classic) or explicit related origins for + WebAuthn Related Origin Requests. + """ rp_id: str rp_name: str | None = None - auth_host: str | None = None - origins: list[str] | None = None - listen: list[str] | None = None + auth_host: str | None = None # This realm's dedicated auth host (URL) + origins: list[str] | None = None # Subdomain origins AND related origins + + +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. + """ + + realms: list[RealmConfig] = msgspec.field( + default_factory=lambda: [RealmConfig(rp_id="localhost")] + ) + 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) # ------------------------------------------------------------------------- @@ -619,7 +668,7 @@ class Config(msgspec.Struct, omit_defaults=True): class DB(msgspec.Struct, dict=True, omit_defaults=False): """In-memory database. Access fields directly for reads.""" - config: Config = msgspec.field(default_factory=lambda: Config(rp_id="localhost")) + config: Config = msgspec.field(default_factory=Config) permissions: dict[UUID, Permission] = {} orgs: dict[UUID, Org] = {} roles: dict[UUID, Role] = {} @@ -627,8 +676,9 @@ 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 - oidc: OIDC = msgspec.field(default_factory=lambda: OIDC()) + # OIDC provider data, keyed by realm rp-id: each realm is an independent + # issuer with its own signing key and clients. + oidc: dict[str, OIDC] = {} def __post_init__(self): # Optional store reference for non-global DB instances (e.g. tests). @@ -649,8 +699,13 @@ class DB(msgspec.Struct, dict=True, omit_defaults=False): for key, token in self.reset_tokens.items(): token.key = key # OIDC - for uuid, client in self.oidc.clients.items(): - client.uuid = uuid + for provider in self.oidc.values(): + for uuid, client in provider.clients.items(): + client.uuid = uuid + + def oidc_for(self, rp_id: str) -> OIDC | None: + """Get the OIDC provider data for a realm, if it exists.""" + return self.oidc.get(rp_id) def session_ctx( self, session_secret: str, host: str | None = None diff --git a/paskia/oidc_notify.py b/paskia/oidc_notify.py index 1c77fad..11dadb7 100644 --- a/paskia/oidc_notify.py +++ b/paskia/oidc_notify.py @@ -3,6 +3,10 @@ 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 +and ``Session.issuer`` (stamped at session creation/refresh) is the `iss`. """ import asyncio @@ -11,9 +15,8 @@ from uuid import UUID import httpx -from paskia import db +from paskia import db, realms from paskia.util import oidjwt -from paskia.util.runtime import config as runtime_config _logger = logging.getLogger(__name__) @@ -21,16 +24,23 @@ _logger = logging.getLogger(__name__) _TIMEOUT = httpx.Timeout(10.0, connect=5.0) -def _issuer() -> str: - """Derive issuer URL from config (same base as discovery document).""" - cfg = runtime_config() - return cfg.site_url if cfg else "https://localhost" +def _session_realm(rp_id: str | None): + """Resolve a session's realm, falling back to the default realm.""" + try: + reg = realms.registry() + 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( session_keys: list[str], -) -> list[tuple[str, str, UUID, UUID | None]]: - """Collect (backchannel_logout_uri, sid, client_uuid, user_uuid) for OIDC sessions. +) -> list[tuple[str, str, str, str, UUID, UUID | None]]: + """Collect (logout_uri, rp_id, issuer, sid, client_uuid, user_uuid). Must be called before the sessions are deleted from the database. Returns only sessions whose client has a backchannel_logout_uri configured. @@ -41,12 +51,23 @@ def _collect_oidc_sessions( session = data.sessions.get(key) if not session or session.client_uuid is None: continue - client = data.oidc.clients.get(session.client_uuid) + realm = _session_realm(session.rp_id) + if realm is None: + continue + provider = data.oidc.get(realm.rp_id) + client = provider.clients.get(session.client_uuid) if provider else None if not client or not client.backchannel_logout_uri: continue - sid = session.key + issuer = session.issuer or realm.site_url notifications.append( - (client.backchannel_logout_uri, sid, session.client_uuid, session.user_uuid) + ( + client.backchannel_logout_uri, + realm.rp_id, + issuer, + session.key, + session.client_uuid, + session.user_uuid, + ) ) return notifications @@ -77,22 +98,22 @@ async def _send_logout_token( async def notify( - notifications: list[tuple[str, str, UUID, UUID | None]], + notifications: list[tuple[str, str, str, str, UUID, UUID | None]], ) -> None: """Send back-channel logout tokens to all collected endpoints. Args: - notifications: list of (backchannel_logout_uri, sid, client_uuid, user_uuid) - as returned by _collect_oidc_sessions. + notifications: list of (backchannel_logout_uri, rp_id, issuer, sid, + client_uuid, user_uuid) as returned by _collect_oidc_sessions. """ if not notifications: return - issuer = _issuer() async with httpx.AsyncClient(timeout=_TIMEOUT) as client: tasks = [] - for uri, sid, client_uuid, user_uuid in notifications: + for uri, rp_id, issuer, sid, client_uuid, user_uuid in notifications: token = oidjwt.create_logout_token( + rp_id, issuer=issuer, audience=str(client_uuid), sid=sid, diff --git a/paskia/realms.py b/paskia/realms.py new file mode 100644 index 0000000..847bb23 --- /dev/null +++ b/paskia/realms.py @@ -0,0 +1,306 @@ +"""Realm registry: per-rp-id runtime state and host resolution. + +A **realm** is one rp-id with its associated hosts and origins. The +registry is built from the stored combined ``Config`` at startup and +rebuilt on admin realm changes; request dispatch resolves hosts to realms +through it. The database itself is global — only the *current realm* +(passkey, site URLs, OIDC view) varies per request, tracked via a +contextvar set by the dispatch middleware. +""" + +from __future__ import annotations + +import contextvars +import os +from urllib.parse import urlparse + +from fastapi_vue.hostutil import parse_endpoints + +from paskia.db.structs import Config, RealmConfig +from paskia.util import hostutil +from paskia.util.constants import DEFAULT_PORT + +# Maximum number of related (non-subdomain) origins per realm. WebAuthn +# Related Origin Requests require browsers to support at least 5 labels. +DEFAULT_RELATED_ORIGIN_CAP = 5 + + +class Realm: + """Runtime view of one realm: stored config plus derived values.""" + + def __init__(self, config: RealmConfig, site_url: str, site_path: str): + # Lazy import: paskia.sansio depends on paskia.db, which (via + # paskia.db.operations → paskia.oidc_notify) depends on this module. + from paskia.sansio import Passkey + + self.config = config + self.site_url = site_url + self.site_path = site_path + self.passkey = Passkey( + rp_id=config.rp_id, + rp_name=config.rp_name, + origins=config.origins, + ) + + @property + def rp_id(self) -> str: + return self.config.rp_id + + @property + def rp_name(self) -> str: + return self.passkey.rp_name + + @property + def own_auth_host(self) -> str | None: + """This realm's own auth host as host[:port], if configured.""" + if not self.config.auth_host: + return None + return hostutil.auth_host_netloc(self.config.auth_host) + + @property + def related_origins(self) -> list[str]: + """Configured origins outside the rp-id subtree (ROR origins).""" + related = [] + for origin in self.config.origins or []: + hostname = hostutil.origin_hostname(origin) + if hostname and not hostutil.is_subdomain(hostname, self.rp_id): + related.append(origin) + return related + + @property + def is_root_mode(self) -> bool: + """Whether this realm's UI lives at the site root (own auth host).""" + return self.config.auth_host is not None + + @property + def ui_base_path(self) -> str: + return "/" if self.is_root_mode else "/auth/" + + @property + def auth_site_url(self) -> str: + """Base URL of this realm's auth site UI.""" + return self.site_url + self.site_path + + def api_url(self, path: str = "") -> str: + """Return an absolute URL under the canonical /auth/api/ prefix.""" + if not path: + return f"{self.site_url}/auth/api/" + return f"{self.site_url}/auth/api/{path.lstrip('/')}" + + def reset_link_url(self, token: str) -> str: + """Generate a reset link URL for the given token on this realm.""" + return f"{self.auth_site_url}{token}" + + +class RealmRegistry: + """Resolved realms and host lookup tables.""" + + def __init__(self, realms: list[Realm]): + self._by_rp_id = {r.rp_id: r for r in realms} + self._auth_hosts: dict[str, Realm] = {} + self._related_hosts: dict[str, Realm] = {} + for realm in realms: + if own := realm.own_auth_host: + self._auth_hosts[hostutil.normalize_host(own) or own] = realm + for origin in realm.related_origins: + if hostname := hostutil.origin_hostname(origin): + self._related_hosts[hostname] = realm + + @property + def realms(self) -> list[Realm]: + """All realms, in configuration order (first is the default).""" + return list(self._by_rp_id.values()) + + @property + def default(self) -> Realm: + """The default realm (first in configuration order).""" + return next(iter(self._by_rp_id.values())) + + def get(self, rp_id: str) -> Realm | None: + return self._by_rp_id.get(rp_id) + + def effective_auth_host(self, realm: Realm) -> str | None: + """Auth host serving WS/restricted APIs for a realm: its own, or the + first configured auth host (in realm order) as a shared fallback. + + Returns host[:port] suitable for URL building, or None. + """ + if realm.own_auth_host: + return realm.own_auth_host + for candidate in self._by_rp_id.values(): + if candidate.own_auth_host: + return candidate.own_auth_host + return None + + def resolve(self, host: str | None) -> Realm | None: + """Resolve a request Host header to a realm. + + Order: exact rp-id → exact auth host → exact related-origin + hostname → longest-suffix rp-id. Unknown hosts return None. + """ + h = hostutil.normalize_host(host) + if not h: + return None + if realm := self._by_rp_id.get(h): + return realm + if realm := self._auth_hosts.get(h): + return realm + if realm := self._related_hosts.get(h): + return realm + best = None + for rp_id, realm in self._by_rp_id.items(): + if h.endswith(f".{rp_id}") and (best is None or len(rp_id) > len(best.rp_id)): + best = realm + return best + + +def validate_config( + config: Config, *, related_origin_cap: int = DEFAULT_RELATED_ORIGIN_CAP +) -> None: + """Validate a combined configuration cross-realm. Raises ValueError.""" + if not config.realms: + raise ValueError("At least one realm (rp-id) is required") + + rp_ids: set[str] = set() + auth_hosts: dict[str, str] = {} # normalized host -> owning rp_id + related_hosts: dict[str, str] = {} # hostname -> owning rp_id + + for realm in config.realms: + hostutil.validate_rp_id(realm.rp_id) + if realm.rp_id in rp_ids: + raise ValueError(f"Duplicate rp-id '{realm.rp_id}'") + rp_ids.add(realm.rp_id) + + if realm.auth_host: + hostutil.validate_auth_host(realm.auth_host, realm.rp_id) + hn = hostutil.normalize_host( + hostutil.auth_host_netloc(realm.auth_host) or "" + ) + if hn: + if hn in auth_hosts: + raise ValueError( + f"auth-host '{hn}' is configured for both " + f"'{auth_hosts[hn]}' and '{realm.rp_id}'" + ) + auth_hosts[hn] = realm.rp_id + + related = 0 + for origin in realm.origins or []: + hn = hostutil.origin_hostname(origin) + if not hn: + raise ValueError(f"Invalid origin URL: '{origin}'") + if hostutil.is_subdomain(hn, realm.rp_id): + continue # Classic subtree origin + related += 1 + if hn in related_hosts: + raise ValueError( + f"Related origin host '{hn}' is configured for both " + f"'{related_hosts[hn]}' and '{realm.rp_id}'" + ) + related_hosts[hn] = realm.rp_id + if related > related_origin_cap: + raise ValueError( + f"Realm '{realm.rp_id}' has {related} related origins " + f"(maximum {related_origin_cap})" + ) + + for hn, owner in auth_hosts.items(): + if hn in rp_ids: + raise ValueError(f"auth-host '{hn}' collides with an rp-id") + if hn in related_hosts: + raise ValueError( + f"auth-host '{hn}' collides with a related origin of " + f"realm '{related_hosts[hn]}'" + ) + + for hn, owner in related_hosts.items(): + if hn in rp_ids: + raise ValueError( + f"Related origin host '{hn}' collides with an rp-id" + ) + for other in rp_ids: + if other != owner and hostutil.is_subdomain(hn, other): + raise ValueError( + f"Related origin host '{hn}' of realm '{owner}' " + f"falls inside realm '{other}'" + ) + + +def _derive_site( + realm: RealmConfig, *, listen_port: int | None, vite_url: str | None +) -> tuple[str, str]: + """Compute a realm's site_url and site_path. + + Priority: auth_host > origins[0] > PASKIA_VITE_URL (localhost realm + only) > http://localhost:port (localhost realm) > https://rp-id. + """ + if realm.auth_host: + return realm.auth_host, "/" + if realm.origins: + return realm.origins[0], "/auth/" + if realm.rp_id == "localhost": + if vite_url: + return vite_url.rstrip("/"), "/auth/" + if listen_port: + return f"http://localhost:{listen_port}", "/auth/" + return f"https://{realm.rp_id}", "/auth/" + + +_registry: RealmRegistry | None = None +_listen: list[str] | None = None + + +def configure(*, listen: list[str] | None = None) -> None: + """Record process-global serve parameters for site URL derivation.""" + global _listen + _listen = listen + + +def build(config: Config) -> RealmRegistry: + """Validate and build a registry from a combined configuration.""" + validate_config(config) + endpoint = next(iter(parse_endpoints(_listen, DEFAULT_PORT)), {}) + vite_url = os.environ.get("PASKIA_VITE_URL") + realms = [ + Realm( + rc, + *_derive_site(rc, listen_port=endpoint.get("port"), vite_url=vite_url), + ) + for rc in config.realms + ] + return RealmRegistry(realms) + + +def init_registry(config: Config) -> RealmRegistry: + """Build and install the global registry from a combined configuration.""" + global _registry + _registry = build(config) + return _registry + + +def registry() -> RealmRegistry: + """Return the global registry (must be initialized).""" + if _registry is None: + raise RuntimeError("Realm registry is not initialized") + return _registry + + +_current_realm: contextvars.ContextVar[Realm | None] = contextvars.ContextVar( + "paskia_current_realm", default=None +) + + +def set_current_realm(realm: Realm | None) -> contextvars.Token: + return _current_realm.set(realm) + + +def reset_current_realm(token: contextvars.Token) -> None: + _current_realm.reset(token) + + +def current_realm() -> Realm: + """Return the request's realm, or the default realm without request context.""" + realm = _current_realm.get() + if realm is not None: + return realm + return registry().default diff --git a/paskia/sansio.py b/paskia/sansio.py index 8890726..2bce3d1 100644 --- a/paskia/sansio.py +++ b/paskia/sansio.py @@ -8,8 +8,6 @@ This module provides a unified interface for WebAuthn operations including: """ import json -import re -from urllib.parse import urlparse from uuid import UUID from webauthn import ( @@ -36,7 +34,8 @@ from webauthn.helpers.structs import ( UserVerificationRequirement, ) -from paskia.db import Credential +from paskia.db.structs import Credential +from paskia.util import hostutil class Passkey: @@ -56,20 +55,22 @@ class Passkey: rp_id: Your security domain (e.g. "example.com") rp_name: The relying party display name (e.g. "Example App"). May be shown in authenticators. origins: List of allowed origin URLs (e.g. ["https://app.example.com", "https://auth.example.com"]). - Each must be a subdomain or same as rp_id. If not provided, any subdomain of rp_id is allowed. + Origins may be subdomains of rp_id (classic) or explicit related + origins on unrelated domains (Related Origin Requests). + If not provided, any subdomain of rp_id is allowed. supported_pub_key_algs: List of supported COSE algorithms (default is EDDSA, ECDSA_SHA_256, RSASSA_PKCS1_v1_5_SHA_256). Raises: - ValueError: If any origin domain doesn't match or isn't a subdomain of rp_id. + ValueError: If rp_id is not a valid domain or an origin is malformed. """ self.rp_id = rp_id - self._validate_rp_id(rp_id) + hostutil.validate_rp_id(rp_id) self.rp_name = rp_name or rp_id self.allowed_origins: set[str] | None = None if origins: # Validate and deduplicate origins into a set for O(1) lookups for o in origins: - self._validate_origin(o, rp_id) + self._validate_origin_url(o) self.allowed_origins = set(origins) self.supported_pub_key_algs = supported_pub_key_algs or [ COSEAlgorithmIdentifier.EDDSA, @@ -77,37 +78,23 @@ class Passkey: COSEAlgorithmIdentifier.RSASSA_PKCS1_v1_5_SHA_256, ] - def _validate_rp_id(self, rp_id: str) -> None: - """Validate that rp_id is a valid domain name.""" - if not rp_id: - raise ValueError("rp_id cannot be empty") - # Allow localhost, or domain-like strings - if rp_id == "localhost": - return - # Regex for valid domain: letters, digits, hyphens, dots, but not starting/ending with hyphen, etc. - # Simplified: alphanumeric, dots, hyphens - if not re.match( - r"^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$", - rp_id, - ): - raise ValueError(f"rp_id '{rp_id}' is not a valid domain name") - - def _validate_origin(self, origin: str, rp_id: str) -> None: - """Validate an origin URL against the rp_id.""" - hostname = urlparse(origin).hostname - if not hostname: + @staticmethod + def _validate_origin_url(origin: str) -> None: + """Validate that an origin URL is well-formed (has a hostname).""" + if not hostutil.origin_hostname(origin): raise ValueError(f"Invalid origin URL: no hostname found in '{origin}'") - if hostname == rp_id or hostname.endswith(f".{rp_id}"): - return - - raise ValueError( - f"Origin domain '{hostname}' must be the same as or a subdomain of rp_id '{rp_id}'" - ) + def _origin_in_subtree(self, origin: str) -> bool: + """Check whether an origin's hostname is the rp-id or its subdomain.""" + hostname = hostutil.origin_hostname(origin) + return bool(hostname) and hostutil.is_subdomain(hostname, self.rp_id) def validate_origin(self, origin: str) -> str: """Validate that origin is allowed and return it. + An origin is valid if its hostname is in the rp-id subtree **or** it + is explicitly listed in the configured origins (related origins). + Args: origin: The origin URL to validate (from WebSocket request header) @@ -115,13 +102,14 @@ class Passkey: The validated origin URL Raises: - ValueError: If origin is not in the allowed list (when origins are configured) - or if origin is not a valid subdomain of rp_id + ValueError: If origin is neither in the rp-id subtree nor listed """ - self._validate_origin(origin, self.rp_id) - if self.allowed_origins is not None and origin not in self.allowed_origins: - raise ValueError(f"Origin '{origin}' is not in the allowed origins list") - return origin + self._validate_origin_url(origin) + if self._origin_in_subtree(origin): + return origin + if self.allowed_origins is not None and origin in self.allowed_origins: + return origin + raise ValueError(f"Origin '{origin}' is not allowed for rp_id '{self.rp_id}'") ### Registration Methods ### @@ -197,6 +185,7 @@ class Passkey: aaguid=UUID(registration.aaguid), public_key=registration.credential_public_key, sign_count=registration.sign_count, + rp_id=self.rp_id, ) ### Authentication Methods ### diff --git a/paskia/util/hostutil.py b/paskia/util/hostutil.py index 5577e42..c4d2421 100644 --- a/paskia/util/hostutil.py +++ b/paskia/util/hostutil.py @@ -1,56 +1,21 @@ -"""Utilities for determining the auth UI host and base URLs.""" +"""Utilities for host/origin normalization and validation.""" +import re from urllib.parse import urlparse, urlsplit -from paskia.util.runtime import clear_config_cache -from paskia.util.runtime import config as runtime_config +_RP_ID_RE = re.compile( + r"^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$" +) -def _cfg(): - return runtime_config() - - -def is_root_mode() -> bool: - cfg = _cfg() - return cfg is not None and cfg.config.auth_host is not None - - -def dedicated_auth_host() -> str | None: - """Return configured auth_host netloc, or None.""" - cfg = _cfg() - auth_host = cfg.config.auth_host if cfg else None - if not auth_host: - return None - - parsed = urlparse(auth_host if "://" in auth_host else f"//{auth_host}") - return parsed.netloc or parsed.path or None - - -def ui_base_path() -> str: - return "/" if is_root_mode() else "/auth/" - - -def api_url(path: str = "") -> str: - """Return an absolute URL under the canonical /auth/api/ prefix.""" - cfg = _cfg() - base = cfg.site_url if cfg else "https://localhost" - if not path: - return f"{base}/auth/api/" - normalized = path.lstrip("/") - return f"{base}/auth/api/{normalized}" - - -def auth_site_url() -> str: - """Return the base URL for the auth site UI (computed at startup).""" - cfg = _cfg() - if cfg: - return cfg.site_url + cfg.site_path - return "https://localhost/auth/" - - -def reset_link_url(token: str) -> str: - """Generate a reset link URL for the given token.""" - return f"{auth_site_url()}{token}" +def validate_rp_id(rp_id: str) -> None: + """Validate that rp_id is a valid domain name (or localhost).""" + if not rp_id: + raise ValueError("rp_id cannot be empty") + if rp_id == "localhost": + return + if not _RP_ID_RE.match(rp_id): + raise ValueError(f"rp_id '{rp_id}' is not a valid domain name") def normalize_origin(origin: str) -> str: @@ -60,6 +25,11 @@ def normalize_origin(origin: str) -> str: return origin.rstrip("/") +def origin_hostname(origin: str) -> str | None: + """Extract the lowercase hostname from an origin URL, if well-formed.""" + return urlparse(origin).hostname + + def is_subdomain(sub: str, domain: str) -> bool: """Check if sub is a subdomain of domain (or equal).""" sub_parts = sub.lower().split(".") @@ -84,10 +54,16 @@ def validate_auth_host(auth_host: str, rp_id: str) -> None: ) +def auth_host_netloc(auth_host: str) -> str | None: + """Return the host[:port] part of a configured auth host URL.""" + parsed = urlparse(auth_host if "://" in auth_host else f"//{auth_host}") + return parsed.netloc or parsed.path or None + + def normalize_auth_host_and_origins( auth_host: str | None, origins: list[str] | None ) -> tuple[str | None, list[str] | None]: - """Normalize auth_host and origins, matching CLI startup behavior. + """Normalize auth_host and origins. - Adds https:// to auth_host if no scheme present, strips trailing slashes - Validates auth_host is a well-formed subdomain (caller provides rp_id via validate_auth_host) @@ -105,12 +81,8 @@ def normalize_auth_host_and_origins( return auth_host, origins -def reload_config() -> None: - clear_config_cache() - - def normalize_host(raw_host: str | None) -> str | None: - """Normalize a Host header, stripping port numbers for consistent matching.""" + """Normalize a Host header, stripping port numbers and trailing dots.""" if not raw_host: return None candidate = raw_host.strip() @@ -127,7 +99,7 @@ def normalize_host(raw_host: str | None) -> str | None: else: # Strip port from host:port netloc = netloc.rsplit(":", 1)[0] - return netloc.lower() or None + return netloc.lower().rstrip(".") or None def format_endpoint(ep: dict) -> str: diff --git a/paskia/util/oidjwt.py b/paskia/util/oidjwt.py index 1a0e55a..ec7adea 100644 --- a/paskia/util/oidjwt.py +++ b/paskia/util/oidjwt.py @@ -1,5 +1,8 @@ """ OIDC JWT utilities for signing ID tokens and serving JWKS. + +Each realm is an independent OIDC provider with its own signing key; +keys are cached per rp-id. """ import hashlib @@ -18,46 +21,50 @@ from paskia.util.crypto import ( secret_key, ) -# JWT signing key (loaded on first use) -_private_key = None -_public_key = None -_kid: str | None = None +# JWT signing keys (loaded on first use), keyed by realm rp-id +_keys: dict[str, tuple[object, object, str]] = {} -def _load_or_generate_key() -> None: - """Load existing Ed25519 key or generate a new one.""" - global _private_key, _public_key, _kid - +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.""" data = db.data() + provider = data.oidc.get(rp_id) + if provider is None: + raise RuntimeError(f"No OIDC provider for realm {rp_id}") store = data._store if store is None: raise RuntimeError("Kanta store is not initialized") - if data.oidc.key is not None: - _private_key = public_key_from_secret(data.oidc.key) + if provider.key is not None: + private_key = public_key_from_secret(provider.key) else: raw_key = secret_key() with store.transaction("oidc_key"): - data.oidc.key = raw_key - _private_key = public_key_from_secret(raw_key) + provider.key = raw_key + private_key = public_key_from_secret(raw_key) - _public_key = _private_key.public_key() + public_key = private_key.public_key() # Generate kid from public key fingerprint - pub_der = get_public_key_der(_private_key) - _kid = generate_kid(pub_der) + kid = generate_kid(get_public_key_der(private_key)) + return private_key, public_key, kid -def _ensure_key() -> None: - """Ensure key is loaded.""" - if _private_key is None: - _load_or_generate_key() +def _ensure_key(rp_id: str) -> tuple[object, object, str]: + """Ensure a realm'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 get_jwks() -> dict: +def clear_key(rp_id: str) -> None: + """Drop a realm's cached key (realm deleted or key rotated).""" + _keys.pop(rp_id, None) + + +def get_jwks(rp_id: str) -> dict: """Get JWKS (JSON Web Key Set) for public key verification.""" - _ensure_key() - assert _public_key is not None + private_key, _, kid = _ensure_key(rp_id) # Ed25519 public key is 32 bytes raw - pub_bytes = get_public_key_raw(_private_key) + pub_bytes = get_public_key_raw(private_key) return { "keys": [ { @@ -65,7 +72,7 @@ def get_jwks() -> dict: "crv": "Ed25519", "use": "sig", "alg": "EdDSA", - "kid": _kid, + "kid": kid, "x": urlsafe_b64encode(pub_bytes).rstrip(b"=").decode("ascii"), } ] @@ -73,6 +80,7 @@ def get_jwks() -> dict: def create_id_token( + rp_id: str, issuer: str, subject: UUID, audience: str, # client_id @@ -89,6 +97,7 @@ def create_id_token( """Create a signed ID token (JWT). Args: + rp_id: Realm whose signing key to use issuer: Token issuer (site URL) subject: User UUID (sub claim) audience: Client ID (aud claim) @@ -105,8 +114,7 @@ def create_id_token( Returns: Signed JWT string """ - _ensure_key() - assert _private_key is not None + private_key, _, kid = _ensure_key(rp_id) now = datetime.now(UTC) payload: dict[str, object] = { "iss": issuer, @@ -132,10 +140,11 @@ def create_id_token( if auth_time: payload["auth_time"] = int(auth_time.timestamp()) - return jwt.encode(payload, _private_key, algorithm="EdDSA", headers={"kid": _kid}) + return jwt.encode(payload, private_key, algorithm="EdDSA", headers={"kid": kid}) def create_access_token( + rp_id: str, issuer: str, subject: UUID, audience: str, @@ -145,6 +154,7 @@ def create_access_token( """Create a signed access token (JWT) for userinfo endpoint. Args: + rp_id: Realm whose signing key to use issuer: Token issuer (site URL) subject: User UUID audience: Client ID @@ -154,8 +164,7 @@ def create_access_token( Returns: Signed JWT string """ - _ensure_key() - assert _private_key is not None + private_key, _, kid = _ensure_key(rp_id) now = datetime.now(UTC) payload: dict[str, object] = { "iss": issuer, @@ -165,15 +174,16 @@ def create_access_token( "iat": int(now.timestamp()), "exp": int((now + timedelta(seconds=expires_in)).timestamp()), } - return jwt.encode(payload, _private_key, algorithm="EdDSA", headers={"kid": _kid}) + return jwt.encode(payload, private_key, algorithm="EdDSA", headers={"kid": kid}) def decode_access_token( - token: str, issuer: str, audience: str | None = None + rp_id: str, token: str, issuer: str, audience: str | None = None ) -> dict | None: """Decode and verify an access token. Args: + rp_id: Realm whose key to verify with token: JWT string issuer: Expected issuer audience: Optional expected audience (client_id). If provided, aud claim must match. @@ -181,13 +191,12 @@ def decode_access_token( Returns: Decoded payload or None if invalid """ - _ensure_key() - assert _public_key is not None + _, public_key, _ = _ensure_key(rp_id) try: if audience is not None: return jwt.decode( token, - _public_key, + public_key, algorithms=["EdDSA"], issuer=issuer, audience=audience, @@ -195,7 +204,7 @@ def decode_access_token( return jwt.decode( token, - _public_key, + public_key, algorithms=["EdDSA"], issuer=issuer, options={"verify_aud": False}, @@ -205,6 +214,7 @@ def decode_access_token( def create_logout_token( + rp_id: str, issuer: str, audience: str, sid: str | None = None, @@ -216,6 +226,7 @@ def create_logout_token( either sid (session) or sub (user), or both. Args: + rp_id: Realm whose signing key to use issuer: Token issuer (site URL) audience: Client ID (aud claim) sid: Session ID (base64url-encoded) @@ -224,8 +235,7 @@ def create_logout_token( Returns: Signed JWT string """ - _ensure_key() - assert _private_key is not None + private_key, _, kid = _ensure_key(rp_id) now = datetime.now(UTC) payload: dict[str, object] = { "iss": issuer, @@ -241,4 +251,4 @@ def create_logout_token( payload["sid"] = sid if sub: payload["sub"] = str(sub) - return jwt.encode(payload, _private_key, algorithm="EdDSA", headers={"kid": _kid}) + return jwt.encode(payload, private_key, algorithm="EdDSA", headers={"kid": kid}) diff --git a/paskia/util/runtime.py b/paskia/util/runtime.py index 5d4e6d6..2d8ec72 100644 --- a/paskia/util/runtime.py +++ b/paskia/util/runtime.py @@ -1,75 +1,36 @@ -"""Runtime configuration utilities.""" +"""Runtime serve configuration (process-global parameters only). + +Realm configuration lives in the database (``Config.realms``); 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. +""" import os from functools import lru_cache import msgspec -from paskia.db.structs import Config +class ServeConfig(msgspec.Struct): + """Process-global serve parameters.""" -class RuntimeConfig(msgspec.Struct): - """Runtime configuration for the Paskia authentication server. - - Wraps the db Config (CLI/stored settings) with computed runtime fields. - Serialized to PASKIA_CONFIG env var as JSON via msgspec. - """ - - config: Config # CLI/stored configuration to persist - site_url: str # Base URL without trailing path (e.g. https://example.com) - site_path: str # Path to auth UI: "/" if auth_host, else "/auth/" - save: bool = False # Whether to persist config to database + listen: list[str] | None = None @lru_cache(maxsize=1) -def _load_config() -> RuntimeConfig | None: - """Load RuntimeConfig from PASKIA_CONFIG env var.""" - config_json = os.getenv("PASKIA_CONFIG") - if not config_json: +def _load() -> ServeConfig | None: + raw = os.getenv("PASKIA_CONFIG") + if not raw: return None - - return msgspec.json.decode(config_json.encode(), type=RuntimeConfig) + return msgspec.json.decode(raw.encode(), type=ServeConfig) -def config() -> RuntimeConfig | None: - """Return cached runtime config loaded from PASKIA_CONFIG.""" - return _load_config() +def serve_config() -> ServeConfig | None: + """Return cached serve configuration loaded from PASKIA_CONFIG.""" + return _load() -def clear_config_cache() -> None: - """Clear cached runtime config; next config() call reloads from env.""" - _load_config.cache_clear() - - -def update_runtime_config(new_config: Config) -> None: - """Update the runtime configuration with a new Config and refresh the cache.""" - current_runtime = config() - if not current_runtime: - return # No runtime config to update - - # Recompute site_url and site_path based on new config - old_auth_host = current_runtime.config.auth_host - if new_config.auth_host: - site_url, site_path = new_config.auth_host, "/" - else: - site_path = "/auth/" - # Never derive site_url from a just-removed auth host - origins = [o for o in (new_config.origins or []) if o != old_auth_host] - if origins: - site_url = origins[0] - elif current_runtime.site_url != old_auth_host: - # Keep current site_url if it wasn't derived from the removed auth host - site_url = current_runtime.site_url - else: - site_url = f"https://{new_config.rp_id}" - - new_runtime = RuntimeConfig( - config=new_config, - site_url=site_url, - site_path=site_path, - save=current_runtime.save, - ) - os.environ["PASKIA_CONFIG"] = msgspec.json.encode(new_runtime).decode() - - # Clear the cache so next access loads the updated config - clear_config_cache() +def clear_cache() -> None: + """Clear cached serve configuration; next serve_config() reloads.""" + _load.cache_clear() diff --git a/paskia/util/startupbox.py b/paskia/util/startupbox.py index a0f45de..c06349f 100644 --- a/paskia/util/startupbox.py +++ b/paskia/util/startupbox.py @@ -14,7 +14,7 @@ from paskia.util.constants import DEFAULT_PORT, DEVMODE from paskia.util.hostutil import format_endpoint if TYPE_CHECKING: - from paskia.util.runtime import RuntimeConfig + from paskia.realms import RealmRegistry BOX_WIDTH = 60 # Inner width (excluding box chars) @@ -48,14 +48,18 @@ def bottom() -> str: return "┗" + "━" * (BOX_WIDTH + 2) + "┛\n" -def print_startup_config(runtime: RuntimeConfig) -> None: - """Print server configuration on startup.""" +def print_startup_config( + registry: RealmRegistry, listen: list[str] | None = None +) -> None: + """Print server configuration on startup (one section per realm).""" # 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 + lines = [top()] lines.append(line(f" {b}▄▄▄▄▄{r}")) lines.append(line(f"{b}█{y} {b}█{r} Paskia " + __version__)) @@ -63,43 +67,41 @@ def print_startup_config(runtime: RuntimeConfig) -> None: lines.append( line( f"{b}█{y} {b}█{y}▀▀▀▀{b}█{y}▀▀{b}█{y}▀▀{b}█{r} {w}" - + runtime.site_url - + runtime.site_path + + default.site_url + + default.site_path + r ) ) lines.append(line(f" {y}▀▀▀▀▀{r}")) - # Format auth host section - if runtime.config.auth_host: - lines.append(line(f"Auth Host: {runtime.config.auth_host}")) - # Show frontend URL if in dev mode if DEVMODE: lines.append(line(f"Dev Frontend: {os.environ.get('PASKIA_VITE_URL')}")) # Format listen endpoints (dev mode only uses the first endpoint) - endpoints = list(parse_endpoints(runtime.config.listen, DEFAULT_PORT)) + endpoints = list(parse_endpoints(listen, DEFAULT_PORT)) if DEVMODE: endpoints = endpoints[:1] # server.run reload=True uses only one parts = [format_endpoint(ep) for ep in endpoints] lines.append(line(f"Backend: {' '.join(parts)}")) - # Relying Party line (omit name if same as id) - rp_id = runtime.config.rp_id - rp_name = runtime.config.rp_name - suffix = f" ({rp_name})" if rp_name and rp_name != rp_id else "" - lines.append(line(f"Relying Party: {rp_id}{suffix}")) - - # Format origins section - allowed = runtime.config.origins - if allowed: - lines.append(line("Permitted Origins:")) - for origin in sorted(allowed): - lines.append(line(f" - {origin}")) - else: - lines.append(line(f"Origin: {rp_id} and all subdomains allowed")) + 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")) lines.append(bottom()) stderr.write("".join(lines))