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:
+11
-6
@@ -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)
|
||||
new = DomainConfig(rp_name=rp_name, origins={f"*.{rp_id}": True})
|
||||
try:
|
||||
validate_config(
|
||||
Config(
|
||||
@@ -133,11 +133,16 @@ def cmd_init(args: argparse.Namespace) -> None:
|
||||
"convert, not 'paskia init'."
|
||||
)
|
||||
|
||||
# Only rp-id and rp-name are bootstrap-time configuration; everything
|
||||
# else (origins, 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)}, listen=listen)
|
||||
# Only rp-id and rp-name are bootstrap-time configuration; the new
|
||||
# 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})},
|
||||
listen=listen,
|
||||
)
|
||||
try:
|
||||
validate_config(config)
|
||||
except ValueError as e:
|
||||
|
||||
@@ -120,6 +120,10 @@ def convert_legacy_database(src: Path, dst: Path) -> Config:
|
||||
origins[origin_key(origin)] = True
|
||||
if old.config.auth_host:
|
||||
origins[origin_key(old.config.auth_host)] = OriginEntry(auth_host=True)
|
||||
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
|
||||
|
||||
new_config = Config(
|
||||
domains={rp_id: DomainConfig(rp_name=old.config.rp_name, origins=origins)},
|
||||
|
||||
@@ -714,10 +714,9 @@ def update_domain(
|
||||
*,
|
||||
rp_name: str | None,
|
||||
origins: dict[str, bool | OriginEntry],
|
||||
related: dict[str, bool],
|
||||
ctx: SessionContext | None = None,
|
||||
) -> None:
|
||||
"""Replace a domain's rp_name, origins and related origins (wholesale).
|
||||
"""Replace a domain's rp_name and origins table (wholesale).
|
||||
|
||||
The rp-id itself is immutable: credentials are stamped with it, so
|
||||
changing it would orphan them — delete and recreate the domain instead.
|
||||
@@ -729,7 +728,6 @@ def update_domain(
|
||||
with _transaction("admin:update_domain", ctx):
|
||||
domain.rp_name = rp_name
|
||||
domain.origins = origins
|
||||
domain.related = related
|
||||
|
||||
|
||||
def delete_domain(rp_id: str, *, ctx: SessionContext | None = None) -> None:
|
||||
|
||||
+14
-15
@@ -625,24 +625,21 @@ class OriginEntry(msgspec.Struct, omit_defaults=True):
|
||||
class DomainConfig(msgspec.Struct, omit_defaults=True):
|
||||
"""Configuration for one domain (one WebAuthn rp-id).
|
||||
|
||||
``origins`` maps sign-in sites within the rp-id domain to their
|
||||
properties. Keys are hosts without the https:// scheme
|
||||
("app.example.com"), wildcard patterns ("*.example.com" — the base
|
||||
domain and its subdomains over https only, any scheme and port under
|
||||
localhost), the bare "*" (shorthand for a wildcard over the rp-id
|
||||
itself), or full origins when not https ("http://localhost:8080").
|
||||
An empty dict means the rp-id and all its subdomains may sign in (the
|
||||
default). Ordering carries no meaning — display order is decided by
|
||||
the UI.
|
||||
|
||||
``related`` lists other domain names that may assert this rp-id
|
||||
(WebAuthn Related Origin Requests): individual hosts or full origins
|
||||
only (no wildcards, no "*").
|
||||
``origins`` is a single table of sites that may sign in with this
|
||||
domain's passkeys, classified by the rp-id: entries within the rp-id
|
||||
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
|
||||
meaning — display order is decided by the UI.
|
||||
"""
|
||||
|
||||
rp_name: str | None = None
|
||||
origins: dict[str, bool | OriginEntry] = {}
|
||||
related: dict[str, bool] = {}
|
||||
|
||||
|
||||
class Config(msgspec.Struct, omit_defaults=True):
|
||||
@@ -653,7 +650,9 @@ class Config(msgspec.Struct, omit_defaults=True):
|
||||
"""
|
||||
|
||||
domains: dict[str, DomainConfig] = msgspec.field(
|
||||
default_factory=lambda: {"localhost": DomainConfig()}
|
||||
default_factory=lambda: {
|
||||
"localhost": DomainConfig(origins={"*.localhost": True})
|
||||
}
|
||||
)
|
||||
listen: list[str] | None = None # Process-global listen endpoints
|
||||
|
||||
|
||||
+85
-105
@@ -6,6 +6,10 @@ rebuilt on admin domain changes; request dispatch resolves hosts to
|
||||
domains through it. The database itself is global — only the *current
|
||||
domain* (passkey, site URLs) varies per request, tracked via a
|
||||
contextvar set by the dispatch middleware.
|
||||
|
||||
Each domain's ``origins`` table holds both in-domain sign-in sites and
|
||||
related origins (ROR), classified by the rp-id: entries within the rp-id
|
||||
domain are in-domain, entries outside it are related.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -29,15 +33,15 @@ DEFAULT_RELATED_ORIGIN_CAP = 5
|
||||
|
||||
|
||||
def origin_url(key: str) -> str:
|
||||
"""URL form of an origins-dict key (https:// is implied); wildcards and
|
||||
'*' pass through unchanged."""
|
||||
if hostutil.is_wildcard_pattern(key) or key == "*" or "://" in key:
|
||||
"""URL form of an origins-table key (https:// is implied); wildcards
|
||||
pass through unchanged."""
|
||||
if hostutil.is_wildcard_pattern(key) or "://" in key:
|
||||
return key
|
||||
return f"https://{key}"
|
||||
|
||||
|
||||
def origin_key(origin: str) -> str:
|
||||
"""Origins-dict key for a full origin URL (https:// omitted).
|
||||
"""Origins-table key for a full origin URL (https:// omitted).
|
||||
|
||||
Keys are canonicalized: lowercased, and bare hosts/wildcards lose any
|
||||
trailing dot.
|
||||
@@ -50,6 +54,24 @@ def origin_key(origin: str) -> str:
|
||||
return key.lower()
|
||||
|
||||
|
||||
def is_related_key(rp_id: str, key: str) -> bool:
|
||||
"""Whether an origins-table key lies outside the rp-id domain (a
|
||||
related origin). Wildcards are never related."""
|
||||
if hostutil.is_wildcard_pattern(key):
|
||||
return False
|
||||
hn = hostutil.origin_hostname(origin_url(key))
|
||||
return bool(hn) and not hostutil.is_subdomain(hn, rp_id)
|
||||
|
||||
|
||||
def partition_origins(
|
||||
rp_id: str, origins: dict[str, bool | OriginEntry]
|
||||
) -> tuple[list[str], list[str]]:
|
||||
"""Split an origins table into (in-domain keys, related keys)."""
|
||||
in_domain = [k for k in origins if not is_related_key(rp_id, k)]
|
||||
related = [k for k in origins if is_related_key(rp_id, k)]
|
||||
return in_domain, related
|
||||
|
||||
|
||||
def auth_host_url(domain: DomainConfig) -> str | None:
|
||||
"""Full URL of the domain's auth host origin, if one is marked."""
|
||||
for key, props in domain.origins.items():
|
||||
@@ -62,6 +84,7 @@ class Domain:
|
||||
"""Runtime view of one domain: stored config plus derived values."""
|
||||
|
||||
def __init__(self, rp_id: str, config: DomainConfig, site_url: str, site_path: str):
|
||||
in_domain, related = partition_origins(rp_id, config.origins)
|
||||
self.rp_id = rp_id
|
||||
self.config = config
|
||||
self.site_url = site_url
|
||||
@@ -69,8 +92,8 @@ class Domain:
|
||||
self.passkey = Passkey(
|
||||
rp_id=rp_id,
|
||||
rp_name=config.rp_name,
|
||||
origins=[origin_url(k) for k in config.origins] or None,
|
||||
related_origins=[origin_url(k) for k in config.related],
|
||||
origins=[origin_url(k) for k in in_domain],
|
||||
related_origins=[origin_url(k) for k in related],
|
||||
)
|
||||
|
||||
@property
|
||||
@@ -85,8 +108,8 @@ class Domain:
|
||||
|
||||
@property
|
||||
def related_origins(self) -> list[str]:
|
||||
"""Configured related (cross-domain) origins for ROR, as URLs."""
|
||||
return [origin_url(k) for k in self.config.related]
|
||||
"""Related (cross-domain) origins for ROR, as URLs."""
|
||||
return sorted(self.passkey.related_origins)
|
||||
|
||||
@property
|
||||
def ui_base_path(self) -> str:
|
||||
@@ -185,19 +208,20 @@ def validate_config(
|
||||
hostutil.validate_rp_id(rp_id)
|
||||
|
||||
domain_auth_host: str | None = None
|
||||
related_count = 0
|
||||
for key, props in domain.origins.items():
|
||||
is_auth = isinstance(props, OriginEntry) and props.auth_host
|
||||
if key == "*":
|
||||
# Shorthand for '*.{rp_id}'
|
||||
if is_auth:
|
||||
raise ValueError("Origin '*' cannot be the auth host")
|
||||
continue
|
||||
raise ValueError(
|
||||
f"Origin '*' is not allowed — list '*.{rp_id}' explicitly"
|
||||
)
|
||||
if hostutil.is_wildcard_pattern(key):
|
||||
base = key[2:].rstrip(".")
|
||||
if not base or not hostutil.is_subdomain(base, rp_id):
|
||||
raise ValueError(
|
||||
f"Origin '{key}' is outside the rp-id domain "
|
||||
f"'{rp_id}' — configure it as a related origin instead"
|
||||
f"Origin '{key}' is a wildcard outside the rp-id "
|
||||
f"domain '{rp_id}' — related origins must be "
|
||||
"individual hosts"
|
||||
)
|
||||
if is_auth:
|
||||
raise ValueError(f"Wildcard origin '{key}' cannot be the auth host")
|
||||
@@ -205,11 +229,7 @@ def validate_config(
|
||||
hn = hostutil.origin_hostname(origin_url(key))
|
||||
if not hn:
|
||||
raise ValueError(f"Invalid origin: '{key}'")
|
||||
if not hostutil.is_subdomain(hn, rp_id):
|
||||
raise ValueError(
|
||||
f"Origin '{key}' is outside the rp-id domain "
|
||||
f"'{rp_id}' — configure it as a related origin instead"
|
||||
)
|
||||
if hostutil.is_subdomain(hn, rp_id):
|
||||
if is_auth:
|
||||
if domain_auth_host is not None:
|
||||
raise ValueError(
|
||||
@@ -223,31 +243,14 @@ def validate_config(
|
||||
# Several domains may share an auth host to consolidate
|
||||
# logins; resolution picks the best suffix match.
|
||||
auth_hosts.setdefault(ah, rp_id)
|
||||
|
||||
if len(domain.related) > related_origin_cap:
|
||||
continue
|
||||
# Related origin (outside the rp-id domain)
|
||||
if is_auth:
|
||||
raise ValueError(
|
||||
f"Domain '{rp_id}' has {len(domain.related)} "
|
||||
f"related origins (maximum {related_origin_cap})"
|
||||
)
|
||||
for key in domain.related:
|
||||
if key == "*":
|
||||
raise ValueError(
|
||||
"Related origin '*' is not allowed — related origins "
|
||||
"(ROR) must be listed individually"
|
||||
)
|
||||
if hostutil.is_wildcard_pattern(key):
|
||||
raise ValueError(
|
||||
f"Related origin '{key}' is a wildcard — related "
|
||||
"origins (ROR) must be listed individually"
|
||||
)
|
||||
hn = hostutil.origin_hostname(origin_url(key))
|
||||
if not hn:
|
||||
raise ValueError(f"Invalid related origin: '{key}'")
|
||||
if hostutil.is_subdomain(hn, rp_id):
|
||||
raise ValueError(
|
||||
f"Related origin '{key}' is within the rp-id domain "
|
||||
f"'{rp_id}' — subdomains need no related origin entry"
|
||||
f"Related origin '{key}' cannot be the auth host — the "
|
||||
"auth host must be within the rp-id domain"
|
||||
)
|
||||
related_count += 1
|
||||
# A related host may be (or fall inside) another domain's
|
||||
# rp-id: a host that *is* a configured rp-id always serves its
|
||||
# own domain; otherwise the related listing wins dispatch over
|
||||
@@ -262,6 +265,11 @@ def validate_config(
|
||||
f"'{related_hosts[hn]}' and '{rp_id}'"
|
||||
)
|
||||
related_hosts[hn] = rp_id
|
||||
if related_count > related_origin_cap:
|
||||
raise ValueError(
|
||||
f"Domain '{rp_id}' has {related_count} related origins "
|
||||
f"(maximum {related_origin_cap})"
|
||||
)
|
||||
|
||||
rp_ids = set(config.domains)
|
||||
for hn, owner in auth_hosts.items():
|
||||
@@ -299,31 +307,25 @@ def sanitize_config(
|
||||
continue
|
||||
|
||||
origins: dict[str, bool | OriginEntry] = {}
|
||||
related: dict[str, bool] = dict(domain.related)
|
||||
auth_seen = False
|
||||
for key, props in domain.origins.items():
|
||||
is_auth = isinstance(props, OriginEntry) and props.auth_host
|
||||
if not is_auth:
|
||||
props = True # canonicalize junk/empty entries to presence-only
|
||||
if key == "*":
|
||||
# Shorthand for '*.{rp_id}'; wildcards cannot be auth hosts
|
||||
if is_auth:
|
||||
warn(
|
||||
f"Domain '{rp_id}': origin '*' cannot be the "
|
||||
"auth host — mark cleared"
|
||||
f"Domain '{rp_id}': origin '*' rewritten as '*.{rp_id}'"
|
||||
+ (" — auth host mark cleared" if is_auth else "")
|
||||
)
|
||||
origins[f"*.{rp_id}"] = True
|
||||
continue
|
||||
if hostutil.is_wildcard_pattern(key):
|
||||
base = key[2:].rstrip(".")
|
||||
if not base:
|
||||
warn(f"Domain '{rp_id}': invalid origin '{key}' dropped")
|
||||
continue
|
||||
if not hostutil.is_subdomain(base, rp_id):
|
||||
if not base or not hostutil.is_subdomain(base, rp_id):
|
||||
warn(
|
||||
f"Domain '{rp_id}': origin '{key}' is outside the "
|
||||
"rp-id domain — dropped (wildcards cannot be "
|
||||
"related origins)"
|
||||
f"Domain '{rp_id}': origin '{key}' is a wildcard "
|
||||
"outside the rp-id domain — dropped (related origins "
|
||||
"must be individual hosts)"
|
||||
)
|
||||
continue
|
||||
if is_auth:
|
||||
@@ -338,9 +340,14 @@ def sanitize_config(
|
||||
if not hn:
|
||||
warn(f"Domain '{rp_id}': invalid origin '{key}' dropped")
|
||||
continue
|
||||
if hostutil.is_subdomain(hn, rp_id):
|
||||
if is_auth:
|
||||
if auth_seen:
|
||||
if not hostutil.is_subdomain(hn, rp_id):
|
||||
warn(
|
||||
f"Domain '{rp_id}': related origin '{key}' cannot be "
|
||||
"the auth host — mark cleared"
|
||||
)
|
||||
props = True
|
||||
elif auth_seen:
|
||||
warn(
|
||||
f"Domain '{rp_id}': several origins marked as "
|
||||
f"auth host — extra mark on '{key}' cleared"
|
||||
@@ -349,49 +356,17 @@ def sanitize_config(
|
||||
else:
|
||||
auth_seen = True
|
||||
origins[key] = props
|
||||
else:
|
||||
warn(
|
||||
f"Domain '{rp_id}': origin '{key}' is outside the rp-id "
|
||||
"domain — treating it as a related origin; fix the "
|
||||
"lists in the admin interface"
|
||||
)
|
||||
related[origin_key(origin_url(key))] = True
|
||||
|
||||
related_ok: dict[str, bool] = {}
|
||||
for key in related:
|
||||
if key == "*":
|
||||
related = sorted(k for k in origins if is_related_key(rp_id, k))
|
||||
if len(related) > related_origin_cap:
|
||||
warn(
|
||||
f"Domain '{rp_id}': related origin '*' is not allowed — "
|
||||
"dropped (ROR entries must be individual)"
|
||||
)
|
||||
continue
|
||||
if hostutil.is_wildcard_pattern(key):
|
||||
warn(
|
||||
f"Domain '{rp_id}': related origin '{key}' is a "
|
||||
"wildcard — dropped (ROR entries must be individual)"
|
||||
)
|
||||
continue
|
||||
hn = hostutil.origin_hostname(origin_url(key))
|
||||
if not hn:
|
||||
warn(f"Domain '{rp_id}': invalid related origin '{key}' dropped")
|
||||
continue
|
||||
if hostutil.is_subdomain(hn, rp_id):
|
||||
warn(
|
||||
f"Domain '{rp_id}': related origin '{key}' is within the "
|
||||
"rp-id domain — dropped (subdomains need no related entry)"
|
||||
)
|
||||
continue
|
||||
related_ok[key] = True
|
||||
if len(related_ok) > related_origin_cap:
|
||||
warn(
|
||||
f"Domain '{rp_id}': {len(related_ok)} related origins exceed "
|
||||
f"Domain '{rp_id}': {len(related)} related origins exceed "
|
||||
f"the maximum of {related_origin_cap} — extras dropped"
|
||||
)
|
||||
related_ok = dict(sorted(related_ok.items())[:related_origin_cap])
|
||||
for key in related[related_origin_cap:]:
|
||||
del origins[key]
|
||||
|
||||
domains[rp_id] = DomainConfig(
|
||||
rp_name=domain.rp_name, origins=origins, related=related_ok
|
||||
)
|
||||
domains[rp_id] = DomainConfig(rp_name=domain.rp_name, origins=origins)
|
||||
|
||||
if not domains:
|
||||
raise ValueError("No servable domain in the stored configuration")
|
||||
@@ -423,26 +398,29 @@ def sanitize_config(
|
||||
|
||||
seen_related: dict[str, str] = {}
|
||||
for rp_id, domain in domains.items():
|
||||
keep: dict[str, bool] = {}
|
||||
for key in domain.related:
|
||||
drop = []
|
||||
for key in domain.origins:
|
||||
if not is_related_key(rp_id, key):
|
||||
continue
|
||||
hn = hostutil.origin_hostname(origin_url(key))
|
||||
covered_by_rp_id = any(hostutil.is_subdomain(hn, o) for o in rp_ids)
|
||||
if covered_by_rp_id:
|
||||
keep[key] = True
|
||||
elif hn in seen_auth_hosts:
|
||||
if any(hostutil.is_subdomain(hn, o) for o in rp_ids):
|
||||
continue # covered by a configured rp-id
|
||||
if hn in seen_auth_hosts:
|
||||
warn(
|
||||
f"Domain '{rp_id}': related origin '{key}' is the "
|
||||
f"auth host of '{seen_auth_hosts[hn]}' — dropped"
|
||||
)
|
||||
drop.append(key)
|
||||
elif hn in seen_related:
|
||||
warn(
|
||||
f"Domain '{rp_id}': related origin '{key}' is also "
|
||||
f"used by '{seen_related[hn]}' — dropped (first domain wins)"
|
||||
)
|
||||
drop.append(key)
|
||||
else:
|
||||
seen_related[hn] = rp_id
|
||||
keep[key] = True
|
||||
domain.related = keep
|
||||
for key in drop:
|
||||
del domain.origins[key]
|
||||
|
||||
return Config(domains=domains, listen=config.listen), warnings
|
||||
|
||||
@@ -452,8 +430,8 @@ def _derive_site(
|
||||
) -> tuple[str, str]:
|
||||
"""Compute a domain's site_url and site_path.
|
||||
|
||||
Priority: auth host > exact rp-id origin key > first concrete origin
|
||||
key (sorted) > PASKIA_VITE_URL (localhost domain only) >
|
||||
Priority: auth host > exact rp-id origin key > first concrete in-domain
|
||||
origin key (sorted) > PASKIA_VITE_URL (localhost domain only) >
|
||||
http://localhost:port (localhost domain) > https://rp-id.
|
||||
"""
|
||||
if auth := auth_host_url(domain):
|
||||
@@ -461,7 +439,9 @@ def _derive_site(
|
||||
if rp_id in domain.origins:
|
||||
return origin_url(rp_id), "/auth/"
|
||||
concrete = sorted(
|
||||
k for k in domain.origins if k != "*" and not hostutil.is_wildcard_pattern(k)
|
||||
k
|
||||
for k in domain.origins
|
||||
if not hostutil.is_wildcard_pattern(k) and not is_related_key(rp_id, k)
|
||||
)
|
||||
if concrete:
|
||||
return origin_url(concrete[0]), "/auth/"
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
"""Domain (rp-id) management API — master admin only.
|
||||
|
||||
Each domain is one rp-id with its own rp-name, allowed in-domain sign-in
|
||||
sites (origins, one of which may be marked as the auth host), and optional
|
||||
related origins on unrelated domains (WebAuthn Related Origin Requests).
|
||||
All changes are validated cross-domain before being persisted, and the
|
||||
runtime domain registry is rebuilt after each change so it takes effect
|
||||
Each domain is one rp-id with its own rp-name and an origins table of
|
||||
sign-in sites: entries within the rp-id domain are in-domain sites (one of
|
||||
which may be marked as the auth host), entries outside it are related
|
||||
origins on unrelated domains (WebAuthn Related Origin Requests). All
|
||||
changes are validated cross-domain before being persisted, and the runtime
|
||||
domain registry is rebuilt after each change so it takes effect
|
||||
immediately.
|
||||
"""
|
||||
|
||||
@@ -30,7 +31,6 @@ def _domain_to_api(domain: domains.Domain) -> ApiDomain:
|
||||
rp_id=domain.rp_id,
|
||||
rp_name=domain.rp_name,
|
||||
origins=domain.config.origins,
|
||||
related=domain.config.related,
|
||||
site_url=domain.site_url,
|
||||
auth_site_url=domain.auth_site_url,
|
||||
auth_host=domain.own_auth_host,
|
||||
@@ -41,7 +41,8 @@ def _normalize_origins_map(values: dict | None) -> dict[str, bool | OriginEntry]
|
||||
"""Normalize an origins object from the admin UI (raises on malformed).
|
||||
|
||||
Keys arrive as bare hosts, wildcard patterns, or full origins; they are
|
||||
stored as origins-dict keys (https:// omitted).
|
||||
stored as origins-table keys (https:// omitted). In-domain vs. related
|
||||
classification is derived from the rp-id at validation time.
|
||||
"""
|
||||
out: dict[str, bool | OriginEntry] = {}
|
||||
for raw_key, raw_props in (values or {}).items():
|
||||
@@ -55,22 +56,6 @@ def _normalize_origins_map(values: dict | None) -> dict[str, bool | OriginEntry]
|
||||
return out
|
||||
|
||||
|
||||
def _normalize_related_map(values: dict | None) -> dict[str, bool]:
|
||||
"""Normalize a related-origins object from the admin UI."""
|
||||
out: dict[str, bool] = {}
|
||||
for raw_key in values or {}:
|
||||
key = raw_key.strip()
|
||||
if not key:
|
||||
continue
|
||||
if key == "*" or hostutil.is_wildcard_pattern(key):
|
||||
raise ValueError(
|
||||
f"Related origin '{key}' is a wildcard — related origins "
|
||||
"(ROR) must be listed individually"
|
||||
)
|
||||
out[domains.origin_key(hostutil.normalize_origin(key))] = True
|
||||
return out
|
||||
|
||||
|
||||
def _rebuild_registry() -> None:
|
||||
"""Rebuild the runtime domain registry from the stored configuration."""
|
||||
domains.init_registry(db.data().config)
|
||||
@@ -94,10 +79,11 @@ def _check_not_locking_self_out(
|
||||
raw_host = (request.headers.get("host") or "").rstrip(".")
|
||||
if not raw_host:
|
||||
return
|
||||
in_domain, related = domains.partition_origins(rp_id, domain.origins)
|
||||
probe = Passkey(
|
||||
rp_id=rp_id,
|
||||
origins=[domains.origin_url(k) for k in domain.origins] or None,
|
||||
related_origins=[domains.origin_url(k) for k in domain.related],
|
||||
origins=[domains.origin_url(k) for k in in_domain],
|
||||
related_origins=[domains.origin_url(k) for k in related],
|
||||
)
|
||||
for scheme in ("https", "http"):
|
||||
try:
|
||||
@@ -137,7 +123,6 @@ async def admin_create_domain(
|
||||
new = DomainConfig(
|
||||
rp_name=(payload.get("rp_name") or "").strip() or None,
|
||||
origins=_normalize_origins_map(payload.get("origins")),
|
||||
related=_normalize_related_map(payload.get("related")),
|
||||
)
|
||||
|
||||
config = db.data().config
|
||||
@@ -174,7 +159,6 @@ async def admin_update_domain(
|
||||
updated = DomainConfig(
|
||||
rp_name=(payload.get("rp_name") or "").strip() or None,
|
||||
origins=_normalize_origins_map(payload.get("origins")),
|
||||
related=_normalize_related_map(payload.get("related")),
|
||||
)
|
||||
would_be = Config(
|
||||
domains={k: updated if k == rp_id else v for k, v in config.domains.items()},
|
||||
@@ -187,7 +171,6 @@ async def admin_update_domain(
|
||||
rp_id,
|
||||
rp_name=updated.rp_name,
|
||||
origins=updated.origins,
|
||||
related=updated.related,
|
||||
ctx=ctx,
|
||||
)
|
||||
_rebuild_registry()
|
||||
|
||||
+15
-24
@@ -56,14 +56,12 @@ 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"]); "*" is shorthand for
|
||||
a wildcard over the whole rp-id domain, and 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. If not
|
||||
provided, the rp-id and any subdomain of it may
|
||||
authenticate (same as listing "*").
|
||||
(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.
|
||||
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).
|
||||
@@ -76,13 +74,8 @@ class Passkey:
|
||||
self.rp_id = rp_id
|
||||
hostutil.validate_rp_id(rp_id)
|
||||
self.rp_name = rp_name or rp_id
|
||||
self.allowed_origins: set[str] | None = None
|
||||
if origins:
|
||||
# Validate and deduplicate origins into a set for O(1) lookups
|
||||
normalized = []
|
||||
for o in origins:
|
||||
if o == "*":
|
||||
o = f"*.{rp_id}" # shorthand: the whole rp-id domain
|
||||
self.allowed_origins: set[str] = set()
|
||||
for o in origins or []:
|
||||
if hostutil.is_wildcard_pattern(o):
|
||||
hostname = hostutil.origin_hostname(o)
|
||||
else:
|
||||
@@ -91,10 +84,9 @@ class Passkey:
|
||||
if not hostname or not hostutil.is_subdomain(hostname, rp_id):
|
||||
raise ValueError(
|
||||
f"Origin '{o}' is outside the rp-id domain '{rp_id}' — "
|
||||
"configure it as a related origin instead"
|
||||
"pass it as a related origin instead"
|
||||
)
|
||||
normalized.append(o)
|
||||
self.allowed_origins = set(normalized)
|
||||
self.allowed_origins.add(o)
|
||||
self.related_origins: set[str] = set()
|
||||
for o in related_origins or []:
|
||||
if hostutil.is_wildcard_pattern(o):
|
||||
@@ -153,11 +145,10 @@ class Passkey:
|
||||
def validate_origin(self, origin: str) -> str:
|
||||
"""Validate that origin is allowed and return it.
|
||||
|
||||
An in-domain origin (rp-id or subdomain) is valid unless an
|
||||
allow-list of origins is configured, in which case it must match
|
||||
a listed origin or wildcard pattern. An origin outside the rp-id
|
||||
domain is valid only when explicitly listed as a related origin
|
||||
(Related Origin Requests).
|
||||
An in-domain origin (rp-id or subdomain) must match a listed origin
|
||||
or wildcard pattern. An origin outside the rp-id domain is valid
|
||||
only when explicitly listed as a related origin (Related Origin
|
||||
Requests).
|
||||
|
||||
Args:
|
||||
origin: The origin URL to validate (from WebSocket request header)
|
||||
@@ -170,7 +161,7 @@ class Passkey:
|
||||
"""
|
||||
self._validate_origin_url(origin)
|
||||
if self._origin_in_subtree(origin):
|
||||
if self.allowed_origins is None or self._allowlisted(origin):
|
||||
if self._allowlisted(origin):
|
||||
return origin
|
||||
elif origin in self.related_origins:
|
||||
return origin
|
||||
|
||||
@@ -182,15 +182,15 @@ class ApiSettings(msgspec.Struct):
|
||||
class ApiDomain(msgspec.Struct):
|
||||
"""Domain entry in the admin domain list response.
|
||||
|
||||
origins/related mirror the stored configuration: objects keyed by host
|
||||
or wildcard pattern (https:// omitted), values True or an object with
|
||||
extra properties (auth_host).
|
||||
origins mirrors the stored configuration: an object keyed by host or
|
||||
wildcard pattern (https:// omitted), values True or an object with
|
||||
extra properties (auth_host). Entries outside the rp-id domain are
|
||||
related origins.
|
||||
"""
|
||||
|
||||
rp_id: str
|
||||
rp_name: str
|
||||
origins: dict[str, bool | OriginEntry]
|
||||
related: dict[str, bool]
|
||||
site_url: str
|
||||
auth_site_url: str
|
||||
auth_host: str | None
|
||||
|
||||
@@ -17,7 +17,7 @@ if TYPE_CHECKING:
|
||||
from paskia.domains import DomainRegistry
|
||||
|
||||
from paskia.db.structs import OriginEntry
|
||||
from paskia.domains import origin_url
|
||||
from paskia.domains import is_related_key, origin_url
|
||||
|
||||
BOX_WIDTH = 60 # Inner width (excluding box chars)
|
||||
|
||||
@@ -103,11 +103,10 @@ def print_startup_config(
|
||||
if isinstance(props, OriginEntry) and props.auth_host
|
||||
else ""
|
||||
)
|
||||
lines.append(line(f" Origin: {origin_url(key)}{marker}"))
|
||||
label = "Related:" if is_related_key(domain.rp_id, key) else "Origin:"
|
||||
lines.append(line(f" {label:<14}{origin_url(key)}{marker}"))
|
||||
if not domain.config.origins:
|
||||
lines.append(line(f" Origin: {domain.rp_id} and subdomains"))
|
||||
for key in sorted(domain.config.related):
|
||||
lines.append(line(f" Related: {origin_url(key)}"))
|
||||
lines.append(line(" Origins: (none configured)"))
|
||||
|
||||
lines.append(bottom())
|
||||
stderr.write("".join(lines))
|
||||
|
||||
+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