Proxy to another Paskia #5
+73
-71
@@ -12,14 +12,18 @@ backend (forward-auth checks) only repoint to the local satellite
|
|||||||
(`http://127.0.0.1:4401`); both remain usable interchangeably, and the
|
(`http://127.0.0.1:4401`); both remain usable interchangeably, and the
|
||||||
satellite ultimately uses `auth.example.com`.
|
satellite ultimately uses `auth.example.com`.
|
||||||
|
|
||||||
Status: **implemented** (see `paskia/satellite.py`, `paskia/syncfeed.py`,
|
Status: **implemented**. The feature lives in `paskia/satellite.py`
|
||||||
`paskia/fastapi/sync.py`, `paskia/fastapi/proxy.py`). The design review
|
(satellite side: replica, sync client, host dispatch, forwarding) and
|
||||||
comparing the rejected alternatives is at the end of this document.
|
`paskia/syncfeed.py` + `paskia/fastapi/sync.py` (remote side: change
|
||||||
|
feed and sync WebSocket). The design review comparing the rejected
|
||||||
|
alternatives is at the end of this document.
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
A domain becomes remote through its stored domain config (admin domains
|
A domain becomes remote in the admin domains UI (master admin, on the
|
||||||
API; the frontend form may lag):
|
primary server's auth host — this configuration itself never touches a
|
||||||
|
remote): enable *Remote instance* and set the remote URL and sync token.
|
||||||
|
In the stored config (`DomainConfig.remote`):
|
||||||
|
|
||||||
```json
|
```json
|
||||||
"remote": {
|
"remote": {
|
||||||
@@ -37,34 +41,39 @@ API; the frontend form may lag):
|
|||||||
via its `PASKIA_SYNC_TOKENS` environment variable** (comma-separated);
|
via its `PASKIA_SYNC_TOKENS` environment variable** (comma-separated);
|
||||||
nothing is stored in the remote's database, and with the variable unset
|
nothing is stored in the remote's database, and with the variable unset
|
||||||
the sync endpoint stays closed. The token is write-only over the admin
|
the sync endpoint stays closed. The token is write-only over the admin
|
||||||
API (never echoed back).
|
API (an empty field keeps the stored one).
|
||||||
- `cache_ttl` — seconds the replica remains trusted after the sync channel
|
- `cache_ttl` — seconds the replica remains trusted after the sync channel
|
||||||
goes down; then the satellite fails closed (503). Set it large (up to
|
goes down; then checks fail closed (503). Set it large (up to the 24 h
|
||||||
the 24 h session lifetime) for fail-open behavior.
|
session lifetime) for fail-open behavior.
|
||||||
- `refresh_interval` — seconds between full snapshots (reconciliation);
|
- `refresh_interval` — seconds between reconnects; every connect starts
|
||||||
reconnects in between replay missed events from the remote's RAM ring
|
from a full snapshot, which reconciles any drift.
|
||||||
buffer.
|
|
||||||
|
|
||||||
A remote domain **must mark an auth host** (validated cross-domain): the
|
A remote domain **must mark an auth host** (validated cross-domain and in
|
||||||
profile, admin and sign-in pages live there, so browsers and WebSockets
|
the UI): the profile, admin and sign-in pages live there, so browsers and
|
||||||
go directly to the remote. Other domains on the same satellite remain
|
WebSockets go directly to the remote. Other domains on the same satellite
|
||||||
fully local — the multi-domain config mixes both kinds freely.
|
remain fully local — the multi-domain config mixes both kinds freely.
|
||||||
|
|
||||||
## What the satellite holds
|
## How it works
|
||||||
|
|
||||||
A RAM-only read replica of the remote's tables (permissions, orgs, roles,
|
**Dispatch is keyed by host, and only this module knows about stores.**
|
||||||
users, credentials, sessions) as another plain `DB` struct instance,
|
`satellite.store_for_host(host)` returns the local DB or the replica of
|
||||||
attached to the runtime `Domain` as its `store`. It is never persisted,
|
the remote backing the host's domain (raising 503 `HTTPException` when
|
||||||
rebuilt from a snapshot on startup, kept current by sequenced events over
|
the replica is unavailable). The session read path (`session_ctx`,
|
||||||
the sync WebSocket, and swept for expired sessions locally. The feed
|
`authz.verify`, `build_user_info`, `/check`) just passes the host it
|
||||||
carries no usable secrets: sessions are keyed by `hash_secret` output,
|
already has; writes dispatch likewise (`satellite.refresh_session` —
|
||||||
credentials carry public keys only, and the OIDC signing key is never
|
write-behind for remote, `db.update_session` for local;
|
||||||
replicated.
|
`satellite.evict_session` on logout). `satellite.forward_request(request)`
|
||||||
|
returns the proxied response for remote domains or `None` for local ones.
|
||||||
|
|
||||||
Reads run unchanged against the replica: `DB.session_ctx` and the
|
**The replica** is a plain `DB` struct instance in RAM, never persisted.
|
||||||
verify/`/check`/`/user-info` helpers take an explicit `store` (the
|
On connect the remote sends a snapshot of the replicated tables
|
||||||
dispatched domain's), defaulting to the local database. There is no
|
(permissions, orgs, roles, users, credentials, sessions), then live
|
||||||
context-dependent global accessor.
|
upsert/delete events emitted from the struct `store()`/`delete()` hooks
|
||||||
|
(which also cover cascade deletes) and field-mutating operations. A
|
||||||
|
single ordered WebSocket cannot gap; a slow subscriber is dropped and
|
||||||
|
resyncs. The feed carries no usable secrets: sessions are keyed by
|
||||||
|
`hash_secret` output, credentials carry public keys only, and the OIDC
|
||||||
|
signing key is never replicated.
|
||||||
|
|
||||||
## Endpoint behavior for remote domains
|
## Endpoint behavior for remote domains
|
||||||
|
|
||||||
@@ -73,7 +82,7 @@ context-dependent global accessor.
|
|||||||
| `GET /auth/api/forward`, `GET /check`, `GET /user-info`, `GET /settings` | served from the replica (sub-ms) |
|
| `GET /auth/api/forward`, `GET /check`, `GET /user-info`, `GET /settings` | served from the replica (sub-ms) |
|
||||||
| `POST /auth/api/validate` | verified from the replica; the throttled refresh updates the replica and is written back over the sync channel; cookie renewed locally |
|
| `POST /auth/api/validate` | verified from the replica; the throttled refresh updates the replica and is written back over the sync channel; cookie renewed locally |
|
||||||
| `POST /auth/api/logout` | proxied to the remote (original Host preserved) and evicted from the replica immediately |
|
| `POST /auth/api/logout` | proxied to the remote (original Host preserved) and evicted from the replica immediately |
|
||||||
| `POST /auth/api/set-session`, `GET /token-info` | proxied (the exchange code/reset token lives in the remote's RAM/DB); the session arrives via sync event |
|
| `POST /auth/api/set-session`, `GET /token-info` | proxied (the exchange code/reset token lives on the remote); the session arrives via sync event |
|
||||||
| `/auth/oidc/*` | proxied (signing key and OIDC sessions stay on the remote) |
|
| `/auth/oidc/*` | proxied (signing key and OIDC sessions stay on the remote) |
|
||||||
| `/auth/ws/*`, `/auth/remote-auth/*`, admin, profile | not served — the auth host requirement means these are reached on the remote directly |
|
| `/auth/ws/*`, `/auth/remote-auth/*`, admin, profile | not served — the auth host requirement means these are reached on the remote directly |
|
||||||
|
|
||||||
@@ -83,37 +92,28 @@ Freshness hierarchy:
|
|||||||
optimistic eviction).
|
optimistic eviction).
|
||||||
2. Changes made **directly on the remote**: a sync event, ~1 network RTT.
|
2. Changes made **directly on the remote**: a sync event, ~1 network RTT.
|
||||||
3. Channel down: the replica stays authoritative until `cache_ttl` past
|
3. Channel down: the replica stays authoritative until `cache_ttl` past
|
||||||
the disconnect, then 503. On reconnect, missed events are replayed
|
the disconnect (dead-peer detection is bounded by the ~10 s keepalive),
|
||||||
from the remote's ring buffer, or a full snapshot is taken (always at
|
then 503. Every reconnect starts from a fresh snapshot.
|
||||||
`refresh_interval` and after remote restarts, detected via a
|
|
||||||
generation stamp).
|
|
||||||
|
|
||||||
## The remote side
|
## The remote side
|
||||||
|
|
||||||
Strictly additive and RAM-only: a `syncfeed` ring buffer fed by hooks in
|
Strictly additive and RAM-only: `syncfeed` (a subscriber set fed by the
|
||||||
the struct `store()`/`delete()` methods (which also cover cascade
|
commit hooks) and the token-gated `/auth/api/sync/ws` endpoint serving
|
||||||
deletes) plus explicit emits for field-mutating operations, and the
|
snapshot + live events and accepting `session_refresh` write-backs. With
|
||||||
token-gated `/auth/api/sync/ws` endpoint serving snapshots, event replay
|
no satellites connected, the hooks are a no-op.
|
||||||
and live events, and accepting `session_refresh` write-backs. With no
|
|
||||||
satellites connected, the hooks are a no-op.
|
|
||||||
|
|
||||||
## Trust and caveats
|
## Trust and caveats
|
||||||
|
|
||||||
- The satellite host holds a full copy of the remote's auth data in RAM
|
- The satellite host holds a full copy of the remote's auth data in RAM
|
||||||
(minus the OIDC key) — treat it as trusted as the remote.
|
(minus the OIDC key) — treat it as trusted as the remote.
|
||||||
- Disconnect detection is bounded by the sync keepalive (~10 s) plus
|
|
||||||
`cache_ttl`.
|
|
||||||
- Avatars are stored on the remote's disk; `user-info` from a replica
|
- Avatars are stored on the remote's disk; `user-info` from a replica
|
||||||
reports no avatar URL.
|
reports no avatar URL.
|
||||||
- OIDC sessions in a replica-backed `user-info` show the client UUID
|
- OIDC sessions in a replica-backed `user-info` show the client UUID
|
||||||
rather than its name (OIDC clients are not replicated).
|
rather than its name (OIDC clients are not replicated).
|
||||||
- Remote and satellite should run compatible versions; the sync handshake
|
|
||||||
carries a generation stamp and protocol mismatches fall back to
|
|
||||||
snapshots.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# Design review (the rejected alternative)
|
# Design review (the rejected alternatives)
|
||||||
|
|
||||||
## Option A — caching HTTP reverse proxy
|
## Option A — caching HTTP reverse proxy
|
||||||
|
|
||||||
@@ -131,44 +131,46 @@ within one RTT instead of at TTL.
|
|||||||
## What the read-only local state buys over the HTTP cache
|
## What the read-only local state buys over the HTTP cache
|
||||||
|
|
||||||
- **Full `SessionContext` locally.** A replays the byte-response it once
|
- **Full `SessionContext` locally.** A replays the byte-response it once
|
||||||
saw; D *computes* the answer. Query combinations never seen before
|
saw; the satellite *computes* the answer. Query combinations never seen
|
||||||
(new `perm`/`max_age`/`public` shapes) are served locally by D but miss
|
before (new `perm`/`max_age`/`public` shapes) are served locally but
|
||||||
A's cache. D caches the *domain model*, so derived answers (effective
|
miss A's cache. The replica holds the *domain model*, so derived
|
||||||
permissions per host, `max_age` against `credential.last_used`,
|
answers (effective permissions per host, `max_age` against
|
||||||
`Remote-*` composition) are correct without having been witnessed.
|
`credential.last_used`, `Remote-*` composition) are correct without
|
||||||
|
having been witnessed.
|
||||||
- **One invalidation model.** A hand-builds invalidation rules per
|
- **One invalidation model.** A hand-builds invalidation rules per
|
||||||
endpoint (query-key mapping, cookie re-keying on renew, 401 variants).
|
endpoint (query-key mapping, cookie re-keying on renew, 401 variants).
|
||||||
D's events mutate the replica (upsert/delete by table+key) and every
|
Events mutate the replica (upsert/delete by table+key) and every
|
||||||
endpoint becomes consistent at once — including future ones.
|
endpoint becomes consistent at once — including future ones.
|
||||||
- **Degradation behaves like a real instance.** With the remote down, D
|
- **Degradation behaves like a real instance.** With the remote down, the
|
||||||
serves a coherent auth service from the replica (expiry enforced
|
satellite serves a coherent auth service from the replica (expiry
|
||||||
locally, bounded by `cache_ttl`); A serves unrelated cached responses
|
enforced locally, bounded by `cache_ttl`); A serves unrelated cached
|
||||||
with gaps wherever the cache was cold.
|
responses with gaps wherever the cache was cold.
|
||||||
- **Multi-domain uniformity.** D is a property of a domain in the
|
- **Multi-domain uniformity.** Remote backing is a property of a domain
|
||||||
existing registry; local and remote rp-ids coexist in one instance. A
|
in the existing registry; local and remote rp-ids coexist in one
|
||||||
is a separate component bolted in front of specific URLs.
|
instance. A is a separate component bolted in front of specific URLs.
|
||||||
- **User simplicity.** D is configured once in domain config; A needs
|
- **User simplicity.** Configured once in domain config; A needs
|
||||||
deployment and cache-key discipline per frontend application.
|
deployment and cache-key discipline per frontend application.
|
||||||
|
|
||||||
## What D costs
|
## What it costs
|
||||||
|
|
||||||
- A store-explicitness refactor in the security-critical read path
|
- The read path must be honest about which DB it reads: `DB.session_ctx`
|
||||||
(`DB.session_ctx` self-contained; explicit `store` parameters) — small
|
and `/check` were rewritten to use their own tables instead of struct
|
||||||
but review-worthy. (The first draft's contextvar-dependent `db.data()`
|
convenience properties that reach the global database. (A first draft's
|
||||||
was rejected: a global accessor whose meaning shifts under the caller.)
|
contextvar-dependent `db.data()` was rejected: a global accessor whose
|
||||||
- A versioned sync protocol (snapshot + sequenced events + ring-buffer
|
meaning shifts under the caller. Dispatch is instead keyed explicitly
|
||||||
replay + reconnect reconciliation).
|
by the request host.)
|
||||||
|
- A sync protocol (snapshot + live events + reconnect reconciliation).
|
||||||
- A trusted satellite host (full data copy in RAM).
|
- A trusted satellite host (full data copy in RAM).
|
||||||
- Additive remote code (sync endpoint + commit hooks), where A needs
|
- Additive remote code (sync endpoint + commit hooks), where A needs
|
||||||
none.
|
none.
|
||||||
- Replica housekeeping (expiry sweeper, write-behind ordering,
|
- Replica housekeeping (expiry sweeper, write-behind, optimistic
|
||||||
optimistic eviction vs. event confirmation).
|
eviction).
|
||||||
|
|
||||||
## Summary
|
## Summary
|
||||||
|
|
||||||
A(+B) is the right tool to "make forward-auth fast in front of an
|
A(+B) is the right tool to "make forward-auth fast in front of an
|
||||||
untouched server". D — implemented here — is the right tool when the
|
untouched server". The satellite — implemented here — is the right tool
|
||||||
satellite should *be* a paskia instance for its remote domains: one
|
when it should *be* a paskia instance for its remote domains: one
|
||||||
consistency model, correct answers for un-cached query shapes, graceful
|
consistency model, correct answers for un-cached query shapes, graceful
|
||||||
degradation, and per-domain mixing with local rp-ids, at the price of the
|
degradation, and per-domain mixing with local rp-ids, at the price of the
|
||||||
core refactor, the sync protocol, and a trusted satellite host.
|
read-path cleanup, the sync protocol, and a trusted satellite host.
|
||||||
|
|||||||
@@ -480,6 +480,7 @@ function createDomain() {
|
|||||||
origins: [],
|
origins: [],
|
||||||
originValidation: [],
|
originValidation: [],
|
||||||
wellKnownCheck: null,
|
wellKnownCheck: null,
|
||||||
|
remote: null,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -495,6 +496,8 @@ function openDomain(domain) {
|
|||||||
origins: rows.map(r => r.key),
|
origins: rows.map(r => r.key),
|
||||||
originValidation: rows.map(() => null),
|
originValidation: rows.map(() => null),
|
||||||
wellKnownCheck: null,
|
wellKnownCheck: null,
|
||||||
|
// The sync token is write-only: an empty field keeps the stored one
|
||||||
|
remote: domain.remote ? { ...domain.remote, token: '' } : null,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -923,9 +926,19 @@ async function submitDialog() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
closeDialog()
|
closeDialog()
|
||||||
|
// remote is replaced wholesale when present; null clears it, an
|
||||||
|
// absent key (create without remote) leaves it unset.
|
||||||
|
const remote = d.remote?.url?.trim()
|
||||||
|
? {
|
||||||
|
url: d.remote.url.trim().replace(/\/+$/, ''),
|
||||||
|
token: d.remote.token || '',
|
||||||
|
cache_ttl: Number(d.remote.cache_ttl) || 60,
|
||||||
|
refresh_interval: Number(d.remote.refresh_interval) || 300,
|
||||||
|
}
|
||||||
|
: null
|
||||||
const req = d.isNew
|
const req = d.isNew
|
||||||
? apiJson('/auth/api/admin/domains/', { method: 'POST', body: { rp_id, rp_name, origins } })
|
? apiJson('/auth/api/admin/domains/', { method: 'POST', body: { rp_id, rp_name, origins, ...(remote ? { remote } : {}) } })
|
||||||
: apiJson(`/auth/api/admin/domains/${rp_id}`, { method: 'PATCH', body: { rp_name, origins } })
|
: apiJson(`/auth/api/admin/domains/${rp_id}`, { method: 'PATCH', body: { rp_name, origins, remote } })
|
||||||
req
|
req
|
||||||
.then(() => {
|
.then(() => {
|
||||||
authStore.showMessage(`Domain "${rp_id}" ${d.isNew ? 'created' : 'updated'}.`, 'success', 2500)
|
authStore.showMessage(`Domain "${rp_id}" ${d.isNew ? 'created' : 'updated'}.`, 'success', 2500)
|
||||||
|
|||||||
@@ -464,6 +464,7 @@ defineExpose({ focusFirstElement })
|
|||||||
</div>
|
</div>
|
||||||
<div class="perm-id-info">
|
<div class="perm-id-info">
|
||||||
<span class="id-text">{{ domain.rp_id }}</span>
|
<span class="id-text">{{ domain.rp_id }}</span>
|
||||||
|
<span v-if="domain.remote" class="id-text" :title="`Served from remote ${domain.remote.url} (satellite mode)`">🛰 {{ domain.remote.url }}</span>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</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="domain-origins"><span v-for="(e, i) in originDisplayEntries(domain)" :key="e.key">{{ i ? ', ' : '' }}{{ e.key }}{{ e.auth ? '🔑' : '' }}{{ e.related ? '🔗' : '' }}</span></td>
|
||||||
|
|||||||
@@ -18,6 +18,33 @@ const title = computed(() =>
|
|||||||
// compares against it, and hosts are case-insensitive)
|
// compares against it, and hosts are case-insensitive)
|
||||||
const dialogRpId = computed(() => (props.dialog.data?.rp_id || '').trim().toLowerCase())
|
const dialogRpId = computed(() => (props.dialog.data?.rp_id || '').trim().toLowerCase())
|
||||||
|
|
||||||
|
// --- Remote (satellite) backing ---
|
||||||
|
//
|
||||||
|
// 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.
|
||||||
|
const remoteEnabled = computed({
|
||||||
|
get: () => !!props.dialog.data?.remote,
|
||||||
|
set: on => {
|
||||||
|
const d = props.dialog.data
|
||||||
|
if (!d) return
|
||||||
|
d.remote = on ? { url: '', token: '', cache_ttl: 60, refresh_interval: 300 } : null
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const remoteUrlInvalid = computed(() => {
|
||||||
|
const url = props.dialog.data?.remote?.url?.trim()
|
||||||
|
if (!url) return false
|
||||||
|
return !/^https?:\/\/[^\s/]+/.test(url)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Remote domains must mark an auth host (the server rejects the save)
|
||||||
|
const remoteMissingAuthHost = computed(
|
||||||
|
() => !!props.dialog.data?.remote && !props.dialog.data?.auth_host
|
||||||
|
)
|
||||||
|
|
||||||
// Block submit on hard errors: malformed entries, an over-cap related
|
// Block submit on hard errors: malformed entries, an over-cap related
|
||||||
// list (the server rejects the save), a save that would lock the admin
|
// 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.
|
// out of the domain they are using, or validation still in flight.
|
||||||
@@ -31,6 +58,8 @@ const isValidationInvalid = computed(() => {
|
|||||||
if (relatedEntries.value.length > 5) return true
|
if (relatedEntries.value.length > 5) return true
|
||||||
if (d.isNew && !isWellFormedDomain(d.rp_id || '')) return true
|
if (d.isNew && !isWellFormedDomain(d.rp_id || '')) return true
|
||||||
if (lockoutWarning.value) 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
|
return false
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -515,6 +544,33 @@ function onRemoveOrigin(i) {
|
|||||||
<p class="small muted">
|
<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>
|
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>
|
||||||
|
|
||||||
|
<div class="origin-label">
|
||||||
|
<label class="remote-toggle">
|
||||||
|
<input type="checkbox" v-model="remoteEnabled" />
|
||||||
|
Remote instance (satellite mode)
|
||||||
|
</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>
|
||||||
|
<p v-if="remoteUrlInvalid" class="small error">Must be an http(s) URL.</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>
|
||||||
|
<label>Staleness limit (cache TTL, seconds)
|
||||||
|
<input v-model.number="dialog.data.remote.cache_ttl" type="number" min="1" />
|
||||||
|
</label>
|
||||||
|
<label>Full re-sync interval (seconds)
|
||||||
|
<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).
|
||||||
|
</p>
|
||||||
|
</template>
|
||||||
</AdminDialog>
|
</AdminDialog>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -540,4 +596,7 @@ function onRemoveOrigin(i) {
|
|||||||
border-color: var(--color-error);
|
border-color: var(--color-error);
|
||||||
background: var(--color-error-bg, rgba(239, 68, 68, 0.05));
|
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; }
|
||||||
|
.remote-toggle input { width: auto; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -375,6 +375,39 @@ async def test_admin_configures_remote_domain(client, session_token, test_db):
|
|||||||
assert entry["remote"]["url"] == "http://remote.test"
|
assert entry["remote"]["url"] == "http://remote.test"
|
||||||
assert "token" not in entry["remote"] # write-only
|
assert "token" not in entry["remote"] # write-only
|
||||||
|
|
||||||
|
headers = {"Host": "localhost:4401", "Cookie": f"{AUTH_COOKIE_NAME}={session_token}"}
|
||||||
|
origins = {"**.example.com": True, "auth.example.com": {"auth_host": True}}
|
||||||
|
|
||||||
|
# PATCH without the remote key preserves it (and its token)
|
||||||
|
r = await client.patch(
|
||||||
|
"/auth/api/admin/domains/example.com",
|
||||||
|
json={"rp_name": "Ex", "origins": origins},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
assert stored.remote.url == "http://remote.test"
|
||||||
|
assert stored.remote.token == "sekret"
|
||||||
|
|
||||||
|
# PATCH with a new URL but no token keeps the stored token
|
||||||
|
r = await client.patch(
|
||||||
|
"/auth/api/admin/domains/example.com",
|
||||||
|
json={"rp_name": "Ex", "origins": origins,
|
||||||
|
"remote": {"url": "http://other.test", "cache_ttl": 30}},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
assert stored.remote.url == "http://other.test"
|
||||||
|
assert stored.remote.token == "sekret"
|
||||||
|
|
||||||
|
# PATCH with remote: null clears it
|
||||||
|
r = await client.patch(
|
||||||
|
"/auth/api/admin/domains/example.com",
|
||||||
|
json={"rp_name": "Ex", "origins": origins, "remote": None},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
assert stored.remote is None
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_admin_remote_domain_requires_auth_host(client, session_token):
|
async def test_admin_remote_domain_requires_auth_host(client, session_token):
|
||||||
|
|||||||
Reference in New Issue
Block a user