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:
@@ -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
@@ -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
@@ -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)},
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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}
|
||||
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user