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 is contained in:
2026-09-07 22:14:42 +00:00
parent 383c9f472e
commit 84985501f5
78 changed files with 5291 additions and 1484 deletions
+303 -53
View File
@@ -22,7 +22,7 @@ import pytest
import pytest_asyncio
import uuid7
from paskia import db
from paskia import db, domains
from paskia.db import (
Credential,
Org,
@@ -37,10 +37,7 @@ from paskia.db import (
create_user,
)
from paskia.db.operations import DB
from paskia.util import hostutil
from paskia.util.crypto import hash_secret
from paskia.util.runtime import clear_config_cache
from paskia.util.runtime import config as runtime_config
from tests.conftest import auth_headers, create_test_image_bytes, create_test_session
# -------------------- Additional Fixtures --------------------
@@ -91,6 +88,7 @@ async def second_org_credential(test_db: DB, second_org_user: User) -> Credentia
aaguid=UUID("00000000-0000-0000-0000-000000000000"),
public_key=os.urandom(64),
sign_count=0,
rp_id="localhost",
)
create_credential(credential)
return credential
@@ -145,6 +143,7 @@ async def org_admin_credential(test_db: DB, org_admin_user: User) -> Credential:
aaguid=UUID("00000000-0000-0000-0000-000000000000"),
public_key=os.urandom(64),
sign_count=0,
rp_id="localhost",
)
create_credential(credential)
return credential
@@ -253,8 +252,6 @@ class TestAdminOrganizations:
monkeypatch,
):
"""Admin org payload should include canonical avatar URLs for listed users."""
monkeypatch.setenv("PASKIA_DB", str(tmp_path / "test-avatar-db.paskiadb"))
upload = await client.put(
f"/auth/api/user/{test_user.uuid}/profile.webp",
files={"file": ("avatar.webp", create_test_image_bytes(), "image/webp")},
@@ -948,8 +945,6 @@ class TestAdminUsersInOrg:
monkeypatch,
):
"""Admin should be able to upload avatar for a managed user."""
monkeypatch.setenv("PASKIA_DB", str(tmp_path / "test-admin-avatar-db.paskiadb"))
response = await client.put(
f"/auth/api/user/{test_user.uuid}/profile.webp",
files={"file": ("avatar.webp", create_test_image_bytes(), "image/webp")},
@@ -1794,38 +1789,60 @@ class TestOrgAdminAuthExceptions:
assert response.status_code == 403
class TestServerConfig:
"""Tests for GET/PATCH /auth/api/admin/server-config/ runtime updates."""
@pytest.fixture(scope="function")
def restore_runtime_config(self):
"""Restore PASKIA_CONFIG env and cache after a test mutates runtime."""
original = os.environ["PASKIA_CONFIG"]
yield
os.environ["PASKIA_CONFIG"] = original
clear_config_cache()
class TestDomains:
"""Tests for the domain management API (/auth/api/admin/domains/)."""
async def _set_auth_host(self, client, session_token, test_user, test_credential):
"""Configure an auth host via PATCH, as the admin UI would."""
"""Configure an auth host on the localhost domain, as the admin UI would."""
r = await client.patch(
"/auth/api/admin/server-config/",
"/auth/api/admin/domains/localhost",
json={
"rp_name": "",
"auth_host": "auth.localhost",
"origins": ["auth.localhost", "localhost"],
"origins": {
"auth.localhost": {"auth_host": True},
"localhost": True,
},
},
headers={**auth_headers(session_token), "Host": "localhost:4401"},
)
assert r.status_code == 200, r.text
assert db.data().config.auth_host == "https://auth.localhost"
assert hostutil.dedicated_auth_host() == "auth.localhost"
assert hostutil.auth_site_url() == "https://auth.localhost/"
domain_cfg = db.data().config.domains["localhost"]
assert domains.auth_host_url(domain_cfg) == "https://auth.localhost"
domain = domains.registry().get("localhost")
assert domain.own_auth_host == "auth.localhost"
assert domain.auth_site_url == "https://auth.localhost/"
# Session for requests coming from the auth host (sessions are host-bound)
_, token = create_test_session(
test_user.uuid, test_credential.uuid, host="auth.localhost"
)
return {**auth_headers(token), "Host": "auth.localhost"}
@pytest.mark.asyncio
async def test_list_domains(self, client: httpx.AsyncClient, session_token: str):
r = await client.get(
"/auth/api/admin/domains/",
headers={**auth_headers(session_token), "Host": "localhost:4401"},
)
assert r.status_code == 200, r.text
data = r.json()
assert len(data) == 1
domain = data[0]
assert domain["rp_id"] == "localhost"
assert domain["origins"] == {"**.localhost": True}
assert "related" not in domain
assert domain["auth_host"] is None
assert domain["site_url"] == "http://localhost:4401"
@pytest.mark.asyncio
async def test_domains_require_master_admin(
self, client: httpx.AsyncClient, regular_session_token: str
):
r = await client.get(
"/auth/api/admin/domains/",
headers={**auth_headers(regular_session_token), "Host": "localhost:4401"},
)
assert r.status_code in (401, 403)
@pytest.mark.asyncio
async def test_remove_auth_host_updates_runtime(
self,
@@ -1833,41 +1850,41 @@ class TestServerConfig:
session_token: str,
test_user,
test_credential,
restore_runtime_config,
):
"""Removing auth_host must clear it from runtime config and URLs."""
"""Removing the auth host mark must clear it from runtime config and URLs."""
headers = await self._set_auth_host(
client, session_token, test_user, test_credential
)
# The dialog still lists the old auth host among origins, so it is sent back
r = await client.patch(
"/auth/api/admin/server-config/",
"/auth/api/admin/domains/localhost",
json={
"rp_name": "",
"auth_host": "",
"origins": ["auth.localhost", "localhost"],
"origins": {"auth.localhost": True, "localhost": True},
},
headers=headers,
)
assert r.status_code == 200, r.text
assert db.data().config.auth_host is None
domain_cfg = db.data().config.domains["localhost"]
assert domains.auth_host_url(domain_cfg) is None
rt = runtime_config()
assert rt.config.auth_host is None
assert rt.site_path == "/auth/"
assert "auth.localhost" not in rt.site_url
assert hostutil.dedicated_auth_host() is None
assert "auth.localhost" not in hostutil.auth_site_url()
domain = domains.registry().get("localhost")
assert domain.own_auth_host is None
assert domain.ui_base_path == "/auth/"
# Site URL derivation is stateless: with the auth host mark removed,
# the exact rp-id origin becomes the site URL.
assert domain.auth_site_url == "https://localhost/auth/"
# GET and settings reflect the cleared state
r = await client.get(
"/auth/api/admin/server-config/",
"/auth/api/admin/domains/",
headers={**auth_headers(session_token), "Host": "localhost:4401"},
)
assert r.json()["auth_host"] == ""
assert r.json()[0]["origins"] == {"auth.localhost": True, "localhost": True}
r = await client.get("/auth/api/settings")
assert r.json()["auth_host"] is None
assert r.json()["own_auth_host"] is None
assert r.json()["ui_base_path"] == "/auth/"
# Middleware no longer redirects to the removed auth host
@@ -1879,28 +1896,261 @@ class TestServerConfig:
assert "auth.localhost" not in r.headers.get("location", "")
@pytest.mark.asyncio
async def test_remove_auth_host_without_origins_falls_back_to_rp_id(
async def test_remove_auth_host_without_origins_falls_back(
self,
client: httpx.AsyncClient,
session_token: str,
):
"""Emptying a domain's origins table must not keep the removed auth
host in derived URLs. Only possible on a domain other than the one
in use — the lockout guard refuses it there."""
headers = {**auth_headers(session_token), "Host": "localhost:4401"}
r = await client.post(
"/auth/api/admin/domains/",
json={
"rp_id": "example.com",
"origins": {
"auth.example.com": {"auth_host": True},
"app.example.com": True,
},
},
headers=headers,
)
assert r.status_code == 200, r.text
domain = domains.registry().get("example.com")
assert domain.own_auth_host == "auth.example.com"
assert "auth.example.com" in domain.site_url
r = await client.patch(
"/auth/api/admin/domains/example.com",
json={"rp_name": "", "origins": {}},
headers=headers,
)
assert r.status_code == 200, r.text
domain = domains.registry().get("example.com")
assert domain.own_auth_host is None
assert domain.ui_base_path == "/auth/"
assert "auth.example.com" not in domain.site_url
assert "auth.example.com" not in domain.auth_site_url
@pytest.mark.asyncio
async def test_create_and_delete_domain(
self, client: httpx.AsyncClient, session_token: str
):
headers = {**auth_headers(session_token), "Host": "localhost:4401"}
r = await client.post(
"/auth/api/admin/domains/",
json={
"rp_id": "example.com",
"rp_name": "Example",
"origins": {"app.example.com": True, "unrelated-site.com": True},
},
headers=headers,
)
assert r.status_code == 200, r.text
r = await client.get("/auth/api/admin/domains/", headers=headers)
domains_list = {domain["rp_id"]: domain for domain in r.json()}
assert set(domains_list) == {"localhost", "example.com"}
created = domains_list["example.com"]
assert created["rp_name"] == "Example"
# In-domain and related origins live in one table; classification
# is derived from the rp-id
assert created["origins"] == {
"app.example.com": True,
"unrelated-site.com": True,
}
r = await client.delete("/auth/api/admin/domains/example.com", headers=headers)
assert r.status_code == 200, r.text
assert "example.com" not in db.data().config.domains
assert domains.registry().get("example.com") is None
@pytest.mark.asyncio
async def test_create_domain_validation(
self, client: httpx.AsyncClient, session_token: str
):
headers = {**auth_headers(session_token), "Host": "localhost:4401"}
# rp_id is required
r = await client.post("/auth/api/admin/domains/", json={}, headers=headers)
assert r.status_code == 400
# Duplicate rp-id
r = await client.post(
"/auth/api/admin/domains/", json={"rp_id": "localhost"}, headers=headers
)
assert r.status_code == 400
# Invalid rp-id
r = await client.post(
"/auth/api/admin/domains/", json={"rp_id": "not a domain!"}, headers=headers
)
assert r.status_code == 400
# An auth host must be within the rp-id domain
r = await client.post(
"/auth/api/admin/domains/",
json={
"rp_id": "example.com",
"origins": {"auth.other.com": {"auth_host": True}},
},
headers=headers,
)
assert r.status_code == 400
# Related origin host may not collide across domains
r = await client.post(
"/auth/api/admin/domains/",
json={"rp_id": "example.com", "origins": {"shared-app.com": True}},
headers=headers,
)
assert r.status_code == 200
r = await client.post(
"/auth/api/admin/domains/",
json={"rp_id": "other.com", "origins": {"shared-app.com": True}},
headers=headers,
)
assert r.status_code == 400
# Cross-domain entries are related origins — accepted in the same table
r = await client.post(
"/auth/api/admin/domains/",
json={"rp_id": "another.com", "origins": {"elsewhere.com": True}},
headers=headers,
)
assert r.status_code == 200
# Plain '*' is rejected — wildcards must be explicit ('**.another.com')
r = await client.post(
"/auth/api/admin/domains/",
json={"rp_id": "star.com", "origins": {"*": True}},
headers=headers,
)
assert r.status_code == 400
@pytest.mark.asyncio
async def test_delete_domain_guards(
self, client: httpx.AsyncClient, session_token: str, test_credential
):
headers = {**auth_headers(session_token), "Host": "localhost:4401"}
# Cannot delete the last domain
r = await client.delete("/auth/api/admin/domains/localhost", headers=headers)
assert r.status_code == 400
# Unknown domain
r = await client.delete("/auth/api/admin/domains/nope.com", headers=headers)
assert r.status_code == 400
# A domain with credentials still registered under it cannot be deleted
r = await client.post(
"/auth/api/admin/domains/", json={"rp_id": "example.com"}, headers=headers
)
assert r.status_code == 200
cred = Credential.create(
credential_id=secrets.token_bytes(32),
user=test_credential.user_uuid,
aaguid=UUID("00000000-0000-0000-0000-000000000000"),
public_key=secrets.token_bytes(64),
sign_count=0,
rp_id="example.com",
)
create_credential(cred)
r = await client.delete("/auth/api/admin/domains/example.com", headers=headers)
assert r.status_code == 400
@pytest.mark.asyncio
async def test_update_domain_refuses_self_lockout(
self, client: httpx.AsyncClient, session_token: str
):
"""An allow-list excluding the admin's current host is refused."""
headers = {**auth_headers(session_token), "Host": "localhost:4401"}
# Allow-list without the current host and no auth host → lockout
r = await client.patch(
"/auth/api/admin/domains/localhost",
json={"rp_name": "", "origins": {"auth.localhost": True}},
headers=headers,
)
assert r.status_code == 400
assert "lock you out" in r.text
# Emptying the origins table entirely is likewise a lockout
r = await client.patch(
"/auth/api/admin/domains/localhost",
json={"rp_name": "", "origins": {}},
headers=headers,
)
assert r.status_code == 400
assert "lock you out" in r.text
# Allow-list including the current host is fine
r = await client.patch(
"/auth/api/admin/domains/localhost",
json={"rp_name": "", "origins": {"localhost:4401": True}},
headers=headers,
)
assert r.status_code == 200, r.text
# An allow-list without the current host is also fine when an auth
# host is set: ceremonies move there (and it is always allowed).
# Done last: with an auth host set, the API here routes differently.
r = await client.patch(
"/auth/api/admin/domains/localhost",
json={
"rp_name": "",
"origins": {"auth.localhost": {"auth_host": True}},
},
headers=headers,
)
assert r.status_code == 200, r.text
@pytest.mark.asyncio
async def test_delete_current_domain_refused(
self, client: httpx.AsyncClient, session_token: str
):
headers = {**auth_headers(session_token), "Host": "localhost:4401"}
r = await client.post(
"/auth/api/admin/domains/", json={"rp_id": "example.com"}, headers=headers
)
assert r.status_code == 200
# Deleting the domain in use is refused even if it has no credentials
r = await client.delete("/auth/api/admin/domains/localhost", headers=headers)
assert r.status_code == 400
assert "currently using" in r.text
# Deleting another domain while authenticated here is fine
r = await client.delete("/auth/api/admin/domains/example.com", headers=headers)
assert r.status_code == 200, r.text
@pytest.mark.asyncio
async def test_no_cross_domain_auth_host_fallback(
self,
client: httpx.AsyncClient,
session_token: str,
test_user,
test_credential,
restore_runtime_config,
):
"""With no origins left, site_url must not keep the removed auth host."""
"""A domain without its own auth host reports none — there is no
cross-domain fallback to another domain's auth host."""
headers = await self._set_auth_host(
client, session_token, test_user, test_credential
)
r = await client.patch(
"/auth/api/admin/server-config/",
json={"rp_name": "", "auth_host": "", "origins": []},
headers=headers,
r = await client.post(
"/auth/api/admin/domains/", json={"rp_id": "example.com"}, headers=headers
)
assert r.status_code == 200, r.text
assert r.status_code == 200
rt = runtime_config()
assert rt.config.auth_host is None
assert rt.site_path == "/auth/"
assert "auth.localhost" not in rt.site_url
assert "auth.localhost" not in hostutil.auth_site_url()
# Settings on the example.com host report no auth host
r = await client.get("/auth/api/settings", headers={"Host": "example.com"})
assert r.status_code == 200
assert r.json()["rp_id"] == "example.com"
assert r.json()["auth_host"] is None
assert r.json()["own_auth_host"] is None
# The localhost domain still reports its own auth host
r = await client.get("/auth/api/settings", headers={"Host": "auth.localhost"})
assert r.status_code == 200
assert r.json()["auth_host"] == "auth.localhost"
assert r.json()["own_auth_host"] == "auth.localhost"