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

12 KiB

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 (entblockt am 14.08.2026), Volltext in ../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 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: 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. 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?