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