"""Webhook от gnexus-auth (ТЗ 3.16).
SSO асинхронно доставляет подписанные события (контракт — docs/04-events-and-webhooks.md
в репозитории gnexus-auth): изменения профиля и статуса пользователя. Эндпоинт
открытый — подлинность гарантирует HMAC-SHA256-подпись (заголовки X-GNexus-*),
секрет GAUTH_WEBHOOK_SECRET задаётся при создании webhook в админке SSO.
Не задан — приём webhook выключен (503).
Обрабатываем подмножество, значимое для gntodo:
- user.email_changed / user.profile_updated — синхронизация профиля User;
- user.blocked / user.archived / user.deleted — отзыв всех MCP-токенов
(аккаунт более недоступен в SSO — агент не должен действовать от его имени).
Ограничение: cookie-сессии stateless, серверно их не отозвать — доступ
пользователю закрывается на стороне SSO. Остальные события (включая
webhook.test и новые типы) подтверждаем 200 без действий — чтобы SSO не
гонял retry'и по незнакомому типу.
"""
import logging
from fastapi import APIRouter, HTTPException, Request
from gnexus_gauth.dto import WebhookEvent
from sqlalchemy import delete
from app.auth.client import get_gauth_client
from app.config import get_settings
from app.dependencies import DbDep
from app.models import McpToken, User
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/auth", tags=["auth"])
# Аккаунт более недоступен в SSO — MCP-токены отзываем (агент теряет доступ).
REVOKE_EVENTS = frozenset({"user.blocked", "user.archived", "user.deleted"})
@router.post("/webhook")
async def webhook(request: Request, db: DbDep) -> dict[str, bool]:
secret = get_settings().gauth_webhook_secret
if not secret:
raise HTTPException(status_code=503, detail="Webhook is not configured")
raw = (await request.body()).decode()
try:
event = get_gauth_client().verify_and_parse_webhook(raw, dict(request.headers), secret)
except Exception as exc: # WebhookVerification/PayloadException из gnexus_gauth
logger.warning("gauth webhook отклонён: %s", exc)
raise HTTPException(status_code=400, detail="Invalid webhook") from exc
_handle(db, event)
return {"ok": True}
def _handle(db: DbDep, event: WebhookEvent) -> None:
data = event.metadata
# user.id в data; для событий без data.user — target.user_id (базовый payload)
data_user = (data.get("user") or {}).get("id")
target_user = event.target_identifiers.get("user_id")
user_id = str(data_user or target_user or "")
if event.event_type in REVOKE_EVENTS:
if user_id:
db.execute(delete(McpToken).where(McpToken.user_id == user_id))
logger.info(
"gauth webhook %s: MCP-токены пользователя %s отозваны", event.event_type, user_id
)
return
user = db.get(User, user_id) if user_id else None
if event.event_type == "user.email_changed":
email = data.get("new_email")
if user is not None and email:
user.email = str(email)
return
if event.event_type == "user.profile_updated":
profile = data.get("profile") or {}
if user is not None:
if "locale" in profile:
user.locale = profile.get("locale") or ""
if "avatar_url" in profile:
user.avatar_url = profile.get("avatar_url")
return
logger.info("gauth webhook %s (user=%s) — без действий", event.event_type, user_id or "?")