"""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}