@@ -85,6 +85,9 @@ def _resolve_websocket_session_api_key(
8585 return None
8686
8787
88+ # Give clients 10 seconds to send auth frame after connection opens.
89+ # This balances security (don't hold connections indefinitely) with
90+ # accommodating slow networks and client startup time.
8891_FIRST_MESSAGE_AUTH_TIMEOUT_SECONDS = 10
8992
9093
@@ -124,31 +127,61 @@ async def _accept_authenticated_websocket(
124127 await websocket .close (code = 4001 , reason = "Authentication failed" )
125128 return False
126129
127- # First-message auth: accept the connection, then read the first frame.
130+ # First-message auth: we must accept() before reading frames because the
131+ # WebSocket protocol requires the handshake to complete first. The legacy
132+ # path above can reject *before* accepting (close on an un-accepted socket
133+ # sends an HTTP 403-style response), but here we need to read a frame.
128134 await websocket .accept ()
129135 try :
130136 raw = await asyncio .wait_for (
131137 websocket .receive_text (),
132138 timeout = _FIRST_MESSAGE_AUTH_TIMEOUT_SECONDS ,
133139 )
134140 data = json .loads (raw )
135- except (TimeoutError , json .JSONDecodeError , WebSocketDisconnect ):
136- logger .warning ("WebSocket first-message auth failed: bad or missing payload" )
141+ except TimeoutError :
142+ logger .warning (
143+ "WebSocket first-message auth failed: timeout waiting for auth frame"
144+ )
145+ await _safe_close_websocket (
146+ websocket , code = 4001 , reason = "Authentication failed"
147+ )
148+ return False
149+ except json .JSONDecodeError :
150+ logger .warning ("WebSocket first-message auth failed: malformed JSON" )
151+ await _safe_close_websocket (
152+ websocket , code = 4001 , reason = "Authentication failed"
153+ )
154+ return False
155+ except WebSocketDisconnect :
156+ logger .warning ("WebSocket first-message auth failed: client disconnected" )
137157 await _safe_close_websocket (
138158 websocket , code = 4001 , reason = "Authentication failed"
139159 )
140160 return False
141161
142- if (
143- isinstance (data , dict )
144- and data .get ("type" ) == "auth"
145- and data .get ("session_api_key" ) in config .session_api_keys
146- ):
147- return True
162+ if not isinstance (data , dict ):
163+ logger .warning (
164+ "WebSocket first-message auth failed: payload is not a JSON object"
165+ )
166+ await _safe_close_websocket (
167+ websocket , code = 4001 , reason = "Authentication failed"
168+ )
169+ return False
170+ if data .get ("type" ) != "auth" :
171+ logger .warning ("WebSocket first-message auth failed: wrong message type" )
172+ await _safe_close_websocket (
173+ websocket , code = 4001 , reason = "Authentication failed"
174+ )
175+ return False
176+ if data .get ("session_api_key" ) not in config .session_api_keys :
177+ logger .warning ("WebSocket first-message auth failed: invalid API key" )
178+ await _safe_close_websocket (
179+ websocket , code = 4001 , reason = "Authentication failed"
180+ )
181+ return False
148182
149- logger .warning ("WebSocket first-message auth failed: invalid key or payload" )
150- await _safe_close_websocket (websocket , code = 4001 , reason = "Authentication failed" )
151- return False
183+ logger .info ("WebSocket authenticated via first-message auth" )
184+ return True
152185
153186
154187@sockets_router .websocket ("/events/{conversation_id}" )
0 commit comments