Skip to content

Commit 84a2392

Browse files
feat: authenticate interpreter requests through envd
Co-Authored-By: mish@e2b.dev <mish@e2b.dev>
1 parent 698c370 commit 84a2392

4 files changed

Lines changed: 306 additions & 2 deletions

File tree

‎template/server/auth.py‎

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import asyncio
2+
import hashlib
3+
import hmac
4+
import time
5+
6+
import httpx
7+
from starlette.datastructures import Headers
8+
from starlette.responses import PlainTextResponse
9+
from starlette.types import ASGIApp, Receive, Scope, Send
10+
11+
from envs import ENVD_PORT
12+
13+
CACHE_TTL = 5.0
14+
15+
16+
class AccessTokenVerifier:
17+
def __init__(self, client: httpx.AsyncClient):
18+
self.client = client
19+
self.digest = b""
20+
self.validated_at = (0.0, 0.0)
21+
self.lock = asyncio.Lock()
22+
23+
def cached(self, digest: bytes) -> bool:
24+
monotonic, wall = self.validated_at
25+
return (
26+
hmac.compare_digest(self.digest, digest)
27+
and 0 <= time.monotonic() - monotonic < CACHE_TTL
28+
and 0 <= time.time() - wall < CACHE_TTL
29+
)
30+
31+
async def verify(self, token: str) -> int:
32+
try:
33+
encoded = token.encode("ascii")
34+
except UnicodeEncodeError:
35+
return 401
36+
if not encoded:
37+
return 401
38+
digest = hashlib.sha256(encoded).digest()
39+
if self.cached(digest):
40+
return 204
41+
42+
async with self.lock:
43+
if self.cached(digest):
44+
return 204
45+
46+
# Start the lease before I/O so a slow response cannot extend it.
47+
validated_at = (time.monotonic(), time.time())
48+
try:
49+
response = await self.client.get(
50+
f"http://127.0.0.1:{ENVD_PORT}/auth",
51+
headers={"X-Access-Token": token},
52+
)
53+
except httpx.HTTPError:
54+
return 503
55+
56+
if response.status_code == 204:
57+
self.digest = digest
58+
self.validated_at = validated_at
59+
return 204
60+
return 401 if response.status_code == 401 else 503
61+
62+
63+
class EnvdAuthMiddleware:
64+
def __init__(self, app: ASGIApp, local: bool = False):
65+
self.app = app
66+
self.local = local
67+
68+
async def __call__(self, scope: Scope, receive: Receive, send: Send):
69+
if scope["type"] == "websocket" and not self.local:
70+
await send({"type": "websocket.close", "code": 1008})
71+
return
72+
73+
if (
74+
scope["type"] != "http"
75+
or self.local
76+
or (scope["method"] == "GET" and scope["path"] == "/health")
77+
):
78+
await self.app(scope, receive, send)
79+
return
80+
81+
tokens = Headers(scope=scope).getlist("x-access-token")
82+
status = 401
83+
if len(tokens) == 1:
84+
verifier = getattr(scope["app"].state, "access_token_verifier", None)
85+
status = await verifier.verify(tokens[0]) if verifier else 503
86+
87+
if status != 204:
88+
response = PlainTextResponse(
89+
"Unauthorized" if status == 401 else "Authentication unavailable",
90+
status_code=status,
91+
headers={"Cache-Control": "no-store"},
92+
)
93+
await response(scope, receive, send)
94+
return
95+
96+
await self.app(scope, receive, send)

‎template/server/envs.py‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,18 +3,19 @@
33

44
import httpx
55

6-
LOCAL = os.getenv("E2B_LOCAL", False)
6+
LOCAL = os.getenv("E2B_LOCAL", "").lower() == "true"
77
ENVD_PORT = 49983
88

99

1010
async def get_envs(access_token: Optional[str]) -> dict:
1111
if LOCAL:
1212
return {"E2B_TEST_VARIABLE": "true"}
13-
async with httpx.AsyncClient() as client:
13+
async with httpx.AsyncClient(trust_env=False) as client:
1414
headers = {}
1515
if access_token:
1616
headers["X-Access-Token"] = f"{access_token}"
1717
response = await client.get(
1818
f"http://localhost:{ENVD_PORT}/envs", headers=headers
1919
)
20+
response.raise_for_status()
2021
return response.json()

‎template/server/main.py‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,10 @@
1212
from api.models.context import Context
1313
from api.models.create_context import CreateContext
1414
from api.models.execution_request import ExecutionRequest
15+
from auth import AccessTokenVerifier, EnvdAuthMiddleware
1516
from consts import JUPYTER_BASE_URL, JUPYTER_SOCKET_PATH
1617
from contexts import create_context, normalize_language
18+
from envs import LOCAL
1719
from messaging import ContextWebSocket
1820
from stream import StreamingListJsonResponse
1921
from utils.locks import LockedMap
@@ -35,6 +37,8 @@ async def lifespan(app: FastAPI):
3537
client = httpx.AsyncClient(
3638
transport=httpx.AsyncHTTPTransport(uds=JUPYTER_SOCKET_PATH), trust_env=False
3739
)
40+
auth_client = httpx.AsyncClient(trust_env=False, timeout=5.0)
41+
app.state.access_token_verifier = AccessTokenVerifier(auth_client)
3842

3943
try:
4044
python_context = await create_context(
@@ -59,10 +63,14 @@ async def lifespan(app: FastAPI):
5963
except Exception as e:
6064
logger.error(f"Failed to initialize default context: {e}")
6165
raise
66+
finally:
67+
del app.state.access_token_verifier
68+
await auth_client.aclose()
6269

6370

6471
app = FastAPI(lifespan=lifespan)
6572

73+
app.add_middleware(EnvdAuthMiddleware, local=LOCAL)
6674
app.add_middleware(
6775
CORSMiddleware,
6876
allow_origins=["*"],

‎template/tests/test_auth.py‎

Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
import asyncio
2+
from types import SimpleNamespace
3+
4+
import httpx
5+
import pytest
6+
from starlette.testclient import TestClient
7+
from starlette.websockets import WebSocketDisconnect
8+
9+
import auth
10+
import main
11+
12+
13+
@pytest.fixture
14+
async def server(monkeypatch):
15+
state = SimpleNamespace(
16+
token="valid-token",
17+
calls=[],
18+
status=None,
19+
monotonic=100.0,
20+
wall=1000.0,
21+
latency=0,
22+
)
23+
monkeypatch.setattr(
24+
auth,
25+
"time",
26+
SimpleNamespace(monotonic=lambda: state.monotonic, time=lambda: state.wall),
27+
)
28+
monkeypatch.setattr(main, "websockets", {})
29+
30+
async def envd(request):
31+
assert str(request.url) == "http://127.0.0.1:49983/auth"
32+
state.calls.append(request)
33+
await asyncio.sleep(0)
34+
state.monotonic += state.latency
35+
state.wall += state.latency
36+
if isinstance(state.status, Exception):
37+
raise state.status
38+
status = state.status
39+
if status is None:
40+
status = 204 if request.headers["X-Access-Token"] == state.token else 401
41+
return httpx.Response(status)
42+
43+
async with httpx.AsyncClient(transport=httpx.MockTransport(envd)) as upstream:
44+
monkeypatch.setattr(
45+
main.app.state,
46+
"access_token_verifier",
47+
auth.AccessTokenVerifier(upstream),
48+
raising=False,
49+
)
50+
async with httpx.AsyncClient(
51+
transport=httpx.ASGITransport(app=main.app), base_url="http://server"
52+
) as client:
53+
yield client, state
54+
55+
56+
@pytest.mark.parametrize(
57+
"method,path",
58+
[
59+
("POST", "/execute"),
60+
("POST", "/contexts"),
61+
("GET", "/contexts"),
62+
("POST", "/contexts/id/restart"),
63+
("DELETE", "/contexts/id"),
64+
("GET", "/docs"),
65+
],
66+
)
67+
async def test_requests_require_auth_before_route_handling(server, method, path):
68+
client, state = server
69+
for headers in ({}, {"X-Access-Token": "wrong"}):
70+
response = await client.request(method, path, headers=headers)
71+
assert response.status_code == 401
72+
assert response.headers["cache-control"] == "no-store"
73+
assert len(state.calls) == 1
74+
75+
76+
async def test_cache_expires_without_sliding_and_replaces_rotated_token(server):
77+
client, state = server
78+
headers = {"X-Access-Token": state.token}
79+
response = await client.get("/contexts", headers=headers)
80+
assert response.status_code == 200
81+
assert response.json() == []
82+
state.token = "rotated-token"
83+
84+
state.monotonic += 4
85+
state.wall += 4
86+
assert (await client.get("/contexts", headers=headers)).status_code == 200
87+
assert len(state.calls) == 1
88+
89+
state.monotonic += 1
90+
state.wall += 1
91+
assert (await client.get("/contexts", headers=headers)).status_code == 401
92+
assert len(state.calls) == 2
93+
assert (
94+
await client.get("/contexts", headers={"X-Access-Token": state.token})
95+
).status_code == 200
96+
assert len(state.calls) == 3
97+
assert (await client.get("/contexts", headers=headers)).status_code == 401
98+
99+
100+
async def test_concurrent_requests_share_validation_and_wrong_tokens_cannot_hit_cache(
101+
server,
102+
):
103+
client, state = server
104+
responses = await asyncio.gather(
105+
*[
106+
client.get("/contexts", headers={"X-Access-Token": state.token})
107+
for _ in range(10)
108+
]
109+
)
110+
assert all(response.status_code == 200 for response in responses)
111+
assert len(state.calls) == 1
112+
for _ in range(2):
113+
assert (
114+
await client.get("/contexts", headers={"X-Access-Token": "wrong"})
115+
).status_code == 401
116+
assert len(state.calls) == 3
117+
assert (
118+
await client.get("/contexts", headers={"X-Access-Token": state.token})
119+
).status_code == 200
120+
assert len(state.calls) == 3
121+
122+
123+
@pytest.mark.parametrize(
124+
"failure", [200, 302, 404, 500, 503, httpx.ConnectError("offline")]
125+
)
126+
async def test_expired_cache_fails_closed_and_recovers(server, failure):
127+
client, state = server
128+
headers = {"X-Access-Token": state.token}
129+
assert (await client.get("/contexts", headers=headers)).status_code == 200
130+
state.monotonic += 5
131+
state.wall += 5
132+
state.status = failure
133+
for _ in range(2):
134+
assert (await client.get("/contexts", headers=headers)).status_code == 503
135+
assert len(state.calls) == 3
136+
state.status = None
137+
assert (await client.get("/contexts", headers=headers)).status_code == 200
138+
assert len(state.calls) == 4
139+
140+
141+
@pytest.mark.parametrize(
142+
"clock,delta", [("wall", 10), ("wall", -10), ("monotonic", -10)]
143+
)
144+
async def test_clock_jumps_invalidate_cached_validation(server, clock, delta):
145+
client, state = server
146+
headers = {"X-Access-Token": state.token}
147+
assert (await client.get("/contexts", headers=headers)).status_code == 200
148+
setattr(state, clock, getattr(state, clock) + delta)
149+
state.token = "rotated-token"
150+
assert (await client.get("/contexts", headers=headers)).status_code == 401
151+
assert len(state.calls) == 2
152+
153+
154+
async def test_health_and_cors_preflight_do_not_require_auth(server):
155+
client, state = server
156+
assert (await client.get("/health")).status_code == 200
157+
assert (await client.get("/health/")).status_code == 401
158+
response = await client.options(
159+
"/contexts",
160+
headers={
161+
"Origin": "https://example.com",
162+
"Access-Control-Request-Method": "GET",
163+
"Access-Control-Request-Headers": "X-Access-Token",
164+
},
165+
)
166+
assert response.status_code == 200
167+
assert response.headers["access-control-allow-origin"] == "*"
168+
assert not state.calls
169+
170+
171+
async def test_slow_response_does_not_extend_cache_lifetime(server):
172+
client, state = server
173+
state.latency = 5
174+
headers = {"X-Access-Token": state.token}
175+
assert (await client.get("/contexts", headers=headers)).status_code == 200
176+
state.token = "rotated-token"
177+
assert (await client.get("/contexts", headers=headers)).status_code == 401
178+
assert len(state.calls) == 2
179+
180+
181+
async def test_malformed_headers_cannot_use_cached_validation(server):
182+
client, state = server
183+
assert (
184+
await client.get("/contexts", headers={"X-Access-Token": state.token})
185+
).status_code == 200
186+
for headers in (
187+
[("X-Access-Token", state.token), ("X-Access-Token", "wrong")],
188+
[(b"X-Access-Token", b"\xff")],
189+
{"X-Access-Token": ""},
190+
):
191+
assert (await client.get("/contexts", headers=headers)).status_code == 401
192+
assert len(state.calls) == 1
193+
194+
195+
def test_public_websockets_are_rejected():
196+
with pytest.raises(WebSocketDisconnect) as exc:
197+
with TestClient(main.app).websocket_connect("/contexts"):
198+
pytest.fail("public websocket accepted")
199+
assert exc.value.code == 1008

0 commit comments

Comments
 (0)