"""Маршруты OAuth-флоу через gnexus-gauth.
Поток: /auth/login → redirect на auth.gnexus.space (PKCE) → /auth/callback →
обмен code на токены → сессия (подписанная cookie через SessionMiddleware) →
redirect на return_to.
InMemory state/pkce store из gnexus-gauth достаточно для одного пользователя;
TODO(M0): заменить на постоянные хранилища до прод-деплоя (см. M0-заметки).
"""
from typing import Any
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import RedirectResponse
from app.auth.client import get_gauth_client
from app.config import get_settings
from app.dependencies import DbDep
from app.models import User
from app.services.users import claim_global_settings, claim_orphan_data, upsert_user
router = APIRouter(prefix="/auth", tags=["auth"])
SCOPES = ["openid", "email", "profile"]
def _safe_return_to(return_to: str) -> str:
"""Только внутренние пути: open redirect через ?return_to=https://evil.com закрыт."""
if return_to.startswith("/") and not return_to.startswith("//"):
return return_to
return "/"
@router.get("/login")
async def login(request: Request, return_to: str = "/") -> RedirectResponse:
return_to = _safe_return_to(return_to)
client = get_gauth_client()
auth_request = client.build_authorization_request(return_to=return_to, scopes=SCOPES)
# return_to запоминаем в сессии и забираем в callback после обмена code.
request.session["return_to"] = return_to
return RedirectResponse(auth_request.authorization_url)
@router.get("/callback")
async def callback(request: Request, code: str, state: str, db: DbDep) -> RedirectResponse:
client = get_gauth_client()
try:
token_set = client.exchange_authorization_code(code, state)
except Exception as exc: # gnexus_gauth.exceptions.* — общий обработчик на M0
raise HTTPException(status_code=400, detail=f"OAuth callback failed: {exc}") from exc
user = client.fetch_user(token_set.access_token)
session_user = {
"user_id": user.user_id,
"email": user.email,
# display_name из профиля SSO; без него — username
"name": user.profile.get("display_name") or user.profile.get("username") or "",
"avatar_url": user.avatar_url,
# Язык UI: SSO отдаёт profile.locale (default 'ru'); '' в поле — нет значения
"locale": user.profile.get("locale") or "",
}
request.session["access_token"] = token_set.access_token
request.session["refresh_token"] = token_set.refresh_token
request.session["user"] = session_user
# Профиль в БД + claim: бесхозные данные (user_id NULL) забирает вошедший
# (ТЗ 1.2) — идемпотентно, при повторных логинах уже ничего не находит
upsert_user(
db,
user.user_id,
user.email,
user.avatar_url,
session_user["locale"],
name=session_user["name"] or None,
)
claim_orphan_data(db, user.user_id)
claim_global_settings(db, user.user_id)
return_to = _safe_return_to(request.session.pop("return_to", "/"))
return RedirectResponse(return_to)
@router.get("/logout")
async def logout(request: Request) -> RedirectResponse:
request.session.clear()
# на экран входа SPA: там единственная кнопка «Войти» (инициирует SSO-флоу)
return RedirectResponse("/login")
@router.get("/me")
async def me(request: Request, db: DbDep) -> dict[str, Any]:
session_user = request.session.get("user")
if not session_user:
raise HTTPException(status_code=401, detail="Not authenticated")
# Профиль берём из БД, а не из cookie-сессии: сессия stateless, и изменения
# от webhook SSO (webhooks.py) иначе видны только после следующего логина.
user = db.get(User, session_user["user_id"])
if user is not None:
session_user = {
"user_id": user.id,
"email": user.email,
"name": user.name or "",
"avatar_url": user.avatar_url,
"locale": user.locale or "",
}
# Раздел профиля в системе авторизации (SSO): {base}/account
account_url = get_settings().gauth_base_url.rstrip("/") + "/account"
return {"user": session_user, "account_url": account_url}