import argparse import asyncio import json import logging import os from urllib.parse import urlparse from fastapi_vue.hostutil import parse_endpoint from uvicorn import Config as UvicornConfig from uvicorn import Server from uvicorn import run as uvicorn_run from paskia import globals as _globals from paskia.bootstrap import bootstrap_if_needed from paskia.config import PaskiaConfig from paskia.db import get_config, set_config from paskia.db import init as db_init from paskia.db.background import flush from paskia.db.structs import Config from paskia.util import startupbox from paskia.util.hostutil import normalize_origin DEFAULT_PORT = 4401 DEVMODE = bool(os.getenv("PASKIA_FRONTEND_URL")) EPILOG = """\ Example: paskia --rp-id example.com --rp-name "Example Corporation" --auth-host auth.example.com """ def is_subdomain(sub: str, domain: str) -> bool: """Check if sub is a subdomain of domain (or equal).""" sub_parts = sub.lower().split(".") domain_parts = domain.lower().split(".") if len(sub_parts) < len(domain_parts): return False return sub_parts[-len(domain_parts) :] == domain_parts def validate_auth_host(auth_host: str, rp_id: str) -> None: """Validate that auth_host is a subdomain of rp_id.""" parsed = urlparse(auth_host if "://" in auth_host else f"//{auth_host}") host = parsed.hostname or parsed.path if not host: raise SystemExit(f"Invalid auth-host: '{auth_host}'") if not is_subdomain(host, rp_id): raise SystemExit( f"auth-host '{auth_host}' is not a subdomain of rp-id '{rp_id}'" ) def add_common_options(p: argparse.ArgumentParser) -> None: p.add_argument( "--rp-id", default="localhost", help="Relying Party ID (default: localhost)" ) p.add_argument("--rp-name", help="Relying Party name (default: same as rp-id)") p.add_argument( "--origin", action="append", dest="origins", metavar="URL", help="Allowed origin URL(s). May be specified multiple times. If any are specified, only those origins are permitted for WebSocket authentication.", ) p.add_argument( "--auth-host", help=("Dedicated authentication site (optionally with scheme/port)"), ) p.add_argument( "--save", action="store_true", help="Save the CLI options to database for future runs.", ) def main(): # Configure logging to remove the "ERROR:root:" prefix logging.basicConfig(level=logging.INFO, format="%(message)s", force=True) parser = argparse.ArgumentParser( prog="paskia", description="Paskia authentication server", formatter_class=argparse.RawDescriptionHelpFormatter, epilog=EPILOG, ) parser.add_argument( "-l", "--listen", metavar="LISTEN", help=( "Endpoint to listen on (default: localhost:4401). " "Forms: host:port port :port [ipv6]:port unix:path /path.sock" ), ) add_common_options(parser) args = parser.parse_args() # Handle clearing options if getattr(args, "auth_host", None) == "": args.auth_host = None if getattr(args, "rp_name", None) == "": args.rp_name = None if getattr(args, "listen", None) == "": args.listen = None # Init db and load stored config asyncio.run(db_init(rp_id=args.rp_id)) stored_config = get_config() # Apply defaults from stored config if args.rp_name is None and stored_config.rp_name is not None: args.rp_name = stored_config.rp_name if args.origins is None and stored_config.origins is not None: args.origins = stored_config.origins if args.auth_host is None and stored_config.auth_host is not None: args.auth_host = stored_config.auth_host if args.listen is None and stored_config.listen is not None: args.listen = stored_config.listen # Parse endpoint using fastapi_vue.hostutil endpoints = parse_endpoint(args.listen, DEFAULT_PORT) # Extract host/port/uds from first endpoint for config display and site_url ep = endpoints[0] if endpoints else {} host = ep.get("host") port = ep.get("port") uds = ep.get("uds") # Collect and normalize origins, handle auth_host origins = [normalize_origin(o) for o in (getattr(args, "origins", None) or [])] 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) # If origins are configured, ensure auth_host is included at top if origins: # Insert auth_host at the beginning origins.insert(0, args.auth_host) # Remove duplicates while preserving order seen = set() origins = [x for x in origins if not (x in seen or seen.add(x))] # Compute site_url and site_path for reset links # Priority: PASKIA_SITE_URL (explicit) > auth_host > first origin with localhost > http://localhost:port explicit_site_url = os.environ.get("PASKIA_SITE_URL") if explicit_site_url: # Explicit site URL from devserver or deployment config site_url = explicit_site_url.rstrip("/") site_path = "/" if args.auth_host else "/auth/" elif args.auth_host: site_url = args.auth_host.rstrip("/") site_path = "/" elif origins: # Find localhost origin if rp_id is localhost, else use first origin localhost_origin = ( next((o for o in origins if "://localhost" in o), None) if args.rp_id == "localhost" else None ) site_url = (localhost_origin or origins[0]).rstrip("/") site_path = "/auth/" elif args.rp_id == "localhost" and port: # Dev mode: use http with port site_url = f"http://localhost:{port}" site_path = "/auth/" else: site_url = f"https://{args.rp_id}" site_path = "/auth/" # Build runtime configuration config = PaskiaConfig( rp_id=args.rp_id, rp_name=args.rp_name or None, origins=origins or None, auth_host=args.auth_host or None, site_url=site_url, site_path=site_path, host=host, port=port, uds=uds, ) # Export configuration via single JSON env variable for worker processes config_json = { "rp_id": config.rp_id, "rp_name": config.rp_name, "origins": config.origins, "auth_host": config.auth_host, "site_url": config.site_url, "site_path": config.site_path, } os.environ["PASKIA_CONFIG"] = json.dumps(config_json) startupbox.print_startup_config(config) # Build config to save (for bootstrap or explicit --save) cli_config = Config( rp_id=args.rp_id, rp_name=args.rp_name, origins=args.origins, auth_host=args.auth_host, listen=args.listen, ) run_kwargs: dict = { "log_level": "warning", # Suppress startup messages; we use custom logging "access_log": False, # We use custom AccessLogMiddleware instead } if DEVMODE: # Security: dev mode must run on localhost:4402 to prevent # accidental public exposure of the Vite dev server if host != "localhost" or port != 4402: raise SystemExit(f"Dev mode requires localhost:4402, got {host}:{port}") run_kwargs["reload"] = True run_kwargs["reload_dirs"] = ["paskia"] async def async_main(): await _globals.init( rp_id=config.rp_id, rp_name=config.rp_name, origins=config.origins, bootstrap=False, ) # Pass config to bootstrap - it will be saved within the bootstrap transaction await bootstrap_if_needed(config=cli_config) # Also save config if --save was explicitly used (even without bootstrap) if args.save: await set_config(cli_config) await flush() if len(endpoints) > 1: async with asyncio.TaskGroup() as tg: for ep in endpoints: tg.create_task( Server( UvicornConfig(app="paskia.fastapi:app", **run_kwargs, **ep) ).serve() ) elif DEVMODE: # Use uvicorn.run for proper reload support (it handles subprocess spawning) ep = endpoints[0] uvicorn_run("paskia.fastapi:app", **run_kwargs, **ep) else: server = Server( UvicornConfig(app="paskia.fastapi:app", **run_kwargs, **endpoints[0]) ) await server.serve() try: asyncio.run(async_main()) except KeyboardInterrupt: pass if __name__ == "__main__": main()