Single origins table per domain; explicit origins semantics

DomainConfig.related is gone: origins holds both in-domain sign-in sites
and related origins, classified by whether the entry lies within the
rp-id. Misfiling is impossible by construction, so validation/sanitize
lose their reclassification paths.

Origins are now always explicit: an empty table allows nothing (a
related-only domain is a valid configuration). Plain '*' is rejected —
wildcards must be under the rp-id ('*.{rp-id}'). New databases, added
domains and legacy conversions seed '*.{rp-id}' (legacy empty origins
meant allow-all). Passkey's implicit allow-all default is gone; the
admin API takes a single origins map and the lockout guard refuses
emptying the table on the domain in use.
This commit is contained in:
2026-09-07 14:40:37 +00:00
parent 93742ecdcf
commit a6138d97f9
13 changed files with 364 additions and 327 deletions
+5 -1
View File
@@ -94,7 +94,11 @@ async def test_db() -> AsyncGenerator[DB]:
data,
org_name="Test Organization",
admin_name="Test Admin",
config=Config(domains={TEST_RP_ID: DomainConfig()}),
config=Config(
domains={
TEST_RP_ID: DomainConfig(origins={f"*.{TEST_RP_ID}": True})
}
),
)
await kanta.open()
+46 -20
View File
@@ -1828,8 +1828,8 @@ class TestDomains:
assert len(data) == 1
domain = data[0]
assert domain["rp_id"] == "localhost"
assert domain["origins"] == {}
assert domain["related"] == {}
assert domain["origins"] == {"*.localhost": True}
assert "related" not in domain
assert domain["auth_host"] is None
assert domain["site_url"] == "http://localhost:4401"
@@ -1900,26 +1900,39 @@ class TestDomains:
self,
client: httpx.AsyncClient,
session_token: str,
test_user,
test_credential,
):
"""With no origins left, site_url must not keep the removed auth host."""
headers = await self._set_auth_host(
client, session_token, test_user, test_credential
"""Emptying a domain's origins table must not keep the removed auth
host in derived URLs. Only possible on a domain other than the one
in use — the lockout guard refuses it there."""
headers = {**auth_headers(session_token), "Host": "localhost:4401"}
r = await client.post(
"/auth/api/admin/domains/",
json={
"rp_id": "example.com",
"origins": {
"auth.example.com": {"auth_host": True},
"app.example.com": True,
},
},
headers=headers,
)
assert r.status_code == 200, r.text
domain = domains.registry().get("example.com")
assert domain.own_auth_host == "auth.example.com"
assert "auth.example.com" in domain.site_url
r = await client.patch(
"/auth/api/admin/domains/localhost",
"/auth/api/admin/domains/example.com",
json={"rp_name": "", "origins": {}},
headers=headers,
)
assert r.status_code == 200, r.text
domain = domains.registry().get("localhost")
domain = domains.registry().get("example.com")
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
assert "auth.example.com" not in domain.site_url
assert "auth.example.com" not in domain.auth_site_url
@pytest.mark.asyncio
async def test_create_and_delete_domain(
@@ -1931,8 +1944,7 @@ class TestDomains:
json={
"rp_id": "example.com",
"rp_name": "Example",
"origins": {"app.example.com": True},
"related": {"unrelated-site.com": True},
"origins": {"app.example.com": True, "unrelated-site.com": True},
},
headers=headers,
)
@@ -1943,7 +1955,12 @@ class TestDomains:
assert set(domains_list) == {"localhost", "example.com"}
created = domains_list["example.com"]
assert created["rp_name"] == "Example"
assert created["related"] == {"unrelated-site.com": True}
# In-domain and related origins live in one table; classification
# is derived from the rp-id
assert created["origins"] == {
"app.example.com": True,
"unrelated-site.com": True,
}
r = await client.delete("/auth/api/admin/domains/example.com", headers=headers)
assert r.status_code == 200, r.text
@@ -1986,29 +2003,29 @@ class TestDomains:
# Related origin host may not collide across domains
r = await client.post(
"/auth/api/admin/domains/",
json={"rp_id": "example.com", "related": {"shared-app.com": True}},
json={"rp_id": "example.com", "origins": {"shared-app.com": True}},
headers=headers,
)
assert r.status_code == 200
r = await client.post(
"/auth/api/admin/domains/",
json={"rp_id": "other.com", "related": {"shared-app.com": True}},
json={"rp_id": "other.com", "origins": {"shared-app.com": True}},
headers=headers,
)
assert r.status_code == 400
# Cross-domain entries are rejected from the in-domain origins list
# Cross-domain entries are related origins — accepted in the same table
r = await client.post(
"/auth/api/admin/domains/",
json={"rp_id": "another.com", "origins": {"elsewhere.com": True}},
headers=headers,
)
assert r.status_code == 400
assert r.status_code == 200
# In-domain entries are rejected from the related origins list
# Plain '*' is rejected — wildcards must be explicit ('*.another.com')
r = await client.post(
"/auth/api/admin/domains/",
json={"rp_id": "another.com", "related": {"app.another.com": True}},
json={"rp_id": "star.com", "origins": {"*": True}},
headers=headers,
)
assert r.status_code == 400
@@ -2060,6 +2077,15 @@ class TestDomains:
assert r.status_code == 400
assert "lock you out" in r.text
# Emptying the origins table entirely is likewise a lockout
r = await client.patch(
"/auth/api/admin/domains/localhost",
json={"rp_name": "", "origins": {}},
headers=headers,
)
assert r.status_code == 400
assert "lock you out" in r.text
# Allow-list including the current host is fine
r = await client.patch(
"/auth/api/admin/domains/localhost",
+2 -2
View File
@@ -87,7 +87,7 @@ def test_init_defaults(run_cli, tmp_path):
config = stored_config(tmp_path)
assert list(config.domains) == ["localhost"]
assert config.domains["localhost"].rp_name is None
assert config.domains["localhost"].origins == {}
assert config.domains["localhost"].origins == {"*.localhost": True}
assert config.listen is None
@@ -97,7 +97,7 @@ def test_init_full_options(run_cli, tmp_path):
config = stored_config(tmp_path)
domain = config.domains["example.com"]
assert domain.rp_name == "Example Corp"
assert domain.origins == {}
assert domain.origins == {"*.example.com": True}
assert config.listen == ["4402"]
+130 -82
View File
@@ -54,10 +54,12 @@ ROR_CONFIG = Config(
domains={
"company.com": DomainConfig(
rp_name="Company",
origins={"auth.company.com": OriginEntry(auth_host=True)},
related={"app.com": True},
origins={
"auth.company.com": OriginEntry(auth_host=True),
"app.com": True, # related origin (outside the rp-id domain)
},
),
"pro.com": DomainConfig(rp_name="Pro"),
"pro.com": DomainConfig(rp_name="Pro", origins={"*.pro.com": True}),
}
)
@@ -183,12 +185,20 @@ class TestValidateConfig:
def test_valid(self):
domains.validate_config(ROR_CONFIG)
def test_empty_origins_table_is_valid(self):
"""No origins at all: nothing of the domain is allowed, but the
configuration itself is legal (e.g. a related-only domain)."""
domains.validate_config(Config(domains={"a.com": DomainConfig()}))
domains.validate_config(
Config(domains={"a.com": DomainConfig(origins={"b.com": True})})
)
def test_related_origin_cap(self):
domains.validate_config(
Config(
domains={
"company.com": DomainConfig(
related={f"app{i}.com": True for i in range(5)}
origins={f"app{i}.com": True for i in range(5)}
)
}
)
@@ -198,38 +208,35 @@ class TestValidateConfig:
Config(
domains={
"company.com": DomainConfig(
related={f"app{i}.com": True for i in range(6)}
origins={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"):
def test_star_origin_rejected(self):
"""Plain '*' suggests 'anything goes' — the wildcard must be
explicit and under the rp-id."""
with pytest.raises(ValueError, match="not allowed"):
domains.validate_config(
Config(domains={"a.com": DomainConfig(origins={"elsewhere.com": True})})
Config(domains={"a.com": DomainConfig(origins={"*": True})})
)
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"):
domains.validate_config(
Config(domains={"a.com": DomainConfig(related={"app.a.com": True})})
)
def test_subdomain_entry_is_in_domain(self):
"""An entry within the rp-id domain is an ordinary in-domain
sign-in site, never a related origin."""
config = Config(domains={"a.com": DomainConfig(origins={"app.a.com": True})})
domains.validate_config(config)
reg = build_registry(config.domains)
assert reg.get("a.com").related_origins == []
def test_wildcard_related_origin_rejected(self):
"""ROR entries are always individual origins; wildcards are meaningless."""
with pytest.raises(ValueError, match="wildcard"):
domains.validate_config(
Config(domains={"a.com": DomainConfig(related={"*.b.com": True})})
)
def test_wildcard_origin_in_domain_accepted(self):
def test_wildcard_outside_rp_id_rejected(self):
"""Related origins are individual hosts; wildcards must stay within
the rp-id domain."""
domains.validate_config(
Config(domains={"a.com": DomainConfig(origins={"*.a.com": True})})
)
with pytest.raises(ValueError, match="outside the rp-id domain"):
with pytest.raises(ValueError, match="wildcard outside the rp-id"):
domains.validate_config(
Config(domains={"a.com": DomainConfig(origins={"*.b.com": True})})
)
@@ -246,16 +253,30 @@ class TestValidateConfig:
)
)
def test_star_origin_accepted_not_auth_host(self):
domains.validate_config(
Config(domains={"a.com": DomainConfig(origins={"*": True})})
)
def test_related_auth_host_rejected(self):
"""The auth host is always in-domain; a related origin cannot
carry the mark."""
with pytest.raises(ValueError, match="cannot be the auth host"):
domains.validate_config(
Config(
domains={
"a.com": DomainConfig(
origins={"*": OriginEntry(auth_host=True)}
origins={"auth.b.com": OriginEntry(auth_host=True)}
)
}
)
)
def test_several_auth_hosts_rejected(self):
with pytest.raises(ValueError, match="several origins as the auth host"):
domains.validate_config(
Config(
domains={
"a.com": DomainConfig(
origins={
"auth.a.com": OriginEntry(auth_host=True),
"login.a.com": OriginEntry(auth_host=True),
}
)
}
)
@@ -269,7 +290,7 @@ class TestValidateConfig:
"a.com": DomainConfig(
origins={"auth.a.com": OriginEntry(auth_host=True)}
),
"b.com": DomainConfig(related={"auth.a.com": True}),
"b.com": DomainConfig(origins={"auth.a.com": True}),
}
)
)
@@ -280,7 +301,7 @@ class TestValidateConfig:
*is* a configured rp-id always serves its own domain)."""
config = Config(
domains={
"a.com": DomainConfig(related={"app.b.com": True}),
"a.com": DomainConfig(origins={"app.b.com": True}),
"b.com": DomainConfig(),
}
)
@@ -295,8 +316,8 @@ class TestValidateConfig:
domains.validate_config(
Config(
domains={
"a.com": DomainConfig(related={"app.b.com": True}),
"c.com": DomainConfig(related={"app.b.com": True}),
"a.com": DomainConfig(origins={"app.b.com": True}),
"c.com": DomainConfig(origins={"app.b.com": True}),
"b.com": DomainConfig(),
}
)
@@ -305,8 +326,8 @@ class TestValidateConfig:
domains.validate_config(
Config(
domains={
"a.com": DomainConfig(related={"shared.com": True}),
"c.com": DomainConfig(related={"shared.com": True}),
"a.com": DomainConfig(origins={"shared.com": True}),
"c.com": DomainConfig(origins={"shared.com": True}),
}
)
)
@@ -333,16 +354,37 @@ class TestValidateConfig:
class TestSanitizeConfig:
"""Serving never fails on stored config problems; it degrades + warns."""
def test_cross_domain_origin_moved_to_related(self):
def test_cross_domain_origin_stays_as_related(self):
"""An out-of-domain entry simply IS a related origin — no repair
needed, no warning."""
config, warnings = domains.sanitize_config(
Config(domains={"localhost": DomainConfig(origins={"example.com": True})})
)
domain = config.domains["localhost"]
assert domain.origins == {}
assert domain.related == {"example.com": True}
assert any("related origin" in w for w in warnings)
assert config.domains["localhost"].origins == {"example.com": True}
assert not warnings
domains.validate_config(config) # sanitized config is strict-clean
def test_star_origin_rewritten_explicit(self):
"""Branch-era '*' shorthand is rewritten to '*.{rp-id}'; an auth
mark on it is cleared."""
config, warnings = domains.sanitize_config(
Config(domains={"a.com": DomainConfig(origins={"*": True})})
)
assert config.domains["a.com"].origins == {"*.a.com": True}
assert any("'*.'" in w or "*." in w for w in warnings)
domains.validate_config(config)
config, warnings = domains.sanitize_config(
Config(
domains={
"a.com": DomainConfig(origins={"*": OriginEntry(auth_host=True)})
}
)
)
assert config.domains["a.com"].origins == {"*.a.com": True}
assert any("mark cleared" in w for w in warnings)
domains.validate_config(config)
def test_malformed_origin_dropped(self):
config, warnings = domains.sanitize_config(
Config(domains={"a.com": DomainConfig(origins={"https://": True})})
@@ -357,17 +399,11 @@ class TestSanitizeConfig:
assert list(config.domains) == ["ok.com"]
assert any("dropped" in w for w in warnings)
def test_related_inside_own_domain_dropped(self):
config, _ = domains.sanitize_config(
Config(domains={"a.com": DomainConfig(related={"app.a.com": True})})
)
assert config.domains["a.com"].related == {}
def test_wildcard_related_origin_dropped(self):
def test_wildcard_outside_rp_id_dropped(self):
config, warnings = domains.sanitize_config(
Config(domains={"a.com": DomainConfig(related={"*.b.com": True})})
Config(domains={"a.com": DomainConfig(origins={"*.b.com": True})})
)
assert config.domains["a.com"].related == {}
assert config.domains["a.com"].origins == {}
assert any("wildcard" in w for w in warnings)
domains.validate_config(config) # sanitized config is strict-clean
@@ -376,16 +412,17 @@ class TestSanitizeConfig:
Config(
domains={
"a.com": DomainConfig(
related={f"app{i}.com": True for i in range(6)}
origins={f"app{i}.com": True for i in range(6)}
)
}
)
)
assert len(config.domains["a.com"].related) == 5
assert len(config.domains["a.com"].origins) == 5
assert any("maximum" in w for w in warnings)
domains.validate_config(config)
def test_auth_host_outside_domain_becomes_related(self):
"""An auth-marked origin outside the rp-id degrades to a related origin."""
def test_related_auth_host_mark_cleared(self):
"""An auth mark on a related (out-of-domain) origin is cleared."""
config, warnings = domains.sanitize_config(
Config(
domains={
@@ -395,10 +432,9 @@ class TestSanitizeConfig:
}
)
)
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)
assert config.domains["a.com"].origins == {"auth.b.com": True}
assert any("cannot be the auth host" in w for w in warnings)
domains.validate_config(config)
def test_auth_host_colliding_with_rp_id_cleared(self):
config, warnings = domains.sanitize_config(
@@ -419,25 +455,25 @@ class TestSanitizeConfig:
config, _ = domains.sanitize_config(
Config(
domains={
"a.com": DomainConfig(related={"app.b.com": True}),
"a.com": DomainConfig(origins={"app.b.com": True}),
"b.com": DomainConfig(),
}
)
)
assert config.domains["a.com"].related == {"app.b.com": True}
assert config.domains["a.com"].origins == {"app.b.com": True}
def test_related_claimed_twice_first_wins(self):
"""Two non-owner domains claiming one related host: first wins."""
config, warnings = domains.sanitize_config(
Config(
domains={
"a.com": DomainConfig(related={"shared.com": True}),
"b.com": DomainConfig(related={"shared.com": True}),
"a.com": DomainConfig(origins={"shared.com": True}),
"b.com": DomainConfig(origins={"shared.com": True}),
}
)
)
assert config.domains["a.com"].related == {"shared.com": True}
assert config.domains["b.com"].related == {}
assert config.domains["a.com"].origins == {"shared.com": True}
assert config.domains["b.com"].origins == {}
assert any("first domain wins" in w for w in warnings)
def test_no_domains_is_fatal(self):
@@ -446,10 +482,8 @@ class TestSanitizeConfig:
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
def test_build_serves_related_origin(self):
reg = build_registry({"localhost": DomainConfig(origins={"example.com": True})})
assert reg.warnings
domain = reg.get("localhost")
assert domain.related_origins == ["https://example.com"]
domain.passkey.validate_origin("https://example.com")
@@ -461,14 +495,15 @@ class TestSanitizeConfig:
class TestOriginValidation:
"""In-domain allow-list and related origins are separate concerns."""
"""The allow-list is explicit: empty allows nothing, wildcards cover
subtrees, related origins are additive exact matches."""
def test_default_allows_whole_subtree(self):
def test_empty_allow_list_denies_all(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")
p.validate_origin("https://example.com")
with pytest.raises(ValueError, match="not allowed"):
p.validate_origin("https://app.example.com")
def test_allow_list_restricts_subtree(self):
p = Passkey(rp_id="example.com", origins=["https://app.example.com"])
@@ -480,8 +515,9 @@ class TestOriginValidation:
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://app.example.com") # nothing in-domain listed
with pytest.raises(ValueError, match="not allowed"):
p.validate_origin("https://other.com")
@@ -500,19 +536,13 @@ class TestOriginValidation:
with pytest.raises(ValueError, match="not allowed"):
p.validate_origin("http://app.example.com:8080")
def test_star_entry_is_wildcard_shorthand(self):
"""The bare '*' entry is shorthand for '*.{rp-id}' — https only."""
p = Passkey(rp_id="example.com", origins=["*"])
assert p.validate_origin("https://example.com")
assert p.validate_origin("https://app.example.com")
with pytest.raises(ValueError, match="not allowed"):
p.validate_origin("http://app.example.com:8080")
with pytest.raises(ValueError, match="not allowed"):
p.validate_origin("https://other.com")
def test_star_entry_rejected(self):
with pytest.raises(ValueError, match="Invalid origin"):
Passkey(rp_id="example.com", origins=["*"])
def test_localhost_wildcard_matches_any_scheme_and_port(self):
"""Under localhost, wildcards match any scheme and any port."""
p = Passkey(rp_id="localhost", origins=["*"])
p = Passkey(rp_id="localhost", origins=["*.localhost"])
assert p.validate_origin("http://localhost:8080")
assert p.validate_origin("http://app.localhost:3000")
assert p.validate_origin("https://localhost")
@@ -779,6 +809,14 @@ def _read_db(path) -> DB:
return asyncio.run(_read())
async def _write_legacy(src_file, config: LegacyConfig) -> None:
kanta = Kanta(str(src_file), LegacyDB())
await kanta.open()
with kanta.transaction("test:seed"):
kanta.data.config = config
await kanta.close()
class TestLegacyConversion:
def test_convert_stamps_domain_everywhere(self, tmp_path):
src = tmp_path / "example.com.paskiadb"
@@ -830,6 +868,16 @@ class TestLegacyConversion:
# The legacy OIDC provider carries over as the instance-global one
assert converted.oidc.key == b"legacy-signing-key"
def test_convert_empty_origins_seeds_wildcard(self, tmp_path):
"""Legacy 'no origins' meant the whole rp-id domain; the new format
makes that explicit as '*.{rp-id}'."""
src_file = tmp_path / "main.db"
asyncio.run(
_write_legacy(src_file, LegacyConfig(rp_id="example.com", rp_name="Ex"))
)
config = convert_legacy_database(src_file, tmp_path / "paskia.kantadb")
assert config.domains["example.com"].origins == {"*.example.com": True}
# -------------------------------------------------------------------------
# Transaction log censoring