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
2 changed files with 63 additions and 38 deletions
Showing only changes of commit f3909ce524 - Show all commits
+52 -31
View File
@@ -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>
+11 -7
View File
@@ -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)