|
|
|
@@ -19,8 +19,9 @@ const NAME_EDIT_TYPES = new Set(['org-update', 'role-update', 'user-update-name'
|
|
|
|
|
// case-insensitive)
|
|
|
|
|
const dialogRpId = computed(() => (props.dialog?.data?.rp_id || '').trim().toLowerCase())
|
|
|
|
|
|
|
|
|
|
// Block submit on hard errors: malformed entries, an over-cap related list
|
|
|
|
|
// (the server rejects the save), or validation still in flight.
|
|
|
|
|
// Block submit on hard errors: malformed entries, an over-cap related
|
|
|
|
|
// list (the server rejects the save), a save that would lock the admin
|
|
|
|
|
// out of the domain they are using, or validation still in flight.
|
|
|
|
|
// Connectivity and rp-id mismatch results are warnings only (entries may
|
|
|
|
|
// be hosted elsewhere, or a new domain whose DNS is not routed to this
|
|
|
|
|
// instance yet).
|
|
|
|
@@ -31,16 +32,17 @@ const isValidationInvalid = computed(() => {
|
|
|
|
|
if (d.originValidation?.some(bad)) return true
|
|
|
|
|
if (relatedEntries.value.length > 5) return true
|
|
|
|
|
if (d.isNew && !isWellFormedDomain(d.rp_id || '')) return true
|
|
|
|
|
if (lockoutWarning.value) return true
|
|
|
|
|
return false
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// 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 — the submit handler splits the two lists apart.
|
|
|
|
|
// from the hostname. A plain '*' is invalid (wildcards must sit under
|
|
|
|
|
// the rp-id) and never a related origin.
|
|
|
|
|
function isRelatedEntry(origin) {
|
|
|
|
|
const v = origin.trim()
|
|
|
|
|
if (v === '*' || v.startsWith('*.')) return false // wildcards are never related
|
|
|
|
|
if (origin.trim().startsWith('*.')) return false // wildcards are never related
|
|
|
|
|
const h = originHostname(origin)
|
|
|
|
|
return !!(h && dialogRpId.value && !isWithinDomain(origin, dialogRpId.value))
|
|
|
|
|
}
|
|
|
|
@@ -74,6 +76,54 @@ function copyText(value, label) {
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// --- Lockout prevention (editing the domain in use) ---
|
|
|
|
|
|
|
|
|
|
// When the admin edits the domain they are currently signed in on and no
|
|
|
|
|
// auth host is marked (with one, ceremonies move there and saving is
|
|
|
|
|
// 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).
|
|
|
|
|
const lockoutWarning = computed(() => {
|
|
|
|
|
const d = props.dialog?.data
|
|
|
|
|
if (props.dialog?.type !== 'domain-edit' || d?.isNew || d?.auth_host) return null
|
|
|
|
|
const rpId = dialogRpId.value
|
|
|
|
|
if (!rpId || rpId !== authStore.settings?.rp_id) return null
|
|
|
|
|
return pageOriginAllowed(d.origins || [], rpId) ? null : window.location.host
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
function pageOriginAllowed(rows, rpId) {
|
|
|
|
|
const toUrl = key => (key.startsWith('*.') || key.includes('://')) ? key : 'https://' + key
|
|
|
|
|
const inDomain = []
|
|
|
|
|
const related = []
|
|
|
|
|
for (const row of rows) {
|
|
|
|
|
if (!originHostname(row)) continue
|
|
|
|
|
const key = entryKey(row).toLowerCase()
|
|
|
|
|
if (!key) continue
|
|
|
|
|
const bucket = isRelatedEntry(row) ? related : inDomain
|
|
|
|
|
bucket.push(toUrl(key))
|
|
|
|
|
}
|
|
|
|
|
const probe = origin => {
|
|
|
|
|
let hostname
|
|
|
|
|
try { hostname = new URL(origin).hostname } catch { return false }
|
|
|
|
|
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
|
|
|
|
|
// Under localhost a wildcard matches any scheme and port
|
|
|
|
|
return base === 'localhost' || base.endsWith('.localhost') || origin.startsWith('https://')
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
return related.includes(origin)
|
|
|
|
|
}
|
|
|
|
|
// The page scheme may be http (e.g. on localhost) — probe both
|
|
|
|
|
return probe(`https://${window.location.host}`) || probe(`http://${window.location.host}`)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function addOrigin() {
|
|
|
|
|
const d = props.dialog?.data
|
|
|
|
|
if (!d) return
|
|
|
|
@@ -83,7 +133,6 @@ function addOrigin() {
|
|
|
|
|
const onThisDomain = authStore.settings?.rp_id && dialogRpId.value === authStore.settings.rp_id
|
|
|
|
|
d.origins.push(onThisDomain ? window.location.origin : dialogRpId.value)
|
|
|
|
|
d.originValidation.push(null)
|
|
|
|
|
d.originPlaceholders.push(false)
|
|
|
|
|
validateOrigin(d.origins.length - 1)
|
|
|
|
|
}
|
|
|
|
|
function removeOrigin(i) {
|
|
|
|
@@ -91,7 +140,6 @@ function removeOrigin(i) {
|
|
|
|
|
if (d) {
|
|
|
|
|
d.origins.splice(i, 1)
|
|
|
|
|
d.originValidation.splice(i, 1)
|
|
|
|
|
d.originPlaceholders.splice(i, 1)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
function focusOriginStart(e) {
|
|
|
|
@@ -111,14 +159,14 @@ function isWellFormedDomain(value) {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function originHostname(origin) {
|
|
|
|
|
if (!origin.trim()) return null
|
|
|
|
|
if (origin.trim() === '*') return '*'
|
|
|
|
|
if (origin.trim().startsWith('*.')) {
|
|
|
|
|
const base = origin.trim().slice(2).replace(/\.+$/, '').toLowerCase()
|
|
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
try {
|
|
|
|
|
const url = origin.startsWith('http') ? new URL(origin) : new URL('https://' + origin)
|
|
|
|
|
const url = v.startsWith('http') ? new URL(v) : new URL('https://' + v)
|
|
|
|
|
return url.hostname || null
|
|
|
|
|
} catch {
|
|
|
|
|
return null
|
|
|
|
@@ -126,7 +174,6 @@ function originHostname(origin) {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function isWithinDomain(origin, rpId) {
|
|
|
|
|
if (origin.trim() === '*') return true
|
|
|
|
|
const hostname = originHostname(origin)
|
|
|
|
|
if (!hostname) return false
|
|
|
|
|
return hostname === rpId || hostname.endsWith('.' + rpId)
|
|
|
|
@@ -175,7 +222,6 @@ function onOriginInput(i, e) {
|
|
|
|
|
el.setSelectionRange(pos, pos)
|
|
|
|
|
}
|
|
|
|
|
d.origins[i] = value
|
|
|
|
|
d.originPlaceholders[i] = false
|
|
|
|
|
// Keep the auth-host mark on a renamed entry, unless it no longer
|
|
|
|
|
// qualifies (wildcards and related origins cannot be the auth host)
|
|
|
|
|
if (d.auth_host && oldKey === d.auth_host) {
|
|
|
|
@@ -193,7 +239,7 @@ function validateOrigin(i) {
|
|
|
|
|
d.originValidation[i] = 'invalid'
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if (value.trim() === '*' || value.trim().startsWith('*.')) {
|
|
|
|
|
if (value.trim().startsWith('*.')) {
|
|
|
|
|
// 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'
|
|
|
|
@@ -231,19 +277,18 @@ async function testWellKnown() {
|
|
|
|
|
}
|
|
|
|
|
watch(() => relatedEntries.value.map(asHttpsOrigin).join('|'), testWellKnown, { immediate: true })
|
|
|
|
|
|
|
|
|
|
// Seed the default '*.<rp-id>' entry for a new domain once its rp-id is
|
|
|
|
|
// known, so the list always shows what is allowed ('*.x' = the domain and
|
|
|
|
|
// all its subdomains; https only, any scheme/port under localhost).
|
|
|
|
|
// Removing the last in-domain entry is blocked in the row menu, so the
|
|
|
|
|
// list never becomes empty afterwards.
|
|
|
|
|
// Seeding waits for a complete-looking rp-id (letters after the final dot)
|
|
|
|
|
// so mid-typing states like 'something.' don't seed a broken '*.something'.
|
|
|
|
|
// 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'.
|
|
|
|
|
function looksCompleteDomain(value) {
|
|
|
|
|
const host = (value || '').trim().replace(/\.$/, '')
|
|
|
|
|
return host === 'localhost' || /\.[a-z]{2,}$/i.test(host)
|
|
|
|
|
}
|
|
|
|
|
// Tracks the auto-seeded entry so it can be corrected if it was seeded
|
|
|
|
|
// from an incomplete rp-id and the admin keeps typing.
|
|
|
|
|
// Tracks the prefilled row so rp-id edits can keep updating it.
|
|
|
|
|
let seededOrigin = null
|
|
|
|
|
watch(dialogRpId, rp => {
|
|
|
|
|
const d = props.dialog?.data
|
|
|
|
@@ -253,7 +298,6 @@ watch(dialogRpId, rp => {
|
|
|
|
|
if (!d.origins.length) {
|
|
|
|
|
d.origins.push(seed)
|
|
|
|
|
d.originValidation.push(null)
|
|
|
|
|
d.originPlaceholders.push(false)
|
|
|
|
|
seededOrigin = seed
|
|
|
|
|
} else if (d.origins.length === 1 && d.origins[0] === seededOrigin && seed !== seededOrigin) {
|
|
|
|
|
d.origins[0] = seed
|
|
|
|
@@ -294,14 +338,12 @@ function setAuthHost(i) {
|
|
|
|
|
if (!d) return
|
|
|
|
|
let key = entryKey(d.origins[i])
|
|
|
|
|
let added = false
|
|
|
|
|
if (key === '*' || key.startsWith('*.')) {
|
|
|
|
|
if (key.startsWith('*.')) {
|
|
|
|
|
// A wildcard cannot be the auth host — create a concrete auth.<base> entry
|
|
|
|
|
const base = key === '*' ? dialogRpId.value : key.slice(2)
|
|
|
|
|
key = 'auth.' + base
|
|
|
|
|
key = 'auth.' + key.slice(2)
|
|
|
|
|
if (!d.origins.some(o => entryKey(o) === key)) {
|
|
|
|
|
d.origins.push(key)
|
|
|
|
|
d.originValidation.push(null)
|
|
|
|
|
d.originPlaceholders.push(false)
|
|
|
|
|
added = true
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
@@ -325,34 +367,15 @@ function resortOrigins() {
|
|
|
|
|
const d = props.dialog?.data
|
|
|
|
|
if (!d) return
|
|
|
|
|
const rank = o => isAuthHostEntry(o) ? 0 : o === dialogRpId.value ? 1 : isRelatedEntry(o) ? 3 : 2
|
|
|
|
|
const pairs = d.origins.map((o, i) => [o, d.originValidation[i], d.originPlaceholders[i]])
|
|
|
|
|
const pairs = d.origins.map((o, i) => [o, d.originValidation[i]])
|
|
|
|
|
pairs.sort((a, b) => rank(a[0]) - rank(b[0]) || compareOrigins(a[0], b[0]))
|
|
|
|
|
d.origins = pairs.map(p => p[0])
|
|
|
|
|
d.originValidation = pairs.map(p => p[1])
|
|
|
|
|
d.originPlaceholders = pairs.map(p => p[2])
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const inDomainCount = computed(() =>
|
|
|
|
|
(props.dialog?.data?.origins || []).filter(o => originHostname(o) && isWithinDomain(o, dialogRpId.value)).length
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// True while the list holds the synthesized row for an empty origins
|
|
|
|
|
// object (the rp-id and all subdomains, any scheme)
|
|
|
|
|
const hasPlaceholderRow = computed(() =>
|
|
|
|
|
!!props.dialog?.data?.originPlaceholders?.some(Boolean)
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
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 !originHostname(o) || !isWithinDomain(o, dialogRpId.value) || inDomainCount.value > 1
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function onRemoveOrigin(i) {
|
|
|
|
|
const d = props.dialog?.data
|
|
|
|
|
if (!d || !canRemoveOrigin(i)) return
|
|
|
|
|
if (!d) return
|
|
|
|
|
if (isAuthHostEntry(d.origins[i])) d.auth_host = ''
|
|
|
|
|
removeOrigin(i)
|
|
|
|
|
openMenu.value = null
|
|
|
|
@@ -461,19 +484,19 @@ function onRemoveOrigin(i) {
|
|
|
|
|
<div v-if="openMenu === i" class="row-menu-popup">
|
|
|
|
|
<button v-if="isAuthHostEntry(dialog.data.origins[i])" type="button" @click="clearAuthHost()"><span class="menu-icon">🔑</span>Remove auth host</button>
|
|
|
|
|
<button v-else-if="!isRelatedEntry(dialog.data.origins[i]) && originHostname(dialog.data.origins[i])" type="button" @click="setAuthHost(i)"><span class="menu-icon">🔑</span>Set as auth host</button>
|
|
|
|
|
<button type="button" @click="onRemoveOrigin(i)" :disabled="!canRemoveOrigin(i)"><span class="menu-icon delete-menu-icon">❌</span>Delete</button>
|
|
|
|
|
<button type="button" @click="onRemoveOrigin(i)"><span class="menu-icon delete-menu-icon">❌</span>Delete</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<p v-if="dialog.data.originValidation.some(v => v === 'invalid')" class="small error">Some entries are invalid — wildcards are only allowed within the domain.</p>
|
|
|
|
|
<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 === '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>*</strong> is shorthand for <strong>*.{{ dialog.data.rp_id }}</strong>: the domain and all its subdomains over https (any scheme and port under localhost); list 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 ⋮).
|
|
|
|
|
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.
|
|
|
|
|
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="hasPlaceholderRow" class="small muted">The <strong>*</strong> row is the default of a domain without configured origins: the domain and all its subdomains may sign in on any scheme. Saving keeps this default only while the row is left unedited.</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>
|
|
|
|
|
|
|
|
|
|
<template v-if="relatedEntries.length">
|
|
|
|
|
<p v-if="relatedEntries.length > 5" class="small error">At most 5 related origins are allowed ({{ relatedEntries.length }} listed) — the save is rejected.</p>
|
|
|
|
|