hermes-hub/src/antigravity_provider/router/grok_oauth.py
Hermes Team 50e4de5230 fix(grok): опрос device-flow не различал отказ, просрочку и ожидание
Владелец: «не даёт зайти в грок через аутх». Backend при этом исправен —
проверено: провайдер возвращает настоящую сессию, адрес
accounts.x.ai/oauth2/device и код.

Дефект в цикле опроса: коды 400, 403 и 404 скопом считались
«авторизация ещё не подтверждена» и опрос молча продолжался. Но в
device-flow сервер сообщает РАЗНЫЕ вещи одним кодом 400, различая их
полем error в теле: authorization_pending, slow_down, access_denied,
expired_token.

Следствие: отказ пользователя и просроченный код выглядели как ожидание.
Мастер показывал «Ожидание подтверждения...» до самого таймаута и не
говорил, что подтверждение уже отклонено или код давно истёк.

Теперь каждый исход обрабатывается по существу: ожидание продолжает
опрос, slow_down увеличивает интервал, отказ и просрочка прекращают его
с внятным сообщением.

Тесты: 414 passed, ruff чисто.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 15:11:54 +07:00

283 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""xAI Grok OAuth / Device Code session manager for Hermes Hub.
Handles official xAI Grok / SuperGrok Device Code authorization flow:
- Requests device code from https://auth.x.ai/oauth2/device/code
- Authorization URL at https://auth.x.ai/device
- Background polling for user sign-in approval
- Exchanges authorization code for tokens at https://auth.x.ai/oauth2/token
- Extracts identity & subscription plan
- Stores credentials into dedicated ~/.hermes/grok_profiles/<profile_id>/auth.json
- Supports manual token/JSON insertion fallback
- Thread-safe single completion lock and zero-secret logging.
"""
from __future__ import annotations
import json
import logging
import os
import secrets
import threading
import time
import urllib.error
import urllib.parse
import urllib.request
from typing import Any, Dict, Optional, Tuple
from antigravity_provider.router.profile_manager import ProfileAuthManager
logger = logging.getLogger("hermes.router.grok_oauth")
XAI_OAUTH_ISSUER = "https://auth.x.ai"
XAI_OAUTH_CLIENT_ID = "b1a00492-073a-47ea-816f-4c329264a828"
XAI_OAUTH_SCOPE = "openid profile email offline_access grok-cli:access api:access"
XAI_OAUTH_DEVICE_CODE_URL = f"{XAI_OAUTH_ISSUER}/oauth2/device/code"
XAI_OAUTH_TOKEN_URL = f"{XAI_OAUTH_ISSUER}/oauth2/token"
_ACTIVE_GROK_SESSIONS: Dict[str, "GrokOAuthSession"] = {}
def _post_form(url: str, data: dict[str, str], timeout: float = 15.0) -> dict[str, Any]:
body = urllib.parse.urlencode(data).encode("utf-8")
req = urllib.request.Request(
url,
data=body,
headers={
"Content-Type": "application/x-www-form-urlencoded",
"Accept": "application/json",
"User-Agent": "hermes-hub/1.0",
},
method="POST",
)
with urllib.request.urlopen(req, timeout=timeout) as resp:
return json.loads(resp.read().decode("utf-8") or "{}")
class GrokOAuthSession:
"""Manages an interactive OAuth Device Code session for linking an xAI / Grok account."""
def __init__(self, profile_id: str):
self.session_id = secrets.token_urlsafe(16)
self.profile_id = profile_id
self.device_code: Optional[str] = None
self.user_code: Optional[str] = None
self.verification_url: str = f"{XAI_OAUTH_ISSUER}/device"
self.interval: int = 5
self.expires_in: int = 600
self.status = "initialized"
self.error_msg: Optional[str] = None
self.created_at = time.time()
self.completed_profile_info: Optional[dict] = None
self._completion_lock = threading.RLock()
self._is_completed = False
self._stop_polling = threading.Event()
self.poll_thread: Optional[threading.Thread] = None
self.is_dev_mode = False
def start(self, start_poll: bool = True) -> Tuple[str, str]:
logger.info("Grok OAuth session starting for profile=%s", self.profile_id)
try:
resp = _post_form(
XAI_OAUTH_DEVICE_CODE_URL,
{
"client_id": XAI_OAUTH_CLIENT_ID,
"scope": XAI_OAUTH_SCOPE,
},
)
self.device_code = resp.get("device_code")
self.user_code = resp.get("user_code")
self.verification_url = resp.get("verification_uri_complete") or resp.get("verification_uri") or f"{XAI_OAUTH_ISSUER}/device"
self.interval = max(1, int(resp.get("interval", 5)))
self.expires_in = int(resp.get("expires_in", 600))
if not self.user_code or not self.device_code:
raise RuntimeError("Сервер xAI не вернул user_code или device_code")
self.status = "pending"
if start_poll:
self.poll_thread = threading.Thread(target=self._poll_loop, daemon=True)
self.poll_thread.start()
_ACTIVE_GROK_SESSIONS[self.session_id] = self
return self.verification_url, self.user_code or ""
except Exception as e:
if os.environ.get("HERMES_HUB_DEV_MODE") == "1":
logger.warning("HERMES_HUB_DEV_MODE=1: using local mock session for Grok OAuth: %s", e)
self.is_dev_mode = True
self.user_code = f"GRK-{secrets.token_hex(3).upper()}"
self.device_code = secrets.token_urlsafe(16)
self.interval = 3
self.status = "pending"
if start_poll:
self.poll_thread = threading.Thread(target=self._poll_loop, daemon=True)
self.poll_thread.start()
_ACTIVE_GROK_SESSIONS[self.session_id] = self
return self.verification_url, self.user_code
else:
logger.error("Could not reach xAI deviceauth endpoint: %s", e)
self.status = "failed"
self.error_msg = f"Не удалось подключиться к серверу авторизации xAI: {e}"
self.poll_thread = None
_ACTIVE_GROK_SESSIONS[self.session_id] = self
return "", ""
def _poll_loop(self) -> None:
deadline = time.time() + self.expires_in
while not self._stop_polling.is_set() and self.status == "pending" and time.time() < deadline:
if self._stop_polling.wait(timeout=self.interval):
break
if self._is_completed:
break
if not self.device_code:
continue
try:
poll_resp = _post_form(
XAI_OAUTH_TOKEN_URL,
{
"grant_type": "urn:ietf:params:oauth:grant-type:device_code",
"client_id": XAI_OAUTH_CLIENT_ID,
"device_code": self.device_code,
},
timeout=10.0,
)
access_token = poll_resp.get("access_token")
refresh_token = poll_resp.get("refresh_token")
if access_token and refresh_token:
logger.info("Grok OAuth authorization received from device poll")
self._finalize_with_tokens(access_token, refresh_token, poll_resp.get("id_token", ""))
break
except urllib.error.HTTPError as http_err:
# В device-flow сервер сообщает РАЗНЫЕ вещи одним кодом 400,
# различая их полем error в теле. Раньше 400/403/404 скопом
# считались «ещё не подтверждено», поэтому отказ пользователя и
# просроченный код выглядели как ожидание: мастер крутил
# «Ожидание подтверждения...» до таймаута и не говорил правду.
reason = ""
try:
body = http_err.read().decode("utf-8", errors="replace")
parsed = json.loads(body) if body else {}
if isinstance(parsed, dict):
reason = str(parsed.get("error") or "")
except Exception:
reason = ""
if reason == "authorization_pending" or (not reason and http_err.code in (400, 403, 404)):
continue
if reason == "slow_down":
self.interval = min(self.interval * 2, 30)
continue
if reason == "access_denied":
self.status = "failed"
self.error_msg = "Доступ отклонён: подтверждение в браузере не выдано"
break
if reason == "expired_token":
self.status = "failed"
self.error_msg = "Код устройства истёк, начните подключение заново"
break
logger.warning("xAI device poll HTTP error: %d (%s)", http_err.code, reason or "без пояснения")
if reason:
self.status = "failed"
self.error_msg = f"xAI отклонил запрос: {reason}"
break
except Exception as ex:
logger.debug("xAI device poll error: %s", ex)
if self.status == "pending" and not self._is_completed:
self.status = "timeout"
self.error_msg = "Время ожидания авторизации xAI Grok истекло"
def handle_manual_input(self, raw_input: str) -> Tuple[bool, str]:
raw_input = raw_input.strip()
if not raw_input:
return False, "Пожалуйста, введите токен или JSON авторизации."
with self._completion_lock:
if self._is_completed:
return True, "Авторизация уже успешно завершена"
try:
if raw_input.startswith("{") and raw_input.endswith("}"):
d = json.loads(raw_input)
token = d.get("access_token") or d.get("token", {}).get("access_token") or d.get("api_key")
refresh = d.get("refresh_token") or d.get("token", {}).get("refresh_token") or ""
id_tok = d.get("id_token") or ""
if token:
self._finalize_with_tokens(token, refresh, id_tok)
return True, "Авторизация успешно завершена"
if len(raw_input) > 20:
self._finalize_with_tokens(raw_input)
return True, "Авторизация успешно завершена"
return False, "Введенные данные не похожи на токен авторизации xAI Grok."
except Exception as e:
return False, f"Ошибка обработки: {e}"
def _finalize_with_tokens(self, access_token: str, refresh_token: str = "", id_token: str = "") -> bool:
with self._completion_lock:
if self._is_completed:
return True
email = None
if id_token:
email, _ = ProfileAuthManager.extract_jwt_identity(id_token)
if not email and access_token:
email, _ = ProfileAuthManager.extract_jwt_identity(access_token)
auth_data = {
"provider": "grok",
"profile_id": self.profile_id,
"auth_mode": "oauth",
"plan_type": "Grok Pro",
"token": {
"access_token": access_token,
"refresh_token": refresh_token,
"id_token": id_token,
},
"email": email or "",
"created_at": time.time(),
}
saved_path = ProfileAuthManager.save_profile_auth("grok", self.profile_id, auth_data)
logger.info("Saved Grok OAuth credentials for %s to %s", self.profile_id, saved_path)
self.completed_profile_info = {
"email": email or "Grok Account",
"valid": True,
"profile_id": self.profile_id,
}
self._is_completed = True
self.status = "completed"
self._stop_polling.set()
return True
def cancel(self) -> None:
with self._completion_lock:
if not self._is_completed:
self.status = "cancelled"
self.error_msg = "Авторизация отменена пользователем"
self._stop_polling.set()
def start_grok_oauth(profile_id: str, start_poll: bool = True) -> Tuple[str, str, str]:
session = GrokOAuthSession(profile_id)
url, code = session.start(start_poll=start_poll)
return session.session_id, url, code
def get_grok_oauth_session(session_id: str) -> Optional[GrokOAuthSession]:
return _ACTIVE_GROK_SESSIONS.get(session_id)
def cancel_grok_oauth_session(session_id: Optional[str]) -> None:
if not session_id:
return
session = _ACTIVE_GROK_SESSIONS.pop(session_id, None)
if session:
session.cancel()