Frontend: **. wildcard support — prefill, typing shortcut, lockout matcher, sort order
This commit is contained in:
@@ -39,10 +39,10 @@ const isValidationInvalid = computed(() => {
|
||||
// A single origins list holds two kinds of entries: sites on the rp-id
|
||||
// domain form the in-domain sign-in allow-list; entries on other domain
|
||||
// names are related origins (WebAuthn ROR). Classification is automatic
|
||||
// from the hostname. A plain '*' is invalid (wildcards must sit under
|
||||
// the rp-id) and never a related origin.
|
||||
// from the hostname. A bare '*' or '**' is invalid (wildcards must sit
|
||||
// under the rp-id) and never a related origin.
|
||||
function isRelatedEntry(origin) {
|
||||
if (origin.trim().startsWith('*.')) return false // wildcards are never related
|
||||
if (isWildcardEntry(origin)) return false // wildcards are never related
|
||||
const h = originHostname(origin)
|
||||
return !!(h && dialogRpId.value && !isWithinDomain(origin, dialogRpId.value))
|
||||
}
|
||||
@@ -83,9 +83,10 @@ function copyText(value, label) {
|
||||
// always allowed), their current page origin must stay allowed to run
|
||||
// passkey ceremonies — otherwise saving locks them out. Mirrors the
|
||||
// backend check (Passkey.validate_origin): an in-domain origin matches a
|
||||
// row exactly (scheme+host+port) or a wildcard row ('*.base' covers the
|
||||
// base domain and its subdomains over https — any scheme and port under
|
||||
// localhost); a related row matches only on exact equality (https://host).
|
||||
// row exactly (scheme+host+port) or a wildcard row — '**.base' covers
|
||||
// the apex and subdomains at any depth, '*.base' exactly one subdomain
|
||||
// level — over https, except under localhost (any scheme and port);
|
||||
// a related row matches only on exact equality (https://host).
|
||||
const lockoutWarning = computed(() => {
|
||||
const d = props.dialog?.data
|
||||
if (props.dialog?.type !== 'domain-edit' || d?.isNew || d?.auth_host) return null
|
||||
@@ -95,7 +96,7 @@ const lockoutWarning = computed(() => {
|
||||
})
|
||||
|
||||
function pageOriginAllowed(rows, rpId) {
|
||||
const toUrl = key => (key.startsWith('*.') || key.includes('://')) ? key : 'https://' + key
|
||||
const toUrl = key => (isWildcardEntry(key) || key.includes('://')) ? key : 'https://' + key
|
||||
const inDomain = []
|
||||
const related = []
|
||||
for (const row of rows) {
|
||||
@@ -111,9 +112,12 @@ function pageOriginAllowed(rows, rpId) {
|
||||
if (hostname === rpId || hostname.endsWith('.' + rpId)) {
|
||||
if (inDomain.includes(origin)) return true
|
||||
return inDomain.some(e => {
|
||||
if (!e.startsWith('*.')) return false
|
||||
const base = e.slice(2).replace(/\.+$/, '')
|
||||
if (hostname !== base && !hostname.endsWith('.' + base)) return false
|
||||
const base = wildcardBase(e)
|
||||
if (!base) return false
|
||||
const matched = e.startsWith('**.')
|
||||
? hostname === base || hostname.endsWith('.' + base)
|
||||
: hostname.endsWith('.' + base) && !hostname.slice(0, -base.length - 1).includes('.')
|
||||
if (!matched) return false
|
||||
// Under localhost a wildcard matches any scheme and port
|
||||
return base === 'localhost' || base.endsWith('.localhost') || origin.startsWith('https://')
|
||||
})
|
||||
@@ -158,12 +162,28 @@ function isWellFormedDomain(value) {
|
||||
}
|
||||
}
|
||||
|
||||
// Wildcard entries follow the shell-glob convention: '*.base' covers
|
||||
// exactly one subdomain level, '**.base' the apex and any depth.
|
||||
const isWildcardEntry = value => {
|
||||
const v = value.trim()
|
||||
return v.startsWith('*.') || v.startsWith('**.')
|
||||
}
|
||||
|
||||
// Base domain of a wildcard entry (lowercased); null when the value is
|
||||
// not a wildcard pattern or has no base.
|
||||
function wildcardBase(value) {
|
||||
const v = value.trim()
|
||||
if (v.startsWith('**.')) return v.slice(3).replace(/\.+$/, '').toLowerCase() || null
|
||||
if (v.startsWith('*.')) return v.slice(2).replace(/\.+$/, '').toLowerCase() || null
|
||||
return null
|
||||
}
|
||||
|
||||
function originHostname(origin) {
|
||||
const v = origin.trim()
|
||||
if (!v || v === '*') return null // a plain '*' is not a valid entry
|
||||
if (v.startsWith('*.')) {
|
||||
const base = v.slice(2).replace(/\.+$/, '').toLowerCase()
|
||||
return isWellFormedDomain(base) ? base : null
|
||||
if (!v || v === '*' || v === '**') return null // a bare '*' or '**' is not a valid entry
|
||||
if (isWildcardEntry(v)) {
|
||||
const base = wildcardBase(v)
|
||||
return base && isWellFormedDomain(base) ? base : null
|
||||
}
|
||||
try {
|
||||
const url = v.startsWith('http') ? new URL(v) : new URL('https://' + v)
|
||||
@@ -207,17 +227,17 @@ async function validateOriginConnectivity(i) {
|
||||
}
|
||||
}
|
||||
|
||||
// A sole '*' expands to '*.<rp-id>' immediately, keeping the cursor where
|
||||
// it was (before the inserted rp-id).
|
||||
// A sole '*' or '**' expands to '**.<rp-id>' immediately, keeping the
|
||||
// cursor where it was (before the inserted rp-id).
|
||||
function onOriginInput(i, e) {
|
||||
const d = props.dialog?.data
|
||||
if (!d) return
|
||||
const el = e.target
|
||||
const oldKey = entryKey(d.origins[i])
|
||||
let value = el.value
|
||||
if (value === '*' && dialogRpId.value) {
|
||||
if ((value === '*' || value === '**') && dialogRpId.value) {
|
||||
const pos = el.selectionStart
|
||||
value = '*.' + dialogRpId.value
|
||||
value = '**.' + dialogRpId.value
|
||||
el.value = value
|
||||
el.setSelectionRange(pos, pos)
|
||||
}
|
||||
@@ -239,7 +259,7 @@ function validateOrigin(i) {
|
||||
d.originValidation[i] = 'invalid'
|
||||
return
|
||||
}
|
||||
if (value.trim().startsWith('*.')) {
|
||||
if (isWildcardEntry(value)) {
|
||||
// Wildcards have no concrete site to probe, and are only allowed
|
||||
// within the domain (related origins are individual hosts)
|
||||
d.originValidation[i] = isWithinDomain(value, dialogRpId.value) ? null : 'invalid'
|
||||
@@ -277,13 +297,13 @@ async function testWellKnown() {
|
||||
}
|
||||
watch(() => relatedEntries.value.map(asHttpsOrigin).join('|'), testWellKnown, { immediate: true })
|
||||
|
||||
// Prefill a new domain's list with the real '*.<rp-id>' row once its
|
||||
// rp-id is known ('*.x' = the domain and all its subdomains over https,
|
||||
// any scheme and port under localhost). The row follows rp-id edits while
|
||||
// it is still the untouched prefilled row; once the admin edits it, it is
|
||||
// left alone. Seeding waits for a complete-looking rp-id (letters after
|
||||
// the final dot) so mid-typing states like 'something.' don't prefill a
|
||||
// broken '*.something'.
|
||||
// Prefill a new domain's list with the real '**.<rp-id>' row once its
|
||||
// rp-id is known ('**.x' = the domain apex and all its subdomains over
|
||||
// https, any scheme and port under localhost). The row follows rp-id
|
||||
// edits while it is still the untouched prefilled row; once the admin
|
||||
// edits it, it is left alone. Seeding waits for a complete-looking rp-id
|
||||
// (letters after the final dot) so mid-typing states like 'something.'
|
||||
// don't prefill a broken '**.something'.
|
||||
function looksCompleteDomain(value) {
|
||||
const host = (value || '').trim().replace(/\.$/, '')
|
||||
return host === 'localhost' || /\.[a-z]{2,}$/i.test(host)
|
||||
@@ -294,7 +314,7 @@ watch(dialogRpId, rp => {
|
||||
const d = props.dialog?.data
|
||||
if (props.dialog?.type !== 'domain-edit' || !d?.isNew) return
|
||||
if (!looksCompleteDomain(rp) || !isWellFormedDomain(rp)) return
|
||||
const seed = '*.' + rp.trim().replace(/\.$/, '')
|
||||
const seed = '**.' + rp.trim().replace(/\.$/, '')
|
||||
if (!d.origins.length) {
|
||||
d.origins.push(seed)
|
||||
d.originValidation.push(null)
|
||||
@@ -338,9 +358,10 @@ function setAuthHost(i) {
|
||||
if (!d) return
|
||||
let key = entryKey(d.origins[i])
|
||||
let added = false
|
||||
if (key.startsWith('*.')) {
|
||||
const wbase = wildcardBase(key)
|
||||
if (wbase) {
|
||||
// A wildcard cannot be the auth host — create a concrete auth.<base> entry
|
||||
key = 'auth.' + key.slice(2)
|
||||
key = 'auth.' + wbase
|
||||
if (!d.origins.some(o => entryKey(o) === key)) {
|
||||
d.origins.push(key)
|
||||
d.originValidation.push(null)
|
||||
@@ -488,12 +509,12 @@ function onRemoveOrigin(i) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="dialog.data.originValidation.some(v => v === 'invalid')" class="small error">Some entries are invalid — a plain '*' is not allowed, and wildcards only within the domain.</p>
|
||||
<p v-if="dialog.data.originValidation.some(v => v === 'invalid')" class="small error">Some entries are invalid — a bare '*' or '**' is not allowed, and wildcards only within the domain.</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>
|
||||
</div>
|
||||
<p class="small muted">
|
||||
Only the listed sites may sign in with this domain's passkeys — <strong>*.{{ dialog.data.rp_id }}</strong> allows the whole domain over https (any scheme and port under localhost); use a full origin like <strong>http://localhost:8080</strong> for other exceptions.
|
||||
Only the listed sites may sign in with this domain's passkeys — <strong>**.{{ dialog.data.rp_id }}</strong> allows the whole domain (apex and any subdomain), <strong>*.{{ dialog.data.rp_id }}</strong> only a single subdomain level, both over https (any scheme and port under localhost); use a full origin like <strong>http://localhost:8080</strong> for other exceptions.
|
||||
Entries on other domain names become related origins (WebAuthn ROR), marked 🔗. The 🔑 site hosts the account and admin interface (set via ⋮). An empty list allows nothing of this domain (related origins still work).
|
||||
</p>
|
||||
<p v-if="lockoutWarning" class="small error">Saving would lock you out: {{ lockoutWarning }} could no longer run sign-in ceremonies for this domain. Keep it listed, or mark an auth host.</p>
|
||||
|
||||
@@ -50,8 +50,9 @@ export const hostIP = ip => {
|
||||
|
||||
// Hierarchical origin comparison: split off scheme/port, compare hostnames
|
||||
// label by label from the TLD down, parents before their subdomains and a
|
||||
// wildcard label after all concrete labels at the same level. Entries on
|
||||
// the same host tie-break by scheme (https first) and numeric port.
|
||||
// wildcard label ('**' any depth, '*' one level — in that order) after all
|
||||
// concrete labels at the same level. Entries on the same host tie-break by
|
||||
// scheme (https first) and numeric port.
|
||||
function originParts(key) {
|
||||
let s = key.toLowerCase().replace(/\/+$/, '')
|
||||
let scheme = ''
|
||||
@@ -71,8 +72,11 @@ export function compareOrigins(a, b) {
|
||||
if (la === undefined) return -1
|
||||
if (lb === undefined) return 1
|
||||
if (la === lb) continue
|
||||
if (la === '*') return 1
|
||||
if (lb === '*') return -1
|
||||
const wa = la === '*' || la === '**'
|
||||
const wb = lb === '*' || lb === '**'
|
||||
if (wa && wb) return la === '**' ? -1 : 1
|
||||
if (wa) return 1
|
||||
if (wb) return -1
|
||||
const c = la.localeCompare(lb)
|
||||
if (c) return c
|
||||
}
|
||||
@@ -86,10 +90,10 @@ export function compareOrigins(a, b) {
|
||||
}
|
||||
|
||||
// An origins-table entry outside the rp-id domain is a related origin
|
||||
// (WebAuthn ROR). Wildcards are never related — they are only valid
|
||||
// under the rp-id.
|
||||
// (WebAuthn ROR). Wildcards ('*.' or '**.') are never related — they are
|
||||
// only valid under the rp-id.
|
||||
function isRelatedKey(rpId, key) {
|
||||
if (key.startsWith('*.')) return false
|
||||
if (key.startsWith('*.') || key.startsWith('**.')) return false
|
||||
try {
|
||||
const hostname = new URL(key.includes('://') ? key : 'https://' + key).hostname
|
||||
return !!hostname && hostname !== rpId && !hostname.endsWith('.' + rpId)
|
||||
|
||||
Reference in New Issue
Block a user