diff --git a/frontend/auth/admin/AdminApp.vue b/frontend/auth/admin/AdminApp.vue index e3472f8..f1aca61 100644 --- a/frontend/auth/admin/AdminApp.vue +++ b/frontend/auth/admin/AdminApp.vue @@ -484,14 +484,15 @@ function createRealm() { origins: [], originValidation: [], wellKnownCheck: null, - authHostValidation: null, }) } function openRealm(realm) { // One combined list for editing: in-domain sites and related origins, - // classified by hostname. Strip https:// scheme for editing. - const origins = [...(realm.origins || []), ...(realm.related_origins || [])] + // classified by hostname. The default is always shown explicitly as the + // '*.rp_id' wildcard entry. Strip https:// scheme for editing. + const stored = realm.origins || [] + const origins = [...(stored.length ? stored : ['*.' + realm.rp_id]), ...(realm.related_origins || [])] .map(o => o.replace(/^https:\/\//, '')) openDialog('realm-edit', { isNew: false, @@ -501,7 +502,6 @@ function openRealm(realm) { origins, originValidation: origins.map(() => null), wellKnownCheck: null, - authHostValidation: null, }) } @@ -945,13 +945,19 @@ async function submitDialog() { const auth_host = d.auth_host?.trim() || '' // The combined origins list is split by hostname: entries on the // rp-id domain form the in-domain allow-list, entries elsewhere are - // related origins (ROR). Bare hostnames are sent as-is; the backend + // related origins (ROR). Wildcards ('*.app.example.com') classify by + // their base domain. Bare hostnames are sent as-is; the backend // normalizes them with https://. const origins = [] const related_origins = [] for (const o of (d.origins || []).map(o => o.trim()).filter(o => o)) { let hn = null - try { hn = new URL(o.startsWith('http') ? o : 'https://' + o).hostname } catch { continue } + if (o.startsWith('*.')) { + hn = o.slice(2).replace(/\.+$/, '') + } else { + try { hn = new URL(o.startsWith('http') ? o : 'https://' + o).hostname } catch { continue } + } + if (!hn) continue if (hn === rp_id || hn.endsWith('.' + rp_id)) origins.push(o) else related_origins.push(o) } diff --git a/frontend/src/admin/AdminDialogs.vue b/frontend/src/admin/AdminDialogs.vue index cbfd47a..833a4dc 100644 --- a/frontend/src/admin/AdminDialogs.vue +++ b/frontend/src/admin/AdminDialogs.vue @@ -1,5 +1,5 @@ @@ -321,15 +359,6 @@ function validateAuthHost() { - -
Validating...
-Valid
-Must be {{ dialog.data.rp_id }} or a subdomain of it.
-Well-formed but unreachable — make sure it is routed to this instance.
-Reachable, but does not serve this domain.
-Optional. Moves the account and admin interface to this one hostname. Sign-in works on every site regardless.
Some sites are unreachable — make sure they are routed to this instance.
Some sites are reachable but do not serve this domain.
-All of {{ dialog.data.rp_id }} and its subdomains may sign in (default). Add entries to restrict sign-in to specific sites, or to share this domain's passkeys with another domain name.
-Only the listed sites may sign in with this domain's passkeys. Entries on other domain names become related origins (WebAuthn ROR).
++ Only the listed sites may sign in with this domain's passkeys — *.{{ dialog.data.rp_id }} means the domain and all its subdomains. + Entries on other domain names become related origins (WebAuthn ROR). The 🔑 site hosts the account and admin interface (set via ⋮). +
Browsers support at most 5 related origins — {{ relatedEntries.length }} listed, extras will not work.
@@ -423,6 +462,12 @@ function validateAuthHost() { .origin-input { flex: 1; min-width: 8rem; font-family: var(--font-mono, monospace); } .origin-row .delete-icon { flex-shrink: 0; } .origin-add-btn { font-size: 1.2rem; } +.key-badge { flex-shrink: 0; } +.row-menu { position: relative; flex-shrink: 0; } +.row-menu-popup { position: absolute; right: 0; top: 100%; z-index: 10; display: flex; flex-direction: column; min-width: 9rem; background: var(--color-bg, #fff); border: 1px solid var(--color-border, #ccc); border-radius: 4px; box-shadow: 0 2px 8px rgba(0,0,0,0.15); } +.row-menu-popup button { text-align: left; padding: var(--space-xs) var(--space-sm); background: none; border: none; cursor: pointer; white-space: nowrap; } +.row-menu-popup button:hover:not(:disabled) { background: var(--color-bg-soft, rgba(127,127,127,0.12)); } +.row-menu-popup button:disabled { opacity: 0.5; cursor: default; } .ror-tag { flex-shrink: 0; font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.03em; color: var(--color-text-muted); border: 1px solid var(--color-border, currentColor); border-radius: 3px; padding: 0 0.3rem; } .wellknown-doc { display: flex; align-items: flex-start; gap: var(--space-xs); } .wellknown-doc pre { flex: 1; margin: 0; padding: var(--space-xs) var(--space-sm); font-size: 0.8rem; background: var(--color-bg-soft, rgba(127,127,127,0.08)); border-radius: 4px; overflow-x: auto; } diff --git a/frontend/src/admin/AdminOverview.vue b/frontend/src/admin/AdminOverview.vue index 3159f4b..e6eb417 100644 --- a/frontend/src/admin/AdminOverview.vue +++ b/frontend/src/admin/AdminOverview.vue @@ -42,6 +42,7 @@ function domainDisplay(domain) { // replaces the default "*.rp_id" wildcard; related domains (ROR) are always // listed individually — they cannot use wildcards. function originHost(origin) { + if (origin.startsWith('*.')) return origin try { return new URL(origin).host } catch { return origin } } function allowedOrigins(realm) { diff --git a/paskia/realms.py b/paskia/realms.py index 99fb53b..f0eae7b 100644 --- a/paskia/realms.py +++ b/paskia/realms.py @@ -201,6 +201,11 @@ def validate_config( f"related origins (maximum {related_origin_cap})" ) for origin in realm.related_origins or []: + if hostutil.is_wildcard_pattern(origin): + raise ValueError( + f"Related origin '{origin}' is a wildcard — related " + "origins (ROR) must be listed individually" + ) hn = hostutil.origin_hostname(origin) if not hn: raise ValueError(f"Invalid related origin URL: '{origin}'") @@ -295,6 +300,12 @@ def sanitize_config( related_ok = [] for origin in related: + if hostutil.is_wildcard_pattern(origin): + warn( + f"Realm '{rp_id}': related origin '{origin}' is a " + "wildcard — dropped (ROR entries must be individual)" + ) + continue hn = hostutil.origin_hostname(origin) if not hn: warn(f"Realm '{rp_id}': invalid related origin '{origin}' dropped") @@ -401,7 +412,9 @@ def _derive_site( if realm.auth_host: return realm.auth_host, "/" if realm.origins: - return realm.origins[0], "/auth/" + for origin in realm.origins: + if not hostutil.is_wildcard_pattern(origin): + return origin, "/auth/" if realm.rp_id == "localhost": if vite_url: return vite_url.rstrip("/"), "/auth/" diff --git a/paskia/sansio.py b/paskia/sansio.py index ab9c767..5c93bc3 100644 --- a/paskia/sansio.py +++ b/paskia/sansio.py @@ -56,8 +56,10 @@ class Passkey: rp_id: Your security domain (e.g. "example.com") rp_name: The relying party display name (e.g. "Example App"). May be shown in authenticators. origins: Allow-list of sign-in site origins within the rp-id domain - (e.g. ["https://app.example.com"]). If not provided, the - rp-id and any subdomain of it may authenticate. + (e.g. ["https://app.example.com"]); wildcard patterns like + "*.example.com" match the base domain and its subdomains. + If not provided, the rp-id and any subdomain of it may + authenticate. related_origins: Origins on unrelated domains that may assert this rp-id (WebAuthn Related Origin Requests). Always additive. supported_pub_key_algs: List of supported COSE algorithms (default is EDDSA, ECDSA_SHA_256, RSASSA_PKCS1_v1_5_SHA_256). @@ -84,6 +86,11 @@ class Passkey: self.allowed_origins = set(origins) self.related_origins: set[str] = set() for o in related_origins or []: + if hostutil.is_wildcard_pattern(o): + raise ValueError( + f"Related origin '{o}' is a wildcard — related origins " + "(ROR) must be listed individually" + ) self._validate_origin_url(o) hostname = hostutil.origin_hostname(o) if hostutil.is_subdomain(hostname, rp_id): @@ -109,13 +116,29 @@ class Passkey: hostname = hostutil.origin_hostname(origin) return bool(hostname) and hostutil.is_subdomain(hostname, self.rp_id) + def _allowlisted(self, origin: str) -> bool: + """Check an in-domain origin against the allow-list. + + An entry matches exactly, or as a wildcard pattern ('*.example.com' + matches the base domain and any subdomain of it). + """ + if origin in self.allowed_origins: + return True + hostname = hostutil.origin_hostname(origin) + return any( + hostutil.is_wildcard_pattern(entry) + and hostutil.is_subdomain(hostname, entry[2:]) + for entry in self.allowed_origins + ) + def validate_origin(self, origin: str) -> str: """Validate that origin is allowed and return it. An in-domain origin (rp-id or subdomain) is valid unless an - allow-list of origins is configured, in which case it must be - listed. An origin outside the rp-id domain is valid only when - explicitly listed as a related origin (Related Origin Requests). + allow-list of origins is configured, in which case it must match + a listed origin or wildcard pattern. An origin outside the rp-id + domain is valid only when explicitly listed as a related origin + (Related Origin Requests). Args: origin: The origin URL to validate (from WebSocket request header) @@ -128,7 +151,7 @@ class Passkey: """ self._validate_origin_url(origin) if self._origin_in_subtree(origin): - if self.allowed_origins is None or origin in self.allowed_origins: + if self.allowed_origins is None or self._allowlisted(origin): return origin elif origin in self.related_origins: return origin diff --git a/paskia/util/hostutil.py b/paskia/util/hostutil.py index c4d2421..19b7505 100644 --- a/paskia/util/hostutil.py +++ b/paskia/util/hostutil.py @@ -18,15 +18,31 @@ def validate_rp_id(rp_id: str) -> None: raise ValueError(f"rp_id '{rp_id}' is not a valid domain name") +def is_wildcard_pattern(value: str) -> bool: + """Check whether an origins entry is a wildcard pattern like '*.example.com'.""" + return value.startswith("*.") + + def normalize_origin(origin: str) -> str: - """Normalize an origin URL by adding https:// if no scheme is present, removing trailing slashes.""" + """Normalize an origin URL by adding https:// if no scheme is present, removing trailing slashes. + + Wildcard patterns ('*.example.com') pass through unchanged — they are + allow-list entries, not concrete origins. + """ + if is_wildcard_pattern(origin): + return origin.rstrip("/.") if "://" not in origin: return f"https://{origin}" return origin.rstrip("/") def origin_hostname(origin: str) -> str | None: - """Extract the lowercase hostname from an origin URL, if well-formed.""" + """Extract the lowercase hostname from an origin URL, if well-formed. + + For wildcard patterns the base domain is returned. + """ + if is_wildcard_pattern(origin): + return origin[2:].rstrip(".").lower() or None return urlparse(origin).hostname diff --git a/tests/test_realms.py b/tests/test_realms.py index 349c351..2925820 100644 --- a/tests/test_realms.py +++ b/tests/test_realms.py @@ -212,6 +212,22 @@ class TestValidateConfig: ) ) + def test_wildcard_related_origin_rejected(self): + """ROR entries are always individual origins; wildcards are meaningless.""" + with pytest.raises(ValueError, match="wildcard"): + realms.validate_config( + Config(realms=[RealmConfig(rp_id="a.com", related_origins=["*.b.com"])]) + ) + + def test_wildcard_origin_in_domain_accepted(self): + realms.validate_config( + Config(realms=[RealmConfig(rp_id="a.com", origins=["*.a.com"])]) + ) + with pytest.raises(ValueError, match="outside the rp-id domain"): + realms.validate_config( + Config(realms=[RealmConfig(rp_id="a.com", origins=["*.b.com"])]) + ) + def test_auth_host_collision(self): with pytest.raises(ValueError, match="collides with a related origin"): realms.validate_config( @@ -309,6 +325,14 @@ class TestSanitizeConfig: ) assert config.realms[0].related_origins is None + def test_wildcard_related_origin_dropped(self): + config, warnings = realms.sanitize_config( + Config(realms=[RealmConfig(rp_id="a.com", related_origins=["*.b.com"])]) + ) + assert config.realms[0].related_origins is None + assert any("wildcard" in w for w in warnings) + realms.validate_config(config) # sanitized config is strict-clean + def test_cap_exceeded_truncated(self): config, warnings = realms.sanitize_config( Config( @@ -400,6 +424,26 @@ class TestOriginValidation: with pytest.raises(ValueError, match="not allowed"): p.validate_origin("https://other.com") + def test_wildcard_entry_matches_subtree(self): + p = Passkey(rp_id="example.com", origins=["*.example.com"]) + assert p.validate_origin("https://example.com") + assert p.validate_origin("https://app.example.com") + with pytest.raises(ValueError, match="not allowed"): + p.validate_origin("https://other.com") + + def test_sub_wildcard_matches_only_its_subtree(self): + p = Passkey(rp_id="example.com", origins=["*.app.example.com"]) + assert p.validate_origin("https://app.example.com") + assert p.validate_origin("https://www.app.example.com") + with pytest.raises(ValueError, match="not allowed"): + p.validate_origin("https://example.com") + with pytest.raises(ValueError, match="not allowed"): + p.validate_origin("https://other.example.com") + + def test_wildcard_related_origin_rejected(self): + with pytest.raises(ValueError, match="wildcard"): + Passkey(rp_id="example.com", related_origins=["*.other.com"]) + def test_related_origins_combined_with_allow_list(self): p = Passkey( rp_id="example.com",