Files
paskia/docs/RemoteProxy.md
T
LeoVasanko 2209e2395c 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.
2026-10-01 05:40:32 +00:00

11 KiB
Raw Blame History

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:

  1. Changes made through the satellite: immediate (write-behind, optimistic eviction).
  2. Changes made directly on the remote: a sync event, ~1 network RTT.
  3. Channel down: the replica stays authoritative until cache_ttl past 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-info is proxied to the remote rather than answered locally.
  • OIDC sessions in a proxied user-info show 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 SessionContext locally. A replays the byte-response it once saw; the satellite computes the answer. Query combinations never seen before (new perm/max_age/public shapes) are served locally but miss A's cache. The replica holds the domain model, so derived answers (effective permissions per host, max_age against credential.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_ctx and /check were rewritten to use their own tables instead of struct convenience properties that reach the global database. (A first draft's contextvar-dependent db.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.