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:
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user