Satellite v2: upstream-minted scoped sync tokens, per-session contexts

Registration simplified to a single sync URL: minted in the upstream's
admin UI (Satellite access section of the domain dialog), carrying the
token as userinfo — https://<token>@<auth-host>. Tokens live hashed in
the upstream database, scoped to one rp-id, revocable (drops live
connections). PASKIA_SYNC_TOKENS env is gone.

Data minimization: the sync feed now carries only the scope's sessions
and credentials; the user graph never leaves the upstream. Each session
upsert embeds its master-computed ApiSessionContext (identity display
data + effective permissions for the session's host); user-graph
mutations eagerly re-push affected sessions with fresh contexts, so
revocations land within ~1 RTT. /check and /user-info are proxied to
the upstream (they need the user graph).

Satellite side: replicas keyed by rp-id (no more shared-replica config
conflicts — each remote domain has its own token), the replica refuses
to go live on a ready-message scope mismatch, write-backs are confined
to the token's scope server-side.

Admin dialog: one sync-URL paste field (token split from userinfo,
write-only), uplink verification required to save (reachability, rp-id
match, no nesting, auth host present); a verified uplink takes over the
domain's origins (**.<rp-id> + upstream auth host) and auth host. New
domains adopt the upstream's rp-id and name.
This commit is contained in:
LeoVasanko committed 2026-10-01 05:40:32 +00:00
1 parent da0b3e0e8b
commit 2209e2395c
14 files changed
+1159 -330

No files matched your search

+243 -80
View File
@@ -2,7 +2,8 @@
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import AdminDialog from './AdminDialog.vue'
import { useAuthStore } from '@/stores/auth'
import { compareOrigins } from '@/utils/helpers'
import { compareOrigins, formatDate } from '@/utils/helpers'
import { apiJson } from 'paskia'
const props = defineProps({
dialog: { type: Object, required: true }
@@ -22,9 +23,11 @@ const dialogRpId = computed(() => (props.dialog.data?.rp_id || '').trim().toLowe
//
// A remote domain is served from another paskia instance: this one keeps a
// RAM-only read replica for fast local session checks and forwards
// mutations. The remote must accept our sync token via its
// PASKIA_SYNC_TOKENS environment variable. An auth host (the remote's) is
// required — profile, admin and sign-in pages live there.
// mutations. Setup is a single sync URL minted on the upstream (its admin
// UI, the domain's Satellite access section): it carries the upstream auth
// host and a token scoped to this domain. The remote configuration takes
// over completely — allowed origins and the auth host are derived from the
// uplink once it verifies.
const remoteEnabled = computed({
get: () => !!props.dialog.data?.remote,
set: on => {
@@ -34,39 +37,74 @@ const remoteEnabled = computed({
},
})
const remoteUrlInvalid = computed(() => {
const url = props.dialog.data?.remote?.url?.trim()
if (!url) return false
return !/^https?:\/\/[^\s/]+/.test(url)
// The raw sync URL as pasted; the parsed parts land in remote.url (clean,
// userinfo stripped) and remote.token. An existing configuration shows the
// clean URL — the stored token is write-only, and a URL without userinfo
// keeps it.
const syncUrl = ref(props.dialog.data?.remote?.url || '')
const hadRemoteOnOpen = !!props.dialog.data?.remote
function parseSyncUrl(value) {
const v = value.trim()
if (!v) return null
try {
const u = new URL(v.includes('://') ? v : 'https://' + v)
if (u.protocol !== 'http:' && u.protocol !== 'https:') return { error: true }
if (!u.hostname) return { error: true }
const token = decodeURIComponent(u.password || u.username || '')
const url = u.origin + (u.pathname === '/' ? '' : u.pathname.replace(/\/+$/, ''))
return { url, token, host: u.host }
} catch {
return { error: true }
}
}
const parsedSyncUrl = computed(() => parseSyncUrl(syncUrl.value))
const syncUrlInvalid = computed(() => !!(syncUrl.value.trim() && parsedSyncUrl.value?.error))
// A token is required unless the domain already has one stored (write-only)
const syncUrlMissingToken = computed(() => {
const parsed = parsedSyncUrl.value
return !!(parsed && !parsed.error && !parsed.token && !hadRemoteOnOpen)
})
// Remote domains must mark an auth host (the server rejects the save)
const remoteMissingAuthHost = computed(
() => !!props.dialog.data?.remote && !props.dialog.data?.auth_host
)
// --- Uplink verification ---
// Fetch the remote's /auth/api/settings (CORS-open), like the origin
// connectivity probes: proves reachability, that the peer is a paskia
// instance, and that it serves this very domain authoritatively — not as
// a satellite itself (nested satellites are not supported). Results are
// warnings, never submit blockers (the remote may not be deployed yet).
const uplinkCheck = ref(null) // null|validating|valid|unreachable|mismatch|nested
let uplinkTimer = null
let uplinkSeq = 0
// Keep the submit model in sync with the pasted URL
watch(
() => [props.dialog.data?.remote?.url, dialogRpId.value],
() => {
clearTimeout(uplinkTimer)
uplinkCheck.value = null
const url = props.dialog.data?.remote?.url?.trim().replace(/\/+$/, '')
if (!url || remoteUrlInvalid.value) return
uplinkTimer = setTimeout(() => checkUplink(url), 600)
parsedSyncUrl,
parsed => {
const d = props.dialog.data
if (!d?.remote) return
if (!parsed || parsed.error) {
d.remote.url = ''
return
}
d.remote.url = parsed.url
if (parsed.token) d.remote.token = parsed.token
},
{ immediate: true }
)
async function checkUplink(url) {
// --- Uplink verification ---
// Fetch the upstream's /auth/api/settings (CORS-open), like the origin
// connectivity probes: proves reachability, that the peer serves this very
// domain authoritatively — not as a satellite itself (nested satellites are
// not supported) — and that it has an auth host (where profile, admin and
// sign-in pages live for satellite users). A verified uplink is required to
// save a remote domain.
const uplinkCheck = ref(null) // null|validating|valid|unreachable|mismatch|nested|no-auth-host
let uplinkTimer = null
let uplinkSeq = 0
watch(
() => [props.dialog.data?.remote ? parsedSyncUrl.value : null, dialogRpId.value],
([parsed]) => {
clearTimeout(uplinkTimer)
uplinkCheck.value = null
if (!parsed || parsed.error) return
uplinkTimer = setTimeout(() => checkUplink(parsed.url, parsed.host), 600)
},
{ immediate: true }
)
async function checkUplink(url, host) {
const seq = ++uplinkSeq
uplinkCheck.value = 'validating'
try {
@@ -76,8 +114,28 @@ async function checkUplink(url) {
if (seq !== uplinkSeq) return // url/rp-id changed while fetching
if (!response.ok) throw new Error('not ok')
const data = await response.json()
uplinkCheck.value =
data.rp_id !== dialogRpId.value ? 'mismatch' : data.remote ? 'nested' : 'valid'
const d = props.dialog.data
// An existing (or already typed) rp-id must match; a fresh domain
// adopts the upstream's, along with its display name.
if (dialogRpId.value && data.rp_id !== dialogRpId.value) {
uplinkCheck.value = 'mismatch'
return
}
uplinkCheck.value = data.remote
? 'nested'
: !data.auth_host
? 'no-auth-host'
: 'valid'
if (uplinkCheck.value !== 'valid' || !d?.remote) return
if (d.isNew && !dialogRpId.value) {
d.rp_id = data.rp_id
if (!d.rp_name) d.rp_name = data.rp_name || ''
}
// The remote config takes over: origins are the whole rp-id domain
// plus the uplink's auth host, which hosts all account pages.
d.auth_host = host
d.origins = ['**.' + data.rp_id, host]
d.originValidation = [null, null]
} catch {
if (seq === uplinkSeq) uplinkCheck.value = 'unreachable'
}
@@ -85,19 +143,22 @@ async function checkUplink(url) {
// 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).
// out of the domain they are using, or validation still in flight. For
// remote domains the uplink must verify — it doubles as the source of
// the derived origins and auth host.
const isValidationInvalid = computed(() => {
const d = props.dialog.data
if (d?.remote) {
if (!parsedSyncUrl.value || parsedSyncUrl.value.error) return true
if (syncUrlMissingToken.value) return true
if (uplinkCheck.value !== 'valid') return true
return d.isNew && !isWellFormedDomain(d.rp_id || '')
}
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
if (remoteUrlInvalid.value || remoteMissingAuthHost.value) return true
if (d.remote && !d.remote.url?.trim()) return true
return false
})
@@ -141,6 +202,68 @@ function copyText(value, label) {
})
}
// --- Satellite access (this instance as the upstream) ---
//
// Tokens are scoped to this domain: a satellite receives only this
// domain's sessions (each with its user's identity context and effective
// permissions) and credentials — never the user directory — and can only
// write back session refreshes within scope. The raw token is shown
// once, embedded in the sync URL; revoking drops the satellite's live
// connections. Only an authoritative domain with an auth host can serve
// satellites (enforced by the server).
const syncTokens = ref(null) // null = not loaded yet
const mintedSyncUrl = ref('')
const satelliteBusy = ref(false)
const showSatelliteAccess = computed(() => {
const d = props.dialog.data
return !!(d && !d.isNew && !d.remote)
})
async function loadSyncTokens() {
const d = props.dialog.data
if (!showSatelliteAccess.value) return
try {
syncTokens.value = await apiJson(
`/auth/api/admin/domains/${d.rp_id}/sync-tokens`, { method: 'GET' }
)
} catch {
syncTokens.value = []
}
}
onMounted(loadSyncTokens)
async function generateSyncToken() {
const d = props.dialog.data
if (!d || satelliteBusy.value) return
satelliteBusy.value = true
try {
const data = await apiJson(
`/auth/api/admin/domains/${d.rp_id}/sync-token`, { method: 'POST' }
)
mintedSyncUrl.value = data.url
await loadSyncTokens()
} catch (e) {
authStore.showMessage(e.message || 'Failed to generate the sync URL', 'error')
} finally {
satelliteBusy.value = false
}
}
async function revokeSyncToken(id) {
const d = props.dialog.data
if (!d || satelliteBusy.value) return
satelliteBusy.value = true
try {
await apiJson(`/auth/api/admin/domains/${d.rp_id}/sync-token/${id}`, { method: 'DELETE' })
authStore.showMessage('Sync token revoked.', 'success', 2500)
await loadSyncTokens()
} catch (e) {
authStore.showMessage(e.message || 'Failed to revoke the sync token', 'error')
} finally {
satelliteBusy.value = false
}
}
// --- Lockout prevention (editing the domain in use) ---
// When the admin edits the domain they are currently signed in on and no
@@ -410,7 +533,8 @@ watch(() => relatedEntries.value.map(asHttpsOrigin).join('|'), testWellKnown, {
// 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'.
// don't prefill a broken '**.something'. Remote domains derive their
// origins from the uplink instead.
function looksCompleteDomain(value) {
const host = (value || '').trim().replace(/\.$/, '')
return host === 'localhost' || /\.[a-z]{2,}$/i.test(host)
@@ -419,7 +543,7 @@ function looksCompleteDomain(value) {
let seededOrigin = null
watch(dialogRpId, rp => {
const d = props.dialog.data
if (!d?.isNew) return
if (!d?.isNew || d.remote) return
if (!looksCompleteDomain(rp) || !isWellFormedDomain(rp)) return
const seed = '**.' + rp.trim().replace(/\.$/, '')
if (!d.origins.length) {
@@ -554,35 +678,37 @@ function onRemoveOrigin(i) {
<input v-model="dialog.data.rp_name" :placeholder="dialog.data.rp_id" />
</label>
<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>
</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 => onOriginInput(i, e)"
@blur="onOriginBlur(i)"
class="origin-input"
:class="{ 'input-error': dialog.data.originValidation[i] === 'invalid' }"
/>
<span v-if="isAuthHostEntry(dialog.data.origins[i])" class="key-badge" title="Authentication site — the account and admin interface live here">🔑</span>
<span v-else-if="isRelatedEntry(dialog.data.origins[i])" class="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>
<template v-if="!dialog.data.remote">
<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>
</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 => onOriginInput(i, e)"
@blur="onOriginBlur(i)"
class="origin-input"
:class="{ 'input-error': dialog.data.originValidation[i] === 'invalid' }"
/>
<span v-if="isAuthHostEntry(dialog.data.origins[i])" class="key-badge" title="Authentication site — the account and admin interface live here">🔑</span>
<span v-else-if="isRelatedEntry(dialog.data.origins[i])" class="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>
</div>
<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>
<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>
<div class="origin-label">
<label class="remote-toggle">
@@ -591,20 +717,27 @@ function onRemoveOrigin(i) {
</label>
</div>
<template v-if="dialog.data.remote">
<label>Remote URL
<input v-model="dialog.data.remote.url" placeholder="https://auth.example.com" data-form-type="other" :class="{ 'input-error': remoteUrlInvalid }" />
<label>Sync URL
<input
v-model="syncUrl"
placeholder="https://alpha.bravo.charlie.delta.echo@auth.example.com"
autocomplete="off"
data-lpignore="true"
data-1p-ignore="true"
data-form-type="other"
spellcheck="false"
:class="{ 'input-error': syncUrlInvalid || syncUrlMissingToken }"
/>
</label>
<p v-if="remoteUrlInvalid" class="small error">Must be an http(s) URL.</p>
<p class="small muted">Minted on the authoritative instance — open the domain there and use <em>Satellite access → Generate sync URL</em>.<template v-if="hadRemoteOnOpen"> Paste a new one to replace the stored token.</template></p>
<p v-if="syncUrlInvalid" class="small error">Must be an http(s) URL.</p>
<p v-else-if="syncUrlMissingToken" class="small error">The URL carries no token — paste the complete sync URL from the upstream.</p>
<p v-else-if="uplinkCheck === 'validating'" class="small muted">Checking the uplink…</p>
<p v-else-if="uplinkCheck === 'valid'" class="small">✓ Uplink verified — the remote serves this domain authoritatively.</p>
<p v-else-if="uplinkCheck === 'unreachable'" class="small">The remote is unreachable — the uplink cannot be verified. Save anyway only if it is not deployed yet.</p>
<p v-else-if="uplinkCheck === 'mismatch'" class="small error">The remote does not serve {{ dialogRpId }} — check the URL. Connect directly to the authoritative instance.</p>
<p v-else-if="uplinkCheck === 'nested'" class="small error">The remote is itself a satellite for this domain — nested satellites are not supported. Point directly at the authoritative instance.</p>
<p v-if="remoteMissingAuthHost" class="small error">A remote domain must mark an auth host above — profile, admin and sign-in pages live there (typically the remote's own site).</p>
<label>Sync token
<input v-model="dialog.data.remote.token" type="password" placeholder="Token in the remote's PASKIA_SYNC_TOKENS" autocomplete="off" data-form-type="other" />
</label>
<p class="small muted">Accepted by the remote via its PASKIA_SYNC_TOKENS environment variable.<template v-if="!dialog.data.isNew"> Leave empty to keep the stored token.</template></p>
<p v-else-if="uplinkCheck === 'valid'" class="small">✓ Uplink verified — {{ parsedSyncUrl?.host }} serves this domain authoritatively. Origins and the auth host are taken from it.</p>
<p v-else-if="uplinkCheck === 'unreachable'" class="small error">The upstream is unreachable — check the URL.</p>
<p v-else-if="uplinkCheck === 'mismatch'" class="small error">The upstream does not serve {{ dialogRpId }} — its sync URL must be minted for this domain.</p>
<p v-else-if="uplinkCheck === 'nested'" class="small error">The upstream is itself a satellite for this domain — nested satellites are not supported. Point directly at the authoritative instance.</p>
<p v-else-if="uplinkCheck === 'no-auth-host'" class="small error">The upstream has no auth host for this domain — only an instance with an auth host can serve satellites.</p>
<label>Staleness limit (cache TTL, seconds)
<input v-model.number="dialog.data.remote.cache_ttl" type="number" min="1" />
</label>
@@ -612,9 +745,33 @@ function onRemoveOrigin(i) {
<input v-model.number="dialog.data.remote.refresh_interval" type="number" min="30" />
</label>
<p class="small muted">
Session checks run locally against a RAM replica of the remote (sub-millisecond). If the connection is down longer than the staleness limit, checks fail closed (503).
Session checks run locally against a RAM replica of the upstream (sub-millisecond). If the connection is down longer than the staleness limit, checks fail closed (503).
</p>
</template>
<template v-if="showSatelliteAccess">
<div class="origin-label">Satellite access</div>
<template v-if="dialog.data.auth_host">
<p class="small muted">
Other paskia instances can serve this domain as satellites — local RAM replicas kept in sync over a feed scoped to {{ dialog.data.rp_id }}. Generate one sync URL per satellite and paste it into that instance's configuration for the domain.
</p>
<div v-if="syncTokens?.length" class="sync-token-list">
<div v-for="t in syncTokens" :key="t.id" class="sync-token-row">
<span class="small sync-token-id" title="Sync token id">…{{ t.id.slice(-8) }}</span>
<span class="small muted">{{ formatDate(t.created) }}</span>
<button type="button" class="icon-btn" :disabled="satelliteBusy" @click="revokeSyncToken(t.id)" aria-label="Revoke sync token" title="Revoke — drops the satellite's connection">❌</button>
</div>
</div>
<div>
<button type="button" class="btn-secondary" :disabled="satelliteBusy" @click="generateSyncToken">Generate sync URL</button>
</div>
<template v-if="mintedSyncUrl">
<pre class="wellknown-doc" title="Click to copy" tabindex="0" @click="copyText(mintedSyncUrl, 'Sync URL')" @keydown.enter.prevent="copyText(mintedSyncUrl, 'Sync URL')">{{ mintedSyncUrl }}</pre>
<p class="small muted">Shown once — the token is stored hashed and cannot be recovered later.</p>
</template>
</template>
<p v-else class="small muted">Mark an auth host above and save to let satellites connect — sign-in, profile and admin pages for satellite users live there.</p>
</template>
</AdminDialog>
</template>
@@ -641,6 +798,12 @@ function onRemoveOrigin(i) {
background: var(--color-error-bg, rgba(239, 68, 68, 0.05));
}
.remote-toggle { display: flex; align-items: center; gap: var(--space-xs); font-weight: 600; font-size: 0.95rem; }
/* The global label rule stacks children vertically — the toggle wants its
checkbox beside the text. */
.remote-toggle { display: flex; flex-direction: row; align-items: center; gap: var(--space-xs); font-weight: 600; font-size: 0.95rem; }
.remote-toggle input { width: auto; }
.sync-token-list { display: flex; flex-direction: column; gap: 0.3rem; margin-bottom: var(--space-xs); }
.sync-token-row { display: flex; align-items: center; gap: var(--space-sm); }
.sync-token-id { font-family: var(--font-mono, monospace); }
</style>