More robust server startup, startup logo and info screen, renewed devmode script.

This commit is contained in:
2025-12-05 19:06:42 +00:00
parent c1204ca020
commit 127e06179b
10 changed files with 287 additions and 99 deletions
+44 -15
View File
@@ -111,7 +111,13 @@ def add_common_options(p: argparse.ArgumentParser) -> None:
"--rp-id", default="localhost", help="Relying Party ID (default: localhost)"
)
p.add_argument("--rp-name", help="Relying Party name (default: same as rp-id)")
p.add_argument("--origin", help="Origin URL (default: https://<rp-id>)")
p.add_argument(
"--origin",
action="append",
dest="origins",
metavar="URL",
help="Allowed origin URL(s). May be specified multiple times. If any are specified, only those origins are permitted for WebSocket authentication.",
)
p.add_argument(
"--auth-host",
help=(
@@ -166,39 +172,62 @@ def main():
else:
host = port = uds = all_ifaces = None # type: ignore
# Export configuration via environment for lifespan initialization in each process
os.environ.setdefault("PASKIA_RP_ID", args.rp_id)
if args.rp_name:
os.environ["PASKIA_RP_NAME"] = args.rp_name
if args.origin:
os.environ["PASKIA_ORIGIN"] = args.origin
if getattr(args, "auth_host", None):
os.environ["PASKIA_AUTH_HOST"] = args.auth_host
else:
# Collect origins and handle auth_host
origins = getattr(args, "origins", None) or []
if not getattr(args, "auth_host", None):
# Preserve pre-set env variable if CLI option omitted
args.auth_host = os.environ.get("PASKIA_AUTH_HOST")
if args.auth_host:
# Normalize auth_host with scheme
if "://" not in args.auth_host:
args.auth_host = f"https://{args.auth_host}"
validate_auth_host(args.auth_host, args.rp_id)
from paskia.util import hostutil as _hostutil # local import
_hostutil.reload_config()
# If origins are configured, ensure auth_host is included at top
if origins:
# Insert auth_host at the beginning (Passkey.__init__ will normalize/dedupe)
origins.insert(0, args.auth_host)
# Export configuration via single JSON env variable for worker processes
# (PASKIA_DEVMODE is kept separate as it's externally defined)
# All keys are always present; None is used where no value is configured
import json
config = {
"rp_id": args.rp_id,
"rp_name": args.rp_name or None,
"origins": origins or None,
"auth_host": args.auth_host or None,
"default_admin": os.getenv("PASKIA_DEFAULT_ADMIN") or None,
"default_org": os.getenv("PASKIA_DEFAULT_ORG") or None,
}
os.environ["PASKIA_CONFIG"] = json.dumps(config)
# One-time initialization + bootstrap before starting any server processes.
# Lifespan in worker processes will call globals.init with bootstrap disabled.
from paskia import globals as _globals # local import
asyncio.run(
_globals.init(
rp_id=args.rp_id,
rp_name=args.rp_name,
origin=args.origin,
default_admin=os.getenv("PASKIA_DEFAULT_ADMIN") or None,
default_org=os.getenv("PASKIA_DEFAULT_ORG") or None,
rp_id=config["rp_id"],
rp_name=config["rp_name"],
origins=config["origins"],
default_admin=config["default_admin"],
default_org=config["default_org"],
bootstrap=True,
)
)
# Print startup configuration
from paskia.util import startupbox
startupbox.print_startup_config(_globals.passkey.instance, args, host, port, uds)
# Handle recover-admin command (no server start)
if args.command == "reset":
from paskia.fastapi import reset as reset_cmd # local import
+11 -13
View File
@@ -15,26 +15,24 @@ from paskia.util import frontend, hostutil, passphrase
async def lifespan(app: FastAPI): # pragma: no cover - startup path
"""Application lifespan to ensure globals (DB, passkey) are initialized in each process.
We populate configuration from environment variables (set by the CLI entrypoint)
Configuration is passed via PASKIA_CONFIG JSON env variable (set by the CLI entrypoint)
so that uvicorn reload / multiprocess workers inherit the settings.
All keys are guaranteed to exist; values are already normalized by __main__.py.
"""
import json
from paskia import globals
rp_id = os.getenv("PASKIA_RP_ID", "localhost")
rp_name = os.getenv("PASKIA_RP_NAME") or None
origin = os.getenv("PASKIA_ORIGIN") or None
default_admin = (
os.getenv("PASKIA_DEFAULT_ADMIN") or None
) # still passed for context
default_org = os.getenv("PASKIA_DEFAULT_ORG") or None
config = json.loads(os.environ["PASKIA_CONFIG"])
try:
# CLI (__main__) performs bootstrap once; here we skip to avoid duplicate work
await globals.init(
rp_id=rp_id,
rp_name=rp_name,
origin=origin,
default_admin=default_admin,
default_org=default_org,
rp_id=config["rp_id"],
rp_name=config["rp_name"],
origins=config["origins"],
default_admin=config["default_admin"],
default_org=config["default_org"],
bootstrap=False,
)
except ValueError as e:
+17 -6
View File
@@ -42,19 +42,30 @@ def websocket_error_handler(func):
app = FastAPI()
def _validate_origin(ws: WebSocket) -> str:
"""Extract and validate origin from WebSocket request headers.
Raises:
ValueError: If origin header is missing or not in allowed list
"""
origin = ws.headers.get("origin")
if not origin:
raise ValueError("Origin header is required for WebSocket connections")
return passkey.instance.validate_origin(origin)
async def register_chat(
ws: WebSocket,
user_uuid: UUID,
user_name: str,
origin: str,
credential_ids: list[bytes] | None = None,
origin: str | None = None,
):
"""Generate registration options and send them to the client."""
options, challenge = passkey.instance.reg_generate_options(
user_id=user_uuid,
user_name=user_name,
credential_ids=credential_ids,
origin=origin,
)
await ws.send_json({"optionsJSON": options})
response = await ws.receive_json()
@@ -75,7 +86,7 @@ async def websocket_register_add(
- Normal session via auth cookie (requires recent authentication)
- Reset token supplied as ?reset=... (auth cookie ignored)
"""
origin = ws.headers["origin"]
origin = _validate_origin(ws)
host = origin.split("://", 1)[1]
if reset is not None:
if not passphrase.is_well_formed(reset):
@@ -100,7 +111,7 @@ async def websocket_register_add(
challenge_ids = await db.instance.get_credentials_by_user_uuid(user_uuid)
# WebAuthn registration
credential = await register_chat(ws, user_uuid, user_name, challenge_ids, origin)
credential = await register_chat(ws, user_uuid, user_name, origin, challenge_ids)
# Create a new session and store everything in database
token = create_token()
@@ -131,7 +142,7 @@ async def websocket_register_add(
@app.websocket("/authenticate")
@websocket_error_handler
async def websocket_authenticate(ws: WebSocket, auth=AUTH_COOKIE):
origin = ws.headers["origin"]
origin = _validate_origin(ws)
host = origin.split("://", 1)[1]
# If there's an existing session, restrict to that user's credentials (reauth)
@@ -166,7 +177,7 @@ async def websocket_authenticate(ws: WebSocket, auth=AUTH_COOKIE):
raise ValueError("This passkey belongs to a different account")
# Verify the credential matches the stored data
passkey.instance.auth_verify(credential, challenge, stored_cred, origin=origin)
passkey.instance.auth_verify(credential, challenge, stored_cred, origin)
# Update both credential and user's last_seen timestamp
await db.instance.login(stored_cred.user_uuid, stored_cred)