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.
253 lines
13 KiB
Markdown
253 lines
13 KiB
Markdown
# 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:
|
||
|
||
```json
|
||
"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.
|