diff --git a/frontend/auth/App.vue b/frontend/auth/App.vue index 73f50f0..f9725fc 100644 --- a/frontend/auth/App.vue +++ b/frontend/auth/App.vue @@ -39,7 +39,7 @@ function normalizeHost(raw) { /** * 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 + * own_auth_host (not auth_host) is used so that domains sharing another domain's auth host * still serve the full profile on their own hosts. */ const isHostMode = computed(() => { diff --git a/frontend/auth/admin/AdminApp.vue b/frontend/auth/admin/AdminApp.vue index f1aca61..dd41b58 100644 --- a/frontend/auth/admin/AdminApp.vue +++ b/frontend/auth/admin/AdminApp.vue @@ -17,7 +17,7 @@ import { apiJson, SessionValidator, settings as paskiaSettings } from 'paskia' import { updateThemeFromSession } from '@/utils/theme' import { uuidv7 } from 'uuidv7' import { getDirection } from '@/utils/keynav' -import { goBack } from '@/utils/helpers' +import { goBack, originDisplayEntries } from '@/utils/helpers' const info = ref(null) const loading = ref(true) @@ -28,7 +28,7 @@ const error = ref(null) const orgs = ref([]) const permissions = ref([]) const oidcClients = ref([]) -const realms = ref([]) +const domains = 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 @@ -175,13 +175,13 @@ 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() { +// Domain list is master-admin only; callers guard on isMasterAdmin +async function loadDomains() { try { - realms.value = await apiJson('/auth/api/admin/realms/') + domains.value = await apiJson('/auth/api/admin/domains/') } catch (e) { - console.warn('Unable to load realms', e) - realms.value = [] + console.warn('Unable to load domains', e) + domains.value = [] } } @@ -218,7 +218,7 @@ function clearSensitiveState() { orgs.value = [] permissions.value = [] oidcClients.value = [] - realms.value = [] + domains.value = [] userDetail.value = null editingOidcClient.value = null authenticated.value = false @@ -248,7 +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) await loadDomains() if (!isMasterAdmin.value && isOrgAdmin.value && orgs.value.length === 1) { if (!window.location.hash || window.location.hash === '#overview') { @@ -475,8 +475,8 @@ function createPermissionForClient(clientId) { openDialog('perm-create', { display_name: '', scope: '', domain: clientId }) } -function createRealm() { - openDialog('realm-edit', { +function createDomain() { + openDialog('domain-edit', { isNew: true, rp_id: '', rp_name: '', @@ -487,31 +487,29 @@ function createRealm() { }) } -function openRealm(realm) { - // One combined list for editing: in-domain sites and related origins, - // classified by hostname. The default is always shown explicitly as the - // '*.rp_id' wildcard entry. Strip https:// scheme for editing. - const stored = realm.origins || [] - const origins = [...(stored.length ? stored : ['*.' + realm.rp_id]), ...(realm.related_origins || [])] - .map(o => o.replace(/^https:\/\//, '')) - openDialog('realm-edit', { +function openDomain(domain) { + // One combined list for editing, in display order: in-domain sites and + // related origins, classified by hostname. The default is always shown + // explicitly as the '*.rp_id' wildcard entry. + const rows = originDisplayEntries(domain) + openDialog('domain-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), + rp_id: domain.rp_id, + rp_name: domain.rp_name || '', + auth_host: rows.find(r => r.auth)?.key || '', + origins: rows.map(r => r.key), + originValidation: rows.map(() => null), wellKnownCheck: null, }) } -function deleteRealm(realm) { +function deleteDomain(domain) { openDialog('confirm', { - message: `Delete domain "${realm.rp_id}"? This is refused while any passkeys remain registered for it.`, + message: `Delete domain "${domain.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(`Domain "${realm.rp_id}" deleted.`, 'success', 2500) - await loadRealms() + await apiJson(`/auth/api/admin/domains/${domain.rp_id}`, { method: 'DELETE' }) + authStore.showMessage(`Domain "${domain.rp_id}" deleted.`, 'success', 2500) + await loadDomains() } }) } @@ -937,39 +935,44 @@ async function submitDialog() { authStore.showMessage(e.message || `Failed to ${isNew ? 'create' : 'update'} OIDC client`, 'error') }) return // Don't call closeDialog() again - } else if (t === 'realm-edit') { + } else if (t === 'domain-edit') { const d = dialog.value.data const rp_id = d.rp_id?.trim().toLowerCase() if (!rp_id) throw new Error('Domain (rp-id) required') const rp_name = d.rp_name?.trim() || '' const auth_host = d.auth_host?.trim() || '' // 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). Wildcards ('*.app.example.com') classify by - // their base domain. Bare hostnames are sent as-is; the backend - // normalizes them with https://. - const origins = [] - const related_origins = [] + // rp-id domain form the in-domain origins object (the auth host + // entry is marked), entries elsewhere are related origins (ROR). + // Wildcards ('*.app.example.com') classify by their base domain. + // Keys are stored without the https:// scheme. + const keyOf = o => o.replace(/^https:\/\//, '').replace(/\/+$/, '') + const origins = {} + const related = {} for (const o of (d.origins || []).map(o => o.trim()).filter(o => o)) { + const key = keyOf(o) let hn = null - if (o.startsWith('*.')) { - hn = o.slice(2).replace(/\.+$/, '') + if (key.startsWith('*.')) { + hn = key.slice(2).replace(/\.+$/, '') } else { - try { hn = new URL(o.startsWith('http') ? o : 'https://' + o).hostname } catch { continue } + try { hn = new URL(key.startsWith('http') ? key : 'https://' + key).hostname } catch { continue } } if (!hn) continue - if (hn === rp_id || hn.endsWith('.' + rp_id)) origins.push(o) - else related_origins.push(o) + if (hn === rp_id || hn.endsWith('.' + rp_id)) { + origins[key] = key === auth_host ? { auth_host: true } : true + } else { + related[key] = true + } } closeDialog() const req = d.isNew - ? 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 } }) + ? apiJson('/auth/api/admin/domains/', { method: 'POST', body: { rp_id, rp_name, origins, related } }) + : apiJson(`/auth/api/admin/domains/${rp_id}`, { method: 'PATCH', body: { rp_name, origins, related } }) req .then(() => { authStore.showMessage(`Domain "${rp_id}" ${d.isNew ? 'created' : 'updated'}.`, 'success', 2500) - loadRealms() + loadDomains() // Reload settings to reflect rp_name changes authStore.loadSettings(true).then(() => { if (authStore.settings?.rp_name) document.title = authStore.settings.rp_name + ' Admin' @@ -1031,7 +1034,8 @@ async function submitDialog() { :orgs="orgs" :permissions="permissions" :oidc-clients="oidcClients" - :realms="realms" + :domains="domains" + :current-rp-id="authStore.settings?.rp_id || ''" :navigation-disabled="hasActiveModal" :permission-summary="permissionSummary" @create-org="createOrg" @@ -1045,9 +1049,9 @@ async function submitDialog() { @create-oidc-client="createOidcClient" @open-oidc-client="openOidcClient" @delete-oidc-client="deleteOidcClient" - @create-realm="createRealm" - @open-realm="openRealm" - @delete-realm="deleteRealm" + @create-domain="createDomain" + @open-domain="openDomain" + @delete-domain="deleteDomain" @navigate-out="handlePanelNavigateOut" /> diff --git a/frontend/src/admin/AdminDialogs.vue b/frontend/src/admin/AdminDialogs.vue index 833a4dc..2ec625e 100644 --- a/frontend/src/admin/AdminDialogs.vue +++ b/frontend/src/admin/AdminDialogs.vue @@ -15,11 +15,11 @@ const NAME_EDIT_TYPES = new Set(['org-update', 'role-update', 'user-update-name' const NO_SUBMIT_TYPES = new Set([]) 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 || '') +// The rp-id of the domain being edited in the 'domain-edit' dialog +const dialogRpId = computed(() => props.dialog?.data?.rp_id || '') // Initialize validation properties -if (props.dialog?.data && props.dialog.type === 'realm-edit') { +if (props.dialog?.data && props.dialog.type === 'domain-edit') { if (!('originValidation' in props.dialog.data)) { props.dialog.data.originValidation = (props.dialog.data.origins || []).map(() => null) } @@ -33,7 +33,7 @@ if (props.dialog?.data && props.dialog.type === 'realm-edit') { // (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 + if (props.dialog?.type !== 'domain-edit') return false const d = props.dialog.data const bad = v => v === 'invalid' || v === 'validating' if (d.originValidation?.some(bad)) return true @@ -47,7 +47,7 @@ const isValidationInvalid = computed(() => { // 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)) + return !!(h && dialogRpId.value && !isWithinDomain(origin, dialogRpId.value)) } const relatedEntries = computed(() => { const d = props.dialog?.data @@ -86,7 +86,7 @@ function addOrigin() { // 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.origins.push(onThisDomain ? window.location.origin : realmRpId.value) + d.origins.push(onThisDomain ? window.location.origin : dialogRpId.value) d.originValidation.push(null) validateOrigin(d.origins.length - 1) } @@ -148,7 +148,7 @@ async function validateOriginConnectivity(i) { if (response.ok) { const data = await response.json() // 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' + d.originValidation[i] = (data.rp_id && data.rp_id === dialogRpId.value) ? 'valid' : 'mismatch' } else { d.originValidation[i] = 'unreachable' } @@ -207,9 +207,9 @@ watch(() => relatedEntries.value.map(asHttpsOrigin).join('|'), testWellKnown, { // so the list always shows what is allowed ('*.example.com' = the domain and // all its subdomains). Removing the last in-domain entry is blocked in the // row menu, so the list never becomes empty afterwards. -watch(realmRpId, rp => { +watch(dialogRpId, rp => { const d = props.dialog?.data - if (props.dialog?.type !== 'realm-edit' || !d?.isNew) return + if (props.dialog?.type !== 'domain-edit' || !d?.isNew) return if (!d.origins.length && isWellFormedDomain(rp)) { d.origins.push('*.' + rp) d.originValidation.push(null) @@ -220,33 +220,32 @@ watch(realmRpId, rp => { const openMenu = ref(null) -// Host[:port] of an entry or the auth_host value, for comparison. -function hostWithPort(value) { - if (!value?.trim()) return null - if (value.trim().startsWith('*.')) return null - try { return new URL(value.startsWith('http') ? value : 'https://' + value).host } catch { return null } +// Origins-dict key form of an entry (https:// omitted), also used for the +// auth_host value. +function entryKey(value) { + return value?.trim().replace(/^https:\/\//, '').replace(/\/+$/, '') || '' } function isAuthHostEntry(origin) { const d = props.dialog?.data - const host = hostWithPort(origin) - return !!(host && d?.auth_host && host === hostWithPort(d.auth_host)) + const key = entryKey(origin) + return !!(key && d?.auth_host && key === d.auth_host) } function setAuthHost(i) { const d = props.dialog?.data if (!d) return - let entry = d.origins[i].trim() - if (entry.startsWith('*.')) { + let key = entryKey(d.origins[i]) + if (key.startsWith('*.')) { // A wildcard cannot be the auth host — create a concrete auth. entry - entry = 'auth.' + entry.slice(2) - if (!d.origins.some(o => hostWithPort(o) === entry)) { - d.origins.push(entry) + key = 'auth.' + key.slice(2) + if (!d.origins.some(o => entryKey(o) === key)) { + d.origins.push(key) d.originValidation.push(null) validateOrigin(d.origins.length - 1) } } - d.auth_host = hostWithPort(entry) || entry + d.auth_host = key openMenu.value = null } @@ -288,7 +287,7 @@ function onRemoveOrigin(i) { - +