Spaces:
Running
Running
File size: 7,642 Bytes
8ce332b 5e79601 8ce332b 5e79601 8ce332b | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 | """
CustomerCore API β JWT Authentication & Tenant Extraction (Phase 10)
WHY JWT AND NOT SESSION TOKENS?
---------------------------------
CustomerCore is a B2B multi-tenant platform. Each API request must:
1. Prove the caller is who they say they are (authentication)
2. Identify which tenant the caller belongs to (authorization / data isolation)
JWT (JSON Web Tokens) are the industry standard for stateless API authentication because:
- Stateless: The server doesn't need to query a database on every request β the tenant_id
and role are encoded directly in the token payload and verified cryptographically.
- Scalable: Any API pod can verify a JWT without shared session state.
- Standard: Every language, framework, and API gateway understands JWTs.
TOKEN STRUCTURE:
Header: {"alg": "HS256", "typ": "JWT"}
Payload: {"tenant_id": "acme-corp", "role": "support_agent", "exp": 1748000000}
Signature: HMAC-SHA256(base64(header) + "." + base64(payload), SECRET_KEY)
SECURITY MODEL:
- Tokens are signed with the LITELLM_MASTER_KEY (injected via Doppler)
- Expiry is enforced β expired tokens are rejected
- The tenant_id from the token is used for ALL data isolation decisions
(ChromaDB collection, DuckDB WHERE clauses, Supabase RLS policies)
- Never trust the tenant_id from the request body β only from the verified token
HOW IT INTEGRATES WITH LANGGRAPH:
The tenant_id extracted here is injected into the AgentState at the start of every
triage run. This ensures the RAG agent only retrieves from that tenant's ChromaDB
collection, and the Graph-RAG engine only reads that tenant's Gold mart data.
"""
from __future__ import annotations
import os
from typing import Any
import jwt
from fastapi import Depends, HTTPException, Security, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
# βββ Bearer token extractor βββββββββββββββββββββββββββββββββββββββββββββββββββ
# FastAPI's HTTPBearer scheme reads the Authorization: Bearer <token> header
# and extracts the raw token string. It raises 403 automatically if the header
# is missing or malformed.
bearer_scheme = HTTPBearer(
scheme_name="Bearer JWT",
description="JWT token with tenant_id and role claims. "
"Format: Authorization: Bearer <token>"
)
def _get_secret_key() -> str:
"""
Retrieve the JWT signing secret from the environment.
In development: injected by Doppler via `doppler run -- uvicorn ...`
In production: injected as a Kubernetes Secret or HF Space secret.
We use LITELLM_MASTER_KEY as the signing key so that the same secret
that controls LiteLLM access also signs our JWTs β one secret to manage.
Alternatively a dedicated JWT_SECRET_KEY can be added to Doppler.
"""
key = os.getenv("LITELLM_MASTER_KEY") or os.getenv("JWT_SECRET_KEY")
if not key:
import logging
logging.getLogger(__name__).warning(
"JWT signing key not found. Using dev placeholder signing key."
)
return "dev-secret-jwt-signing-key-placeholder-only"
return key
class AuthenticatedTenant:
"""
The authenticated caller's identity, extracted from a verified JWT.
This object is injected into route handlers via FastAPI's Depends() system.
Route handlers can trust that if this object exists, the caller has a valid,
unexpired JWT with a verified tenant_id.
"""
def __init__(self, tenant_id: str, role: str, claims: dict[str, Any]) -> None:
self.tenant_id = tenant_id
self.role = role
self.claims = claims # Full decoded payload for any additional fields
def __repr__(self) -> str:
return f"AuthenticatedTenant(tenant_id={self.tenant_id!r}, role={self.role!r})"
def verify_token(
credentials: HTTPAuthorizationCredentials = Security(bearer_scheme),
) -> AuthenticatedTenant:
"""
FastAPI dependency: verifies the JWT and returns the authenticated tenant.
Usage in a route:
@router.post("/triage")
async def submit_ticket(
body: TicketSubmitRequest,
caller: AuthenticatedTenant = Depends(verify_token),
):
tenant_id = caller.tenant_id # Safe to use β cryptographically verified
Raises:
401 UNAUTHORIZED β if the token is missing, expired, or has an invalid signature
403 FORBIDDEN β if the token is valid but the role is not allowed for this endpoint
"""
try:
secret = _get_secret_key()
payload: dict[str, Any] = jwt.decode(
credentials.credentials,
secret,
algorithms=["HS256"],
options={"verify_exp": True}, # Always enforce expiry
)
except jwt.ExpiredSignatureError:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Token has expired. Request a new token.",
headers={"WWW-Authenticate": "Bearer"},
)
except jwt.InvalidTokenError as exc:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=f"Invalid token: {exc}",
headers={"WWW-Authenticate": "Bearer"},
)
tenant_id: str | None = payload.get("tenant_id")
role: str = payload.get("role", "support_agent")
if not tenant_id:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Token payload missing required 'tenant_id' claim.",
headers={"WWW-Authenticate": "Bearer"},
)
return AuthenticatedTenant(tenant_id=tenant_id, role=role, claims=payload)
def require_role(*allowed_roles: str):
"""
FastAPI dependency factory for role-based access control (RBAC).
Usage:
@router.post("/triage/{id}/resume")
async def resume_hitl(
caller: AuthenticatedTenant = Depends(require_role("manager", "admin")),
):
... # Only managers and admins can resume HITL tickets
Why RBAC matters in B2B:
A support_agent should be able to SUBMIT tickets but not RESUME HITL reviews.
A manager can review and override AI decisions.
An admin has full access.
These role boundaries prevent privilege escalation β a compromised support
agent token cannot approve high-risk actions.
"""
def _check(caller: AuthenticatedTenant = Depends(verify_token)) -> AuthenticatedTenant:
if caller.role not in allowed_roles:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Role '{caller.role}' is not authorized for this endpoint. "
f"Required: {allowed_roles}",
)
return caller
return _check
def generate_dev_token(tenant_id: str, role: str = "support_agent") -> str:
"""
Generate a development JWT for testing the API locally.
WARNING: This uses the dev secret key. NEVER use in production.
Production tokens must be issued by a proper auth service (Supabase Auth or similar).
Usage:
from src.api.auth import generate_dev_token
token = generate_dev_token("acme-corp", role="manager")
# Use in: Authorization: Bearer <token>
"""
import time
payload = {
"tenant_id": tenant_id,
"role": role,
"iat": int(time.time()),
"exp": int(time.time()) + 86_400, # 24 hours
}
secret = _get_secret_key()
return jwt.encode(payload, secret, algorithm="HS256")
|