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

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

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

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}