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()
expect(domains.map((r: any) => r.rp_id).sort()).toEqual(['localhost', 'test.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 —
// same origins table; classification is derived from the rp-id
const patch = await page.request.patch(`${baseUrl}/auth/api/admin/domains/localhost`, {
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()
@@ -109,7 +109,7 @@ test.describe('Multi-domain E2E', () => {
// Restore: back to the pristine seeded state for later tests
const restore = await page.request.patch(`${baseUrl}/auth/api/admin/domains/localhost`, {
headers,
data: { rp_name: '', origins: { '*.localhost': true } },
data: { rp_name: '', origins: { '**.localhost': true } },
})
expect(restore.ok()).toBeTruthy()
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.
const patch = await page.request.patch(`${baseUrl}/auth/api/admin/domains/test.localhost`, {
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()
@@ -168,7 +168,7 @@ test.describe('Multi-domain E2E', () => {
// test.localhost, and an empty table would allow nothing)
const restore = await page.request.patch(`${baseUrl}/auth/api/admin/domains/test.localhost`, {
headers,
data: { rp_name: '', origins: { '*.test.localhost': true } },
data: { rp_name: '', origins: { '**.test.localhost': true } },
})
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:
data.config.listen = listen
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:
validate_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
# 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
# set up afterwards via the admin interface. The bootstrap rp-name
# exists so the very first admin registration ceremony already shows
# the correct name.
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,
)
try:
+1 -1
View File
@@ -123,7 +123,7 @@ def convert_legacy_database(src: Path, dst: Path) -> Config:
if not origins:
# Legacy semantics: no origins configured = the whole rp-id domain
# allowed. The new format requires explicit entries.
origins[f"*.{rp_id}"] = True
origins[f"**.{rp_id}"] = True
new_config = Config(
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
origins (WebAuthn Related Origin Requests — individual hosts only,
no wildcards). Keys are hosts without the https:// scheme
("app.example.com"), wildcard patterns under the rp-id
("*.example.com" — the base domain and its subdomains over https only,
any scheme and port under localhost), or full origins
("http://localhost:8080", "https://app2.com"). An empty dict means
nothing is allowed — list sites explicitly. Ordering carries no
("app.example.com"), wildcard patterns under the rp-id following the
shell-glob convention ("**.example.com" — the base domain and its
subdomains at any depth; "*.example.com" — exactly one subdomain
level; https only, any scheme and port under localhost), or full
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.
"""
@@ -651,7 +652,7 @@ class Config(msgspec.Struct, omit_defaults=True):
domains: dict[str, DomainConfig] = msgspec.field(
default_factory=lambda: {
"localhost": DomainConfig(origins={"*.localhost": True})
"localhost": DomainConfig(origins={"**.localhost": True})
}
)
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("/")
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:
key = key.rstrip(".")
return key.lower()
@@ -213,10 +214,10 @@ def validate_config(
is_auth = isinstance(props, OriginEntry) and props.auth_host
if key == "*":
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):
base = key[2:].rstrip(".")
base = hostutil.wildcard_base(key)
if not base or not hostutil.is_subdomain(base, rp_id):
raise ValueError(
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
if key == "*":
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 "")
)
origins[f"*.{rp_id}"] = True
origins[f"**.{rp_id}"] = True
continue
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):
warn(
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_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
(e.g. ["https://app.example.com"]); wildcard patterns like
"*.example.com" match the base domain and its subdomains
over https only — except under localhost ("*.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.
(e.g. ["https://app.example.com"]); wildcard patterns
follow the shell-glob convention: "**.example.com" matches
the base domain and its subdomains at any depth, while
"*.example.com" matches exactly one subdomain level —
over https only, except under localhost
("**.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
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).
@@ -122,19 +125,30 @@ class Passkey:
def _allowlisted(self, origin: str) -> bool:
"""Check an in-domain origin against the allow-list.
An entry matches exactly; a wildcard pattern ('*.example.com')
matches the base domain and any subdomain of it over https only,
except under localhost ('*.localhost'), which matches any scheme
and any port.
An entry matches exactly. A wildcard pattern matches hostnames
under its base: '**.example.com' covers the base domain itself and
subdomains at any depth, while '*.example.com' covers exactly one
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:
return True
hostname = hostutil.origin_hostname(origin)
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
base = entry[2:].rstrip(".")
if not hostutil.is_subdomain(hostname, base):
if entry.startswith("**."):
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
if hostutil.is_subdomain(base, "localhost"):
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:
"""Check whether an origins entry is a wildcard pattern like '*.example.com'."""
return value.startswith("*.")
"""Check whether an origins entry is a wildcard pattern like
'*.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:
"""Normalize an origin URL by adding https:// if no scheme is present, removing trailing slashes.
Wildcard patterns ('*.example.com') pass through unchanged — they are
allow-list entries, not concrete origins.
Wildcard patterns ('*.example.com', '**.example.com') pass through
unchanged — they are allow-list entries, not concrete origins.
"""
if is_wildcard_pattern(origin):
return origin.rstrip("/.")
@@ -41,8 +52,8 @@ def origin_hostname(origin: str) -> str | None:
For wildcard patterns the base domain is returned.
"""
if is_wildcard_pattern(origin):
return origin[2:].rstrip(".").lower() or None
if base := wildcard_base(origin):
return base.lower()
return urlparse(origin).hostname
+1 -1
View File
@@ -96,7 +96,7 @@ async def test_db() -> AsyncGenerator[DB]:
admin_name="Test Admin",
config=Config(
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
domain = data[0]
assert domain["rp_id"] == "localhost"
assert domain["origins"] == {"*.localhost": True}
assert domain["origins"] == {"**.localhost": True}
assert "related" not in domain
assert domain["auth_host"] is None
assert domain["site_url"] == "http://localhost:4401"
@@ -2022,7 +2022,7 @@ class TestDomains:
)
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(
"/auth/api/admin/domains/",
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)
assert list(config.domains) == ["localhost"]
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
@@ -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 == {"*.example.com": True}
assert domain.origins == {"**.example.com": True}
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)
},
),
"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):
"""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(
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"):
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):
@@ -365,13 +368,13 @@ class TestSanitizeConfig:
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
"""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)
assert config.domains["a.com"].origins == {"**.a.com": True}
assert any("**." in w for w in warnings)
domains.validate_config(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)
domains.validate_config(config)
@@ -521,16 +524,31 @@ class TestOriginValidation:
with pytest.raises(ValueError, match="not allowed"):
p.validate_origin("https://other.com")
def test_wildcard_entry_matches_subtree(self):
p = Passkey(rp_id="example.com", origins=["*.example.com"])
assert p.validate_origin("https://example.com")
assert p.validate_origin("https://app.example.com")
def test_double_star_matches_apex_and_any_depth(self):
"""'**.example.com' covers the apex and subdomains at any depth
(the shell-glob convention)."""
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"):
p.validate_origin("https://other.com")
def test_wildcard_is_https_only(self):
"""A '*.example.com' entry does not fall back to other schemes."""
def test_single_star_matches_exactly_one_level(self):
"""'*.example.com' covers exactly one subdomain level — neither the
apex nor deeper levels."""
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"):
p.validate_origin("http://example.com")
with pytest.raises(ValueError, match="not allowed"):
@@ -542,9 +560,10 @@ class TestOriginValidation:
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=["*.localhost"])
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("http://a.b.localhost:3000")
assert p.validate_origin("https://localhost")
def test_exact_entry_matches_scheme_and_port(self):
@@ -556,7 +575,7 @@ class TestOriginValidation:
p.validate_origin("http://localhost:4404")
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://www.app.example.com")
with pytest.raises(ValueError, match="not allowed"):
@@ -870,13 +889,13 @@ class TestLegacyConversion:
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}'."""
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}
assert config.domains["example.com"].origins == {"**.example.com": True}
# -------------------------------------------------------------------------