Nico 7458b2ea35 v0.8.0: refactor agent.py into modular package
Split 1161-line monolith into agent/ package:
auth, llm, types, process, runtime, api, and
nodes/ (base, sensor, input, output, thinker, memorizer).
No logic changes — pure structural split.
uvicorn agent:app entrypoint unchanged.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 01:36:41 +01:00

93 lines
3.2 KiB
Python

"""OIDC auth: Zitadel token validation, FastAPI dependencies."""
import json
import logging
import os
import time
import httpx
from fastapi import Depends, HTTPException, Query
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
log = logging.getLogger("runtime")
ZITADEL_ISSUER = os.environ.get("ZITADEL_ISSUER", "https://auth.loop42.de")
ZITADEL_CLIENT_ID = os.environ.get("ZITADEL_CLIENT_ID", "365996029172056091")
ZITADEL_PROJECT_ID = os.environ.get("ZITADEL_PROJECT_ID", "365995955654230043")
AUTH_ENABLED = os.environ.get("AUTH_ENABLED", "false").lower() == "true"
SERVICE_TOKENS = set(filter(None, os.environ.get("SERVICE_TOKENS", "").split(",")))
_jwks_cache: dict = {"keys": [], "fetched_at": 0}
async def _get_jwks():
if time.time() - _jwks_cache["fetched_at"] < 3600:
return _jwks_cache["keys"]
async with httpx.AsyncClient() as client:
resp = await client.get(f"{ZITADEL_ISSUER}/oauth/v2/keys")
_jwks_cache["keys"] = resp.json()["keys"]
_jwks_cache["fetched_at"] = time.time()
return _jwks_cache["keys"]
async def _validate_token(token: str) -> dict:
"""Validate token: check service tokens, then JWT, then userinfo."""
import base64
if token in SERVICE_TOKENS:
return {"sub": "titan", "username": "titan", "source": "service_token"}
try:
parts = token.split(".")
if len(parts) == 3:
keys = await _get_jwks()
header_b64 = parts[0] + "=" * (4 - len(parts[0]) % 4)
header = json.loads(base64.urlsafe_b64decode(header_b64))
kid = header.get("kid")
key = next((k for k in keys if k["kid"] == kid), None)
if key:
import jwt as pyjwt
from jwt import PyJWK
jwk_obj = PyJWK(key)
claims = pyjwt.decode(
token, jwk_obj.key, algorithms=["RS256"],
issuer=ZITADEL_ISSUER, options={"verify_aud": False},
)
return claims
except Exception:
pass
async with httpx.AsyncClient() as client:
resp = await client.get(
f"{ZITADEL_ISSUER}/oidc/v1/userinfo",
headers={"Authorization": f"Bearer {token}"},
)
if resp.status_code == 200:
info = resp.json()
log.info(f"[auth] userinfo response: {info}")
return {"sub": info.get("sub"), "preferred_username": info.get("preferred_username"),
"email": info.get("email"), "name": info.get("name"), "source": "userinfo"}
raise HTTPException(status_code=401, detail="Invalid token")
_bearer = HTTPBearer(auto_error=False)
async def require_auth(credentials: HTTPAuthorizationCredentials | None = Depends(_bearer)):
"""Dependency: require valid JWT when AUTH_ENABLED."""
if not AUTH_ENABLED:
return {"sub": "anonymous"}
if not credentials:
raise HTTPException(status_code=401, detail="Missing token")
return await _validate_token(credentials.credentials)
async def ws_auth(token: str | None = Query(None)) -> dict:
"""Validate WebSocket token from query param."""
if not AUTH_ENABLED:
return {"sub": "anonymous"}
if not token:
return None
return await _validate_token(token)