# 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` 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` 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?