Separate related domains (ROR) from the in-domain sign-in allow-list

RealmConfig.origins is again purely an allow-list of sign-in sites
within the realm's domain (unset = rp-id and all subdomains), restoring
the restriction semantics the realm rework had silently turned into an
always-open subtree. Cross-domain ROR origins move to their own
RealmConfig.related_origins field — always additive, capped, validated
to be outside the rp-id domain, and the sole source of the
/.well-known/webauthn document.

Admin API POST/PATCH accept related_origins; misfiled entries are
rejected (cross-domain in origins, in-domain in related_origins).

Admin UI: the realm dialog edits the two lists separately with
end-user-oriented explanations (allowed sign-in sites vs. related
domains + the well-known note); the Realms section intro explains the
multi-domain model, and the table shows sign-in site and related domain
counts.
This commit is contained in:
2026-09-06 22:29:12 +00:00
parent fefd54f02a
commit b9e6f4bc27
16 changed files with 376 additions and 160 deletions
+89 -5
View File
@@ -28,6 +28,7 @@ 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.fastapi.dispatch import DispatchMiddleware
from paskia.sansio import Passkey
# -------------------------------------------------------------------------
# Registry construction helpers
@@ -45,7 +46,8 @@ ROR_CONFIG = Config(
RealmConfig(
rp_id="company.com",
auth_host="https://auth.company.com",
origins=["https://auth.company.com", "https://app.com"],
origins=["https://auth.company.com"],
related_origins=["https://app.com"],
),
RealmConfig(rp_id="pro.com"),
]
@@ -169,7 +171,7 @@ class TestValidateConfig:
realms=[
RealmConfig(
rp_id="company.com",
origins=[f"https://app{i}.com" for i in range(5)],
related_origins=[f"https://app{i}.com" for i in range(5)],
)
]
)
@@ -180,7 +182,31 @@ class TestValidateConfig:
realms=[
RealmConfig(
rp_id="company.com",
origins=[f"https://app{i}.com" for i in range(6)],
related_origins=[f"https://app{i}.com" 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"])
]
)
)
def test_related_origin_inside_own_realm_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"]
)
]
)
@@ -194,7 +220,7 @@ class TestValidateConfig:
RealmConfig(rp_id="a.com", auth_host="https://auth.a.com"),
RealmConfig(
rp_id="b.com",
origins=["https://auth.a.com"],
related_origins=["https://auth.a.com"],
),
]
)
@@ -205,7 +231,9 @@ class TestValidateConfig:
realms.validate_config(
Config(
realms=[
RealmConfig(rp_id="a.com", origins=["https://app.b.com"]),
RealmConfig(
rp_id="a.com", related_origins=["https://app.b.com"]
),
RealmConfig(rp_id="b.com"),
]
)
@@ -223,6 +251,62 @@ class TestValidateConfig:
)
# -------------------------------------------------------------------------
# Origin validation semantics (Passkey)
# -------------------------------------------------------------------------
class TestOriginValidation:
"""In-domain allow-list and related origins are separate concerns."""
def test_default_allows_whole_subtree(self):
p = Passkey(rp_id="example.com")
assert p.validate_origin("https://example.com") == "https://example.com"
assert p.validate_origin("https://app.example.com")
with pytest.raises(ValueError, match="not allowed"):
p.validate_origin("https://other.com")
def test_allow_list_restricts_subtree(self):
p = Passkey(rp_id="example.com", origins=["https://app.example.com"])
assert p.validate_origin("https://app.example.com")
with pytest.raises(ValueError, match="not allowed"):
p.validate_origin("https://www.example.com")
with pytest.raises(ValueError, match="not allowed"):
p.validate_origin("https://example.com")
def test_related_origins_are_additive(self):
p = Passkey(rp_id="example.com", related_origins=["https://app2.com"])
assert p.validate_origin("https://app.example.com") # subtree stays open
assert p.validate_origin("https://app2.com")
with pytest.raises(ValueError, match="not allowed"):
p.validate_origin("https://other.com")
def test_related_origins_combined_with_allow_list(self):
p = Passkey(
rp_id="example.com",
origins=["https://app.example.com"],
related_origins=["https://app2.com"],
)
assert p.validate_origin("https://app.example.com")
assert p.validate_origin("https://app2.com")
with pytest.raises(ValueError, match="not allowed"):
p.validate_origin("https://www.example.com")
def test_constructor_rejects_mixed_up_fields(self):
with pytest.raises(ValueError, match="related origin"):
Passkey(rp_id="example.com", origins=["https://app2.com"])
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)
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
with pytest.raises(ValueError, match="not allowed"):
p.validate_origin("https://www.company.com") # not allow-listed
# -------------------------------------------------------------------------
# ASGI dispatch
# -------------------------------------------------------------------------