Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,32 @@ async with DecartClient(api_key=os.getenv("DECART_API_KEY")) as client:
f.write(data)
```

### Client tokens

Create short-lived client tokens on your backend and hand the signed `token` to your frontend.
Its claims (`service_tier`, allowed models and origins, expiry, ...) are signed into the JWT, so
your backend can verify and read them **offline** instead of round-tripping to the platform.
Verification needs the `verify` extra (`pip install "decart[verify]"`, adds PyJWT + cryptography):

```python
from decart import DecartClient, TokenVerifyError, verify_client_token

async with DecartClient(api_key=os.getenv("DECART_API_KEY")) as client:
token = await client.tokens.create(expires_in=300, metadata={"service_tier": 0})

verified = await client.tokens.verify(token.token) # or: await verify_client_token(token.token)
verified.service_tier # 0
verified.pool # "free" for tier 0, else "paid"
verified.user_id, verified.organization_id, verified.api_key_id, verified.expires_at
```

`verify` checks the Ed25519 signature against the platform JWKS (`https://platform.decart.ai/api/auth/jwks`,
fetched once and cached), plus `exp`, `iss` and `aud`. It raises `TokenVerifyError` on a tampered,
expired or foreign token. It is offline JWKS verification, unrelated to the gateway's online
`POST /v1/verify`. To inspect a token *without* verifying it, `client.tokens.decode(token)` /
`decode_client_token(token)` returns the same fields, untrusted. The SDK is async-only; from sync
code use `asyncio.run(verify_client_token(token))`.

### Realtime fast mode

Realtime sessions accept an optional `speed` on `RealtimeConnectOptions`, alongside `resolution`.
Expand Down
12 changes: 12 additions & 0 deletions decart/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@
QueueStatusError,
QueueResultError,
TokenCreateError,
TokenDecodeError,
TokenVerifyError,
)
from .models import (
models,
Expand All @@ -29,10 +31,14 @@
)
from .tokens import (
TokensClient,
ClientTokenClaims,
CreateTokenResponse,
RealtimeConstraints,
TokenConstraints,
TokenPermissions,
VerifiedClientToken,
decode_client_token,
verify_client_token,
)

try:
Expand Down Expand Up @@ -87,11 +93,17 @@
"JobStatusResponse",
"QueueJobResult",
"TokensClient",
"ClientTokenClaims",
"CreateTokenResponse",
"RealtimeConstraints",
"TokenConstraints",
"TokenPermissions",
"VerifiedClientToken",
"decode_client_token",
"verify_client_token",
"TokenCreateError",
"TokenDecodeError",
"TokenVerifyError",
]

if REALTIME_AVAILABLE:
Expand Down
13 changes: 13 additions & 0 deletions decart/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,3 +88,16 @@ class TokenCreateError(DecartSDKError):
"""Raised when token creation fails."""

pass


class TokenDecodeError(DecartSDKError):
"""Raised when a string is not a well-formed client-token JWT (decoding checks no signature)."""

pass


class TokenVerifyError(DecartSDKError):
"""Raised when offline verification of a client token fails (signature, expiry, issuer,
audience, unknown signing key, or unreachable JWKS)."""

pass
7 changes: 7 additions & 0 deletions decart/tokens/__init__.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,22 @@
from .client import TokensClient
from .types import (
ClientTokenClaims,
CreateTokenResponse,
RealtimeConstraints,
TokenConstraints,
TokenPermissions,
VerifiedClientToken,
)
from .verify import decode_client_token, verify_client_token

__all__ = [
"TokensClient",
"ClientTokenClaims",
"CreateTokenResponse",
"RealtimeConstraints",
"TokenConstraints",
"TokenPermissions",
"VerifiedClientToken",
"decode_client_token",
"verify_client_token",
]
60 changes: 58 additions & 2 deletions decart/tokens/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,23 @@
from ..errors import TokenCreateError
from ..models import Model
from .._user_agent import build_user_agent
from .types import CreateTokenResponse, TokenConstraints
from .types import ClientTokenClaims, CreateTokenResponse, TokenConstraints, VerifiedClientToken
from .verify import (
DEFAULT_AUDIENCE,
DEFAULT_ISSUER,
DEFAULT_JWKS_URL,
DEFAULT_LEEWAY,
decode_client_token,
verify_client_token,
)

if TYPE_CHECKING:
from ..client import DecartClient


class TokensClient:
"""
Client for creating client tokens.
Client for creating and verifying client tokens.
Client tokens are short-lived API keys safe for client-side use.

Example:
Expand All @@ -32,6 +40,10 @@ class TokensClient:
allowed_origins=["https://example.com"],
constraints={"realtime": {"maxSessionDuration": 300}},
)

# Verify a token offline against the platform JWKS (needs `decart[verify]`):
verified = await client.tokens.verify(token.token)
verified.service_tier, verified.pool, verified.user_id
```
"""

Expand Down Expand Up @@ -133,3 +145,47 @@ async def create(
permissions=data.get("permissions"),
constraints=data.get("constraints"),
)

async def verify(
self,
token: str,
*,
jwks_url: str = DEFAULT_JWKS_URL,
issuer: str = DEFAULT_ISSUER,
audience: str = DEFAULT_AUDIENCE,
leeway: float = DEFAULT_LEEWAY,
) -> VerifiedClientToken:
"""
Verify a client token offline against the platform's public JWKS.

Same as the module-level ``verify_client_token``: checks the EdDSA
signature, ``exp`` (with ``leeway``), ``iss`` and ``aud`` and returns the
claims signed into the token. The JWKS comes from the platform host (not
this client's ``base_url``) and is cached in-process. Requires the
``verify`` extra: ``pip install 'decart[verify]'``.

Example:
```python
verified = await client.tokens.verify(token.token)
verified.service_tier, verified.pool, verified.user_id
```

Raises:
TokenVerifyError: If the token is malformed, tampered with, expired,
from the wrong issuer or audience, or signed by an unknown key.
ImportError: If the ``verify`` extra is not installed.
"""
return await verify_client_token(
token, jwks_url=jwks_url, issuer=issuer, audience=audience, leeway=leeway
)

def decode(self, token: str) -> ClientTokenClaims:
"""
Decode a client token's claims **without verifying it** (no network, no extra).
Same as the module-level ``decode_client_token``. The result is untrusted:
use ``verify()`` before acting on a token you received from elsewhere.

Raises:
TokenDecodeError: If the string is not a well-formed client-token JWT.
"""
return decode_client_token(token)
36 changes: 36 additions & 0 deletions decart/tokens/types.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
from datetime import datetime
from typing import Any, Literal

from typing_extensions import TypedDict

from pydantic import BaseModel
Expand Down Expand Up @@ -29,3 +32,36 @@ class CreateTokenResponse(BaseModel):
expires_at: str
permissions: TokenPermissions | None = None
constraints: TokenConstraints | None = None


class ClientTokenClaims(BaseModel):
"""
Claims of a client token, under the names the gateway derives from them.

``decode_client_token`` returns this **without verification**: treat it as
untrusted input. ``verify_client_token`` returns the ``VerifiedClientToken``
subclass once the signature, issuer, audience and expiry have been checked.
"""

user_id: str # sub
organization_id: str | None = None # organizationId
api_key_id: str | None = None # parent_api_key_id when minted with an API key, else jti
api_key_name: str | None = None
service_tier: int | None = None # 0 free, 1 user, 2 pro, 3 priority; None when unset
allowed_models: list[str] | None = None # models; None = unrestricted
allowed_origins: list[str] | None = None # origins; None = any
constraints: TokenConstraints | None = None
realtime_concurrent_session_limit: int | None = None
zero_data_retention: bool = False
attribution: dict[str, str] | None = None # metadata.attribution usage labels
expires_at: datetime # exp, UTC
claims: dict[str, Any] # raw payload, for anything not mapped above

@property
def pool(self) -> Literal["free", "paid"]:
"""``"free"`` when ``service_tier`` is 0, otherwise ``"paid"`` (including no tier)."""
return "free" if self.service_tier == 0 else "paid"


class VerifiedClientToken(ClientTokenClaims):
"""Claims whose signature, issuer, audience and expiry were verified against the platform JWKS."""
Loading
Loading