"""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)