Admin UI: single origins list with automatic in-domain/ROR split + well-known check

This commit is contained in:
2026-09-07 00:27:58 +00:00
parent 7f1688e1f5
commit 7a9953f703
2 changed files with 132 additions and 105 deletions
+18 -14
View File
@@ -483,16 +483,16 @@ function createRealm() {
auth_host: '',
origins: [],
originValidation: [],
related_origins: [],
relatedValidation: [],
wellKnownCheck: null,
authHostValidation: null,
})
}
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:\/\//, ''))
// One combined list for editing: in-domain sites and related origins,
// classified by hostname. Strip https:// scheme for editing.
const origins = [...(realm.origins || []), ...(realm.related_origins || [])]
.map(o => o.replace(/^https:\/\//, ''))
openDialog('realm-edit', {
isNew: false,
rp_id: realm.rp_id,
@@ -500,8 +500,7 @@ function openRealm(realm) {
auth_host: (realm.auth_host || '').replace(/^https:\/\//, ''),
origins,
originValidation: origins.map(() => null),
related_origins: related,
relatedValidation: related.map(() => null),
wellKnownCheck: null,
authHostValidation: null,
})
}
@@ -944,13 +943,18 @@ 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() || ''
// 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)
// The combined origins list is split by hostname: entries on the
// rp-id domain form the in-domain allow-list, entries elsewhere are
// related origins (ROR). Bare hostnames are sent as-is; the backend
// normalizes them with https://.
const origins = []
const related_origins = []
for (const o of (d.origins || []).map(o => o.trim()).filter(o => o)) {
let hn = null
try { hn = new URL(o.startsWith('http') ? o : 'https://' + o).hostname } catch { continue }
if (hn === rp_id || hn.endsWith('.' + rp_id)) origins.push(o)
else related_origins.push(o)
}
closeDialog()
const req = d.isNew
+114 -91
View File
@@ -1,5 +1,5 @@
<script setup>
import { computed } from 'vue'
import { computed, watch } from 'vue'
import Modal from '@/components/Modal.vue'
import NameEditForm from '@/components/NameEditForm.vue'
import { useAuthStore } from '@/stores/auth'
@@ -26,33 +26,55 @@ 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)
if (!('wellKnownCheck' in props.dialog.data)) {
props.dialog.data.wellKnownCheck = null
}
}
// 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).
// 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 (entries may be hosted elsewhere, or
// a new domain 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
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
const bad = v => v === 'invalid' || v === 'validating'
if (d.originValidation?.some(bad)) return true
if (d.isNew && !isWellFormedDomain(d.rp_id || '')) return true
return false
})
// Well-known URL that must list any related (cross-domain) origins.
// Browsers always fetch it from the rp-id domain, never the auth host.
// 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.
function isRelatedEntry(origin) {
const h = originHostname(origin)
return !!(h && realmRpId.value && !isWithinDomain(origin, realmRpId.value))
}
const relatedEntries = computed(() => {
const d = props.dialog?.data
if (!d?.origins) return []
return d.origins.filter(isRelatedEntry)
})
// Well-known document browsers fetch from the rp-id domain to verify the
// related-origin list (never from the auth host).
const wellKnownUrl = computed(() => {
const host = (props.dialog?.data?.rp_id || '').replace(/^https:\/\//, '').replace(/\/+$/, '')
return host ? `https://${host}/.well-known/webauthn` : ''
})
// ROR origins must be absolute https URLs in the well-known document.
function asHttpsOrigin(origin) {
const o = origin.trim().replace(/\/+$/, '')
return o.startsWith('http') ? o : `https://${o}`
}
const wellKnownJson = computed(() =>
JSON.stringify({ origins: relatedEntries.value.map(asHttpsOrigin) }, null, 2)
)
// Copy-to-clipboard helper
const authStore = useAuthStore()
function copyText(value, label) {
@@ -61,27 +83,22 @@ function copyText(value, label) {
})
}
// 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) {
function addOrigin() {
const d = props.dialog?.data
if (!d) return
// Prefill the in-domain list: with the origin the admin is currently on
// when editing that very domain (so saving never locks them out), else
// with the rp-id (https default). Related domains start blank.
// Prefill with the origin the admin is currently on when editing that
// very domain (so saving never locks them out), else with the rp-id
// (https default).
const onThisDomain = authStore.settings?.rp_id && d.rp_id === authStore.settings.rp_id
d[field].push(field === 'origins' ? (onThisDomain ? window.location.origin : realmRpId.value) : '')
d[LIST_VALIDATION[field]].push(null)
const i = d[field].length - 1
if (d[field][i]) validateEntry(field, i)
d.origins.push(onThisDomain ? window.location.origin : realmRpId.value)
d.originValidation.push(null)
validateOrigin(d.origins.length - 1)
}
function removeEntry(field, i) {
function removeOrigin(i) {
const d = props.dialog?.data
if (d) {
d[field].splice(i, 1)
d[LIST_VALIDATION[field]].splice(i, 1)
d.origins.splice(i, 1)
d.originValidation.splice(i, 1)
}
}
function focusOriginStart(e) {
@@ -114,13 +131,12 @@ function isWithinDomain(origin, rpId) {
return hostname === rpId || hostname.endsWith('.' + rpId)
}
async function validateEntryConnectivity(field, i) {
async function validateOriginConnectivity(i) {
const d = props.dialog?.data
if (!d) return
const value = d[field][i]
const vlist = d[LIST_VALIDATION[field]]
const value = d.origins[i]
vlist[i] = 'validating'
d.originValidation[i] = 'validating'
try {
const cleanValue = value.replace(/\/+$/, '')
const testUrl = cleanValue.startsWith('http') ? cleanValue : 'https://' + cleanValue
@@ -128,45 +144,60 @@ async function validateEntryConnectivity(field, i) {
method: 'GET',
headers: { 'Accept': 'application/json' }
})
if (d[field][i] !== value) return // entry changed while validating
if (d.origins[i] !== value) return // entry changed while validating
if (response.ok) {
const data = await response.json()
// 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'
// Valid when the entry is served by this instance for the edited domain
d.originValidation[i] = (data.rp_id && data.rp_id === realmRpId.value) ? 'valid' : 'mismatch'
} else {
vlist[i] = 'unreachable'
d.originValidation[i] = 'unreachable'
}
} catch (e) {
if (d[field][i] === value) {
vlist[i] = 'unreachable'
if (d.origins[i] === value) {
d.originValidation[i] = 'unreachable'
}
}
}
function validateEntry(field, i) {
function validateOrigin(i) {
const d = props.dialog?.data
if (!d) return
const value = d[field][i]
const vlist = d[LIST_VALIDATION[field]]
if (!originHostname(value)) {
vlist[i] = 'invalid'
if (!originHostname(d.origins[i])) {
d.originValidation[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)
validateOriginConnectivity(i)
}
// Fetch the well-known document and check it lists every related origin.
// Runs automatically whenever the related set changes; result is a
// warning only, never a submit blocker (the rp-id site may be hosted
// elsewhere, and cross-origin fetches can fail for unrelated reasons).
async function testWellKnown() {
const d = props.dialog?.data
if (!d) return
const related = relatedEntries.value.map(asHttpsOrigin)
if (!related.length) {
d.wellKnownCheck = null
return
}
const key = related.join('|')
d.wellKnownCheck = 'validating'
try {
const response = await fetch(wellKnownUrl.value, { headers: { 'Accept': 'application/json' } })
if (!response.ok) throw new Error('not ok')
const doc = await response.json()
if (related.join('|') !== key) return // list changed while fetching
const listed = new Set((doc.origins || []).map(o => String(o).replace(/\/+$/, '')))
const missing = related.filter(o => !listed.has(o))
d.wellKnownCheck = missing.length ? 'missing' : 'valid'
d.wellKnownMissing = missing
} catch {
if (related.join('|') === key) d.wellKnownCheck = 'unreachable'
}
}
watch(() => relatedEntries.value.map(asHttpsOrigin).join('|'), testWellKnown, { immediate: true })
async function validateAuthHostConnectivity(authHost) {
const d = props.dialog?.data
if (!d) return
@@ -301,54 +332,43 @@ function validateAuthHost() {
<p v-else class="small muted">Optional. Moves the account and admin interface to this one hostname. Sign-in works on every site regardless.</p>
<div class="origin-label">
Allowed Sign-in Sites
<button type="button" class="icon-btn origin-add-btn" @click="addEntry('origins')" aria-label="Add site" title="Add site"></button>
Allowed Origins
<button type="button" class="icon-btn origin-add-btn" @click="addOrigin()" aria-label="Add origin" title="Add origin"></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; validateEntry('origins', i) }"
@input="e => { dialog.data.origins[i] = e.target.value; validateOrigin(i) }"
@focus="focusOriginStart"
class="origin-input"
:class="{ 'input-error': ['invalid', 'invalid-domain'].includes(dialog.data.originValidation[i]) }"
:class="{ 'input-error': dialog.data.originValidation[i] === 'invalid' }"
/>
<button type="button" class="icon-btn delete-icon" @click="removeEntry('origins', i)" aria-label="Remove site" title="Remove site">❌</button>
<span v-if="isRelatedEntry(dialog.data.origins[i])" class="ror-tag" title="Related origin (WebAuthn ROR) — shares this domain's passkeys">related</span>
<button type="button" class="icon-btn delete-icon" @click="removeOrigin(i)" aria-label="Remove origin" title="Remove origin">❌</button>
</div>
<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-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 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 domain's passkeys.</p>
<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, or to share this domain's passkeys with another domain name.</p>
<p v-else class="small muted">Only the listed sites may sign in with this domain's passkeys. Entries on other domain names become related origins (WebAuthn ROR).</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 domain 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 domain.</p>
</div>
<p class="small muted">
Other domain names that may use this domain'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
<template v-if="relatedEntries.length">
<p v-if="relatedEntries.length > 5" class="small error">Browsers support at most 5 related origins — {{ relatedEntries.length }} listed, extras will not work.</p>
<p class="small muted">
Related origins are verified by browsers against
<a :href="wellKnownUrl" target="_blank" rel="noopener noreferrer">{{ wellKnownUrl }}</a>
— served automatically when this instance hosts {{ dialog.data.rp_id }}; otherwise copy the document there.
</template>
</p>
— served automatically when this instance hosts {{ dialog.data.rp_id }}; otherwise publish this document there:
</p>
<div class="wellknown-doc">
<pre>{{ wellKnownJson }}</pre>
<button type="button" class="icon-btn" @click="copyText(wellKnownJson, 'Well-known document')" aria-label="Copy well-known document" title="Copy">📋</button>
</div>
<p v-if="dialog.data.wellKnownCheck === 'validating'" class="small muted">Checking the published document…</p>
<p v-else-if="dialog.data.wellKnownCheck === 'valid'" class="small muted">✓ The published document lists all related origins.</p>
<p v-else-if="dialog.data.wellKnownCheck === 'missing'" class="small error">The published document does not list: {{ (dialog.data.wellKnownMissing || []).join(', ') }}</p>
<p v-else-if="dialog.data.wellKnownCheck === 'unreachable'" class="small muted">Could not fetch the published document to verify it.</p>
</template>
</template>
<template v-else-if="dialog.type==='confirm'">
<p>{{ dialog.data.message }}</p>
@@ -403,6 +423,9 @@ function validateAuthHost() {
.origin-input { flex: 1; min-width: 8rem; font-family: var(--font-mono, monospace); }
.origin-row .delete-icon { flex-shrink: 0; }
.origin-add-btn { font-size: 1.2rem; }
.ror-tag { flex-shrink: 0; font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.03em; color: var(--color-text-muted); border: 1px solid var(--color-border, currentColor); border-radius: 3px; padding: 0 0.3rem; }
.wellknown-doc { display: flex; align-items: flex-start; gap: var(--space-xs); }
.wellknown-doc pre { flex: 1; margin: 0; padding: var(--space-xs) var(--space-sm); font-size: 0.8rem; background: var(--color-bg-soft, rgba(127,127,127,0.08)); border-radius: 4px; overflow-x: auto; }
.input-error {
border-color: var(--color-error);