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:
+175
-112
@@ -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__":
|
||||
|
||||
+20
-31
@@ -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
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
+26
-31
@@ -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.<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:
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
+66
-11
@@ -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
|
||||
|
||||
+37
-16
@@ -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,
|
||||
|
||||
@@ -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
|
||||
+27
-38
@@ -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 ###
|
||||
|
||||
+27
-55
@@ -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
@@ -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
@@ -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()
|
||||
|
||||
+26
-24
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user