MultiSite: one instance serves authentication across many domains #4

Merged
LeoVasanko merged 48 commits from multihost into main 2026-09-07 22:02:06 +00:00
8 changed files with 87 additions and 110 deletions
Showing only changes of commit 0d1d18e8a7 - Show all commits
+14 -40
View File
@@ -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>
+51 -50
View File
@@ -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; }
+1 -1
View File
@@ -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()
+3 -4
View File
@@ -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>
+9 -5
View File
@@ -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 })
}
+6 -3
View File
@@ -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
}
+1 -1
View File
@@ -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
View File
@@ -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: