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