Wildcard origins follow the shell-glob convention: **. for apex+any depth, *. for one level

'**.example.com' covers the apex and subdomains at any depth;
'*.example.com' covers exactly one subdomain level (neither apex nor
deeper) — analogous to permission scope wildcards, and sidestepping the
DNS/TLS/nginx ambiguity around '*.'. This also allows excluding the apex
where wanted. The seeded/default entry becomes '**.{rp-id}' (init,
add-domain, legacy empty-origins conversion, branch-era '*' sanitize
rewrite).
This commit is contained in:
2026-09-07 15:15:30 +00:00
parent 7726203382
commit 52f3b26630
11 changed files with 108 additions and 62 deletions
+5 -5
View File
@@ -82,13 +82,13 @@ test.describe('Multi-domain E2E', () => {
const domains = await list.json() const domains = await list.json()
expect(domains.map((r: any) => r.rp_id).sort()).toEqual(['localhost', 'test.localhost']) expect(domains.map((r: any) => r.rp_id).sort()).toEqual(['localhost', 'test.localhost'])
const localhostDomain = domains.find((r: any) => r.rp_id === 'localhost') const localhostDomain = domains.find((r: any) => r.rp_id === 'localhost')
expect(localhostDomain.origins).toEqual({ '*.localhost': true }) expect(localhostDomain.origins).toEqual({ '**.localhost': true })
// Add a related origin (unrelated domain) to the localhost domain — // Add a related origin (unrelated domain) to the localhost domain —
// same origins table; classification is derived from the rp-id // same origins table; classification is derived from the rp-id
const patch = await page.request.patch(`${baseUrl}/auth/api/admin/domains/localhost`, { const patch = await page.request.patch(`${baseUrl}/auth/api/admin/domains/localhost`, {
headers, headers,
data: { rp_name: '', origins: { '*.localhost': true, 'app.example.com': true } }, data: { rp_name: '', origins: { '**.localhost': true, 'app.example.com': true } },
}) })
expect(patch.ok()).toBeTruthy() expect(patch.ok()).toBeTruthy()
@@ -109,7 +109,7 @@ test.describe('Multi-domain E2E', () => {
// Restore: back to the pristine seeded state for later tests // Restore: back to the pristine seeded state for later tests
const restore = await page.request.patch(`${baseUrl}/auth/api/admin/domains/localhost`, { const restore = await page.request.patch(`${baseUrl}/auth/api/admin/domains/localhost`, {
headers, headers,
data: { rp_name: '', origins: { '*.localhost': true } }, data: { rp_name: '', origins: { '**.localhost': true } },
}) })
expect(restore.ok()).toBeTruthy() expect(restore.ok()).toBeTruthy()
const after = await page.request.get(`${baseUrl}/.well-known/webauthn`) const after = await page.request.get(`${baseUrl}/.well-known/webauthn`)
@@ -138,7 +138,7 @@ test.describe('Multi-domain E2E', () => {
// *.localhost hostname to loopback, so the auth host is reachable. // *.localhost hostname to loopback, so the auth host is reachable.
const patch = await page.request.patch(`${baseUrl}/auth/api/admin/domains/test.localhost`, { const patch = await page.request.patch(`${baseUrl}/auth/api/admin/domains/test.localhost`, {
headers, headers,
data: { rp_name: '', origins: { [`http://${authHost}`]: { auth_host: true }, '*.test.localhost': true } }, data: { rp_name: '', origins: { [`http://${authHost}`]: { auth_host: true }, '**.test.localhost': true } },
}) })
expect(patch.ok()).toBeTruthy() expect(patch.ok()).toBeTruthy()
@@ -168,7 +168,7 @@ test.describe('Multi-domain E2E', () => {
// test.localhost, and an empty table would allow nothing) // test.localhost, and an empty table would allow nothing)
const restore = await page.request.patch(`${baseUrl}/auth/api/admin/domains/test.localhost`, { const restore = await page.request.patch(`${baseUrl}/auth/api/admin/domains/test.localhost`, {
headers, headers,
data: { rp_name: '', origins: { '*.test.localhost': true } }, data: { rp_name: '', origins: { '**.test.localhost': true } },
}) })
expect(restore.ok()).toBeTruthy() expect(restore.ok()).toBeTruthy()
} }
+3 -3
View File
@@ -91,7 +91,7 @@ def _init_add_domain(db_path: Path, rp_id: str, rp_name: str | None, listen) ->
if listen is not None: if listen is not None:
data.config.listen = listen data.config.listen = listen
return f"Updated domain {rp_id}" return f"Updated domain {rp_id}"
new = DomainConfig(rp_name=rp_name, origins={f"*.{rp_id}": True}) new = DomainConfig(rp_name=rp_name, origins={f"**.{rp_id}": True})
try: try:
validate_config( validate_config(
Config( Config(
@@ -134,13 +134,13 @@ def cmd_init(args: argparse.Namespace) -> None:
) )
# Only rp-id and rp-name are bootstrap-time configuration; the new # Only rp-id and rp-name are bootstrap-time configuration; the new
# domain starts with its whole subtree allowed ('*.{rp-id}') and # domain starts with its whole subtree allowed ('**.{rp-id}') and
# everything else (origin allow-list, auth host, related domains) is # everything else (origin allow-list, auth host, related domains) is
# set up afterwards via the admin interface. The bootstrap rp-name # set up afterwards via the admin interface. The bootstrap rp-name
# exists so the very first admin registration ceremony already shows # exists so the very first admin registration ceremony already shows
# the correct name. # the correct name.
config = Config( config = Config(
domains={rp_id: DomainConfig(rp_name=rp_name, origins={f"*.{rp_id}": True})}, domains={rp_id: DomainConfig(rp_name=rp_name, origins={f"**.{rp_id}": True})},
listen=listen, listen=listen,
) )
try: try:
+1 -1
View File
@@ -123,7 +123,7 @@ def convert_legacy_database(src: Path, dst: Path) -> Config:
if not origins: if not origins:
# Legacy semantics: no origins configured = the whole rp-id domain # Legacy semantics: no origins configured = the whole rp-id domain
# allowed. The new format requires explicit entries. # allowed. The new format requires explicit entries.
origins[f"*.{rp_id}"] = True origins[f"**.{rp_id}"] = True
new_config = Config( new_config = Config(
domains={rp_id: DomainConfig(rp_name=old.config.rp_name, origins=origins)}, domains={rp_id: DomainConfig(rp_name=old.config.rp_name, origins=origins)},
+7 -6
View File
@@ -630,11 +630,12 @@ class DomainConfig(msgspec.Struct, omit_defaults=True):
domain are in-domain sign-in sites, entries outside it are related domain are in-domain sign-in sites, entries outside it are related
origins (WebAuthn Related Origin Requests — individual hosts only, origins (WebAuthn Related Origin Requests — individual hosts only,
no wildcards). Keys are hosts without the https:// scheme no wildcards). Keys are hosts without the https:// scheme
("app.example.com"), wildcard patterns under the rp-id ("app.example.com"), wildcard patterns under the rp-id following the
("*.example.com" — the base domain and its subdomains over https only, shell-glob convention ("**.example.com" — the base domain and its
any scheme and port under localhost), or full origins subdomains at any depth; "*.example.com" — exactly one subdomain
("http://localhost:8080", "https://app2.com"). An empty dict means level; https only, any scheme and port under localhost), or full
nothing is allowed — list sites explicitly. Ordering carries no origins ("http://localhost:8080", "https://app2.com"). An empty dict
means nothing is allowed — list sites explicitly. Ordering carries no
meaning — display order is decided by the UI. meaning — display order is decided by the UI.
""" """
@@ -651,7 +652,7 @@ class Config(msgspec.Struct, omit_defaults=True):
domains: dict[str, DomainConfig] = msgspec.field( domains: dict[str, DomainConfig] = msgspec.field(
default_factory=lambda: { default_factory=lambda: {
"localhost": DomainConfig(origins={"*.localhost": True}) "localhost": DomainConfig(origins={"**.localhost": True})
} }
) )
listen: list[str] | None = None # Process-global listen endpoints listen: list[str] | None = None # Process-global listen endpoints
+7 -6
View File
@@ -48,7 +48,8 @@ def origin_key(origin: str) -> str:
""" """
key = origin.removeprefix("https://").rstrip("/") key = origin.removeprefix("https://").rstrip("/")
if hostutil.is_wildcard_pattern(key): if hostutil.is_wildcard_pattern(key):
return "*." + key[2:].rstrip(".").lower() prefix = "**." if key.startswith("**.") else "*."
return prefix + key[len(prefix) :].rstrip(".").lower()
if "://" not in key: if "://" not in key:
key = key.rstrip(".") key = key.rstrip(".")
return key.lower() return key.lower()
@@ -213,10 +214,10 @@ def validate_config(
is_auth = isinstance(props, OriginEntry) and props.auth_host is_auth = isinstance(props, OriginEntry) and props.auth_host
if key == "*": if key == "*":
raise ValueError( raise ValueError(
f"Origin '*' is not allowed — list '*.{rp_id}' explicitly" f"Origin '*' is not allowed — list '**.{rp_id}' explicitly"
) )
if hostutil.is_wildcard_pattern(key): if hostutil.is_wildcard_pattern(key):
base = key[2:].rstrip(".") base = hostutil.wildcard_base(key)
if not base or not hostutil.is_subdomain(base, rp_id): if not base or not hostutil.is_subdomain(base, rp_id):
raise ValueError( raise ValueError(
f"Origin '{key}' is a wildcard outside the rp-id " f"Origin '{key}' is a wildcard outside the rp-id "
@@ -314,13 +315,13 @@ def sanitize_config(
props = True # canonicalize junk/empty entries to presence-only props = True # canonicalize junk/empty entries to presence-only
if key == "*": if key == "*":
warn( warn(
f"Domain '{rp_id}': origin '*' rewritten as '*.{rp_id}'" f"Domain '{rp_id}': origin '*' rewritten as '**.{rp_id}'"
+ (" — auth host mark cleared" if is_auth else "") + (" — auth host mark cleared" if is_auth else "")
) )
origins[f"*.{rp_id}"] = True origins[f"**.{rp_id}"] = True
continue continue
if hostutil.is_wildcard_pattern(key): if hostutil.is_wildcard_pattern(key):
base = key[2:].rstrip(".") base = hostutil.wildcard_base(key)
if not base or not hostutil.is_subdomain(base, rp_id): if not base or not hostutil.is_subdomain(base, rp_id):
warn( warn(
f"Domain '{rp_id}': origin '{key}' is a wildcard " f"Domain '{rp_id}': origin '{key}' is a wildcard "
+27 -13
View File
@@ -56,12 +56,15 @@ class Passkey:
rp_id: Your security domain (e.g. "example.com") rp_id: Your security domain (e.g. "example.com")
rp_name: The relying party display name (e.g. "Example App"). May be shown in authenticators. rp_name: The relying party display name (e.g. "Example App"). May be shown in authenticators.
origins: Allow-list of sign-in site origins within the rp-id domain origins: Allow-list of sign-in site origins within the rp-id domain
(e.g. ["https://app.example.com"]); wildcard patterns like (e.g. ["https://app.example.com"]); wildcard patterns
"*.example.com" match the base domain and its subdomains follow the shell-glob convention: "**.example.com" matches
over https only — except under localhost ("*.localhost"), the base domain and its subdomains at any depth, while
which matches any scheme and any port. Exact entries match "*.example.com" matches exactly one subdomain level —
scheme, host and port. An empty list (the default) allows over https only, except under localhost
nothing — pass ["*.{rp-id}"] to allow the whole domain. ("**.localhost"), which matches any scheme and any port.
Exact entries match scheme, host and port. An empty list
(the default) allows nothing — pass ["**.{rp-id}"] to
allow the whole domain.
related_origins: Origins on unrelated domains that may assert this related_origins: Origins on unrelated domains that may assert this
rp-id (WebAuthn Related Origin Requests). Always additive. rp-id (WebAuthn Related Origin Requests). Always additive.
supported_pub_key_algs: List of supported COSE algorithms (default is EDDSA, ECDSA_SHA_256, RSASSA_PKCS1_v1_5_SHA_256). supported_pub_key_algs: List of supported COSE algorithms (default is EDDSA, ECDSA_SHA_256, RSASSA_PKCS1_v1_5_SHA_256).
@@ -122,19 +125,30 @@ class Passkey:
def _allowlisted(self, origin: str) -> bool: def _allowlisted(self, origin: str) -> bool:
"""Check an in-domain origin against the allow-list. """Check an in-domain origin against the allow-list.
An entry matches exactly; a wildcard pattern ('*.example.com') An entry matches exactly. A wildcard pattern matches hostnames
matches the base domain and any subdomain of it over https only, under its base: '**.example.com' covers the base domain itself and
except under localhost ('*.localhost'), which matches any scheme subdomains at any depth, while '*.example.com' covers exactly one
and any port. subdomain level (neither the apex nor deeper levels) — the
shell-glob convention, analogous to permission scope wildcards.
Wildcards match over https only, except under localhost
('**.localhost'), which matches any scheme and any port.
""" """
if origin in self.allowed_origins: if origin in self.allowed_origins:
return True return True
hostname = hostutil.origin_hostname(origin) hostname = hostutil.origin_hostname(origin)
for entry in self.allowed_origins: for entry in self.allowed_origins:
if not hostutil.is_wildcard_pattern(entry): base = hostutil.wildcard_base(entry)
if not base or not hostname:
continue continue
base = entry[2:].rstrip(".") if entry.startswith("**."):
if not hostutil.is_subdomain(hostname, base): matched = hostutil.is_subdomain(hostname, base)
else:
# Exactly one subdomain level below the base
matched = (
hostname.endswith(f".{base}")
and "." not in hostname[: -len(base) - 1]
)
if not matched:
continue continue
if hostutil.is_subdomain(base, "localhost"): if hostutil.is_subdomain(base, "localhost"):
return True # localhost: any scheme, any port return True # localhost: any scheme, any port
+17 -6
View File
@@ -19,15 +19,26 @@ def validate_rp_id(rp_id: str) -> None:
def is_wildcard_pattern(value: str) -> bool: def is_wildcard_pattern(value: str) -> bool:
"""Check whether an origins entry is a wildcard pattern like '*.example.com'.""" """Check whether an origins entry is a wildcard pattern like
return value.startswith("*.") '*.example.com' (one subdomain level) or '**.example.com' (the base
domain and any depth of subdomains)."""
return value.startswith("*.") or value.startswith("**.")
def wildcard_base(pattern: str) -> str | None:
"""Base domain of a wildcard pattern; None if not a wildcard."""
if pattern.startswith("**."):
return pattern[3:].rstrip(".") or None
if pattern.startswith("*."):
return pattern[2:].rstrip(".") or None
return None
def normalize_origin(origin: str) -> str: def normalize_origin(origin: str) -> str:
"""Normalize an origin URL by adding https:// if no scheme is present, removing trailing slashes. """Normalize an origin URL by adding https:// if no scheme is present, removing trailing slashes.
Wildcard patterns ('*.example.com') pass through unchanged — they are Wildcard patterns ('*.example.com', '**.example.com') pass through
allow-list entries, not concrete origins. unchanged — they are allow-list entries, not concrete origins.
""" """
if is_wildcard_pattern(origin): if is_wildcard_pattern(origin):
return origin.rstrip("/.") return origin.rstrip("/.")
@@ -41,8 +52,8 @@ def origin_hostname(origin: str) -> str | None:
For wildcard patterns the base domain is returned. For wildcard patterns the base domain is returned.
""" """
if is_wildcard_pattern(origin): if base := wildcard_base(origin):
return origin[2:].rstrip(".").lower() or None return base.lower()
return urlparse(origin).hostname return urlparse(origin).hostname
+1 -1
View File
@@ -96,7 +96,7 @@ async def test_db() -> AsyncGenerator[DB]:
admin_name="Test Admin", admin_name="Test Admin",
config=Config( config=Config(
domains={ domains={
TEST_RP_ID: DomainConfig(origins={f"*.{TEST_RP_ID}": True}) TEST_RP_ID: DomainConfig(origins={f"**.{TEST_RP_ID}": True})
} }
), ),
) )
+2 -2
View File
@@ -1828,7 +1828,7 @@ class TestDomains:
assert len(data) == 1 assert len(data) == 1
domain = data[0] domain = data[0]
assert domain["rp_id"] == "localhost" assert domain["rp_id"] == "localhost"
assert domain["origins"] == {"*.localhost": True} assert domain["origins"] == {"**.localhost": True}
assert "related" not in domain assert "related" not in domain
assert domain["auth_host"] is None assert domain["auth_host"] is None
assert domain["site_url"] == "http://localhost:4401" assert domain["site_url"] == "http://localhost:4401"
@@ -2022,7 +2022,7 @@ class TestDomains:
) )
assert r.status_code == 200 assert r.status_code == 200
# Plain '*' is rejected — wildcards must be explicit ('*.another.com') # Plain '*' is rejected — wildcards must be explicit ('**.another.com')
r = await client.post( r = await client.post(
"/auth/api/admin/domains/", "/auth/api/admin/domains/",
json={"rp_id": "star.com", "origins": {"*": True}}, json={"rp_id": "star.com", "origins": {"*": True}},
+2 -2
View File
@@ -87,7 +87,7 @@ def test_init_defaults(run_cli, tmp_path):
config = stored_config(tmp_path) config = stored_config(tmp_path)
assert list(config.domains) == ["localhost"] assert list(config.domains) == ["localhost"]
assert config.domains["localhost"].rp_name is None assert config.domains["localhost"].rp_name is None
assert config.domains["localhost"].origins == {"*.localhost": True} assert config.domains["localhost"].origins == {"**.localhost": True}
assert config.listen is None assert config.listen is None
@@ -97,7 +97,7 @@ def test_init_full_options(run_cli, tmp_path):
config = stored_config(tmp_path) config = stored_config(tmp_path)
domain = config.domains["example.com"] domain = config.domains["example.com"]
assert domain.rp_name == "Example Corp" assert domain.rp_name == "Example Corp"
assert domain.origins == {"*.example.com": True} assert domain.origins == {"**.example.com": True}
assert config.listen == ["4402"] assert config.listen == ["4402"]
+36 -17
View File
@@ -59,7 +59,7 @@ ROR_CONFIG = Config(
"app.com": True, # related origin (outside the rp-id domain) "app.com": True, # related origin (outside the rp-id domain)
}, },
), ),
"pro.com": DomainConfig(rp_name="Pro", origins={"*.pro.com": True}), "pro.com": DomainConfig(rp_name="Pro", origins={"**.pro.com": True}),
} }
) )
@@ -232,13 +232,16 @@ class TestValidateConfig:
def test_wildcard_outside_rp_id_rejected(self): def test_wildcard_outside_rp_id_rejected(self):
"""Related origins are individual hosts; wildcards must stay within """Related origins are individual hosts; wildcards must stay within
the rp-id domain.""" the rp-id domain. Both wildcard forms are accepted in-domain."""
domains.validate_config( domains.validate_config(
Config(domains={"a.com": DomainConfig(origins={"*.a.com": True})}) Config(domains={"a.com": DomainConfig(origins={"*.a.com": True})})
) )
domains.validate_config(
Config(domains={"a.com": DomainConfig(origins={"**.a.com": True})})
)
with pytest.raises(ValueError, match="wildcard outside the rp-id"): with pytest.raises(ValueError, match="wildcard outside the rp-id"):
domains.validate_config( domains.validate_config(
Config(domains={"a.com": DomainConfig(origins={"*.b.com": True})}) Config(domains={"a.com": DomainConfig(origins={"**.b.com": True})})
) )
def test_wildcard_auth_host_rejected(self): def test_wildcard_auth_host_rejected(self):
@@ -365,13 +368,13 @@ class TestSanitizeConfig:
domains.validate_config(config) # sanitized config is strict-clean domains.validate_config(config) # sanitized config is strict-clean
def test_star_origin_rewritten_explicit(self): def test_star_origin_rewritten_explicit(self):
"""Branch-era '*' shorthand is rewritten to '*.{rp-id}'; an auth """Branch-era '*' shorthand is rewritten to '**.{rp-id}'; an auth
mark on it is cleared.""" mark on it is cleared."""
config, warnings = domains.sanitize_config( config, warnings = domains.sanitize_config(
Config(domains={"a.com": DomainConfig(origins={"*": True})}) Config(domains={"a.com": DomainConfig(origins={"*": True})})
) )
assert config.domains["a.com"].origins == {"*.a.com": True} assert config.domains["a.com"].origins == {"**.a.com": True}
assert any("'*.'" in w or "*." in w for w in warnings) assert any("**." in w for w in warnings)
domains.validate_config(config) domains.validate_config(config)
config, warnings = domains.sanitize_config( config, warnings = domains.sanitize_config(
@@ -381,7 +384,7 @@ class TestSanitizeConfig:
} }
) )
) )
assert config.domains["a.com"].origins == {"*.a.com": True} assert config.domains["a.com"].origins == {"**.a.com": True}
assert any("mark cleared" in w for w in warnings) assert any("mark cleared" in w for w in warnings)
domains.validate_config(config) domains.validate_config(config)
@@ -521,16 +524,31 @@ class TestOriginValidation:
with pytest.raises(ValueError, match="not allowed"): with pytest.raises(ValueError, match="not allowed"):
p.validate_origin("https://other.com") p.validate_origin("https://other.com")
def test_wildcard_entry_matches_subtree(self): def test_double_star_matches_apex_and_any_depth(self):
p = Passkey(rp_id="example.com", origins=["*.example.com"]) """'**.example.com' covers the apex and subdomains at any depth
assert p.validate_origin("https://example.com") (the shell-glob convention)."""
assert p.validate_origin("https://app.example.com") p = Passkey(rp_id="example.com", origins=["**.example.com"])
assert p.validate_origin("https://example.com") # apex
assert p.validate_origin("https://app.example.com") # one level
assert p.validate_origin("https://a.b.c.example.com") # any depth
with pytest.raises(ValueError, match="not allowed"):
p.validate_origin("https://anotherexample.com")
with pytest.raises(ValueError, match="not allowed"): with pytest.raises(ValueError, match="not allowed"):
p.validate_origin("https://other.com") p.validate_origin("https://other.com")
def test_wildcard_is_https_only(self): def test_single_star_matches_exactly_one_level(self):
"""A '*.example.com' entry does not fall back to other schemes.""" """'*.example.com' covers exactly one subdomain level — neither the
apex nor deeper levels."""
p = Passkey(rp_id="example.com", origins=["*.example.com"]) p = Passkey(rp_id="example.com", origins=["*.example.com"])
assert p.validate_origin("https://app.example.com")
with pytest.raises(ValueError, match="not allowed"):
p.validate_origin("https://example.com") # apex excluded
with pytest.raises(ValueError, match="not allowed"):
p.validate_origin("https://a.b.example.com") # too deep
def test_wildcard_is_https_only(self):
"""A '**.example.com' entry does not fall back to other schemes."""
p = Passkey(rp_id="example.com", origins=["**.example.com"])
with pytest.raises(ValueError, match="not allowed"): with pytest.raises(ValueError, match="not allowed"):
p.validate_origin("http://example.com") p.validate_origin("http://example.com")
with pytest.raises(ValueError, match="not allowed"): with pytest.raises(ValueError, match="not allowed"):
@@ -542,9 +560,10 @@ class TestOriginValidation:
def test_localhost_wildcard_matches_any_scheme_and_port(self): def test_localhost_wildcard_matches_any_scheme_and_port(self):
"""Under localhost, wildcards match any scheme and any port.""" """Under localhost, wildcards match any scheme and any port."""
p = Passkey(rp_id="localhost", origins=["*.localhost"]) p = Passkey(rp_id="localhost", origins=["**.localhost"])
assert p.validate_origin("http://localhost:8080") assert p.validate_origin("http://localhost:8080")
assert p.validate_origin("http://app.localhost:3000") assert p.validate_origin("http://app.localhost:3000")
assert p.validate_origin("http://a.b.localhost:3000")
assert p.validate_origin("https://localhost") assert p.validate_origin("https://localhost")
def test_exact_entry_matches_scheme_and_port(self): def test_exact_entry_matches_scheme_and_port(self):
@@ -556,7 +575,7 @@ class TestOriginValidation:
p.validate_origin("http://localhost:4404") p.validate_origin("http://localhost:4404")
def test_sub_wildcard_matches_only_its_subtree(self): def test_sub_wildcard_matches_only_its_subtree(self):
p = Passkey(rp_id="example.com", origins=["*.app.example.com"]) p = Passkey(rp_id="example.com", origins=["**.app.example.com"])
assert p.validate_origin("https://app.example.com") assert p.validate_origin("https://app.example.com")
assert p.validate_origin("https://www.app.example.com") assert p.validate_origin("https://www.app.example.com")
with pytest.raises(ValueError, match="not allowed"): with pytest.raises(ValueError, match="not allowed"):
@@ -870,13 +889,13 @@ class TestLegacyConversion:
def test_convert_empty_origins_seeds_wildcard(self, tmp_path): def test_convert_empty_origins_seeds_wildcard(self, tmp_path):
"""Legacy 'no origins' meant the whole rp-id domain; the new format """Legacy 'no origins' meant the whole rp-id domain; the new format
makes that explicit as '*.{rp-id}'.""" makes that explicit as '**.{rp-id}'."""
src_file = tmp_path / "main.db" src_file = tmp_path / "main.db"
asyncio.run( asyncio.run(
_write_legacy(src_file, LegacyConfig(rp_id="example.com", rp_name="Ex")) _write_legacy(src_file, LegacyConfig(rp_id="example.com", rp_name="Ex"))
) )
config = convert_legacy_database(src_file, tmp_path / "paskia.kantadb") config = convert_legacy_database(src_file, tmp_path / "paskia.kantadb")
assert config.domains["example.com"].origins == {"*.example.com": True} assert config.domains["example.com"].origins == {"**.example.com": True}
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------