Newer
Older
gn-synapse / app / api / auth_routes.py
"""SSO-эндпоинты: login (redirect на gnexus-auth), callback (обмен кода), refresh, revoke.

Весь OAuth2 Authorization Code + PKCE выполняется на сервере через gnexus-gauth:
токены никогда не проходят через браузер напрямую, а роль проверяется до выдачи
токена пользователю.
"""

import logging
from urllib.parse import quote, urlencode

from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import RedirectResponse
from gnexus_gauth.client import GAuthClient
from pydantic import BaseModel

from app.auth.client import GAUTH_SCOPES, get_gauth_client
from app.auth.deps import ADMIN_ROLES
from app.config import Settings, get_settings

logger = logging.getLogger("synapse.auth")

router = APIRouter(prefix="/auth")


def _spa_redirect(settings: Settings, path: str, fragment: dict | None = None) -> RedirectResponse:
    """Редирект на адрес SPA (dev: SPA_PUBLIC_URL, прод: тот же origin)."""
    url = f"{settings.spa_public_url.rstrip('/')}{path}" if settings.spa_public_url else path
    if fragment:
        # Фрагмент (#) — не логируется серверами и не уходит в историю запросов.
        url += "#" + urlencode(fragment, safe="", quote_via=quote)
    return RedirectResponse(url)


@router.get("/login")
def login(
    return_to: str | None = None,
    settings=Depends(get_settings),
) -> RedirectResponse:
    """Начало SSO: state+PKCE складываются в Redis, браузер уходит на авторизацию gnexus-auth."""
    client = get_gauth_client()
    request = client.build_authorization_request(return_to=return_to, scopes=GAUTH_SCOPES)
    return RedirectResponse(request.authorization_url)


@router.get("/callback")
def callback(
    code: str | None = None,
    state: str | None = None,
    error: str | None = None,
    settings=Depends(get_settings),
) -> RedirectResponse:
    """Код от gnexus-auth -> TokenSet. Пользователю без роли admin токен не выдаётся."""
    if error or not code or not state:
        return _spa_redirect(settings, "/login", {"error": error or "missing_code_or_state"})

    client = get_gauth_client()
    try:
        tokens = client.exchange_authorization_code(code, state)
    except Exception:
        return _spa_redirect(settings, "/login", {"error": "exchange_failed"})

    try:
        user = client.fetch_user(tokens.access_token)
    except Exception:
        return _spa_redirect(settings, "/login", {"error": "userinfo_failed"})

    if (user.system_role or "") not in ADMIN_ROLES:
        # Отзываем access-токен сразу: он валиден ~30 минут, не оставляем его пользователю.
        try:
            client.revoke_token(tokens.access_token)
        except Exception:
            pass
        return _spa_redirect(settings, "/denied", {})

    fragment = {
        "access_token": tokens.access_token,
        "expires_in": tokens.expires_in,
        "token_type": tokens.token_type,
    }
    if tokens.refresh_token:
        fragment["refresh_token"] = tokens.refresh_token
    return _spa_redirect(settings, "/oauth/callback", fragment)


class RefreshRequest(BaseModel):
    refresh_token: str


@router.post("/refresh")
def refresh(body: RefreshRequest) -> dict:
    """Обновление access-токена (SPA делает это тихо до истечения текущего)."""
    try:
        tokens = get_gauth_client().refresh_token(body.refresh_token)
    except Exception:
        raise HTTPException(status_code=401, detail="Refresh-токен невалиден") from None
    payload: dict = {
        "access_token": tokens.access_token,
        "expires_in": tokens.expires_in,
    }
    if tokens.refresh_token:
        payload["refresh_token"] = tokens.refresh_token
    return payload


@router.post("/revoke")
def revoke(request: Request) -> dict:
    """Логаут: отзываем access-токен (гнексус-auth учитывает это мгновенно)."""
    auth = request.headers.get("Authorization", "")
    if not auth.startswith("Bearer "):
        raise HTTPException(status_code=401, detail="Требуется Bearer-токен")
    get_gauth_client().revoke_token(auth.removeprefix("Bearer "))
    return {"revoked": True}


@router.post("/webhooks")
async def webhooks(request: Request) -> dict:
    """Приём вебхуков gnexus-auth (user.*, role.*, client.*, webhook.test).

    Подпись HMAC (`t=...,v1=...`) проверяется через gnexus-gauth с секретом
    из env; уведомления о смене пользователя/ролей нужны для мгновенной
    инвалидации (TODO: кэш userinfo в задаче #30-#31).
    """
    secret = get_settings().gauth_webhook_secret
    if not secret:
        raise HTTPException(status_code=503, detail="GAUTH_WEBHOOK_SECRET не настроен")

    raw = (await request.body()).decode("utf-8", errors="replace")
    try:
        event = get_gauth_client().verify_and_parse_webhook(raw, dict(request.headers), secret)
    except Exception:
        raise HTTPException(status_code=400, detail="Некорректная подпись вебхука") from None

    logger.info("gauth webhook: %s id=%s target=%s", event.event_type, event.event_id, event.target_identifiers)
    return {"received": True, "event_type": event.event_type}