diff --git a/frontend/auth/App.vue b/frontend/auth/App.vue index 3933b54..73f50f0 100644 --- a/frontend/auth/App.vue +++ b/frontend/auth/App.vue @@ -37,11 +37,13 @@ function normalizeHost(raw) { } /** - * Host mode is active when an auth_host is configured AND the current host differs from it. + * Host mode is active when an own_auth_host is configured AND the current host differs from it. * In host mode, we show a limited profile view with logout and link to full profile. + * own_auth_host (not auth_host) is used so that realms sharing another realm's auth host + * still serve the full profile on their own hosts. */ const isHostMode = computed(() => { - const authHost = store.settings?.auth_host + const authHost = store.settings?.own_auth_host if (!authHost) return false const currentHost = normalizeHost(window.location.host) const configuredHost = normalizeHost(authHost) @@ -99,7 +101,7 @@ onMounted(async () => { if (rpName) { // In host mode, show "account summary" style title // Settings are loaded but isHostMode depends on them, so check here - const authHost = store.settings?.auth_host + const authHost = store.settings?.own_auth_host const inHostMode = authHost && normalizeHost(window.location.host) !== normalizeHost(authHost) document.title = inHostMode ? `${rpName} · Account summary` : rpName } diff --git a/frontend/auth/admin/AdminApp.vue b/frontend/auth/admin/AdminApp.vue index 8b1949a..f0fc8f0 100644 --- a/frontend/auth/admin/AdminApp.vue +++ b/frontend/auth/admin/AdminApp.vue @@ -28,6 +28,7 @@ const error = ref(null) const orgs = ref([]) const permissions = ref([]) const oidcClients = ref([]) +const realms = ref([]) const currentOrgId = ref(null) // UUID of selected org for detail view const currentUserId = ref(null) // UUID for user detail view const currentOidcId = ref(null) // UUID for OIDC client detail view @@ -174,6 +175,16 @@ async function loadAdminData() { oidcClients.value = Object.entries(data.oidc_clients).map(([uuid, c]) => ({ uuid, ...c })) } +// Realm list is master-admin only; callers guard on isMasterAdmin +async function loadRealms() { + try { + realms.value = await apiJson('/auth/api/admin/realms/') + } catch (e) { + console.warn('Unable to load realms', e) + realms.value = [] + } +} + // Helper to get users for a role as sorted array of [uuid, user] function roleUsers(org, roleUuid) { return Object.entries(org.users) @@ -207,6 +218,7 @@ function clearSensitiveState() { orgs.value = [] permissions.value = [] oidcClients.value = [] + realms.value = [] userDetail.value = null editingOidcClient.value = null authenticated.value = false @@ -236,6 +248,7 @@ async function load() { await loadAdminData() // If we get here, user has admin access - now fetch user info for display await loadUserInfo() + if (isMasterAdmin.value) await loadRealms() if (!isMasterAdmin.value && isOrgAdmin.value && orgs.value.length === 1) { if (!window.location.hash || window.location.hash === '#overview') { @@ -462,21 +475,41 @@ function createPermissionForClient(clientId) { openDialog('perm-create', { display_name: '', scope: '', domain: clientId }) } -async function openServerConfig() { - try { - const config = await apiJson('/auth/api/admin/server-config') - // Strip https:// scheme from stored origins and auth_host for editing - const origins = (config.origins || []).map(o => o.replace(/^https:\/\//, '')) - const auth_host = (config.auth_host || '').replace(/^https:\/\//, '') - openDialog('server-config', { - rp_name: config.rp_name || '', - auth_host, - origins, - originValidation: origins.map(() => null), - }) - } catch (e) { - authStore.showMessage(e.message || 'Failed to load server configuration', 'error') - } +function createRealm() { + openDialog('realm-edit', { + isNew: true, + rp_id: '', + rp_name: '', + auth_host: '', + origins: [], + originValidation: [], + 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:\/\//, '')) + openDialog('realm-edit', { + isNew: false, + rp_id: realm.rp_id, + rp_name: realm.rp_name || '', + auth_host: (realm.auth_host || '').replace(/^https:\/\//, ''), + origins, + originValidation: origins.map(() => null), + authHostValidation: null, + }) +} + +function deleteRealm(realm) { + openDialog('confirm', { + message: `Delete realm "${realm.rp_id}"? This is refused while any passkeys remain registered for it.`, + action: async () => { + await apiJson(`/auth/api/admin/realms/${realm.rp_id}`, { method: 'DELETE' }) + authStore.showMessage(`Realm "${realm.rp_id}" deleted.`, 'success', 2500) + await loadRealms() + } + }) } function deleteOidcClient(client) { @@ -900,25 +933,32 @@ async function submitDialog() { authStore.showMessage(e.message || `Failed to ${isNew ? 'create' : 'update'} OIDC client`, 'error') }) return // Don't call closeDialog() again - } else if (t === 'server-config') { - const rp_name = dialog.value.data.rp_name?.trim() || '' - const auth_host = dialog.value.data.auth_host?.trim() || '' + } 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') + 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 = dialog.value.data.origins + const origins = (d.origins || []) .map(o => o.trim()) .filter(o => o) closeDialog() - apiJson('/auth/api/admin/server-config', { method: 'PATCH', body: { rp_name, auth_host, origins } }) + 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 } }) + req .then(() => { - authStore.showMessage('Server configuration updated.', 'success', 2500) + authStore.showMessage(`Realm "${rp_id}" ${d.isNew ? 'created' : 'updated'}.`, 'success', 2500) + loadRealms() // Reload settings to reflect rp_name changes - authStore.loadSettings().then(() => { + authStore.loadSettings(true).then(() => { if (authStore.settings?.rp_name) document.title = authStore.settings.rp_name + ' Admin' }) }) .catch(e => { - authStore.showMessage(e.message || 'Failed to update server configuration', 'error') + authStore.showMessage(e.message || 'Failed to save realm', 'error') }) return // Don't call closeDialog() again } else if (t === 'confirm') { @@ -973,6 +1013,7 @@ async function submitDialog() { :orgs="orgs" :permissions="permissions" :oidc-clients="oidcClients" + :realms="realms" :navigation-disabled="hasActiveModal" :permission-summary="permissionSummary" @create-org="createOrg" @@ -986,7 +1027,9 @@ async function submitDialog() { @create-oidc-client="createOidcClient" @open-oidc-client="openOidcClient" @delete-oidc-client="deleteOidcClient" - @open-server-config="openServerConfig" + @create-realm="createRealm" + @open-realm="openRealm" + @delete-realm="deleteRealm" @navigate-out="handlePanelNavigateOut" /> @@ -1047,7 +1090,6 @@ async function submitDialog() { 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() { - +