"""gnexus-auth (OAuth PKCE) интеграция: конфиг клиента, обмен кода, сессии.
Ровно как в gnexus-creds: state/PKCE пишем напрямую в БД (наша — aiosqlite),
высокоуровневому GAuthClient-у здесь взять нечего — берём SDK-детали
(HttpTokenEndpoint, HttpRuntimeUserProvider, webhook-верификатор) напрямую.
Синхронные httpx-запросы к gnexus-auth выполняются в потоке (asyncio.to_thread),
чтобы медленный/опавший auth-сервер не замораживал event loop панели.
Режим: GHARD_AUTH_CLIENT_ID пуст → панель открыта (проверка в security);
задан → браузер шлюзится через gnexus-auth, агент — по X-Server-Key,
скрипты/MCP — по Bearer GHARD_ADMIN_TOKEN.
"""
import uuid
from datetime import datetime, timedelta, timezone
import httpx
from app.config import get_settings
from app.db import get_db
SESSION_COOKIE = "ghard_session"
SCOPES = ["openid", "email", "profile", "roles", "permissions"]
# Лениво собираемый синхронный SDK-контекст (httpx-клиент живёт один на процесс)
_ctx: dict = {}
def _now() -> datetime:
return datetime.now(timezone.utc)
def get_ctx() -> dict:
global _ctx
if not _ctx:
settings = get_settings()
from gnexus_gauth.config import GAuthConfig
from gnexus_gauth.oauth import HttpTokenEndpoint
from gnexus_gauth.runtime import HttpRuntimeUserProvider
from gnexus_gauth.webhook import HmacWebhookVerifier, JsonWebhookParser
gconf = GAuthConfig(
base_url=settings.auth_base_url,
client_id=settings.auth_client_id,
client_secret=settings.auth_client_secret,
redirect_uri=settings.auth_redirect_uri,
user_agent="ghard-monitor",
)
# gnexus-auth живёт внутри сети — TLS не проверяем (по умолчанию http)
http = httpx.Client(timeout=15.0, verify=False)
_ctx = {
"config": gconf,
"http": http,
"authorize": None, # AuthorizationUrlBuilder строится по требованию
"token": HttpTokenEndpoint(gconf, http),
"runtime": HttpRuntimeUserProvider(gconf, http),
"webhook": HmacWebhookVerifier(gconf),
"parser": JsonWebhookParser(),
}
return _ctx
def auth_enabled() -> bool:
return bool(get_settings().auth_client_id)
def safe_return_to(value: str | None) -> str:
"""Только внутренние пути: '/path', не '//evil' и не 'https://…'."""
if not value or not value.startswith("/") or value.startswith("//") or "\\" in value:
return "/"
return value
def allowlisted(email: str, user_id: str) -> bool:
"""GHARD_AUTH_ALLOWLIST: пусто = любой залогиненный; иначе перечислены
через запятую e-mail или user_id."""
raw = get_settings().auth_allowlist.strip()
if not raw:
return True
allowed = {item.strip().lower() for item in raw.split(",") if item.strip()}
return email.lower() in allowed or str(user_id).lower() in allowed
def exchange_and_fetch(code: str, pkce_verifier: str) -> tuple:
"""Синхронный обмен кода → (TokenSet, AuthenticatedUser). Выполнять в треде."""
ctx = get_ctx()
token_set = ctx["token"].exchange_authorization_code(code, pkce_verifier)
user = ctx["runtime"].fetch_user(token_set.access_token)
return token_set, user
# --- Сессии в SQLite -----------------------------------------------------------
async def create_session(user) -> str:
"""user — gnexus_gauth.dto.AuthenticatedUser. Возвращает id сессии."""
db = get_db()
session_id = uuid.uuid4().hex
profile = user.profile or {}
settings = get_settings()
await db.execute(
"INSERT INTO sessions (id, user_id, email, display_name, avatar_url, expires_at, created_at)"
" VALUES (?, ?, ?, ?, ?, ?, ?)",
(
session_id,
user.user_id,
user.email,
profile.get("display_name") or profile.get("name") or user.email,
profile.get("avatar_url") or "",
(_now() + timedelta(seconds=settings.session_ttl_seconds)).isoformat(),
_now().isoformat(),
),
)
await db.commit()
return session_id
async def get_session(session_id: str | None):
"""Свежая сессия по cookie-значению или None."""
if not session_id:
return None
db = get_db()
cursor = await db.execute(
"SELECT id, user_id, email, display_name, avatar_url"
" FROM sessions WHERE id = ? AND expires_at > ?",
(session_id, _now().isoformat()),
)
row = await cursor.fetchone()
return dict(row) if row else None
async def delete_session(session_id: str | None) -> None:
if not session_id:
return
db = get_db()
await db.execute("DELETE FROM sessions WHERE id = ?", (session_id,))
await db.commit()
async def purge_expired() -> None:
"""Севшие сессии и state — подчистить (вызывается из фоновых циклов)."""
db = get_db()
now = _now().isoformat()
await db.execute("DELETE FROM sessions WHERE expires_at < ?", (now,))
await db.execute("DELETE FROM oauth_states WHERE expires_at < ?", (now,))
await db.commit()
async def apply_webhook_update(user_id: str, event_type: str, metadata: dict) -> None:
"""Webhook gnexus-auth — отражаем в наших живых сессиях.
auth.global_logout = у пользователя заканчиваются все его сессии в
панели (SPA-cookie перестаёт работать, как и сессия в gauth);
user.updated может обновлять имя/аватар.
"""
db = get_db()
cutoff = _now().isoformat()
if event_type == "auth.global_logout":
await db.execute(
"DELETE FROM sessions WHERE user_id = ? AND expires_at > ?", (user_id, cutoff)
)
else:
profile = (metadata.get("profile") or {}) if isinstance(metadata, dict) else {}
display_name = profile.get("display_name") or profile.get("name")
avatar = profile.get("avatar_url")
if display_name:
await db.execute(
"UPDATE sessions SET display_name = ? WHERE user_id = ? AND expires_at > ?",
(display_name, user_id, cutoff),
)
if avatar:
await db.execute(
"UPDATE sessions SET avatar_url = ? WHERE user_id = ? AND expires_at > ?",
(avatar, user_id, cutoff),
)
await db.commit()