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 kanta import Kanta
|
||||||
|
|
||||||
from paskia._version import __version__
|
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.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 import startupbox
|
||||||
from paskia.util.constants import DEFAULT_PORT, DEVMODE
|
from paskia.util.constants import DEFAULT_PORT, DEVMODE
|
||||||
from paskia.util.hostutil import (
|
from paskia.util.hostutil import (
|
||||||
@@ -20,52 +25,44 @@ from paskia.util.hostutil import (
|
|||||||
normalize_origin,
|
normalize_origin,
|
||||||
validate_auth_host,
|
validate_auth_host,
|
||||||
)
|
)
|
||||||
from paskia.util.runtime import RuntimeConfig
|
from paskia.util.runtime import ServeConfig
|
||||||
|
|
||||||
EPILOG = """\
|
EPILOG = """\
|
||||||
Example:
|
Examples:
|
||||||
paskia --rp-id example.com --rp-name "Example Corporation" --auth-host auth.example.com
|
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(
|
p.add_argument(
|
||||||
"--rp-id", default="localhost", help="Relying Party ID (default: localhost)"
|
"-l",
|
||||||
)
|
"--listen",
|
||||||
p.add_argument("--rp-name", help="Relying Party name (default: same as rp-id)")
|
|
||||||
p.add_argument(
|
|
||||||
"--origin",
|
|
||||||
action="append",
|
action="append",
|
||||||
dest="origins",
|
metavar="LISTEN",
|
||||||
metavar="URL",
|
help=(
|
||||||
help="Allowed origin URL(s). May be specified multiple times. If any are specified, only those origins are permitted for WebSocket authentication.",
|
"Endpoint to listen on (default: localhost:4401). "
|
||||||
)
|
"Forms: host:port port :port [ipv6]:port unix:path /path.sock"
|
||||||
p.add_argument(
|
)
|
||||||
"--auth-host",
|
+ help_extra,
|
||||||
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.",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
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.
|
"""Load the stored Config from disk using Kanta in read-only mode.
|
||||||
|
|
||||||
This must not depend on PASKIA_CONFIG or the global lifecycle Kanta.
|
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():
|
kanta = Kanta(str(db_path), DB())
|
||||||
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
|
|
||||||
|
|
||||||
async def _read() -> Config:
|
async def _read() -> Config:
|
||||||
await kanta.open(readonly=True)
|
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
|
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():
|
def main():
|
||||||
# Configure logging to remove the "ERROR:root:" prefix
|
# Configure logging to remove the "ERROR:root:" prefix
|
||||||
logging.basicConfig(level=logging.INFO, format="%(message)s", force=True)
|
logging.basicConfig(level=logging.INFO, format="%(message)s", force=True)
|
||||||
@@ -91,91 +199,46 @@ def main():
|
|||||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||||
epilog=EPILOG,
|
epilog=EPILOG,
|
||||||
)
|
)
|
||||||
|
_add_listen_option(parser)
|
||||||
|
|
||||||
parser.add_argument(
|
init_parser = argparse.ArgumentParser(
|
||||||
"-l",
|
prog="paskia init",
|
||||||
"--listen",
|
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",
|
action="append",
|
||||||
metavar="LISTEN",
|
help="Relying Party ID of the initial realm(s) (default: localhost). "
|
||||||
help=(
|
"Repeatable and comma-separated; the first is the default realm. "
|
||||||
"Endpoint to listen on (default: localhost:4401). "
|
"Further realms are added via the admin interface.",
|
||||||
"Forms: host:port port :port [ipv6]:port unix:path /path.sock"
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
add_common_options(parser)
|
init_parser.add_argument(
|
||||||
|
"--rp-name",
|
||||||
args = parser.parse_args()
|
help="Relying Party name of the default realm (default: same as rp-id). "
|
||||||
|
"Used by the initial admin registration; editable later via admin UI.",
|
||||||
# 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(
|
||||||
|
"--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
|
argv = sys.argv[1:]
|
||||||
ep = next(iter(parse_endpoints(config.listen, DEFAULT_PORT)), {})
|
if argv and argv[0] == "init":
|
||||||
port = ep.get("port")
|
cmd_init(init_parser.parse_args(argv[1:]))
|
||||||
|
|
||||||
# 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
|
|
||||||
else:
|
else:
|
||||||
site_url = f"https://{config.rp_id}" # Assume external reverse proxy
|
cmd_serve(parser.parse_args(argv))
|
||||||
|
|
||||||
# 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,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
+20
-31
@@ -1,20 +1,17 @@
|
|||||||
"""
|
"""
|
||||||
Bootstrap module for passkey authentication system.
|
Bootstrap module for passkey authentication system.
|
||||||
|
|
||||||
This module handles initial system setup when a new database is created,
|
The initial database seeding (admin user, organization, permissions,
|
||||||
including creating default admin user, organization, permissions, and
|
registration reset token) is performed by ``paskia init`` via
|
||||||
generating a reset link for initial admin setup.
|
: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
|
||||||
The actual database seeding is performed by the module-level kanta bootstrap
|
passkey under the default realm.
|
||||||
callback defined in :mod:`paskia.db.bootstrap` and registered during
|
|
||||||
:func:`paskia.db.lifecycle.init`.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from paskia import authsession, db
|
from paskia import authsession, db, realms
|
||||||
from paskia.db.bootstrap import log_reset_link
|
from paskia.db.bootstrap import log_reset_link
|
||||||
from paskia.db.structs import Config
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -32,15 +29,14 @@ def _configure_logger() -> None:
|
|||||||
_configure_logger()
|
_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:
|
async def check_admin_credentials() -> bool:
|
||||||
"""
|
"""
|
||||||
Check if the admin user needs credentials and create a reset link if needed.
|
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:
|
Returns:
|
||||||
bool: True if a reset link was created, False if admin already has credentials
|
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:
|
if not admin_users:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Check first admin user for credentials
|
# Check first admin user for credentials under the default realm
|
||||||
admin_user = admin_users[0]
|
admin_user = admin_users[0]
|
||||||
|
default = realms.registry().default
|
||||||
|
|
||||||
if not admin_user.credential_ids:
|
if not admin_user.credential_ids_for(default.rp_id):
|
||||||
# Admin exists but has no credentials, create reset link
|
# Admin exists but has no credential on the default realm
|
||||||
logger.info("⚠️ Admin user has no credentials!")
|
logger.info("⚠️ Admin user has no credentials on %s!", default.rp_id)
|
||||||
|
|
||||||
expiry = authsession.reset_expires()
|
expiry = authsession.reset_expires()
|
||||||
token = db.create_reset_token(
|
token = db.create_reset_token(
|
||||||
@@ -80,7 +77,7 @@ async def check_admin_credentials() -> bool:
|
|||||||
expiry=expiry,
|
expiry=expiry,
|
||||||
token_type="admin registration",
|
token_type="admin registration",
|
||||||
)
|
)
|
||||||
_log_reset_link(token)
|
log_reset_link(default.reset_link_url(token))
|
||||||
return True
|
return True
|
||||||
|
|
||||||
return False
|
return False
|
||||||
@@ -89,20 +86,12 @@ async def check_admin_credentials() -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
async def bootstrap_if_needed(config: Config | None = None) -> bool:
|
async def bootstrap_if_needed() -> bool:
|
||||||
"""
|
"""Run the serve-time admin credential check.
|
||||||
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()``.
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
bool: Always returns False (bootstrapping is performed during init).
|
bool: Always returns False (bootstrapping is performed by ``paskia init``).
|
||||||
"""
|
"""
|
||||||
await check_admin_credentials()
|
await check_admin_credentials()
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ from paskia.db.operations import (
|
|||||||
create_oid_client,
|
create_oid_client,
|
||||||
create_org,
|
create_org,
|
||||||
create_permission,
|
create_permission,
|
||||||
|
create_realm,
|
||||||
create_reset_token,
|
create_reset_token,
|
||||||
create_role,
|
create_role,
|
||||||
create_user,
|
create_user,
|
||||||
@@ -35,6 +36,7 @@ from paskia.db.operations import (
|
|||||||
delete_oid_client,
|
delete_oid_client,
|
||||||
delete_org,
|
delete_org,
|
||||||
delete_permission,
|
delete_permission,
|
||||||
|
delete_realm,
|
||||||
delete_reset_token,
|
delete_reset_token,
|
||||||
delete_role,
|
delete_role,
|
||||||
delete_session,
|
delete_session,
|
||||||
@@ -52,6 +54,7 @@ from paskia.db.operations import (
|
|||||||
update_oid_client,
|
update_oid_client,
|
||||||
update_org_name,
|
update_org_name,
|
||||||
update_permission,
|
update_permission,
|
||||||
|
update_realm,
|
||||||
update_role_name,
|
update_role_name,
|
||||||
update_session,
|
update_session,
|
||||||
update_user_display_name,
|
update_user_display_name,
|
||||||
@@ -60,11 +63,13 @@ from paskia.db.operations import (
|
|||||||
)
|
)
|
||||||
from paskia.db.structs import (
|
from paskia.db.structs import (
|
||||||
DB,
|
DB,
|
||||||
|
OIDC,
|
||||||
Client,
|
Client,
|
||||||
Config,
|
Config,
|
||||||
Credential,
|
Credential,
|
||||||
Org,
|
Org,
|
||||||
Permission,
|
Permission,
|
||||||
|
RealmConfig,
|
||||||
ResetToken,
|
ResetToken,
|
||||||
Role,
|
Role,
|
||||||
Session,
|
Session,
|
||||||
@@ -84,8 +89,10 @@ __all__ = [
|
|||||||
"Credential",
|
"Credential",
|
||||||
"DB",
|
"DB",
|
||||||
"Client",
|
"Client",
|
||||||
|
"OIDC",
|
||||||
"Org",
|
"Org",
|
||||||
"Permission",
|
"Permission",
|
||||||
|
"RealmConfig",
|
||||||
"ResetToken",
|
"ResetToken",
|
||||||
"Role",
|
"Role",
|
||||||
"Session",
|
"Session",
|
||||||
@@ -102,12 +109,14 @@ __all__ = [
|
|||||||
"create_credential_session",
|
"create_credential_session",
|
||||||
"create_org",
|
"create_org",
|
||||||
"create_permission",
|
"create_permission",
|
||||||
|
"create_realm",
|
||||||
"create_reset_token",
|
"create_reset_token",
|
||||||
"create_role",
|
"create_role",
|
||||||
"create_user",
|
"create_user",
|
||||||
"delete_credential",
|
"delete_credential",
|
||||||
"delete_org",
|
"delete_org",
|
||||||
"delete_permission",
|
"delete_permission",
|
||||||
|
"delete_realm",
|
||||||
"delete_reset_token",
|
"delete_reset_token",
|
||||||
"delete_role",
|
"delete_role",
|
||||||
"delete_session",
|
"delete_session",
|
||||||
@@ -122,6 +131,7 @@ __all__ = [
|
|||||||
"update_credential_sign_count",
|
"update_credential_sign_count",
|
||||||
"update_org_name",
|
"update_org_name",
|
||||||
"update_permission",
|
"update_permission",
|
||||||
|
"update_realm",
|
||||||
"update_role_name",
|
"update_role_name",
|
||||||
"update_session",
|
"update_session",
|
||||||
"update_user_display_name",
|
"update_user_display_name",
|
||||||
|
|||||||
@@ -9,9 +9,8 @@ from datetime import UTC, datetime
|
|||||||
import uuid7
|
import uuid7
|
||||||
|
|
||||||
from paskia.authsession import reset_expires
|
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.crypto import secret_key
|
||||||
from paskia.util.hostutil import reset_link_url
|
|
||||||
|
|
||||||
_reset_link_logger = logging.getLogger("paskia.reset_link")
|
_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."""
|
"""Log a reset link message and return the URL."""
|
||||||
reset_link = reset_link_url(passphrase)
|
|
||||||
if message:
|
if message:
|
||||||
_reset_link_logger.info(message)
|
_reset_link_logger.info(message)
|
||||||
_reset_link_logger.info(ADMIN_RESET_MESSAGE, reset_link)
|
_reset_link_logger.info(ADMIN_RESET_MESSAGE, url)
|
||||||
return reset_link
|
return url
|
||||||
|
|
||||||
|
|
||||||
def bootstrap(
|
def bootstrap(
|
||||||
@@ -147,8 +145,9 @@ def bootstrap(
|
|||||||
if config is not None:
|
if config is not None:
|
||||||
data.config = config
|
data.config = config
|
||||||
|
|
||||||
# Generate OIDC signing key
|
# Generate an OIDC signing key for each realm
|
||||||
data.oidc.key = secret_key()
|
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
|
# Store all bootstrapped objects in the live data object
|
||||||
data.permissions[perm_admin_uuid] = perm_admin
|
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 asyncio
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import signal
|
import signal
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -17,24 +18,14 @@ from kanta.exceptions import DatabaseError
|
|||||||
import paskia.db.operations as _ops
|
import paskia.db.operations as _ops
|
||||||
from paskia import oidc_notify
|
from paskia import oidc_notify
|
||||||
from paskia.authsession import EXPIRES
|
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.paths import db_file_path
|
||||||
from paskia.db.structs import DB
|
from paskia.db.structs import DB
|
||||||
from paskia.util.runtime import config as runtime_config
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# The combined database lives at a fixed CWD-relative path; no runtime
|
||||||
runtime = runtime_config()
|
# configuration is needed to locate it.
|
||||||
if runtime is None:
|
kanta = Kanta(str(db_file_path()), _ops._db)
|
||||||
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
|
|
||||||
_ops._db._store = kanta
|
_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:
|
if isinstance(display_name, str) and display_name:
|
||||||
return display_name
|
return display_name
|
||||||
|
|
||||||
# OIDC clients use "name" instead of "display_name".
|
# OIDC clients use "name" instead of "display_name"; providers are
|
||||||
client = state.get("oidc", {}).get("clients", {}).get(uuid_str)
|
# nested per realm rp-id.
|
||||||
if isinstance(client, dict):
|
for provider in state.get("oidc", {}).values():
|
||||||
name = client.get("name")
|
if not isinstance(provider, dict):
|
||||||
if isinstance(name, str) and name:
|
continue
|
||||||
return name
|
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
|
return None
|
||||||
|
|
||||||
@@ -89,11 +84,16 @@ def _resolve_uuid_label(
|
|||||||
return _ops._db.roles[uid].display_name
|
return _ops._db.roles[uid].display_name
|
||||||
if uid in _ops._db.permissions:
|
if uid in _ops._db.permissions:
|
||||||
return _ops._db.permissions[uid].display_name
|
return _ops._db.permissions[uid].display_name
|
||||||
if uid in _ops._db.oidc.clients:
|
for provider in _ops._db.oidc.values():
|
||||||
return _ops._db.oidc.clients[uid].name
|
if uid in provider.clients:
|
||||||
|
return provider.clients[uid].name
|
||||||
return None
|
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
|
@kanta.logfmt
|
||||||
def format_log_uuid(
|
def format_log_uuid(
|
||||||
value: Any,
|
value: Any,
|
||||||
@@ -104,8 +104,8 @@ def format_log_uuid(
|
|||||||
"""Format UUID values/keys/actor labels and censor secrets in transaction logs."""
|
"""Format UUID values/keys/actor labels and censor secrets in transaction logs."""
|
||||||
# Censor sensitive OIDC key material regardless of value type, but only
|
# Censor sensitive OIDC key material regardless of value type, but only
|
||||||
# when formatting the value: path components are passed with the component
|
# when formatting the value: path components are passed with the component
|
||||||
# itself as value and must stay visible ("oidc.key = <hidden>").
|
# itself as value and must stay visible ("oidc.<rp-id>.key = <hidden>").
|
||||||
if (path == "oidc.key" or path.endswith(".oidc.key")) and value != "key":
|
if _OIDC_KEY_PATH.fullmatch(path) and value != "key":
|
||||||
return "<hidden>"
|
return "<hidden>"
|
||||||
|
|
||||||
if not isinstance(value, str):
|
if not isinstance(value, str):
|
||||||
@@ -122,17 +122,12 @@ def terminate(error: DatabaseError) -> None:
|
|||||||
os.kill(os.getpid(), signal.SIGTERM)
|
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():
|
async def init():
|
||||||
"""Load database from JSONL file using kanta.
|
"""Load database from JSONL file using kanta.
|
||||||
|
|
||||||
If the database file is empty, the configured bootstrap callback seeds it
|
The database must already exist and be initialized (see ``paskia
|
||||||
with default permissions, organization, role, admin user and a reset token.
|
init``); the serve command's startup checks guarantee this before the
|
||||||
|
lifespan runs.
|
||||||
"""
|
"""
|
||||||
rootpath = Path(kanta.filename).parent
|
rootpath = Path(kanta.filename).parent
|
||||||
try:
|
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.config import SESSION_LIFETIME
|
||||||
from paskia.db.structs import (
|
from paskia.db.structs import (
|
||||||
DB,
|
DB,
|
||||||
|
OIDC,
|
||||||
Client,
|
Client,
|
||||||
Config,
|
Config,
|
||||||
Credential,
|
Credential,
|
||||||
Org,
|
Org,
|
||||||
Permission,
|
Permission,
|
||||||
|
RealmConfig,
|
||||||
ResetToken,
|
ResetToken,
|
||||||
Role,
|
Role,
|
||||||
Session,
|
Session,
|
||||||
SessionContext,
|
SessionContext,
|
||||||
User,
|
User,
|
||||||
)
|
)
|
||||||
from paskia.util.crypto import hash_secret
|
from paskia.util.crypto import hash_secret, secret_key
|
||||||
from paskia.util.nameutil import slugify_name
|
from paskia.util.nameutil import slugify_name
|
||||||
|
|
||||||
_logger = logging.getLogger(__name__)
|
_logger = logging.getLogger(__name__)
|
||||||
@@ -37,7 +39,7 @@ _logger = logging.getLogger(__name__)
|
|||||||
_UNSET = object()
|
_UNSET = object()
|
||||||
|
|
||||||
# Global database instance (empty until init() loads data)
|
# Global database instance (empty until init() loads data)
|
||||||
_db = DB(config=Config(rp_id="uninitialized.invalid"))
|
_db = DB()
|
||||||
|
|
||||||
|
|
||||||
def _store():
|
def _store():
|
||||||
@@ -703,20 +705,89 @@ def create_credential_session(
|
|||||||
return token
|
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
|
# OIDC Provider operations
|
||||||
# -------------------------------------------------------------------------
|
# -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def create_oid_client(client: Client, *, ctx: SessionContext | None = None) -> None:
|
def create_oid_client(
|
||||||
"""Create a new OIDC client."""
|
rp_id: str, client: Client, *, ctx: SessionContext | None = None
|
||||||
if client.uuid in _db.oidc.clients:
|
) -> 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")
|
raise ValueError(f"OIDC client {client.uuid} already exists")
|
||||||
with _transaction("admin:create_oid_client", ctx):
|
with _transaction("admin:create_oid_client", ctx):
|
||||||
_db.oidc.clients[client.uuid] = client
|
provider.clients[client.uuid] = client
|
||||||
|
|
||||||
|
|
||||||
def update_oid_client(
|
def update_oid_client(
|
||||||
|
rp_id: str,
|
||||||
client_uuid: UUID,
|
client_uuid: UUID,
|
||||||
name: str | None = None,
|
name: str | None = None,
|
||||||
redirect_uris: list[str] | None = None,
|
redirect_uris: list[str] | None = None,
|
||||||
@@ -726,10 +797,11 @@ def update_oid_client(
|
|||||||
ctx: SessionContext | None = None,
|
ctx: SessionContext | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Update an OIDC client's name, redirect URIs, and/or secret."""
|
"""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")
|
raise ValueError(f"OIDC client {client_uuid} not found")
|
||||||
|
|
||||||
client = _db.oidc.clients[client_uuid]
|
client = provider.clients[client_uuid]
|
||||||
changes = {}
|
changes = {}
|
||||||
|
|
||||||
if name is not None and name != client.name:
|
if name is not None and name != client.name:
|
||||||
@@ -766,19 +838,21 @@ def update_oid_client(
|
|||||||
backchannel_logout_uri=new_logout_uri,
|
backchannel_logout_uri=new_logout_uri,
|
||||||
)
|
)
|
||||||
updated_client.uuid = client.uuid
|
updated_client.uuid = client.uuid
|
||||||
_db.oidc.clients[client_uuid] = updated_client
|
provider.clients[client_uuid] = updated_client
|
||||||
|
|
||||||
|
|
||||||
def reset_oid_client_secret(
|
def reset_oid_client_secret(
|
||||||
|
rp_id: str,
|
||||||
client_uuid: UUID,
|
client_uuid: UUID,
|
||||||
new_secret_hash: bytes,
|
new_secret_hash: bytes,
|
||||||
*,
|
*,
|
||||||
ctx: SessionContext | None = None,
|
ctx: SessionContext | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Reset an OIDC client's secret."""
|
"""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")
|
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):
|
with _transaction("admin:reset_oid_client_secret", ctx):
|
||||||
updated = Client(
|
updated = Client(
|
||||||
client_secret_hash=new_secret_hash,
|
client_secret_hash=new_secret_hash,
|
||||||
@@ -787,12 +861,15 @@ def reset_oid_client_secret(
|
|||||||
backchannel_logout_uri=client.backchannel_logout_uri,
|
backchannel_logout_uri=client.backchannel_logout_uri,
|
||||||
)
|
)
|
||||||
updated.uuid = client.uuid
|
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."""
|
"""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")
|
raise ValueError(f"OIDC client {client_uuid} not found")
|
||||||
with _transaction("admin:delete_oid_client", ctx):
|
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
|
from pathlib import Path
|
||||||
|
|
||||||
|
DB_FILENAME = "paskia.kantadb"
|
||||||
def db_root_path(*, rp_id: str = "localhost") -> Path:
|
DATA_DIRNAME = "paskia.data"
|
||||||
"""Return the configured persistence root directory."""
|
|
||||||
return Path(os.environ.get("PASKIA_DB", f"{rp_id}.paskiadb"))
|
|
||||||
|
|
||||||
|
|
||||||
def db_file_path(*, rp_id: str = "localhost", create_root: bool = False) -> Path:
|
def db_file_path() -> Path:
|
||||||
"""Return the JSONL database file path under the persistence root."""
|
"""Return the combined database file path."""
|
||||||
root = db_root_path(rp_id=rp_id)
|
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:
|
if create_root:
|
||||||
root.mkdir(parents=True, exist_ok=True)
|
root.mkdir(parents=True, exist_ok=True)
|
||||||
|
return root
|
||||||
return root / "main.db"
|
|
||||||
|
|
||||||
|
|
||||||
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."""
|
"""Return the filesystem root for persisted user files."""
|
||||||
root = db_root_path(rp_id=rp_id)
|
root = data_root_path(create_root=create_root) / "users"
|
||||||
|
|
||||||
if root.is_file():
|
|
||||||
_migrate_legacy_db_file(root)
|
|
||||||
|
|
||||||
if create_root:
|
if create_root:
|
||||||
root.mkdir(parents=True, exist_ok=True)
|
root.mkdir(parents=True, exist_ok=True)
|
||||||
|
return root
|
||||||
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)
|
|
||||||
|
|||||||
+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)."""
|
"""Get credential IDs for this user (for WebAuthn exclude lists)."""
|
||||||
return [c.credential_id for c in self.credentials]
|
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
|
@property
|
||||||
def sessions(self) -> list[Session]:
|
def sessions(self) -> list[Session]:
|
||||||
"""Get all sessions for this user."""
|
"""Get all sessions for this user."""
|
||||||
@@ -290,8 +296,12 @@ class Credential(msgspec.Struct, dict=True):
|
|||||||
"""Credential (passkey) data structure.
|
"""Credential (passkey) data structure.
|
||||||
|
|
||||||
Mutable fields: sign_count, last_used, last_verified
|
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.
|
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
|
credential_id: bytes # Long binary ID from the authenticator
|
||||||
@@ -300,6 +310,7 @@ class Credential(msgspec.Struct, dict=True):
|
|||||||
public_key: bytes
|
public_key: bytes
|
||||||
sign_count: int
|
sign_count: int
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
rp_id: str
|
||||||
last_used: datetime | None = None
|
last_used: datetime | None = None
|
||||||
last_verified: datetime | None = None
|
last_verified: datetime | None = None
|
||||||
|
|
||||||
@@ -341,6 +352,7 @@ class Credential(msgspec.Struct, dict=True):
|
|||||||
aaguid: UUID,
|
aaguid: UUID,
|
||||||
public_key: bytes,
|
public_key: bytes,
|
||||||
sign_count: int,
|
sign_count: int,
|
||||||
|
rp_id: str,
|
||||||
created_at: datetime | None = None,
|
created_at: datetime | None = None,
|
||||||
) -> Credential:
|
) -> Credential:
|
||||||
"""Create a new Credential with auto-generated uuid7."""
|
"""Create a new Credential with auto-generated uuid7."""
|
||||||
@@ -353,6 +365,7 @@ class Credential(msgspec.Struct, dict=True):
|
|||||||
public_key=public_key,
|
public_key=public_key,
|
||||||
sign_count=sign_count,
|
sign_count=sign_count,
|
||||||
created_at=now,
|
created_at=now,
|
||||||
|
rp_id=rp_id,
|
||||||
last_used=now,
|
last_used=now,
|
||||||
last_verified=now,
|
last_verified=now,
|
||||||
)
|
)
|
||||||
@@ -380,6 +393,8 @@ class Session(msgspec.Struct, dict=True, omit_defaults=True):
|
|||||||
user_agent: str
|
user_agent: str
|
||||||
validated: datetime
|
validated: datetime
|
||||||
client_uuid: UUID | None = msgspec.field(name="client", default=None)
|
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):
|
def __post_init__(self):
|
||||||
if not hasattr(self, "key"):
|
if not hasattr(self, "key"):
|
||||||
@@ -429,11 +444,15 @@ class Session(msgspec.Struct, dict=True, omit_defaults=True):
|
|||||||
user_agent: str,
|
user_agent: str,
|
||||||
validated: datetime,
|
validated: datetime,
|
||||||
client: UUID | None = None,
|
client: UUID | None = None,
|
||||||
|
rp_id: str | None = None,
|
||||||
|
issuer: str | None = None,
|
||||||
) -> Session:
|
) -> Session:
|
||||||
"""Create a new Session with the provided key.
|
"""Create a new Session with the provided key.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
key: The hashed session key (derived from secret via hash_secret)
|
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:
|
Returns:
|
||||||
Session object with key set
|
Session object with key set
|
||||||
@@ -452,6 +471,8 @@ class Session(msgspec.Struct, dict=True, omit_defaults=True):
|
|||||||
user_agent=user_agent,
|
user_agent=user_agent,
|
||||||
validated=validated,
|
validated=validated,
|
||||||
client_uuid=client,
|
client_uuid=client,
|
||||||
|
rp_id=rp_id,
|
||||||
|
issuer=issuer,
|
||||||
)
|
)
|
||||||
session.key = key
|
session.key = key
|
||||||
return session
|
return session
|
||||||
@@ -601,14 +622,42 @@ class OIDC(msgspec.Struct, dict=True):
|
|||||||
key: bytes | None = None
|
key: bytes | None = None
|
||||||
|
|
||||||
|
|
||||||
class Config(msgspec.Struct, omit_defaults=True):
|
class RealmConfig(msgspec.Struct, omit_defaults=True):
|
||||||
"""Stored configuration for the instance."""
|
"""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_id: str
|
||||||
rp_name: str | None = None
|
rp_name: str | None = None
|
||||||
auth_host: str | None = None
|
auth_host: str | None = None # This realm's dedicated auth host (URL)
|
||||||
origins: list[str] | None = None
|
origins: list[str] | None = None # Subdomain origins AND related origins
|
||||||
listen: list[str] | None = None
|
|
||||||
|
|
||||||
|
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):
|
class DB(msgspec.Struct, dict=True, omit_defaults=False):
|
||||||
"""In-memory database. Access fields directly for reads."""
|
"""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] = {}
|
permissions: dict[UUID, Permission] = {}
|
||||||
orgs: dict[UUID, Org] = {}
|
orgs: dict[UUID, Org] = {}
|
||||||
roles: dict[UUID, Role] = {}
|
roles: dict[UUID, Role] = {}
|
||||||
@@ -627,8 +676,9 @@ class DB(msgspec.Struct, dict=True, omit_defaults=False):
|
|||||||
credentials: dict[UUID, Credential] = {}
|
credentials: dict[UUID, Credential] = {}
|
||||||
sessions: dict[str, Session] = {}
|
sessions: dict[str, Session] = {}
|
||||||
reset_tokens: dict[str, ResetToken] = {}
|
reset_tokens: dict[str, ResetToken] = {}
|
||||||
# OIDC provider data
|
# OIDC provider data, keyed by realm rp-id: each realm is an independent
|
||||||
oidc: OIDC = msgspec.field(default_factory=lambda: OIDC())
|
# issuer with its own signing key and clients.
|
||||||
|
oidc: dict[str, OIDC] = {}
|
||||||
|
|
||||||
def __post_init__(self):
|
def __post_init__(self):
|
||||||
# Optional store reference for non-global DB instances (e.g. tests).
|
# 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():
|
for key, token in self.reset_tokens.items():
|
||||||
token.key = key
|
token.key = key
|
||||||
# OIDC
|
# OIDC
|
||||||
for uuid, client in self.oidc.clients.items():
|
for provider in self.oidc.values():
|
||||||
client.uuid = uuid
|
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(
|
def session_ctx(
|
||||||
self, session_secret: str, host: str | None = None
|
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
|
When sessions are deleted (logout, admin, expiry), this module notifies
|
||||||
any OIDC clients that have a backchannel_logout_uri configured.
|
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
|
import asyncio
|
||||||
@@ -11,9 +15,8 @@ from uuid import UUID
|
|||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
from paskia import db
|
from paskia import db, realms
|
||||||
from paskia.util import oidjwt
|
from paskia.util import oidjwt
|
||||||
from paskia.util.runtime import config as runtime_config
|
|
||||||
|
|
||||||
_logger = logging.getLogger(__name__)
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -21,16 +24,23 @@ _logger = logging.getLogger(__name__)
|
|||||||
_TIMEOUT = httpx.Timeout(10.0, connect=5.0)
|
_TIMEOUT = httpx.Timeout(10.0, connect=5.0)
|
||||||
|
|
||||||
|
|
||||||
def _issuer() -> str:
|
def _session_realm(rp_id: str | None):
|
||||||
"""Derive issuer URL from config (same base as discovery document)."""
|
"""Resolve a session's realm, falling back to the default realm."""
|
||||||
cfg = runtime_config()
|
try:
|
||||||
return cfg.site_url if cfg else "https://localhost"
|
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(
|
def _collect_oidc_sessions(
|
||||||
session_keys: list[str],
|
session_keys: list[str],
|
||||||
) -> list[tuple[str, str, UUID, UUID | None]]:
|
) -> list[tuple[str, str, str, str, UUID, UUID | None]]:
|
||||||
"""Collect (backchannel_logout_uri, sid, client_uuid, user_uuid) for OIDC sessions.
|
"""Collect (logout_uri, rp_id, issuer, sid, client_uuid, user_uuid).
|
||||||
|
|
||||||
Must be called before the sessions are deleted from the database.
|
Must be called before the sessions are deleted from the database.
|
||||||
Returns only sessions whose client has a backchannel_logout_uri configured.
|
Returns only sessions whose client has a backchannel_logout_uri configured.
|
||||||
@@ -41,12 +51,23 @@ def _collect_oidc_sessions(
|
|||||||
session = data.sessions.get(key)
|
session = data.sessions.get(key)
|
||||||
if not session or session.client_uuid is None:
|
if not session or session.client_uuid is None:
|
||||||
continue
|
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:
|
if not client or not client.backchannel_logout_uri:
|
||||||
continue
|
continue
|
||||||
sid = session.key
|
issuer = session.issuer or realm.site_url
|
||||||
notifications.append(
|
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
|
return notifications
|
||||||
|
|
||||||
@@ -77,22 +98,22 @@ async def _send_logout_token(
|
|||||||
|
|
||||||
|
|
||||||
async def notify(
|
async def notify(
|
||||||
notifications: list[tuple[str, str, UUID, UUID | None]],
|
notifications: list[tuple[str, str, str, str, UUID, UUID | None]],
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Send back-channel logout tokens to all collected endpoints.
|
"""Send back-channel logout tokens to all collected endpoints.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
notifications: list of (backchannel_logout_uri, sid, client_uuid, user_uuid)
|
notifications: list of (backchannel_logout_uri, rp_id, issuer, sid,
|
||||||
as returned by _collect_oidc_sessions.
|
client_uuid, user_uuid) as returned by _collect_oidc_sessions.
|
||||||
"""
|
"""
|
||||||
if not notifications:
|
if not notifications:
|
||||||
return
|
return
|
||||||
|
|
||||||
issuer = _issuer()
|
|
||||||
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
|
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
|
||||||
tasks = []
|
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(
|
token = oidjwt.create_logout_token(
|
||||||
|
rp_id,
|
||||||
issuer=issuer,
|
issuer=issuer,
|
||||||
audience=str(client_uuid),
|
audience=str(client_uuid),
|
||||||
sid=sid,
|
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 json
|
||||||
import re
|
|
||||||
from urllib.parse import urlparse
|
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from webauthn import (
|
from webauthn import (
|
||||||
@@ -36,7 +34,8 @@ from webauthn.helpers.structs import (
|
|||||||
UserVerificationRequirement,
|
UserVerificationRequirement,
|
||||||
)
|
)
|
||||||
|
|
||||||
from paskia.db import Credential
|
from paskia.db.structs import Credential
|
||||||
|
from paskia.util import hostutil
|
||||||
|
|
||||||
|
|
||||||
class Passkey:
|
class Passkey:
|
||||||
@@ -56,20 +55,22 @@ class Passkey:
|
|||||||
rp_id: Your security domain (e.g. "example.com")
|
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.
|
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"]).
|
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).
|
supported_pub_key_algs: List of supported COSE algorithms (default is EDDSA, ECDSA_SHA_256, RSASSA_PKCS1_v1_5_SHA_256).
|
||||||
|
|
||||||
Raises:
|
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.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.rp_name = rp_name or rp_id
|
||||||
self.allowed_origins: set[str] | None = None
|
self.allowed_origins: set[str] | None = None
|
||||||
if origins:
|
if origins:
|
||||||
# Validate and deduplicate origins into a set for O(1) lookups
|
# Validate and deduplicate origins into a set for O(1) lookups
|
||||||
for o in origins:
|
for o in origins:
|
||||||
self._validate_origin(o, rp_id)
|
self._validate_origin_url(o)
|
||||||
self.allowed_origins = set(origins)
|
self.allowed_origins = set(origins)
|
||||||
self.supported_pub_key_algs = supported_pub_key_algs or [
|
self.supported_pub_key_algs = supported_pub_key_algs or [
|
||||||
COSEAlgorithmIdentifier.EDDSA,
|
COSEAlgorithmIdentifier.EDDSA,
|
||||||
@@ -77,37 +78,23 @@ class Passkey:
|
|||||||
COSEAlgorithmIdentifier.RSASSA_PKCS1_v1_5_SHA_256,
|
COSEAlgorithmIdentifier.RSASSA_PKCS1_v1_5_SHA_256,
|
||||||
]
|
]
|
||||||
|
|
||||||
def _validate_rp_id(self, rp_id: str) -> None:
|
@staticmethod
|
||||||
"""Validate that rp_id is a valid domain name."""
|
def _validate_origin_url(origin: str) -> None:
|
||||||
if not rp_id:
|
"""Validate that an origin URL is well-formed (has a hostname)."""
|
||||||
raise ValueError("rp_id cannot be empty")
|
if not hostutil.origin_hostname(origin):
|
||||||
# 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:
|
|
||||||
raise ValueError(f"Invalid origin URL: no hostname found in '{origin}'")
|
raise ValueError(f"Invalid origin URL: no hostname found in '{origin}'")
|
||||||
|
|
||||||
if hostname == rp_id or hostname.endswith(f".{rp_id}"):
|
def _origin_in_subtree(self, origin: str) -> bool:
|
||||||
return
|
"""Check whether an origin's hostname is the rp-id or its subdomain."""
|
||||||
|
hostname = hostutil.origin_hostname(origin)
|
||||||
raise ValueError(
|
return bool(hostname) and hostutil.is_subdomain(hostname, self.rp_id)
|
||||||
f"Origin domain '{hostname}' must be the same as or a subdomain of rp_id '{rp_id}'"
|
|
||||||
)
|
|
||||||
|
|
||||||
def validate_origin(self, origin: str) -> str:
|
def validate_origin(self, origin: str) -> str:
|
||||||
"""Validate that origin is allowed and return it.
|
"""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:
|
Args:
|
||||||
origin: The origin URL to validate (from WebSocket request header)
|
origin: The origin URL to validate (from WebSocket request header)
|
||||||
|
|
||||||
@@ -115,13 +102,14 @@ class Passkey:
|
|||||||
The validated origin URL
|
The validated origin URL
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
ValueError: If origin is not in the allowed list (when origins are configured)
|
ValueError: If origin is neither in the rp-id subtree nor listed
|
||||||
or if origin is not a valid subdomain of rp_id
|
|
||||||
"""
|
"""
|
||||||
self._validate_origin(origin, self.rp_id)
|
self._validate_origin_url(origin)
|
||||||
if self.allowed_origins is not None and origin not in self.allowed_origins:
|
if self._origin_in_subtree(origin):
|
||||||
raise ValueError(f"Origin '{origin}' is not in the allowed origins list")
|
return 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 ###
|
### Registration Methods ###
|
||||||
|
|
||||||
@@ -197,6 +185,7 @@ class Passkey:
|
|||||||
aaguid=UUID(registration.aaguid),
|
aaguid=UUID(registration.aaguid),
|
||||||
public_key=registration.credential_public_key,
|
public_key=registration.credential_public_key,
|
||||||
sign_count=registration.sign_count,
|
sign_count=registration.sign_count,
|
||||||
|
rp_id=self.rp_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
### Authentication Methods ###
|
### 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 urllib.parse import urlparse, urlsplit
|
||||||
|
|
||||||
from paskia.util.runtime import clear_config_cache
|
_RP_ID_RE = re.compile(
|
||||||
from paskia.util.runtime import config as runtime_config
|
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():
|
def validate_rp_id(rp_id: str) -> None:
|
||||||
return runtime_config()
|
"""Validate that rp_id is a valid domain name (or localhost)."""
|
||||||
|
if not rp_id:
|
||||||
|
raise ValueError("rp_id cannot be empty")
|
||||||
def is_root_mode() -> bool:
|
if rp_id == "localhost":
|
||||||
cfg = _cfg()
|
return
|
||||||
return cfg is not None and cfg.config.auth_host is not None
|
if not _RP_ID_RE.match(rp_id):
|
||||||
|
raise ValueError(f"rp_id '{rp_id}' is not a valid domain name")
|
||||||
|
|
||||||
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 normalize_origin(origin: str) -> str:
|
def normalize_origin(origin: str) -> str:
|
||||||
@@ -60,6 +25,11 @@ def normalize_origin(origin: str) -> str:
|
|||||||
return origin.rstrip("/")
|
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:
|
def is_subdomain(sub: str, domain: str) -> bool:
|
||||||
"""Check if sub is a subdomain of domain (or equal)."""
|
"""Check if sub is a subdomain of domain (or equal)."""
|
||||||
sub_parts = sub.lower().split(".")
|
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(
|
def normalize_auth_host_and_origins(
|
||||||
auth_host: str | None, origins: list[str] | None
|
auth_host: str | None, origins: list[str] | None
|
||||||
) -> tuple[str | None, 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
|
- 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)
|
- 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
|
return auth_host, origins
|
||||||
|
|
||||||
|
|
||||||
def reload_config() -> None:
|
|
||||||
clear_config_cache()
|
|
||||||
|
|
||||||
|
|
||||||
def normalize_host(raw_host: str | None) -> str | None:
|
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:
|
if not raw_host:
|
||||||
return None
|
return None
|
||||||
candidate = raw_host.strip()
|
candidate = raw_host.strip()
|
||||||
@@ -127,7 +99,7 @@ def normalize_host(raw_host: str | None) -> str | None:
|
|||||||
else:
|
else:
|
||||||
# Strip port from host:port
|
# Strip port from host:port
|
||||||
netloc = netloc.rsplit(":", 1)[0]
|
netloc = netloc.rsplit(":", 1)[0]
|
||||||
return netloc.lower() or None
|
return netloc.lower().rstrip(".") or None
|
||||||
|
|
||||||
|
|
||||||
def format_endpoint(ep: dict) -> str:
|
def format_endpoint(ep: dict) -> str:
|
||||||
|
|||||||
+48
-38
@@ -1,5 +1,8 @@
|
|||||||
"""
|
"""
|
||||||
OIDC JWT utilities for signing ID tokens and serving JWKS.
|
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
|
import hashlib
|
||||||
@@ -18,46 +21,50 @@ from paskia.util.crypto import (
|
|||||||
secret_key,
|
secret_key,
|
||||||
)
|
)
|
||||||
|
|
||||||
# JWT signing key (loaded on first use)
|
# JWT signing keys (loaded on first use), keyed by realm rp-id
|
||||||
_private_key = None
|
_keys: dict[str, tuple[object, object, str]] = {}
|
||||||
_public_key = None
|
|
||||||
_kid: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
def _load_or_generate_key() -> None:
|
def _load_or_generate_key(rp_id: str) -> tuple[object, object, str]:
|
||||||
"""Load existing Ed25519 key or generate a new one."""
|
"""Load a realm's Ed25519 key or generate and store a new one."""
|
||||||
global _private_key, _public_key, _kid
|
|
||||||
|
|
||||||
data = db.data()
|
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
|
store = data._store
|
||||||
if store is None:
|
if store is None:
|
||||||
raise RuntimeError("Kanta store is not initialized")
|
raise RuntimeError("Kanta store is not initialized")
|
||||||
if data.oidc.key is not None:
|
if provider.key is not None:
|
||||||
_private_key = public_key_from_secret(data.oidc.key)
|
private_key = public_key_from_secret(provider.key)
|
||||||
else:
|
else:
|
||||||
raw_key = secret_key()
|
raw_key = secret_key()
|
||||||
with store.transaction("oidc_key"):
|
with store.transaction("oidc_key"):
|
||||||
data.oidc.key = raw_key
|
provider.key = raw_key
|
||||||
_private_key = public_key_from_secret(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
|
# Generate kid from public key fingerprint
|
||||||
pub_der = get_public_key_der(_private_key)
|
kid = generate_kid(get_public_key_der(private_key))
|
||||||
_kid = generate_kid(pub_der)
|
return private_key, public_key, kid
|
||||||
|
|
||||||
|
|
||||||
def _ensure_key() -> None:
|
def _ensure_key(rp_id: str) -> tuple[object, object, str]:
|
||||||
"""Ensure key is loaded."""
|
"""Ensure a realm's key is loaded and return (private, public, kid)."""
|
||||||
if _private_key is None:
|
if rp_id not in _keys:
|
||||||
_load_or_generate_key()
|
_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."""
|
"""Get JWKS (JSON Web Key Set) for public key verification."""
|
||||||
_ensure_key()
|
private_key, _, kid = _ensure_key(rp_id)
|
||||||
assert _public_key is not None
|
|
||||||
# Ed25519 public key is 32 bytes raw
|
# Ed25519 public key is 32 bytes raw
|
||||||
pub_bytes = get_public_key_raw(_private_key)
|
pub_bytes = get_public_key_raw(private_key)
|
||||||
return {
|
return {
|
||||||
"keys": [
|
"keys": [
|
||||||
{
|
{
|
||||||
@@ -65,7 +72,7 @@ def get_jwks() -> dict:
|
|||||||
"crv": "Ed25519",
|
"crv": "Ed25519",
|
||||||
"use": "sig",
|
"use": "sig",
|
||||||
"alg": "EdDSA",
|
"alg": "EdDSA",
|
||||||
"kid": _kid,
|
"kid": kid,
|
||||||
"x": urlsafe_b64encode(pub_bytes).rstrip(b"=").decode("ascii"),
|
"x": urlsafe_b64encode(pub_bytes).rstrip(b"=").decode("ascii"),
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -73,6 +80,7 @@ def get_jwks() -> dict:
|
|||||||
|
|
||||||
|
|
||||||
def create_id_token(
|
def create_id_token(
|
||||||
|
rp_id: str,
|
||||||
issuer: str,
|
issuer: str,
|
||||||
subject: UUID,
|
subject: UUID,
|
||||||
audience: str, # client_id
|
audience: str, # client_id
|
||||||
@@ -89,6 +97,7 @@ def create_id_token(
|
|||||||
"""Create a signed ID token (JWT).
|
"""Create a signed ID token (JWT).
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
|
rp_id: Realm whose signing key to use
|
||||||
issuer: Token issuer (site URL)
|
issuer: Token issuer (site URL)
|
||||||
subject: User UUID (sub claim)
|
subject: User UUID (sub claim)
|
||||||
audience: Client ID (aud claim)
|
audience: Client ID (aud claim)
|
||||||
@@ -105,8 +114,7 @@ def create_id_token(
|
|||||||
Returns:
|
Returns:
|
||||||
Signed JWT string
|
Signed JWT string
|
||||||
"""
|
"""
|
||||||
_ensure_key()
|
private_key, _, kid = _ensure_key(rp_id)
|
||||||
assert _private_key is not None
|
|
||||||
now = datetime.now(UTC)
|
now = datetime.now(UTC)
|
||||||
payload: dict[str, object] = {
|
payload: dict[str, object] = {
|
||||||
"iss": issuer,
|
"iss": issuer,
|
||||||
@@ -132,10 +140,11 @@ def create_id_token(
|
|||||||
if auth_time:
|
if auth_time:
|
||||||
payload["auth_time"] = int(auth_time.timestamp())
|
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(
|
def create_access_token(
|
||||||
|
rp_id: str,
|
||||||
issuer: str,
|
issuer: str,
|
||||||
subject: UUID,
|
subject: UUID,
|
||||||
audience: str,
|
audience: str,
|
||||||
@@ -145,6 +154,7 @@ def create_access_token(
|
|||||||
"""Create a signed access token (JWT) for userinfo endpoint.
|
"""Create a signed access token (JWT) for userinfo endpoint.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
|
rp_id: Realm whose signing key to use
|
||||||
issuer: Token issuer (site URL)
|
issuer: Token issuer (site URL)
|
||||||
subject: User UUID
|
subject: User UUID
|
||||||
audience: Client ID
|
audience: Client ID
|
||||||
@@ -154,8 +164,7 @@ def create_access_token(
|
|||||||
Returns:
|
Returns:
|
||||||
Signed JWT string
|
Signed JWT string
|
||||||
"""
|
"""
|
||||||
_ensure_key()
|
private_key, _, kid = _ensure_key(rp_id)
|
||||||
assert _private_key is not None
|
|
||||||
now = datetime.now(UTC)
|
now = datetime.now(UTC)
|
||||||
payload: dict[str, object] = {
|
payload: dict[str, object] = {
|
||||||
"iss": issuer,
|
"iss": issuer,
|
||||||
@@ -165,15 +174,16 @@ def create_access_token(
|
|||||||
"iat": int(now.timestamp()),
|
"iat": int(now.timestamp()),
|
||||||
"exp": int((now + timedelta(seconds=expires_in)).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(
|
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:
|
) -> dict | None:
|
||||||
"""Decode and verify an access token.
|
"""Decode and verify an access token.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
|
rp_id: Realm whose key to verify with
|
||||||
token: JWT string
|
token: JWT string
|
||||||
issuer: Expected issuer
|
issuer: Expected issuer
|
||||||
audience: Optional expected audience (client_id). If provided, aud claim must match.
|
audience: Optional expected audience (client_id). If provided, aud claim must match.
|
||||||
@@ -181,13 +191,12 @@ def decode_access_token(
|
|||||||
Returns:
|
Returns:
|
||||||
Decoded payload or None if invalid
|
Decoded payload or None if invalid
|
||||||
"""
|
"""
|
||||||
_ensure_key()
|
_, public_key, _ = _ensure_key(rp_id)
|
||||||
assert _public_key is not None
|
|
||||||
try:
|
try:
|
||||||
if audience is not None:
|
if audience is not None:
|
||||||
return jwt.decode(
|
return jwt.decode(
|
||||||
token,
|
token,
|
||||||
_public_key,
|
public_key,
|
||||||
algorithms=["EdDSA"],
|
algorithms=["EdDSA"],
|
||||||
issuer=issuer,
|
issuer=issuer,
|
||||||
audience=audience,
|
audience=audience,
|
||||||
@@ -195,7 +204,7 @@ def decode_access_token(
|
|||||||
|
|
||||||
return jwt.decode(
|
return jwt.decode(
|
||||||
token,
|
token,
|
||||||
_public_key,
|
public_key,
|
||||||
algorithms=["EdDSA"],
|
algorithms=["EdDSA"],
|
||||||
issuer=issuer,
|
issuer=issuer,
|
||||||
options={"verify_aud": False},
|
options={"verify_aud": False},
|
||||||
@@ -205,6 +214,7 @@ def decode_access_token(
|
|||||||
|
|
||||||
|
|
||||||
def create_logout_token(
|
def create_logout_token(
|
||||||
|
rp_id: str,
|
||||||
issuer: str,
|
issuer: str,
|
||||||
audience: str,
|
audience: str,
|
||||||
sid: str | None = None,
|
sid: str | None = None,
|
||||||
@@ -216,6 +226,7 @@ def create_logout_token(
|
|||||||
either sid (session) or sub (user), or both.
|
either sid (session) or sub (user), or both.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
|
rp_id: Realm whose signing key to use
|
||||||
issuer: Token issuer (site URL)
|
issuer: Token issuer (site URL)
|
||||||
audience: Client ID (aud claim)
|
audience: Client ID (aud claim)
|
||||||
sid: Session ID (base64url-encoded)
|
sid: Session ID (base64url-encoded)
|
||||||
@@ -224,8 +235,7 @@ def create_logout_token(
|
|||||||
Returns:
|
Returns:
|
||||||
Signed JWT string
|
Signed JWT string
|
||||||
"""
|
"""
|
||||||
_ensure_key()
|
private_key, _, kid = _ensure_key(rp_id)
|
||||||
assert _private_key is not None
|
|
||||||
now = datetime.now(UTC)
|
now = datetime.now(UTC)
|
||||||
payload: dict[str, object] = {
|
payload: dict[str, object] = {
|
||||||
"iss": issuer,
|
"iss": issuer,
|
||||||
@@ -241,4 +251,4 @@ def create_logout_token(
|
|||||||
payload["sid"] = sid
|
payload["sid"] = sid
|
||||||
if sub:
|
if sub:
|
||||||
payload["sub"] = str(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
|
import os
|
||||||
from functools import lru_cache
|
from functools import lru_cache
|
||||||
|
|
||||||
import msgspec
|
import msgspec
|
||||||
|
|
||||||
from paskia.db.structs import Config
|
|
||||||
|
|
||||||
|
class ServeConfig(msgspec.Struct):
|
||||||
|
"""Process-global serve parameters."""
|
||||||
|
|
||||||
class RuntimeConfig(msgspec.Struct):
|
listen: list[str] | None = None
|
||||||
"""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
|
|
||||||
|
|
||||||
|
|
||||||
@lru_cache(maxsize=1)
|
@lru_cache(maxsize=1)
|
||||||
def _load_config() -> RuntimeConfig | None:
|
def _load() -> ServeConfig | None:
|
||||||
"""Load RuntimeConfig from PASKIA_CONFIG env var."""
|
raw = os.getenv("PASKIA_CONFIG")
|
||||||
config_json = os.getenv("PASKIA_CONFIG")
|
if not raw:
|
||||||
if not config_json:
|
|
||||||
return None
|
return None
|
||||||
|
return msgspec.json.decode(raw.encode(), type=ServeConfig)
|
||||||
return msgspec.json.decode(config_json.encode(), type=RuntimeConfig)
|
|
||||||
|
|
||||||
|
|
||||||
def config() -> RuntimeConfig | None:
|
def serve_config() -> ServeConfig | None:
|
||||||
"""Return cached runtime config loaded from PASKIA_CONFIG."""
|
"""Return cached serve configuration loaded from PASKIA_CONFIG."""
|
||||||
return _load_config()
|
return _load()
|
||||||
|
|
||||||
|
|
||||||
def clear_config_cache() -> None:
|
def clear_cache() -> None:
|
||||||
"""Clear cached runtime config; next config() call reloads from env."""
|
"""Clear cached serve configuration; next serve_config() reloads."""
|
||||||
_load_config.cache_clear()
|
_load.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()
|
|
||||||
|
|||||||
+26
-24
@@ -14,7 +14,7 @@ from paskia.util.constants import DEFAULT_PORT, DEVMODE
|
|||||||
from paskia.util.hostutil import format_endpoint
|
from paskia.util.hostutil import format_endpoint
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from paskia.util.runtime import RuntimeConfig
|
from paskia.realms import RealmRegistry
|
||||||
|
|
||||||
BOX_WIDTH = 60 # Inner width (excluding box chars)
|
BOX_WIDTH = 60 # Inner width (excluding box chars)
|
||||||
|
|
||||||
@@ -48,14 +48,18 @@ def bottom() -> str:
|
|||||||
return "┗" + "━" * (BOX_WIDTH + 2) + "┛\n"
|
return "┗" + "━" * (BOX_WIDTH + 2) + "┛\n"
|
||||||
|
|
||||||
|
|
||||||
def print_startup_config(runtime: RuntimeConfig) -> None:
|
def print_startup_config(
|
||||||
"""Print server configuration on startup."""
|
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)
|
# Key graphic with yellow shading (bright for highlights, dark for body)
|
||||||
y = YELLOW # Bright golden yellow for main body
|
y = YELLOW # Bright golden yellow for main body
|
||||||
b = BRIGHT_YELLOW # Brightest yellow for highlights/edges
|
b = BRIGHT_YELLOW # Brightest yellow for highlights/edges
|
||||||
w = BRIGHT_WHITE # Bold white for URL
|
w = BRIGHT_WHITE # Bold white for URL
|
||||||
r = RESET
|
r = RESET
|
||||||
|
|
||||||
|
default = registry.default
|
||||||
|
|
||||||
lines = [top()]
|
lines = [top()]
|
||||||
lines.append(line(f" {b}▄▄▄▄▄{r}"))
|
lines.append(line(f" {b}▄▄▄▄▄{r}"))
|
||||||
lines.append(line(f"{b}█{y} {b}█{r} Paskia " + __version__))
|
lines.append(line(f"{b}█{y} {b}█{r} Paskia " + __version__))
|
||||||
@@ -63,43 +67,41 @@ def print_startup_config(runtime: RuntimeConfig) -> None:
|
|||||||
lines.append(
|
lines.append(
|
||||||
line(
|
line(
|
||||||
f"{b}█{y} {b}█{y}▀▀▀▀{b}█{y}▀▀{b}█{y}▀▀{b}█{r} {w}"
|
f"{b}█{y} {b}█{y}▀▀▀▀{b}█{y}▀▀{b}█{y}▀▀{b}█{r} {w}"
|
||||||
+ runtime.site_url
|
+ default.site_url
|
||||||
+ runtime.site_path
|
+ default.site_path
|
||||||
+ r
|
+ r
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
lines.append(line(f" {y}▀▀▀▀▀{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
|
# Show frontend URL if in dev mode
|
||||||
if DEVMODE:
|
if DEVMODE:
|
||||||
lines.append(line(f"Dev Frontend: {os.environ.get('PASKIA_VITE_URL')}"))
|
lines.append(line(f"Dev Frontend: {os.environ.get('PASKIA_VITE_URL')}"))
|
||||||
|
|
||||||
# Format listen endpoints (dev mode only uses the first endpoint)
|
# 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:
|
if DEVMODE:
|
||||||
endpoints = endpoints[:1] # server.run reload=True uses only one
|
endpoints = endpoints[:1] # server.run reload=True uses only one
|
||||||
parts = [format_endpoint(ep) for ep in endpoints]
|
parts = [format_endpoint(ep) for ep in endpoints]
|
||||||
lines.append(line(f"Backend: {' '.join(parts)}"))
|
lines.append(line(f"Backend: {' '.join(parts)}"))
|
||||||
|
|
||||||
# Relying Party line (omit name if same as id)
|
realms = registry.realms
|
||||||
rp_id = runtime.config.rp_id
|
for realm in realms:
|
||||||
rp_name = runtime.config.rp_name
|
# Realm line (omit name if same as id); mark the default realm
|
||||||
suffix = f" ({rp_name})" if rp_name and rp_name != rp_id else ""
|
rp_name = realm.rp_name
|
||||||
lines.append(line(f"Relying Party: {rp_id}{suffix}"))
|
suffix = f" ({rp_name})" if rp_name and rp_name != realm.rp_id else ""
|
||||||
|
header = "Realm: " if len(realms) > 1 else "Relying Party: "
|
||||||
# Format origins section
|
lines.append(line(f"{header}{realm.rp_id}{suffix}"))
|
||||||
allowed = runtime.config.origins
|
if len(realms) > 1:
|
||||||
if allowed:
|
lines.append(line(f" URL: {realm.site_url}{realm.site_path}"))
|
||||||
lines.append(line("Permitted Origins:"))
|
if realm.config.auth_host:
|
||||||
for origin in sorted(allowed):
|
lines.append(line(f" Auth Host: {realm.config.auth_host}"))
|
||||||
lines.append(line(f" - {origin}"))
|
if realm.config.origins:
|
||||||
else:
|
for origin in sorted(realm.config.origins):
|
||||||
lines.append(line(f"Origin: {rp_id} and all subdomains allowed"))
|
lines.append(line(f" Origin: {origin}"))
|
||||||
|
else:
|
||||||
|
lines.append(line(f" Origin: {realm.rp_id} and subdomains"))
|
||||||
|
|
||||||
lines.append(bottom())
|
lines.append(bottom())
|
||||||
stderr.write("".join(lines))
|
stderr.write("".join(lines))
|
||||||
|
|||||||
Reference in New Issue
Block a user