Clean-slate storage: realms Config, Credential.rp_id, Session rp_id/issuer, per-realm OIDC

- Config is now a realm list (first = default); DB.oidc keyed by rp-id
- Database at fixed paskia.kantadb; user files under paskia.data/users/
- Legacy <rp-id>.paskiadb reader/converter in db/legacy.py (to be deleted eventually)
- paskia init / paskia serve CLI split; serve adopts a lone legacy database
- Realm registry (paskia/realms.py) with cross-realm validation
- Per-realm OIDC keys in oidjwt; backchannel logout uses Session.rp_id/issuer
- Schema migrations discarded; on-disk legacy format assumed current
This commit is contained in:
2026-09-06 03:50:06 +00:00
parent 2da1ce777a
commit 4591a023dd
17 changed files with 1149 additions and 519 deletions
+174 -111
View File
@@ -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.",
metavar="LISTEN",
help=(
"Endpoint to listen on (default: localhost:4401). "
"Forms: host:port port :port [ipv6]:port unix:path /path.sock"
)
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.",
+ 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__":
+20 -31
View File
@@ -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
+10
View File
@@ -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",
+7 -8
View File
@@ -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
+243
View File
@@ -0,0 +1,243 @@
"""Legacy database format reader and converter.
Retains the msgspec structs used by the old ``<rp-id>.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 ``<name>.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
+22 -27
View File
@@ -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,8 +42,12 @@ 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)
# 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:
@@ -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.<rp-id>.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 = <hidden>").
if (path == "oidc.key" or path.endswith(".oidc.key")) and value != "key":
# itself as value and must stay visible ("oidc.<rp-id>.key = <hidden>").
if _OIDC_KEY_PATH.fullmatch(path) and value != "key":
return "<hidden>"
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:
-48
View File
@@ -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]
+92 -15
View File
@@ -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]
+19 -33
View File
@@ -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
+65 -10
View File
@@ -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,9 +699,14 @@ 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():
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
) -> SessionContext | None:
+37 -16
View File
@@ -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,
+306
View File
@@ -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
+26 -37
View File
@@ -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")
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 ###
+27 -55
View File
@@ -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:
+48 -38
View File
@@ -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})
+20 -59
View File
@@ -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()
+25 -23
View File
@@ -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}"))
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: {rp_id} and all subdomains allowed"))
lines.append(line(f" Origin: {realm.rp_id} and subdomains"))
lines.append(bottom())
stderr.write("".join(lines))