MultiSite: one instance serves authentication across many domains (#4)
- Serve multiple domains (RP IDs) from one instance: host-based dispatch, per-domain credentials and sessions, domains managed at runtime in the admin UI — previously one RP per instance - Cross-domain sign-in via Related Origin Requests: per-domain related-origins list with a served .well-known/webauthn document - Explicit per-domain origin lists with shell-glob wildcards (**. for apex + any subdomain depth, *. for one level), editable in the admin UI with validation and self-lockout guards - Per-domain auth hosts: the account/admin UI can live on a different host per domain, no longer confined to subdomains of a single RP - CLI: 'paskia init <rp-id [rp-name]' initializes or adds a domain to an existing database; 'paskia migrate' converts legacy databases BREAKING CHANGES (v2.0): - Database schema: config is now per-domain and credentials/sessions carry an rp_id — existing databases must be converted with 'paskia migrate' - Origins are now explicit: main implicitly allowed every subdomain of the RP; configure '**.' origins to reproduce that behavior - CLI: the flat '--rp-id/--rp-name/--origin/--auth/--save' flags are replaced by the 'init' and 'migrate' subcommandsReviewed-on: #4
This commit was merged in pull request #4.
This commit is contained in:
@@ -37,11 +37,11 @@ function normalizeHost(raw) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Host mode is active when an auth_host is configured AND the current host differs from it.
|
||||
* Host mode is active when an own_auth_host is configured AND the current host differs from it.
|
||||
* In host mode, we show a limited profile view with logout and link to full profile.
|
||||
*/
|
||||
const isHostMode = computed(() => {
|
||||
const authHost = store.settings?.auth_host
|
||||
const authHost = store.settings?.own_auth_host
|
||||
if (!authHost) return false
|
||||
const currentHost = normalizeHost(window.location.host)
|
||||
const configuredHost = normalizeHost(authHost)
|
||||
@@ -99,7 +99,7 @@ onMounted(async () => {
|
||||
if (rpName) {
|
||||
// In host mode, show "account summary" style title
|
||||
// Settings are loaded but isHostMode depends on them, so check here
|
||||
const authHost = store.settings?.auth_host
|
||||
const authHost = store.settings?.own_auth_host
|
||||
const inHostMode = authHost && normalizeHost(window.location.host) !== normalizeHost(authHost)
|
||||
document.title = inHostMode ? `${rpName} · Account summary` : rpName
|
||||
}
|
||||
|
||||
@@ -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 } from '@/utils/helpers'
|
||||
import { originDisplayEntries } from '@/utils/helpers'
|
||||
|
||||
const info = ref(null)
|
||||
const loading = ref(true)
|
||||
@@ -28,6 +28,7 @@ const error = ref(null)
|
||||
const orgs = ref([])
|
||||
const permissions = ref([])
|
||||
const oidcClients = ref([])
|
||||
const domains = ref([])
|
||||
const currentOrgId = ref(null) // UUID of selected org for detail view
|
||||
const currentUserId = ref(null) // UUID for user detail view
|
||||
const currentOidcId = ref(null) // UUID for OIDC client detail view
|
||||
@@ -174,6 +175,16 @@ async function loadAdminData() {
|
||||
oidcClients.value = Object.entries(data.oidc_clients).map(([uuid, c]) => ({ uuid, ...c }))
|
||||
}
|
||||
|
||||
// Domain list is master-admin only; callers guard on isMasterAdmin
|
||||
async function loadDomains() {
|
||||
try {
|
||||
domains.value = await apiJson('/auth/api/admin/domains/')
|
||||
} catch (e) {
|
||||
console.warn('Unable to load domains', e)
|
||||
domains.value = []
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to get users for a role as sorted array of [uuid, user]
|
||||
function roleUsers(org, roleUuid) {
|
||||
return Object.entries(org.users)
|
||||
@@ -207,6 +218,7 @@ function clearSensitiveState() {
|
||||
orgs.value = []
|
||||
permissions.value = []
|
||||
oidcClients.value = []
|
||||
domains.value = []
|
||||
userDetail.value = null
|
||||
editingOidcClient.value = null
|
||||
authenticated.value = false
|
||||
@@ -236,6 +248,7 @@ async function load() {
|
||||
await loadAdminData()
|
||||
// If we get here, user has admin access - now fetch user info for display
|
||||
await loadUserInfo()
|
||||
if (isMasterAdmin.value) await loadDomains()
|
||||
|
||||
if (!isMasterAdmin.value && isOrgAdmin.value && orgs.value.length === 1) {
|
||||
if (!window.location.hash || window.location.hash === '#overview') {
|
||||
@@ -452,31 +465,48 @@ 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) {
|
||||
openDialog('perm-create', { display_name: '', scope: '', domain: clientId })
|
||||
}
|
||||
|
||||
async function openServerConfig() {
|
||||
try {
|
||||
const config = await apiJson('/auth/api/admin/server-config')
|
||||
// Strip https:// scheme from stored origins and auth_host for editing
|
||||
const origins = (config.origins || []).map(o => o.replace(/^https:\/\//, ''))
|
||||
const auth_host = (config.auth_host || '').replace(/^https:\/\//, '')
|
||||
openDialog('server-config', {
|
||||
rp_name: config.rp_name || '',
|
||||
auth_host,
|
||||
origins,
|
||||
originValidation: origins.map(() => null),
|
||||
})
|
||||
} catch (e) {
|
||||
authStore.showMessage(e.message || 'Failed to load server configuration', 'error')
|
||||
}
|
||||
function createDomain() {
|
||||
openDialog('domain-edit', {
|
||||
isNew: true,
|
||||
rp_id: '',
|
||||
rp_name: '',
|
||||
auth_host: '',
|
||||
origins: [],
|
||||
originValidation: [],
|
||||
wellKnownCheck: null,
|
||||
})
|
||||
}
|
||||
|
||||
function openDomain(domain) {
|
||||
// One combined list for editing, in display order: in-domain sites and
|
||||
// related origins, classified by hostname against the rp-id.
|
||||
const rows = originDisplayEntries(domain)
|
||||
openDialog('domain-edit', {
|
||||
isNew: false,
|
||||
rp_id: domain.rp_id,
|
||||
rp_name: domain.rp_name || '',
|
||||
auth_host: rows.find(r => r.auth)?.key || '',
|
||||
origins: rows.map(r => r.key),
|
||||
originValidation: rows.map(() => null),
|
||||
wellKnownCheck: null,
|
||||
})
|
||||
}
|
||||
|
||||
function deleteDomain(domain) {
|
||||
openDialog('confirm', {
|
||||
message: `Delete domain "${domain.rp_id}"? This is refused while any passkeys remain registered for it.`,
|
||||
action: async () => {
|
||||
await apiJson(`/auth/api/admin/domains/${domain.rp_id}`, { method: 'DELETE' })
|
||||
authStore.showMessage(`Domain "${domain.rp_id}" deleted.`, 'success', 2500)
|
||||
await loadDomains()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function deleteOidcClient(client) {
|
||||
@@ -875,50 +905,38 @@ 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')
|
||||
} 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().toLowerCase() || ''
|
||||
// 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 = {}
|
||||
for (const o of d.origins || []) {
|
||||
const key = keyOf(o.trim())
|
||||
if (!key) continue
|
||||
origins[key] = key === auth_host ? { auth_host: true } : true
|
||||
}
|
||||
|
||||
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 } })
|
||||
const req = d.isNew
|
||||
? 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(`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 === 'server-config') {
|
||||
const rp_name = dialog.value.data.rp_name?.trim() || ''
|
||||
const auth_host = dialog.value.data.auth_host?.trim() || ''
|
||||
// Origins are stored as-is (hostnames); backend normalizes with https://
|
||||
const origins = dialog.value.data.origins
|
||||
.map(o => o.trim())
|
||||
.filter(o => o)
|
||||
|
||||
closeDialog()
|
||||
apiJson('/auth/api/admin/server-config', { method: 'PATCH', body: { rp_name, auth_host, origins } })
|
||||
.then(() => {
|
||||
authStore.showMessage('Server configuration updated.', 'success', 2500)
|
||||
authStore.showMessage(`Domain "${rp_id}" ${d.isNew ? 'created' : 'updated'}.`, 'success', 2500)
|
||||
loadDomains()
|
||||
// Reload settings to reflect rp_name changes
|
||||
authStore.loadSettings().then(() => {
|
||||
authStore.loadSettings(true).then(() => {
|
||||
if (authStore.settings?.rp_name) document.title = authStore.settings.rp_name + ' Admin'
|
||||
})
|
||||
})
|
||||
.catch(e => {
|
||||
authStore.showMessage(e.message || 'Failed to update server configuration', 'error')
|
||||
authStore.showMessage(e.message || 'Failed to save domain', 'error')
|
||||
})
|
||||
return // Don't call closeDialog() again
|
||||
} else if (t === 'confirm') {
|
||||
@@ -973,6 +991,8 @@ async function submitDialog() {
|
||||
:orgs="orgs"
|
||||
:permissions="permissions"
|
||||
:oidc-clients="oidcClients"
|
||||
:domains="domains"
|
||||
:current-rp-id="authStore.settings?.rp_id || ''"
|
||||
:navigation-disabled="hasActiveModal"
|
||||
:permission-summary="permissionSummary"
|
||||
@create-org="createOrg"
|
||||
@@ -986,7 +1006,9 @@ async function submitDialog() {
|
||||
@create-oidc-client="createOidcClient"
|
||||
@open-oidc-client="openOidcClient"
|
||||
@delete-oidc-client="deleteOidcClient"
|
||||
@open-server-config="openServerConfig"
|
||||
@create-domain="createDomain"
|
||||
@open-domain="openDomain"
|
||||
@delete-domain="deleteDomain"
|
||||
@navigate-out="handlePanelNavigateOut"
|
||||
/>
|
||||
|
||||
@@ -1029,6 +1051,7 @@ async function submitDialog() {
|
||||
ref="adminOidcDetailRef"
|
||||
:client="editingOidcClient"
|
||||
:permissions="permissions"
|
||||
:domains="domains"
|
||||
:is-new="editingOidcClient.isNew"
|
||||
:navigation-disabled="hasActiveModal"
|
||||
@save="handleOidcSave"
|
||||
@@ -1047,11 +1070,8 @@ async function submitDialog() {
|
||||
<AdminDialogs
|
||||
:dialog="dialog"
|
||||
:permission-id-pattern="PERMISSION_ID_PATTERN"
|
||||
:settings="authStore.settings"
|
||||
@submit-dialog="submitDialog"
|
||||
@close-dialog="closeDialog"
|
||||
@reset-oidc-secret="resetOidcSecret"
|
||||
@create-permission-for-client="createPermissionForClient"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
+442
-142
@@ -1,37 +1,73 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { computed, nextTick, 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,
|
||||
PERMISSION_ID_PATTERN: String,
|
||||
settings: Object
|
||||
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 rpId = computed(() => props.settings?.rp_id || 'the configured domain')
|
||||
const discoveryUrl = computed(() => `${window.location.origin}/.well-known/openid-configuration`)
|
||||
|
||||
// Initialize validation properties
|
||||
if (props.dialog?.data && props.dialog.type === 'server-config') {
|
||||
if (!('authHostValidation' in props.dialog.data)) {
|
||||
props.dialog.data.authHostValidation = null
|
||||
}
|
||||
}
|
||||
// The rp-id of the domain being edited in the 'domain-edit' dialog
|
||||
// (lowercased: classification compares against it, and hosts are
|
||||
// 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), 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).
|
||||
const isValidationInvalid = computed(() => {
|
||||
if (props.dialog?.type !== 'server-config') return false
|
||||
if (props.dialog?.type !== 'domain-edit') return false
|
||||
const d = props.dialog.data
|
||||
if (d.authHostValidation?.startsWith('invalid') || d.authHostValidation === 'validating') return true
|
||||
if (d.originValidation?.some(v => v === 'invalid' || v === 'validating')) return true
|
||||
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
|
||||
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. A bare '*' or '**' is invalid (wildcards must sit
|
||||
// under the rp-id) and never a related origin.
|
||||
function isRelatedEntry(origin) {
|
||||
if (isWildcardEntry(origin)) return false // wildcards are never related
|
||||
const h = originHostname(origin)
|
||||
return !!(h && dialogRpId.value && !isWithinDomain(origin, dialogRpId.value))
|
||||
}
|
||||
const relatedEntries = computed(() => {
|
||||
const d = props.dialog?.data
|
||||
if (!d?.origins) return []
|
||||
return d.origins.filter(isRelatedEntry)
|
||||
})
|
||||
|
||||
// Well-known document browsers fetch from the rp-id domain to verify the
|
||||
// related-origin list (never from the auth host).
|
||||
const wellKnownUrl = computed(() => {
|
||||
const host = (props.dialog?.data?.rp_id || '').replace(/^https:\/\//, '').replace(/\/+$/, '')
|
||||
return host ? `https://${host}/.well-known/webauthn` : ''
|
||||
})
|
||||
|
||||
// ROR origins must be absolute https URLs in the well-known document.
|
||||
function asHttpsOrigin(origin) {
|
||||
const o = origin.trim().replace(/\/+$/, '')
|
||||
return o.startsWith('http') ? o : `https://${o}`
|
||||
}
|
||||
const wellKnownJson = computed(() =>
|
||||
JSON.stringify({ origins: relatedEntries.value.map(asHttpsOrigin) })
|
||||
)
|
||||
|
||||
// Copy-to-clipboard helper
|
||||
const authStore = useAuthStore()
|
||||
function copyText(value, label) {
|
||||
@@ -40,138 +76,405 @@ function copyText(value, label) {
|
||||
})
|
||||
}
|
||||
|
||||
function addOrigin() {
|
||||
// --- 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 apex and subdomains at any depth, '*.base' exactly one subdomain
|
||||
// level — over https, except under localhost (any scheme and port);
|
||||
// a related row matches only on exact equality (https://host).
|
||||
const lockoutWarning = computed(() => {
|
||||
const d = props.dialog?.data
|
||||
if (d) {
|
||||
d.origins.push(rpId.value)
|
||||
d.originValidation.push(null)
|
||||
validateOrigin(d.origins[d.origins.length - 1], d.origins.length - 1)
|
||||
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
|
||||
})
|
||||
|
||||
// Whether any origin diagnostic is present
|
||||
const hasOriginDiagnostics = computed(() => {
|
||||
const d = props.dialog?.data
|
||||
if (props.dialog?.type !== 'domain-edit' || !d) return false
|
||||
if (d.originValidation?.some(v => v === 'invalid' || v === 'unreachable' || v === 'mismatch')) return true
|
||||
return relatedEntries.value.length > 5 || !!lockoutWarning.value
|
||||
})
|
||||
|
||||
// Any runtime diagnostic to show in the dialog's attached feedback panel
|
||||
const hasDiagnostics = computed(
|
||||
() => hasOriginDiagnostics.value || !!props.dialog?.data?.wellKnownCheck
|
||||
)
|
||||
|
||||
function pageOriginAllowed(rows, rpId) {
|
||||
const toUrl = key => (isWildcardEntry(key) || 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 => {
|
||||
const base = wildcardBase(e)
|
||||
if (!base) return false
|
||||
const matched = e.startsWith('**.')
|
||||
? hostname === base || hostname.endsWith('.' + base)
|
||||
: hostname.endsWith('.' + base) && !hostname.slice(0, -base.length - 1).includes('.')
|
||||
if (!matched) 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}`)
|
||||
}
|
||||
|
||||
const originInputs = ref([])
|
||||
|
||||
async function addOrigin() {
|
||||
const d = props.dialog?.data
|
||||
if (!d) return
|
||||
d.origins.push('')
|
||||
d.originValidation.push(null)
|
||||
await nextTick()
|
||||
originInputs.value[originInputs.value.length - 1]?.focus()
|
||||
}
|
||||
|
||||
// Row validation runs after a short typing pause and immediately on
|
||||
// blur, so no error indication appears mid-edit. Empty rows are ignored.
|
||||
const originValidateTimers = new Map()
|
||||
|
||||
function scheduleValidateOrigin(i) {
|
||||
clearTimeout(originValidateTimers.get(i))
|
||||
originValidateTimers.set(i, setTimeout(() => {
|
||||
originValidateTimers.delete(i)
|
||||
validateOrigin(i)
|
||||
}, 600))
|
||||
}
|
||||
|
||||
function onOriginBlur(i) {
|
||||
clearTimeout(originValidateTimers.get(i))
|
||||
originValidateTimers.delete(i)
|
||||
validateOrigin(i)
|
||||
}
|
||||
|
||||
function removeOrigin(i) {
|
||||
const d = props.dialog?.data
|
||||
if (d) {
|
||||
// Row indices shift on removal — drop all pending validations
|
||||
for (const t of originValidateTimers.values()) clearTimeout(t)
|
||||
originValidateTimers.clear()
|
||||
d.origins.splice(i, 1)
|
||||
d.originValidation.splice(i, 1)
|
||||
}
|
||||
}
|
||||
function stripScheme(val, i) {
|
||||
const d = props.dialog?.data
|
||||
if (d) d.origins[i] = val.replace(/^https:\/\//, '').replace(/\/+$/, '')
|
||||
}
|
||||
function stripSchemeAuthHost() {
|
||||
const d = props.dialog?.data
|
||||
if (d && d.auth_host) d.auth_host = d.auth_host.replace(/^https:\/\//, '').replace(/\/+$/, '')
|
||||
}
|
||||
function focusOriginStart(e) {
|
||||
e.target.setSelectionRange(0, 0)
|
||||
}
|
||||
|
||||
function validateOriginDomain(origin, rpId) {
|
||||
if (!origin.trim()) return false
|
||||
function isWellFormedDomain(value) {
|
||||
if (!value.trim()) return false
|
||||
try {
|
||||
const url = origin.startsWith('http') ? new URL(origin) : new URL('https://' + origin)
|
||||
const hostname = url.hostname
|
||||
return hostname === rpId || hostname.endsWith('.' + rpId)
|
||||
const url = value.startsWith('http') ? new URL(value) : new URL('https://' + value)
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
||||
async function validateOriginConnectivity(origin, i) {
|
||||
// Wildcard entries follow the shell-glob convention: '*.base' covers
|
||||
// exactly one subdomain level, '**.base' the apex and any depth.
|
||||
const isWildcardEntry = value => {
|
||||
const v = value.trim()
|
||||
return v.startsWith('*.') || v.startsWith('**.')
|
||||
}
|
||||
|
||||
// Base domain of a wildcard entry (lowercased); null when the value is
|
||||
// not a wildcard pattern or has no base.
|
||||
function wildcardBase(value) {
|
||||
const v = value.trim()
|
||||
if (v.startsWith('**.')) return v.slice(3).replace(/\.+$/, '').toLowerCase() || null
|
||||
if (v.startsWith('*.')) return v.slice(2).replace(/\.+$/, '').toLowerCase() || null
|
||||
return null
|
||||
}
|
||||
|
||||
function originHostname(origin) {
|
||||
const v = origin.trim()
|
||||
if (!v || v === '*' || v === '**') return null // a bare '*' or '**' is not a valid entry
|
||||
if (isWildcardEntry(v)) {
|
||||
const base = wildcardBase(v)
|
||||
return base && isWellFormedDomain(base) ? base : null
|
||||
}
|
||||
try {
|
||||
const url = v.startsWith('http') ? new URL(v) : new URL('https://' + v)
|
||||
// The URL parser keeps malformed hostnames like '.localhost' or
|
||||
// 'a..b.com' — reject anything that is not clean dot-separated labels
|
||||
return url.hostname && isWellFormedDomain(url.hostname) ? url.hostname : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function isWithinDomain(origin, rpId) {
|
||||
const hostname = originHostname(origin)
|
||||
if (!hostname) return false
|
||||
return hostname === rpId || hostname.endsWith('.' + rpId)
|
||||
}
|
||||
|
||||
async function validateOriginConnectivity(i) {
|
||||
const d = props.dialog?.data
|
||||
if (!d) return
|
||||
const value = d.origins[i]
|
||||
|
||||
d.originValidation[i] = 'validating'
|
||||
try {
|
||||
const cleanOrigin = origin.replace(/\/+$/, '')
|
||||
const testUrl = cleanOrigin.startsWith('http') ? cleanOrigin : 'https://' + cleanOrigin
|
||||
const cleanValue = value.replace(/\/+$/, '')
|
||||
const testUrl = cleanValue.startsWith('http') ? cleanValue : 'https://' + cleanValue
|
||||
const response = await fetch(testUrl + '/auth/api/settings', {
|
||||
method: 'GET',
|
||||
headers: { 'Accept': 'application/json' }
|
||||
})
|
||||
if (d.origins[i] !== value) return // entry changed while validating
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
// Check if it returns valid settings (has rp_id and matches current rp_id)
|
||||
const result = (data.rp_id && data.rp_id === rpId.value) ? 'valid' : 'invalid'
|
||||
// Only update if the origin hasn't changed
|
||||
if (d.origins[i] === origin) {
|
||||
d.originValidation[i] = result
|
||||
}
|
||||
// Valid when the entry is served by this instance for the edited domain
|
||||
d.originValidation[i] = (data.rp_id && data.rp_id === dialogRpId.value) ? 'valid' : 'mismatch'
|
||||
} else {
|
||||
if (d.origins[i] === origin) {
|
||||
d.originValidation[i] = 'invalid'
|
||||
}
|
||||
d.originValidation[i] = 'unreachable'
|
||||
}
|
||||
} catch (e) {
|
||||
if (d.origins[i] === origin) {
|
||||
d.originValidation[i] = 'invalid'
|
||||
if (d.origins[i] === value) {
|
||||
d.originValidation[i] = 'unreachable'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function validateOrigin(origin, i) {
|
||||
// A '*' typed into an empty field expands to '**.<rp-id>' with the second
|
||||
// asterisk selected: typing on (e.g. '.') replaces the selection —
|
||||
// yielding '*.<rp-id>' — while the rp-id stays at the end; Backspace
|
||||
// deletes the second asterisk; doing nothing keeps the any-depth form.
|
||||
// Only typed input into an empty field triggers this — never pasting or
|
||||
// deleting (e.g. backspacing '**' down to '*' must not re-expand).
|
||||
function onOriginInput(i, e) {
|
||||
const d = props.dialog?.data
|
||||
if (!d) return
|
||||
|
||||
const id = rpId.value
|
||||
if (validateOriginDomain(origin, id)) {
|
||||
validateOriginConnectivity(origin, i)
|
||||
} else {
|
||||
d.originValidation[i] = 'invalid'
|
||||
const el = e.target
|
||||
const oldKey = entryKey(d.origins[i])
|
||||
let value = el.value
|
||||
if (value === '*' && dialogRpId.value && (e.inputType === 'insertText' || e.inputType === 'insertCompositionText')) {
|
||||
value = '**.' + dialogRpId.value
|
||||
el.value = value
|
||||
el.setSelectionRange(1, 2)
|
||||
}
|
||||
d.origins[i] = value
|
||||
// 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 : ''
|
||||
}
|
||||
d.originValidation[i] = null
|
||||
scheduleValidateOrigin(i)
|
||||
}
|
||||
|
||||
async function validateAuthHostConnectivity(authHost) {
|
||||
function validateOrigin(i) {
|
||||
const d = props.dialog?.data
|
||||
if (!d) return
|
||||
|
||||
d.authHostValidation = 'validating'
|
||||
try {
|
||||
const cleanAuthHost = authHost.replace(/\/+$/, '')
|
||||
const testUrl = cleanAuthHost.startsWith('http') ? cleanAuthHost : 'https://' + cleanAuthHost
|
||||
const response = await fetch(testUrl + '/auth/api/settings', {
|
||||
method: 'GET',
|
||||
headers: { 'Accept': 'application/json' }
|
||||
})
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
// Check if it returns valid settings (has rp_id and matches current rp_id)
|
||||
const result = (data.rp_id && data.rp_id === rpId.value) ? 'valid' : 'invalid'
|
||||
// Only update if the auth_host hasn't changed
|
||||
if (d.auth_host === authHost) {
|
||||
d.authHostValidation = result
|
||||
}
|
||||
} else {
|
||||
if (d.auth_host === authHost) {
|
||||
d.authHostValidation = 'invalid-connectivity'
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (d.auth_host === authHost) {
|
||||
d.authHostValidation = 'invalid-connectivity'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function validateAuthHost() {
|
||||
const d = props.dialog?.data
|
||||
if (!d || !d.auth_host?.trim()) {
|
||||
d.authHostValidation = null // Allow empty
|
||||
const value = d.origins[i]
|
||||
// Empty rows are ignored — never errors, and skipped on save
|
||||
if (!value || !value.trim()) {
|
||||
d.originValidation[i] = null
|
||||
return
|
||||
}
|
||||
|
||||
const id = rpId.value
|
||||
if (validateOriginDomain(d.auth_host, id)) {
|
||||
validateAuthHostConnectivity(d.auth_host)
|
||||
} else {
|
||||
d.authHostValidation = 'invalid-domain'
|
||||
if (!originHostname(value)) {
|
||||
d.originValidation[i] = 'invalid'
|
||||
return
|
||||
}
|
||||
if (isWildcardEntry(value)) {
|
||||
// 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)
|
||||
}
|
||||
|
||||
// Fetch the well-known document and check it lists every related origin.
|
||||
// Runs automatically whenever the related set changes; result is a
|
||||
// warning only, never a submit blocker (the rp-id site may be hosted
|
||||
// elsewhere, and cross-origin fetches can fail for unrelated reasons).
|
||||
async function testWellKnown() {
|
||||
const d = props.dialog?.data
|
||||
if (!d) return
|
||||
const related = relatedEntries.value.map(asHttpsOrigin)
|
||||
if (!related.length) {
|
||||
d.wellKnownCheck = null
|
||||
return
|
||||
}
|
||||
const key = related.join('|')
|
||||
d.wellKnownCheck = 'validating'
|
||||
try {
|
||||
const response = await fetch(wellKnownUrl.value, { headers: { 'Accept': 'application/json' } })
|
||||
if (!response.ok) throw new Error('not ok')
|
||||
const doc = await response.json()
|
||||
if (related.join('|') !== key) return // list changed while fetching
|
||||
const listed = new Set((doc.origins || []).map(o => String(o).replace(/\/+$/, '')))
|
||||
const missing = related.filter(o => !listed.has(o))
|
||||
d.wellKnownCheck = missing.length ? 'missing' : 'valid'
|
||||
d.wellKnownMissing = missing
|
||||
} catch {
|
||||
if (related.join('|') === key) d.wellKnownCheck = 'unreachable'
|
||||
}
|
||||
}
|
||||
watch(() => relatedEntries.value.map(asHttpsOrigin).join('|'), testWellKnown, { immediate: true })
|
||||
|
||||
// Prefill a new domain's list with the real '**.<rp-id>' row once its
|
||||
// rp-id is known ('**.x' = the domain apex 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 prefilled row so rp-id edits can keep updating it.
|
||||
let seededOrigin = null
|
||||
watch(dialogRpId, rp => {
|
||||
const d = props.dialog?.data
|
||||
if (props.dialog?.type !== 'domain-edit' || !d?.isNew) return
|
||||
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
|
||||
}
|
||||
})
|
||||
|
||||
// --- Row menu: auth host assignment and entry removal ---
|
||||
|
||||
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) {
|
||||
if (openMenu.value !== null && !e.target.closest('.row-menu')) openMenu.value = null
|
||||
}
|
||||
onMounted(() => document.addEventListener('click', onDocumentClick))
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('click', onDocumentClick)
|
||||
for (const t of originValidateTimers.values()) clearTimeout(t)
|
||||
originValidateTimers.clear()
|
||||
})
|
||||
|
||||
// Origins-dict key form of an entry (https:// omitted), also used for the
|
||||
// auth_host value.
|
||||
function entryKey(value) {
|
||||
return value?.trim().replace(/^https:\/\//, '').replace(/\/+$/, '') || ''
|
||||
}
|
||||
|
||||
function isAuthHostEntry(origin) {
|
||||
const d = props.dialog?.data
|
||||
const key = entryKey(origin)
|
||||
return !!(key && d?.auth_host && key === d.auth_host)
|
||||
}
|
||||
|
||||
function setAuthHost(i) {
|
||||
const d = props.dialog?.data
|
||||
if (!d) return
|
||||
let key = entryKey(d.origins[i])
|
||||
let added = false
|
||||
const wbase = wildcardBase(key)
|
||||
if (wbase) {
|
||||
// A wildcard cannot be the auth host — create a concrete auth.<base> entry
|
||||
key = 'auth.' + wbase
|
||||
if (!d.origins.some(o => entryKey(o) === key)) {
|
||||
d.origins.push(key)
|
||||
d.originValidation.push(null)
|
||||
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])
|
||||
}
|
||||
|
||||
function onRemoveOrigin(i) {
|
||||
const d = props.dialog?.data
|
||||
if (!d) return
|
||||
if (isAuthHostEntry(d.origins[i])) d.auth_host = ''
|
||||
removeOrigin(i)
|
||||
openMenu.value = null
|
||||
resortOrigins()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal v-if="dialog.type" @close="$emit('closeDialog')">
|
||||
<template #attached>
|
||||
<div v-if="dialog?.type === 'domain-edit' && (relatedEntries.length || hasDiagnostics)" class="attach-panel" @click.stop>
|
||||
<template v-if="relatedEntries.length">
|
||||
<p class="small muted">
|
||||
Related origins are verified by browsers against
|
||||
<a :href="wellKnownUrl" target="_blank" rel="noopener noreferrer">{{ wellKnownUrl }}</a>
|
||||
— served automatically when this instance hosts {{ dialog.data.rp_id }}; otherwise publish this document there:
|
||||
</p>
|
||||
<pre class="wellknown-doc" title="Click to copy" tabindex="0" @click="copyText(wellKnownJson, 'Well-known document')" @keydown.enter.prevent="copyText(wellKnownJson, 'Well-known document')">{{ wellKnownJson }}</pre>
|
||||
</template>
|
||||
<ul v-if="hasDiagnostics" class="diag-list">
|
||||
<li v-if="dialog.data.originValidation.some(v => v === 'invalid')" class="small error">Some entries are invalid — check for typos in the hostname; a bare '*' or '**' is not allowed, and wildcards only within the domain.</li>
|
||||
<li v-if="dialog.data.originValidation.some(v => v === 'unreachable')" class="small">Some sites are unreachable — make sure they are routed to this instance.</li>
|
||||
<li v-else-if="dialog.data.originValidation.some(v => v === 'mismatch')" class="small">Some sites are reachable but do not serve this domain.</li>
|
||||
<li v-if="relatedEntries.length > 5" class="small error">At most 5 related origins are allowed ({{ relatedEntries.length }} listed) — the save is rejected.</li>
|
||||
<li v-if="lockoutWarning" class="small error">Saving would lock you out: {{ lockoutWarning }} could no longer run sign-in ceremonies for this domain. Keep it listed, or mark an auth host.</li>
|
||||
<li v-if="dialog.data.wellKnownCheck === 'validating'" class="small">Checking the published document…</li>
|
||||
<li v-else-if="dialog.data.wellKnownCheck === 'valid'" class="small">✓ The published document lists all related origins.</li>
|
||||
<li v-else-if="dialog.data.wellKnownCheck === 'missing'" class="small error">The published document does not list: {{ (dialog.data.wellKnownMissing || []).join(', ') }}</li>
|
||||
<li v-else-if="dialog.data.wellKnownCheck === 'unreachable'" class="small">Could not fetch the published document to verify it.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
<h3 class="modal-title">
|
||||
<template v-if="dialog.type==='org-create'">Create Organization</template>
|
||||
<template v-else-if="dialog.type==='org-update'">Rename Organization</template>
|
||||
@@ -180,8 +483,7 @@ function validateAuthHost() {
|
||||
<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==='server-config'">Server Options</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">
|
||||
@@ -239,45 +541,54 @@ function validateAuthHost() {
|
||||
<label>Domain Scope
|
||||
<input v-model="dialog.data.domain" data-form-type="other" />
|
||||
</label>
|
||||
<p class="small muted">A domain ({{ rpId }} or subdomain) restricts this permission to that host. An OIDC client UUID sends it as a <em>groups</em> claim to that client.</p>
|
||||
<p class="small muted">A domain restricts this permission to that host (any configured domain's rp-id or a subdomain of it). An OIDC client UUID sends it as a <em>groups</em> claim to that client.</p>
|
||||
</template>
|
||||
<template v-else-if="dialog.type==='server-config'">
|
||||
<label>Site Branding (rp-name)
|
||||
<input v-model="dialog.data.rp_name" :placeholder="rpId" />
|
||||
<template v-else-if="dialog.type==='domain-edit'">
|
||||
<template v-if="dialog.data.isNew">
|
||||
<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 related domains. Cannot be changed later.</p>
|
||||
</template>
|
||||
<label>Display Name (rp-name)
|
||||
<input v-model="dialog.data.rp_name" :placeholder="dialog.data.rp_id" />
|
||||
</label>
|
||||
<label>Dedicated Authentication Site (auth-host)
|
||||
<input v-model="dialog.data.auth_host" @input="validateAuthHost()" :class="{ 'input-error': dialog.data.authHostValidation?.startsWith('invalid') }" />
|
||||
</label>
|
||||
<p v-if="dialog.data.authHostValidation === 'validating'" class="small muted">Validating...</p>
|
||||
<p v-else-if="dialog.data.authHostValidation === 'valid'" class="small muted">Valid</p>
|
||||
<p v-else-if="dialog.data.authHostValidation === 'invalid-domain'" class="small muted">Invalid domain</p>
|
||||
<p v-else-if="dialog.data.authHostValidation === 'invalid-connectivity'" class="small muted">Well-formed but unreachable</p>
|
||||
<p v-else-if="dialog.data.authHostValidation === 'invalid'" class="small muted">Invalid configuration</p>
|
||||
<p v-else-if="dialog.data.authHostValidation === 'invalid'" class="small muted">Enter {{ rpId }} or any subdomain of it.</p>
|
||||
|
||||
<div class="origin-label">
|
||||
Allowed Origins
|
||||
<button type="button" class="icon-btn origin-add-btn" @click="addOrigin" aria-label="Add origin" title="Add origin">➕</button>
|
||||
<button type="button" class="icon-btn origin-add-btn" @click="addOrigin()" aria-label="Add origin" title="Add origin">➕</button>
|
||||
</div>
|
||||
<div v-if="dialog.data.origins.length" class="origin-list">
|
||||
<div v-for="(_, i) in dialog.data.origins" :key="i" class="origin-row">
|
||||
<input
|
||||
ref="originInputs"
|
||||
:value="dialog.data.origins[i]"
|
||||
@input="e => { dialog.data.origins[i] = e.target.value; validateOrigin(e.target.value, i) }"
|
||||
@focus="focusOriginStart"
|
||||
@input="e => onOriginInput(i, e)"
|
||||
@blur="onOriginBlur(i)"
|
||||
class="origin-input"
|
||||
:class="{ 'input-error': dialog.data.originValidation[i] === 'invalid' }"
|
||||
/>
|
||||
<button type="button" class="icon-btn delete-icon" @click="removeOrigin(i)" aria-label="Remove origin" title="Remove origin">❌</button>
|
||||
<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="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()"><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)"><span class="menu-icon delete-menu-icon">❌</span>Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="!dialog.data.origins.length" class="small muted">{{ rpId }} and all subdomains allowed.</p>
|
||||
<p v-else class="small muted">Only the above sites are allowed to authenticate.</p>
|
||||
<p class="small muted">
|
||||
Only the listed sites may sign in with {{ dialog.data.rp_id }} passkeys. Wildcards may be used: <strong>**.{{ dialog.data.rp_id }}</strong> allows the whole domain, <strong>*.{{ dialog.data.rp_id }}</strong> only a single subdomain level.<template v-if="relatedEntries.length"> 🔗 means related host requiring WebAuthn ROR setup.</template><template v-if="dialog.data.auth_host"> 🔑 is the dedicated Paskia host for all account management.</template>
|
||||
</p>
|
||||
</template>
|
||||
<template v-else-if="dialog.type==='confirm'">
|
||||
<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"
|
||||
@@ -294,38 +605,27 @@ function validateAuthHost() {
|
||||
{{ 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; }
|
||||
.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 { 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; }
|
||||
.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 { 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; white-space: pre; overflow: hidden; text-overflow: ellipsis; cursor: pointer; }
|
||||
|
||||
.input-error {
|
||||
border-color: var(--color-error);
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useAuthStore } from '@/stores/auth'
|
||||
const props = defineProps({
|
||||
client: Object,
|
||||
permissions: Array,
|
||||
domains: Array,
|
||||
isNew: { type: Boolean, default: false },
|
||||
navigationDisabled: { type: Boolean, default: false }
|
||||
})
|
||||
@@ -29,7 +30,17 @@ const clientSecret = ref(null)
|
||||
|
||||
// Computed
|
||||
const clientId = computed(() => props.client?.client_id || props.client?.uuid || '')
|
||||
const discoveryUrl = computed(() => authSitePath('/.well-known/openid-configuration'))
|
||||
// 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()
|
||||
for (const d of props.domains || []) {
|
||||
const url = d.site_url && new URL(d.site_url)
|
||||
if (url) origins.add(url.origin)
|
||||
}
|
||||
if (!origins.size) origins.add(new URL(authStore.settings.auth_site_url).origin)
|
||||
return [...origins].sort().map(o => `${o}/.well-known/openid-configuration`)
|
||||
})
|
||||
const iconUrl = computed(() => authSitePath('/favicon.ico'))
|
||||
|
||||
// Groups (permissions) scoped to this client
|
||||
@@ -147,8 +158,14 @@ defineExpose({ focusFirstElement })
|
||||
<span v-else class="small muted">(only stored in hashed form)</span>
|
||||
</dd>
|
||||
|
||||
<dt>Auto Discovery URL</dt>
|
||||
<dd><output @click="copyText(discoveryUrl, 'OpenID Connect Auto Discovery URL')" title="Click to copy">{{ discoveryUrl }}</output></dd>
|
||||
<dt class="discovery-dt">Auto Discovery URL
|
||||
<span v-if="discoveryUrls.length > 1" class="small muted">Any one — pick the site your users should log in on, and use it consistently.</span>
|
||||
</dt>
|
||||
<dd class="discovery-dd">
|
||||
<span class="discovery-urls">
|
||||
<output v-for="url in discoveryUrls" :key="url" @click="copyText(url, 'OpenID Connect Auto Discovery URL')" title="Click to copy">{{ url }}</output>
|
||||
</span>
|
||||
</dd>
|
||||
|
||||
<dt>Icon URL</dt>
|
||||
<dd>
|
||||
@@ -258,6 +275,29 @@ defineExpose({ focusFirstElement })
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.discovery-dt {
|
||||
white-space: normal;
|
||||
max-width: 20em;
|
||||
}
|
||||
|
||||
.discovery-dt .small {
|
||||
display: block;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.discovery-dd {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.discovery-urls {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.warning-text {
|
||||
display: block;
|
||||
font-size: 0.9rem;
|
||||
|
||||
@@ -1,18 +1,20 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
import { getDirection, navigateButtonRow, focusPreferred, focusAtIndex } from '@/utils/keynav'
|
||||
import { formatDate } from '@/utils/helpers'
|
||||
import { formatDate, originDisplayEntries } from '@/utils/helpers'
|
||||
|
||||
const props = defineProps({
|
||||
info: Object,
|
||||
orgs: Array,
|
||||
permissions: Array,
|
||||
oidcClients: Array,
|
||||
domains: Array,
|
||||
currentRpId: { type: String, default: '' },
|
||||
permissionSummary: Object,
|
||||
navigationDisabled: { type: Boolean, default: false }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['createOrg', 'openOrg', 'updateOrg', 'deleteOrg', 'toggleOrgPermission', 'openDialog', 'deletePermission', 'renamePermissionDisplay', 'createOidcClient', 'openOidcClient', 'deleteOidcClient', 'openServerConfig', 'navigateOut'])
|
||||
const emit = defineEmits(['createOrg', 'openOrg', 'updateOrg', 'deleteOrg', 'toggleOrgPermission', 'openDialog', 'deletePermission', 'renamePermissionDisplay', 'createOidcClient', 'openOidcClient', 'deleteOidcClient', 'createDomain', 'openDomain', 'deleteDomain', 'navigateOut'])
|
||||
|
||||
// Template refs for navigation
|
||||
const orgActionsRef = ref(null)
|
||||
@@ -37,6 +39,11 @@ function domainDisplay(domain) {
|
||||
return oidcClientNames.value[domain] || domain
|
||||
}
|
||||
|
||||
// Domains display in alphabetical rp-id order.
|
||||
const sortedDomains = computed(() =>
|
||||
[...(props.domains || [])].sort((a, b) => a.rp_id.localeCompare(b.rp_id))
|
||||
)
|
||||
|
||||
// Map OIDC client UUIDs to their group permissions (sorted by scope)
|
||||
const clientGroups = computed(() => {
|
||||
const map = {}
|
||||
@@ -425,14 +432,47 @@ defineExpose({ focusFirstElement })
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div v-if="isMasterAdmin" class="server-options-section">
|
||||
<div v-if="isMasterAdmin" class="domains-section">
|
||||
<div class="section-header">
|
||||
<h2>Server</h2>
|
||||
<h2>Domains</h2>
|
||||
<p class="section-description">
|
||||
Configure core server settings such as the display name, authentication host, and allowed origins.
|
||||
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">
|
||||
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>
|
||||
<button @click="$emit('openServerConfig')">⚙ Server Options</button>
|
||||
<div>
|
||||
<button @click="$emit('createDomain')">+ Add Domain</button>
|
||||
</div>
|
||||
<table class="org-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Domain (rp-id)</th>
|
||||
<th>Allowed Origins</th>
|
||||
<th class="center"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-if="!domains || domains.length === 0">
|
||||
<td colspan="3" class="center muted">No domains configured</td>
|
||||
</tr>
|
||||
<tr v-for="domain in sortedDomains" :key="domain.rp_id">
|
||||
<td class="perm-name-cell">
|
||||
<div class="perm-title">
|
||||
<a :href="'#domain:' + domain.rp_id" @click.prevent="$emit('openDomain', domain)">{{ domain.rp_name || domain.rp_id }}</a>
|
||||
</div>
|
||||
<div class="perm-id-info">
|
||||
<span class="id-text">{{ domain.rp_id }}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="domain-origins"><span v-for="(e, i) in originDisplayEntries(domain)" :key="e.key">{{ i ? ', ' : '' }}{{ e.key }}{{ e.auth ? '🔑' : '' }}{{ e.related ? '🔗' : '' }}</span></td>
|
||||
<td class="center">
|
||||
<button v-if="domain.rp_id !== currentRpId" @click="$emit('deleteDomain', domain)" class="icon-btn delete-icon" aria-label="Delete domain" title="Delete domain">❌</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -459,7 +499,9 @@ defineExpose({ focusFirstElement })
|
||||
.oidc-clients-section .section-header { display: flex; flex-direction: column; gap: 0.4rem; margin-bottom: var(--space-md); }
|
||||
.client-groups { font-size: 0.85rem; color: var(--color-text-muted); max-width: 200px; font-family: var(--font-mono, monospace); }
|
||||
|
||||
/* Server Options Section */
|
||||
.server-options-section { margin-top: var(--space-2xl); }
|
||||
.server-options-section .section-header { display: flex; flex-direction: column; gap: 0.4rem; margin-bottom: var(--space-md); }
|
||||
/* Domains Section */
|
||||
.domains-section { margin-top: var(--space-2xl); }
|
||||
.domains-section .section-header { display: flex; flex-direction: column; gap: 0.4rem; margin-bottom: var(--space-md); }
|
||||
.domain-origins { font-family: var(--font-mono, monospace); font-size: 0.85rem; }
|
||||
.domains-section .perm-title { display: flex; align-items: center; gap: 0.5rem; }
|
||||
</style>
|
||||
|
||||
@@ -467,6 +467,61 @@ th {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* Runtime diagnostics list: 🔸 markers with a hanging indent, so wrapped
|
||||
lines align with the text rather than under the marker */
|
||||
.diag-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.diag-list li {
|
||||
position: relative;
|
||||
padding-left: 1.4em;
|
||||
}
|
||||
|
||||
.diag-list li + li {
|
||||
margin-top: 0.3em;
|
||||
}
|
||||
|
||||
.diag-list li::before {
|
||||
content: "🔸";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
/* Dialog attachment panel (runtime diagnostics, related-origin setup):
|
||||
docked on the right of the dialog, so appearing or disappearing never
|
||||
shifts the dialog itself. On narrow screens it hangs below instead. */
|
||||
.attach-panel {
|
||||
position: absolute;
|
||||
top: calc(100% + 0.5rem);
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: var(--color-dialog);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-xl);
|
||||
padding: var(--space-md) var(--space-lg);
|
||||
max-height: 30vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.attach-panel > * + * {
|
||||
margin-top: var(--space-md);
|
||||
}
|
||||
|
||||
@media (min-width: 1200px) {
|
||||
.attach-panel {
|
||||
top: 0;
|
||||
left: calc(100% + 0.75rem);
|
||||
right: auto;
|
||||
/* Never wider than the space right of the centered 500px dialog */
|
||||
width: min(340px, calc(50vw - 286px));
|
||||
max-height: calc(100vh - 3rem);
|
||||
}
|
||||
}
|
||||
|
||||
.icon-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
@@ -576,6 +631,12 @@ th {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
/* Positions attachments (e.g. the diagnostics panel) relative to the
|
||||
dialog; shrink-wraps the panel in the overlay's flex layout */
|
||||
.modal-wrap {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.device-dialog,
|
||||
.modal {
|
||||
background: var(--color-dialog);
|
||||
@@ -804,6 +865,28 @@ th {
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.badge-domain {
|
||||
font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.domain-enroll-notice {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 1rem;
|
||||
margin-bottom: 1rem;
|
||||
border: 1px solid var(--color-accent);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-surface-subtle);
|
||||
}
|
||||
|
||||
.domain-enroll-notice p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
|
||||
.session-meta-info {
|
||||
font-size: 0.75rem;
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
</div>
|
||||
<h4 class="item-title">{{ getCredentialAuthName(credential) }}</h4>
|
||||
<div class="item-actions">
|
||||
<span v-if="credential.rp_id && settings?.rp_id && credential.rp_id !== settings.rp_id" class="badge badge-domain" :title="`Passkey registered for ${credential.rp_id}`">{{ credential.rp_id }}</span>
|
||||
<span v-if="credential.is_current_session && !hoveredCredentialUuid && !hoveredSessionCredentialUuid" class="badge badge-current">Current</span>
|
||||
<span v-else-if="hoveredCredentialUuid === credential.credential" class="badge badge-current">Selected</span>
|
||||
<span v-else-if="hoveredSessionCredentialUuid === credential.credential" class="badge badge-current">Linked</span>
|
||||
@@ -61,8 +62,13 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { formatDate } from '@/utils/helpers'
|
||||
import { navigateGrid, handleEscape, handleDeleteKey, getDirection } from '@/utils/keynav'
|
||||
import { getSettings } from '@/utils/settings'
|
||||
|
||||
const settings = ref(null)
|
||||
onMounted(async () => { settings.value = await getSettings() })
|
||||
|
||||
const props = defineProps({
|
||||
credentials: { type: Array, default: () => [] },
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
<template>
|
||||
<div class="dialog-overlay" @click="$emit('close')">
|
||||
<div ref="dialog" :class="['modal-panel', panelClass]" @keydown="handleDialogKeydown" @click.stop>
|
||||
<slot />
|
||||
<div class="modal-wrap">
|
||||
<div ref="dialog" :class="['modal-panel', panelClass]" @keydown="handleDialogKeydown" @click.stop>
|
||||
<slot />
|
||||
</div>
|
||||
<slot name="attached" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -54,6 +54,10 @@
|
||||
<p class="section-description">Ideally have at least two passkeys in case you lose one. More than one user can be registered on the same device, giving you a choice at login. <a href="https://bitwarden.com/pricing/" target="_blank" rel="noopener noreferrer">Bitwarden</a> can sync one passkey to all your devices. Other secure options include <b>local passkeys</b>, as well as hardware keys such as <a href="https://www.yubico.com" target="_blank" rel="noopener noreferrer">YubiKey</a>. Cloud sync via Google, Microsoft or iCloud is discouraged.</p>
|
||||
</div>
|
||||
<div class="section-body">
|
||||
<div v-if="missingDomainPasskey" class="domain-enroll-notice">
|
||||
<p>You don't have a passkey for <strong>{{ rpName }}</strong> ({{ authStore.settings.rp_id }}) yet. Add one to log in here directly.</p>
|
||||
<button @click="addNewCredential" class="btn-primary">Add Passkey for {{ authStore.settings.rp_id }}</button>
|
||||
</div>
|
||||
<CredentialList
|
||||
ref="credentialList"
|
||||
:credentials="credentials"
|
||||
@@ -410,6 +414,11 @@ const hasMultipleSessions = computed(() => Object.keys(sessions.value).length >
|
||||
const credentials = computed(() =>
|
||||
Object.entries(authStore.userInfo.credentials).map(([uuid, c]) => ({ ...c, credential: uuid }))
|
||||
)
|
||||
const missingDomainPasskey = computed(() => {
|
||||
const rpId = authStore.settings?.rp_id
|
||||
if (!rpId) return false
|
||||
return !credentials.value.some(c => c.rp_id === rpId)
|
||||
})
|
||||
const useWideLayout = computed(() => {
|
||||
// Check if any single site has more than 8 sessions
|
||||
const groups = {}
|
||||
|
||||
@@ -53,6 +53,7 @@
|
||||
<!-- Device info display (shown when 3 words match a request) -->
|
||||
<div v-else-if="deviceInfo" class="device-info">
|
||||
<p class="device-permit-text">Permit {{ deviceInfo.action === 'register' ? 'registration' : 'login' }} to <strong>{{ deviceInfo.host }}</strong></p>
|
||||
<p v-if="crossDomainNotice" class="device-meta domain-notice">on <strong>{{ deviceInfo.rp_name || deviceInfo.rp_id }}</strong><template v-if="deviceInfo.rp_name"> ({{ deviceInfo.rp_id }})</template></p>
|
||||
<p class="device-meta">{{ deviceInfo.user_agent_pretty || '—' }}</p>
|
||||
|
||||
<p v-if="error" class="error-message">{{ error }}</p>
|
||||
@@ -122,6 +123,13 @@ watch(deviceInfo, (newVal) => {
|
||||
emit('deviceInfoVisible', !!newVal)
|
||||
})
|
||||
|
||||
const crossDomainNotice = computed(() => {
|
||||
const info = deviceInfo.value
|
||||
if (!info?.rp_id) return false
|
||||
const ownRpId = settings.value?.rp_id
|
||||
return ownRpId ? info.rp_id !== ownRpId : true
|
||||
})
|
||||
|
||||
const hasInvalidWord = ref(false)
|
||||
const serverError = ref(false)
|
||||
const cursorPos = ref(0)
|
||||
@@ -613,7 +621,9 @@ async function lookupDeviceInfo() {
|
||||
host: res.host,
|
||||
user_agent_pretty: res.user_agent_pretty,
|
||||
client_ip: res.client_ip,
|
||||
action: res.action || 'login'
|
||||
action: res.action || 'login',
|
||||
rp_id: res.rp_id || null,
|
||||
rp_name: res.rp_name || null
|
||||
}
|
||||
lastLookedUpCode = currentCode
|
||||
nextTick(() => { submitBtnRef.value?.focus() })
|
||||
@@ -937,6 +947,12 @@ defineExpose({ reset, deny, code, handleInput, loading, error })
|
||||
font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
|
||||
}
|
||||
|
||||
.domain-notice {
|
||||
color: var(--color-text);
|
||||
font-family: inherit;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
margin: 0.5rem 0 0;
|
||||
font-size: 0.875rem;
|
||||
|
||||
@@ -83,8 +83,8 @@ export const useAuthStore = defineStore('auth', {
|
||||
if (!this.userInfo) this.currentView = 'login'
|
||||
else this.currentView = 'profile'
|
||||
},
|
||||
async loadSettings() {
|
||||
this.settings = await getSettings()
|
||||
async loadSettings(force = false) {
|
||||
this.settings = await getSettings(force)
|
||||
},
|
||||
async loadUserInfo() {
|
||||
try {
|
||||
|
||||
@@ -41,3 +41,87 @@ export const hostIP = ip => {
|
||||
return ip
|
||||
}
|
||||
}
|
||||
|
||||
// Display-time ordering of a domain's configured origins (the stored
|
||||
// object is unordered): the auth host first (flagged), then in-domain
|
||||
// entries (exact rp-id, then hierarchical), then related origins — hosts
|
||||
// outside the rp-id domain — hierarchically. An empty origins object
|
||||
// allows nothing and shows as an empty list.
|
||||
|
||||
// Hierarchical origin comparison: split off scheme/port, compare hostnames
|
||||
// label by label from the TLD down, parents before their subdomains and a
|
||||
// wildcard label ('**' any depth, '*' one level — in that order) after all
|
||||
// concrete labels at the same level. Entries on the same host tie-break by
|
||||
// scheme (https first) and numeric port.
|
||||
function originParts(key) {
|
||||
let s = key.toLowerCase().replace(/\/+$/, '')
|
||||
let scheme = ''
|
||||
const sm = s.match(/^([a-z][a-z0-9+.-]*):\/\//)
|
||||
if (sm) { scheme = sm[1]; s = s.slice(sm[0].length) }
|
||||
let port = ''
|
||||
const pm = s.match(/:(\d+)$/)
|
||||
if (pm) { port = pm[1]; s = s.slice(0, -pm[0].length) }
|
||||
const labels = s.split('.').reverse()
|
||||
return { labels, scheme, port }
|
||||
}
|
||||
|
||||
export function compareOrigins(a, b) {
|
||||
const A = originParts(a), B = originParts(b)
|
||||
for (let i = 0; i < Math.max(A.labels.length, B.labels.length); i++) {
|
||||
const la = A.labels[i], lb = B.labels[i]
|
||||
if (la === undefined) return -1
|
||||
if (lb === undefined) return 1
|
||||
if (la === lb) continue
|
||||
const wa = la === '*' || la === '**'
|
||||
const wb = lb === '*' || lb === '**'
|
||||
if (wa && wb) return la === '**' ? -1 : 1
|
||||
if (wa) return 1
|
||||
if (wb) return -1
|
||||
const c = la.localeCompare(lb)
|
||||
if (c) return c
|
||||
}
|
||||
if (A.scheme !== B.scheme) {
|
||||
if (A.scheme === 'https') return -1
|
||||
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)
|
||||
}
|
||||
|
||||
// An origins-table entry outside the rp-id domain is a related origin
|
||||
// (WebAuthn ROR). Wildcards ('*.' or '**.') are never related — they are
|
||||
// only valid under the rp-id.
|
||||
function isRelatedKey(rpId, key) {
|
||||
if (key.startsWith('*.') || key.startsWith('**.')) return false
|
||||
try {
|
||||
const hostname = new URL(key.includes('://') ? key : 'https://' + key).hostname
|
||||
return !!hostname && hostname !== rpId && !hostname.endsWith('.' + rpId)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export function originDisplayEntries(domain) {
|
||||
const origins = domain.origins || {}
|
||||
const keys = Object.keys(origins)
|
||||
const authKey = keys.find(k => origins[k] !== true && origins[k]?.auth_host)
|
||||
const inDomain = []
|
||||
const related = []
|
||||
for (const k of keys) {
|
||||
if (k === authKey) continue
|
||||
const bucket = isRelatedKey(domain.rp_id, k) ? related : inDomain
|
||||
bucket.push(k)
|
||||
}
|
||||
inDomain.sort((a, b) => {
|
||||
if (a === domain.rp_id) return -1
|
||||
if (b === domain.rp_id) return 1
|
||||
return compareOrigins(a, b)
|
||||
})
|
||||
related.sort(compareOrigins)
|
||||
const rows = []
|
||||
if (authKey) rows.push({ key: authKey, auth: true })
|
||||
for (const k of inDomain) rows.push({ key: k, auth: false })
|
||||
for (const k of related) rows.push({ key: k, auth: false, related: true })
|
||||
return rows
|
||||
}
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
let _settingsPromise = null
|
||||
let _settings = null
|
||||
let _requestGen = 0
|
||||
|
||||
export function getSettingsCached() { return _settings }
|
||||
|
||||
export async function getSettings() {
|
||||
export async function getSettings(force = false) {
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
* Configures Vite for FastAPI backend integration:
|
||||
* - Proxies /api/* requests to the FastAPI backend
|
||||
* - Builds to the Python module's frontend-build directory
|
||||
* - Disables Vite's screen clearing on startup
|
||||
*
|
||||
* Options:
|
||||
* paths - Array of paths to proxy (default: ["/api"])
|
||||
@@ -26,6 +27,7 @@ export default function fastapiVue({ paths = ["/api"] } = {}) {
|
||||
return {
|
||||
name: "vite-plugin-fastapi-paskia",
|
||||
config: () => ({
|
||||
clearScreen: false,
|
||||
server: { proxy },
|
||||
build: {
|
||||
outDir: "../paskia/frontend-build",
|
||||
|
||||
+10
-5
@@ -6,8 +6,12 @@ import { existsSync, renameSync, mkdirSync } from 'node:fs'
|
||||
import sirv from 'sirv'
|
||||
import fastapiVue from './vite-plugin-fastapi.js'
|
||||
|
||||
// Auth host mode: when set, clients accessing the auth host get /auth/ at / and /auth/admin/ at /admin/
|
||||
const authHost = process.env.PASKIA_AUTH_HOST
|
||||
// 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 domain with a dedicated auth host)
|
||||
const authHosts = (process.env.PASKIA_AUTH_HOST || '')
|
||||
.split(',')
|
||||
.map(h => h.trim().replace(/^https?:\/\//, '').split(':')[0].split('/')[0])
|
||||
.filter(Boolean)
|
||||
|
||||
export default defineConfig(({ command }) => ({
|
||||
appType: 'mpa',
|
||||
@@ -17,6 +21,7 @@ export default defineConfig(({ command }) => ({
|
||||
"/auth/api",
|
||||
"/auth/ws",
|
||||
"/.well-known/openid-configuration",
|
||||
"/.well-known/webauthn",
|
||||
// Passphrase links: /auth/word1.word2.word3.word4.word5
|
||||
"^/auth/[a-z]+\\.[a-z]+\\.[a-z]+\\.[a-z]+\\.[a-z]+$",
|
||||
// Passphrase links: /word1.word2.word3.word4.word5
|
||||
@@ -25,13 +30,13 @@ export default defineConfig(({ command }) => ({
|
||||
vue(),
|
||||
// Auth host routing: rewrite paths when accessing dedicated auth host
|
||||
// Must run before serve-examples to handle / correctly
|
||||
authHost && {
|
||||
authHosts.length && {
|
||||
name: 'auth-host-routing',
|
||||
configureServer(server) {
|
||||
server.middlewares.use((req, _res, next) => {
|
||||
const host = req.headers.host?.split(':')[0]
|
||||
// Check if request is coming to the auth host
|
||||
if (host === authHost) {
|
||||
if (authHosts.includes(host)) {
|
||||
// Only rewrite specific paths that should map to /auth/*
|
||||
// Rewrite / and /index.html to /auth/
|
||||
if (req.url === '/' || req.url === '/index.html') {
|
||||
@@ -67,7 +72,7 @@ export default defineConfig(({ command }) => ({
|
||||
server.middlewares.use((req, _res, next) => {
|
||||
// Skip redirect to examples on auth host (handled by auth-host-routing)
|
||||
const host = req.headers.host?.split(':')[0]
|
||||
if (authHost && host === authHost) {
|
||||
if (authHosts.includes(host)) {
|
||||
next()
|
||||
return
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user