Skip to content

Commit 79b3c59

Browse files
committed
Add disable_auth parameter
1 parent 8b4381d commit 79b3c59

4 files changed

Lines changed: 204 additions & 6 deletions

File tree

README.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -401,6 +401,25 @@ await server.serve()
401401
await server.stop()
402402
```
403403

404+
### Authentication
405+
406+
Both `engine.serve()` and `SpeechEngineServer` automatically verify the `X-Elevenlabs-Speech-Engine-Authorization` header on every incoming connection, rejecting any requests that were not signed by ElevenLabs. The API key is read from the `AsyncElevenLabs` client (or `api_key=` on `SpeechEngineServer`), falling back to the `ELEVENLABS_API_KEY` environment variable.
407+
408+
#### Disabling authentication
409+
410+
If your server sits behind an infrastructure layer that already restricts incoming traffic to ElevenLabs (typically an IP allowlist scoped to [ElevenLabs' egress ranges](https://elevenlabs.io/docs/overview/capabilities/speech-engine#ip-allowlisting)), you can skip JWT verification by passing `disable_auth=True`:
411+
412+
```python
413+
# Via engine.serve() — no api_key required when disable_auth is True
414+
await engine.serve(port=3001, disable_auth=True, on_transcript=on_transcript)
415+
416+
# Or directly on SpeechEngineServer
417+
server = SpeechEngineServer(port=3001, disable_auth=True, on_transcript=on_transcript)
418+
await server.serve()
419+
```
420+
421+
When auth is disabled the server accepts any client that can reach it and emits a `UserWarning` on startup. **Only use this if you have an IP allowlist or equivalent network-level restriction in front of the server** — without one, anyone on the internet can open a session and consume your compute and downstream LLM quota.
422+
404423
## Languages Supported
405424

406425
Explore [all models & languages](https://elevenlabs.io/docs/models).

src/elevenlabs/speech_engine/resource.py

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -163,12 +163,26 @@ async def serve(
163163
port: int = 3001,
164164
path: typing.Optional[str] = None,
165165
debug: bool = False,
166+
disable_auth: bool = False,
166167
**handlers: typing.Any,
167168
) -> None:
168-
"""Start a standalone WebSocket server. Blocks until stopped."""
169+
"""Start a standalone WebSocket server. Blocks until stopped.
170+
171+
:param disable_auth: If ``True``, skip verification of the
172+
``X-Elevenlabs-Speech-Engine-Authorization`` JWT on incoming
173+
connections. **Insecure** — only enable this if the server is
174+
protected by an IP allowlist scoped to ElevenLabs' egress
175+
ranges. Without one, anyone on the internet can open a session
176+
and consume your compute and downstream LLM quota.
177+
"""
169178
api_key = self._get_api_key()
170179
server = SpeechEngineServer(
171-
port=port, path=path, debug=debug, api_key=api_key, **handlers
180+
port=port,
181+
path=path,
182+
debug=debug,
183+
api_key=api_key,
184+
disable_auth=disable_auth,
185+
**handlers,
172186
)
173187
await server.serve()
174188

src/elevenlabs/speech_engine/server.py

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import http
55
import os
66
import typing
7+
import warnings
78

89
from .session import SpeechEngineSession, _make_log, _wire_handlers
910
from .types import WebSocketLike
@@ -15,7 +16,8 @@ class SpeechEngineServer:
1516
API.
1617
1718
Every incoming connection is verified against the ElevenLabs API using
18-
the configured API key before being accepted.
19+
the configured API key before being accepted, unless ``disable_auth`` is
20+
set to ``True``.
1921
2022
Example::
2123
@@ -35,12 +37,23 @@ def __init__(
3537
path: typing.Optional[str] = None,
3638
api_key: typing.Optional[str] = None,
3739
debug: bool = False,
40+
disable_auth: bool = False,
3841
**handlers: typing.Any,
3942
) -> None:
43+
"""Initialize a Speech Engine server.
44+
45+
:param disable_auth: If ``True``, skip verification of the
46+
``X-Elevenlabs-Speech-Engine-Authorization`` JWT on incoming
47+
connections. **Insecure** — only enable this if the server is
48+
protected by an IP allowlist scoped to ElevenLabs' egress
49+
ranges. Without one, anyone on the internet can open a session
50+
and consume your compute and downstream LLM quota.
51+
"""
4052
self._port = port
4153
self._path = path
4254
self._api_key = api_key
4355
self._debug = debug
56+
self._disable_auth = disable_auth
4457
self._handlers = handlers
4558
self._stop_event = None # type: typing.Optional[asyncio.Event]
4659
self._server = None # type: typing.Any
@@ -66,11 +79,22 @@ async def serve(self) -> None:
6679
import websockets # noqa: E402 — keep import lazy
6780

6881
api_key = self._api_key or os.environ.get("ELEVENLABS_API_KEY")
69-
if not api_key:
82+
if not api_key and not self._disable_auth:
7083
raise RuntimeError(
7184
"SpeechEngineServer requires an API key to verify incoming "
7285
"connections. Pass api_key= or set the ELEVENLABS_API_KEY "
73-
"environment variable."
86+
"environment variable. To run without authentication, pass "
87+
"disable_auth=True — but only behind an IP allowlist."
88+
)
89+
90+
if self._disable_auth:
91+
warnings.warn(
92+
"SpeechEngineServer: authentication is disabled — incoming "
93+
"connections will NOT be verified. Make sure the server is "
94+
"protected by an IP allowlist restricting traffic to "
95+
"ElevenLabs.",
96+
UserWarning,
97+
stacklevel=2,
7498
)
7599

76100
self._stop_event = asyncio.Event()
@@ -89,6 +113,9 @@ def _process_request(
89113
http.HTTPStatus.NOT_FOUND, "not found\n"
90114
)
91115

116+
if self._disable_auth:
117+
return None
118+
92119
header_value = request.headers.get(
93120
"x-elevenlabs-speech-engine-authorization"
94121
)
@@ -103,7 +130,7 @@ def _process_request(
103130
)
104131

105132
try:
106-
verify_speech_engine_jwt(header_value, api_key)
133+
verify_speech_engine_jwt(header_value, typing.cast(str, api_key))
107134
except ValueError as e:
108135
self._log("rejected connection — %s", e)
109136
return connection.respond(

tests/test_speech_engine_auth.py

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,18 @@
11
"""Tests for Speech Engine JWT verification."""
22

3+
import asyncio
34
import base64
45
import hashlib
56
import hmac
67
import json
78
import time
89
import typing
10+
import warnings
911

1012
import pytest
13+
import websockets
1114

15+
from elevenlabs.speech_engine import SpeechEngineServer
1216
from elevenlabs.speech_engine.resource import (
1317
SpeechEngineResource,
1418
verify_speech_engine_jwt,
@@ -195,3 +199,137 @@ async def test_raises_without_api_key(self, monkeypatch: pytest.MonkeyPatch) ->
195199
server = SpeechEngineServer(port=0)
196200
with pytest.raises(RuntimeError, match="API key"):
197201
await server.serve()
202+
203+
204+
# ---------------------------------------------------------------------------
205+
# SpeechEngineServer — disable_auth
206+
# ---------------------------------------------------------------------------
207+
208+
209+
async def _run_server_briefly(server: SpeechEngineServer) -> asyncio.Task:
210+
"""Start ``server`` in a background task and wait until it's listening."""
211+
task = asyncio.create_task(server.serve())
212+
# Give the server loop a moment to reach `await self._stop_event.wait()`.
213+
for _ in range(50):
214+
await asyncio.sleep(0.02)
215+
if server._server is not None:
216+
break
217+
return task
218+
219+
220+
def _server_port(server: SpeechEngineServer) -> int:
221+
import socket as _socket
222+
223+
# websockets.serve(host="") binds both IPv4 and IPv6; on macOS the first
224+
# socket is often IPv6. Prefer an IPv4 socket so the client can connect
225+
# via 127.0.0.1 reliably.
226+
ipv4 = [
227+
s for s in server._server.sockets if s.family == _socket.AF_INET
228+
]
229+
chosen = ipv4[0] if ipv4 else next(iter(server._server.sockets), None)
230+
if chosen is None:
231+
raise RuntimeError("server has no sockets")
232+
return int(chosen.getsockname()[1])
233+
234+
235+
class TestServerDisableAuth:
236+
@pytest.mark.asyncio
237+
async def test_serves_without_api_key_when_disable_auth(
238+
self, monkeypatch: pytest.MonkeyPatch
239+
) -> None:
240+
monkeypatch.delenv("ELEVENLABS_API_KEY", raising=False)
241+
server = SpeechEngineServer(port=0, disable_auth=True)
242+
task = None
243+
try:
244+
with warnings.catch_warnings():
245+
warnings.simplefilter("ignore")
246+
task = await _run_server_briefly(server)
247+
assert server._server is not None
248+
finally:
249+
await server.stop()
250+
if task is not None:
251+
await task
252+
253+
@pytest.mark.asyncio
254+
async def test_emits_warning_when_disable_auth(
255+
self, monkeypatch: pytest.MonkeyPatch
256+
) -> None:
257+
monkeypatch.delenv("ELEVENLABS_API_KEY", raising=False)
258+
server = SpeechEngineServer(port=0, disable_auth=True)
259+
task = None
260+
try:
261+
with warnings.catch_warnings(record=True) as caught:
262+
warnings.simplefilter("always")
263+
task = await _run_server_briefly(server)
264+
messages = [str(w.message) for w in caught]
265+
assert any("authentication is disabled" in m for m in messages)
266+
finally:
267+
await server.stop()
268+
if task is not None:
269+
await task
270+
271+
@pytest.mark.asyncio
272+
async def test_accepts_unauthenticated_connection(
273+
self, monkeypatch: pytest.MonkeyPatch
274+
) -> None:
275+
monkeypatch.delenv("ELEVENLABS_API_KEY", raising=False)
276+
277+
init_ids: typing.List[str] = []
278+
279+
async def on_init(conversation_id: str, session: typing.Any) -> None:
280+
init_ids.append(conversation_id)
281+
282+
server = SpeechEngineServer(port=0, disable_auth=True, on_init=on_init)
283+
task = None
284+
try:
285+
with warnings.catch_warnings():
286+
warnings.simplefilter("ignore")
287+
task = await _run_server_briefly(server)
288+
port = _server_port(server)
289+
async with websockets.connect(f"ws://127.0.0.1:{port}") as ws: # type: ignore[attr-defined]
290+
await ws.send(
291+
json.dumps({"type": "init", "conversation_id": "conv_1"})
292+
)
293+
await asyncio.sleep(0.1)
294+
assert init_ids == ["conv_1"]
295+
finally:
296+
await server.stop()
297+
if task is not None:
298+
await task
299+
300+
@pytest.mark.asyncio
301+
async def test_rejects_missing_header_when_auth_enabled(self) -> None:
302+
server = SpeechEngineServer(port=0, api_key=TEST_API_KEY)
303+
task = None
304+
try:
305+
task = await _run_server_briefly(server)
306+
port = _server_port(server)
307+
with pytest.raises(websockets.exceptions.InvalidStatus) as exc: # type: ignore[attr-defined]
308+
async with websockets.connect(f"ws://127.0.0.1:{port}"): # type: ignore[attr-defined]
309+
pass
310+
assert exc.value.response.status_code == 401
311+
finally:
312+
await server.stop()
313+
if task is not None:
314+
await task
315+
316+
@pytest.mark.asyncio
317+
async def test_rejects_invalid_jwt_when_auth_enabled(self) -> None:
318+
server = SpeechEngineServer(port=0, api_key=TEST_API_KEY)
319+
task = None
320+
try:
321+
task = await _run_server_briefly(server)
322+
port = _server_port(server)
323+
with pytest.raises(websockets.exceptions.InvalidStatus) as exc: # type: ignore[attr-defined]
324+
async with websockets.connect( # type: ignore[attr-defined]
325+
f"ws://127.0.0.1:{port}",
326+
additional_headers={
327+
"X-Elevenlabs-Speech-Engine-Authorization": "not.a.jwt"
328+
},
329+
):
330+
pass
331+
assert exc.value.response.status_code == 401
332+
finally:
333+
await server.stop()
334+
if task is not None:
335+
await task

0 commit comments

Comments
 (0)