Frontend: realm admin UI, passkey realm badges, cross-realm notices

- Admin: replace Server Options dialog with per-realm management —
  realms table on the overview, add/edit/delete realm dialog backed by
  /auth/api/admin/realms/. Origins may be any well-formed origin;
  non-subdomain ones are related origins (ROR, max 5) and the dialog
  points at the .well-known/webauthn URL that must list them.
  Connectivity checks compare against the edited realm's rp-id and
  degrade to warnings instead of blocking saves.
- Host mode (limited profile) now keys off own_auth_host so realms
  sharing another realm's auth host serve the full profile locally.
- Credential list shows a realm badge on passkeys registered for a
  different rp-id than the current realm.
- Profile shows an enrollment prompt when the user has no passkey for
  the current realm (e.g. after a cross-realm remote login).
- Remote auth permit shows the requesting realm when it differs from
  the approver's own.
- settings cache can be force-refreshed after realm changes.
This commit is contained in:
2026-09-06 04:50:35 +00:00
parent cdabc5d9e6
commit 8e7acd6b9e
10 changed files with 274 additions and 89 deletions
+96 -49
View File
@@ -6,32 +6,59 @@ import { useAuthStore } from '@/stores/auth'
const props = defineProps({
dialog: Object,
PERMISSION_ID_PATTERN: String,
settings: Object
PERMISSION_ID_PATTERN: String
})
const emit = defineEmits(['submitDialog', 'closeDialog', 'resetOidcSecret', 'createPermissionForClient'])
const NAME_EDIT_TYPES = new Set(['org-update', 'role-update', 'user-update-name'])
const NO_SUBMIT_TYPES = new Set([])
const rpId = computed(() => props.settings?.rp_id || 'the configured domain')
const discoveryUrl = computed(() => `${window.location.origin}/.well-known/openid-configuration`)
// The rp-id of the realm being edited in the 'realm-edit' dialog
const realmRpId = computed(() => props.dialog?.data?.rp_id || '')
// Initialize validation properties
if (props.dialog?.data && props.dialog.type === 'server-config') {
if (props.dialog?.data && props.dialog.type === 'realm-edit') {
if (!('authHostValidation' in props.dialog.data)) {
props.dialog.data.authHostValidation = null
}
if (!('originValidation' in props.dialog.data)) {
props.dialog.data.originValidation = (props.dialog.data.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).
const isValidationInvalid = computed(() => {
if (props.dialog?.type !== 'server-config') return false
if (props.dialog?.type !== 'realm-edit') return false
const d = props.dialog.data
if (d.authHostValidation?.startsWith('invalid') || d.authHostValidation === 'validating') return true
if (d.authHostValidation === 'invalid-domain' || d.authHostValidation === 'validating') return true
if (d.originValidation?.some(v => v === 'invalid' || v === 'validating')) 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.
// 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) {
@@ -43,7 +70,7 @@ function copyText(value, label) {
function addOrigin() {
const d = props.dialog?.data
if (d) {
d.origins.push(rpId.value)
d.origins.push(realmRpId.value)
d.originValidation.push(null)
validateOrigin(d.origins[d.origins.length - 1], d.origins.length - 1)
}
@@ -67,17 +94,32 @@ function focusOriginStart(e) {
e.target.setSelectionRange(0, 0)
}
function validateOriginDomain(origin, rpId) {
if (!origin.trim()) return false
function isWellFormedDomain(value) {
if (!value.trim()) return false
try {
const url = origin.startsWith('http') ? new URL(origin) : new URL('https://' + origin)
const hostname = url.hostname
return hostname === rpId || hostname.endsWith('.' + rpId)
const url = value.startsWith('http') ? new URL(value) : new URL('https://' + value)
return url.hostname.includes('.') || url.hostname === 'localhost'
} catch {
return false
}
}
function originHostname(origin) {
if (!origin.trim()) return null
try {
const url = origin.startsWith('http') ? new URL(origin) : new URL('https://' + origin)
return url.hostname || null
} catch {
return null
}
}
function isWithinDomain(origin, rpId) {
const hostname = originHostname(origin)
if (!hostname) return false
return hostname === rpId || hostname.endsWith('.' + rpId)
}
async function validateOriginConnectivity(origin, i) {
const d = props.dialog?.data
if (!d) return
@@ -90,22 +132,17 @@ async function validateOriginConnectivity(origin, i) {
method: 'GET',
headers: { 'Accept': 'application/json' }
})
if (d.origins[i] !== origin) return // origin changed while validating
if (response.ok) {
const data = await response.json()
// Check if it returns valid settings (has rp_id and matches current rp_id)
const result = (data.rp_id && data.rp_id === rpId.value) ? 'valid' : 'invalid'
// Only update if the origin hasn't changed
if (d.origins[i] === origin) {
d.originValidation[i] = result
}
// 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'
} else {
if (d.origins[i] === origin) {
d.originValidation[i] = 'invalid'
}
d.originValidation[i] = 'unreachable'
}
} catch (e) {
if (d.origins[i] === origin) {
d.originValidation[i] = 'invalid'
d.originValidation[i] = 'unreachable'
}
}
}
@@ -114,8 +151,9 @@ function validateOrigin(origin, i) {
const d = props.dialog?.data
if (!d) return
const id = rpId.value
if (validateOriginDomain(origin, id)) {
// 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'
@@ -134,22 +172,16 @@ async function validateAuthHostConnectivity(authHost) {
method: 'GET',
headers: { 'Accept': 'application/json' }
})
if (d.auth_host !== authHost) return // auth_host changed while validating
if (response.ok) {
const data = await response.json()
// Check if it returns valid settings (has rp_id and matches current rp_id)
const result = (data.rp_id && data.rp_id === rpId.value) ? 'valid' : 'invalid'
// Only update if the auth_host hasn't changed
if (d.auth_host === authHost) {
d.authHostValidation = result
}
d.authHostValidation = (data.rp_id && data.rp_id === realmRpId.value) ? 'valid' : 'mismatch'
} else {
if (d.auth_host === authHost) {
d.authHostValidation = 'invalid-connectivity'
}
d.authHostValidation = 'unreachable'
}
} catch (e) {
if (d.auth_host === authHost) {
d.authHostValidation = 'invalid-connectivity'
d.authHostValidation = 'unreachable'
}
}
}
@@ -157,12 +189,11 @@ async function validateAuthHostConnectivity(authHost) {
function validateAuthHost() {
const d = props.dialog?.data
if (!d || !d.auth_host?.trim()) {
d.authHostValidation = null // Allow empty
if (d) d.authHostValidation = null // Allow empty
return
}
const id = rpId.value
if (validateOriginDomain(d.auth_host, id)) {
if (isWithinDomain(d.auth_host, realmRpId.value)) {
validateAuthHostConnectivity(d.auth_host)
} else {
d.authHostValidation = 'invalid-domain'
@@ -181,7 +212,7 @@ function validateAuthHost() {
<template v-else-if="dialog.type==='user-update-name'">Edit User Name</template>
<template v-else-if="dialog.type==='perm-create' || dialog.type==='perm-display'">{{ dialog.type === 'perm-create' ? 'Create Permission' : 'Edit Permission' }}</template>
<template v-else-if="dialog.type==='oidc-edit'">{{ dialog.data?.isNew ? 'New OIDC Client' : 'OIDC Client' }}</template>
<template v-else-if="dialog.type==='server-config'">Server Options</template>
<template v-else-if="dialog.type==='realm-edit'">{{ dialog.data?.isNew ? 'Add Realm' : 'Edit Realm' }}</template>
<template v-else-if="dialog.type==='confirm'">Confirm</template>
</h3>
<form @submit.prevent="$emit('submitDialog')" class="modal-form">
@@ -239,21 +270,28 @@ function validateAuthHost() {
<label>Domain Scope
<input v-model="dialog.data.domain" data-form-type="other" />
</label>
<p class="small muted">A domain ({{ rpId }} or subdomain) restricts this permission to that host. An OIDC client UUID sends it as a <em>groups</em> claim to that client.</p>
<p class="small muted">A domain restricts this permission to that host (any configured realm's rp-id or a subdomain of it). An OIDC client UUID sends it as a <em>groups</em> claim to that client.</p>
</template>
<template v-else-if="dialog.type==='server-config'">
<label>Site Branding (rp-name)
<input v-model="dialog.data.rp_name" :placeholder="rpId" />
<template v-else-if="dialog.type==='realm-edit'">
<template v-if="dialog.data.isNew">
<label>RP ID (domain)
<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>
</template>
<p v-else class="small muted">Realm: <strong>{{ dialog.data.rp_id }}</strong></p>
<label>Display Name (rp-name)
<input v-model="dialog.data.rp_name" :placeholder="dialog.data.rp_id" />
</label>
<label>Dedicated Authentication Site (auth-host)
<input v-model="dialog.data.auth_host" @input="validateAuthHost()" :class="{ 'input-error': dialog.data.authHostValidation?.startsWith('invalid') }" />
<input v-model="dialog.data.auth_host" @input="validateAuthHost()" :class="{ 'input-error': dialog.data.authHostValidation === 'invalid-domain' }" />
</label>
<p v-if="dialog.data.authHostValidation === 'validating'" class="small muted">Validating...</p>
<p v-else-if="dialog.data.authHostValidation === 'valid'" class="small muted">Valid</p>
<p v-else-if="dialog.data.authHostValidation === 'invalid-domain'" class="small muted">Invalid domain</p>
<p v-else-if="dialog.data.authHostValidation === 'invalid-connectivity'" class="small muted">Well-formed but unreachable</p>
<p v-else-if="dialog.data.authHostValidation === 'invalid'" class="small muted">Invalid configuration</p>
<p v-else-if="dialog.data.authHostValidation === 'invalid'" class="small muted">Enter {{ rpId }} or any subdomain of it.</p>
<p v-else-if="dialog.data.authHostValidation === 'invalid-domain'" class="small muted">Must be {{ dialog.data.rp_id }} or a subdomain of it.</p>
<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>
@@ -269,9 +307,18 @@ function validateAuthHost() {
/>
<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 === '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>
</div>
<p v-if="!dialog.data.origins.length" class="small muted">{{ rpId }} and all subdomains allowed.</p>
<p v-else class="small muted">Only the above sites are allowed to authenticate.</p>
<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
<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>
</template>
<template v-else-if="dialog.type==='confirm'">
<p>{{ dialog.data.message }}</p>