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:
2026-09-07 14:40:37 +00:00
parent 93742ecdcf
commit a6138d97f9
13 changed files with 364 additions and 327 deletions
+11 -6
View File
@@ -91,7 +91,7 @@ def _init_add_domain(db_path: Path, rp_id: str, rp_name: str | None, listen) ->
if listen is not None: if listen is not None:
data.config.listen = listen data.config.listen = listen
return f"Updated domain {rp_id}" return f"Updated domain {rp_id}"
new = DomainConfig(rp_name=rp_name) new = DomainConfig(rp_name=rp_name, origins={f"*.{rp_id}": True})
try: try:
validate_config( validate_config(
Config( Config(
@@ -133,11 +133,16 @@ def cmd_init(args: argparse.Namespace) -> None:
"convert, not 'paskia init'." "convert, not 'paskia init'."
) )
# Only rp-id and rp-name are bootstrap-time configuration; everything # Only rp-id and rp-name are bootstrap-time configuration; the new
# else (origins, auth host, related domains) is set up afterwards via # domain starts with its whole subtree allowed ('*.{rp-id}') and
# the admin interface. The bootstrap rp-name exists so the very first # everything else (origin allow-list, auth host, related domains) is
# admin registration ceremony already shows the correct name. # set up afterwards via the admin interface. The bootstrap rp-name
config = Config(domains={rp_id: DomainConfig(rp_name=rp_name)}, listen=listen) # 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: try:
validate_config(config) validate_config(config)
except ValueError as e: except ValueError as e:
+4
View File
@@ -120,6 +120,10 @@ def convert_legacy_database(src: Path, dst: Path) -> Config:
origins[origin_key(origin)] = True origins[origin_key(origin)] = True
if old.config.auth_host: if old.config.auth_host:
origins[origin_key(old.config.auth_host)] = OriginEntry(auth_host=True) 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( new_config = Config(
domains={rp_id: DomainConfig(rp_name=old.config.rp_name, origins=origins)}, domains={rp_id: DomainConfig(rp_name=old.config.rp_name, origins=origins)},
+1 -3
View File
@@ -714,10 +714,9 @@ def update_domain(
*, *,
rp_name: str | None, rp_name: str | None,
origins: dict[str, bool | OriginEntry], origins: dict[str, bool | OriginEntry],
related: dict[str, bool],
ctx: SessionContext | None = None, ctx: SessionContext | None = 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 The rp-id itself is immutable: credentials are stamped with it, so
changing it would orphan them — delete and recreate the domain instead. changing it would orphan them — delete and recreate the domain instead.
@@ -729,7 +728,6 @@ def update_domain(
with _transaction("admin:update_domain", ctx): with _transaction("admin:update_domain", ctx):
domain.rp_name = rp_name domain.rp_name = rp_name
domain.origins = origins domain.origins = origins
domain.related = related
def delete_domain(rp_id: str, *, ctx: SessionContext | None = None) -> None: def delete_domain(rp_id: str, *, ctx: SessionContext | None = None) -> None:
+14 -15
View File
@@ -625,24 +625,21 @@ class OriginEntry(msgspec.Struct, omit_defaults=True):
class DomainConfig(msgspec.Struct, omit_defaults=True): class DomainConfig(msgspec.Struct, omit_defaults=True):
"""Configuration for one domain (one WebAuthn rp-id). """Configuration for one domain (one WebAuthn rp-id).
``origins`` maps sign-in sites within the rp-id domain to their ``origins`` is a single table of sites that may sign in with this
properties. Keys are hosts without the https:// scheme domain's passkeys, classified by the rp-id: entries within the rp-id
("app.example.com"), wildcard patterns ("*.example.com" — the base domain are in-domain sign-in sites, entries outside it are related
domain and its subdomains over https only, any scheme and port under origins (WebAuthn Related Origin Requests — individual hosts only,
localhost), the bare "*" (shorthand for a wildcard over the rp-id no wildcards). Keys are hosts without the https:// scheme
itself), or full origins when not https ("http://localhost:8080"). ("app.example.com"), wildcard patterns under the rp-id
An empty dict means the rp-id and all its subdomains may sign in (the ("*.example.com" — the base domain and its subdomains over https only,
default). Ordering carries no meaning — display order is decided by any scheme and port under localhost), or full origins
the UI. ("http://localhost:8080", "https://app2.com"). An empty dict means
nothing is allowed — list sites explicitly. Ordering carries no
``related`` lists other domain names that may assert this rp-id meaning — display order is decided by the UI.
(WebAuthn Related Origin Requests): individual hosts or full origins
only (no wildcards, no "*").
""" """
rp_name: str | None = None rp_name: str | None = None
origins: dict[str, bool | OriginEntry] = {} origins: dict[str, bool | OriginEntry] = {}
related: dict[str, bool] = {}
class Config(msgspec.Struct, omit_defaults=True): class Config(msgspec.Struct, omit_defaults=True):
@@ -653,7 +650,9 @@ class Config(msgspec.Struct, omit_defaults=True):
""" """
domains: dict[str, DomainConfig] = msgspec.field( 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 listen: list[str] | None = None # Process-global listen endpoints
+85 -105
View File
@@ -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 domains through it. The database itself is global — only the *current
domain* (passkey, site URLs) varies per request, tracked via a domain* (passkey, site URLs) varies per request, tracked via a
contextvar set by the dispatch middleware. 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 from __future__ import annotations
@@ -29,15 +33,15 @@ DEFAULT_RELATED_ORIGIN_CAP = 5
def origin_url(key: str) -> str: def origin_url(key: str) -> str:
"""URL form of an origins-dict key (https:// is implied); wildcards and """URL form of an origins-table key (https:// is implied); wildcards
'*' pass through unchanged.""" pass through unchanged."""
if hostutil.is_wildcard_pattern(key) or key == "*" or "://" in key: if hostutil.is_wildcard_pattern(key) or "://" in key:
return key return key
return f"https://{key}" return f"https://{key}"
def origin_key(origin: str) -> str: 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 Keys are canonicalized: lowercased, and bare hosts/wildcards lose any
trailing dot. trailing dot.
@@ -50,6 +54,24 @@ def origin_key(origin: str) -> str:
return key.lower() 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: def auth_host_url(domain: DomainConfig) -> str | None:
"""Full URL of the domain's auth host origin, if one is marked.""" """Full URL of the domain's auth host origin, if one is marked."""
for key, props in domain.origins.items(): for key, props in domain.origins.items():
@@ -62,6 +84,7 @@ class Domain:
"""Runtime view of one domain: stored config plus derived values.""" """Runtime view of one domain: stored config plus derived values."""
def __init__(self, rp_id: str, config: DomainConfig, site_url: str, site_path: str): 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.rp_id = rp_id
self.config = config self.config = config
self.site_url = site_url self.site_url = site_url
@@ -69,8 +92,8 @@ class Domain:
self.passkey = Passkey( self.passkey = Passkey(
rp_id=rp_id, rp_id=rp_id,
rp_name=config.rp_name, rp_name=config.rp_name,
origins=[origin_url(k) for k in config.origins] or None, origins=[origin_url(k) for k in in_domain],
related_origins=[origin_url(k) for k in config.related], related_origins=[origin_url(k) for k in related],
) )
@property @property
@@ -85,8 +108,8 @@ class Domain:
@property @property
def related_origins(self) -> list[str]: def related_origins(self) -> list[str]:
"""Configured related (cross-domain) origins for ROR, as URLs.""" """Related (cross-domain) origins for ROR, as URLs."""
return [origin_url(k) for k in self.config.related] return sorted(self.passkey.related_origins)
@property @property
def ui_base_path(self) -> str: def ui_base_path(self) -> str:
@@ -185,19 +208,20 @@ def validate_config(
hostutil.validate_rp_id(rp_id) hostutil.validate_rp_id(rp_id)
domain_auth_host: str | None = None domain_auth_host: str | None = None
related_count = 0
for key, props in domain.origins.items(): for key, props in domain.origins.items():
is_auth = isinstance(props, OriginEntry) and props.auth_host is_auth = isinstance(props, OriginEntry) and props.auth_host
if key == "*": if key == "*":
# Shorthand for '*.{rp_id}' raise ValueError(
if is_auth: f"Origin '*' is not allowed — list '*.{rp_id}' explicitly"
raise ValueError("Origin '*' cannot be the auth host") )
continue
if hostutil.is_wildcard_pattern(key): if hostutil.is_wildcard_pattern(key):
base = key[2:].rstrip(".") base = key[2:].rstrip(".")
if not base or not hostutil.is_subdomain(base, rp_id): if not base or not hostutil.is_subdomain(base, rp_id):
raise ValueError( raise ValueError(
f"Origin '{key}' is outside the rp-id domain " f"Origin '{key}' is a wildcard outside the rp-id "
f"'{rp_id}' configure it as a related origin instead" f"domain '{rp_id}' — related origins must be "
"individual hosts"
) )
if is_auth: if is_auth:
raise ValueError(f"Wildcard origin '{key}' cannot be the auth host") 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)) hn = hostutil.origin_hostname(origin_url(key))
if not hn: if not hn:
raise ValueError(f"Invalid origin: '{key}'") raise ValueError(f"Invalid origin: '{key}'")
if not hostutil.is_subdomain(hn, rp_id): if 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 is_auth: if is_auth:
if domain_auth_host is not None: if domain_auth_host is not None:
raise ValueError( raise ValueError(
@@ -223,31 +243,14 @@ def validate_config(
# Several domains may share an auth host to consolidate # Several domains may share an auth host to consolidate
# logins; resolution picks the best suffix match. # logins; resolution picks the best suffix match.
auth_hosts.setdefault(ah, rp_id) auth_hosts.setdefault(ah, rp_id)
continue
if len(domain.related) > related_origin_cap: # Related origin (outside the rp-id domain)
if is_auth:
raise ValueError( raise ValueError(
f"Domain '{rp_id}' has {len(domain.related)} " f"Related origin '{key}' cannot be the auth host — the "
f"related origins (maximum {related_origin_cap})" "auth host must be within the rp-id domain"
)
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"
) )
related_count += 1
# A related host may be (or fall inside) another domain's # A related host may be (or fall inside) another domain's
# rp-id: a host that *is* a configured rp-id always serves its # rp-id: a host that *is* a configured rp-id always serves its
# own domain; otherwise the related listing wins dispatch over # own domain; otherwise the related listing wins dispatch over
@@ -262,6 +265,11 @@ def validate_config(
f"'{related_hosts[hn]}' and '{rp_id}'" f"'{related_hosts[hn]}' and '{rp_id}'"
) )
related_hosts[hn] = 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) rp_ids = set(config.domains)
for hn, owner in auth_hosts.items(): for hn, owner in auth_hosts.items():
@@ -299,31 +307,25 @@ def sanitize_config(
continue continue
origins: dict[str, bool | OriginEntry] = {} origins: dict[str, bool | OriginEntry] = {}
related: dict[str, bool] = dict(domain.related)
auth_seen = False auth_seen = False
for key, props in domain.origins.items(): for key, props in domain.origins.items():
is_auth = isinstance(props, OriginEntry) and props.auth_host is_auth = isinstance(props, OriginEntry) and props.auth_host
if not is_auth: if not is_auth:
props = True # canonicalize junk/empty entries to presence-only props = True # canonicalize junk/empty entries to presence-only
if key == "*": if key == "*":
# Shorthand for '*.{rp_id}'; wildcards cannot be auth hosts
if is_auth:
warn( warn(
f"Domain '{rp_id}': origin '*' cannot be the " f"Domain '{rp_id}': origin '*' rewritten as '*.{rp_id}'"
"auth host mark cleared" + ("auth host mark cleared" if is_auth else "")
) )
origins[f"*.{rp_id}"] = True origins[f"*.{rp_id}"] = True
continue continue
if hostutil.is_wildcard_pattern(key): if hostutil.is_wildcard_pattern(key):
base = key[2:].rstrip(".") base = key[2:].rstrip(".")
if not base: if not base or not hostutil.is_subdomain(base, rp_id):
warn(f"Domain '{rp_id}': invalid origin '{key}' dropped")
continue
if not hostutil.is_subdomain(base, rp_id):
warn( warn(
f"Domain '{rp_id}': origin '{key}' is outside the " f"Domain '{rp_id}': origin '{key}' is a wildcard "
"rp-id domain — dropped (wildcards cannot be " "outside the rp-id domain — dropped (related origins "
"related origins)" "must be individual hosts)"
) )
continue continue
if is_auth: if is_auth:
@@ -338,9 +340,14 @@ def sanitize_config(
if not hn: if not hn:
warn(f"Domain '{rp_id}': invalid origin '{key}' dropped") warn(f"Domain '{rp_id}': invalid origin '{key}' dropped")
continue continue
if hostutil.is_subdomain(hn, rp_id):
if is_auth: 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( warn(
f"Domain '{rp_id}': several origins marked as " f"Domain '{rp_id}': several origins marked as "
f"auth host — extra mark on '{key}' cleared" f"auth host — extra mark on '{key}' cleared"
@@ -349,49 +356,17 @@ def sanitize_config(
else: else:
auth_seen = True auth_seen = True
origins[key] = props 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] = {} related = sorted(k for k in origins if is_related_key(rp_id, k))
for key in related: if len(related) > related_origin_cap:
if key == "*":
warn( warn(
f"Domain '{rp_id}': related origin '*' is not allowed — " f"Domain '{rp_id}': {len(related)} related origins exceed "
"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"the maximum of {related_origin_cap} — extras dropped" 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( domains[rp_id] = DomainConfig(rp_name=domain.rp_name, origins=origins)
rp_name=domain.rp_name, origins=origins, related=related_ok
)
if not domains: if not domains:
raise ValueError("No servable domain in the stored configuration") raise ValueError("No servable domain in the stored configuration")
@@ -423,26 +398,29 @@ def sanitize_config(
seen_related: dict[str, str] = {} seen_related: dict[str, str] = {}
for rp_id, domain in domains.items(): for rp_id, domain in domains.items():
keep: dict[str, bool] = {} drop = []
for key in domain.related: for key in domain.origins:
if not is_related_key(rp_id, key):
continue
hn = hostutil.origin_hostname(origin_url(key)) hn = hostutil.origin_hostname(origin_url(key))
covered_by_rp_id = any(hostutil.is_subdomain(hn, o) for o in rp_ids) if any(hostutil.is_subdomain(hn, o) for o in rp_ids):
if covered_by_rp_id: continue # covered by a configured rp-id
keep[key] = True if hn in seen_auth_hosts:
elif hn in seen_auth_hosts:
warn( warn(
f"Domain '{rp_id}': related origin '{key}' is the " f"Domain '{rp_id}': related origin '{key}' is the "
f"auth host of '{seen_auth_hosts[hn]}' — dropped" f"auth host of '{seen_auth_hosts[hn]}' — dropped"
) )
drop.append(key)
elif hn in seen_related: elif hn in seen_related:
warn( warn(
f"Domain '{rp_id}': related origin '{key}' is also " f"Domain '{rp_id}': related origin '{key}' is also "
f"used by '{seen_related[hn]}' — dropped (first domain wins)" f"used by '{seen_related[hn]}' — dropped (first domain wins)"
) )
drop.append(key)
else: else:
seen_related[hn] = rp_id seen_related[hn] = rp_id
keep[key] = True for key in drop:
domain.related = keep del domain.origins[key]
return Config(domains=domains, listen=config.listen), warnings return Config(domains=domains, listen=config.listen), warnings
@@ -452,8 +430,8 @@ def _derive_site(
) -> tuple[str, str]: ) -> tuple[str, str]:
"""Compute a domain's site_url and site_path. """Compute a domain's site_url and site_path.
Priority: auth host > exact rp-id origin key > first concrete origin Priority: auth host > exact rp-id origin key > first concrete in-domain
key (sorted) > PASKIA_VITE_URL (localhost domain only) > origin key (sorted) > PASKIA_VITE_URL (localhost domain only) >
http://localhost:port (localhost domain) > https://rp-id. http://localhost:port (localhost domain) > https://rp-id.
""" """
if auth := auth_host_url(domain): if auth := auth_host_url(domain):
@@ -461,7 +439,9 @@ def _derive_site(
if rp_id in domain.origins: if rp_id in domain.origins:
return origin_url(rp_id), "/auth/" return origin_url(rp_id), "/auth/"
concrete = sorted( 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: if concrete:
return origin_url(concrete[0]), "/auth/" return origin_url(concrete[0]), "/auth/"
+11 -28
View File
@@ -1,10 +1,11 @@
"""Domain (rp-id) management API — master admin only. """Domain (rp-id) management API — master admin only.
Each domain is one rp-id with its own rp-name, allowed in-domain sign-in Each domain is one rp-id with its own rp-name and an origins table of
sites (origins, one of which may be marked as the auth host), and optional sign-in sites: entries within the rp-id domain are in-domain sites (one of
related origins on unrelated domains (WebAuthn Related Origin Requests). which may be marked as the auth host), entries outside it are related
All changes are validated cross-domain before being persisted, and the origins on unrelated domains (WebAuthn Related Origin Requests). All
runtime domain registry is rebuilt after each change so it takes effect changes are validated cross-domain before being persisted, and the runtime
domain registry is rebuilt after each change so it takes effect
immediately. immediately.
""" """
@@ -30,7 +31,6 @@ def _domain_to_api(domain: domains.Domain) -> ApiDomain:
rp_id=domain.rp_id, rp_id=domain.rp_id,
rp_name=domain.rp_name, rp_name=domain.rp_name,
origins=domain.config.origins, origins=domain.config.origins,
related=domain.config.related,
site_url=domain.site_url, site_url=domain.site_url,
auth_site_url=domain.auth_site_url, auth_site_url=domain.auth_site_url,
auth_host=domain.own_auth_host, 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). """Normalize an origins object from the admin UI (raises on malformed).
Keys arrive as bare hosts, wildcard patterns, or full origins; they are 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] = {} out: dict[str, bool | OriginEntry] = {}
for raw_key, raw_props in (values or {}).items(): 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 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: def _rebuild_registry() -> None:
"""Rebuild the runtime domain registry from the stored configuration.""" """Rebuild the runtime domain registry from the stored configuration."""
domains.init_registry(db.data().config) domains.init_registry(db.data().config)
@@ -94,10 +79,11 @@ def _check_not_locking_self_out(
raw_host = (request.headers.get("host") or "").rstrip(".") raw_host = (request.headers.get("host") or "").rstrip(".")
if not raw_host: if not raw_host:
return return
in_domain, related = domains.partition_origins(rp_id, domain.origins)
probe = Passkey( probe = Passkey(
rp_id=rp_id, rp_id=rp_id,
origins=[domains.origin_url(k) for k in domain.origins] or None, origins=[domains.origin_url(k) for k in in_domain],
related_origins=[domains.origin_url(k) for k in domain.related], related_origins=[domains.origin_url(k) for k in related],
) )
for scheme in ("https", "http"): for scheme in ("https", "http"):
try: try:
@@ -137,7 +123,6 @@ async def admin_create_domain(
new = DomainConfig( new = DomainConfig(
rp_name=(payload.get("rp_name") or "").strip() or None, rp_name=(payload.get("rp_name") or "").strip() or None,
origins=_normalize_origins_map(payload.get("origins")), origins=_normalize_origins_map(payload.get("origins")),
related=_normalize_related_map(payload.get("related")),
) )
config = db.data().config config = db.data().config
@@ -174,7 +159,6 @@ async def admin_update_domain(
updated = DomainConfig( updated = DomainConfig(
rp_name=(payload.get("rp_name") or "").strip() or None, rp_name=(payload.get("rp_name") or "").strip() or None,
origins=_normalize_origins_map(payload.get("origins")), origins=_normalize_origins_map(payload.get("origins")),
related=_normalize_related_map(payload.get("related")),
) )
would_be = Config( would_be = Config(
domains={k: updated if k == rp_id else v for k, v in config.domains.items()}, 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_id,
rp_name=updated.rp_name, rp_name=updated.rp_name,
origins=updated.origins, origins=updated.origins,
related=updated.related,
ctx=ctx, ctx=ctx,
) )
_rebuild_registry() _rebuild_registry()
+15 -24
View File
@@ -56,14 +56,12 @@ class Passkey:
rp_id: Your security domain (e.g. "example.com") rp_id: Your security domain (e.g. "example.com")
rp_name: The relying party display name (e.g. "Example App"). May be shown in authenticators. rp_name: The relying party display name (e.g. "Example App"). May be shown in authenticators.
origins: Allow-list of sign-in site origins within the rp-id domain origins: Allow-list of sign-in site origins within the rp-id domain
(e.g. ["https://app.example.com"]); "*" is shorthand for (e.g. ["https://app.example.com"]); wildcard patterns like
a wildcard over the whole rp-id domain, and wildcard "*.example.com" match the base domain and its subdomains
patterns like "*.example.com" match the base domain and over https only — except under localhost ("*.localhost"),
its subdomains over https only — except under localhost which matches any scheme and any port. Exact entries match
("*.localhost"), which matches any scheme and any port. scheme, host and port. An empty list (the default) allows
Exact entries match scheme, host and port. If not nothing — pass ["*.{rp-id}"] to allow the whole domain.
provided, the rp-id and any subdomain of it may
authenticate (same as listing "*").
related_origins: Origins on unrelated domains that may assert this related_origins: Origins on unrelated domains that may assert this
rp-id (WebAuthn Related Origin Requests). Always additive. rp-id (WebAuthn Related Origin Requests). Always additive.
supported_pub_key_algs: List of supported COSE algorithms (default is EDDSA, ECDSA_SHA_256, RSASSA_PKCS1_v1_5_SHA_256). supported_pub_key_algs: List of supported COSE algorithms (default is EDDSA, ECDSA_SHA_256, RSASSA_PKCS1_v1_5_SHA_256).
@@ -76,13 +74,8 @@ class Passkey:
self.rp_id = rp_id self.rp_id = rp_id
hostutil.validate_rp_id(rp_id) hostutil.validate_rp_id(rp_id)
self.rp_name = rp_name or rp_id self.rp_name = rp_name or rp_id
self.allowed_origins: set[str] | None = None self.allowed_origins: set[str] = set()
if origins: for o in origins or []:
# 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
if hostutil.is_wildcard_pattern(o): if hostutil.is_wildcard_pattern(o):
hostname = hostutil.origin_hostname(o) hostname = hostutil.origin_hostname(o)
else: else:
@@ -91,10 +84,9 @@ class Passkey:
if not hostname or not hostutil.is_subdomain(hostname, rp_id): if not hostname or not hostutil.is_subdomain(hostname, rp_id):
raise ValueError( raise ValueError(
f"Origin '{o}' is outside the rp-id domain '{rp_id}'" 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.add(o)
self.allowed_origins = set(normalized)
self.related_origins: set[str] = set() self.related_origins: set[str] = set()
for o in related_origins or []: for o in related_origins or []:
if hostutil.is_wildcard_pattern(o): if hostutil.is_wildcard_pattern(o):
@@ -153,11 +145,10 @@ class Passkey:
def validate_origin(self, origin: str) -> str: def validate_origin(self, origin: str) -> str:
"""Validate that origin is allowed and return it. """Validate that origin is allowed and return it.
An in-domain origin (rp-id or subdomain) is valid unless an An in-domain origin (rp-id or subdomain) must match a listed origin
allow-list of origins is configured, in which case it must match or wildcard pattern. An origin outside the rp-id domain is valid
a listed origin or wildcard pattern. An origin outside the rp-id only when explicitly listed as a related origin (Related Origin
domain is valid only when explicitly listed as a related origin Requests).
(Related Origin Requests).
Args: Args:
origin: The origin URL to validate (from WebSocket request header) origin: The origin URL to validate (from WebSocket request header)
@@ -170,7 +161,7 @@ class Passkey:
""" """
self._validate_origin_url(origin) self._validate_origin_url(origin)
if self._origin_in_subtree(origin): if self._origin_in_subtree(origin):
if self.allowed_origins is None or self._allowlisted(origin): if self._allowlisted(origin):
return origin return origin
elif origin in self.related_origins: elif origin in self.related_origins:
return origin return origin
+4 -4
View File
@@ -182,15 +182,15 @@ class ApiSettings(msgspec.Struct):
class ApiDomain(msgspec.Struct): class ApiDomain(msgspec.Struct):
"""Domain entry in the admin domain list response. """Domain entry in the admin domain list response.
origins/related mirror the stored configuration: objects keyed by host origins mirrors the stored configuration: an object keyed by host or
or wildcard pattern (https:// omitted), values True or an object with wildcard pattern (https:// omitted), values True or an object with
extra properties (auth_host). extra properties (auth_host). Entries outside the rp-id domain are
related origins.
""" """
rp_id: str rp_id: str
rp_name: str rp_name: str
origins: dict[str, bool | OriginEntry] origins: dict[str, bool | OriginEntry]
related: dict[str, bool]
site_url: str site_url: str
auth_site_url: str auth_site_url: str
auth_host: str | None auth_host: str | None
+4 -5
View File
@@ -17,7 +17,7 @@ if TYPE_CHECKING:
from paskia.domains import DomainRegistry from paskia.domains import DomainRegistry
from paskia.db.structs import OriginEntry 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) BOX_WIDTH = 60 # Inner width (excluding box chars)
@@ -103,11 +103,10 @@ def print_startup_config(
if isinstance(props, OriginEntry) and props.auth_host if isinstance(props, OriginEntry) and props.auth_host
else "" 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: if not domain.config.origins:
lines.append(line(f" Origin: {domain.rp_id} and subdomains")) lines.append(line(" Origins: (none configured)"))
for key in sorted(domain.config.related):
lines.append(line(f" Related: {origin_url(key)}"))
lines.append(bottom()) lines.append(bottom())
stderr.write("".join(lines)) stderr.write("".join(lines))
+5 -1
View File
@@ -94,7 +94,11 @@ async def test_db() -> AsyncGenerator[DB]:
data, data,
org_name="Test Organization", org_name="Test Organization",
admin_name="Test Admin", 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() await kanta.open()
+46 -20
View File
@@ -1828,8 +1828,8 @@ class TestDomains:
assert len(data) == 1 assert len(data) == 1
domain = data[0] domain = data[0]
assert domain["rp_id"] == "localhost" assert domain["rp_id"] == "localhost"
assert domain["origins"] == {} assert domain["origins"] == {"*.localhost": True}
assert domain["related"] == {} assert "related" not in domain
assert domain["auth_host"] is None assert domain["auth_host"] is None
assert domain["site_url"] == "http://localhost:4401" assert domain["site_url"] == "http://localhost:4401"
@@ -1900,26 +1900,39 @@ class TestDomains:
self, self,
client: httpx.AsyncClient, client: httpx.AsyncClient,
session_token: str, session_token: str,
test_user,
test_credential,
): ):
"""With no origins left, site_url must not keep the removed auth host.""" """Emptying a domain's origins table must not keep the removed auth
headers = await self._set_auth_host( host in derived URLs. Only possible on a domain other than the one
client, session_token, test_user, test_credential 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( r = await client.patch(
"/auth/api/admin/domains/localhost", "/auth/api/admin/domains/example.com",
json={"rp_name": "", "origins": {}}, json={"rp_name": "", "origins": {}},
headers=headers, headers=headers,
) )
assert r.status_code == 200, r.text 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.own_auth_host is None
assert domain.ui_base_path == "/auth/" assert domain.ui_base_path == "/auth/"
assert "auth.localhost" not in domain.site_url assert "auth.example.com" not in domain.site_url
assert "auth.localhost" not in domain.auth_site_url assert "auth.example.com" not in domain.auth_site_url
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_create_and_delete_domain( async def test_create_and_delete_domain(
@@ -1931,8 +1944,7 @@ class TestDomains:
json={ json={
"rp_id": "example.com", "rp_id": "example.com",
"rp_name": "Example", "rp_name": "Example",
"origins": {"app.example.com": True}, "origins": {"app.example.com": True, "unrelated-site.com": True},
"related": {"unrelated-site.com": True},
}, },
headers=headers, headers=headers,
) )
@@ -1943,7 +1955,12 @@ class TestDomains:
assert set(domains_list) == {"localhost", "example.com"} assert set(domains_list) == {"localhost", "example.com"}
created = domains_list["example.com"] created = domains_list["example.com"]
assert created["rp_name"] == "Example" 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) r = await client.delete("/auth/api/admin/domains/example.com", headers=headers)
assert r.status_code == 200, r.text assert r.status_code == 200, r.text
@@ -1986,29 +2003,29 @@ class TestDomains:
# Related origin host may not collide across domains # Related origin host may not collide across domains
r = await client.post( r = await client.post(
"/auth/api/admin/domains/", "/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, headers=headers,
) )
assert r.status_code == 200 assert r.status_code == 200
r = await client.post( r = await client.post(
"/auth/api/admin/domains/", "/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, headers=headers,
) )
assert r.status_code == 400 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( r = await client.post(
"/auth/api/admin/domains/", "/auth/api/admin/domains/",
json={"rp_id": "another.com", "origins": {"elsewhere.com": True}}, json={"rp_id": "another.com", "origins": {"elsewhere.com": True}},
headers=headers, 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( r = await client.post(
"/auth/api/admin/domains/", "/auth/api/admin/domains/",
json={"rp_id": "another.com", "related": {"app.another.com": True}}, json={"rp_id": "star.com", "origins": {"*": True}},
headers=headers, headers=headers,
) )
assert r.status_code == 400 assert r.status_code == 400
@@ -2060,6 +2077,15 @@ class TestDomains:
assert r.status_code == 400 assert r.status_code == 400
assert "lock you out" in r.text 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 # Allow-list including the current host is fine
r = await client.patch( r = await client.patch(
"/auth/api/admin/domains/localhost", "/auth/api/admin/domains/localhost",
+2 -2
View File
@@ -87,7 +87,7 @@ def test_init_defaults(run_cli, tmp_path):
config = stored_config(tmp_path) config = stored_config(tmp_path)
assert list(config.domains) == ["localhost"] assert list(config.domains) == ["localhost"]
assert config.domains["localhost"].rp_name is None assert config.domains["localhost"].rp_name is None
assert config.domains["localhost"].origins == {} assert config.domains["localhost"].origins == {"*.localhost": True}
assert config.listen is None assert config.listen is None
@@ -97,7 +97,7 @@ def test_init_full_options(run_cli, tmp_path):
config = stored_config(tmp_path) config = stored_config(tmp_path)
domain = config.domains["example.com"] domain = config.domains["example.com"]
assert domain.rp_name == "Example Corp" assert domain.rp_name == "Example Corp"
assert domain.origins == {} assert domain.origins == {"*.example.com": True}
assert config.listen == ["4402"] assert config.listen == ["4402"]
+130 -82
View File
@@ -54,10 +54,12 @@ ROR_CONFIG = Config(
domains={ domains={
"company.com": DomainConfig( "company.com": DomainConfig(
rp_name="Company", rp_name="Company",
origins={"auth.company.com": OriginEntry(auth_host=True)}, origins={
related={"app.com": True}, "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): def test_valid(self):
domains.validate_config(ROR_CONFIG) 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): def test_related_origin_cap(self):
domains.validate_config( domains.validate_config(
Config( Config(
domains={ domains={
"company.com": DomainConfig( "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( Config(
domains={ domains={
"company.com": DomainConfig( "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): def test_star_origin_rejected(self):
"""In-domain origins are an allow-list; cross-domain needs related.""" """Plain '*' suggests 'anything goes' — the wildcard must be
with pytest.raises(ValueError, match="outside the rp-id domain"): explicit and under the rp-id."""
with pytest.raises(ValueError, match="not allowed"):
domains.validate_config( 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): def test_subdomain_entry_is_in_domain(self):
"""Subdomains of the rp-id are covered already; listing is an error.""" """An entry within the rp-id domain is an ordinary in-domain
with pytest.raises(ValueError, match="within the rp-id domain"): sign-in site, never a related origin."""
domains.validate_config( config = Config(domains={"a.com": DomainConfig(origins={"app.a.com": True})})
Config(domains={"a.com": DomainConfig(related={"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): def test_wildcard_outside_rp_id_rejected(self):
"""ROR entries are always individual origins; wildcards are meaningless.""" """Related origins are individual hosts; wildcards must stay within
with pytest.raises(ValueError, match="wildcard"): the rp-id domain."""
domains.validate_config(
Config(domains={"a.com": DomainConfig(related={"*.b.com": True})})
)
def test_wildcard_origin_in_domain_accepted(self):
domains.validate_config( domains.validate_config(
Config(domains={"a.com": DomainConfig(origins={"*.a.com": True})}) 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( domains.validate_config(
Config(domains={"a.com": DomainConfig(origins={"*.b.com": True})}) Config(domains={"a.com": DomainConfig(origins={"*.b.com": True})})
) )
@@ -246,16 +253,30 @@ class TestValidateConfig:
) )
) )
def test_star_origin_accepted_not_auth_host(self): def test_related_auth_host_rejected(self):
domains.validate_config( """The auth host is always in-domain; a related origin cannot
Config(domains={"a.com": DomainConfig(origins={"*": True})}) carry the mark."""
)
with pytest.raises(ValueError, match="cannot be the auth host"): with pytest.raises(ValueError, match="cannot be the auth host"):
domains.validate_config( domains.validate_config(
Config( Config(
domains={ domains={
"a.com": DomainConfig( "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( "a.com": DomainConfig(
origins={"auth.a.com": OriginEntry(auth_host=True)} 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).""" *is* a configured rp-id always serves its own domain)."""
config = Config( config = Config(
domains={ domains={
"a.com": DomainConfig(related={"app.b.com": True}), "a.com": DomainConfig(origins={"app.b.com": True}),
"b.com": DomainConfig(), "b.com": DomainConfig(),
} }
) )
@@ -295,8 +316,8 @@ class TestValidateConfig:
domains.validate_config( domains.validate_config(
Config( Config(
domains={ domains={
"a.com": DomainConfig(related={"app.b.com": True}), "a.com": DomainConfig(origins={"app.b.com": True}),
"c.com": DomainConfig(related={"app.b.com": True}), "c.com": DomainConfig(origins={"app.b.com": True}),
"b.com": DomainConfig(), "b.com": DomainConfig(),
} }
) )
@@ -305,8 +326,8 @@ class TestValidateConfig:
domains.validate_config( domains.validate_config(
Config( Config(
domains={ domains={
"a.com": DomainConfig(related={"shared.com": True}), "a.com": DomainConfig(origins={"shared.com": True}),
"c.com": DomainConfig(related={"shared.com": True}), "c.com": DomainConfig(origins={"shared.com": True}),
} }
) )
) )
@@ -333,16 +354,37 @@ class TestValidateConfig:
class TestSanitizeConfig: class TestSanitizeConfig:
"""Serving never fails on stored config problems; it degrades + warns.""" """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, warnings = domains.sanitize_config(
Config(domains={"localhost": DomainConfig(origins={"example.com": True})}) Config(domains={"localhost": DomainConfig(origins={"example.com": True})})
) )
domain = config.domains["localhost"] assert config.domains["localhost"].origins == {"example.com": True}
assert domain.origins == {} assert not warnings
assert domain.related == {"example.com": True}
assert any("related origin" in w for w in warnings)
domains.validate_config(config) # sanitized config is strict-clean domains.validate_config(config) # sanitized config is strict-clean
def test_star_origin_rewritten_explicit(self):
"""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): def test_malformed_origin_dropped(self):
config, warnings = domains.sanitize_config( config, warnings = domains.sanitize_config(
Config(domains={"a.com": DomainConfig(origins={"https://": True})}) Config(domains={"a.com": DomainConfig(origins={"https://": True})})
@@ -357,17 +399,11 @@ class TestSanitizeConfig:
assert list(config.domains) == ["ok.com"] assert list(config.domains) == ["ok.com"]
assert any("dropped" in w for w in warnings) assert any("dropped" in w for w in warnings)
def test_related_inside_own_domain_dropped(self): def test_wildcard_outside_rp_id_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):
config, warnings = domains.sanitize_config( 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) assert any("wildcard" in w for w in warnings)
domains.validate_config(config) # sanitized config is strict-clean domains.validate_config(config) # sanitized config is strict-clean
@@ -376,16 +412,17 @@ class TestSanitizeConfig:
Config( Config(
domains={ domains={
"a.com": DomainConfig( "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) assert any("maximum" in w for w in warnings)
domains.validate_config(config)
def test_auth_host_outside_domain_becomes_related(self): def test_related_auth_host_mark_cleared(self):
"""An auth-marked origin outside the rp-id degrades to a related origin.""" """An auth mark on a related (out-of-domain) origin is cleared."""
config, warnings = domains.sanitize_config( config, warnings = domains.sanitize_config(
Config( Config(
domains={ domains={
@@ -395,10 +432,9 @@ class TestSanitizeConfig:
} }
) )
) )
domain = config.domains["a.com"] assert config.domains["a.com"].origins == {"auth.b.com": True}
assert domain.origins == {} assert any("cannot be the auth host" in w for w in warnings)
assert domain.related == {"auth.b.com": True} domains.validate_config(config)
assert any("related origin" in w for w in warnings)
def test_auth_host_colliding_with_rp_id_cleared(self): def test_auth_host_colliding_with_rp_id_cleared(self):
config, warnings = domains.sanitize_config( config, warnings = domains.sanitize_config(
@@ -419,25 +455,25 @@ class TestSanitizeConfig:
config, _ = domains.sanitize_config( config, _ = domains.sanitize_config(
Config( Config(
domains={ domains={
"a.com": DomainConfig(related={"app.b.com": True}), "a.com": DomainConfig(origins={"app.b.com": True}),
"b.com": DomainConfig(), "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): def test_related_claimed_twice_first_wins(self):
"""Two non-owner domains claiming one related host: first wins.""" """Two non-owner domains claiming one related host: first wins."""
config, warnings = domains.sanitize_config( config, warnings = domains.sanitize_config(
Config( Config(
domains={ domains={
"a.com": DomainConfig(related={"shared.com": True}), "a.com": DomainConfig(origins={"shared.com": True}),
"b.com": DomainConfig(related={"shared.com": True}), "b.com": DomainConfig(origins={"shared.com": True}),
} }
) )
) )
assert config.domains["a.com"].related == {"shared.com": True} assert config.domains["a.com"].origins == {"shared.com": True}
assert config.domains["b.com"].related == {} assert config.domains["b.com"].origins == {}
assert any("first domain wins" in w for w in warnings) assert any("first domain wins" in w for w in warnings)
def test_no_domains_is_fatal(self): def test_no_domains_is_fatal(self):
@@ -446,10 +482,8 @@ class TestSanitizeConfig:
with pytest.raises(ValueError, match="No servable domain"): with pytest.raises(ValueError, match="No servable domain"):
domains.sanitize_config(Config(domains={"not a domain!": DomainConfig()})) domains.sanitize_config(Config(domains={"not a domain!": DomainConfig()}))
def test_build_tolerates_and_serves(self): def test_build_serves_related_origin(self):
# Cross-domain entry stored in origins: served as a related origin
reg = build_registry({"localhost": DomainConfig(origins={"example.com": True})}) reg = build_registry({"localhost": DomainConfig(origins={"example.com": True})})
assert reg.warnings
domain = reg.get("localhost") domain = reg.get("localhost")
assert domain.related_origins == ["https://example.com"] assert domain.related_origins == ["https://example.com"]
domain.passkey.validate_origin("https://example.com") domain.passkey.validate_origin("https://example.com")
@@ -461,14 +495,15 @@ class TestSanitizeConfig:
class TestOriginValidation: 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") 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"): 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): def test_allow_list_restricts_subtree(self):
p = Passkey(rp_id="example.com", origins=["https://app.example.com"]) p = Passkey(rp_id="example.com", origins=["https://app.example.com"])
@@ -480,8 +515,9 @@ class TestOriginValidation:
def test_related_origins_are_additive(self): def test_related_origins_are_additive(self):
p = Passkey(rp_id="example.com", related_origins=["https://app2.com"]) 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") 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"): with pytest.raises(ValueError, match="not allowed"):
p.validate_origin("https://other.com") p.validate_origin("https://other.com")
@@ -500,19 +536,13 @@ class TestOriginValidation:
with pytest.raises(ValueError, match="not allowed"): with pytest.raises(ValueError, match="not allowed"):
p.validate_origin("http://app.example.com:8080") p.validate_origin("http://app.example.com:8080")
def test_star_entry_is_wildcard_shorthand(self): def test_star_entry_rejected(self):
"""The bare '*' entry is shorthand for '*.{rp-id}' — https only.""" with pytest.raises(ValueError, match="Invalid origin"):
p = Passkey(rp_id="example.com", origins=["*"]) 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_localhost_wildcard_matches_any_scheme_and_port(self): def test_localhost_wildcard_matches_any_scheme_and_port(self):
"""Under localhost, wildcards match any scheme and any port.""" """Under localhost, wildcards match any scheme and any port."""
p = Passkey(rp_id="localhost", origins=["*"]) p = Passkey(rp_id="localhost", origins=["*.localhost"])
assert p.validate_origin("http://localhost:8080") assert p.validate_origin("http://localhost:8080")
assert p.validate_origin("http://app.localhost:3000") assert p.validate_origin("http://app.localhost:3000")
assert p.validate_origin("https://localhost") assert p.validate_origin("https://localhost")
@@ -779,6 +809,14 @@ def _read_db(path) -> DB:
return asyncio.run(_read()) 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: class TestLegacyConversion:
def test_convert_stamps_domain_everywhere(self, tmp_path): def test_convert_stamps_domain_everywhere(self, tmp_path):
src = tmp_path / "example.com.paskiadb" src = tmp_path / "example.com.paskiadb"
@@ -830,6 +868,16 @@ class TestLegacyConversion:
# The legacy OIDC provider carries over as the instance-global one # The legacy OIDC provider carries over as the instance-global one
assert converted.oidc.key == b"legacy-signing-key" 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 # Transaction log censoring