Skip to content

Commit 84ec301

Browse files
committed
refactor: migrate from api-key auth to jwt
1 parent 5e7c362 commit 84ec301

7 files changed

Lines changed: 431 additions & 41 deletions

File tree

README.md

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -32,22 +32,34 @@ By default the project will create two directories in the root directory: `./con
3232

3333
### Authentication
3434

35-
By default the API is unauthenticated. To require an API key, set `API_KEY` in the service environment (or `.env`):
35+
The API authenticates requests with short-lived JWTs minted by LibreChat (`Authorization: Bearer <token>`, EdDSA/Ed25519 by default, RS256 also supported). LibreChat signs tokens with a private key; this service verifies them with the matching public key.
36+
37+
By default the API is unauthenticated — when no public key is configured, all requests are accepted (dev mode only; a warning is logged at startup). To enable auth:
38+
39+
1. Generate a keypair:
40+
41+
```bash
42+
openssl genpkey -algorithm ed25519 -out codeapi-private.pem
43+
openssl pkey -in codeapi-private.pem -pubout -out codeapi-public.pem
44+
awk 'NF {printf "%s\\n", $0}' codeapi-public.pem # \n-escape a PEM for a one-line env value
45+
```
46+
47+
2. Set the public key in the service environment (or `.env`):
3648

3749
```ini
38-
API_KEY=<your-secret-key>
50+
CODEAPI_JWT_PUBLIC_KEY="-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----\n"
51+
# or alternatively: CODEAPI_JWT_PUBLIC_KEY_BASE64=<base64-encoded PEM>
3952
```
4053

41-
When set, all `/v1` endpoints require a matching `x-api-key` header and return `401` otherwise (`/health` stays open). LibreChat sends this header automatically using its `LIBRECHAT_CODE_API_KEY` value, so the two must match.
54+
When set, all `/v1` endpoints require a valid Bearer token and return `401` otherwise (`/health` stays open). Optional overrides (defaults match LibreChat): `CODEAPI_JWT_ISSUER=librechat`, `CODEAPI_JWT_AUDIENCE=codeapi`, `CODEAPI_JWT_ALGORITHMS=["EdDSA","RS256"]`, `CODEAPI_JWT_LEEWAY=30`.
4255

4356
### Configuring LibreChat
4457

45-
LibreChat is configured to use the code interpreter API by default.
46-
4758
To configure LibreChat to use the local code interpreter, set the following environment variables in LibreChat:
4859

4960
```ini
50-
LIBRECHAT_CODE_API_KEY=<any-value-here> # must match the service's API_KEY if one is configured
61+
CODEAPI_JWT_ENABLED=true # or CODEAPI_AUTH_PROVIDER=librechat-jwt
62+
CODEAPI_JWT_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----\n" # \n-escaped private PEM; _BASE64 and _JWK_JSON variants also exist
5163
LIBRECHAT_CODE_BASEURL=http(s)://host:port/v1/librechat # for local testing use to point to host IP http://host.docker.internal:8000/v1/librechat
5264
```
5365

app/api/auth.py

Lines changed: 83 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,91 @@
1-
import secrets
2-
from typing import Optional
1+
from dataclasses import dataclass, field
2+
from functools import lru_cache
3+
from typing import Any, Dict, Optional
34

4-
from fastapi import Header, HTTPException
5+
import jwt
6+
from cryptography.hazmat.primitives import serialization
7+
from fastapi import Header, HTTPException, Request
8+
from loguru import logger
59

610
from app.shared.config import get_settings
711

812

9-
async def verify_api_key(x_api_key: Optional[str] = Header(None)) -> None:
10-
"""Require a matching x-api-key header when an API key is configured.
13+
@dataclass(frozen=True)
14+
class AuthContext:
15+
"""Verified identity from a LibreChat code-API JWT."""
1116

12-
Auth is disabled when ``API_KEY`` is unset. Settings are read at request
13-
time so the key can be toggled in tests.
17+
enabled: bool # False when auth is unconfigured (open mode)
18+
sub: Optional[str] = None # LibreChat user id (trustworthy identity)
19+
tenant_id: Optional[str] = None
20+
role: Optional[str] = None
21+
claims: Dict[str, Any] = field(default_factory=dict)
22+
23+
24+
@lru_cache(maxsize=4)
25+
def _load_public_key(pem: str):
26+
"""Parse a PEM public key; cached on the PEM string so settings swaps in tests just work."""
27+
return serialization.load_pem_public_key(pem.encode("utf-8"))
28+
29+
30+
def _unauthorized() -> HTTPException:
31+
return HTTPException(status_code=401, detail="Unauthorized", headers={"WWW-Authenticate": "Bearer"})
32+
33+
34+
async def verify_jwt(request: Request, authorization: Optional[str] = Header(None)) -> AuthContext:
35+
"""Verify the LibreChat-minted Bearer JWT on the request.
36+
37+
Auth is disabled when no public key is configured. Settings are read at
38+
request time so the key can be toggled in tests. The verified claims are
39+
attached to ``request.state.auth`` for route handlers.
1440
"""
1541
settings = get_settings()
16-
if settings.API_KEY is None:
17-
return
18-
if x_api_key is None or not secrets.compare_digest(x_api_key, settings.API_KEY):
19-
raise HTTPException(status_code=401, detail="Unauthorized")
42+
pem = settings.JWT_PUBLIC_KEY_PEM
43+
if pem is None:
44+
context = AuthContext(enabled=False)
45+
request.state.auth = context
46+
return context
47+
48+
if authorization is None:
49+
raise _unauthorized()
50+
scheme, _, token = authorization.partition(" ")
51+
if scheme.lower() != "bearer" or not token.strip():
52+
raise _unauthorized()
53+
token = token.strip()
54+
55+
try:
56+
key = _load_public_key(pem)
57+
except ValueError:
58+
logger.error("CODEAPI_JWT_PUBLIC_KEY is not a valid PEM public key")
59+
raise HTTPException(status_code=500, detail="Server authentication misconfigured")
60+
61+
try:
62+
kid = jwt.get_unverified_header(token).get("kid")
63+
logger.debug(f"Verifying code-API token with kid={kid}")
64+
except jwt.InvalidTokenError:
65+
raise _unauthorized()
66+
67+
try:
68+
payload = jwt.decode(
69+
token,
70+
key,
71+
algorithms=settings.CODEAPI_JWT_ALGORITHMS,
72+
audience=settings.CODEAPI_JWT_AUDIENCE,
73+
issuer=settings.CODEAPI_JWT_ISSUER,
74+
leeway=settings.CODEAPI_JWT_LEEWAY,
75+
options={"require": ["exp", "iat", "sub"]},
76+
)
77+
except (jwt.InvalidTokenError, TypeError, ValueError) as exc:
78+
# PyJWT raises TypeError/ValueError (not InvalidTokenError) when the
79+
# token's alg does not match the configured key type
80+
logger.warning(f"Rejected code-API token: {exc}")
81+
raise _unauthorized()
82+
83+
context = AuthContext(
84+
enabled=True,
85+
sub=payload["sub"],
86+
tenant_id=payload.get("tenant_id"),
87+
role=payload.get("role"),
88+
claims=payload,
89+
)
90+
request.state.auth = context
91+
return context

app/main.py

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from fastapi.responses import JSONResponse
77
from loguru import logger
88

9-
from .api.auth import verify_api_key
9+
from .api.auth import verify_jwt
1010
from .api.base import router as base_router
1111
from .api.librechat import router as librechat_router
1212
from .api.container import router as docker_router
@@ -24,6 +24,9 @@ async def lifespan(app: FastAPI):
2424
setup_logging()
2525
logger.info("Starting application")
2626

27+
if get_settings().JWT_PUBLIC_KEY_PEM is None:
28+
logger.warning("CODEAPI_JWT_PUBLIC_KEY not set — API is UNAUTHENTICATED (dev mode only)")
29+
2730
# Initialize database
2831
await db_manager.initialize()
2932

@@ -65,10 +68,10 @@ async def lifespan(app: FastAPI):
6568
# Add logging middleware
6669
app.add_middleware(RequestLoggingMiddleware)
6770

68-
# Include routers (all /v1 routes require x-api-key when API_KEY is configured)
69-
app.include_router(base_router, dependencies=[Depends(verify_api_key)])
70-
app.include_router(librechat_router, dependencies=[Depends(verify_api_key)])
71-
app.include_router(docker_router, dependencies=[Depends(verify_api_key)])
71+
# Include routers (all /v1 routes require a LibreChat JWT when a public key is configured)
72+
app.include_router(base_router, dependencies=[Depends(verify_jwt)])
73+
app.include_router(librechat_router, dependencies=[Depends(verify_jwt)])
74+
app.include_router(docker_router, dependencies=[Depends(verify_jwt)])
7275

7376

7477
@app.exception_handler(HTTPException)

app/shared/config.py

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
1+
import base64
2+
13
from loguru import logger
24
from pydantic_settings import BaseSettings, SettingsConfigDict
35
from functools import lru_cache
46
from pathlib import Path
5-
from typing import Dict, Optional, Set
7+
from typing import Dict, List, Optional, Set
68

79

810
class Settings(BaseSettings):
@@ -26,7 +28,23 @@ def CONFIG_PATH_ABS(self) -> Path:
2628
# API settings
2729
PORT: int = 8000 # Port exposed from the container
2830
API_PREFIX: str = "/v1" # API prefix
29-
API_KEY: Optional[str] = None # When set, requests must send a matching x-api-key header
31+
32+
# JWT auth (LibreChat code-API tokens); auth is disabled when no public key is set
33+
CODEAPI_JWT_PUBLIC_KEY: Optional[str] = None # PEM, "\n"-escaped newlines allowed
34+
CODEAPI_JWT_PUBLIC_KEY_BASE64: Optional[str] = None # base64-encoded PEM alternative
35+
CODEAPI_JWT_ISSUER: str = "librechat"
36+
CODEAPI_JWT_AUDIENCE: str = "codeapi"
37+
CODEAPI_JWT_ALGORITHMS: List[str] = ["EdDSA", "RS256"]
38+
CODEAPI_JWT_LEEWAY: int = 30 # seconds of clock-skew tolerance
39+
40+
@property
41+
def JWT_PUBLIC_KEY_PEM(self) -> Optional[str]:
42+
"""Resolved public key PEM, or None when JWT auth is unconfigured."""
43+
if self.CODEAPI_JWT_PUBLIC_KEY:
44+
return self.CODEAPI_JWT_PUBLIC_KEY.replace("\\n", "\n").strip()
45+
if self.CODEAPI_JWT_PUBLIC_KEY_BASE64:
46+
return base64.b64decode(self.CODEAPI_JWT_PUBLIC_KEY_BASE64).decode("utf-8").strip()
47+
return None
3048

3149
# Code execution sandbox settings
3250
SANDBOX_MAX_EXECUTION_TIME: int = 300 # Docker container execution time limit in seconds

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ dependencies = [
1717
"python-magic>=0.4.27",
1818
"loguru>=0.7.3",
1919
"nanoid>=2.0.0",
20+
"pyjwt[crypto]>=2.10.1",
2021
]
2122

2223
[project.optional-dependencies]

0 commit comments

Comments
 (0)