Devserver and proxy configs for multi-realm

- devserver bootstraps via one-shot 'paskia init' when no database
  exists (multi --rp-id, --rp-name/--auth-host/--origin apply to the
  default realm), then runs plain 'paskia' serve which reads all realm
  configuration from the database; legacy *.paskiadb is adopted by
  serve without init.
- Caddy origins iterate all bootstrap rp-ids.
- vite.config.js accepts a comma-separated PASKIA_AUTH_HOST list and
  proxies /.well-known/webauthn to the backend so ROR works in dev.
- caddy/auth/setup forwards /.well-known/openid-configuration and
  /.well-known/webauthn to paskia (they must not be swallowed by a
  static /.well-known/* file handler); Caddyfile.dev updated to match
  the generated dev config.
This commit is contained in:
2026-09-06 14:49:27 +00:00
parent 8e7acd6b9e
commit e979dd6312
4 changed files with 84 additions and 25 deletions
+5 -2
View File
@@ -1,9 +1,12 @@
localhost { 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 # Avoids bug https://github.com/oven-sh/bun/issues/9882
handle /api/* { handle /auth/ws/* {
reverse_proxy :4402 # directly to backend 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 { handle {
reverse_proxy :4403 # vite dev server reverse_proxy :4403 # vite dev server
} }
+7
View File
@@ -4,3 +4,10 @@ header -Remote-*
handle @auth_api { handle @auth_api {
reverse_proxy {$AUTH_UPSTREAM::4401} 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}
}
+10 -5
View File
@@ -6,8 +6,12 @@ import { existsSync, renameSync, mkdirSync } from 'node:fs'
import sirv from 'sirv' import sirv from 'sirv'
import fastapiVue from './vite-plugin-fastapi.js' 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/ // Auth host mode: when set, clients accessing an auth host get /auth/ at / and /auth/admin/ at /admin/
const authHost = process.env.PASKIA_AUTH_HOST // 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 }) => ({ export default defineConfig(({ command }) => ({
appType: 'mpa', appType: 'mpa',
@@ -17,6 +21,7 @@ export default defineConfig(({ command }) => ({
"/auth/api", "/auth/api",
"/auth/ws", "/auth/ws",
"/.well-known/openid-configuration", "/.well-known/openid-configuration",
"/.well-known/webauthn",
// Passphrase links: /auth/word1.word2.word3.word4.word5 // Passphrase links: /auth/word1.word2.word3.word4.word5
"^/auth/[a-z]+\\.[a-z]+\\.[a-z]+\\.[a-z]+\\.[a-z]+$", "^/auth/[a-z]+\\.[a-z]+\\.[a-z]+\\.[a-z]+\\.[a-z]+$",
// Passphrase links: /word1.word2.word3.word4.word5 // Passphrase links: /word1.word2.word3.word4.word5
@@ -25,13 +30,13 @@ export default defineConfig(({ command }) => ({
vue(), vue(),
// Auth host routing: rewrite paths when accessing dedicated auth host // Auth host routing: rewrite paths when accessing dedicated auth host
// Must run before serve-examples to handle / correctly // Must run before serve-examples to handle / correctly
authHost && { authHosts.length && {
name: 'auth-host-routing', name: 'auth-host-routing',
configureServer(server) { configureServer(server) {
server.middlewares.use((req, _res, next) => { server.middlewares.use((req, _res, next) => {
const host = req.headers.host?.split(':')[0] const host = req.headers.host?.split(':')[0]
// Check if request is coming to the auth host // 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/* // Only rewrite specific paths that should map to /auth/*
// Rewrite / and /index.html to /auth/ // Rewrite / and /index.html to /auth/
if (req.url === '/' || req.url === '/index.html') { if (req.url === '/' || req.url === '/index.html') {
@@ -67,7 +72,7 @@ export default defineConfig(({ command }) => ({
server.middlewares.use((req, _res, next) => { server.middlewares.use((req, _res, next) => {
// Skip redirect to examples on auth host (handled by auth-host-routing) // Skip redirect to examples on auth host (handled by auth-host-routing)
const host = req.headers.host?.split(':')[0] const host = req.headers.host?.split(':')[0]
if (authHost && host === authHost) { if (authHosts.includes(host)) {
next() next()
return return
} }
+62 -18
View File
@@ -6,6 +6,7 @@ import asyncio
import json import json
import os import os
import shutil import shutil
import subprocess
import sys import sys
from contextlib import suppress from contextlib import suppress
from pathlib import Path from pathlib import Path
@@ -13,6 +14,9 @@ from urllib.parse import urlparse
import tracerite 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) # 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"))) sys.path.insert(0, str(Path(__file__).with_name("fastapi-vue")))
from devutil import ( # noqa: E402 from devutil import ( # noqa: E402
@@ -135,6 +139,40 @@ async def run_caddy(
return proc 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: async def run_devserver(args: argparse.Namespace, remaining: list[str]) -> None:
"""Run the development server with all components.""" """Run the development server with all components."""
reporoot = Path(__file__).parent.parent 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) viteurl, npm_install, vite = setup_vite(args.listen, DEFAULT_VITE_PORT)
backurl, paskia = setup_cli("paskia", args.backend, DEFAULT_DEV_PORT) backurl, paskia = setup_cli("paskia", args.backend, DEFAULT_DEV_PORT)
# Build paskia command with options rp_ids = _split_multi(args.rp_id) or ["localhost"]
paskia.extend(["--rp-id", args.rp_id]) ensure_database(rp_ids, args, listen=backurl.removeprefix("http://"))
if args.auth_host:
paskia.extend(["--auth-host", args.auth_host]) # Serve: no realm options — all configuration lives in the database
if args.origins:
for origin in args.origins:
paskia.extend(["--origin", origin])
paskia.extend(remaining) paskia.extend(remaining)
# Set environment for subprocesses # Set environment for subprocesses
@@ -171,14 +206,12 @@ async def run_devserver(args: argparse.Namespace, remaining: list[str]) -> None:
if "://" not in auth_host: if "://" not in auth_host:
auth_host = f"https://{auth_host}" auth_host = f"https://{auth_host}"
caddy_origins.append(auth_host) caddy_origins.append(auth_host)
caddy_origins.append(f"https://{args.rp_id}") for rp_id in rp_ids:
if args.origins: caddy_origins.append(f"https://{rp_id}")
for origin in args.origins: for origin in _split_multi(args.origins):
if "://" not in origin: if "://" not in origin:
origin = f"https://{origin}" origin = f"https://{origin}"
caddy_origins.append(origin) caddy_origins.append(origin)
if not caddy_origins:
caddy_origins.append(f"https://{args.rp_id}")
seen: set = set() seen: set = set()
caddy_origins = [x for x in caddy_origins if not (x in seen or seen.add(x))] 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) caddy_proc = await run_caddy(caddy_origins, viteurl, backurl)
@@ -209,11 +242,22 @@ def main():
help=f"FastAPI (default: localhost:{DEFAULT_DEV_PORT})", help=f"FastAPI (default: localhost:{DEFAULT_DEV_PORT})",
) )
parser.add_argument("--caddy", action="store_true", help="Run Caddy as HTTPS proxy") 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( 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() args, remaining = parser.parse_known_args()
with suppress(KeyboardInterrupt): with suppress(KeyboardInterrupt):