Repository navigation
Implement connection migration and session resumption with zero-downtime client handoff, encrypted session state transfer, and consistent client identity across gateway instances #258
Description
Difficulty
10/10 — Expert. Estimated effort: 6–8 days for a senior engineer.
Context
Issue 11 (distributed room state) enables multiple gateway instances to share room membership and broadcast across instances. However, it does not solve connection migration: when a client's WebSocket connection is terminated (load balancer rebalance, instance scale-down, network glitch, TLS cert rotation) and the client reconnects to a different gateway instance, the new instance must instantly restore the client's full session state — room subscriptions, sequence numbers, ACK state (issue 16), geofence inside-set (issue 12), rate limit counters, and protocol version — without the client re-sending join_room for each room or losing its place in the message stream.
The README.md targets "mobile or web-based tracking platforms" (line 23) where clients frequently switch networks (WiFi → cellular, tower handoff) and reconnect. Kubernetes HPA scales gateway pods up/down, causing connection draining and rebalancing. Without session resumption, each reconnection causes: (1) client re-joins all rooms (N round-trips), (2) replay from last known seq (may have gaps if client didn't persist), (3) rate limit counters reset (bursts allowed), (4) geofence state lost (re-evaluation from scratch).
Problem statement
Design and implement a session resumption protocol that enables zero-downtime client handoff between gateway instances:
-
Encrypted session state blob: On each significant state change (room join/leave, sequence advance, ACK update, geofence state change, rate limit window shift), the gateway serializes the client's session state and stores it in Redis with TTL (default 1 hour). State includes:
clientId,protocolVersion,authIdentity(JWT claims or mTLS deviceId)rooms:[{ roomId, highestAckedSeq, highestReceivedSeq, geofenceInsideSet: [...] }]rateLimitState:{ messageWindow: [...], connectionWindow: [...] }metadata:{ ip, userAgent, connectedAt, lastActivityAt }- Encrypted with AEAD (AES-256-GCM) using a per-deployment key (
SESSION_ENCRYPTION_KEYenv var, 32 bytes base64). Key rotation supported via key ID prefix in blob.
-
Session resumption handshake: On WebSocket connection, client sends
?token=...&session_id=<blob>(orsession_idin JWTsidclaim). Server:- Decrypts blob, verifies integrity, checks TTL.
- Validates
clientIdmatches auth identity (JWTsubor mTLS deviceId). - Restores
RoomManagermemberships locally (callsjoin()for each room with restoredhighestAckedSeq). - Restores rate limiter windows.
- Responds with
{ type: "session_resumed", rooms: [...], currentSeqPerRoom: [...] }. - Client compares
currentSeqPerRoomwith its localhighestAckedSeq— if gap, sendsreconnectfor each room.
-
Proactive session push (optional optimization): Before draining a connection (issue 5 graceful shutdown, or instance scale-down), the gateway pushes the latest session state to Redis. Client receives
server_shutting_downwithsession_id— can immediately reconnect to another instance with full state. -
Session affinity via load balancer cookie: For clients that don't support
session_id(legacy), use a sticky session cookie (GW_AFFINITY=<instanceId>) so reconnections land on the same instance. Instance reads local session cache (in-memory Map) instead of Redis. -
Cross-instance session migration API (admin):
POST /admin/v1/clients/{clientId}/migrate— forces session state sync to Redis, then disconnects client withsession_idin close frame reason. Client reconnects (load balancer routes to new instance) and resumes. -
Security: Session blob MUST be encrypted. If decryption fails (key rotation, corruption, tampering), fall back to full re-authentication (treat as new connection). No sensitive data (JWT, coordinates) in plaintext in Redis.
-
Metrics:
session_resumption_total{result="success|decrypt_failed|expired|mismatch|new_session"},session_state_size_bytes.
Current behavior
- Issue 11: distributed room state syncs membership via Redis, but no per-client session blob.
server.js: on connect, generates newclientId(uuid), no session restoration.room-manager.js:join()creates new membership, nohighestAckedSeqrestoration.- Rate limiters: fresh windows on each connection.
- No
session_idconcept in protocol.
Required behavior
- New module
src/session-manager.jsexportingSessionManagerclass. SessionManagerconstructor:{ redis, encryptionKey, ttlMs: 3600000, keyId: "v1" }.sessionManager.save(clientId, state)— encrypts, stores in Redissession:{clientId}with TTL.sessionManager.load(sessionId)— decrypts, returns state or null.sessionManager.delete(clientId)— removes from Redis (on explicit logout).- Encryption:
crypto.subtle(Web Crypto API) ornode:cryptocreateCipheriv/createDecipherivwith AES-256-GCM. Key derived fromSESSION_ENCRYPTION_KEYvia HKDF if needed. server.jsconnection handler:- Extract
session_idfrom query or JWTsid. - If present:
state = await sessionManager.load(session_id). If valid: restore. - Else: check sticky cookie
GW_AFFINITY. If matches local instance: load from local cache. - Else: new session.
- Extract
- On state change (join, leave, broadcast ACK, geofence change, rate limit update): debounce
sessionManager.save()(max once per 500ms per client). - On graceful shutdown (issue 5):
sessionManager.save()for all clients before closing connections. validator.js(issue 13): addsession_resumedserver-to-client message type.- Admin API (issue 14):
POST /admin/v1/clients/{clientId}/migratetriggers save + disconnect.
Constraints
- Do not modify
auth.js,validator.js,rate-limiter.js,conn-rate-limiter.js,logger.js,errors.js,room-manager.js,geofence-engine.js,protocol-registry.js,distributed-room-manager.js,tls-manager.js,admin-server.js. - Do not modify existing test files. New test files required.
- Use only Node.js built-in
cryptofor encryption. No new npm dependencies. - Redis key TTL must be refreshed on each save (sliding window).
- Session blob size must be < 16KB (Redis limit for reasonable performance). Compress with
zlib.deflateSyncbefore encryption if needed. - Debounced save: use a
Map<clientId, NodeJS.Timeout>to coalesce rapid state changes. - The
SessionManagermust work in single-instance mode (no Redis) — falls back to in-memory Map with TTL cleanup. - Client identity binding:
state.authIdentitymust match current connection's auth (JWTsubor mTLS deviceId). If mismatch → reject session, force new auth. - Key rotation:
SESSION_ENCRYPTION_KEYcan be a JSON object{ "v1": "base64key1", "v2": "base64key2" }. Blob format:v1.<base64iv>.<base64ciphertext>.<base64tag>. On load, try each key until one works.
Acceptance criteria
-
SessionManagerencrypts/decrypts session blobs with AES-256-GCM - Client connects with valid
session_id→session_resumedreceived with correct room list and sequence numbers - Client connects with expired
session_id(TTL exceeded) → treated as new session - Client connects with corrupted
session_id→ treated as new session - Client connects with
session_idfor differentclientId(identity mismatch) → rejected, new session - Room join/leave triggers debounced session save (verify Redis updated within 1s)
- Graceful shutdown saves all sessions before closing connections
- Sticky cookie fallback: client with
GW_AFFINITY=instance-1reconnects to instance-1 → local cache restore (no Redis round-trip) - Admin migrate API:
POST /admin/v1/clients/{clientId}/migrate→ session saved, client disconnected withsession_idin close reason - Key rotation: add
v2key, new sessions usev2, oldv1sessions still load - Metrics:
session_resumption_totalcounters increment correctly - Session blob size < 16KB for 50 rooms with full state
-
npm run lintpasses - All existing tests pass
- New test file:
tests/session-manager.test.jswith unit tests for encryption, save/load, debouncing - New test file:
tests/session-resumption-integration.test.jswith full handshake scenarios (requires Redis)
Out of scope
- Client-side session persistence (localStorage, IndexedDB) — server manages state.
- Session sharing across different deployments (multi-region) — single Redis cluster assumed.
- Session revocation list (for logout everywhere) —
delete(clientId)is sufficient. - WebSocket session resumption (RFC 8441) — application-layer only.
Hints and references
- AES-256-GCM with Node.js
crypto:Decrypt: parse blob,const iv = crypto.randomBytes(12); const cipher = crypto.createCipheriv('aes-256-gcm', key, iv); const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]); const tag = cipher.getAuthTag(); const blob = `v1.${iv.toString('base64')}.${ciphertext.toString('base64')}.${tag.toString('base64')}`;
createDecipheriv,setAuthTag,update+final. - HKDF for key derivation:
crypto.hkdfSync('sha256', masterKey, '', 'session-key', 32). - Debounce pattern:
const timers = new Map(); function debouncedSave(clientId, state) { const existing = timers.get(clientId); if (existing) clearTimeout(existing); timers.set(clientId, setTimeout(() => { timers.delete(clientId); sessionManager.save(clientId, state); }, 500)); }
- Session state structure:
{ clientId: "abc", protocolVersion: 3, authIdentity: { sub: "device-123", iss: "fleet-auth", ... }, rooms: [ { roomId: "fleet-alpha", highestAckedSeq: 42, highestReceivedSeq: 45, geofenceInsideSet: ["fence-1", "fence-3"] } ], rateLimitState: { messageWindow: [ts1, ts2, ...], connectionWindow: [ts1, ...] }, metadata: { ip: "10.0.0.1", userAgent: "FleetApp/2.3", connectedAt: 1234567890, lastActivityAt: 1234567900 } }
- Integration with issue 11:
DistributedRoomManageralready syncs membership to Redis.SessionManageradds the per-client sequence/ACK/geoface/rate-limit state. They can share the same Redis connection. - For sticky cookie:
res.cookie('GW_AFFINITY', instanceId, { httpOnly: true, secure: true, sameSite: 'lax', maxAge: 3600000 })on HTTP upgrade response (requireshttpserver access — see issue 9).
Activity
ayomidemariam commentedon Aug 17, 2026
I'm interested in working on this issue as part of my contribution to the project. Please assign it to me if it's still open.
grantfox-oss commentedon Aug 17, 2026
🦊 GrantFox — @ayomidemariam has been assigned to this issue as part of the Third Campaign campaign!
Next steps:
Closes #258)Good luck! Track your progress on GrantFox.
feat: session resumption and connection migration
grantfox-oss commentedon Aug 17, 2026
🎉 This issue has been marked as completed on GrantFox as part of the Third Campaign campaign!
@ayomidemariam's PR #267 was approved and merged by @levibliz.
🏆 @ayomidemariam: You earned 35 FoxPoints for this contribution! Your current tier: Explorer (274 total points). Track your full progress on GrantFox.
👏 Great work, @ayomidemariam! Keep contributing to RiftCore00.