Rename realms to domains; object-keyed origins/related config format

Finish the realm→domain terminology removal across source, tests, e2e
and docs. The stored config drops all lists: Config.domains is keyed by
rp-id, DomainConfig.origins/related are objects keyed by host (https://
omitted), values True or OriginEntry(auth_host=True). The default/primary
domain concept is gone; ordering is display-time. Tests and e2e updated
to the new API shapes (not run). Database re-migrated from the legacy
backup into the new format.
This commit is contained in:
2026-09-07 02:05:12 +00:00
parent 80d55679fb
commit f2e6f5784e
33 changed files with 627 additions and 651 deletions
+9 -9
View File
@@ -26,7 +26,7 @@ import pytest_asyncio
from kanta import Kanta
import paskia.db.operations as ops_db
from paskia import realms
from paskia import domains
from paskia.authsession import reset_expires
from paskia.config import SESSION_LIFETIME
from paskia.db import (
@@ -42,7 +42,7 @@ from paskia.db import (
)
from paskia.db.bootstrap import bootstrap
from paskia.db.operations import DB
from paskia.db.structs import Config, RealmConfig, Session
from paskia.db.structs import Config, DomainConfig, Session
from paskia.fastapi.mainapp import app
from paskia.fastapi.session import AUTH_COOKIE_NAME
from paskia.util import avatar
@@ -81,7 +81,7 @@ async def test_db() -> AsyncGenerator[DB]:
- auth:admin and auth:org:admin permissions
- A default organization with Administration role
- An admin user with the Administration role
- The localhost realm configuration (with its OIDC provider)
- The localhost domain configuration (with its OIDC provider)
"""
with tempfile.NamedTemporaryFile(suffix=".jsonl", delete=True) as f:
db = DB()
@@ -94,7 +94,7 @@ async def test_db() -> AsyncGenerator[DB]:
data,
org_name="Test Organization",
admin_name="Test Admin",
config=Config(realms=[RealmConfig(rp_id=TEST_RP_ID)]),
config=Config(domains={TEST_RP_ID: DomainConfig()}),
)
await kanta.open()
@@ -106,10 +106,10 @@ async def test_db() -> AsyncGenerator[DB]:
@pytest_asyncio.fixture(scope="function")
async def realm_registry(test_db: DB) -> realms.RealmRegistry:
"""Install the realm registry built from the test database config."""
realms.configure(listen=TEST_LISTEN)
return realms.init_registry(test_db.config)
async def domain_registry(test_db: DB) -> domains.DomainRegistry:
"""Install the domain registry built from the test database config."""
domains.configure(listen=TEST_LISTEN)
return domains.init_registry(test_db.config)
@pytest_asyncio.fixture(scope="function")
@@ -233,7 +233,7 @@ async def reset_token(test_db: DB, test_user: User, test_credential: Credential)
@pytest_asyncio.fixture(scope="function")
async def client(
test_db: DB, realm_registry: realms.RealmRegistry
test_db: DB, domain_registry: domains.DomainRegistry
) -> AsyncGenerator[httpx.AsyncClient]:
"""Create an async test client for the FastAPI app."""
transport = httpx.ASGITransport(app=app)
+99 -109
View File
@@ -22,7 +22,7 @@ import pytest
import pytest_asyncio
import uuid7
from paskia import db, realms
from paskia import db, domains
from paskia.db import (
Credential,
Org,
@@ -1789,26 +1789,28 @@ class TestOrgAdminAuthExceptions:
assert response.status_code == 403
class TestRealms:
"""Tests for the realm management API (/auth/api/admin/realms/)."""
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 on the localhost realm, 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/realms/localhost",
"/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
realm_cfg = db.data().config.find_realm("localhost")
assert realm_cfg.auth_host == "https://auth.localhost"
realm = realms.registry().get("localhost")
assert realm.own_auth_host == "auth.localhost"
assert realm.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"
@@ -1816,26 +1818,27 @@ class TestRealms:
return {**auth_headers(token), "Host": "auth.localhost"}
@pytest.mark.asyncio
async def test_list_realms(self, client: httpx.AsyncClient, session_token: str):
async def test_list_domains(self, client: httpx.AsyncClient, session_token: str):
r = await client.get(
"/auth/api/admin/realms/",
"/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
realm = data[0]
assert realm["rp_id"] == "localhost"
assert realm["is_default"] is True
assert realm["auth_host"] is None
assert realm["site_url"] == "http://localhost:4401"
domain = data[0]
assert domain["rp_id"] == "localhost"
assert domain["origins"] == {}
assert domain["related"] == {}
assert domain["effective_auth_host"] is None
assert domain["site_url"] == "http://localhost:4401"
@pytest.mark.asyncio
async def test_realms_require_master_admin(
async def test_domains_require_master_admin(
self, client: httpx.AsyncClient, regular_session_token: str
):
r = await client.get(
"/auth/api/admin/realms/",
"/auth/api/admin/domains/",
headers={**auth_headers(regular_session_token), "Host": "localhost:4401"},
)
assert r.status_code in (401, 403)
@@ -1848,37 +1851,37 @@ class TestRealms:
test_user,
test_credential,
):
"""Removing auth_host must clear it from runtime realm 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/realms/localhost",
"/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.find_realm("localhost").auth_host is None
domain_cfg = db.data().config.domains["localhost"]
assert domains.auth_host_url(domain_cfg) is None
realm = realms.registry().get("localhost")
assert realm.own_auth_host is None
assert realm.ui_base_path == "/auth/"
# Site URL derivation is stateless: with the auth host removed, the
# first remaining origin becomes the site URL.
assert realm.auth_site_url == "https://auth.localhost/auth/"
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/realms/",
"/auth/api/admin/domains/",
headers={**auth_headers(session_token), "Host": "localhost:4401"},
)
assert r.json()[0]["auth_host"] is None
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
@@ -1906,134 +1909,130 @@ class TestRealms:
)
r = await client.patch(
"/auth/api/admin/realms/localhost",
json={"rp_name": "", "auth_host": "", "origins": []},
"/auth/api/admin/domains/localhost",
json={"rp_name": "", "origins": {}},
headers=headers,
)
assert r.status_code == 200, r.text
realm = realms.registry().get("localhost")
assert realm.own_auth_host is None
assert realm.ui_base_path == "/auth/"
assert "auth.localhost" not in realm.site_url
assert "auth.localhost" not in realm.auth_site_url
domain = domains.registry().get("localhost")
assert domain.own_auth_host is None
assert domain.ui_base_path == "/auth/"
assert "auth.localhost" not in domain.site_url
assert "auth.localhost" not in domain.auth_site_url
@pytest.mark.asyncio
async def test_create_and_delete_realm(
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/realms/",
"/auth/api/admin/domains/",
json={
"rp_id": "example.com",
"rp_name": "Example",
"origins": ["https://app.example.com"],
"related_origins": ["https://unrelated-site.com"],
"origins": {"app.example.com": True},
"related": {"unrelated-site.com": True},
},
headers=headers,
)
assert r.status_code == 200, r.text
r = await client.get("/auth/api/admin/realms/", headers=headers)
realms_list = {realm["rp_id"]: realm for realm in r.json()}
assert set(realms_list) == {"localhost", "example.com"}
created = realms_list["example.com"]
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"
assert created["is_default"] is False
assert created["related_origins"] == ["https://unrelated-site.com"]
assert created["related"] == {"unrelated-site.com": True}
# OIDC provider seeded for the new realm
# OIDC provider seeded for the new domain
assert db.data().oidc_for("example.com") is not None
r = await client.delete("/auth/api/admin/realms/example.com", headers=headers)
r = await client.delete("/auth/api/admin/domains/example.com", headers=headers)
assert r.status_code == 200, r.text
assert db.data().config.find_realm("example.com") is None
assert realms.registry().get("example.com") is None
assert "example.com" not in db.data().config.domains
assert domains.registry().get("example.com") is None
@pytest.mark.asyncio
async def test_create_realm_validation(
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/realms/", json={}, headers=headers)
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/realms/", json={"rp_id": "localhost"}, headers=headers
"/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/realms/", json={"rp_id": "not a domain!"}, headers=headers
"/auth/api/admin/domains/", json={"rp_id": "not a domain!"}, headers=headers
)
assert r.status_code == 400
# auth-host must be a subdomain of the rp-id
# An auth host must be within the rp-id domain
r = await client.post(
"/auth/api/admin/realms/",
json={"rp_id": "example.com", "auth_host": "auth.other.com"},
"/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 realms
# Related origin host may not collide across domains
r = await client.post(
"/auth/api/admin/realms/",
json={
"rp_id": "example.com",
"related_origins": ["https://shared-app.com"],
},
"/auth/api/admin/domains/",
json={"rp_id": "example.com", "related": {"shared-app.com": True}},
headers=headers,
)
assert r.status_code == 200
r = await client.post(
"/auth/api/admin/realms/",
json={"rp_id": "other.com", "related_origins": ["https://shared-app.com"]},
"/auth/api/admin/domains/",
json={"rp_id": "other.com", "related": {"shared-app.com": True}},
headers=headers,
)
assert r.status_code == 400
# Cross-domain entries are rejected from the in-domain origins list
r = await client.post(
"/auth/api/admin/realms/",
json={"rp_id": "another.com", "origins": ["https://elsewhere.com"]},
"/auth/api/admin/domains/",
json={"rp_id": "another.com", "origins": {"elsewhere.com": True}},
headers=headers,
)
assert r.status_code == 400
# In-domain entries are rejected from the related origins list
r = await client.post(
"/auth/api/admin/realms/",
json={
"rp_id": "another.com",
"related_origins": ["https://app.another.com"],
},
"/auth/api/admin/domains/",
json={"rp_id": "another.com", "related": {"app.another.com": True}},
headers=headers,
)
assert r.status_code == 400
@pytest.mark.asyncio
async def test_delete_realm_guards(
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 realm
r = await client.delete("/auth/api/admin/realms/localhost", headers=headers)
# Cannot delete the last domain
r = await client.delete("/auth/api/admin/domains/localhost", headers=headers)
assert r.status_code == 400
# Unknown realm
r = await client.delete("/auth/api/admin/realms/nope.com", headers=headers)
# Unknown domain
r = await client.delete("/auth/api/admin/domains/nope.com", headers=headers)
assert r.status_code == 400
# A realm with credentials still registered under it cannot be deleted
# A domain with credentials still registered under it cannot be deleted
r = await client.post(
"/auth/api/admin/realms/", json={"rp_id": "example.com"}, headers=headers
"/auth/api/admin/domains/", json={"rp_id": "example.com"}, headers=headers
)
assert r.status_code == 200
cred = Credential.create(
@@ -2045,11 +2044,11 @@ class TestRealms:
rp_id="example.com",
)
create_credential(cred)
r = await client.delete("/auth/api/admin/realms/example.com", headers=headers)
r = await client.delete("/auth/api/admin/domains/example.com", headers=headers)
assert r.status_code == 400
@pytest.mark.asyncio
async def test_update_realm_refuses_self_lockout(
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."""
@@ -2057,12 +2056,8 @@ class TestRealms:
# Allow-list without the current host and no auth host → lockout
r = await client.patch(
"/auth/api/admin/realms/localhost",
json={
"rp_name": "",
"auth_host": "",
"origins": ["https://auth.localhost"],
},
"/auth/api/admin/domains/localhost",
json={"rp_name": "", "origins": {"auth.localhost": True}},
headers=headers,
)
assert r.status_code == 400
@@ -2070,12 +2065,8 @@ class TestRealms:
# Allow-list including the current host is fine
r = await client.patch(
"/auth/api/admin/realms/localhost",
json={
"rp_name": "",
"auth_host": "",
"origins": ["https://localhost:4401"],
},
"/auth/api/admin/domains/localhost",
json={"rp_name": "", "origins": {"localhost:4401": True}},
headers=headers,
)
assert r.status_code == 200, r.text
@@ -2084,31 +2075,30 @@ class TestRealms:
# 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/realms/localhost",
"/auth/api/admin/domains/localhost",
json={
"rp_name": "",
"auth_host": "auth.localhost",
"origins": ["https://auth.localhost"],
"origins": {"auth.localhost": {"auth_host": True}},
},
headers=headers,
)
assert r.status_code == 200, r.text
@pytest.mark.asyncio
async def test_delete_current_realm_refused(
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/realms/", json={"rp_id": "example.com"}, headers=headers
"/auth/api/admin/domains/", json={"rp_id": "example.com"}, headers=headers
)
assert r.status_code == 200
# Deleting the realm in use is refused even if it has no credentials
r = await client.delete("/auth/api/admin/realms/localhost", headers=headers)
# 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 realm while authenticated here is fine
r = await client.delete("/auth/api/admin/realms/example.com", headers=headers)
# 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
@@ -2119,12 +2109,12 @@ class TestRealms:
test_user,
test_credential,
):
"""A realm without its own auth host uses the shared one in settings."""
"""A domain without its own auth host uses the shared one in settings."""
headers = await self._set_auth_host(
client, session_token, test_user, test_credential
)
r = await client.post(
"/auth/api/admin/realms/", json={"rp_id": "example.com"}, headers=headers
"/auth/api/admin/domains/", json={"rp_id": "example.com"}, headers=headers
)
assert r.status_code == 200
+11 -5
View File
@@ -18,10 +18,10 @@ from uuid import UUID
import httpx
import pytest
from paskia import authcode, db, realms
from paskia import authcode, db, domains
from paskia.authsession import EXPIRES
from paskia.db import delete_session
from paskia.db.structs import Client, Config, RealmConfig
from paskia.db.structs import Client, Config, DomainConfig, OriginEntry
from paskia.fastapi.api import _REFRESH_INTERVAL
from paskia.util import avatar, oidjwt, permutil
from paskia.util.crypto import hash_secret
@@ -69,9 +69,15 @@ class TestAvatarUrls:
self, tmp_path, monkeypatch
):
"""Absolute avatar URLs should preserve /auth/api even with an auth host."""
realms.configure(listen=None)
realms.init_registry(
Config(realms=[RealmConfig(rp_id="zi.fi", auth_host="https://auth.zi.fi")])
domains.configure(listen=None)
domains.init_registry(
Config(
domains={
"zi.fi": DomainConfig(
origins={"auth.zi.fi": OriginEntry(auth_host=True)}
)
}
)
)
# The autouse avatar fixture redirects storage to tmp_path / "users"
+16 -16
View File
@@ -1,9 +1,9 @@
"""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 realm(s)), ``paskia migrate`` (convert a legacy
with the initial domain(s)), ``paskia migrate`` (convert a legacy
``<rp-id>.paskiadb`` database), and bare ``paskia`` (serve the stored
realms; never migrates).
domains; never migrates).
"""
from __future__ import annotations
@@ -20,7 +20,7 @@ from kanta import Kanta
from paskia.__main__ import _load_stored_config, main
from paskia.db import legacy
from paskia.db.structs import Config
from paskia.db.structs import Config, OriginEntry
from paskia.util.runtime import ServeConfig, clear_cache
@@ -85,9 +85,9 @@ def test_init_defaults(run_cli, tmp_path):
run_cli("init")
config = stored_config(tmp_path)
assert [r.rp_id for r in config.realms] == ["localhost"]
assert config.realms[0].rp_name is None
assert config.realms[0].auth_host is None
assert list(config.domains) == ["localhost"]
assert config.domains["localhost"].rp_name is None
assert config.domains["localhost"].origins == {}
assert config.listen is None
@@ -107,11 +107,12 @@ def test_init_full_options(run_cli, tmp_path):
)
config = stored_config(tmp_path)
realm = config.realms[0]
assert realm.rp_id == "example.com"
assert realm.rp_name == "Example Corp"
assert realm.auth_host == "https://auth.example.com"
assert realm.origins == ["https://auth.example.com", "https://app.example.com"]
domain = config.domains["example.com"]
assert domain.rp_name == "Example Corp"
assert domain.origins == {
"app.example.com": True,
"auth.example.com": OriginEntry(auth_host=True),
}
assert config.listen == ["4402"]
@@ -119,8 +120,7 @@ def test_init_multiple_rp_ids(run_cli, tmp_path):
run_cli("init", "--rp-id", "company.com,app.com", "--rp-id", "pro.com")
config = stored_config(tmp_path)
assert [r.rp_id for r in config.realms] == ["company.com", "app.com", "pro.com"]
assert config.default_realm.rp_id == "company.com"
assert list(config.domains) == ["company.com", "app.com", "pro.com"]
def test_init_refuses_existing_database(run_cli):
@@ -184,8 +184,8 @@ def test_migrate_converts_legacy_database(run_cli, tmp_path):
run_cli("migrate")
config = stored_config(tmp_path)
assert [r.rp_id for r in config.realms] == ["example.com"]
assert config.realms[0].rp_name == "Legacy Name"
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()
@@ -212,7 +212,7 @@ def test_migrate_explicit_rp_id_selects_candidate(run_cli, tmp_path):
run_cli("migrate", "--rp-id", "two.com")
config = stored_config(tmp_path)
assert [r.rp_id for r in config.realms] == ["two.com"]
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()
+189 -200
View File
@@ -1,5 +1,5 @@
"""Tests for the multi-realm machinery: registry resolution, config
validation, ASGI dispatch, realm binding of auth codes, legacy database
"""Tests for the multi-domain machinery: registry resolution, config
validation, ASGI dispatch, domain binding of auth codes, legacy database
conversion, log censoring and bootstrap caveats.
"""
@@ -14,7 +14,7 @@ import httpx
import pytest
from kanta import Kanta
from paskia import authcode, realms
from paskia import authcode, domains
from paskia.bootstrap import check_admin_credentials
from paskia.db import create_credential
from paskia.db.legacy import (
@@ -26,7 +26,7 @@ from paskia.db.legacy import (
)
from paskia.db.lifecycle import format_log_uuid
from paskia.db.operations import DB
from paskia.db.structs import Client, Config, Credential, RealmConfig
from paskia.db.structs import Client, Config, Credential, DomainConfig, OriginEntry
from paskia.fastapi.dispatch import DispatchMiddleware
from paskia.sansio import Passkey
@@ -35,22 +35,21 @@ from paskia.sansio import Passkey
# -------------------------------------------------------------------------
def build_registry(*realm_configs: RealmConfig) -> realms.RealmRegistry:
"""Build and install a registry from realm configs (listen unset)."""
realms.configure(listen=None)
return realms.init_registry(Config(realms=list(realm_configs)))
def build_registry(configs: dict[str, DomainConfig]) -> domains.DomainRegistry:
"""Build and install a registry from domain configs (listen unset)."""
domains.configure(listen=None)
return domains.init_registry(Config(domains=configs))
ROR_CONFIG = Config(
realms=[
RealmConfig(
rp_id="company.com",
auth_host="https://auth.company.com",
origins=["https://auth.company.com"],
related_origins=["https://app.com"],
domains={
"company.com": DomainConfig(
rp_name="Company",
origins={"auth.company.com": OriginEntry(auth_host=True)},
related={"app.com": True},
),
RealmConfig(rp_id="pro.com"),
]
"pro.com": DomainConfig(rp_name="Pro"),
}
)
@@ -117,152 +116,151 @@ async def drive_http(
class TestResolve:
def test_exact_rp_id(self):
reg = build_registry(*ROR_CONFIG.realms)
reg = build_registry(ROR_CONFIG.domains)
assert reg.resolve("pro.com").rp_id == "pro.com"
assert reg.resolve("company.com").rp_id == "company.com"
def test_auth_host_and_related_origin(self):
reg = build_registry(*ROR_CONFIG.realms)
reg = build_registry(ROR_CONFIG.domains)
assert reg.resolve("auth.company.com").rp_id == "company.com"
assert reg.resolve("app.com").rp_id == "company.com"
def test_subdomain_suffix_longest_match(self):
reg = build_registry(
RealmConfig(rp_id="example.com"), RealmConfig(rp_id="sub.example.com")
{"example.com": DomainConfig(), "sub.example.com": DomainConfig()}
)
assert reg.resolve("www.example.com").rp_id == "example.com"
assert reg.resolve("api.sub.example.com").rp_id == "sub.example.com"
def test_port_and_trailing_dot_normalized(self):
reg = build_registry(*ROR_CONFIG.realms)
reg = build_registry(ROR_CONFIG.domains)
assert reg.resolve("pro.com:8443").rp_id == "pro.com"
assert reg.resolve("app.com.").rp_id == "company.com"
def test_unknown_host(self):
reg = build_registry(*ROR_CONFIG.realms)
reg = build_registry(ROR_CONFIG.domains)
assert reg.resolve("evil.com") is None
assert reg.resolve("") is None
assert reg.resolve(None) is None
def test_effective_auth_host_fallback(self):
reg = build_registry(*ROR_CONFIG.realms)
reg = build_registry(ROR_CONFIG.domains)
company = reg.get("company.com")
pro = reg.get("pro.com")
assert reg.effective_auth_host(company) == "auth.company.com"
# pro.com has no own auth host: falls back to the first configured one
assert reg.effective_auth_host(pro) == "auth.company.com"
# No auth hosts at all: None
reg2 = build_registry(RealmConfig(rp_id="a.com"), RealmConfig(rp_id="b.com"))
reg2 = build_registry({"a.com": DomainConfig(), "b.com": DomainConfig()})
assert reg2.effective_auth_host(reg2.get("a.com")) is None
# -------------------------------------------------------------------------
# Cross-realm configuration validation
# Cross-domain configuration validation
# -------------------------------------------------------------------------
class TestValidateConfig:
def test_valid(self):
realms.validate_config(ROR_CONFIG)
domains.validate_config(ROR_CONFIG)
def test_related_origin_cap(self):
realms.validate_config(
domains.validate_config(
Config(
realms=[
RealmConfig(
rp_id="company.com",
related_origins=[f"https://app{i}.com" for i in range(5)],
domains={
"company.com": DomainConfig(
related={f"app{i}.com": True for i in range(5)}
)
]
}
)
)
with pytest.raises(ValueError, match="related origins"):
realms.validate_config(
domains.validate_config(
Config(
realms=[
RealmConfig(
rp_id="company.com",
related_origins=[f"https://app{i}.com" for i in range(6)],
domains={
"company.com": DomainConfig(
related={f"app{i}.com": True for i in range(6)}
)
]
}
)
)
def test_origin_outside_rp_id_rejected(self):
"""In-domain origins are an allow-list; cross-domain needs related."""
with pytest.raises(ValueError, match="outside the rp-id domain"):
realms.validate_config(
Config(
realms=[
RealmConfig(rp_id="a.com", origins=["https://elsewhere.com"])
]
)
domains.validate_config(
Config(domains={"a.com": DomainConfig(origins={"elsewhere.com": True})})
)
def test_related_origin_inside_own_realm_rejected(self):
def test_related_origin_inside_own_domain_rejected(self):
"""Subdomains of the rp-id are covered already; listing is an error."""
with pytest.raises(ValueError, match="within the rp-id domain"):
realms.validate_config(
Config(
realms=[
RealmConfig(
rp_id="a.com", related_origins=["https://app.a.com"]
)
]
)
domains.validate_config(
Config(domains={"a.com": DomainConfig(related={"app.a.com": True})})
)
def test_wildcard_related_origin_rejected(self):
"""ROR entries are always individual origins; wildcards are meaningless."""
with pytest.raises(ValueError, match="wildcard"):
realms.validate_config(
Config(realms=[RealmConfig(rp_id="a.com", related_origins=["*.b.com"])])
domains.validate_config(
Config(domains={"a.com": DomainConfig(related={"*.b.com": True})})
)
def test_wildcard_origin_in_domain_accepted(self):
realms.validate_config(
Config(realms=[RealmConfig(rp_id="a.com", origins=["*.a.com"])])
domains.validate_config(
Config(domains={"a.com": DomainConfig(origins={"*.a.com": True})})
)
with pytest.raises(ValueError, match="outside the rp-id domain"):
realms.validate_config(
Config(realms=[RealmConfig(rp_id="a.com", origins=["*.b.com"])])
domains.validate_config(
Config(domains={"a.com": DomainConfig(origins={"*.b.com": True})})
)
def test_wildcard_auth_host_rejected(self):
with pytest.raises(ValueError, match="cannot be the auth host"):
domains.validate_config(
Config(
domains={
"a.com": DomainConfig(
origins={"*.a.com": OriginEntry(auth_host=True)}
)
}
)
)
def test_auth_host_collision(self):
with pytest.raises(ValueError, match="collides with a related origin"):
realms.validate_config(
domains.validate_config(
Config(
realms=[
RealmConfig(rp_id="a.com", auth_host="https://auth.a.com"),
RealmConfig(
rp_id="b.com",
related_origins=["https://auth.a.com"],
domains={
"a.com": DomainConfig(
origins={"auth.a.com": OriginEntry(auth_host=True)}
),
]
"b.com": DomainConfig(related={"auth.a.com": True}),
}
)
)
def test_related_origin_inside_other_realm(self):
with pytest.raises(ValueError, match="falls inside realm"):
realms.validate_config(
def test_related_origin_inside_other_domain(self):
with pytest.raises(ValueError, match="falls inside domain"):
domains.validate_config(
Config(
realms=[
RealmConfig(
rp_id="a.com", related_origins=["https://app.b.com"]
),
RealmConfig(rp_id="b.com"),
]
domains={
"a.com": DomainConfig(related={"app.b.com": True}),
"b.com": DomainConfig(),
}
)
)
def test_auth_host_must_not_collide_with_rp_id(self):
with pytest.raises(ValueError, match="collides with an rp-id"):
realms.validate_config(
domains.validate_config(
Config(
realms=[
RealmConfig(rp_id="a.com", auth_host="https://b.a.com"),
RealmConfig(rp_id="b.a.com"),
]
domains={
"a.com": DomainConfig(
origins={"b.a.com": OriginEntry(auth_host=True)}
),
"b.a.com": DomainConfig(),
}
)
)
@@ -276,122 +274,110 @@ class TestSanitizeConfig:
"""Serving never fails on stored config problems; it degrades + warns."""
def test_cross_domain_origin_moved_to_related(self):
config, warnings = realms.sanitize_config(
Config(
realms=[RealmConfig(rp_id="localhost", origins=["https://example.com"])]
)
config, warnings = domains.sanitize_config(
Config(domains={"localhost": DomainConfig(origins={"example.com": True})})
)
realm = config.realms[0]
assert realm.origins is None
assert realm.related_origins == ["https://example.com"]
domain = config.domains["localhost"]
assert domain.origins == {}
assert domain.related == {"example.com": True}
assert any("related origin" in w for w in warnings)
realms.validate_config(config) # sanitized config is strict-clean
domains.validate_config(config) # sanitized config is strict-clean
def test_malformed_origin_dropped(self):
config, warnings = realms.sanitize_config(
Config(realms=[RealmConfig(rp_id="a.com", origins=["not a url"])])
config, warnings = domains.sanitize_config(
Config(domains={"a.com": DomainConfig(origins={"https://": True})})
)
assert config.realms[0].origins is None
assert config.domains["a.com"].origins == {}
assert warnings
def test_invalid_rp_id_realm_dropped(self):
config, warnings = realms.sanitize_config(
Config(
realms=[RealmConfig(rp_id="not a domain!"), RealmConfig(rp_id="ok.com")]
)
def test_invalid_rp_id_domain_dropped(self):
config, warnings = domains.sanitize_config(
Config(domains={"not a domain!": DomainConfig(), "ok.com": DomainConfig()})
)
assert [r.rp_id for r in config.realms] == ["ok.com"]
assert list(config.domains) == ["ok.com"]
assert any("dropped" in w for w in warnings)
def test_duplicate_rp_id_first_wins(self):
config, _warnings = realms.sanitize_config(
Config(
realms=[
RealmConfig(rp_id="a.com", rp_name="First"),
RealmConfig(rp_id="a.com"),
]
)
)
assert len(config.realms) == 1
assert config.realms[0].rp_name == "First"
def test_related_inside_own_domain_dropped(self):
config, _ = realms.sanitize_config(
Config(
realms=[
RealmConfig(rp_id="a.com", related_origins=["https://app.a.com"])
]
)
config, _ = domains.sanitize_config(
Config(domains={"a.com": DomainConfig(related={"app.a.com": True})})
)
assert config.realms[0].related_origins is None
assert config.domains["a.com"].related == {}
def test_wildcard_related_origin_dropped(self):
config, warnings = realms.sanitize_config(
Config(realms=[RealmConfig(rp_id="a.com", related_origins=["*.b.com"])])
config, warnings = domains.sanitize_config(
Config(domains={"a.com": DomainConfig(related={"*.b.com": True})})
)
assert config.realms[0].related_origins is None
assert config.domains["a.com"].related == {}
assert any("wildcard" in w for w in warnings)
realms.validate_config(config) # sanitized config is strict-clean
domains.validate_config(config) # sanitized config is strict-clean
def test_cap_exceeded_truncated(self):
config, warnings = realms.sanitize_config(
config, warnings = domains.sanitize_config(
Config(
realms=[
RealmConfig(
rp_id="a.com",
related_origins=[f"https://app{i}.com" for i in range(6)],
domains={
"a.com": DomainConfig(
related={f"app{i}.com": True for i in range(6)}
)
]
}
)
)
assert len(config.realms[0].related_origins) == 5
assert len(config.domains["a.com"].related) == 5
assert any("maximum" in w for w in warnings)
def test_auth_host_outside_domain_ignored(self):
config, warnings = realms.sanitize_config(
Config(realms=[RealmConfig(rp_id="a.com", auth_host="https://auth.b.com")])
)
assert config.realms[0].auth_host is None
assert any("auth host ignored" in w for w in warnings)
def test_auth_host_colliding_with_rp_id_ignored(self):
config, warnings = realms.sanitize_config(
def test_auth_host_outside_domain_becomes_related(self):
"""An auth-marked origin outside the rp-id degrades to a related origin."""
config, warnings = domains.sanitize_config(
Config(
realms=[
RealmConfig(rp_id="a.com", auth_host="https://auth.a.com"),
RealmConfig(rp_id="auth.a.com"),
]
domains={
"a.com": DomainConfig(
origins={"auth.b.com": OriginEntry(auth_host=True)}
)
}
)
)
assert config.realms[0].auth_host is None
assert any("collides with an rp-id" in w for w in warnings)
domain = config.domains["a.com"]
assert domain.origins == {}
assert domain.related == {"auth.b.com": True}
assert any("related origin" in w for w in warnings)
def test_related_colliding_with_other_realm_dropped(self):
config, _ = realms.sanitize_config(
def test_auth_host_colliding_with_rp_id_cleared(self):
config, warnings = domains.sanitize_config(
Config(
realms=[
RealmConfig(rp_id="a.com", related_origins=["https://app.b.com"]),
RealmConfig(rp_id="b.com"),
]
domains={
"a.com": DomainConfig(
origins={"auth.a.com": OriginEntry(auth_host=True)}
),
"auth.a.com": DomainConfig(),
}
)
)
assert config.realms[0].related_origins is None
assert config.domains["a.com"].origins == {"auth.a.com": True}
assert any("collides" in w for w in warnings)
def test_no_realms_is_fatal(self):
with pytest.raises(ValueError, match="realm"):
realms.sanitize_config(Config(realms=[]))
with pytest.raises(ValueError, match="No servable realm"):
realms.sanitize_config(Config(realms=[RealmConfig(rp_id="not a domain!")]))
def test_related_colliding_with_other_domain_dropped(self):
config, _ = domains.sanitize_config(
Config(
domains={
"a.com": DomainConfig(related={"app.b.com": True}),
"b.com": DomainConfig(),
}
)
)
assert config.domains["a.com"].related == {}
def test_no_domains_is_fatal(self):
with pytest.raises(ValueError, match="No servable domain"):
domains.sanitize_config(Config(domains={}))
with pytest.raises(ValueError, match="No servable domain"):
domains.sanitize_config(Config(domains={"not a domain!": DomainConfig()}))
def test_build_tolerates_and_serves(self):
# Cross-domain entry stored in origins: served as a related origin
reg = build_registry(
RealmConfig(rp_id="localhost", origins=["https://example.com"])
)
reg = build_registry({"localhost": DomainConfig(origins={"example.com": True})})
assert reg.warnings
realm = reg.get("localhost")
assert realm.related_origins == ["https://example.com"]
realm.passkey.validate_origin("https://example.com")
domain = reg.get("localhost")
assert domain.related_origins == ["https://example.com"]
domain.passkey.validate_origin("https://example.com")
# -------------------------------------------------------------------------
@@ -461,8 +447,8 @@ class TestOriginValidation:
with pytest.raises(ValueError, match="within the rp-id domain"):
Passkey(rp_id="example.com", related_origins=["https://app.example.com"])
def test_realm_wires_both_lists(self):
reg = build_registry(*ROR_CONFIG.realms)
def test_domain_wires_both_lists(self):
reg = build_registry(ROR_CONFIG.domains)
p = reg.get("company.com").passkey
assert p.validate_origin("https://app.com") # related origin
assert p.validate_origin("https://auth.company.com") # allow-listed
@@ -478,7 +464,7 @@ class TestOriginValidation:
class TestDispatchMiddleware:
@pytest.mark.asyncio
async def test_http_unknown_host_421(self):
build_registry(*ROR_CONFIG.realms)
build_registry(ROR_CONFIG.domains)
stub, sent = await drive_http(
DispatchMiddleware(StubApp()), [(b"host", b"evil.com")]
)
@@ -487,30 +473,32 @@ class TestDispatchMiddleware:
assert sent[0]["status"] == 421
@pytest.mark.asyncio
async def test_http_dispatches_realm(self):
build_registry(*ROR_CONFIG.realms)
async def test_http_dispatches_domain(self):
build_registry(ROR_CONFIG.domains)
stub, _sent = await drive_http(
DispatchMiddleware(StubApp()), [(b"host", b"app.com.")]
)
assert stub.scope is not None
assert stub.scope["state"]["realm"].rp_id == "company.com"
assert stub.scope["state"]["domain"].rp_id == "company.com"
@pytest.mark.asyncio
async def test_http_current_realm_set_inside_request(self):
reg = build_registry(*ROR_CONFIG.realms)
async def test_http_current_domain_set_inside_request(self):
build_registry(ROR_CONFIG.domains)
seen = {}
async def app(scope, receive, send):
seen["realm"] = realms.current_realm()
seen["domain"] = domains.current_domain()
await drive_http(DispatchMiddleware(app), [(b"host", b"pro.com")])
assert seen["realm"].rp_id == "pro.com"
# Contextvar is reset after the request
assert realms.current_realm() is reg.default
assert seen["domain"].rp_id == "pro.com"
# Contextvar is reset after the request; with several domains there
# is no implicit current domain outside a request context.
with pytest.raises(RuntimeError, match="request context"):
domains.current_domain()
@pytest.mark.asyncio
async def test_ws_unknown_host_closed(self):
build_registry(*ROR_CONFIG.realms)
build_registry(ROR_CONFIG.domains)
stub, sent = await drive_ws(
DispatchMiddleware(StubApp()), [(b"host", b"evil.com")]
)
@@ -518,25 +506,25 @@ class TestDispatchMiddleware:
assert sent == [{"type": "websocket.close", "code": 1008}]
@pytest.mark.asyncio
async def test_ws_same_realm_origin(self):
build_registry(*ROR_CONFIG.realms)
async def test_ws_same_domain_origin(self):
build_registry(ROR_CONFIG.domains)
stub, sent = await drive_ws(
DispatchMiddleware(StubApp()),
[(b"host", b"auth.company.com"), (b"origin", b"https://app.com")],
)
assert sent == []
assert stub.scope["state"]["realm"].rp_id == "company.com"
assert stub.scope["state"]["domain"].rp_id == "company.com"
@pytest.mark.asyncio
async def test_ws_cross_realm_requires_effective_auth_host(self):
build_registry(*ROR_CONFIG.realms)
# pro.com page connecting to the shared auth host: allowed, pro realm
async def test_ws_cross_domain_requires_effective_auth_host(self):
build_registry(ROR_CONFIG.domains)
# pro.com page connecting to the shared auth host: allowed, pro domain
stub, sent = await drive_ws(
DispatchMiddleware(StubApp()),
[(b"host", b"auth.company.com"), (b"origin", b"https://pro.com")],
)
assert sent == []
assert stub.scope["state"]["realm"].rp_id == "pro.com"
assert stub.scope["state"]["domain"].rp_id == "pro.com"
# pro.com page connecting to some other host: closed pre-accept
stub, sent = await drive_ws(
@@ -547,27 +535,27 @@ class TestDispatchMiddleware:
assert sent == [{"type": "websocket.close", "code": 1008}]
@pytest.mark.asyncio
async def test_ws_unknown_origin_uses_host_realm(self):
build_registry(*ROR_CONFIG.realms)
async def test_ws_unknown_origin_uses_host_domain(self):
build_registry(ROR_CONFIG.domains)
# Missing origin
stub, _ = await drive_ws(DispatchMiddleware(StubApp()), [(b"host", b"pro.com")])
assert stub.scope["state"]["realm"].rp_id == "pro.com"
# Unknown origin: host realm applies (endpoint-side validation decides)
assert stub.scope["state"]["domain"].rp_id == "pro.com"
# Unknown origin: host domain applies (endpoint-side validation decides)
stub, _ = await drive_ws(
DispatchMiddleware(StubApp()),
[(b"host", b"pro.com"), (b"origin", b"https://evil.com")],
)
assert stub.scope["state"]["realm"].rp_id == "pro.com"
assert stub.scope["state"]["domain"].rp_id == "pro.com"
# -------------------------------------------------------------------------
# Realm binding of auth codes
# Domain binding of auth codes
# -------------------------------------------------------------------------
class TestAuthCodeRealmBinding:
class TestAuthCodeDomainBinding:
@pytest.mark.asyncio
async def test_cookie_code_rejected_on_other_realm(
async def test_cookie_code_rejected_on_other_domain(
self, client: httpx.AsyncClient, session_token: str
):
code = authcode.store_cookie(
@@ -587,7 +575,7 @@ class TestAuthCodeRealmBinding:
assert response.status_code == 401
@pytest.mark.asyncio
async def test_oidc_code_rejected_on_other_realm(
async def test_oidc_code_rejected_on_other_domain(
self, client: httpx.AsyncClient, test_db: DB
):
oidc_client, secret = Client.create(
@@ -621,7 +609,7 @@ class TestAuthCodeRealmBinding:
)
assert response.status_code == 400
assert response.json()["error"] == "invalid_grant"
assert "realm" in response.json()["error_description"]
assert "domain" in response.json()["error_description"]
# -------------------------------------------------------------------------
@@ -640,7 +628,7 @@ def _read_db(path) -> DB:
class TestLegacyConversion:
def test_convert_stamps_realm_everywhere(self, tmp_path):
def test_convert_stamps_domain_everywhere(self, tmp_path):
src = tmp_path / "example.com.paskiadb"
src.mkdir()
src_file = src / "main.db"
@@ -678,8 +666,9 @@ class TestLegacyConversion:
asyncio.run(_write())
config = convert_legacy_database(src_file, tmp_path / "paskia.kantadb")
assert config.default_realm.rp_id == "example.com"
assert config.default_realm.rp_name == "Example"
domain = config.domains["example.com"]
assert domain.rp_name == "Example"
assert domain.origins == {"app.example.com": True}
converted = _read_db(tmp_path / "paskia.kantadb")
assert converted.credentials[cred_uuid].rp_id == "example.com"
@@ -703,32 +692,32 @@ class TestLogCensoring:
def test_other_paths_unaffected(self):
assert format_log_uuid("not-a-uuid", "oidc.localhost.clients") is None
assert format_log_uuid("not-a-uuid", "config.realms") is None
assert format_log_uuid("not-a-uuid", "config.domains") is None
# -------------------------------------------------------------------------
# Bootstrap caveat: admin credential is checked on the default realm
# Bootstrap caveat: admin credential is checked on the configured domains
# -------------------------------------------------------------------------
class TestBootstrapCaveat:
@pytest.mark.asyncio
async def test_admin_without_credentials_gets_link(
self, test_db: DB, realm_registry
self, test_db: DB, domain_registry
):
assert await check_admin_credentials() is True
@pytest.mark.asyncio
async def test_admin_with_default_realm_credential_ok(
self, test_db: DB, realm_registry, test_user, test_credential
async def test_admin_with_domain_credential_ok(
self, test_db: DB, domain_registry, test_user, test_credential
):
assert await check_admin_credentials() is False
@pytest.mark.asyncio
async def test_admin_with_only_other_realm_credential_gets_link(
self, test_db: DB, realm_registry, test_user
async def test_admin_with_only_unconfigured_domain_credential_gets_link(
self, test_db: DB, domain_registry, test_user
):
"""A passkey under a non-default realm does not satisfy the check."""
"""A passkey under an rp-id outside the config does not satisfy the check."""
cred = Credential.create(
credential_id=os.urandom(32),
user=test_user.uuid,