Instance-global OIDC provider; per-domain auth hosts with shared-host resolution
- DB.oidc is a single OIDC (one key, one client set); hosts are issuer
aliases. OIDCCode drops its rp_id field; client CRUD is not keyed by
domain.
- No cross-domain auth-host fallback: a domain without its own auth host
uses its own hosts; several domains may share one auth host (nested
rp-ids) with deterministic best-suffix resolution.
- '*' origin shorthand expands to '*.{rp-id}'; legacy wildcards convert
as-is; related origins may point at/inside another domain's rp-id.
- Admin UI and docs updated to match.
This commit is contained in:
@@ -1,8 +1,9 @@
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import Modal from '@/components/Modal.vue'
|
||||
import NameEditForm from '@/components/NameEditForm.vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { compareOrigins } from '@/utils/helpers'
|
||||
|
||||
const props = defineProps({
|
||||
dialog: Object,
|
||||
@@ -161,6 +162,23 @@ async function validateOriginConnectivity(i) {
|
||||
}
|
||||
}
|
||||
|
||||
// A sole '*' expands to '*.<rp-id>' immediately, keeping the cursor where
|
||||
// it was (before the inserted rp-id).
|
||||
function onOriginInput(i, e) {
|
||||
const d = props.dialog?.data
|
||||
if (!d) return
|
||||
const el = e.target
|
||||
let value = el.value
|
||||
if (value === '*' && dialogRpId.value) {
|
||||
const pos = el.selectionStart
|
||||
value = '*.' + dialogRpId.value
|
||||
el.value = value
|
||||
el.setSelectionRange(pos, pos)
|
||||
}
|
||||
d.origins[i] = value
|
||||
validateOrigin(i)
|
||||
}
|
||||
|
||||
function validateOrigin(i) {
|
||||
const d = props.dialog?.data
|
||||
if (!d) return
|
||||
@@ -205,16 +223,32 @@ 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 ('*' = the domain and all its
|
||||
// subdomains, any scheme/port). Removing the last in-domain entry is
|
||||
// blocked in the row menu, so the list never becomes empty afterwards.
|
||||
// Seed the default '*.<rp-id>' 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'.
|
||||
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.
|
||||
let seededOrigin = null
|
||||
watch(dialogRpId, rp => {
|
||||
const d = props.dialog?.data
|
||||
if (props.dialog?.type !== 'domain-edit' || !d?.isNew) return
|
||||
if (!d.origins.length && isWellFormedDomain(rp)) {
|
||||
d.origins.push('*')
|
||||
if (!looksCompleteDomain(rp) || !isWellFormedDomain(rp)) return
|
||||
const seed = '*.' + rp.trim().replace(/\.$/, '')
|
||||
if (!d.origins.length) {
|
||||
d.origins.push(seed)
|
||||
d.originValidation.push(null)
|
||||
seededOrigin = seed
|
||||
} else if (d.origins.length === 1 && d.origins[0] === seededOrigin && seed !== seededOrigin) {
|
||||
d.origins[0] = seed
|
||||
seededOrigin = seed
|
||||
}
|
||||
})
|
||||
|
||||
@@ -222,6 +256,14 @@ watch(dialogRpId, rp => {
|
||||
|
||||
const openMenu = ref(null)
|
||||
|
||||
// Close the popup on any click outside it (the toggle button stops
|
||||
// propagation, so it never reaches this listener).
|
||||
function onDocumentClick(e) {
|
||||
if (openMenu.value !== null && !e.target.closest('.row-menu')) openMenu.value = null
|
||||
}
|
||||
onMounted(() => document.addEventListener('click', onDocumentClick))
|
||||
onBeforeUnmount(() => document.removeEventListener('click', onDocumentClick))
|
||||
|
||||
// Origins-dict key form of an entry (https:// omitted), also used for the
|
||||
// auth_host value.
|
||||
function entryKey(value) {
|
||||
@@ -238,6 +280,7 @@ function setAuthHost(i) {
|
||||
const d = props.dialog?.data
|
||||
if (!d) return
|
||||
let key = entryKey(d.origins[i])
|
||||
let added = false
|
||||
if (key === '*' || key.startsWith('*.')) {
|
||||
// A wildcard cannot be the auth host — create a concrete auth.<base> entry
|
||||
const base = key === '*' ? dialogRpId.value : key.slice(2)
|
||||
@@ -245,17 +288,33 @@ function setAuthHost(i) {
|
||||
if (!d.origins.some(o => entryKey(o) === key)) {
|
||||
d.origins.push(key)
|
||||
d.originValidation.push(null)
|
||||
validateOrigin(d.origins.length - 1)
|
||||
added = true
|
||||
}
|
||||
}
|
||||
d.auth_host = key
|
||||
openMenu.value = null
|
||||
resortOrigins()
|
||||
if (added) validateOrigin(d.origins.findIndex(o => entryKey(o) === key))
|
||||
}
|
||||
|
||||
function clearAuthHost() {
|
||||
const d = props.dialog?.data
|
||||
if (d) d.auth_host = ''
|
||||
openMenu.value = null
|
||||
resortOrigins()
|
||||
}
|
||||
|
||||
// Display order, applied after row-menu actions (never while typing in an
|
||||
// input, to avoid focus loss): auth host first, then the rp-id, then
|
||||
// in-domain entries hierarchically, then related origins.
|
||||
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]])
|
||||
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])
|
||||
}
|
||||
|
||||
const inDomainCount = computed(() =>
|
||||
@@ -276,6 +335,7 @@ function onRemoveOrigin(i) {
|
||||
if (isAuthHostEntry(d.origins[i])) d.auth_host = ''
|
||||
removeOrigin(i)
|
||||
openMenu.value = null
|
||||
resortOrigins()
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -290,7 +350,7 @@ function onRemoveOrigin(i) {
|
||||
<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==='domain-edit'">{{ dialog.data?.isNew ? 'Add Domain' : 'Edit Domain' }}</template>
|
||||
<template v-else-if="dialog.type==='domain-edit'">{{ dialog.data?.isNew ? 'Add Domain' : `Edit Domain: ${dialog.data?.rp_id}` }}</template>
|
||||
<template v-else-if="dialog.type==='confirm'">Confirm</template>
|
||||
</h3>
|
||||
<form @submit.prevent="$emit('submitDialog')" class="modal-form">
|
||||
@@ -355,9 +415,8 @@ function onRemoveOrigin(i) {
|
||||
<label>Domain (rp-id)
|
||||
<input v-model="dialog.data.rp_id" placeholder="example.com" data-form-type="other" required />
|
||||
</label>
|
||||
<p class="small muted">The domain name passkeys belong to — they work on this domain and its subdomains, and never on other domains. Cannot be changed later.</p>
|
||||
<p class="small muted">The domain name passkeys belong to — they work on this domain and its subdomains, and related domains. Cannot be changed later.</p>
|
||||
</template>
|
||||
<p v-else class="small muted">Domain: <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>
|
||||
@@ -370,19 +429,19 @@ function onRemoveOrigin(i) {
|
||||
<div v-for="(_, i) in dialog.data.origins" :key="i" class="origin-row">
|
||||
<input
|
||||
:value="dialog.data.origins[i]"
|
||||
@input="e => { dialog.data.origins[i] = e.target.value; validateOrigin(i) }"
|
||||
@input="e => onOriginInput(i, e)"
|
||||
@focus="focusOriginStart"
|
||||
class="origin-input"
|
||||
:class="{ 'input-error': dialog.data.originValidation[i] === 'invalid' }"
|
||||
/>
|
||||
<span v-if="isAuthHostEntry(dialog.data.origins[i])" class="key-badge" title="Authentication site — the account and admin interface live here">🔑</span>
|
||||
<span v-else-if="isRelatedEntry(dialog.data.origins[i])" class="ror-tag" title="Related origin (WebAuthn ROR) — shares this domain's passkeys">related</span>
|
||||
<span v-else-if="isRelatedEntry(dialog.data.origins[i])" class="key-badge" title="Related origin (WebAuthn ROR) — shares this domain's passkeys">🔗</span>
|
||||
<div class="row-menu">
|
||||
<button type="button" class="icon-btn" @click.stop="openMenu = openMenu === i ? null : i" aria-label="Origin actions" title="Actions">⋮</button>
|
||||
<div v-if="openMenu === i" class="row-menu-popup">
|
||||
<button v-if="isAuthHostEntry(dialog.data.origins[i])" type="button" @click="clearAuthHost()">Remove auth host</button>
|
||||
<button v-else-if="!isRelatedEntry(dialog.data.origins[i]) && originHostname(dialog.data.origins[i])" type="button" @click="setAuthHost(i)">Set as auth host</button>
|
||||
<button type="button" @click="onRemoveOrigin(i)" :disabled="!canRemoveOrigin(i)">Remove entry</button>
|
||||
<button v-if="isAuthHostEntry(dialog.data.origins[i])" type="button" @click="clearAuthHost()"><span class="menu-icon">🔑</span>Remove auth host</button>
|
||||
<button v-else-if="!isRelatedEntry(dialog.data.origins[i]) && originHostname(dialog.data.origins[i])" type="button" @click="setAuthHost(i)"><span class="menu-icon">🔑</span>Set as auth host</button>
|
||||
<button type="button" @click="onRemoveOrigin(i)" :disabled="!canRemoveOrigin(i)"><span class="menu-icon delete-menu-icon">❌</span>Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -390,8 +449,8 @@ function onRemoveOrigin(i) {
|
||||
<p v-else-if="dialog.data.originValidation.some(v => v === 'mismatch')" class="small muted">Some sites are reachable but do not serve this domain.</p>
|
||||
</div>
|
||||
<p class="small muted">
|
||||
Only the listed sites may sign in with this domain's passkeys — <strong>*</strong> means the domain and all its subdomains on any scheme and port; <strong>*.{{ dialog.data.rp_id }}</strong> restricts that to https.
|
||||
Entries on other domain names become related origins (WebAuthn ROR). The 🔑 site hosts the account and admin interface (set via ⋮).
|
||||
Only the listed sites may sign in with this domain's passkeys — <strong>*</strong> is shorthand for <strong>*.{{ dialog.data.rp_id }}</strong>: the domain and all its subdomains over https (any scheme and port under localhost); list a full origin like <strong>http://localhost:8080</strong> for other exceptions.
|
||||
Entries on other domain names become related origins (WebAuthn ROR), marked 🔗. The 🔑 site hosts the account and admin interface (set via ⋮).
|
||||
</p>
|
||||
|
||||
<template v-if="relatedEntries.length">
|
||||
@@ -467,10 +526,12 @@ function onRemoveOrigin(i) {
|
||||
.key-badge { flex-shrink: 0; }
|
||||
.row-menu { position: relative; flex-shrink: 0; }
|
||||
.row-menu-popup { position: absolute; right: 0; top: 100%; z-index: 10; display: flex; flex-direction: column; min-width: 9rem; background: var(--color-bg, #fff); border: 1px solid var(--color-border, #ccc); border-radius: 4px; box-shadow: 0 2px 8px rgba(0,0,0,0.15); }
|
||||
.row-menu-popup button { text-align: left; padding: var(--space-xs) var(--space-sm); background: none; border: none; cursor: pointer; white-space: nowrap; }
|
||||
.row-menu-popup button { display: flex; align-items: center; justify-content: flex-start; gap: 0.45em; text-align: left; padding: var(--space-xs) var(--space-sm); background: none; border: none; cursor: pointer; white-space: nowrap; }
|
||||
.row-menu-popup button:hover:not(:disabled) { background: var(--color-bg-soft, rgba(127,127,127,0.12)); }
|
||||
.row-menu-popup button:disabled { opacity: 0.5; cursor: default; }
|
||||
.ror-tag { flex-shrink: 0; font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.03em; color: var(--color-text-muted); border: 1px solid var(--color-border, currentColor); border-radius: 3px; padding: 0 0.3rem; }
|
||||
.row-menu-popup .menu-icon { flex-shrink: 0; width: 1.1em; text-align: center; }
|
||||
.row-menu-popup .delete-menu-icon { filter: saturate(1.4); }
|
||||
|
||||
.wellknown-doc { display: flex; align-items: flex-start; gap: var(--space-xs); }
|
||||
.wellknown-doc pre { flex: 1; margin: 0; padding: var(--space-xs) var(--space-sm); font-size: 0.8rem; background: var(--color-bg-soft, rgba(127,127,127,0.08)); border-radius: 4px; overflow-x: auto; }
|
||||
|
||||
|
||||
Reference in New Issue
Block a user