Separate related domains (ROR) from the in-domain sign-in allow-list

RealmConfig.origins is again purely an allow-list of sign-in sites
within the realm's domain (unset = rp-id and all subdomains), restoring
the restriction semantics the realm rework had silently turned into an
always-open subtree. Cross-domain ROR origins move to their own
RealmConfig.related_origins field — always additive, capped, validated
to be outside the rp-id domain, and the sole source of the
/.well-known/webauthn document.

Admin API POST/PATCH accept related_origins; misfiled entries are
rejected (cross-domain in origins, in-domain in related_origins).

Admin UI: the realm dialog edits the two lists separately with
end-user-oriented explanations (allowed sign-in sites vs. related
domains + the well-known note); the Realms section intro explains the
multi-domain model, and the table shows sign-in site and related domain
counts.
This commit is contained in:
2026-09-06 22:29:12 +00:00
parent fefd54f02a
commit b9e6f4bc27
16 changed files with 376 additions and 160 deletions
+11 -3
View File
@@ -483,6 +483,8 @@ function createRealm() {
auth_host: '',
origins: [],
originValidation: [],
related_origins: [],
relatedValidation: [],
authHostValidation: null,
})
}
@@ -490,6 +492,7 @@ function createRealm() {
function openRealm(realm) {
// Strip https:// scheme from stored origins and auth_host for editing
const origins = (realm.origins || []).map(o => o.replace(/^https:\/\//, ''))
const related = (realm.related_origins || []).map(o => o.replace(/^https:\/\//, ''))
openDialog('realm-edit', {
isNew: false,
rp_id: realm.rp_id,
@@ -497,6 +500,8 @@ function openRealm(realm) {
auth_host: (realm.auth_host || '').replace(/^https:\/\//, ''),
origins,
originValidation: origins.map(() => null),
related_origins: related,
relatedValidation: related.map(() => null),
authHostValidation: null,
})
}
@@ -936,18 +941,21 @@ async function submitDialog() {
} else if (t === 'realm-edit') {
const d = dialog.value.data
const rp_id = d.rp_id?.trim().toLowerCase()
if (!rp_id) throw new Error('RP ID (domain) required')
if (!rp_id) throw new Error('Domain (rp-id) required')
const rp_name = d.rp_name?.trim() || ''
const auth_host = d.auth_host?.trim() || ''
// Origins are stored as-is (hostnames); backend normalizes with https://
const origins = (d.origins || [])
.map(o => o.trim())
.filter(o => o)
const related_origins = (d.related_origins || [])
.map(o => o.trim())
.filter(o => o)
closeDialog()
const req = d.isNew
? apiJson('/auth/api/admin/realms/', { method: 'POST', body: { rp_id, rp_name, auth_host, origins } })
: apiJson(`/auth/api/admin/realms/${rp_id}`, { method: 'PATCH', body: { rp_name, auth_host, origins } })
? apiJson('/auth/api/admin/realms/', { method: 'POST', body: { rp_id, rp_name, auth_host, origins, related_origins } })
: apiJson(`/auth/api/admin/realms/${rp_id}`, { method: 'PATCH', body: { rp_name, auth_host, origins, related_origins } })
req
.then(() => {
authStore.showMessage(`Realm "${rp_id}" ${d.isNew ? 'created' : 'updated'}.`, 'success', 2500)
+97 -70
View File
@@ -26,39 +26,33 @@ if (props.dialog?.data && props.dialog.type === 'realm-edit') {
if (!('originValidation' in props.dialog.data)) {
props.dialog.data.originValidation = (props.dialog.data.origins || []).map(() => null)
}
if (!('relatedValidation' in props.dialog.data)) {
props.dialog.data.relatedValidation = (props.dialog.data.related_origins || []).map(() => null)
}
}
// Block submit on hard errors: malformed entries, auth-host outside the
// rp-id domain, or validation still in flight. Connectivity and rp-id
// mismatch results are warnings only (e.g. related origins hosted elsewhere,
// or a new realm whose DNS is not routed to this instance yet).
// Block submit on hard errors: malformed entries, entries filed under the
// wrong list, auth-host outside the rp-id domain, or validation still in
// flight. Connectivity and rp-id mismatch results are warnings only (e.g.
// related domains hosted elsewhere, or a new realm whose DNS is not routed
// to this instance yet).
const isValidationInvalid = computed(() => {
if (props.dialog?.type !== 'realm-edit') return false
const d = props.dialog.data
if (d.authHostValidation === 'invalid-domain' || d.authHostValidation === 'validating') return true
if (d.originValidation?.some(v => v === 'invalid' || v === 'validating')) return true
const bad = v => v === 'invalid' || v === 'invalid-domain' || v === 'validating'
if (d.originValidation?.some(bad) || d.relatedValidation?.some(bad)) return true
if (props.dialog.type === 'realm-edit' && d.isNew && !isWellFormedDomain(d.rp_id || '')) return true
return false
})
// Well-known URL that must list any related (non-subdomain) origins.
// Well-known URL that must list any related (cross-domain) origins.
// Browsers always fetch it from the rp-id domain, never the auth host.
const wellKnownUrl = computed(() => {
const host = (props.dialog?.data?.rp_id || '').replace(/^https:\/\//, '').replace(/\/+$/, '')
return host ? `https://${host}/.well-known/webauthn` : ''
})
// Number of related (non-subdomain) origins in the realm dialog
const relatedOriginCount = computed(() => {
const d = props.dialog?.data
if (!d?.origins) return 0
const id = realmRpId.value
return d.origins.filter(o => {
const h = originHostname(o)
return h && id && h !== id && !h.endsWith('.' + id)
}).length
})
// Copy-to-clipboard helper
const authStore = useAuthStore()
function copyText(value, label) {
@@ -67,29 +61,26 @@ function copyText(value, label) {
})
}
function addOrigin() {
// The two origin lists are separate concerns: an in-domain allow-list of
// sign-in sites, and cross-domain related origins (WebAuthn ROR).
const LIST_VALIDATION = { origins: 'originValidation', related_origins: 'relatedValidation' }
function addEntry(field) {
const d = props.dialog?.data
if (!d) return
// Prefill the in-domain list with the rp-id; related domains start blank
d[field].push(field === 'origins' ? realmRpId.value : '')
d[LIST_VALIDATION[field]].push(null)
const i = d[field].length - 1
if (d[field][i]) validateEntry(field, i)
}
function removeEntry(field, i) {
const d = props.dialog?.data
if (d) {
d.origins.push(realmRpId.value)
d.originValidation.push(null)
validateOrigin(d.origins[d.origins.length - 1], d.origins.length - 1)
d[field].splice(i, 1)
d[LIST_VALIDATION[field]].splice(i, 1)
}
}
function removeOrigin(i) {
const d = props.dialog?.data
if (d) {
d.origins.splice(i, 1)
d.originValidation.splice(i, 1)
}
}
function stripScheme(val, i) {
const d = props.dialog?.data
if (d) d.origins[i] = val.replace(/^https:\/\//, '').replace(/\/+$/, '')
}
function stripSchemeAuthHost() {
const d = props.dialog?.data
if (d && d.auth_host) d.auth_host = d.auth_host.replace(/^https:\/\//, '').replace(/\/+$/, '')
}
function focusOriginStart(e) {
e.target.setSelectionRange(0, 0)
}
@@ -120,44 +111,57 @@ function isWithinDomain(origin, rpId) {
return hostname === rpId || hostname.endsWith('.' + rpId)
}
async function validateOriginConnectivity(origin, i) {
async function validateEntryConnectivity(field, i) {
const d = props.dialog?.data
if (!d) return
const value = d[field][i]
const vlist = d[LIST_VALIDATION[field]]
d.originValidation[i] = 'validating'
vlist[i] = 'validating'
try {
const cleanOrigin = origin.replace(/\/+$/, '')
const testUrl = cleanOrigin.startsWith('http') ? cleanOrigin : 'https://' + cleanOrigin
const cleanValue = value.replace(/\/+$/, '')
const testUrl = cleanValue.startsWith('http') ? cleanValue : 'https://' + cleanValue
const response = await fetch(testUrl + '/auth/api/settings', {
method: 'GET',
headers: { 'Accept': 'application/json' }
})
if (d.origins[i] !== origin) return // origin changed while validating
if (d[field][i] !== value) return // entry changed while validating
if (response.ok) {
const data = await response.json()
// Valid when the origin is served by this instance for the edited realm
d.originValidation[i] = (data.rp_id && data.rp_id === realmRpId.value) ? 'valid' : 'mismatch'
// Valid when the entry is served by this instance for the edited realm
vlist[i] = (data.rp_id && data.rp_id === realmRpId.value) ? 'valid' : 'mismatch'
} else {
d.originValidation[i] = 'unreachable'
vlist[i] = 'unreachable'
}
} catch (e) {
if (d.origins[i] === origin) {
d.originValidation[i] = 'unreachable'
if (d[field][i] === value) {
vlist[i] = 'unreachable'
}
}
}
function validateOrigin(origin, i) {
function validateEntry(field, i) {
const d = props.dialog?.data
if (!d) return
const value = d[field][i]
const vlist = d[LIST_VALIDATION[field]]
// Related origins on unrelated domains are allowed (WebAuthn ROR), so any
// well-formed origin passes; connectivity is checked as a hint only.
if (originHostname(origin)) {
validateOriginConnectivity(origin, i)
} else {
d.originValidation[i] = 'invalid'
if (!originHostname(value)) {
vlist[i] = 'invalid'
return
}
// Each entry must be filed under the right list: the in-domain allow-list
// only covers the rp-id domain; related domains must be outside it.
const within = isWithinDomain(value, realmRpId.value)
if (field === 'origins' && !within) {
vlist[i] = 'invalid-domain'
return
}
if (field === 'related_origins' && within) {
vlist[i] = 'invalid-domain'
return
}
validateEntryConnectivity(field, i)
}
async function validateAuthHostConnectivity(authHost) {
@@ -274,10 +278,10 @@ function validateAuthHost() {
</template>
<template v-else-if="dialog.type==='realm-edit'">
<template v-if="dialog.data.isNew">
<label>RP ID (domain)
<label>Domain (rp-id)
<input v-model="dialog.data.rp_id" placeholder="example.com" data-form-type="other" required />
</label>
<p class="small muted">The domain passkeys are registered for. Cannot be changed later.</p>
<p class="small muted">The domain name this realm's passkeys belong to they work on this domain and its subdomains, and never on other realms. Cannot be changed later.</p>
</template>
<p v-else class="small muted">Realm: <strong>{{ dialog.data.rp_id }}</strong></p>
<label>Display Name (rp-name)
@@ -292,33 +296,56 @@ function validateAuthHost() {
<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 realm.</p>
<p v-else class="small muted">Optional. Leave empty to serve authentication on {{ dialog.data.rp_id }} itself.</p>
<div class="origin-label">
Allowed Origins
<button type="button" class="icon-btn origin-add-btn" @click="addOrigin" aria-label="Add origin" title="Add origin"></button>
Allowed Sign-in Sites
<button type="button" class="icon-btn origin-add-btn" @click="addEntry('origins')" aria-label="Add site" title="Add site"></button>
</div>
<div v-if="dialog.data.origins.length" class="origin-list">
<div v-for="(_, i) in dialog.data.origins" :key="i" class="origin-row">
<input
:value="dialog.data.origins[i]"
@input="e => { dialog.data.origins[i] = e.target.value; validateOrigin(e.target.value, i) }"
@input="e => { dialog.data.origins[i] = e.target.value; validateEntry('origins', i) }"
@focus="focusOriginStart"
class="origin-input"
:class="{ 'input-error': dialog.data.originValidation[i] === 'invalid' }"
:class="{ 'input-error': ['invalid', 'invalid-domain'].includes(dialog.data.originValidation[i]) }"
/>
<button type="button" class="icon-btn delete-icon" @click="removeOrigin(i)" aria-label="Remove origin" title="Remove origin">❌</button>
<button type="button" class="icon-btn delete-icon" @click="removeEntry('origins', i)" aria-label="Remove site" title="Remove site"></button>
</div>
<p v-if="dialog.data.originValidation.some(v => v === 'unreachable')" class="small muted">Some origins are unreachable — make sure they are routed to this instance, or host them externally.</p>
<p v-else-if="dialog.data.originValidation.some(v => v === 'mismatch')" class="small muted">Some origins are reachable but do not serve this realm.</p>
<p v-if="dialog.data.originValidation.some(v => v === 'invalid-domain')" class="small muted">Sites must be on {{ dialog.data.rp_id }} or a subdomain of it use Related Domains below for other domain names.</p>
<p v-else-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 realm.</p>
</div>
<p v-if="!dialog.data.origins.length" class="small muted">{{ dialog.data.rp_id }} and all subdomains allowed.</p>
<p v-else class="small muted">Only the above sites are allowed to authenticate. Origins on unrelated domains count as related origins (max 5 per realm).</p>
<template v-if="relatedOriginCount > 0">
<p class="small muted">
Related origins require the rp-id domain to list them at
<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 on this domain.</p>
<p v-else class="small muted">Only the listed sites may sign in with this realm's passkeys.</p>
<div class="origin-label">
Related Domains
<button type="button" class="icon-btn origin-add-btn" @click="addEntry('related_origins')" aria-label="Add related domain" title="Add related domain"></button>
</div>
<div v-if="dialog.data.related_origins.length" class="origin-list">
<div v-for="(_, i) in dialog.data.related_origins" :key="i" class="origin-row">
<input
v-model="dialog.data.related_origins[i]"
@input="validateEntry('related_origins', i)"
placeholder="other-domain.com"
class="origin-input"
:class="{ 'input-error': ['invalid', 'invalid-domain'].includes(dialog.data.relatedValidation[i]) }"
/>
<button type="button" class="icon-btn delete-icon" @click="removeEntry('related_origins', i)" aria-label="Remove related domain" title="Remove related domain">❌</button>
</div>
<p v-if="dialog.data.relatedValidation.some(v => v === 'invalid-domain')" class="small muted">That entry is inside {{ dialog.data.rp_id }} — subdomains are already covered by the realm itself.</p>
<p v-else-if="dialog.data.relatedValidation.some(v => v === 'unreachable')" class="small muted">Some domains are unreachable — make sure they are routed to this instance.</p>
<p v-else-if="dialog.data.relatedValidation.some(v => v === 'mismatch')" class="small muted">Some domains are reachable but do not serve this realm.</p>
</div>
<p class="small muted">
Other domain names that may use this realm's passkeys (WebAuthn Related Origins, max 5). List only domains you trust as much as {{ dialog.data.rp_id }} itself.
<template v-if="dialog.data.related_origins.length">
Browsers verify the list at
<a :href="wellKnownUrl" target="_blank" rel="noopener noreferrer">{{ wellKnownUrl }}</a>
— this instance serves it automatically; copy it there if the main site is hosted elsewhere.
</p>
</template>
served automatically when this instance hosts {{ dialog.data.rp_id }}; otherwise copy the document there.
</template>
</p>
</template>
<template v-else-if="dialog.type==='confirm'">
<p>{{ dialog.data.message }}</p>
+6 -4
View File
@@ -430,7 +430,7 @@ defineExpose({ focusFirstElement })
<div class="section-header">
<h2>Realms</h2>
<p class="section-description">
Each realm is one passkey rp-id with its own display name, optional dedicated auth host, and allowed origins (including Related Origin Requests origins on unrelated domains). Changes apply immediately.
Realms are the domain names this instance serves. Each realm has its own passkeys: users sign in per domain, and a passkey registered on one realm never works on another. Add a realm for every domain you operate. To let several <em>different</em> domain names share the same passkeys, open the realm and configure related domains (WebAuthn Related Origins). Changes apply immediately.
</p>
</div>
<div class="section-actions">
@@ -441,13 +441,14 @@ defineExpose({ focusFirstElement })
<tr>
<th>Realm</th>
<th>Auth Host</th>
<th class="center">Origins</th>
<th class="center">Sign-in Sites</th>
<th class="center">Related Domains</th>
<th class="center"></th>
</tr>
</thead>
<tbody>
<tr v-if="!realms || realms.length === 0">
<td colspan="4" class="center muted">No realms configured</td>
<td colspan="5" class="center muted">No realms configured</td>
</tr>
<tr v-for="realm in realms" :key="realm.rp_id">
<td class="perm-name-cell">
@@ -463,7 +464,8 @@ defineExpose({ focusFirstElement })
<span v-if="realm.effective_auth_host">{{ realm.effective_auth_host }}<span v-if="!realm.auth_host" class="muted"> (shared)</span></span>
<span v-else class="muted"></span>
</td>
<td class="center">{{ realm.origins?.length || 0 }}</td>
<td class="center">{{ realm.origins?.length || 'All' }}</td>
<td class="center">{{ realm.related_origins?.length || '—' }}</td>
<td class="center">
<button v-if="!realm.is_default" @click="$emit('deleteRealm', realm)" class="icon-btn delete-icon" aria-label="Delete realm" title="Delete realm"></button>
</td>