MultiSite: one instance serves authentication across many domains #4
@@ -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, originDisplayEntries } from '@/utils/helpers'
|
||||
import { originDisplayEntries } from '@/utils/helpers'
|
||||
|
||||
const info = ref(null)
|
||||
const loading = ref(true)
|
||||
@@ -465,10 +465,6 @@ function resetOidcSecret(clientId) {
|
||||
if (editingOidcClient.value?.client_id === clientId) {
|
||||
editingOidcClient.value = { ...editingOidcClient.value, client_secret }
|
||||
}
|
||||
// Also update dialog if open (for backwards compatibility)
|
||||
if (dialog.value.type === 'oidc-edit' && dialog.value.data?.client_id === clientId) {
|
||||
dialog.value.data.client_secret = client_secret
|
||||
}
|
||||
}
|
||||
|
||||
function createPermissionForClient(clientId) {
|
||||
@@ -483,14 +479,15 @@ 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. The default is always shown
|
||||
// explicitly as the '*' entry.
|
||||
// related origins, classified by hostname. An empty origins object shows
|
||||
// as a '*' placeholder row, omitted again on submit unless edited.
|
||||
const rows = originDisplayEntries(domain)
|
||||
openDialog('domain-edit', {
|
||||
isNew: false,
|
||||
@@ -499,6 +496,7 @@ 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,
|
||||
})
|
||||
}
|
||||
@@ -910,49 +908,27 @@ async function submitDialog() {
|
||||
authStore.showMessage(e.message || 'Failed to create permission', 'error')
|
||||
})
|
||||
return // Don't call closeDialog() again
|
||||
} else if (t === 'oidc-edit') {
|
||||
const { client_id, client_secret, isNew } = dialog.value.data
|
||||
const name = dialog.value.data.name?.trim()
|
||||
const uris = dialog.value.data.redirect_uris?.trim()
|
||||
if (!name) throw new Error('Client name required')
|
||||
|
||||
const redirect_uris = uris ? uris.split('\n').map(u => u.trim()).filter(u => u) : []
|
||||
|
||||
// Close dialog immediately, then perform async operation
|
||||
closeDialog()
|
||||
|
||||
const req = client_secret
|
||||
? sha256Hex(client_secret).then(secret_hash => isNew
|
||||
? apiJson('/auth/api/admin/oidc-clients', { method: 'POST', body: { client_id, secret_hash, name, redirect_uris } })
|
||||
: apiJson(`/auth/api/admin/oidc-clients/${client_id}`, { method: 'PATCH', body: { name, redirect_uris, secret_hash } }))
|
||||
: apiJson(`/auth/api/admin/oidc-clients/${client_id}`, { method: 'PATCH', body: { name, redirect_uris } })
|
||||
req
|
||||
.then(() => {
|
||||
authStore.showMessage(`OIDC client "${name}" ${isNew ? 'created' : 'updated'}.`, 'success', 2500)
|
||||
loadAdminData()
|
||||
})
|
||||
.catch(e => {
|
||||
authStore.showMessage(e.message || `Failed to ${isNew ? 'create' : 'update'} OIDC client`, 'error')
|
||||
})
|
||||
return // Don't call closeDialog() again
|
||||
} 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() || ''
|
||||
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.
|
||||
// Keys are stored without the https:// scheme.
|
||||
const keyOf = o => o.replace(/^https:\/\//, '').replace(/\/+$/, '')
|
||||
// Keys are stored lowercased, without the https:// scheme.
|
||||
const keyOf = o => o.replace(/^https:\/\//i, '').replace(/\/+$/, '').toLowerCase()
|
||||
const origins = {}
|
||||
const related = {}
|
||||
for (const o of (d.origins || []).map(o => o.trim()).filter(o => o)) {
|
||||
const key = keyOf(o)
|
||||
for (const [i, o] of (d.origins || []).entries()) {
|
||||
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 // anything in-domain, any scheme/port
|
||||
origins['*'] = true // shorthand for '*.{rp-id}' (https-only outside localhost)
|
||||
continue
|
||||
}
|
||||
let hn = null
|
||||
@@ -1119,8 +1095,6 @@ async function submitDialog() {
|
||||
:permission-id-pattern="PERMISSION_ID_PATTERN"
|
||||
@submit-dialog="submitDialog"
|
||||
@close-dialog="closeDialog"
|
||||
@reset-oidc-secret="resetOidcSecret"
|
||||
@create-permission-for-client="createPermissionForClient"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -10,34 +10,26 @@ const props = defineProps({
|
||||
PERMISSION_ID_PATTERN: String
|
||||
})
|
||||
|
||||
const emit = defineEmits(['submitDialog', 'closeDialog', 'resetOidcSecret', 'createPermissionForClient'])
|
||||
defineEmits(['submitDialog', 'closeDialog'])
|
||||
|
||||
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 domain being edited in the 'domain-edit' dialog
|
||||
const dialogRpId = computed(() => props.dialog?.data?.rp_id || '')
|
||||
// (lowercased: classification compares against it, and hosts are
|
||||
// case-insensitive)
|
||||
const dialogRpId = computed(() => (props.dialog?.data?.rp_id || '').trim().toLowerCase())
|
||||
|
||||
// Initialize validation properties
|
||||
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)
|
||||
}
|
||||
if (!('wellKnownCheck' in props.dialog.data)) {
|
||||
props.dialog.data.wellKnownCheck = null
|
||||
}
|
||||
}
|
||||
|
||||
// Block submit on hard errors: malformed entries 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).
|
||||
// Block submit on hard errors: malformed entries, an over-cap related list
|
||||
// (the server rejects the save), 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).
|
||||
const isValidationInvalid = computed(() => {
|
||||
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
|
||||
if (relatedEntries.value.length > 5) return true
|
||||
if (d.isNew && !isWellFormedDomain(d.rp_id || '')) return true
|
||||
return false
|
||||
})
|
||||
@@ -47,6 +39,8 @@ const isValidationInvalid = computed(() => {
|
||||
// names are related origins (WebAuthn ROR). Classification is automatic
|
||||
// from the hostname — the submit handler splits the two lists apart.
|
||||
function isRelatedEntry(origin) {
|
||||
const v = origin.trim()
|
||||
if (v === '*' || v.startsWith('*.')) return false // wildcards are never related
|
||||
const h = originHostname(origin)
|
||||
return !!(h && dialogRpId.value && !isWithinDomain(origin, dialogRpId.value))
|
||||
}
|
||||
@@ -86,9 +80,10 @@ function addOrigin() {
|
||||
// Prefill with the origin the admin is currently on when editing that
|
||||
// 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
|
||||
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) {
|
||||
@@ -96,6 +91,7 @@ function removeOrigin(i) {
|
||||
if (d) {
|
||||
d.origins.splice(i, 1)
|
||||
d.originValidation.splice(i, 1)
|
||||
d.originPlaceholders.splice(i, 1)
|
||||
}
|
||||
}
|
||||
function focusOriginStart(e) {
|
||||
@@ -106,7 +102,9 @@ function isWellFormedDomain(value) {
|
||||
if (!value.trim()) return false
|
||||
try {
|
||||
const url = value.startsWith('http') ? new URL(value) : new URL('https://' + value)
|
||||
return url.hostname.includes('.') || url.hostname === 'localhost'
|
||||
// Any DNS label sequence (matching backend validate_rp_id): labels of
|
||||
// 1-63 alnum/hyphen chars, no leading/trailing hyphen, dot-separated
|
||||
return /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)*$/i.test(url.hostname)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
@@ -116,7 +114,7 @@ function originHostname(origin) {
|
||||
if (!origin.trim()) return null
|
||||
if (origin.trim() === '*') return '*'
|
||||
if (origin.trim().startsWith('*.')) {
|
||||
const base = origin.trim().slice(2).replace(/\.+$/, '')
|
||||
const base = origin.trim().slice(2).replace(/\.+$/, '').toLowerCase()
|
||||
return isWellFormedDomain(base) ? base : null
|
||||
}
|
||||
try {
|
||||
@@ -168,6 +166,7 @@ function onOriginInput(i, e) {
|
||||
const d = props.dialog?.data
|
||||
if (!d) return
|
||||
const el = e.target
|
||||
const oldKey = entryKey(d.origins[i])
|
||||
let value = el.value
|
||||
if (value === '*' && dialogRpId.value) {
|
||||
const pos = el.selectionStart
|
||||
@@ -176,6 +175,13 @@ 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) {
|
||||
const key = entryKey(value)
|
||||
d.auth_host = key && !key.startsWith('*') && !isRelatedEntry(value) ? key : ''
|
||||
}
|
||||
validateOrigin(i)
|
||||
}
|
||||
|
||||
@@ -188,7 +194,9 @@ function validateOrigin(i) {
|
||||
return
|
||||
}
|
||||
if (value.trim() === '*' || value.trim().startsWith('*.')) {
|
||||
d.originValidation[i] = null // wildcards have no concrete site to probe
|
||||
// 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'
|
||||
return
|
||||
}
|
||||
validateOriginConnectivity(i)
|
||||
@@ -245,6 +253,7 @@ 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
|
||||
@@ -256,6 +265,10 @@ watch(dialogRpId, rp => {
|
||||
|
||||
const openMenu = ref(null)
|
||||
|
||||
// The component stays mounted across dialogs; a closed dialog (including
|
||||
// Escape in Modal) must not leave a row menu open
|
||||
watch(() => props.dialog?.type, () => { openMenu.value = null })
|
||||
|
||||
// Close the popup on any click outside it (the toggle button stops
|
||||
// propagation, so it never reaches this listener).
|
||||
function onDocumentClick(e) {
|
||||
@@ -288,6 +301,7 @@ function setAuthHost(i) {
|
||||
if (!d.origins.some(o => entryKey(o) === key)) {
|
||||
d.origins.push(key)
|
||||
d.originValidation.push(null)
|
||||
d.originPlaceholders.push(false)
|
||||
added = true
|
||||
}
|
||||
}
|
||||
@@ -311,14 +325,21 @@ 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]])
|
||||
const pairs = d.origins.map((o, i) => [o, d.originValidation[i], d.originPlaceholders[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) && !isRelatedEntry(o)).length
|
||||
(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) {
|
||||
@@ -326,7 +347,7 @@ function canRemoveOrigin(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 isRelatedEntry(o) || !originHostname(o) || inDomainCount.value > 1
|
||||
return !originHostname(o) || !isWithinDomain(o, dialogRpId.value) || inDomainCount.value > 1
|
||||
}
|
||||
|
||||
function onRemoveOrigin(i) {
|
||||
@@ -349,7 +370,6 @@ function onRemoveOrigin(i) {
|
||||
<template v-else-if="dialog.type==='user-create'">Add User To Role</template>
|
||||
<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: ${dialog.data?.rp_id}` }}</template>
|
||||
<template v-else-if="dialog.type==='confirm'">Confirm</template>
|
||||
</h3>
|
||||
@@ -445,6 +465,7 @@ function onRemoveOrigin(i) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="dialog.data.originValidation.some(v => v === 'invalid')" class="small error">Some entries are invalid — wildcards are only allowed within the domain.</p>
|
||||
<p v-if="dialog.data.originValidation.some(v => v === 'unreachable')" class="small muted">Some sites are unreachable — make sure they are routed to this instance.</p>
|
||||
<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>
|
||||
@@ -452,9 +473,10 @@ function onRemoveOrigin(i) {
|
||||
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>
|
||||
<p v-if="hasPlaceholderRow" class="small muted">The <strong>*</strong> 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.</p>
|
||||
|
||||
<template v-if="relatedEntries.length">
|
||||
<p v-if="relatedEntries.length > 5" class="small error">Browsers support at most 5 related origins — {{ relatedEntries.length }} listed, extras will not work.</p>
|
||||
<p v-if="relatedEntries.length > 5" class="small error">At most 5 related origins are allowed ({{ relatedEntries.length }} listed) — the save is rejected.</p>
|
||||
<p class="small muted">
|
||||
Related origins are verified by browsers against
|
||||
<a :href="wellKnownUrl" target="_blank" rel="noopener noreferrer">{{ wellKnownUrl }}</a>
|
||||
@@ -474,7 +496,7 @@ function onRemoveOrigin(i) {
|
||||
<p>{{ dialog.data.message }}</p>
|
||||
</template>
|
||||
<div v-if="dialog.error && !NAME_EDIT_TYPES.has(dialog.type)" class="error small">{{ dialog.error }}</div>
|
||||
<div v-if="!NAME_EDIT_TYPES.has(dialog.type) && !NO_SUBMIT_TYPES.has(dialog.type)" class="modal-actions">
|
||||
<div v-if="!NAME_EDIT_TYPES.has(dialog.type)" class="modal-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="btn-secondary"
|
||||
@@ -491,37 +513,16 @@ function onRemoveOrigin(i) {
|
||||
{{ dialog.type==='confirm' ? 'OK' : 'Save' }}
|
||||
</button>
|
||||
</div>
|
||||
<div v-else-if="NO_SUBMIT_TYPES.has(dialog.type)" class="modal-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="btn-primary"
|
||||
@click="$emit('closeDialog')"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.optional { font-weight: normal; color: var(--color-text-muted); font-size: 0.85em; }
|
||||
.oidc-divider { border: none; border-top: 1px solid var(--color-border); margin: var(--space-sm) 0; }
|
||||
.oidc-dl { display: grid; grid-template-columns: auto 1fr; gap: 0.2rem 1rem; align-items: baseline; margin: 0; }
|
||||
.oidc-dl dt { font-size: 0.85rem; color: var(--color-text-muted); white-space: nowrap; }
|
||||
.oidc-dl dd { margin: 0; cursor: pointer; overflow: hidden; }
|
||||
.oidc-dl output { font-family: var(--font-mono, monospace); font-size: 0.85rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; display: block; }
|
||||
.oidc-reset-row { display: flex; align-items: center; gap: var(--space-sm); flex-wrap: wrap; }
|
||||
.oidc-groups { cursor: default; }
|
||||
.oidc-group { cursor: pointer; }
|
||||
.oidc-group output { white-space: normal; word-break: break-all; }
|
||||
|
||||
/* Server config origins */
|
||||
/* Domain origins */
|
||||
.origin-label { font-weight: 600; font-size: 0.95rem; margin-top: var(--space-sm); display: flex; align-items: center; gap: var(--space-sm); }
|
||||
.origin-list { display: flex; flex-direction: column; gap: 0.4rem; }
|
||||
.origin-row { display: flex; align-items: center; gap: var(--space-xs); }
|
||||
.origin-input { flex: 1; min-width: 8rem; font-family: var(--font-mono, monospace); }
|
||||
.origin-row .delete-icon { flex-shrink: 0; }
|
||||
.origin-add-btn { font-size: 1.2rem; }
|
||||
.key-badge { flex-shrink: 0; }
|
||||
.row-menu { position: relative; flex-shrink: 0; }
|
||||
|
||||
@@ -30,7 +30,7 @@ const clientSecret = ref(null)
|
||||
|
||||
// Computed
|
||||
const clientId = computed(() => props.client?.client_id || props.client?.uuid || '')
|
||||
// One discovery URL per domain origin (the OIDC provider is instance-global;
|
||||
// One discovery URL per domain (the OIDC provider is instance-global;
|
||||
// any configured host works — the RP must use its chosen one consistently)
|
||||
const discoveryUrls = computed(() => {
|
||||
const origins = new Set()
|
||||
|
||||
@@ -39,8 +39,7 @@ function domainDisplay(domain) {
|
||||
return oidcClientNames.value[domain] || domain
|
||||
}
|
||||
|
||||
// Domains display in alphabetical rp-id order — the stored configuration
|
||||
// is an unordered object.
|
||||
// Domains display in alphabetical rp-id order.
|
||||
const sortedDomains = computed(() =>
|
||||
[...(props.domains || [])].sort((a, b) => a.rp_id.localeCompare(b.rp_id))
|
||||
)
|
||||
@@ -437,10 +436,10 @@ defineExpose({ focusFirstElement })
|
||||
<div class="section-header">
|
||||
<h2>Domains</h2>
|
||||
<p class="section-description">
|
||||
The domain names (rp-ids) served, along with hosts belonging to them. Each domain has its own passkeys, and each host will only accept passkeys from its own domain. To let several <em>different</em> domain names share the same passkeys, open the domain and configure related domains (WebAuthn Related Origins). Alternatively create entirely separate domains, or combine the two modes. Only the main domain can have wildcards, and there can be only up to five related origins on the same domain.
|
||||
The domain names (rp-ids) served, along with hosts belonging to them. Each domain has its own passkeys, and each host will only accept passkeys from its own domain. To let several <em>different</em> domain names share the same passkeys, open the domain and configure related domains (WebAuthn Related Origins). Alternatively create entirely separate domains, or combine the two modes. Each domain's own origins may use wildcards; related origins are individual hosts only, at most five per domain.
|
||||
</p>
|
||||
<p class="section-description">
|
||||
Relative domains within the same domain are your choice when you wish to preserve existing credentials to a few alternative domains. Configure separate domains only when there is more separation, or a need for wildcard hosts. Note that users are shared and remote logins remain possible across domains.
|
||||
Related origins are your choice when you wish to keep existing credentials working on a few alternative domains. Configure separate domains only when there is more separation, or a need for wildcard hosts. Note that users are shared and remote logins remain possible across domains.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
|
||||
@@ -45,13 +45,14 @@ export const hostIP = ip => {
|
||||
// Display-time ordering of a domain's configured origins (the stored
|
||||
// objects are unordered): the auth host first (flagged), then in-domain
|
||||
// entries (exact rp-id, then hierarchical), then related domains
|
||||
// hierarchically. An empty origins object shows as the '*' default
|
||||
// (anything within the rp-id domain, any scheme/port).
|
||||
// hierarchically. An empty origins object (the rp-id and all subdomains
|
||||
// may sign in on any scheme — deliberately broader than a literal '*',
|
||||
// which is https-only outside localhost) shows as a '*' placeholder row.
|
||||
|
||||
// Hierarchical origin comparison: split off scheme/port, compare hostnames
|
||||
// label by label from the TLD down, parents before their subdomains and a
|
||||
// wildcard label after all concrete labels at the same level. Entries on
|
||||
// the same host tie-break by scheme (https first) and port.
|
||||
// the same host tie-break by scheme (https first) and numeric port.
|
||||
function originParts(key) {
|
||||
let s = key.toLowerCase().replace(/\/+$/, '')
|
||||
let scheme = ''
|
||||
@@ -60,7 +61,9 @@ function originParts(key) {
|
||||
let port = ''
|
||||
const pm = s.match(/:(\d+)$/)
|
||||
if (pm) { port = pm[1]; s = s.slice(0, -pm[0].length) }
|
||||
return { labels: s.split('.').reverse(), scheme, port }
|
||||
// A bare '*' (shorthand for '*.{rp-id}') sorts before all host entries
|
||||
const labels = s === '*' ? [] : s.split('.').reverse()
|
||||
return { labels, scheme, port }
|
||||
}
|
||||
|
||||
export function compareOrigins(a, b) {
|
||||
@@ -80,6 +83,7 @@ export function compareOrigins(a, b) {
|
||||
if (B.scheme === 'https') return 1
|
||||
return A.scheme.localeCompare(B.scheme)
|
||||
}
|
||||
if (A.port && B.port) return Number(A.port) - Number(B.port)
|
||||
return A.port.localeCompare(B.port)
|
||||
}
|
||||
|
||||
@@ -95,7 +99,7 @@ export function originDisplayEntries(domain) {
|
||||
const rows = []
|
||||
if (authKey) rows.push({ key: authKey, auth: true })
|
||||
for (const k of inDomain) rows.push({ key: k, auth: false })
|
||||
if (!keys.length) rows.push({ key: '*', auth: false })
|
||||
if (!keys.length) rows.push({ key: '*', auth: false, placeholder: true })
|
||||
for (const k of Object.keys(domain.related || {}).sort(compareOrigins)) {
|
||||
rows.push({ key: k, auth: false, related: true })
|
||||
}
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
let _settingsPromise = null
|
||||
let _settings = null
|
||||
let _requestGen = 0
|
||||
|
||||
export function getSettingsCached() { return _settings }
|
||||
|
||||
export async function getSettings(force = false) {
|
||||
if (force) { _settings = null; _settingsPromise = null }
|
||||
if (force) { _settings = null; _settingsPromise = null; _requestGen++ }
|
||||
if (_settings) return _settings
|
||||
if (_settingsPromise) return _settingsPromise
|
||||
const gen = _requestGen
|
||||
const stale = () => getSettings() // superseded by a force reset: defer to the fresh state
|
||||
_settingsPromise = fetch('/auth/api/settings')
|
||||
.then(r => (r.ok ? r.json() : {}))
|
||||
.then(obj => { _settings = obj || {}; return _settings })
|
||||
.catch(() => { _settings = {}; return _settings })
|
||||
.then(obj => gen === _requestGen ? (_settings = obj || {}) : stale())
|
||||
.catch(() => gen === _requestGen ? (_settings = {}) : stale())
|
||||
return _settingsPromise
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import sirv from 'sirv'
|
||||
import fastapiVue from './vite-plugin-fastapi.js'
|
||||
|
||||
// Auth host mode: when set, clients accessing an auth host get /auth/ at / and /auth/admin/ at /admin/
|
||||
// Comma-separated list of bare hostnames (one per realm with a dedicated auth host)
|
||||
// Comma-separated list of bare hostnames (one per domain with a dedicated auth host)
|
||||
const authHosts = (process.env.PASKIA_AUTH_HOST || '')
|
||||
.split(',')
|
||||
.map(h => h.trim().replace(/^https?:\/\//, '').split(':')[0].split('/')[0])
|
||||
|
||||
+2
-6
@@ -91,14 +91,10 @@ class Domain:
|
||||
"""Configured related (cross-domain) origins for ROR, as URLs."""
|
||||
return [origin_url(k) for k in self.config.related]
|
||||
|
||||
@property
|
||||
def is_root_mode(self) -> bool:
|
||||
"""Whether this domain's UI lives at the site root (own auth host)."""
|
||||
return auth_host_url(self.config) is not None
|
||||
|
||||
@property
|
||||
def ui_base_path(self) -> str:
|
||||
return "/" if self.is_root_mode else "/auth/"
|
||||
"""UI base path: site root on an own auth host, /auth/ elsewhere."""
|
||||
return "/" if auth_host_url(self.config) is not None else "/auth/"
|
||||
|
||||
@property
|
||||
def auth_site_url(self) -> str:
|
||||
|
||||
Reference in New Issue
Block a user