zico-agent / src /security /chat_auth.py
github-actions[bot]
Deploy from GitHub Actions: 5cc337f01ae4c62c0929b385ab8d2bea26198bcb
3b8269b
Raw History Blame Contribute Delete
4.61 kB
from __future__ import annotations
import os
from dataclasses import dataclass
from typing import Any, Mapping
from urllib.parse import urlsplit
import httpx
from fastapi import Header, HTTPException
class ChatAuthError(RuntimeError):
"""Raised when the supplied end-user authentication is invalid."""
class ChatAuthUnavailableError(RuntimeError):
"""Raised when the Panorama auth service cannot validate a token."""
@dataclass(frozen=True, slots=True)
class PanoramaPrincipal:
"""Authenticated Panorama end-user principal."""
user_id: str
def _normalise_address(value: Any) -> str:
if not isinstance(value, str):
raise ChatAuthError("Authenticated principal address is missing.")
address = value.strip()
if not address:
raise ChatAuthError("Authenticated principal address is missing.")
return address.lower()
def _resolve_auth_service_url() -> str:
"""Resolve Panorama auth without requiring deployment-specific changes."""
explicit = (os.getenv("AUTH_SERVICE_URL") or "").strip()
if explicit:
return explicit.rstrip("/")
gateway_url = (os.getenv("PANORAMA_GATEWAY_URL") or "").strip()
if not gateway_url:
raise HTTPException(
status_code=503,
detail="Authentication service is not configured.",
)
parsed = urlsplit(gateway_url)
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
raise HTTPException(
status_code=503,
detail="Authentication service is not configured.",
)
return f"{parsed.scheme}://{parsed.netloc}"
def validate_bearer_token(
token: str,
*,
auth_service_url: str,
client: httpx.Client | Any | None = None,
) -> PanoramaPrincipal:
"""Validate an end-user bearer token with Panorama auth-service."""
cleaned_token = token.strip()
if not cleaned_token:
raise ChatAuthError("Bearer token is missing.")
base_url = auth_service_url.rstrip("/")
if not base_url:
raise ChatAuthUnavailableError("Panorama auth service URL is not configured.")
owns_client = client is None
http_client = client or httpx.Client(timeout=10.0)
try:
try:
response = http_client.post(
f"{base_url}/auth/validate",
json={"token": cleaned_token},
)
except Exception as exc:
raise ChatAuthUnavailableError(
"Panorama auth service is unavailable."
) from exc
if response.status_code != 200:
raise ChatAuthError("Bearer token is invalid.")
try:
payload: Mapping[str, Any] = response.json()
except Exception as exc:
raise ChatAuthUnavailableError(
"Panorama auth service returned an invalid response."
) from exc
if payload.get("isValid") is not True:
raise ChatAuthError("Bearer token is invalid.")
auth_payload = payload.get("payload")
if not isinstance(auth_payload, Mapping):
raise ChatAuthError("Authenticated principal is missing.")
address = _normalise_address(auth_payload.get("address"))
return PanoramaPrincipal(user_id=address)
finally:
if owns_client:
http_client.close()
def require_chat_principal(
authorization: str | None = Header(default=None),
) -> PanoramaPrincipal:
"""FastAPI dependency resolving the authenticated chat principal."""
if not authorization:
raise HTTPException(
status_code=401,
detail="Authentication required.",
headers={"WWW-Authenticate": "Bearer"},
)
scheme, separator, token = authorization.partition(" ")
if (
not separator
or scheme.lower() != "bearer"
or not token.strip()
):
raise HTTPException(
status_code=401,
detail="Authentication required.",
headers={"WWW-Authenticate": "Bearer"},
)
auth_service_url = _resolve_auth_service_url()
try:
return validate_bearer_token(
token,
auth_service_url=auth_service_url,
)
except ChatAuthError as exc:
raise HTTPException(
status_code=401,
detail="Invalid authentication token.",
headers={"WWW-Authenticate": "Bearer"},
) from exc
except ChatAuthUnavailableError as exc:
raise HTTPException(
status_code=503,
detail="Authentication service is unavailable.",
) from exc