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