"""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.auth.stores import revoke_logins_for_user, store_login
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", {})
# Учёт входа: при выходе пользователя из gnexus-auth вебхук найдёт
# этот токен и отзовёт его (см. webhooks()).
store_login(user.user_id, state, tokens.access_token, tokens.refresh_token)
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("/logout")
def full_logout(request: Request) -> dict:
"""Выход из Synapse: revoke всех известных входов пользователя (access+refresh).
SSO-сессия gnexus-auth (web-cookie) пока не завершается — RP-initiated
logout требует поддержки на стороне gnexus-auth; зафиксировано в #32.
После revoke любой API-запрос и heartbeat получат 401.
"""
auth = request.headers.get("Authorization", "")
if not auth.startswith("Bearer "):
raise HTTPException(status_code=401, detail="Требуется Bearer-токен")
client = get_gauth_client()
token = auth.removeprefix("Bearer ")
user_id: str | None = None
try:
user = client.fetch_user(token) # токен ещё жив — узнаём, чей выход
user_id = str(user.user_id)
except Exception:
pass # токен уже мёртв — просто чистим локально по index'у
revoked = revoke_logins_for_user(user_id, client) if user_id else 0
# текущий (не учтён в store_login, если вход был до этой функции) — добиваем явно
try:
client.revoke_token(token, "access_token")
except Exception:
pass
return {"revoked": True, "logins_killed": revoked}
@router.post("/webhooks")
async def webhooks(request: Request) -> dict:
"""Приём вебхуков gnexus-auth (user.*, role.*, client.*, session.*, auth.*, webhook.test).
Подпись HMAC (`t=...,v1=...`) проверяется через gnexus-gauth с секретом
из env.
Ключевое правило single sign-out: gnexus-auth при выходе НЕ отзывает
OAuth-токены, поэтому выход из auth должен долететь до нас через
auth.logout / auth.global_logout / session.revoked — по user_id мы
отзываем все выданные этому пользователю access+refresh.
"""
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
if event.event_type in ("auth.logout", "auth.global_logout", "session.revoked"):
payload = event.raw_payload or {}
user = payload.get("user") or {}
target_user = user.get("id") or event.target_identifiers.get("user_id")
killed = revoke_logins_for_user(target_user, get_gauth_client()) if target_user else 0
if target_user:
logger.info(
"synapse logout по вебхуку: user=%s type=%s revoked_logins=%d",
target_user, event.event_type, killed,
)
else:
logger.warning("logout-событие %s без user.id — нечем отзвать токены", event.event_type)
else:
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}