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.
11 KiB
Remote Proxy / Satellite
Paskia can serve a configured domain (rp-id) from a remote paskia
instance instead of the local database, so latency-sensitive checks
(/auth/api/forward, /auth/api/validate) answer in ~1 ms even when the
auth server is on another continent. Example: app2.example.com runs on
our local host and needs fast local checks, while app1.example.com and
auth.example.com run far away — all sharing example.com as rp-id.
Client applications that used https://auth.example.com as their auth
backend (forward-auth checks) only repoint to the local satellite
(http://127.0.0.1:4401); both remain usable interchangeably, and the
satellite ultimately uses auth.example.com.
Status: implemented. The feature lives in paskia/satellite.py
(satellite side: replica, sync client, host dispatch, forwarding) and
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
Setup is a single paste. On the upstream (the authoritative instance), open the domain in the admin UI and use Satellite access → Generate sync URL. That mints a token scoped to the domain's rp-id and returns one URL carrying everything the satellite needs:
https://<token>@auth.example.com
On the satellite, open (or add) the same domain in its admin UI,
enable Remote instance, and paste the sync URL. The dialog probes the
upstream's /auth/api/settings as you type: it must be reachable, serve
this very rp-id authoritatively (a satellite cannot upstream another
satellite), and have an auth host configured — only such an instance can
serve satellites at all. Once the uplink verifies, the remote
configuration takes over completely: the domain's origins become
**.<rp-id> plus the upstream's auth host, and saving requires the
verified uplink.
In the stored config (DomainConfig.remote):
"remote": {
"url": "https://auth.example.com",
"token": "<sync token>",
"cache_ttl": 60,
"refresh_interval": 300
}
url— the upstream's auth host URL (the userinfo token is split off before storing). The satellite connects to{url}/auth/api/sync/ws; the connection is server-to-server and not host-dispatched, so internal addresses work.token— bearer token for the sync channel, stored hashed in the upstream's database and scoped to one rp-id. Managed in the upstream admin UI (the domain's Satellite access section: generate, list, revoke); the raw token is shown once, inside the sync URL. On the satellite it is write-only over the admin API (a sync URL without userinfo keeps the stored token). Revoking a token drops the satellite's live connections immediately.cache_ttl— seconds the replica remains trusted after the sync channel goes down; then checks fail closed (503). Set it large (up to the 24 h session lifetime) for fail-open behavior. Minimum 1.refresh_interval— seconds between reconnects; every connect starts from a full snapshot, which reconciles any drift. Minimum 30.
Each remote domain gets its own replica and its own token; several domains may point at the same upstream instance independently.
A remote domain must mark an auth host (the uplink's own — the dialog derives it): the profile, admin and sign-in pages live there, so browsers and WebSockets go directly to the upstream. Other domains on the same satellite remain fully local — the multi-domain config mixes both kinds freely.
How it works
Dispatch is keyed by host, and only this module knows about stores.
satellite.store_for_host(host) returns the local DB or the replica of
the remote backing the host's domain (raising 503 HTTPException when
the replica is unavailable). The session read path (session_ctx,
authz.verify, build_user_info, /check) just passes the host it
already has; writes dispatch likewise (satellite.refresh_session —
write-behind for remote, db.update_session for local;
satellite.evict_session on logout). satellite.forward_request(request)
returns the proxied response for remote domains or None for local ones.
The replica is a ReplicaDB instance in RAM, never persisted. On
connect the upstream sends a snapshot of the replicated tables
(the domain's credentials and sessions), then live upsert/delete events
emitted from the struct store()/delete() hooks (which also cover
cascade deletes) and field-mutating operations. The feed is scoped to the
token's rp-id, and the user graph never leaves the upstream: every session
upsert carries an embedded ctx — the same ApiSessionContext sent to
users — holding the session's user/org/role display data and effective
permissions, computed for the session's bound host. Mutations of users,
orgs, roles or permissions are not published; instead the upstream eagerly
re-pushes the affected sessions with freshly computed contexts, so renames
and permission revocations take effect within ~1 RTT. The replica refuses
to go live when the scope in the ready message does not match its domain.
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 | Handling |
|---|---|
GET /auth/api/forward, 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 |
GET /check, GET /user-info |
proxied — these need the user graph (per-session contexts do not cover arbitrary user lookups or full profile data) |
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 on the remote); the session arrives via sync event |
/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 |
Proxied calls answer 503 when the remote is unreachable. /auth/api/settings
reports remote: true for a satellite-served domain (this is what the admin
dialog's uplink probe checks). Should a remote domain's auth host be
misrouted to the satellite anyway, profile/avatar requests
(/auth/api/user/*) are forwarded to the remote rather than answered from
local storage — the satellite never takes writes for a remote domain.
Freshness hierarchy:
- Changes made through the satellite: immediate (write-behind, optimistic eviction).
- Changes made directly on the remote: a sync event, ~1 network RTT.
- Channel down: the replica stays authoritative until
cache_ttlpast the disconnect (dead-peer detection is bounded by the ~10 s keepalive), then 503. Every reconnect starts from a fresh snapshot.
The remote side
Strictly additive and RAM-only besides the token table: syncfeed (a
subscriber set fed by the commit hooks) and the /auth/api/sync/ws
endpoint serving snapshot + live events and accepting session_refresh
write-backs within the token's scope. With no satellites connected, the
hooks are a no-op.
Trust and caveats
- The satellite host holds only its own domain's sessions (with their
identity contexts) and credentials in RAM — it learns the users that
actually sign in to its domain, never the directory, and never the OIDC
key. Still treat it as trusted: it is authoritative for session checks
within
cache_ttl. - Avatars are stored on the remote's disk; profile data such as
user-infois proxied to the remote rather than answered locally. - OIDC sessions in a proxied
user-infoshow the client UUID rather than its name (OIDC clients are not replicated).
Design review (the rejected alternatives)
Option A — caching HTTP reverse proxy
A thin proxy caching /auth/api/forward, /check, /user-info,
/settings responses keyed by (Host, cookie, query) with
TTL = min(configured TTL, Remote-Session-Expires − now); everything
else forwarded verbatim, WebSockets tunneled, /logout intercepted for
eviction. Option B adds a remote change feed so eviction happens
within one RTT instead of at TTL.
- Remote changes: none for A; one additive endpoint for B.
- The proxy needs no credentials — requests are authenticated by the end user's cookie, forwarded on a miss.
What the read-only local state buys over the HTTP cache
- Full
SessionContextlocally. A replays the byte-response it once saw; the satellite computes the answer. Query combinations never seen before (newperm/max_age/publicshapes) are served locally but miss A's cache. The replica holds the domain model, so derived answers (effective permissions per host,max_ageagainstcredential.last_used,Remote-*composition) are correct without having been witnessed. - One invalidation model. A hand-builds invalidation rules per endpoint (query-key mapping, cookie re-keying on renew, 401 variants). Events mutate the replica (upsert/delete by table+key) and every endpoint becomes consistent at once — including future ones.
- Degradation behaves like a real instance. With the remote down, the
satellite serves a coherent auth service from the replica (expiry
enforced locally, bounded by
cache_ttl); A serves unrelated cached responses with gaps wherever the cache was cold. - Multi-domain uniformity. Remote backing is a property of a domain in the existing registry; local and remote rp-ids coexist in one instance. A is a separate component bolted in front of specific URLs.
- User simplicity. Configured once in domain config; A needs deployment and cache-key discipline per frontend application.
What it costs
- The read path must be honest about which DB it reads:
DB.session_ctxand/checkwere rewritten to use their own tables instead of struct convenience properties that reach the global database. (A first draft's contextvar-dependentdb.data()was rejected: a global accessor whose meaning shifts under the caller. Dispatch is instead keyed explicitly by the request host.) - A sync protocol (snapshot + live events + reconnect reconciliation).
- A trusted satellite host (its domain's sessions and credentials in RAM).
- Additive remote code (sync endpoint + commit hooks), where A needs none.
- Replica housekeeping (expiry sweeper, write-behind, optimistic eviction).
Summary
A(+B) is the right tool to "make forward-auth fast in front of an untouched server". The satellite — implemented here — is the right tool when it should be a paskia instance for its remote domains: one consistency model, correct answers for un-cached query shapes, graceful degradation, and per-domain mixing with local rp-ids, at the price of the read-path cleanup, the sync protocol, and a trusted satellite host.