Files
paskia/docs/RemoteProxy.md
LeoVasanko 05ea188ac9 Docs: RemoteProxy.md for the link model
Configuration is one copy-paste of a wss link URL (Satellites panel or
the paskia satellite CLI), registration is the online 3-way handshake,
scope is adjustable post-registration, the upstream is authoritative for
pushed domains, revocation unlinks both sides.
2026-10-01 06:16:50 +00:00

13 KiB
Raw Permalink Blame History

Remote Proxy / Satellite

Paskia can serve configured domains (rp-ids) 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: uplinks, replicas, host dispatch, forwarding) and paskia/syncfeed.py + paskia/fastapi/sync.py (upstream side: change feed, registration handshake, sync WebSocket). The design review comparing the rejected alternatives is at the end of this document.

Configuration

Setup is a single copy-paste of a link URL, and everything else flows from it. On the upstream (the authoritative instance), open the admin Satellites panel and use New satellite link, choosing the domains the satellite will serve. That mints a registration offer and shows one URL:

wss://<initial-key>@auth.example.com

The wss:// scheme is deliberate: the URL is meant only for a satellite to connect to, and browsers refuse to open it. The initial key is held in the upstream's RAM only, expires in 10 minutes, and is single-use — losing or leaking it just means minting a new one. The permanent credentials are created during registration (see How it works).

On the satellite, either:

  • paste the URL in its admin Satellites panel under Uplinks → Add uplink — registration runs immediately and reports failures inline; or
  • run paskia satellite <url> on the satellite host — the whole setup of a satellite-only installation (it creates the database on first run; no local domain or admin site is needed).

That's all: the upstream pushes the link's domain set — rp-id, display name and the origins table verbatim, including its auth host — and the satellite persists it. The upstream is authoritative: pushed domains are validated against the satellite's local configuration (an rp-id already served locally, or origins colliding with it, are rejected and reported back into the upstream's link list), and remote domains are read-only on the satellite — the only satellite-side configuration is linking and unlinking.

A link's scope is adjustable at any time from the upstream's link list (edit which domains it serves): the new set is pushed over the live link — register once, no new cryptographic setup. Revoking a link drops the satellite's connections; the satellite observes the policy close and unlinks its side (its copy of the domains is removed).

A satellite-only instance serves its backend apps on local addresses and needs no inbound connectivity at all — the link is an outbound WebSocket from the satellite.

In the stored config, a remote domain carries only the link reference:

"remote": { "link": "<uplink id>" }

and the uplink itself lives in the database's uplinks table (the upstream URL, the shared key bytes from the handshake, and the local tolerance knobs):

  • cache_ttl (default 60) — 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.
  • refresh_interval (default 300) — seconds between reconnects; every connect starts from a full snapshot, which reconciles any drift.

On the upstream, links live in the satellite_links table: the peer's key bytes, its self-reported label, and the scope (rp_ids).

How it works

Registration and the sync channel share one endpoint — {upstream}/auth/api/sync/ws — multiplexed by the first binary frame. Registration is a 3-way dialogue over WebSocket binary frames:

satellite → upstream  hello    {link: null, key: <initial key>, label}
upstream  → satellite  welcome  {link, key: <server part>, domains: [...]}
satellite → upstream  ready    {key: <client part>, rejected: {...}}

The permanent link credential is created during the handshake from key parts both sides contribute; both sides then store the link in their databases. (Cryptography is currently a placeholder: the parts are exchanged as-is. The final version derives the channel key from the handshake without sending key material, and the JSON frames that follow are transparently encrypted.)

An established link runs the same dialogue with hello {link, key} proving the stored credential; the welcome re-pushes the link's domain set (the scope may have changed while the satellite was offline), the ready is a bare ack carrying any domain rejections. After the handshake the channel switches to plain JSON frames: a snapshot of the scoped tables, ready, then live upsert/delete events; the satellite sends session_refresh write-backs. A live domains frame pushes scope or origin changes; the satellite applies and acknowledges.

Dispatch is keyed by host, and only the satellite module knows about stores. satellite.store_for_host(host) returns the local DB or the replica of the uplink backing the host's domain (raising 503 HTTPException when the replica is unavailable). The session read path (session_ctx, authz.verify, build_user_info) 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, one per uplink connection. On connect the upstream sends a snapshot of the scoped tables (the link's domains' 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 link's rp-ids, 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. 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 upstream (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 upstream); the session arrives via sync event
/auth/oidc/* proxied (signing key and OIDC sessions stay on the upstream)
/auth/ws/*, /auth/remote-auth/*, admin, profile not served — the auth host requirement means these are reached on the upstream directly

Proxied calls answer 503 when the upstream is unreachable. /auth/api/settings reports remote: true for a satellite-served domain. Should a remote domain's auth host be misrouted to the satellite anyway, profile/avatar requests (/auth/api/user/*) are forwarded to the upstream 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 upstream: 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 upstream side

Nearly additive: the link table, RAM-only registration offers and the syncfeed subscriber set fed by the commit hooks, plus the /auth/api/sync/ws endpoint doing the handshake, serving snapshots and live events, pushing domain configuration, and accepting session_refresh write-backs within the link's scope. With no satellites connected, the hooks are a no-op.

Trust and caveats

  • The satellite host holds only its link's domains' sessions (with their identity contexts) and credentials in RAM — it learns the users that actually sign in to those domains, 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 upstream's disk; profile data such as user-info is proxied to the upstream 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. One link registration per satellite; 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 (registration handshake, snapshot + live events + reconnect reconciliation, live domain pushes).
  • A trusted satellite host (its link's sessions and credentials in RAM).
  • Additive upstream 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.