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:
+89
-5
@@ -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
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user