diff --git a/caddy/Caddyfile.dev b/caddy/Caddyfile.dev index 2c87a6f..ca940ca 100644 --- a/caddy/Caddyfile.dev +++ b/caddy/Caddyfile.dev @@ -1,9 +1,12 @@ localhost { - # Forwards API by caddy, bypassing the Vite dev proxy + # WebSockets bypass directly to backend (workaround for bun proxy bug) # Avoids bug https://github.com/oven-sh/bun/issues/9882 - handle /api/* { + handle /auth/ws/* { reverse_proxy :4402 # directly to backend } + # Everything else goes to or via Vite + # (Vite proxies /auth/api, /.well-known/openid-configuration and + # /.well-known/webauthn to the backend) handle { reverse_proxy :4403 # vite dev server } diff --git a/caddy/auth/setup b/caddy/auth/setup index 7e15c6c..f5bb22a 100644 --- a/caddy/auth/setup +++ b/caddy/auth/setup @@ -4,3 +4,10 @@ header -Remote-* handle @auth_api { reverse_proxy {$AUTH_UPSTREAM::4401} } +# Paskia-served well-known endpoints: OIDC discovery and WebAuthn Related +# Origin Requests (must reach paskia even when other /.well-known/* files +# are served statically) +@auth_wellknown path /.well-known/openid-configuration /.well-known/webauthn +handle @auth_wellknown { + reverse_proxy {$AUTH_UPSTREAM::4401} +} diff --git a/frontend/vite.config.js b/frontend/vite.config.js index 03ef995..57edd36 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -6,8 +6,12 @@ import { existsSync, renameSync, mkdirSync } from 'node:fs' import sirv from 'sirv' import fastapiVue from './vite-plugin-fastapi.js' -// Auth host mode: when set, clients accessing the auth host get /auth/ at / and /auth/admin/ at /admin/ -const authHost = process.env.PASKIA_AUTH_HOST +// Auth host mode: when set, clients accessing an auth host get /auth/ at / and /auth/admin/ at /admin/ +// Comma-separated list of bare hostnames (one per realm with a dedicated auth host) +const authHosts = (process.env.PASKIA_AUTH_HOST || '') + .split(',') + .map(h => h.trim().replace(/^https?:\/\//, '').split(':')[0].split('/')[0]) + .filter(Boolean) export default defineConfig(({ command }) => ({ appType: 'mpa', @@ -17,6 +21,7 @@ export default defineConfig(({ command }) => ({ "/auth/api", "/auth/ws", "/.well-known/openid-configuration", + "/.well-known/webauthn", // Passphrase links: /auth/word1.word2.word3.word4.word5 "^/auth/[a-z]+\\.[a-z]+\\.[a-z]+\\.[a-z]+\\.[a-z]+$", // Passphrase links: /word1.word2.word3.word4.word5 @@ -25,13 +30,13 @@ export default defineConfig(({ command }) => ({ vue(), // Auth host routing: rewrite paths when accessing dedicated auth host // Must run before serve-examples to handle / correctly - authHost && { + authHosts.length && { name: 'auth-host-routing', configureServer(server) { server.middlewares.use((req, _res, next) => { const host = req.headers.host?.split(':')[0] // Check if request is coming to the auth host - if (host === authHost) { + if (authHosts.includes(host)) { // Only rewrite specific paths that should map to /auth/* // Rewrite / and /index.html to /auth/ if (req.url === '/' || req.url === '/index.html') { @@ -67,7 +72,7 @@ export default defineConfig(({ command }) => ({ server.middlewares.use((req, _res, next) => { // Skip redirect to examples on auth host (handled by auth-host-routing) const host = req.headers.host?.split(':')[0] - if (authHost && host === authHost) { + if (authHosts.includes(host)) { next() return } diff --git a/scripts/devserver.py b/scripts/devserver.py index c30cf40..f9f1cb8 100755 --- a/scripts/devserver.py +++ b/scripts/devserver.py @@ -6,6 +6,7 @@ import asyncio import json import os import shutil +import subprocess import sys from contextlib import suppress from pathlib import Path @@ -13,6 +14,9 @@ from urllib.parse import urlparse import tracerite +from paskia.db.legacy import find_legacy_databases +from paskia.db.paths import db_file_path + # Import utilities from scripts/fastapi-vue (not a package, so we adjust sys.path) sys.path.insert(0, str(Path(__file__).with_name("fastapi-vue"))) from devutil import ( # noqa: E402 @@ -135,6 +139,40 @@ async def run_caddy( return proc +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 ensure_database(rp_ids: list[str], args: argparse.Namespace, listen: str) -> None: + """Bootstrap paskia.kantadb via 'paskia init' when no database exists. + + Realm options are init-only; 'paskia' (serve) reads all configuration + from the database. A legacy *.paskiadb database is adopted by serve, + so no init is run in that case either. + """ + if db_file_path().exists() or find_legacy_databases(): + return + + cmd = [sys.executable, "-m", "paskia", "init", f"--listen={listen}"] + for rp_id in rp_ids: + cmd.extend(["--rp-id", rp_id]) + if args.rp_name: + cmd.extend(["--rp-name", args.rp_name]) + if args.auth_host: + cmd.extend(["--auth-host", args.auth_host]) + for origin in _split_multi(args.origins): + cmd.extend(["--origin", origin]) + + logger.info(">>> paskia init (first run)") + proc = subprocess.run(cmd, check=False) # noqa: S603 + if proc.returncode != 0: + raise SystemExit(proc.returncode) + + async def run_devserver(args: argparse.Namespace, remaining: list[str]) -> None: """Run the development server with all components.""" reporoot = Path(__file__).parent.parent @@ -146,13 +184,10 @@ async def run_devserver(args: argparse.Namespace, remaining: list[str]) -> None: viteurl, npm_install, vite = setup_vite(args.listen, DEFAULT_VITE_PORT) backurl, paskia = setup_cli("paskia", args.backend, DEFAULT_DEV_PORT) - # Build paskia command with options - paskia.extend(["--rp-id", args.rp_id]) - if args.auth_host: - paskia.extend(["--auth-host", args.auth_host]) - if args.origins: - for origin in args.origins: - paskia.extend(["--origin", origin]) + rp_ids = _split_multi(args.rp_id) or ["localhost"] + ensure_database(rp_ids, args, listen=backurl.removeprefix("http://")) + + # Serve: no realm options — all configuration lives in the database paskia.extend(remaining) # Set environment for subprocesses @@ -171,14 +206,12 @@ async def run_devserver(args: argparse.Namespace, remaining: list[str]) -> None: if "://" not in auth_host: auth_host = f"https://{auth_host}" caddy_origins.append(auth_host) - caddy_origins.append(f"https://{args.rp_id}") - if args.origins: - for origin in args.origins: - if "://" not in origin: - origin = f"https://{origin}" - caddy_origins.append(origin) - if not caddy_origins: - caddy_origins.append(f"https://{args.rp_id}") + for rp_id in rp_ids: + caddy_origins.append(f"https://{rp_id}") + for origin in _split_multi(args.origins): + if "://" not in origin: + origin = f"https://{origin}" + caddy_origins.append(origin) seen: set = set() caddy_origins = [x for x in caddy_origins if not (x in seen or seen.add(x))] caddy_proc = await run_caddy(caddy_origins, viteurl, backurl) @@ -209,11 +242,22 @@ def main(): help=f"FastAPI (default: localhost:{DEFAULT_DEV_PORT})", ) parser.add_argument("--caddy", action="store_true", help="Run Caddy as HTTPS proxy") - parser.add_argument("--rp-id", default="localhost", help="Relying Party ID") parser.add_argument( - "--origin", action="append", dest="origins", help="Allowed origin(s)" + "--rp-id", + action="append", + help="Relying Party ID(s) for first-run bootstrap (default: localhost). " + "Repeatable and comma-separated; the first is the default realm.", ) - parser.add_argument("--auth-host", help="Dedicated auth host") + parser.add_argument( + "--rp-name", help="Relying Party name of the default realm (bootstrap only)" + ) + parser.add_argument( + "--origin", + action="append", + dest="origins", + help="Allowed origin(s), bootstrap only", + ) + parser.add_argument("--auth-host", help="Dedicated auth host for the default realm") args, remaining = parser.parse_known_args() with suppress(KeyboardInterrupt):