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:
+9
-9
@@ -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
@@ -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
@@ -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
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
Reference in New Issue
Block a user