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.