Files
rustification-server/docs/realtime/issues/07-verbindungsaufbau-und-auth.md
MDietermann 55a2af4756 docs(realtime): record the design decisions behind the realtime layer
Eleven resolved decision tickets plus two research notes, produced with
/wayfinder before any code was written. They live next to the code they explain
rather than in the issue tracker: they are reasoning documents, not open work,
and in a tracker they would drift from the code with no diff and no review.

The value is in the rejected alternatives. Why a counter under a row lock and
not BIGSERIAL, why an event log and not a column on messages, why resync is a
200 with a discriminated body and not a 4xx, why the fan-out trait was deleted
again, why voice is peer-to-peer with a hard six-person cap and no speculative
SFU seam, and why Linux needs a native media stack while the other three
platforms do not.

Two entries record where the spec was wrong and got corrected: created_at was
never a safe sort key, and an unread count cannot be derived without fetching
because seq counts events rather than messages.

Open work is tracked as issues, not here.
2026-08-14 19:04:41 +02:00

208 lines
12 KiB
Markdown

# Verbindungsaufbau & Auth über WS
Type: grilling
Status: resolved
Blocked by: —
Parent: ../map.md
## Question
Wie authentifiziert sich eine WebSocket-Verbindung, wie bleibt sie am Leben, und was
passiert, wenn das Token abläuft, während sie steht?
Die Faktenlage liegt vor: [*Recherche: WS-Auth, Heartbeat und Reconnect*](03-research-ws-auth-reconnect.md)
(**entblockt am 14.08.2026**), Volltext in
[`../research/03-ws-auth-reconnect.md`](../research/03-ws-auth-reconnect.md). Dieses Ticket
entscheidet.
### Vier Befunde, die die Optionen bereits verschoben haben
1. **Kein Transportweg schützt vor dem Betreiber** — der Server muss das Token sehen. Der
Unterschied ist ausschließlich, was den Handshake in Logs, Backups und APM-Systemen
**überlebt**. In den Defaults von nginx, Apache und Caddy landet der Query-String im
Access-Log; Caddy redigiert `Authorization` aktiv, `request.uri` nicht.
2. **`Sec-WebSocket-Protocol` ist schlechter als erwartet** — Caddy loggt per Default alle
Header außer der Redaktionsliste, und dieser gehört nicht dazu. Dazu: `=` ist als
Separator verboten (JWT-Base64-Padding), und der Server muss das Subprotokoll echoen.
3. **Die Browser-API verschluckt alle Fehlerdetails** (WHATWG-Vorgabe): im WebView ist
„Token abgelaufen" nicht von „Server down" unterscheidbar — immer `CloseEvent.code === 1006`.
`tungstenite::Error::Http` trägt dagegen die vollständige Response. Wenn die Verbindung im
Rust-Layer lebt, ist `Authorization: Bearer` möglich **und** die Fehlerdiagnose brauchbar;
beides zusammen macht die Entscheidung in
[*Wo lebt die WS-Verbindung im Client*](05-wo-lebt-die-ws-verbindung.md) fast zur Vorentscheidung
für dieses Ticket.
4. **Verworfen, nicht übernehmen:** Referrer-Leak und Browser-History als Argumente gegen den
Query-String ließen sich für `new WebSocket(url)` **nicht** belegen. Belegbar bleiben
Access-Logs, Log-Aggregation und deren Backups — das reicht als Argument.
### Zusätzlich zu entscheiden: Graceful Shutdown selbst bauen
`axum::serve(...).with_graceful_shutdown(...)` **beendet WebSocket-Verbindungen nicht und
wartet nicht auf sie** — vierfach belegt (hyper-util-Doku „does nothing" nach dem Hijack,
axum-Issue #3003 offen seit 21.10.2024, PR #3831 offen seit 11.07.2026, und der `close_rx`-Guard
hängt nur an der hyper-Task). Solange #3831 nicht gemerged ist, muss das Projekt WS-Verbindungen
selbst tracken. Belegte Bausteine: `CancellationToken` (ein Child-Token pro Verbindung) plus
`TaskTracker` (auf N Tasks warten). Beim Shutdown einen Close-Frame senden — `WebSocket::close`
existiert seit axum 0.8 nicht mehr, der Frame geht explizit über `send`; Code 1001 „going away"
oder 1012 „service restart".
Weitere belegte Randbedingungen: `middleware::from_fn_with_state` **greift** beim
Upgrade-Request (401/403 regulär sendbar); Protokoll-Ping ist aus dem WebView **nicht auslösbar**;
dokumentierter Proxy-Idle-Korridor: Azure AppGW 20 s, GCP 30 s, nginx 60 s (die verbreiteten
Cloudflare- und HAProxy-Werte sind Mythen und ließen sich nicht belegen).
**Bestandsaufnahme:** `axum = "0.8.9"` ist **ohne `ws`-Feature** eingebunden, es gibt keinen
Graceful Shutdown, und `Extension<Claims>` wird nirgends gelesen.
---
## Answer
Entschieden am 14.08.2026. Bestand: Access-Token 15 Minuten (`auth.rs:22`), Refresh-Token
30 Tage, gehasht in `auth_tokens` und löschbar — Widerruf existiert bereits auf Refresh-Ebene.
### 1. Handshake: `Authorization: Bearer` (abgeleitet)
Folgt aus [*Wo lebt die WS-Verbindung im Client*](05-wo-lebt-die-ws-verbindung.md): die
Verbindung lebt in Rust, eigene Header sind also möglich. Laut Recherche der einzige Kanal,
den die Defaults von nginx, Apache und Caddy nicht mitschreiben.
`middleware::from_fn_with_state` greift beim Upgrade-Request, ein 401 mit Body ist regulär
sendbar — und `tungstenite::Error::Http` trägt ihn vollständig zum Client.
### 2. Token-Ablauf: In-Band-Re-Auth über den offenen Socket
Der Client erneuert sein Access-Token wie bisher per REST und schiebt es als Frame über den
bestehenden Socket; der Server prüft und verlängert. Kein Reconnect, keine Handshake-Welle,
kein erneuter Manifest-Abruf — bei 15 Minuten Tokenlebensdauer und drei verbundenen Servern
wären das sonst zwölf Reconnects pro Stunde.
Der Socket ist für Voice-Signaling ohnehin bidirektional; dies ist damit der **zweite**
client→server-Frame-Typ, nicht der erste.
**Widerruf wirkt trotzdem:** schlägt die REST-Erneuerung fehl, weil das Refresh-Token
gelöscht wurde, kann der Client nicht verlängern, und der Server schließt bei Ablauf mit
`4001`. Die Wirkungsverzögerung ist damit auf die Restlaufzeit des Access-Tokens begrenzt,
also höchstens 15 Minuten — dieselbe Schranke wie bei einer Verbindungs-TTL.
Verworfen: eine serverseitige Widerrufsliste. Sie machte Widerruf sofort wirksam, kostet aber
geteilten Zustand über Instanzen hinweg plus eine Prüfung je Event — und gegen den Betreiber
als Angreifer wirkt sie per Definition gar nicht, weil er die Liste kontrolliert.
### 3. Autorisierung: Registry ist maßgeblich, REST prüft je Anfrage
Die Zustellung folgt der Registry aus [*Fan-out-Architektur*](08-fan-out-architektur.md). Die
**Sicherheitsgrenze liegt bei den REST-Endpunkten** — Manifest, Backfill und Historie prüfen
Mitgliedschaft bei jeder Anfrage.
Das trägt, weil der Event nur `{conversation, seq}` enthält und **keinen Inhalt**. Eine
Abweichung der Registry von der Datenbank leckt folglich nur Metadaten („Conversation X war
um 14:03 aktiv"), niemals Inhalte — und Metadaten gegenüber dem Server hat die Map
ausdrücklich als akzeptabel eingestuft.
Verworfen: eine Datenbankprüfung je Event (bei einem Channel mit fünfhundert Mitgliedern
fünfhundert Abfragen für eine Nachricht, zusätzlich zum Backfill, der ohnehin prüft) und
periodische Neuvalidierung (ein weiterer Mechanismus ohne nachgewiesenes Driftproblem —
bleibt als billiges Sicherheitsnetz in der Hinterhand).
### 4. Heartbeat: der Client pingt alle 15 Sekunden
Protokoll-Ping, kein Application-Frame — im WebView wäre das nicht möglich, in Rust schon.
Der Server antwortet (tungstenite automatisch) und führt `last_seen`; nach zwei ausbleibenden
Intervallen räumt er die Registry auf. Der Client erkennt eine tote Verbindung am
ausbleibenden Pong. **Eine Richtung bedient beide Bedürfnisse.**
Der Client treibt, weil er hinter NAT und Proxy sitzt und sein Intervall situativ anpassen
kann — im Hintergrund oder bei schlechtem Funk seltener, ohne dass der Server davon wissen
muss. Bei N verbundenen Servern kann er die Pings zeitlich verteilen statt sie zu bündeln.
**15 Sekunden**, um unter dem striktesten dokumentierten Proxy-Idle-Timeout der Recherche zu
bleiben (Azure Application Gateway, 20 s). Konfigurierbar — die meisten Aufstellungen sind
nginx mit 60 s. Die verbreiteten Cloudflare- und HAProxy-Werte ließen sich laut Recherche
nicht belegen und sind nicht Grundlage dieser Wahl.
### 5. Verbindungslimit: acht je Nutzer, die älteste weicht
Bei Überschreitung wird die **älteste** Verbindung mit `4002` getrennt, nicht die neue
abgewiesen. Das ist der wichtige Teil: ein Gerät mit hängengebliebener Verbindung könnte sich
sonst selbst aussperren — und genau das passiert auf Mobile regelmäßig, wenn das
Betriebssystem eine App abräumt, ohne dass der Socket sauber schließt.
Acht ist großzügig gegenüber dem legitimen Fall (Desktop, Handy, Tablet, Zweitrechner) und
trotzdem eine harte Obergrenze. Gezählt wird in der `user_id`-Registry, die es seit
Ticket 08 ohnehin gibt.
Ein Rate-Limit auf Handshakes wurde erwogen und **nicht** jetzt entschieden: es trifft ein
anderes Missbrauchsmuster, ist aber ohne Betriebserfahrung nicht kalibrierbar — ein
Reconnect-Sturm nach einem Server-Neustart sieht genauso aus wie ein Angriff. Bleibt in der
Fog-Notiz zum Rate-Limiting.
### 6. Close-Codes und Reconnect-Politik
Drei Handlungsklassen im Client:
| Code | Bedeutung | Klasse |
|---|---|---|
| `1012` | Server-Neustart | **sofort neu verbinden**, mit Jitter |
| `4001` | Token abgelaufen, keine gültige Re-Auth | Token erneuern, dann sofort neu verbinden; scheitert die Erneuerung → Anmeldung |
| `4002` | durch neuere Verbindung verdrängt | **nicht** neu verbinden |
| `4003` | Konto gesperrt oder gelöscht | **nicht** neu verbinden, zur Anmeldung |
| `1001`, `1006`, alles Übrige | Netzfehler, unbekannt | mit Backoff neu verbinden |
`4002` ist der Grund, warum es diese Tabelle überhaupt gibt: ohne ein „nicht neu verbinden"
verdrängt ein verdrängtes Gerät sofort das nächste, und bei mehr aktiven Geräten als erlaubt
entsteht ein Kreisverkehr.
Der Nutzen reicht über den Reconnect hinaus: der Client muss je Server einen aussagekräftigen
Zustand anzeigen, und „Anmeldung abgelaufen" von „Server nicht erreichbar" zu unterscheiden
geht nur so.
**Backoff (abgeleitet):** Full Jitter nach dem in der Recherche belegten Muster —
`warte = random(0, min(30 s, 0,5 s · 2^Versuch))`. Der Zufallsanteil ist nicht optional; ohne
ihn kehren nach einem Neustart alle Clients gleichzeitig zurück.
### 7. Graceful Shutdown selbst bauen (abgeleitet)
Vierfach belegt in der Recherche: `axum::serve(...).with_graceful_shutdown(...)` beendet
WebSocket-Verbindungen nicht und wartet nicht auf sie (axum-Issue #3003 offen seit
21.10.2024, PR #3831 offen seit 11.07.2026). Bausteine:
- Ein `CancellationToken` je Verbindung, abgeleitet vom globalen Shutdown-Token.
- Ein `TaskTracker`, um beim Herunterfahren auf die Verbindungstasks zu warten.
- Beim Shutdown Close-Frame mit `1012` senden — `WebSocket::close` gibt es seit axum 0.8
nicht mehr, der Frame geht explizit über `send`.
- `broadcast::Receiver::recv` und `CancellationToken::cancelled` sind dokumentiert cancel-safe;
für `SplitStream::next()` fehlt eine explizite Zusage, weshalb die axum-Beispiele
durchweg in zwei Tasks aufteilen statt über den Socket-Stream zu selektieren.
Sobald PR #3831 gemerged ist, kann das eigene Tracking entfallen — bis dahin ist es zwingend.
### Nebenbefund
`axum = "0.8.9"` ist heute **ohne das `ws`-Feature** eingebunden; es fehlt also die
Voraussetzung überhaupt. Und `Extension<Claims>` wird nirgends gelesen — die Middleware
authentifiziert, aber kein Handler nutzt die Identität. Beides gehört in die Umsetzung.
### Zu entscheiden
- **Handshake-Auth.** Token im Query-String ist bei diesem Bedrohungsmodell heikel
(Server-Logs, Proxy-Logs, Browser-History). Welcher Weg wird es? Falls die Verbindung
im Rust-Layer lebt (siehe *Wo lebt die WS-Verbindung im Client*), ist ein
`Authorization`-Header möglich — dann ist die Frage fast beantwortet.
- **Token-Ablauf bei laufender Verbindung.** Verbindungs-TTL mit erzwungenem Reconnect,
In-Band-Re-Auth, oder Revocation-Prüfung. Wichtig: eine Verbindung darf einen
**Token-Widerruf nicht überleben** — bei „Nutzersicherheit als Erfolgsfaktor" ist eine
Sitzung, die nach dem Logout weiterläuft, ein echter Fehler, kein Schönheitsfleck.
- **Autorisierung.** Der Nutzer darf nur Events für Conversations sehen, in denen er
Mitglied ist. Wird die Mitgliedschaft beim Verbindungsaufbau einmal geladen oder pro
Event geprüft? Was passiert, wenn er während der Verbindung aus einer Conversation
entfernt wird — oder hinzugefügt?
- **Heartbeat.** Protokoll-Ping/Pong oder Application-Level, mit welchen Intervallen.
Zielgruppe Firmen heißt: hinter Proxies, die Idle-Verbindungen abräumen.
- **Reconnect-Parameter.** Backoff-Kurve und Jitter, konkret. Was passiert beim
Server-Neustart, wenn alle Clients gleichzeitig zurückkommen?
- **Verbindungslimits.** Wieviele Verbindungen pro Nutzer sind erlaubt? Multi-Device ist
gefordert (Desktop + Mobile parallel), also mehr als eine — aber nicht unbegrenzt,
sonst ist es ein Vektor auf einem öffentlichen Server.
- **Sauberes Herunterfahren.** Wie werden offene Verbindungen beim Server-Shutdown
beendet, sodass Clients einen geordneten Reconnect machen statt in Timeouts zu laufen?