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>
93 lines
3.2 KiB
Python
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)
|