Skip to content

Commit 48d5430

Browse files
authored
feat: scope client tokens to specific browser origins (#50)
Adds `allowed_origins` to `tokens.create()`. When set, realtime sessions opened with the resulting token are accepted only if the browser's WebSocket `Origin` header matches one of the listed origins. Pairs with `allowed_models` to give server-side issuers tighter control over how a frontend-bound token can be used.
1 parent 8e75396 commit 48d5430

5 files changed

Lines changed: 56 additions & 6 deletions

File tree

decart/tokens/client.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,10 +25,11 @@ class TokensClient:
2525
# With metadata:
2626
token = await client.tokens.create(metadata={"role": "viewer"})
2727
28-
# With expiry, model restrictions, and constraints:
28+
# With expiry, model restrictions, origin restrictions, and constraints:
2929
token = await client.tokens.create(
3030
expires_in=120,
3131
allowed_models=["lucy-2.1"],
32+
allowed_origins=["https://example.com"],
3233
constraints={"realtime": {"maxSessionDuration": 300}},
3334
)
3435
```
@@ -46,6 +47,7 @@ async def create(
4647
metadata: dict[str, Any] | None = None,
4748
expires_in: int | None = None,
4849
allowed_models: list[Union[Model, str]] | None = None,
50+
allowed_origins: list[str] | None = None,
4951
constraints: TokenConstraints | None = None,
5052
) -> CreateTokenResponse:
5153
"""
@@ -55,6 +57,11 @@ async def create(
5557
metadata: Optional custom key-value pairs to attach to the token.
5658
expires_in: Seconds until the token expires (1-3600, default 60).
5759
allowed_models: Restrict which models this token can access (max 20).
60+
allowed_origins: Restrict which web origins this token can be used
61+
from (max 20). Each entry must be a full origin including
62+
scheme, e.g. ``https://example.com``. Enforced on realtime
63+
sessions by matching the WebSocket ``Origin`` header verbatim.
64+
Defense-in-depth — only effective for browser-based clients.
5865
constraints: Operational limits, e.g.
5966
``{"realtime": {"maxSessionDuration": 120}}``.
6067
@@ -71,6 +78,7 @@ async def create(
7178
metadata={"role": "viewer"},
7279
expires_in=120,
7380
allowed_models=["lucy-2.1"],
81+
allowed_origins=["https://example.com"],
7482
constraints={"realtime": {"maxSessionDuration": 300}},
7583
)
7684
```
@@ -93,6 +101,8 @@ async def create(
93101
body["expiresIn"] = expires_in
94102
if allowed_models is not None:
95103
body["allowedModels"] = list(allowed_models)
104+
if allowed_origins is not None:
105+
body["allowedOrigins"] = list(allowed_origins)
96106
if constraints is not None:
97107
body["constraints"] = constraints
98108

decart/tokens/types.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,9 @@ class TokenConstraints(TypedDict, total=False):
1111
realtime: RealtimeConstraints
1212

1313

14-
class TokenPermissions(TypedDict):
14+
class TokenPermissions(TypedDict, total=False):
1515
models: list[str]
16+
origins: list[str]
1617

1718

1819
class CreateTokenResponse(BaseModel):

examples/create_token.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,15 @@ async def main() -> None:
88
async with DecartClient(api_key=os.getenv("DECART_API_KEY")) as server_client:
99
print("Creating client token...")
1010

11-
token = await server_client.tokens.create()
11+
token = await server_client.tokens.create(
12+
allowed_origins=["https://example.com"],
13+
)
1214

1315
print("Token created successfully:")
1416
print(f" API Key: {token.api_key[:10]}...")
1517
print(f" Expires At: {token.expires_at}")
18+
origins = (token.permissions or {}).get("origins")
19+
print(f" Allowed Origins: {', '.join(origins) if origins else '(any)'}")
1620

1721
# Client-side: Use the client token
1822
# In a real app, you would send token.api_key to the frontend

tests/test_tokens.py

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,33 @@ async def test_create_token_with_allowed_models() -> None:
164164
assert call_kwargs.kwargs["json"] == {"allowedModels": ["lucy-2.1"]}
165165

166166

167+
@pytest.mark.asyncio
168+
async def test_create_token_with_allowed_origins() -> None:
169+
"""Sends allowedOrigins in request body."""
170+
client = DecartClient(api_key="test-api-key")
171+
172+
mock_response = AsyncMock()
173+
mock_response.ok = True
174+
mock_response.json = AsyncMock(
175+
return_value={"apiKey": "ek_test123", "expiresAt": "2024-12-15T12:10:00Z"}
176+
)
177+
178+
mock_session = MagicMock()
179+
mock_session.post = MagicMock(
180+
return_value=AsyncMock(__aenter__=AsyncMock(return_value=mock_response))
181+
)
182+
183+
with patch.object(client, "_get_session", AsyncMock(return_value=mock_session)):
184+
await client.tokens.create(
185+
allowed_origins=["https://example.com", "https://app.example.com"]
186+
)
187+
188+
call_kwargs = mock_session.post.call_args
189+
assert call_kwargs.kwargs["json"] == {
190+
"allowedOrigins": ["https://example.com", "https://app.example.com"]
191+
}
192+
193+
167194
@pytest.mark.asyncio
168195
async def test_create_token_with_constraints() -> None:
169196
"""Sends constraints in request body."""
@@ -199,7 +226,10 @@ async def test_create_token_with_all_v2_fields() -> None:
199226
return_value={
200227
"apiKey": "ek_test123",
201228
"expiresAt": "2024-12-15T12:10:00Z",
202-
"permissions": {"models": ["lucy-2.1"]},
229+
"permissions": {
230+
"models": ["lucy-2.1"],
231+
"origins": ["https://example.com"],
232+
},
203233
"constraints": {"realtime": {"maxSessionDuration": 120}},
204234
}
205235
)
@@ -214,18 +244,23 @@ async def test_create_token_with_all_v2_fields() -> None:
214244
metadata={"role": "viewer"},
215245
expires_in=120,
216246
allowed_models=["lucy-2.1"],
247+
allowed_origins=["https://example.com"],
217248
constraints={"realtime": {"maxSessionDuration": 120}},
218249
)
219250

220251
assert result.api_key == "ek_test123"
221252
assert result.expires_at == "2024-12-15T12:10:00Z"
222-
assert result.permissions == {"models": ["lucy-2.1"]}
253+
assert result.permissions == {
254+
"models": ["lucy-2.1"],
255+
"origins": ["https://example.com"],
256+
}
223257
assert result.constraints == {"realtime": {"maxSessionDuration": 120}}
224258

225259
call_kwargs = mock_session.post.call_args
226260
assert call_kwargs.kwargs["json"] == {
227261
"metadata": {"role": "viewer"},
228262
"expiresIn": 120,
229263
"allowedModels": ["lucy-2.1"],
264+
"allowedOrigins": ["https://example.com"],
230265
"constraints": {"realtime": {"maxSessionDuration": 120}},
231266
}

uv.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)