MultiSite: one instance serves authentication across many domains #4

Merged
LeoVasanko merged 48 commits from multihost into main 2026-09-07 22:02:06 +00:00
3 changed files with 114 additions and 98 deletions
Showing only changes of commit 7726203382 - Show all commits
+8 -31
View File
@@ -479,15 +479,13 @@ function createDomain() {
auth_host: '',
origins: [],
originValidation: [],
originPlaceholders: [],
wellKnownCheck: null,
})
}
function openDomain(domain) {
// One combined list for editing, in display order: in-domain sites and
// related origins, classified by hostname. An empty origins object shows
// as a '*' placeholder row, omitted again on submit unless edited.
// related origins, classified by hostname against the rp-id.
const rows = originDisplayEntries(domain)
openDialog('domain-edit', {
isNew: false,
@@ -496,7 +494,6 @@ function openDomain(domain) {
auth_host: rows.find(r => r.auth)?.key || '',
origins: rows.map(r => r.key),
originValidation: rows.map(() => null),
originPlaceholders: rows.map(r => !!r.placeholder),
wellKnownCheck: null,
})
}
@@ -914,41 +911,21 @@ async function submitDialog() {
if (!rp_id) throw new Error('Domain (rp-id) required')
const rp_name = d.rp_name?.trim() || ''
const auth_host = d.auth_host?.trim().toLowerCase() || ''
// The combined origins list is split by hostname: entries on the
// rp-id domain form the in-domain origins object (the auth host
// entry is marked), entries elsewhere are related origins (ROR).
// Wildcards ('*.app.example.com') classify by their base domain.
// One origins object holds in-domain sites and related origins
// (ROR) together; the server classifies each key against the rp-id.
// Keys are stored lowercased, without the https:// scheme.
const keyOf = o => o.replace(/^https:\/\//i, '').replace(/\/+$/, '').toLowerCase()
const origins = {}
const related = {}
for (const [i, o] of (d.origins || []).entries()) {
for (const o of d.origins || []) {
const key = keyOf(o.trim())
// An untouched placeholder row only displays the empty-origins
// default (any scheme in-domain) — don't persist it as '*'
if (!key || d.originPlaceholders?.[i]) continue
if (key === '*') {
origins['*'] = true // shorthand for '*.{rp-id}' (https-only outside localhost)
continue
}
let hn = null
if (key.startsWith('*.')) {
hn = key.slice(2).replace(/\.+$/, '')
} else {
try { hn = new URL(key.startsWith('http') ? key : 'https://' + key).hostname } catch { continue }
}
if (!hn) continue
if (hn === rp_id || hn.endsWith('.' + rp_id)) {
origins[key] = key === auth_host ? { auth_host: true } : true
} else {
related[key] = true
}
if (!key) continue
origins[key] = key === auth_host ? { auth_host: true } : true
}
closeDialog()
const req = d.isNew
? apiJson('/auth/api/admin/domains/', { method: 'POST', body: { rp_id, rp_name, origins, related } })
: apiJson(`/auth/api/admin/domains/${rp_id}`, { method: 'PATCH', body: { rp_name, origins, related } })
? apiJson('/auth/api/admin/domains/', { method: 'POST', body: { rp_id, rp_name, origins } })
: apiJson(`/auth/api/admin/domains/${rp_id}`, { method: 'PATCH', body: { rp_name, origins } })
req
.then(() => {
authStore.showMessage(`Domain "${rp_id}" ${d.isNew ? 'created' : 'updated'}.`, 'success', 2500)
+78 -55
View File
@@ -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>
+28 -12
View File
@@ -43,11 +43,10 @@ export const hostIP = ip => {
}
// Display-time ordering of a domain's configured origins (the stored
// objects are unordered): the auth host first (flagged), then in-domain
// entries (exact rp-id, then hierarchical), then related domains
// hierarchically. An empty origins object (the rp-id and all subdomains
// may sign in on any scheme — deliberately broader than a literal '*',
// which is https-only outside localhost) shows as a '*' placeholder row.
// object is unordered): the auth host first (flagged), then in-domain
// entries (exact rp-id, then hierarchical), then related origins — hosts
// outside the rp-id domain — hierarchically. An empty origins object
// allows nothing and shows as an empty list.
// Hierarchical origin comparison: split off scheme/port, compare hostnames
// label by label from the TLD down, parents before their subdomains and a
@@ -61,8 +60,7 @@ function originParts(key) {
let port = ''
const pm = s.match(/:(\d+)$/)
if (pm) { port = pm[1]; s = s.slice(0, -pm[0].length) }
// A bare '*' (shorthand for '*.{rp-id}') sorts before all host entries
const labels = s === '*' ? [] : s.split('.').reverse()
const labels = s.split('.').reverse()
return { labels, scheme, port }
}
@@ -87,21 +85,39 @@ export function compareOrigins(a, b) {
return A.port.localeCompare(B.port)
}
// 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.
function isRelatedKey(rpId, key) {
if (key.startsWith('*.')) return false
try {
const hostname = new URL(key.includes('://') ? key : 'https://' + key).hostname
return !!hostname && hostname !== rpId && !hostname.endsWith('.' + rpId)
} catch {
return false
}
}
export function originDisplayEntries(domain) {
const origins = domain.origins || {}
const keys = Object.keys(origins)
const authKey = keys.find(k => origins[k] !== true && origins[k]?.auth_host)
const inDomain = keys.filter(k => k !== authKey).sort((a, b) => {
const inDomain = []
const related = []
for (const k of keys) {
if (k === authKey) continue
const bucket = isRelatedKey(domain.rp_id, k) ? related : inDomain
bucket.push(k)
}
inDomain.sort((a, b) => {
if (a === domain.rp_id) return -1
if (b === domain.rp_id) return 1
return compareOrigins(a, b)
})
related.sort(compareOrigins)
const rows = []
if (authKey) rows.push({ key: authKey, auth: true })
for (const k of inDomain) rows.push({ key: k, auth: false })
if (!keys.length) rows.push({ key: '*', auth: false, placeholder: true })
for (const k of Object.keys(domain.related || {}).sort(compareOrigins)) {
rows.push({ key: k, auth: false, related: true })
}
for (const k of related) rows.push({ key: k, auth: false, related: true })
return rows
}