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:
2026-09-07 06:10:08 +00:00
parent 00560a05c1
commit af6d7e3a3f
29 changed files with 491 additions and 420 deletions
+81 -20
View File
@@ -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; }