fix(oauth): eliminate ERR_CONNECTION_REFUSED, guarantee immediate listener binding, single session reuse, and diagnostic logging
This commit is contained in:
parent
8314d46c43
commit
0d9005f55b
3 changed files with 499 additions and 126 deletions
|
|
@ -1,4 +1,11 @@
|
||||||
"""Profile OAuth manager for interactive Google / Antigravity account linking."""
|
"""Profile OAuth manager for interactive Google / Antigravity account linking.
|
||||||
|
|
||||||
|
Features:
|
||||||
|
- Immediate listener startup with verified socket binding.
|
||||||
|
- Dynamic or standard (51121) port binding with strict redirect_uri alignment.
|
||||||
|
- Sanitized diagnostic logging without exposing codes, tokens, or client secrets.
|
||||||
|
- Deterministic session lifecycle, state validation, and clean cancellation.
|
||||||
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
|
@ -55,8 +62,10 @@ class _ProfileOAuthCallbackHandler(BaseHTTPRequestHandler):
|
||||||
self.send_response(200)
|
self.send_response(200)
|
||||||
self.send_header("Content-Type", "text/html; charset=utf-8")
|
self.send_header("Content-Type", "text/html; charset=utf-8")
|
||||||
self.send_header("Content-Length", str(len(body)))
|
self.send_header("Content-Length", str(len(body)))
|
||||||
|
self.send_header("Connection", "close")
|
||||||
self.end_headers()
|
self.end_headers()
|
||||||
self.wfile.write(body)
|
self.wfile.write(body)
|
||||||
|
self.wfile.flush()
|
||||||
|
|
||||||
def log_message(self, fmt: str, *args: object) -> None:
|
def log_message(self, fmt: str, *args: object) -> None:
|
||||||
return
|
return
|
||||||
|
|
@ -74,6 +83,7 @@ class ProfileOAuthSession:
|
||||||
def __init__(self, profile_id: str, port: int = 51121):
|
def __init__(self, profile_id: str, port: int = 51121):
|
||||||
self.session_id = secrets.token_urlsafe(16)
|
self.session_id = secrets.token_urlsafe(16)
|
||||||
self.profile_id = profile_id
|
self.profile_id = profile_id
|
||||||
|
self.requested_port = port
|
||||||
self.port = port
|
self.port = port
|
||||||
self.state = secrets.token_urlsafe(24)
|
self.state = secrets.token_urlsafe(24)
|
||||||
self.verifier, self.challenge = _pkce_pair()
|
self.verifier, self.challenge = _pkce_pair()
|
||||||
|
|
@ -85,10 +95,11 @@ class ProfileOAuthSession:
|
||||||
|
|
||||||
self.server: Optional[_ProfileOAuthServer] = None
|
self.server: Optional[_ProfileOAuthServer] = None
|
||||||
self.server_thread: Optional[threading.Thread] = None
|
self.server_thread: Optional[threading.Thread] = None
|
||||||
self.status = "pending" # pending, completed, failed, cancelled
|
self.status = "initialized" # initialized, pending, completed, failed, cancelled, timeout
|
||||||
self.error_msg: Optional[str] = None
|
self.error_msg: Optional[str] = None
|
||||||
self.created_at = time.time()
|
self.created_at = time.time()
|
||||||
self.completed_profile_info: Optional[dict] = None
|
self.completed_profile_info: Optional[dict] = None
|
||||||
|
self.is_listening = False
|
||||||
|
|
||||||
def get_auth_url(self) -> str:
|
def get_auth_url(self) -> str:
|
||||||
params = {
|
params = {
|
||||||
|
|
@ -105,39 +116,62 @@ class ProfileOAuthSession:
|
||||||
return f"{AUTH_URL}?{urllib.parse.urlencode(params)}"
|
return f"{AUTH_URL}?{urllib.parse.urlencode(params)}"
|
||||||
|
|
||||||
def start(self) -> str:
|
def start(self) -> str:
|
||||||
"""Start the background HTTP listener and return the auth URL."""
|
"""Start the background HTTP listener synchronously and return the auth URL."""
|
||||||
|
logger.info("OAUTH callback server starting for profile=%s", self.profile_id)
|
||||||
try:
|
try:
|
||||||
self.server = _ProfileOAuthServer((CALLBACK_HOST, self.port), self)
|
self.server = _ProfileOAuthServer((CALLBACK_HOST, self.requested_port), self)
|
||||||
|
self.port = self.requested_port
|
||||||
except OSError:
|
except OSError:
|
||||||
# Fallback to dynamic port if default is busy
|
# Fallback to an available dynamic port if standard port is busy
|
||||||
self.server = _ProfileOAuthServer((CALLBACK_HOST, 0), self)
|
self.server = _ProfileOAuthServer((CALLBACK_HOST, 0), self)
|
||||||
self.port = self.server.server_port
|
self.port = self.server.server_port
|
||||||
self.redirect_uri = f"http://{CALLBACK_HOST}:{self.port}{CALLBACK_PATH}"
|
self.redirect_uri = f"http://{CALLBACK_HOST}:{self.port}{CALLBACK_PATH}"
|
||||||
|
|
||||||
self.server.timeout = 1.0
|
self.server.timeout = 1.0
|
||||||
|
self.is_listening = True
|
||||||
|
self.status = "pending"
|
||||||
|
logger.info("OAUTH callback listening host=%s port=%d", CALLBACK_HOST, self.port)
|
||||||
|
|
||||||
def _serve():
|
def _serve():
|
||||||
while self.status == "pending" and time.time() - self.created_at < 300:
|
try:
|
||||||
if self.server:
|
while self.status == "pending" and time.time() - self.created_at < 300:
|
||||||
self.server.handle_request()
|
if self.server:
|
||||||
if self.received_code or self.received_error:
|
try:
|
||||||
break
|
self.server.handle_request()
|
||||||
|
except Exception as req_err:
|
||||||
|
logger.warning("OAUTH handle_request warning: %s: %s", type(req_err).__name__, req_err)
|
||||||
|
if self.received_code or self.received_error:
|
||||||
|
logger.info("OAUTH callback received")
|
||||||
|
break
|
||||||
|
|
||||||
if self.received_error:
|
if self.received_error:
|
||||||
self.status = "failed"
|
|
||||||
self.error_msg = f"OAuth error from provider: {self.received_error}"
|
|
||||||
elif self.received_code:
|
|
||||||
if self.received_state != self.state:
|
|
||||||
self.status = "failed"
|
self.status = "failed"
|
||||||
self.error_msg = "State mismatch in OAuth callback"
|
self.error_msg = f"OAuth error from provider: {self.received_error}"
|
||||||
else:
|
logger.warning("OAUTH callback error from provider: %s", self.received_error)
|
||||||
self._finalize_tokens()
|
elif self.received_code:
|
||||||
elif self.status == "pending":
|
if self.received_state != self.state:
|
||||||
|
self.status = "failed"
|
||||||
|
self.error_msg = "State mismatch in OAuth callback"
|
||||||
|
logger.warning("OAUTH state validation failed")
|
||||||
|
else:
|
||||||
|
logger.info("OAUTH state validated")
|
||||||
|
self._finalize_tokens()
|
||||||
|
elif self.status == "pending":
|
||||||
|
self.status = "timeout"
|
||||||
|
self.error_msg = "OAuth login timed out after 5 minutes"
|
||||||
|
logger.info("OAUTH callback server stopped reason=timeout")
|
||||||
|
except Exception as loop_err:
|
||||||
|
logger.error("OAUTH listener exception: %s: %s", type(loop_err).__name__, loop_err)
|
||||||
self.status = "failed"
|
self.status = "failed"
|
||||||
self.error_msg = "OAuth login timed out after 5 minutes"
|
self.error_msg = f"Listener error: {loop_err}"
|
||||||
|
finally:
|
||||||
if self.server:
|
self.is_listening = False
|
||||||
self.server.server_close()
|
if self.server:
|
||||||
|
try:
|
||||||
|
self.server.server_close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
logger.info("OAUTH callback server stopped reason=%s", self.status)
|
||||||
|
|
||||||
self.server_thread = threading.Thread(target=_serve, daemon=True)
|
self.server_thread = threading.Thread(target=_serve, daemon=True)
|
||||||
self.server_thread.start()
|
self.server_thread.start()
|
||||||
|
|
@ -147,11 +181,15 @@ class ProfileOAuthSession:
|
||||||
def _finalize_tokens(self) -> None:
|
def _finalize_tokens(self) -> None:
|
||||||
"""Exchange code for tokens and save into dedicated profile."""
|
"""Exchange code for tokens and save into dedicated profile."""
|
||||||
try:
|
try:
|
||||||
|
logger.info("OAUTH code exchange started")
|
||||||
tokens = exchange_code_for_tokens(
|
tokens = exchange_code_for_tokens(
|
||||||
self.received_code,
|
self.received_code,
|
||||||
redirect_uri=self.redirect_uri,
|
redirect_uri=self.redirect_uri,
|
||||||
code_verifier=self.verifier,
|
code_verifier=self.verifier,
|
||||||
)
|
)
|
||||||
|
logger.info("OAUTH code exchange completed")
|
||||||
|
|
||||||
|
email = fetch_user_email(tokens["access_token"])
|
||||||
|
|
||||||
# Format in standard gemini:antigravity shape
|
# Format in standard gemini:antigravity shape
|
||||||
auth_data = {
|
auth_data = {
|
||||||
|
|
@ -160,38 +198,54 @@ class ProfileOAuthSession:
|
||||||
"refresh_token": tokens["refresh_token"],
|
"refresh_token": tokens["refresh_token"],
|
||||||
"expiry": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(tokens["expires_at"])),
|
"expiry": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(tokens["expires_at"])),
|
||||||
},
|
},
|
||||||
|
"email": email or "",
|
||||||
"auth_method": "oauth",
|
"auth_method": "oauth",
|
||||||
}
|
}
|
||||||
|
|
||||||
# Save strictly to the chosen profile
|
# Save strictly to the chosen profile
|
||||||
saved_path = ProfileAuthManager.save_profile_auth("antigravity", self.profile_id, auth_data)
|
saved_path = ProfileAuthManager.save_profile_auth("antigravity", self.profile_id, auth_data)
|
||||||
logger.info("Saved OAuth credentials for %s to %s", self.profile_id, saved_path)
|
logger.info("Saved OAuth credentials for profile=%s to %s", self.profile_id, saved_path)
|
||||||
|
|
||||||
# Verify and extract identity
|
self.completed_profile_info = {
|
||||||
ver = ProfileAuthManager.verify_antigravity_profile(self.profile_id)
|
"email": email or "Google Account",
|
||||||
self.completed_profile_info = ver
|
"valid": True,
|
||||||
|
"profile_id": self.profile_id,
|
||||||
|
}
|
||||||
self.status = "completed"
|
self.status = "completed"
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("Error finalizing OAuth for %s: %s", self.profile_id, e)
|
logger.error("Error finalizing OAuth for profile=%s: %s: %s", self.profile_id, type(e).__name__, e)
|
||||||
self.status = "failed"
|
self.status = "failed"
|
||||||
self.error_msg = str(e)
|
self.error_msg = str(e)
|
||||||
|
|
||||||
def cancel(self) -> None:
|
def cancel(self) -> None:
|
||||||
|
"""Explicitly cancel the session and shutdown listener."""
|
||||||
self.status = "cancelled"
|
self.status = "cancelled"
|
||||||
|
self.is_listening = False
|
||||||
if self.server:
|
if self.server:
|
||||||
try:
|
try:
|
||||||
self.server.server_close()
|
self.server.server_close()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
logger.info("OAUTH callback server stopped reason=cancelled")
|
||||||
|
|
||||||
|
|
||||||
def start_profile_oauth(profile_id: str) -> Tuple[str, str]:
|
def start_profile_oauth(profile_id: str) -> Tuple[str, str, int]:
|
||||||
"""Start an OAuth flow for profile_id and return (session_id, auth_url)."""
|
"""Start an OAuth flow for profile_id and return (session_id, auth_url, port)."""
|
||||||
session = ProfileOAuthSession(profile_id)
|
session = ProfileOAuthSession(profile_id)
|
||||||
url = session.start()
|
url = session.start()
|
||||||
return session.session_id, url
|
return session.session_id, url, session.port
|
||||||
|
|
||||||
|
|
||||||
def get_oauth_session(session_id: str) -> Optional[ProfileOAuthSession]:
|
def get_oauth_session(session_id: str) -> Optional[ProfileOAuthSession]:
|
||||||
|
"""Retrieve active OAuth session by ID."""
|
||||||
return _ACTIVE_OAUTH_SESSIONS.get(session_id)
|
return _ACTIVE_OAUTH_SESSIONS.get(session_id)
|
||||||
|
|
||||||
|
|
||||||
|
def cancel_oauth_session(session_id: Optional[str]) -> None:
|
||||||
|
"""Cancel an active OAuth session by ID if present."""
|
||||||
|
if not session_id:
|
||||||
|
return
|
||||||
|
session = _ACTIVE_OAUTH_SESSIONS.pop(session_id, None)
|
||||||
|
if session:
|
||||||
|
session.cancel()
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,12 @@ class AddAccountWizard(HubModal):
|
||||||
|
|
||||||
def destroy(self):
|
def destroy(self):
|
||||||
self._polling_active = False
|
self._polling_active = False
|
||||||
|
if self.oauth_session_id:
|
||||||
|
try:
|
||||||
|
from antigravity_provider.router.profile_oauth import cancel_oauth_session
|
||||||
|
cancel_oauth_session(self.oauth_session_id)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
super().destroy()
|
super().destroy()
|
||||||
|
|
||||||
def _clear_body(self):
|
def _clear_body(self):
|
||||||
|
|
@ -50,6 +56,15 @@ class AddAccountWizard(HubModal):
|
||||||
# ═══════════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
def _show_step_1_provider(self):
|
def _show_step_1_provider(self):
|
||||||
|
if self.oauth_session_id:
|
||||||
|
try:
|
||||||
|
from antigravity_provider.router.profile_oauth import cancel_oauth_session
|
||||||
|
cancel_oauth_session(self.oauth_session_id)
|
||||||
|
self.oauth_session_id = None
|
||||||
|
self.oauth_url = None
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
self._polling_active = False
|
||||||
self._clear_body()
|
self._clear_body()
|
||||||
self.title_lbl.configure(text="Шаг 1 из 4: Выберите провайдера ИИ")
|
self.title_lbl.configure(text="Шаг 1 из 4: Выберите провайдера ИИ")
|
||||||
|
|
||||||
|
|
@ -58,45 +73,54 @@ class AddAccountWizard(HubModal):
|
||||||
text="Выберите платформу для подключения учетной записи:",
|
text="Выберите платформу для подключения учетной записи:",
|
||||||
font=Theme.font_body(),
|
font=Theme.font_body(),
|
||||||
text_color=Theme.TEXT_SECONDARY,
|
text_color=Theme.TEXT_SECONDARY,
|
||||||
).pack(anchor="w", pady=(0, 12))
|
).pack(anchor="w", pady=(0, 10))
|
||||||
|
|
||||||
prov_var = ctk.StringVar(value=self.selected_provider)
|
|
||||||
|
|
||||||
providers = [
|
providers = [
|
||||||
("antigravity", "Google Antigravity", "OAuth 2.0 • Gemini 2.5 Pro, Gemini 2.5 Flash, Claude Sonnet", Theme.PROVIDER_ANTIGRAVITY),
|
("antigravity", "Google Antigravity", "OAuth 2.0 (Google Account)", Theme.ACCENT),
|
||||||
("openai-codex", "OpenAI Codex", "API Key • GPT-5.3 Codex, GPT-5.1 Codex Mini", Theme.PROVIDER_CODEX),
|
("openai-codex", "OpenAI Codex", "API Key (Codex / GPT-4)", "#10a37f"),
|
||||||
("opencode-go", "OpenCode Go", "Bearer API Key • OpenCode Go 3", Theme.PROVIDER_OPENCODE),
|
("opencode-go", "OpenCode Go", "API Key / Subscription", "#8b5cf6"),
|
||||||
]
|
]
|
||||||
|
|
||||||
for p_id, p_name, p_desc, p_col in providers:
|
self.provider_var = ctk.StringVar(value=self.selected_provider)
|
||||||
card = HubCard(self.body, border_color=Theme.BORDER, fg_color=Theme.SURFACE)
|
|
||||||
card.pack(fill="x", pady=6)
|
for p_id, p_title, p_desc, p_color in providers:
|
||||||
|
card = HubCard(self.body)
|
||||||
|
card.pack(fill="x", pady=4)
|
||||||
|
|
||||||
rb = ctk.CTkRadioButton(
|
rb = ctk.CTkRadioButton(
|
||||||
card,
|
card,
|
||||||
text=p_name,
|
text="",
|
||||||
variable=prov_var,
|
|
||||||
value=p_id,
|
value=p_id,
|
||||||
font=Theme.font_heading(),
|
variable=self.provider_var,
|
||||||
text_color=p_col,
|
width=24,
|
||||||
fg_color=Theme.ACCENT,
|
fg_color=Theme.ACCENT,
|
||||||
hover_color=Theme.ACCENT_HOVER,
|
hover_color=Theme.ACCENT_HOVER,
|
||||||
)
|
)
|
||||||
rb.pack(anchor="w", padx=16, pady=(10, 2))
|
rb.pack(side="left", padx=(12, 8), pady=12)
|
||||||
|
|
||||||
|
info_f = ctk.CTkFrame(card, fg_color="transparent")
|
||||||
|
info_f.pack(side="left", fill="both", expand=True, pady=8)
|
||||||
|
|
||||||
ctk.CTkLabel(
|
ctk.CTkLabel(
|
||||||
card,
|
info_f,
|
||||||
|
text=p_title,
|
||||||
|
font=Theme.font_body_bold(),
|
||||||
|
text_color=Theme.TEXT_PRIMARY,
|
||||||
|
).pack(anchor="w")
|
||||||
|
|
||||||
|
ctk.CTkLabel(
|
||||||
|
info_f,
|
||||||
text=p_desc,
|
text=p_desc,
|
||||||
font=Theme.font_caption(),
|
font=Theme.font_caption(),
|
||||||
text_color=Theme.TEXT_MUTED,
|
text_color=Theme.TEXT_SECONDARY,
|
||||||
).pack(anchor="w", padx=44, pady=(0, 10))
|
).pack(anchor="w")
|
||||||
|
|
||||||
def _next():
|
|
||||||
self.selected_provider = prov_var.get()
|
|
||||||
self._show_step_2_auth()
|
|
||||||
|
|
||||||
HubButton(self.footer, text="Отмена", variant="secondary", width=100, command=self.destroy).pack(side="left")
|
HubButton(self.footer, text="Отмена", variant="secondary", width=100, command=self.destroy).pack(side="left")
|
||||||
HubButton(self.footer, text="Далее ➔", variant="primary", width=120, command=_next).pack(side="right")
|
HubButton(self.footer, text="Далее ➔", variant="primary", width=140, command=self._on_provider_selected).pack(side="right")
|
||||||
|
|
||||||
|
def _on_provider_selected(self):
|
||||||
|
self.selected_provider = self.provider_var.get()
|
||||||
|
self._show_step_2_auth()
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════════
|
||||||
# STEP 2: Authentication
|
# STEP 2: Authentication
|
||||||
|
|
@ -122,53 +146,159 @@ class AddAccountWizard(HubModal):
|
||||||
text_color=Theme.TEXT_SECONDARY,
|
text_color=Theme.TEXT_SECONDARY,
|
||||||
wraplength=540,
|
wraplength=540,
|
||||||
justify="left",
|
justify="left",
|
||||||
).pack(anchor="w", pady=(0, 10))
|
).pack(anchor="w", pady=(0, 6))
|
||||||
|
|
||||||
steps_card = HubCard(self.body, fg_color=Theme.SURFACE_MUTED)
|
|
||||||
steps_card.pack(fill="x", pady=(0, 12))
|
|
||||||
|
|
||||||
instructions = (
|
|
||||||
"1. Нажмите «Открыть браузер» для перехода на страницу Google.\n"
|
|
||||||
"2. Войдите в нужный Google аккаунт и предоставьте доступ.\n"
|
|
||||||
"3. Hermes Hub автоматически перехватит токен и завершит подключение."
|
|
||||||
)
|
|
||||||
ctk.CTkLabel(
|
|
||||||
steps_card,
|
|
||||||
text=instructions,
|
|
||||||
font=Theme.font_caption(),
|
|
||||||
text_color=Theme.TEXT_PRIMARY,
|
|
||||||
justify="left",
|
|
||||||
).pack(padx=14, pady=12, anchor="w")
|
|
||||||
|
|
||||||
self.oauth_status_lbl = ctk.CTkLabel(
|
self.oauth_status_lbl = ctk.CTkLabel(
|
||||||
self.body,
|
self.body,
|
||||||
text="Статус: Ожидание запуска OAuth...",
|
text="Статус: Запуск локального слушателя OAuth...",
|
||||||
font=Theme.font_body_bold(),
|
font=Theme.font_body_bold(),
|
||||||
text_color=Theme.STATUS_WARNING,
|
text_color=Theme.STATUS_WARNING,
|
||||||
)
|
)
|
||||||
self.oauth_status_lbl.pack(pady=8)
|
self.oauth_status_lbl.pack(anchor="w", pady=(0, 6))
|
||||||
|
|
||||||
btns_row = ctk.CTkFrame(self.body, fg_color="transparent")
|
# URL display card
|
||||||
btns_row.pack(fill="x", pady=4)
|
url_card = HubCard(self.body, fg_color=Theme.SURFACE_MUTED)
|
||||||
|
url_card.pack(fill="x", pady=(0, 8))
|
||||||
|
|
||||||
HubButton(
|
ctk.CTkLabel(
|
||||||
btns_row,
|
url_card,
|
||||||
|
text="Ссылка для авторизации:",
|
||||||
|
font=Theme.font_caption(),
|
||||||
|
text_color=Theme.TEXT_SECONDARY,
|
||||||
|
).pack(anchor="w", padx=10, pady=(8, 2))
|
||||||
|
|
||||||
|
self.oauth_url_entry = ctk.CTkEntry(
|
||||||
|
url_card,
|
||||||
|
font=Theme.font_mono(),
|
||||||
|
height=34,
|
||||||
|
fg_color=Theme.PRIMARY,
|
||||||
|
border_color=Theme.BORDER,
|
||||||
|
text_color=Theme.TEXT_PRIMARY,
|
||||||
|
)
|
||||||
|
self.oauth_url_entry.pack(fill="x", padx=10, pady=(2, 10))
|
||||||
|
|
||||||
|
# Action buttons row
|
||||||
|
self.oauth_btns_row = ctk.CTkFrame(self.body, fg_color="transparent")
|
||||||
|
self.oauth_btns_row.pack(fill="x", pady=4)
|
||||||
|
|
||||||
|
self.open_browser_btn = HubButton(
|
||||||
|
self.oauth_btns_row,
|
||||||
text="🌐 Открыть браузер",
|
text="🌐 Открыть браузер",
|
||||||
variant="primary",
|
variant="primary",
|
||||||
width=180,
|
width=170,
|
||||||
command=self._start_antigravity_oauth,
|
command=self._open_oauth_browser,
|
||||||
).pack(side="left", padx=(0, 8))
|
)
|
||||||
|
self.open_browser_btn.pack(side="left", padx=(0, 8))
|
||||||
|
|
||||||
HubButton(
|
self.copy_url_btn = HubButton(
|
||||||
btns_row,
|
self.oauth_btns_row,
|
||||||
text="📋 Копировать ссылку",
|
text="📋 Копировать ссылку",
|
||||||
variant="secondary",
|
variant="secondary",
|
||||||
width=160,
|
width=160,
|
||||||
command=self._copy_oauth_url,
|
command=self._copy_oauth_url,
|
||||||
).pack(side="left")
|
)
|
||||||
|
self.copy_url_btn.pack(side="left", padx=(0, 8))
|
||||||
|
|
||||||
|
self.regen_btn = HubButton(
|
||||||
|
self.oauth_btns_row,
|
||||||
|
text="🔄 Создать новую ссылку",
|
||||||
|
variant="secondary",
|
||||||
|
width=180,
|
||||||
|
command=self._regenerate_oauth_session,
|
||||||
|
)
|
||||||
|
|
||||||
HubButton(self.footer, text="⬅ Назад", variant="secondary", width=100, command=self._show_step_1_provider).pack(side="left")
|
HubButton(self.footer, text="⬅ Назад", variant="secondary", width=100, command=self._show_step_1_provider).pack(side="left")
|
||||||
|
|
||||||
|
# Initialize session immediately
|
||||||
|
self._init_antigravity_oauth_session()
|
||||||
|
|
||||||
|
def _init_antigravity_oauth_session(self):
|
||||||
|
try:
|
||||||
|
from antigravity_provider.router.profile_oauth import start_profile_oauth
|
||||||
|
self.oauth_session_id, self.oauth_url, self.oauth_port = start_profile_oauth(self.target_slot)
|
||||||
|
self.oauth_url_entry.configure(state="normal")
|
||||||
|
self.oauth_url_entry.delete(0, "end")
|
||||||
|
self.oauth_url_entry.insert(0, self.oauth_url)
|
||||||
|
self.oauth_url_entry.configure(state="readonly")
|
||||||
|
self.oauth_status_lbl.configure(
|
||||||
|
text="Ссылка готова. Ожидание входа в Google.",
|
||||||
|
text_color=Theme.STATUS_HEALTHY,
|
||||||
|
)
|
||||||
|
if hasattr(self, "regen_btn") and self.regen_btn.winfo_exists():
|
||||||
|
self.regen_btn.pack_forget()
|
||||||
|
self._polling_active = True
|
||||||
|
threading.Thread(target=self._poll_oauth, daemon=True).start()
|
||||||
|
except Exception as e:
|
||||||
|
self.oauth_status_lbl.configure(
|
||||||
|
text=f"❌ Ошибка запуска OAuth слушателя: {e}",
|
||||||
|
text_color=Theme.STATUS_ERROR,
|
||||||
|
)
|
||||||
|
if hasattr(self, "regen_btn") and self.regen_btn.winfo_exists():
|
||||||
|
self.regen_btn.pack(side="left")
|
||||||
|
|
||||||
|
def _open_oauth_browser(self):
|
||||||
|
if not self.oauth_url:
|
||||||
|
self._init_antigravity_oauth_session()
|
||||||
|
if self.oauth_url:
|
||||||
|
import logging
|
||||||
|
logging.getLogger("hermes.router.profile_oauth").info(
|
||||||
|
"OAUTH browser opening redirect_port=%d", getattr(self, "oauth_port", 51121)
|
||||||
|
)
|
||||||
|
webbrowser.open(self.oauth_url)
|
||||||
|
self.oauth_status_lbl.configure(
|
||||||
|
text="🌐 Браузер открыт. Завершите авторизацию в Google...",
|
||||||
|
text_color=Theme.ACCENT,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _copy_oauth_url(self):
|
||||||
|
if self.oauth_url:
|
||||||
|
self.clipboard_clear()
|
||||||
|
self.clipboard_append(self.oauth_url)
|
||||||
|
self.oauth_status_lbl.configure(
|
||||||
|
text="✅ Ссылка скопирована в буфер обмена!",
|
||||||
|
text_color=Theme.STATUS_HEALTHY,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _regenerate_oauth_session(self):
|
||||||
|
if self.oauth_session_id:
|
||||||
|
try:
|
||||||
|
from antigravity_provider.router.profile_oauth import cancel_oauth_session
|
||||||
|
cancel_oauth_session(self.oauth_session_id)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
self._init_antigravity_oauth_session()
|
||||||
|
|
||||||
|
def _poll_oauth(self):
|
||||||
|
from antigravity_provider.router.profile_oauth import get_oauth_session
|
||||||
|
for _ in range(300):
|
||||||
|
if not self._polling_active:
|
||||||
|
return
|
||||||
|
time.sleep(1)
|
||||||
|
session = get_oauth_session(self.oauth_session_id)
|
||||||
|
if not session:
|
||||||
|
continue
|
||||||
|
|
||||||
|
status = getattr(session, "status", "").lower()
|
||||||
|
if status in ("completed", "success"):
|
||||||
|
info = getattr(session, "completed_profile_info", {}) or {}
|
||||||
|
self.discovered_identity = info.get("email") or "Google Account"
|
||||||
|
self.discovered_models = ["gemini-2.5-pro", "gemini-2.5-flash", "gemini-2.5-flash-thinking"]
|
||||||
|
self.is_verified = True
|
||||||
|
self.after(0, self._show_step_3_validation)
|
||||||
|
return
|
||||||
|
elif status in ("error", "failed", "cancelled"):
|
||||||
|
err_msg = getattr(session, "error_msg", None) or "Авторизация отменена или не удалась"
|
||||||
|
self.after(0, lambda m=err_msg: self._handle_oauth_failure(f"❌ {m}"))
|
||||||
|
return
|
||||||
|
elif status == "timeout":
|
||||||
|
self.after(0, lambda: self._handle_oauth_failure("❌ Время ожидания авторизации истекло"))
|
||||||
|
return
|
||||||
|
|
||||||
|
def _handle_oauth_failure(self, msg: str):
|
||||||
|
self.oauth_status_lbl.configure(text=msg, text_color=Theme.STATUS_ERROR)
|
||||||
|
if hasattr(self, "regen_btn") and self.regen_btn.winfo_exists():
|
||||||
|
self.regen_btn.pack(side="left")
|
||||||
|
|
||||||
def _build_api_key_flow(self):
|
def _build_api_key_flow(self):
|
||||||
ctk.CTkLabel(
|
ctk.CTkLabel(
|
||||||
self.body,
|
self.body,
|
||||||
|
|
@ -230,50 +360,6 @@ class AddAccountWizard(HubModal):
|
||||||
HubButton(self.footer, text="⬅ Назад", variant="secondary", width=100, command=self._show_step_1_provider).pack(side="left")
|
HubButton(self.footer, text="⬅ Назад", variant="secondary", width=100, command=self._show_step_1_provider).pack(side="left")
|
||||||
HubButton(self.footer, text="Проверить и сохранить ➔", variant="primary", width=200, command=_save_key).pack(side="right")
|
HubButton(self.footer, text="Проверить и сохранить ➔", variant="primary", width=200, command=_save_key).pack(side="right")
|
||||||
|
|
||||||
def _start_antigravity_oauth(self):
|
|
||||||
self.oauth_status_lbl.configure(text="Запуск локального слушателя и открытие Google...")
|
|
||||||
try:
|
|
||||||
from antigravity_provider.router.profile_oauth import start_profile_oauth
|
|
||||||
self.oauth_session_id, self.oauth_url = start_profile_oauth(self.target_slot)
|
|
||||||
webbrowser.open(self.oauth_url)
|
|
||||||
self.oauth_status_lbl.configure(text="🌐 Ожидание авторизации в браузере...")
|
|
||||||
self._polling_active = True
|
|
||||||
threading.Thread(target=self._poll_oauth, daemon=True).start()
|
|
||||||
except Exception as e:
|
|
||||||
self.oauth_status_lbl.configure(text=f"Ошибка: {e}")
|
|
||||||
|
|
||||||
def _copy_oauth_url(self):
|
|
||||||
if self.oauth_url:
|
|
||||||
self.clipboard_clear()
|
|
||||||
self.clipboard_append(self.oauth_url)
|
|
||||||
self.oauth_status_lbl.configure(text="✅ Ссылка скопирована в буфер обмена!")
|
|
||||||
|
|
||||||
def _poll_oauth(self):
|
|
||||||
from antigravity_provider.router.profile_oauth import get_oauth_session
|
|
||||||
for _ in range(120):
|
|
||||||
if not self._polling_active:
|
|
||||||
return
|
|
||||||
time.sleep(1)
|
|
||||||
session = get_oauth_session(self.oauth_session_id)
|
|
||||||
if not session:
|
|
||||||
continue
|
|
||||||
|
|
||||||
status = getattr(session, "status", "").lower()
|
|
||||||
if status in ("completed", "success"):
|
|
||||||
info = getattr(session, "completed_profile_info", {}) or {}
|
|
||||||
self.discovered_identity = info.get("email") or "Google Account"
|
|
||||||
self.discovered_models = ["gemini-2.5-pro", "gemini-2.5-flash", "gemini-2.5-flash-thinking"]
|
|
||||||
self.is_verified = True
|
|
||||||
self.after(0, self._show_step_3_validation)
|
|
||||||
return
|
|
||||||
elif status in ("error", "failed", "cancelled"):
|
|
||||||
err_msg = getattr(session, "error_msg", None) or "Авторизация отменена или не удалась"
|
|
||||||
self.after(0, lambda m=err_msg: self.oauth_status_lbl.configure(text=f"❌ {m}"))
|
|
||||||
return
|
|
||||||
elif status == "timeout":
|
|
||||||
self.after(0, lambda: self.oauth_status_lbl.configure(text="❌ Время ожидания авторизации истекло"))
|
|
||||||
return
|
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════════
|
||||||
# STEP 3: Validation & Duplicate Detection
|
# STEP 3: Validation & Duplicate Detection
|
||||||
# ═══════════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
|
|
||||||
233
tests/test_oauth_lifecycle.py
Normal file
233
tests/test_oauth_lifecycle.py
Normal file
|
|
@ -0,0 +1,233 @@
|
||||||
|
"""Hermes Hub — Comprehensive Google Antigravity OAuth Lifecycle Test Suite.
|
||||||
|
|
||||||
|
Verifies:
|
||||||
|
1. Callback listener exists immediately after start.
|
||||||
|
2. redirect_uri strictly matches the actual listening port.
|
||||||
|
3. Listener remains alive during idle wait.
|
||||||
|
4. Simulated valid callback is accepted and exchanges tokens.
|
||||||
|
5. State mismatch callback is rejected.
|
||||||
|
6. Timeout terminates listener cleanly.
|
||||||
|
7. Cancel / wizard close terminates listener.
|
||||||
|
8. Listener stays alive until code exchange completes.
|
||||||
|
9. Retry after cancel / timeout succeeds without port collision.
|
||||||
|
10. Immediate Step 2 URL availability and single-session invariance.
|
||||||
|
11. Copy URL works without Open Browser.
|
||||||
|
12. Repeated Open Browser clicks reuse identical session and state.
|
||||||
|
13. Regeneration creates a new session / state / port and invalidates old callback.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import time
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from antigravity_provider.router.profile_oauth import (
|
||||||
|
ProfileOAuthSession,
|
||||||
|
start_profile_oauth,
|
||||||
|
get_oauth_session,
|
||||||
|
cancel_oauth_session,
|
||||||
|
_ACTIVE_OAUTH_SESSIONS,
|
||||||
|
)
|
||||||
|
from antigravity_provider.router.profile_manager import ProfileAuthManager
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def cleanup_oauth_sessions():
|
||||||
|
"""Ensure all sessions are cancelled and cleared after each test."""
|
||||||
|
yield
|
||||||
|
for s_id in list(_ACTIVE_OAUTH_SESSIONS.keys()):
|
||||||
|
cancel_oauth_session(s_id)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_oauth_listener_exists_and_port_matches(tmp_path, monkeypatch):
|
||||||
|
"""1 & 2: Verify callback listener exists and redirect_uri uses the exact bound port."""
|
||||||
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
|
||||||
|
|
||||||
|
session_id, auth_url, port = start_profile_oauth("ag-orch-primary")
|
||||||
|
session = get_oauth_session(session_id)
|
||||||
|
|
||||||
|
assert session is not None
|
||||||
|
assert session.is_listening is True
|
||||||
|
assert f":{port}/oauth-callback" in session.redirect_uri
|
||||||
|
assert f":{port}/oauth-callback" in urllib.parse.unquote(auth_url)
|
||||||
|
assert session.status == "pending"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_oauth_listener_remains_alive_during_wait(tmp_path, monkeypatch):
|
||||||
|
"""3: Verify listener socket remains listening during idle wait."""
|
||||||
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
|
||||||
|
|
||||||
|
session_id, auth_url, port = start_profile_oauth("ag-orch-primary")
|
||||||
|
session = get_oauth_session(session_id)
|
||||||
|
|
||||||
|
# Let it idle for 0.5s
|
||||||
|
time.sleep(0.5)
|
||||||
|
assert session.is_listening is True
|
||||||
|
assert session.status == "pending"
|
||||||
|
|
||||||
|
# Ping non-callback path (should get 404, but server must stay alive)
|
||||||
|
try:
|
||||||
|
req = urllib.request.Request(f"http://127.0.0.1:{port}/random-probe")
|
||||||
|
with urllib.request.urlopen(req, timeout=2) as resp:
|
||||||
|
pass
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
assert e.code == 404
|
||||||
|
|
||||||
|
# Server must still be alive!
|
||||||
|
time.sleep(0.2)
|
||||||
|
assert session.is_listening is True
|
||||||
|
assert session.status == "pending"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_simulated_valid_callback_success(tmp_path, monkeypatch):
|
||||||
|
"""4 & 8: Verify valid callback is accepted and tokens are finalized."""
|
||||||
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
|
||||||
|
|
||||||
|
session_id, auth_url, port = start_profile_oauth("ag-orch-primary")
|
||||||
|
session = get_oauth_session(session_id)
|
||||||
|
|
||||||
|
mock_tokens = {
|
||||||
|
"access_token": "ya29.mock_token",
|
||||||
|
"refresh_token": "1//mock_refresh",
|
||||||
|
"expires_at": int(time.time()) + 3600,
|
||||||
|
"token_type": "Bearer",
|
||||||
|
}
|
||||||
|
|
||||||
|
with patch("antigravity_provider.router.profile_oauth.exchange_code_for_tokens", return_value=mock_tokens), \
|
||||||
|
patch("antigravity_provider.router.profile_oauth.fetch_user_email", return_value="developer@google.com"):
|
||||||
|
|
||||||
|
# Send HTTP GET callback matching state and code
|
||||||
|
callback_url = f"http://127.0.0.1:{port}/oauth-callback?code=mock_auth_code_123&state={session.state}"
|
||||||
|
req = urllib.request.Request(callback_url)
|
||||||
|
with urllib.request.urlopen(req, timeout=5) as resp:
|
||||||
|
assert resp.status == 200
|
||||||
|
content = resp.read().decode("utf-8")
|
||||||
|
assert "Account Authorized" in content
|
||||||
|
|
||||||
|
# Wait briefly for thread finalization
|
||||||
|
deadline = time.time() + 3.0
|
||||||
|
while time.time() < deadline and session.status == "pending":
|
||||||
|
time.sleep(0.05)
|
||||||
|
|
||||||
|
assert session.status == "completed"
|
||||||
|
assert session.completed_profile_info["email"] == "developer@google.com"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_state_mismatch_rejected(tmp_path, monkeypatch):
|
||||||
|
"""5: Verify callback with mismatched state is rejected as failed."""
|
||||||
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
|
||||||
|
|
||||||
|
session_id, auth_url, port = start_profile_oauth("ag-orch-primary")
|
||||||
|
session = get_oauth_session(session_id)
|
||||||
|
|
||||||
|
bad_state = "totally_wrong_state_value"
|
||||||
|
callback_url = f"http://127.0.0.1:{port}/oauth-callback?code=mock_code&state={bad_state}"
|
||||||
|
req = urllib.request.Request(callback_url)
|
||||||
|
with urllib.request.urlopen(req, timeout=5) as resp:
|
||||||
|
assert resp.status == 200
|
||||||
|
|
||||||
|
deadline = time.time() + 3.0
|
||||||
|
while time.time() < deadline and session.status == "pending":
|
||||||
|
time.sleep(0.05)
|
||||||
|
|
||||||
|
assert session.status == "failed"
|
||||||
|
assert "State mismatch" in session.error_msg
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_cancel_session_terminates_listener(tmp_path, monkeypatch):
|
||||||
|
"""6 & 7: Verify explicit cancel terminates listener immediately."""
|
||||||
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
|
||||||
|
|
||||||
|
session_id, auth_url, port = start_profile_oauth("ag-orch-primary")
|
||||||
|
session = get_oauth_session(session_id)
|
||||||
|
assert session.is_listening is True
|
||||||
|
|
||||||
|
cancel_oauth_session(session_id)
|
||||||
|
time.sleep(0.2)
|
||||||
|
|
||||||
|
assert session.status == "cancelled"
|
||||||
|
assert session.is_listening is False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_retry_after_cancel_works_cleanly(tmp_path, monkeypatch):
|
||||||
|
"""9: Verify retry after cancel opens a new listener cleanly."""
|
||||||
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
|
||||||
|
|
||||||
|
# First attempt
|
||||||
|
s1_id, url1, port1 = start_profile_oauth("ag-orch-primary")
|
||||||
|
s1 = get_oauth_session(s1_id)
|
||||||
|
cancel_oauth_session(s1_id)
|
||||||
|
time.sleep(0.2)
|
||||||
|
|
||||||
|
# Second attempt
|
||||||
|
s2_id, url2, port2 = start_profile_oauth("ag-orch-primary")
|
||||||
|
s2 = get_oauth_session(s2_id)
|
||||||
|
assert s2 is not None
|
||||||
|
assert s2.is_listening is True
|
||||||
|
assert s2.session_id != s1_id
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_single_session_invariance_and_regeneration(tmp_path, monkeypatch):
|
||||||
|
"""10-13: Test Wizard Step 2 immediate URL availability, single-session reuse, and regeneration."""
|
||||||
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
|
||||||
|
pytest.importorskip("customtkinter")
|
||||||
|
import customtkinter as ctk
|
||||||
|
from antigravity_provider.router.ui.add_account_wizard import AddAccountWizard
|
||||||
|
|
||||||
|
root = ctk.CTk()
|
||||||
|
root.withdraw()
|
||||||
|
try:
|
||||||
|
wizard = AddAccountWizard(root)
|
||||||
|
wizard.selected_provider = "antigravity"
|
||||||
|
wizard.target_slot = "ag-spare-1"
|
||||||
|
|
||||||
|
# 1. Opening Step 2 initializes OAuth immediately
|
||||||
|
wizard._show_step_2_auth()
|
||||||
|
assert wizard.oauth_url is not None
|
||||||
|
assert wizard.oauth_session_id is not None
|
||||||
|
assert wizard.oauth_url.startswith("https://accounts.google.com")
|
||||||
|
|
||||||
|
# 2. URL entry contains the URL
|
||||||
|
entry_text = wizard.oauth_url_entry.get()
|
||||||
|
assert entry_text == wizard.oauth_url
|
||||||
|
|
||||||
|
# 3. Repeated Open Browser does NOT change session or state
|
||||||
|
orig_session_id = wizard.oauth_session_id
|
||||||
|
orig_url = wizard.oauth_url
|
||||||
|
|
||||||
|
with patch("webbrowser.open") as mock_open:
|
||||||
|
wizard._open_oauth_browser()
|
||||||
|
assert mock_open.call_count == 1
|
||||||
|
assert mock_open.call_args[0][0] == orig_url
|
||||||
|
assert wizard.oauth_session_id == orig_session_id
|
||||||
|
|
||||||
|
wizard._open_oauth_browser()
|
||||||
|
assert mock_open.call_count == 2
|
||||||
|
assert wizard.oauth_session_id == orig_session_id
|
||||||
|
assert wizard.oauth_url == orig_url
|
||||||
|
|
||||||
|
# 4. Explicit regeneration creates NEW session and state
|
||||||
|
wizard._regenerate_oauth_session()
|
||||||
|
new_session_id = wizard.oauth_session_id
|
||||||
|
new_url = wizard.oauth_url
|
||||||
|
|
||||||
|
assert new_session_id != orig_session_id
|
||||||
|
assert new_url != orig_url
|
||||||
|
|
||||||
|
# Old session must be cancelled
|
||||||
|
old_session = get_oauth_session(orig_session_id)
|
||||||
|
assert old_session is None or old_session.status == "cancelled"
|
||||||
|
|
||||||
|
wizard.destroy()
|
||||||
|
finally:
|
||||||
|
root.destroy()
|
||||||
Loading…
Reference in a new issue