Wildcard origins + auth host configured per origin row (⋮ menu, 🔑 indicator)
This commit is contained in:
@@ -484,14 +484,15 @@ function createRealm() {
|
|||||||
origins: [],
|
origins: [],
|
||||||
originValidation: [],
|
originValidation: [],
|
||||||
wellKnownCheck: null,
|
wellKnownCheck: null,
|
||||||
authHostValidation: null,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function openRealm(realm) {
|
function openRealm(realm) {
|
||||||
// One combined list for editing: in-domain sites and related origins,
|
// One combined list for editing: in-domain sites and related origins,
|
||||||
// classified by hostname. Strip https:// scheme for editing.
|
// classified by hostname. The default is always shown explicitly as the
|
||||||
const origins = [...(realm.origins || []), ...(realm.related_origins || [])]
|
// '*.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:\/\//, ''))
|
.map(o => o.replace(/^https:\/\//, ''))
|
||||||
openDialog('realm-edit', {
|
openDialog('realm-edit', {
|
||||||
isNew: false,
|
isNew: false,
|
||||||
@@ -501,7 +502,6 @@ function openRealm(realm) {
|
|||||||
origins,
|
origins,
|
||||||
originValidation: origins.map(() => null),
|
originValidation: origins.map(() => null),
|
||||||
wellKnownCheck: null,
|
wellKnownCheck: null,
|
||||||
authHostValidation: null,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -945,13 +945,19 @@ async function submitDialog() {
|
|||||||
const auth_host = d.auth_host?.trim() || ''
|
const auth_host = d.auth_host?.trim() || ''
|
||||||
// The combined origins list is split by hostname: entries on the
|
// The combined origins list is split by hostname: entries on the
|
||||||
// rp-id domain form the in-domain allow-list, entries elsewhere are
|
// 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://.
|
// normalizes them with https://.
|
||||||
const origins = []
|
const origins = []
|
||||||
const related_origins = []
|
const related_origins = []
|
||||||
for (const o of (d.origins || []).map(o => o.trim()).filter(o => o)) {
|
for (const o of (d.origins || []).map(o => o.trim()).filter(o => o)) {
|
||||||
let hn = null
|
let hn = null
|
||||||
|
if (o.startsWith('*.')) {
|
||||||
|
hn = o.slice(2).replace(/\.+$/, '')
|
||||||
|
} else {
|
||||||
try { hn = new URL(o.startsWith('http') ? o : 'https://' + o).hostname } catch { continue }
|
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)
|
if (hn === rp_id || hn.endsWith('.' + rp_id)) origins.push(o)
|
||||||
else related_origins.push(o)
|
else related_origins.push(o)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed, watch } from 'vue'
|
import { computed, ref, watch } from 'vue'
|
||||||
import Modal from '@/components/Modal.vue'
|
import Modal from '@/components/Modal.vue'
|
||||||
import NameEditForm from '@/components/NameEditForm.vue'
|
import NameEditForm from '@/components/NameEditForm.vue'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
@@ -20,9 +20,6 @@ const realmRpId = computed(() => props.dialog?.data?.rp_id || '')
|
|||||||
|
|
||||||
// Initialize validation properties
|
// Initialize validation properties
|
||||||
if (props.dialog?.data && props.dialog.type === 'realm-edit') {
|
if (props.dialog?.data && props.dialog.type === 'realm-edit') {
|
||||||
if (!('authHostValidation' in props.dialog.data)) {
|
|
||||||
props.dialog.data.authHostValidation = null
|
|
||||||
}
|
|
||||||
if (!('originValidation' in props.dialog.data)) {
|
if (!('originValidation' in props.dialog.data)) {
|
||||||
props.dialog.data.originValidation = (props.dialog.data.origins || []).map(() => null)
|
props.dialog.data.originValidation = (props.dialog.data.origins || []).map(() => null)
|
||||||
}
|
}
|
||||||
@@ -31,14 +28,13 @@ if (props.dialog?.data && props.dialog.type === 'realm-edit') {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Block submit on hard errors: malformed entries, auth-host outside the
|
// Block submit on hard errors: malformed entries or validation still in
|
||||||
// rp-id domain, or validation still in flight. Connectivity and rp-id
|
// flight. Connectivity and rp-id mismatch results are warnings only
|
||||||
// mismatch results are warnings only (entries may be hosted elsewhere, or
|
// (entries may be hosted elsewhere, or a new domain whose DNS is not
|
||||||
// a new domain whose DNS is not routed to this instance yet).
|
// routed to this instance yet).
|
||||||
const isValidationInvalid = computed(() => {
|
const isValidationInvalid = computed(() => {
|
||||||
if (props.dialog?.type !== 'realm-edit') return false
|
if (props.dialog?.type !== 'realm-edit') return false
|
||||||
const d = props.dialog.data
|
const d = props.dialog.data
|
||||||
if (d.authHostValidation === 'invalid-domain' || d.authHostValidation === 'validating') return true
|
|
||||||
const bad = v => v === 'invalid' || v === 'validating'
|
const bad = v => v === 'invalid' || v === 'validating'
|
||||||
if (d.originValidation?.some(bad)) return true
|
if (d.originValidation?.some(bad)) return true
|
||||||
if (d.isNew && !isWellFormedDomain(d.rp_id || '')) return true
|
if (d.isNew && !isWellFormedDomain(d.rp_id || '')) return true
|
||||||
@@ -117,6 +113,10 @@ function isWellFormedDomain(value) {
|
|||||||
|
|
||||||
function originHostname(origin) {
|
function originHostname(origin) {
|
||||||
if (!origin.trim()) return null
|
if (!origin.trim()) return null
|
||||||
|
if (origin.trim().startsWith('*.')) {
|
||||||
|
const base = origin.trim().slice(2).replace(/\.+$/, '')
|
||||||
|
return isWellFormedDomain(base) ? base : null
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const url = origin.startsWith('http') ? new URL(origin) : new URL('https://' + origin)
|
const url = origin.startsWith('http') ? new URL(origin) : new URL('https://' + origin)
|
||||||
return url.hostname || null
|
return url.hostname || null
|
||||||
@@ -162,10 +162,15 @@ async function validateOriginConnectivity(i) {
|
|||||||
function validateOrigin(i) {
|
function validateOrigin(i) {
|
||||||
const d = props.dialog?.data
|
const d = props.dialog?.data
|
||||||
if (!d) return
|
if (!d) return
|
||||||
if (!originHostname(d.origins[i])) {
|
const value = d.origins[i]
|
||||||
|
if (!originHostname(value)) {
|
||||||
d.originValidation[i] = 'invalid'
|
d.originValidation[i] = 'invalid'
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (value.trim().startsWith('*.')) {
|
||||||
|
d.originValidation[i] = null // wildcards have no concrete site to probe
|
||||||
|
return
|
||||||
|
}
|
||||||
validateOriginConnectivity(i)
|
validateOriginConnectivity(i)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -198,44 +203,77 @@ async function testWellKnown() {
|
|||||||
}
|
}
|
||||||
watch(() => relatedEntries.value.map(asHttpsOrigin).join('|'), testWellKnown, { immediate: true })
|
watch(() => relatedEntries.value.map(asHttpsOrigin).join('|'), testWellKnown, { immediate: true })
|
||||||
|
|
||||||
async function validateAuthHostConnectivity(authHost) {
|
// Seed the default wildcard entry for a new domain once its rp-id is known,
|
||||||
|
// so the list always shows what is allowed ('*.example.com' = the domain and
|
||||||
|
// all its subdomains). Removing the last in-domain entry is blocked in the
|
||||||
|
// row menu, so the list never becomes empty afterwards.
|
||||||
|
watch(realmRpId, rp => {
|
||||||
const d = props.dialog?.data
|
const d = props.dialog?.data
|
||||||
if (!d) return
|
if (props.dialog?.type !== 'realm-edit' || !d?.isNew) return
|
||||||
|
if (!d.origins.length && isWellFormedDomain(rp)) {
|
||||||
|
d.origins.push('*.' + rp)
|
||||||
|
d.originValidation.push(null)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
d.authHostValidation = 'validating'
|
// --- Row menu: auth host assignment and entry removal ---
|
||||||
try {
|
|
||||||
const cleanAuthHost = authHost.replace(/\/+$/, '')
|
const openMenu = ref(null)
|
||||||
const testUrl = cleanAuthHost.startsWith('http') ? cleanAuthHost : 'https://' + cleanAuthHost
|
|
||||||
const response = await fetch(testUrl + '/auth/api/settings', {
|
// Host[:port] of an entry or the auth_host value, for comparison.
|
||||||
method: 'GET',
|
function hostWithPort(value) {
|
||||||
headers: { 'Accept': 'application/json' }
|
if (!value?.trim()) return null
|
||||||
})
|
if (value.trim().startsWith('*.')) return null
|
||||||
if (d.auth_host !== authHost) return // auth_host changed while validating
|
try { return new URL(value.startsWith('http') ? value : 'https://' + value).host } catch { return null }
|
||||||
if (response.ok) {
|
|
||||||
const data = await response.json()
|
|
||||||
d.authHostValidation = (data.rp_id && data.rp_id === realmRpId.value) ? 'valid' : 'mismatch'
|
|
||||||
} else {
|
|
||||||
d.authHostValidation = 'unreachable'
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
if (d.auth_host === authHost) {
|
|
||||||
d.authHostValidation = 'unreachable'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function validateAuthHost() {
|
function isAuthHostEntry(origin) {
|
||||||
const d = props.dialog?.data
|
const d = props.dialog?.data
|
||||||
if (!d || !d.auth_host?.trim()) {
|
const host = hostWithPort(origin)
|
||||||
if (d) d.authHostValidation = null // Allow empty
|
return !!(host && d?.auth_host && host === hostWithPort(d.auth_host))
|
||||||
return
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (isWithinDomain(d.auth_host, realmRpId.value)) {
|
function setAuthHost(i) {
|
||||||
validateAuthHostConnectivity(d.auth_host)
|
const d = props.dialog?.data
|
||||||
} else {
|
if (!d) return
|
||||||
d.authHostValidation = 'invalid-domain'
|
let entry = d.origins[i].trim()
|
||||||
|
if (entry.startsWith('*.')) {
|
||||||
|
// A wildcard cannot be the auth host — create a concrete auth.<base> entry
|
||||||
|
entry = 'auth.' + entry.slice(2)
|
||||||
|
if (!d.origins.some(o => hostWithPort(o) === entry)) {
|
||||||
|
d.origins.push(entry)
|
||||||
|
d.originValidation.push(null)
|
||||||
|
validateOrigin(d.origins.length - 1)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
d.auth_host = hostWithPort(entry) || entry
|
||||||
|
openMenu.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearAuthHost() {
|
||||||
|
const d = props.dialog?.data
|
||||||
|
if (d) d.auth_host = ''
|
||||||
|
openMenu.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
const inDomainCount = computed(() =>
|
||||||
|
(props.dialog?.data?.origins || []).filter(o => originHostname(o) && !isRelatedEntry(o)).length
|
||||||
|
)
|
||||||
|
|
||||||
|
function canRemoveOrigin(i) {
|
||||||
|
const o = props.dialog?.data?.origins[i]
|
||||||
|
if (o === undefined) return false
|
||||||
|
// Never empty the in-domain list — that would silently mean the wildcard
|
||||||
|
// default on the server; keep at least one in-domain entry visible
|
||||||
|
return isRelatedEntry(o) || !originHostname(o) || inDomainCount.value > 1
|
||||||
|
}
|
||||||
|
|
||||||
|
function onRemoveOrigin(i) {
|
||||||
|
const d = props.dialog?.data
|
||||||
|
if (!d || !canRemoveOrigin(i)) return
|
||||||
|
if (isAuthHostEntry(d.origins[i])) d.auth_host = ''
|
||||||
|
removeOrigin(i)
|
||||||
|
openMenu.value = null
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -321,15 +359,6 @@ function validateAuthHost() {
|
|||||||
<label>Display Name (rp-name)
|
<label>Display Name (rp-name)
|
||||||
<input v-model="dialog.data.rp_name" :placeholder="dialog.data.rp_id" />
|
<input v-model="dialog.data.rp_name" :placeholder="dialog.data.rp_id" />
|
||||||
</label>
|
</label>
|
||||||
<label>Dedicated Authentication Site (auth-host)
|
|
||||||
<input v-model="dialog.data.auth_host" @input="validateAuthHost()" :class="{ 'input-error': dialog.data.authHostValidation === 'invalid-domain' }" />
|
|
||||||
</label>
|
|
||||||
<p v-if="dialog.data.authHostValidation === 'validating'" class="small muted">Validating...</p>
|
|
||||||
<p v-else-if="dialog.data.authHostValidation === 'valid'" class="small muted">Valid</p>
|
|
||||||
<p v-else-if="dialog.data.authHostValidation === 'invalid-domain'" class="small muted">Must be {{ dialog.data.rp_id }} or a subdomain of it.</p>
|
|
||||||
<p v-else-if="dialog.data.authHostValidation === 'unreachable'" class="small muted">Well-formed but unreachable — make sure it is routed to this instance.</p>
|
|
||||||
<p v-else-if="dialog.data.authHostValidation === 'mismatch'" class="small muted">Reachable, but does not serve this domain.</p>
|
|
||||||
<p v-else class="small muted">Optional. Moves the account and admin interface to this one hostname. Sign-in works on every site regardless.</p>
|
|
||||||
|
|
||||||
<div class="origin-label">
|
<div class="origin-label">
|
||||||
Allowed Origins
|
Allowed Origins
|
||||||
@@ -344,14 +373,24 @@ function validateAuthHost() {
|
|||||||
class="origin-input"
|
class="origin-input"
|
||||||
:class="{ 'input-error': dialog.data.originValidation[i] === 'invalid' }"
|
:class="{ 'input-error': dialog.data.originValidation[i] === 'invalid' }"
|
||||||
/>
|
/>
|
||||||
<span v-if="isRelatedEntry(dialog.data.origins[i])" class="ror-tag" title="Related origin (WebAuthn ROR) — shares this domain's passkeys">related</span>
|
<span v-if="isAuthHostEntry(dialog.data.origins[i])" class="key-badge" title="Authentication site — the account and admin interface live here">🔑</span>
|
||||||
<button type="button" class="icon-btn delete-icon" @click="removeOrigin(i)" aria-label="Remove origin" title="Remove origin">❌</button>
|
<span v-else-if="isRelatedEntry(dialog.data.origins[i])" class="ror-tag" title="Related origin (WebAuthn ROR) — shares this domain's passkeys">related</span>
|
||||||
|
<div class="row-menu">
|
||||||
|
<button type="button" class="icon-btn" @click.stop="openMenu = openMenu === i ? null : i" aria-label="Origin actions" title="Actions">⋮</button>
|
||||||
|
<div v-if="openMenu === i" class="row-menu-popup">
|
||||||
|
<button v-if="isAuthHostEntry(dialog.data.origins[i])" type="button" @click="clearAuthHost()">Remove auth host</button>
|
||||||
|
<button v-else-if="!isRelatedEntry(dialog.data.origins[i]) && originHostname(dialog.data.origins[i])" type="button" @click="setAuthHost(i)">Set as auth host</button>
|
||||||
|
<button type="button" @click="onRemoveOrigin(i)" :disabled="!canRemoveOrigin(i)">Remove entry</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p v-if="dialog.data.originValidation.some(v => v === 'unreachable')" class="small muted">Some sites are unreachable — make sure they are routed to this instance.</p>
|
<p v-if="dialog.data.originValidation.some(v => v === 'unreachable')" class="small muted">Some sites are unreachable — make sure they are routed to this instance.</p>
|
||||||
<p v-else-if="dialog.data.originValidation.some(v => v === 'mismatch')" class="small muted">Some sites are reachable but do not serve this domain.</p>
|
<p v-else-if="dialog.data.originValidation.some(v => v === 'mismatch')" class="small muted">Some sites are reachable but do not serve this domain.</p>
|
||||||
</div>
|
</div>
|
||||||
<p v-if="!dialog.data.origins.length" class="small muted">All of <strong>{{ dialog.data.rp_id }}</strong> 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.</p>
|
<p class="small muted">
|
||||||
<p v-else class="small muted">Only the listed sites may sign in with this domain's passkeys. Entries on other domain names become related origins (WebAuthn ROR).</p>
|
Only the listed sites may sign in with this domain's passkeys — <strong>*.{{ dialog.data.rp_id }}</strong> 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 ⋮).
|
||||||
|
</p>
|
||||||
|
|
||||||
<template v-if="relatedEntries.length">
|
<template v-if="relatedEntries.length">
|
||||||
<p v-if="relatedEntries.length > 5" class="small error">Browsers support at most 5 related origins — {{ relatedEntries.length }} listed, extras will not work.</p>
|
<p v-if="relatedEntries.length > 5" class="small error">Browsers support at most 5 related origins — {{ relatedEntries.length }} listed, extras will not work.</p>
|
||||||
@@ -423,6 +462,12 @@ function validateAuthHost() {
|
|||||||
.origin-input { flex: 1; min-width: 8rem; font-family: var(--font-mono, monospace); }
|
.origin-input { flex: 1; min-width: 8rem; font-family: var(--font-mono, monospace); }
|
||||||
.origin-row .delete-icon { flex-shrink: 0; }
|
.origin-row .delete-icon { flex-shrink: 0; }
|
||||||
.origin-add-btn { font-size: 1.2rem; }
|
.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; }
|
.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 { 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; }
|
.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; }
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ function domainDisplay(domain) {
|
|||||||
// replaces the default "*.rp_id" wildcard; related domains (ROR) are always
|
// replaces the default "*.rp_id" wildcard; related domains (ROR) are always
|
||||||
// listed individually — they cannot use wildcards.
|
// listed individually — they cannot use wildcards.
|
||||||
function originHost(origin) {
|
function originHost(origin) {
|
||||||
|
if (origin.startsWith('*.')) return origin
|
||||||
try { return new URL(origin).host } catch { return origin }
|
try { return new URL(origin).host } catch { return origin }
|
||||||
}
|
}
|
||||||
function allowedOrigins(realm) {
|
function allowedOrigins(realm) {
|
||||||
|
|||||||
+14
-1
@@ -201,6 +201,11 @@ def validate_config(
|
|||||||
f"related origins (maximum {related_origin_cap})"
|
f"related origins (maximum {related_origin_cap})"
|
||||||
)
|
)
|
||||||
for origin in realm.related_origins or []:
|
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)
|
hn = hostutil.origin_hostname(origin)
|
||||||
if not hn:
|
if not hn:
|
||||||
raise ValueError(f"Invalid related origin URL: '{origin}'")
|
raise ValueError(f"Invalid related origin URL: '{origin}'")
|
||||||
@@ -295,6 +300,12 @@ def sanitize_config(
|
|||||||
|
|
||||||
related_ok = []
|
related_ok = []
|
||||||
for origin in related:
|
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)
|
hn = hostutil.origin_hostname(origin)
|
||||||
if not hn:
|
if not hn:
|
||||||
warn(f"Realm '{rp_id}': invalid related origin '{origin}' dropped")
|
warn(f"Realm '{rp_id}': invalid related origin '{origin}' dropped")
|
||||||
@@ -401,7 +412,9 @@ def _derive_site(
|
|||||||
if realm.auth_host:
|
if realm.auth_host:
|
||||||
return realm.auth_host, "/"
|
return realm.auth_host, "/"
|
||||||
if realm.origins:
|
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 realm.rp_id == "localhost":
|
||||||
if vite_url:
|
if vite_url:
|
||||||
return vite_url.rstrip("/"), "/auth/"
|
return vite_url.rstrip("/"), "/auth/"
|
||||||
|
|||||||
+29
-6
@@ -56,8 +56,10 @@ 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"]). If not provided, the
|
(e.g. ["https://app.example.com"]); wildcard patterns like
|
||||||
rp-id and any subdomain of it may authenticate.
|
"*.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
|
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).
|
||||||
@@ -84,6 +86,11 @@ class Passkey:
|
|||||||
self.allowed_origins = set(origins)
|
self.allowed_origins = set(origins)
|
||||||
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):
|
||||||
|
raise ValueError(
|
||||||
|
f"Related origin '{o}' is a wildcard — related origins "
|
||||||
|
"(ROR) must be listed individually"
|
||||||
|
)
|
||||||
self._validate_origin_url(o)
|
self._validate_origin_url(o)
|
||||||
hostname = hostutil.origin_hostname(o)
|
hostname = hostutil.origin_hostname(o)
|
||||||
if hostutil.is_subdomain(hostname, rp_id):
|
if hostutil.is_subdomain(hostname, rp_id):
|
||||||
@@ -109,13 +116,29 @@ class Passkey:
|
|||||||
hostname = hostutil.origin_hostname(origin)
|
hostname = hostutil.origin_hostname(origin)
|
||||||
return bool(hostname) and hostutil.is_subdomain(hostname, self.rp_id)
|
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:
|
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) is valid unless an
|
||||||
allow-list of origins is configured, in which case it must be
|
allow-list of origins is configured, in which case it must match
|
||||||
listed. An origin outside the rp-id domain is valid only when
|
a listed origin or wildcard pattern. An origin outside the rp-id
|
||||||
explicitly listed as a related origin (Related Origin Requests).
|
domain is valid only when explicitly listed as a related origin
|
||||||
|
(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)
|
||||||
@@ -128,7 +151,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 origin in self.allowed_origins:
|
if self.allowed_origins is None or self._allowlisted(origin):
|
||||||
return origin
|
return origin
|
||||||
elif origin in self.related_origins:
|
elif origin in self.related_origins:
|
||||||
return origin
|
return origin
|
||||||
|
|||||||
+18
-2
@@ -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")
|
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:
|
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:
|
if "://" not in origin:
|
||||||
return f"https://{origin}"
|
return f"https://{origin}"
|
||||||
return origin.rstrip("/")
|
return origin.rstrip("/")
|
||||||
|
|
||||||
|
|
||||||
def origin_hostname(origin: str) -> str | None:
|
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
|
return urlparse(origin).hostname
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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):
|
def test_auth_host_collision(self):
|
||||||
with pytest.raises(ValueError, match="collides with a related origin"):
|
with pytest.raises(ValueError, match="collides with a related origin"):
|
||||||
realms.validate_config(
|
realms.validate_config(
|
||||||
@@ -309,6 +325,14 @@ class TestSanitizeConfig:
|
|||||||
)
|
)
|
||||||
assert config.realms[0].related_origins is None
|
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):
|
def test_cap_exceeded_truncated(self):
|
||||||
config, warnings = realms.sanitize_config(
|
config, warnings = realms.sanitize_config(
|
||||||
Config(
|
Config(
|
||||||
@@ -400,6 +424,26 @@ class TestOriginValidation:
|
|||||||
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")
|
||||||
|
|
||||||
|
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):
|
def test_related_origins_combined_with_allow_list(self):
|
||||||
p = Passkey(
|
p = Passkey(
|
||||||
rp_id="example.com",
|
rp_id="example.com",
|
||||||
|
|||||||
Reference in New Issue
Block a user