MultiSite: one instance serves authentication across many domains (#4)

- Serve multiple domains (RP IDs) from one instance: host-based dispatch,
  per-domain credentials and sessions, domains managed at runtime in the
  admin UI — previously one RP per instance
- Cross-domain sign-in via Related Origin Requests: per-domain related-origins
  list with a served .well-known/webauthn document
- Explicit per-domain origin lists with shell-glob wildcards (**. for apex +
  any subdomain depth, *. for one level), editable in the admin UI with
  validation and self-lockout guards
- Per-domain auth hosts: the account/admin UI can live on a different host
  per domain, no longer confined to subdomains of a single RP
- CLI: 'paskia init <rp-id [rp-name]' initializes or adds a domain to an
  existing database; 'paskia migrate' converts legacy databases

BREAKING CHANGES (v2.0):
- Database schema: config is now per-domain and credentials/sessions carry
  an rp_id — existing databases must be converted with 'paskia migrate'
- Origins are now explicit: main implicitly allowed every subdomain of the
  RP; configure '**.' origins to reproduce that behavior
- CLI: the flat '--rp-id/--rp-name/--origin/--auth/--save' flags are
  replaced by the 'init' and 'migrate' subcommandsReviewed-on: #4
This commit was merged in pull request #4.
This commit is contained in:
2026-09-07 22:02:06 +00:00
parent 3912b5473e
commit 10af29f92d
91 changed files with 5755 additions and 1867 deletions
+234 -102
View File
@@ -1,4 +1,10 @@
"""Tests for the CLI entry point in paskia/__main__.py."""
"""Tests for the CLI entry point in paskia/__main__.py.
The CLI is split into ``paskia init`` (create the combined paskia.kantadb
with the initial domain(s)), ``paskia migrate`` (convert a legacy
``<rp-id>.paskiadb`` database), and bare ``paskia`` (serve the stored
domains; never migrates).
"""
from __future__ import annotations
@@ -6,141 +12,245 @@ import asyncio
import os
import subprocess
import sys
import tempfile
from pathlib import Path
from typing import Any
import msgspec
import pytest
from kanta import Kanta
from paskia.__main__ import main
from paskia.__main__ import _load_stored_config, main
from paskia.db import legacy
from paskia.db.structs import DB, Config
from paskia.util.runtime import clear_config_cache
from paskia.util.runtime import config as runtime_config
from paskia.util.runtime import ServeConfig, clear_cache
@pytest.fixture
def cli_run(monkeypatch):
"""Run the CLI main() with the given args and return the RuntimeConfig."""
def run_cli(monkeypatch, tmp_path):
"""Run the CLI main() in a temporary working directory.
def _run(*args: str, db_root: str | None = None) -> Any:
env = os.environ.copy()
if db_root is not None:
env["PASKIA_DB"] = db_root
monkeypatch.setattr(os, "environ", env)
Returns a callable; server.run and the startup box are stubbed out.
The returned dict records the server.run invocation (if any).
"""
monkeypatch.chdir(tmp_path)
calls: dict = {}
monkeypatch.setattr(
"fastapi_vue.server.run",
lambda app, **kw: calls.update({"app": app, **kw}),
)
monkeypatch.setattr(
"paskia.util.startupbox.print_startup_config", lambda *a, **kw: None
)
monkeypatch.setattr("logging.basicConfig", lambda **kw: None)
# Isolate environment mutations (PASKIA_CONFIG) from other tests
env = os.environ.copy()
env.pop("PASKIA_CONFIG", None)
env.pop("PASKIA_VITE_URL", None)
monkeypatch.setattr(os, "environ", env)
def _run(*args: str) -> dict:
monkeypatch.setattr(sys, "argv", ["paskia", *args])
monkeypatch.setattr("fastapi_vue.server.run", lambda *_args, **_kw: None)
monkeypatch.setattr(
"paskia.util.startupbox.print_startup_config", lambda _rt: None
)
monkeypatch.setattr("logging.basicConfig", lambda **_kw: None)
clear_config_cache()
main()
runtime = runtime_config()
clear_config_cache()
return runtime
clear_cache()
try:
main()
finally:
clear_cache()
return calls
return _run
async def _write_config(db_path: Path, config: Config) -> None:
"""Write a Config into a JSONL database file using Kanta.
The initial root uses a different rp_id so the stored diff includes the
target rp_id (required because Config omits defaults when diffing).
"""
kanta = Kanta(
str(db_path),
DB(config=Config(rp_id="uninitialized.invalid")),
migrations="paskia.db.migrations",
)
kanta.ctx.rp_id = config.rp_id
await kanta.open()
with kanta.transaction("test:write_config"):
kanta.data.config = config
await kanta.close()
def stored_config(tmp_path: Path) -> Config:
"""Read back the stored combined configuration."""
return _load_stored_config(tmp_path / "paskia.kantadb")
def write_config(db_path: Path, config: Config) -> None:
"""Synchronous wrapper for _write_config."""
asyncio.run(_write_config(db_path, config))
def write_legacy_db(root: Path, config: legacy.LegacyConfig) -> Path:
"""Create a legacy-format database directory <rp-id>.paskiadb/main.db."""
src_dir = root / f"{config.rp_id}.paskiadb"
src_dir.mkdir()
db_file = src_dir / "main.db"
async def _write() -> None:
kanta = Kanta(str(db_file), legacy.LegacyDB())
await kanta.open()
with kanta.transaction("test:seed"):
kanta.data.config = config
await kanta.close()
asyncio.run(_write())
return src_dir
def test_cli_defaults(cli_run):
with tempfile.TemporaryDirectory() as tmp:
runtime = cli_run("--rp-id", "localhost", db_root=tmp)
def test_init_defaults(run_cli, tmp_path):
run_cli("init")
assert runtime.config.rp_id == "localhost"
assert runtime.config.rp_name is None
assert runtime.config.auth_host is None
assert runtime.config.origins is None
assert runtime.site_url == "http://localhost:4401"
assert runtime.site_path == "/auth/"
assert runtime.save is False
config = stored_config(tmp_path)
assert list(config.domains) == ["localhost"]
assert config.domains["localhost"].rp_name is None
assert config.domains["localhost"].origins == {"**.localhost": True}
assert config.listen is None
def test_cli_explicit_options(cli_run):
runtime = cli_run(
"--rp-id",
"example.com",
"--rp-name",
"Example Corp",
"--auth-host",
"auth.example.com",
"--origin",
"https://app.example.com",
)
def test_init_full_options(run_cli, tmp_path):
run_cli("init", "example.com", "Example Corp", "--listen", "4402")
assert runtime.config.rp_id == "example.com"
assert runtime.config.rp_name == "Example Corp"
assert runtime.config.auth_host == "https://auth.example.com"
assert runtime.config.origins == [
"https://auth.example.com",
"https://app.example.com",
]
assert runtime.site_url == "https://auth.example.com"
assert runtime.site_path == "/"
config = stored_config(tmp_path)
domain = config.domains["example.com"]
assert domain.rp_name == "Example Corp"
assert domain.origins == {"**.example.com": True}
assert config.listen == ["4402"]
def test_cli_loads_stored_config(cli_run):
with tempfile.TemporaryDirectory() as tmp:
db_path = Path(tmp) / "main.db"
write_config(
db_path,
Config(
rp_id="example.com",
rp_name="Stored Name",
origins=["https://stored.example.com"],
),
)
runtime = cli_run("--rp-id", "example.com", db_root=tmp)
def test_init_adds_domains_to_existing_database(run_cli, tmp_path):
"""Further rp-ids are added by repeating init; no comma separation."""
run_cli("init", "company.com")
run_cli("init", "app.com")
run_cli("init", "pro.com", "Pro Corp")
assert runtime.config.rp_name == "Stored Name"
assert runtime.config.origins == ["https://stored.example.com"]
assert runtime.site_url == "https://stored.example.com"
config = stored_config(tmp_path)
assert list(config.domains) == ["company.com", "app.com", "pro.com"]
assert config.domains["pro.com"].rp_name == "Pro Corp"
def test_cli_overrides_stored_config(cli_run):
with tempfile.TemporaryDirectory() as tmp:
db_path = Path(tmp) / "main.db"
write_config(db_path, Config(rp_id="example.com", rp_name="Stored Name"))
runtime = cli_run(
"--rp-id", "example.com", "--rp-name", "Overridden", db_root=tmp
)
assert runtime.config.rp_name == "Overridden"
def test_init_seeds_one_global_oidc_key(run_cli, tmp_path):
"""OIDC is instance-global: init seeds a single signing key."""
run_cli("init", "company.com")
run_cli("init", "app.com")
assert converted_oidc_key(tmp_path) is not None
def test_cli_save_flag(cli_run):
runtime = cli_run("--save")
assert runtime.save is True
def converted_oidc_key(tmp_path):
async def _read():
new_db = DB()
kanta = Kanta(str(tmp_path / "paskia.kantadb"), new_db)
await kanta.open(readonly=True)
try:
return kanta.data.oidc.key
finally:
await kanta.close()
return asyncio.run(_read())
def test_cli_invalid_auth_host(cli_run):
def test_init_updates_rp_name_of_existing_domain(run_cli, tmp_path):
run_cli("init", "example.com", "Old Name")
run_cli("init", "example.com", "New Name")
assert stored_config(tmp_path).domains["example.com"].rp_name == "New Name"
def test_init_noop_on_existing_domain(run_cli):
run_cli("init")
with pytest.raises(SystemExit, match="already configured"):
run_cli("init")
def test_init_refuses_legacy_database(run_cli, tmp_path):
write_legacy_db(tmp_path, legacy.LegacyConfig(rp_id="example.com"))
with pytest.raises(SystemExit):
cli_run("--rp-id", "example.com", "--auth-host", "notsub.example.org")
run_cli("init")
def test_init_rejects_removed_options(run_cli):
"""Origins and auth hosts are admin-interface configuration, not init's."""
with pytest.raises(SystemExit):
run_cli("init", "example.com", "--auth-host", "auth.example.com")
with pytest.raises(SystemExit):
run_cli("init", "--origin", "https://app.example.com")
def test_serve_requires_database(run_cli):
with pytest.raises(SystemExit, match="paskia init"):
run_cli()
def test_serve_uses_stored_config(run_cli, tmp_path):
run_cli("init", "example.com", "Stored Name")
calls = run_cli()
assert calls["app"] == "paskia.fastapi.mainapp:app"
assert calls["listen"] is None # stored listen (None) used
serve = msgspec.json.decode(os.environ["PASKIA_CONFIG"].encode(), type=ServeConfig)
assert serve.listen is None
def test_serve_listen_override_not_persisted(run_cli, tmp_path):
run_cli("init", "--listen", "4402")
calls = run_cli("--listen", "4403")
assert calls["listen"] == ["4403"]
serve = msgspec.json.decode(os.environ["PASKIA_CONFIG"].encode(), type=ServeConfig)
assert serve.listen == ["4403"]
# Stored config keeps the original listen value
assert stored_config(tmp_path).listen == ["4402"]
def test_serve_suggests_migrate_when_legacy_present(run_cli, tmp_path):
write_legacy_db(tmp_path, legacy.LegacyConfig(rp_id="example.com"))
with pytest.raises(SystemExit, match="paskia migrate"):
run_cli()
def test_migrate_converts_legacy_database(run_cli, tmp_path):
src_dir = write_legacy_db(
tmp_path, legacy.LegacyConfig(rp_id="example.com", rp_name="Legacy Name")
)
# Persisted user files move to the new data root
avatar = src_dir / "users" / "019c6831-84cf-7b88-b66c-c8165890b7c5"
avatar.mkdir(parents=True)
(avatar / "profile.webp").write_bytes(b"RIFF1234WEBP")
run_cli("migrate")
config = stored_config(tmp_path)
assert list(config.domains) == ["example.com"]
assert config.domains["example.com"].rp_name == "Legacy Name"
# Legacy directory renamed aside, user files moved over
assert not src_dir.exists()
assert (tmp_path / "example.com.paskiadb.converted-bak").is_dir()
assert (
tmp_path
/ "paskia.data"
/ "users"
/ "019c6831-84cf-7b88-b66c-c8165890b7c5"
/ "profile.webp"
).read_bytes() == b"RIFF1234WEBP"
def test_migrate_multiple_legacy_databases_require_rp_id(run_cli, tmp_path):
write_legacy_db(tmp_path, legacy.LegacyConfig(rp_id="one.com"))
write_legacy_db(tmp_path, legacy.LegacyConfig(rp_id="two.com"))
with pytest.raises(SystemExit, match="paskia migrate"):
run_cli("migrate")
def test_migrate_explicit_rp_id_selects_candidate(run_cli, tmp_path):
write_legacy_db(tmp_path, legacy.LegacyConfig(rp_id="one.com"))
write_legacy_db(tmp_path, legacy.LegacyConfig(rp_id="two.com"))
run_cli("migrate", "two.com")
config = stored_config(tmp_path)
assert list(config.domains) == ["two.com"]
# The other candidate is left in place
assert (tmp_path / "one.com.paskiadb").is_dir()
assert (tmp_path / "two.com.paskiadb.converted-bak").is_dir()
def test_migrate_unknown_rp_id(run_cli, tmp_path):
write_legacy_db(tmp_path, legacy.LegacyConfig(rp_id="one.com"))
with pytest.raises(SystemExit, match="nope.com.paskiadb"):
run_cli("migrate", "nope.com")
def test_migrate_refuses_existing_database(run_cli):
run_cli("init")
with pytest.raises(SystemExit, match="already exists"):
run_cli("migrate")
def test_migrate_without_legacy_database(run_cli):
with pytest.raises(SystemExit, match="No legacy"):
run_cli("migrate")
def test_cli_help():
@@ -152,3 +262,25 @@ def test_cli_help():
)
assert result.returncode == 0
assert "Paskia authentication server" in result.stdout
def test_cli_init_help():
result = subprocess.run(
[sys.executable, "-m", "paskia", "init", "--help"],
capture_output=True,
text=True,
check=False,
)
assert result.returncode == 0
assert "Bootstrap" in result.stdout
def test_cli_migrate_help():
result = subprocess.run(
[sys.executable, "-m", "paskia", "migrate", "--help"],
capture_output=True,
text=True,
check=False,
)
assert result.returncode == 0
assert "Convert" in result.stdout