|
2 | 2 | WebSocket endpoints for OpenHands SDK. |
3 | 3 |
|
4 | 4 | These endpoints are separate from the main API routes to handle WebSocket-specific |
5 | | -authentication. Browsers cannot send custom HTTP headers directly with WebSocket |
6 | | -connections, so we support the `session_api_key` query param. For non-browser |
7 | | -clients (e.g. Python/Node), we also support authenticating via headers. |
| 5 | +authentication. Three auth methods are supported (highest to lowest precedence): |
| 6 | +
|
| 7 | +1. **First-message auth** (recommended): The client sends |
| 8 | + ``{"type": "auth", "session_api_key": "..."}`` as the very first WebSocket |
| 9 | + frame after the connection opens. This keeps tokens out of URLs and |
| 10 | + therefore out of reverse-proxy / load-balancer access logs. |
| 11 | +2. Query parameter ``session_api_key`` — deprecated, kept for backwards compat. |
| 12 | +3. ``X-Session-API-Key`` header — for non-browser clients. |
8 | 13 | """ |
9 | 14 |
|
| 15 | +import asyncio |
| 16 | +import json |
10 | 17 | import logging |
11 | 18 | from dataclasses import dataclass |
12 | 19 | from datetime import datetime |
@@ -78,18 +85,102 @@ def _resolve_websocket_session_api_key( |
78 | 85 | return None |
79 | 86 |
|
80 | 87 |
|
| 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. |
| 91 | +_FIRST_MESSAGE_AUTH_TIMEOUT_SECONDS = 10 |
| 92 | + |
| 93 | + |
81 | 94 | async def _accept_authenticated_websocket( |
82 | 95 | websocket: WebSocket, |
83 | 96 | session_api_key: str | None, |
84 | 97 | ) -> bool: |
85 | | - """Authenticate and accept the socket, or close with an auth error.""" |
| 98 | + """Authenticate and accept the socket, or close with an auth error. |
| 99 | +
|
| 100 | + Authentication is attempted in the following order: |
| 101 | +
|
| 102 | + 1. Query parameter / header (legacy, deprecated). |
| 103 | + 2. First-message auth — the client sends |
| 104 | + ``{"type": "auth", "session_api_key": "..."}`` as the first frame. |
| 105 | +
|
| 106 | + The WebSocket is always *accepted* before first-message auth is attempted |
| 107 | + because raw WebSocket requires ``accept()`` before any frames can be read. |
| 108 | + """ |
86 | 109 | config = _get_config(websocket) |
87 | 110 | resolved_key = _resolve_websocket_session_api_key(websocket, session_api_key) |
88 | | - if config.session_api_keys and resolved_key not in config.session_api_keys: |
89 | | - logger.warning("WebSocket authentication failed: invalid or missing API key") |
| 111 | + |
| 112 | + # No auth configured — accept unconditionally. |
| 113 | + if not config.session_api_keys: |
| 114 | + await websocket.accept() |
| 115 | + return True |
| 116 | + |
| 117 | + # Legacy path: key supplied via query param or header. |
| 118 | + if resolved_key is not None: |
| 119 | + if resolved_key in config.session_api_keys: |
| 120 | + logger.warning( |
| 121 | + "session_api_key passed via query param or header is deprecated. " |
| 122 | + "Use first-message auth instead." |
| 123 | + ) |
| 124 | + await websocket.accept() |
| 125 | + return True |
| 126 | + logger.warning("WebSocket authentication failed: invalid API key") |
90 | 127 | await websocket.close(code=4001, reason="Authentication failed") |
91 | 128 | return False |
| 129 | + |
| 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. |
92 | 134 | await websocket.accept() |
| 135 | + try: |
| 136 | + raw = await asyncio.wait_for( |
| 137 | + websocket.receive_text(), |
| 138 | + timeout=_FIRST_MESSAGE_AUTH_TIMEOUT_SECONDS, |
| 139 | + ) |
| 140 | + data = json.loads(raw) |
| 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") |
| 157 | + await _safe_close_websocket( |
| 158 | + websocket, code=4001, reason="Authentication failed" |
| 159 | + ) |
| 160 | + return False |
| 161 | + |
| 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 |
| 182 | + |
| 183 | + logger.info("WebSocket authenticated via first-message auth") |
93 | 184 | return True |
94 | 185 |
|
95 | 186 |
|
@@ -329,9 +420,13 @@ async def _send_event(event: Event, websocket: WebSocket): |
329 | 420 | logger.exception("error_sending_event: %r", event, stack_info=True) |
330 | 421 |
|
331 | 422 |
|
332 | | -async def _safe_close_websocket(websocket: WebSocket): |
| 423 | +async def _safe_close_websocket( |
| 424 | + websocket: WebSocket, |
| 425 | + code: int = 1000, |
| 426 | + reason: str = "Connection closed", |
| 427 | +): |
333 | 428 | try: |
334 | | - await websocket.close(code=1000, reason="Connection closed") |
| 429 | + await websocket.close(code=code, reason=reason) |
335 | 430 | except Exception: |
336 | 431 | # WebSocket may already be closed or in inconsistent state |
337 | 432 | logger.debug("WebSocket close failed (may already be closed)") |
|
0 commit comments