From 7726203382f3555df7e27c1e33e2cb42430e4a8d Mon Sep 17 00:00:00 2001 From: Leo Vasanko Date: Mon, 7 Sep 2026 14:54:46 +0000 Subject: [PATCH] Frontend: single origins table; lockout guard in the domain editor - Domain dialog submits one origins map (in-domain + related together); classification is derived, the submit-time split is gone - Placeholder-row machinery deleted: an empty list now means 'nothing allowed'; new domains get a real pre-filled '*.{rp-id}' row that follows rp-id edits until touched - Plain '*' is invalid; wildcards only within the domain - Editing the domain in use: when no auth host is marked and the admin's current page origin would no longer be allowed to run ceremonies, Save is disabled with an explanatory error (mirrors the backend guard) - Origin list display: single table with derived related badges; '*' sort special case removed --- frontend/auth/admin/AdminApp.vue | 39 ++------ frontend/src/admin/AdminDialogs.vue | 133 ++++++++++++++++------------ frontend/src/utils/helpers.js | 40 ++++++--- 3 files changed, 114 insertions(+), 98 deletions(-) diff --git a/frontend/auth/admin/AdminApp.vue b/frontend/auth/admin/AdminApp.vue index a1e18f1..c54a3c5 100644 --- a/frontend/auth/admin/AdminApp.vue +++ b/frontend/auth/admin/AdminApp.vue @@ -479,15 +479,13 @@ function createDomain() { auth_host: '', origins: [], originValidation: [], - originPlaceholders: [], wellKnownCheck: null, }) } function openDomain(domain) { // One combined list for editing, in display order: in-domain sites and - // related origins, classified by hostname. An empty origins object shows - // as a '*' placeholder row, omitted again on submit unless edited. + // related origins, classified by hostname against the rp-id. const rows = originDisplayEntries(domain) openDialog('domain-edit', { isNew: false, @@ -496,7 +494,6 @@ function openDomain(domain) { auth_host: rows.find(r => r.auth)?.key || '', origins: rows.map(r => r.key), originValidation: rows.map(() => null), - originPlaceholders: rows.map(r => !!r.placeholder), wellKnownCheck: null, }) } @@ -914,41 +911,21 @@ async function submitDialog() { if (!rp_id) throw new Error('Domain (rp-id) required') const rp_name = d.rp_name?.trim() || '' const auth_host = d.auth_host?.trim().toLowerCase() || '' - // The combined origins list is split by hostname: entries on the - // 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. + // One origins object holds in-domain sites and related origins + // (ROR) together; the server classifies each key against the rp-id. // Keys are stored lowercased, without the https:// scheme. const keyOf = o => o.replace(/^https:\/\//i, '').replace(/\/+$/, '').toLowerCase() const origins = {} - const related = {} - for (const [i, o] of (d.origins || []).entries()) { + for (const o of d.origins || []) { const key = keyOf(o.trim()) - // An untouched placeholder row only displays the empty-origins - // default (any scheme in-domain) — don't persist it as '*' - if (!key || d.originPlaceholders?.[i]) continue - if (key === '*') { - origins['*'] = true // shorthand for '*.{rp-id}' (https-only outside localhost) - continue - } - let hn = null - if (key.startsWith('*.')) { - hn = key.slice(2).replace(/\.+$/, '') - } else { - 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[key] = key === auth_host ? { auth_host: true } : true - } else { - related[key] = true - } + if (!key) continue + origins[key] = key === auth_host ? { auth_host: true } : true } closeDialog() const req = d.isNew - ? 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 } }) + ? apiJson('/auth/api/admin/domains/', { method: 'POST', body: { rp_id, rp_name, origins } }) + : apiJson(`/auth/api/admin/domains/${rp_id}`, { method: 'PATCH', body: { rp_name, origins } }) req .then(() => { authStore.showMessage(`Domain "${rp_id}" ${d.isNew ? 'created' : 'updated'}.`, 'success', 2500) diff --git a/frontend/src/admin/AdminDialogs.vue b/frontend/src/admin/AdminDialogs.vue index 2e155d3..3844c19 100644 --- a/frontend/src/admin/AdminDialogs.vue +++ b/frontend/src/admin/AdminDialogs.vue @@ -19,8 +19,9 @@ const NAME_EDIT_TYPES = new Set(['org-update', 'role-update', 'user-update-name' // case-insensitive) const dialogRpId = computed(() => (props.dialog?.data?.rp_id || '').trim().toLowerCase()) -// Block submit on hard errors: malformed entries, an over-cap related list -// (the server rejects the save), or validation still in flight. +// Block submit on hard errors: malformed entries, an over-cap related +// list (the server rejects the save), a save that would lock the admin +// out of the domain they are using, or validation still in flight. // Connectivity and rp-id mismatch results are warnings only (entries may // be hosted elsewhere, or a new domain whose DNS is not routed to this // instance yet). @@ -31,16 +32,17 @@ const isValidationInvalid = computed(() => { if (d.originValidation?.some(bad)) return true if (relatedEntries.value.length > 5) return true if (d.isNew && !isWellFormedDomain(d.rp_id || '')) return true + if (lockoutWarning.value) return true return false }) // A single origins list holds two kinds of entries: sites on the rp-id // domain form the in-domain sign-in allow-list; entries on other domain // names are related origins (WebAuthn ROR). Classification is automatic -// from the hostname — the submit handler splits the two lists apart. +// from the hostname. A plain '*' is invalid (wildcards must sit under +// the rp-id) and never a related origin. function isRelatedEntry(origin) { - const v = origin.trim() - if (v === '*' || v.startsWith('*.')) return false // wildcards are never related + if (origin.trim().startsWith('*.')) return false // wildcards are never related const h = originHostname(origin) return !!(h && dialogRpId.value && !isWithinDomain(origin, dialogRpId.value)) } @@ -74,6 +76,54 @@ function copyText(value, label) { }) } +// --- Lockout prevention (editing the domain in use) --- + +// When the admin edits the domain they are currently signed in on and no +// auth host is marked (with one, ceremonies move there and saving is +// always allowed), their current page origin must stay allowed to run +// passkey ceremonies — otherwise saving locks them out. Mirrors the +// backend check (Passkey.validate_origin): an in-domain origin matches a +// row exactly (scheme+host+port) or a wildcard row ('*.base' covers the +// base domain and its subdomains over https — any scheme and port under +// localhost); a related row matches only on exact equality (https://host). +const lockoutWarning = computed(() => { + const d = props.dialog?.data + if (props.dialog?.type !== 'domain-edit' || d?.isNew || d?.auth_host) return null + const rpId = dialogRpId.value + if (!rpId || rpId !== authStore.settings?.rp_id) return null + return pageOriginAllowed(d.origins || [], rpId) ? null : window.location.host +}) + +function pageOriginAllowed(rows, rpId) { + const toUrl = key => (key.startsWith('*.') || key.includes('://')) ? key : 'https://' + key + const inDomain = [] + const related = [] + for (const row of rows) { + if (!originHostname(row)) continue + const key = entryKey(row).toLowerCase() + if (!key) continue + const bucket = isRelatedEntry(row) ? related : inDomain + bucket.push(toUrl(key)) + } + const probe = origin => { + let hostname + try { hostname = new URL(origin).hostname } catch { return false } + if (hostname === rpId || hostname.endsWith('.' + rpId)) { + if (inDomain.includes(origin)) return true + return inDomain.some(e => { + if (!e.startsWith('*.')) return false + const base = e.slice(2).replace(/\.+$/, '') + if (hostname !== base && !hostname.endsWith('.' + base)) return false + // Under localhost a wildcard matches any scheme and port + return base === 'localhost' || base.endsWith('.localhost') || origin.startsWith('https://') + }) + } + return related.includes(origin) + } + // The page scheme may be http (e.g. on localhost) — probe both + return probe(`https://${window.location.host}`) || probe(`http://${window.location.host}`) +} + function addOrigin() { const d = props.dialog?.data if (!d) return @@ -83,7 +133,6 @@ function addOrigin() { const onThisDomain = authStore.settings?.rp_id && dialogRpId.value === authStore.settings.rp_id d.origins.push(onThisDomain ? window.location.origin : dialogRpId.value) d.originValidation.push(null) - d.originPlaceholders.push(false) validateOrigin(d.origins.length - 1) } function removeOrigin(i) { @@ -91,7 +140,6 @@ function removeOrigin(i) { if (d) { d.origins.splice(i, 1) d.originValidation.splice(i, 1) - d.originPlaceholders.splice(i, 1) } } function focusOriginStart(e) { @@ -111,14 +159,14 @@ function isWellFormedDomain(value) { } function originHostname(origin) { - if (!origin.trim()) return null - if (origin.trim() === '*') return '*' - if (origin.trim().startsWith('*.')) { - const base = origin.trim().slice(2).replace(/\.+$/, '').toLowerCase() + const v = origin.trim() + if (!v || v === '*') return null // a plain '*' is not a valid entry + if (v.startsWith('*.')) { + const base = v.slice(2).replace(/\.+$/, '').toLowerCase() return isWellFormedDomain(base) ? base : null } try { - const url = origin.startsWith('http') ? new URL(origin) : new URL('https://' + origin) + const url = v.startsWith('http') ? new URL(v) : new URL('https://' + v) return url.hostname || null } catch { return null @@ -126,7 +174,6 @@ function originHostname(origin) { } function isWithinDomain(origin, rpId) { - if (origin.trim() === '*') return true const hostname = originHostname(origin) if (!hostname) return false return hostname === rpId || hostname.endsWith('.' + rpId) @@ -175,7 +222,6 @@ function onOriginInput(i, e) { el.setSelectionRange(pos, pos) } d.origins[i] = value - d.originPlaceholders[i] = false // Keep the auth-host mark on a renamed entry, unless it no longer // qualifies (wildcards and related origins cannot be the auth host) if (d.auth_host && oldKey === d.auth_host) { @@ -193,7 +239,7 @@ function validateOrigin(i) { d.originValidation[i] = 'invalid' return } - if (value.trim() === '*' || value.trim().startsWith('*.')) { + if (value.trim().startsWith('*.')) { // Wildcards have no concrete site to probe, and are only allowed // within the domain (related origins are individual hosts) d.originValidation[i] = isWithinDomain(value, dialogRpId.value) ? null : 'invalid' @@ -231,19 +277,18 @@ async function testWellKnown() { } watch(() => relatedEntries.value.map(asHttpsOrigin).join('|'), testWellKnown, { immediate: true }) -// Seed the default '*.' entry for a new domain once its rp-id is -// known, so the list always shows what is allowed ('*.x' = the domain and -// all its subdomains; https only, any scheme/port under localhost). -// Removing the last in-domain entry is blocked in the row menu, so the -// list never becomes empty afterwards. -// Seeding waits for a complete-looking rp-id (letters after the final dot) -// so mid-typing states like 'something.' don't seed a broken '*.something'. +// Prefill a new domain's list with the real '*.' row once its +// rp-id is known ('*.x' = the domain and all its subdomains over https, +// any scheme and port under localhost). The row follows rp-id edits while +// it is still the untouched prefilled row; once the admin edits it, it is +// left alone. Seeding waits for a complete-looking rp-id (letters after +// the final dot) so mid-typing states like 'something.' don't prefill a +// broken '*.something'. function looksCompleteDomain(value) { const host = (value || '').trim().replace(/\.$/, '') return host === 'localhost' || /\.[a-z]{2,}$/i.test(host) } -// Tracks the auto-seeded entry so it can be corrected if it was seeded -// from an incomplete rp-id and the admin keeps typing. +// Tracks the prefilled row so rp-id edits can keep updating it. let seededOrigin = null watch(dialogRpId, rp => { const d = props.dialog?.data @@ -253,7 +298,6 @@ watch(dialogRpId, rp => { if (!d.origins.length) { d.origins.push(seed) d.originValidation.push(null) - d.originPlaceholders.push(false) seededOrigin = seed } else if (d.origins.length === 1 && d.origins[0] === seededOrigin && seed !== seededOrigin) { d.origins[0] = seed @@ -294,14 +338,12 @@ function setAuthHost(i) { if (!d) return let key = entryKey(d.origins[i]) let added = false - if (key === '*' || key.startsWith('*.')) { + if (key.startsWith('*.')) { // A wildcard cannot be the auth host — create a concrete auth. entry - const base = key === '*' ? dialogRpId.value : key.slice(2) - key = 'auth.' + base + key = 'auth.' + key.slice(2) if (!d.origins.some(o => entryKey(o) === key)) { d.origins.push(key) d.originValidation.push(null) - d.originPlaceholders.push(false) added = true } } @@ -325,34 +367,15 @@ function resortOrigins() { const d = props.dialog?.data if (!d) return const rank = o => isAuthHostEntry(o) ? 0 : o === dialogRpId.value ? 1 : isRelatedEntry(o) ? 3 : 2 - const pairs = d.origins.map((o, i) => [o, d.originValidation[i], d.originPlaceholders[i]]) + const pairs = d.origins.map((o, i) => [o, d.originValidation[i]]) pairs.sort((a, b) => rank(a[0]) - rank(b[0]) || compareOrigins(a[0], b[0])) d.origins = pairs.map(p => p[0]) d.originValidation = pairs.map(p => p[1]) - d.originPlaceholders = pairs.map(p => p[2]) -} - -const inDomainCount = computed(() => - (props.dialog?.data?.origins || []).filter(o => originHostname(o) && isWithinDomain(o, dialogRpId.value)).length -) - -// True while the list holds the synthesized row for an empty origins -// object (the rp-id and all subdomains, any scheme) -const hasPlaceholderRow = computed(() => - !!props.dialog?.data?.originPlaceholders?.some(Boolean) -) - -function canRemoveOrigin(i) { - const o = props.dialog?.data?.origins[i] - if (o === undefined) return false - // Never empty the in-domain list — that would silently mean the wildcard - // default on the server; keep at least one in-domain entry visible - return !originHostname(o) || !isWithinDomain(o, dialogRpId.value) || inDomainCount.value > 1 } function onRemoveOrigin(i) { const d = props.dialog?.data - if (!d || !canRemoveOrigin(i)) return + if (!d) return if (isAuthHostEntry(d.origins[i])) d.auth_host = '' removeOrigin(i) openMenu.value = null @@ -461,19 +484,19 @@ function onRemoveOrigin(i) {
- +
-

Some entries are invalid — wildcards are only allowed within the domain.

+

Some entries are invalid — a plain '*' is not allowed, and wildcards only within the domain.

Some sites are unreachable — make sure they are routed to this instance.

Some sites are reachable but do not serve this domain.

- Only the listed sites may sign in with this domain's passkeys — * is shorthand for *.{{ dialog.data.rp_id }}: the domain and all its subdomains over https (any scheme and port under localhost); list a full origin like http://localhost:8080 for other exceptions. - Entries on other domain names become related origins (WebAuthn ROR), marked 🔗. The 🔑 site hosts the account and admin interface (set via ⋮). + Only the listed sites may sign in with this domain's passkeys — *.{{ dialog.data.rp_id }} allows the whole domain over https (any scheme and port under localhost); use a full origin like http://localhost:8080 for other exceptions. + Entries on other domain names become related origins (WebAuthn ROR), marked 🔗. The 🔑 site hosts the account and admin interface (set via ⋮). An empty list allows nothing of this domain (related origins still work).

-

The * row is the default of a domain without configured origins: the domain and all its subdomains may sign in on any scheme. Saving keeps this default only while the row is left unedited.

+

Saving would lock you out: {{ lockoutWarning }} could no longer run sign-in ceremonies for this domain. Keep it listed, or mark an auth host.