436 lines
16 KiB
Python
436 lines
16 KiB
Python
"""Shared device-code / browser / TLS helpers for interactive OAuth logins.
|
|
|
|
Split out of ``hermes_cli/auth.py``; every moved name is re-imported there, so
|
|
``hermes_cli.auth.<name>`` keeps resolving (and monkeypatching) as before. Origin-internal
|
|
helpers are imported lazily inside each function (no import cycle; patches on
|
|
``hermes_cli.auth.<helper>`` still intercept).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from typing import FrozenSet
|
|
import os
|
|
import ssl
|
|
import sys
|
|
import time
|
|
import webbrowser
|
|
from pathlib import Path
|
|
from typing import Any, Callable, Dict, Optional
|
|
from urllib.parse import urlparse
|
|
from hermes_cli.auth_constants import (
|
|
AuthError,
|
|
DEFAULT_NOUS_PORTAL_URL,
|
|
DEVICE_AUTH_POLL_INTERVAL_CAP_SECONDS,
|
|
DEVICE_CODE_GRANT_TYPE,
|
|
OAUTH_OVER_SSH_DOCS_URL,
|
|
httpx,
|
|
)
|
|
from utils import is_truthy_value
|
|
|
|
# Log-record parity with the origin module (caplog tests pin "hermes_cli.auth").
|
|
logger = logging.getLogger("hermes_cli.auth")
|
|
|
|
|
|
# Console/text-mode browsers that ``webbrowser`` will happily launch INSIDE
|
|
# the terminal. Opening one of these is worse than not opening anything —
|
|
# it hijacks the user's TTY with an unusable text browser (the xAI OAuth
|
|
# "Account Management" page rendered in w3m, reported May 2026) instead of
|
|
# letting them copy the URL to a real browser. When the resolved browser is
|
|
# one of these we refuse to auto-open and fall back to the print-the-URL
|
|
# path, same as a remote session.
|
|
_CONSOLE_BROWSER_NAMES: FrozenSet[str] = frozenset(
|
|
{
|
|
"w3m",
|
|
"lynx",
|
|
"links",
|
|
"links2",
|
|
"elinks",
|
|
"www-browser",
|
|
"browsh", # TUI browser — still hijacks the terminal
|
|
}
|
|
)
|
|
|
|
|
|
def _is_remote_session() -> bool:
|
|
"""Detect environments where loopback OAuth can't reach the local browser.
|
|
|
|
These environments typically don't set ``SSH_CLIENT`` / ``SSH_TTY``, so the SSH-only check left
|
|
them with no guidance and no fallback.
|
|
"""
|
|
if os.getenv("SSH_CLIENT") or os.getenv("SSH_TTY"):
|
|
return True
|
|
# Browser-only remote IDEs / cloud shells. Keep this list narrow
|
|
# (well-known, documented env vars set by the host platform) so
|
|
# we don't falsely trip on a developer's local shell.
|
|
for var in (
|
|
"CLOUD_SHELL", # GCP Cloud Shell
|
|
"CODESPACES", # GitHub Codespaces
|
|
"CODESPACE_NAME", # GitHub Codespaces (alt)
|
|
"GITPOD_WORKSPACE_ID", # Gitpod
|
|
"REPL_ID", # Replit
|
|
"STACKBLITZ", # StackBlitz
|
|
):
|
|
if os.getenv(var):
|
|
return True
|
|
return False
|
|
|
|
|
|
def _can_open_graphical_browser() -> bool:
|
|
"""Return True only when a *graphical* browser is likely to open.
|
|
|
|
``webbrowser.open()`` resolves to whatever the platform offers, and on a headless / CLI-only
|
|
Linux box with no GUI browser installed that is often a text-mode browser (w3m/lynx/links) which
|
|
launches inside the terminal and takes over the user's session.
|
|
|
|
Heuristics: * Respect ``$BROWSER`` — if it names a known console browser, refuse. * On Linux,
|
|
require a display server (``$DISPLAY`` / ``$WAYLAND_DISPLAY``) unless ``$BROWSER`` points at
|
|
something graphical; no display server almost always means no GUI browser.
|
|
"""
|
|
import webbrowser as _webbrowser
|
|
|
|
def _names_console_browser(value: str) -> bool:
|
|
token = value.strip().split()[0] if value.strip() else ""
|
|
base = os.path.basename(token).lower()
|
|
return base in _CONSOLE_BROWSER_NAMES
|
|
|
|
browser_env = os.environ.get("BROWSER", "")
|
|
if browser_env and _names_console_browser(browser_env):
|
|
return False
|
|
|
|
if sys.platform.startswith("linux"):
|
|
has_display = bool(
|
|
os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY")
|
|
)
|
|
# An explicit graphical $BROWSER can work without $DISPLAY in odd
|
|
# setups, but a console $BROWSER already returned False above, so the
|
|
# only way to reach here with a $BROWSER set is a graphical one.
|
|
if not has_display and not browser_env:
|
|
return False
|
|
|
|
try:
|
|
controller = _webbrowser.get()
|
|
except Exception:
|
|
# No browser resolvable at all → definitely don't auto-open.
|
|
return False
|
|
|
|
candidate = (
|
|
getattr(controller, "name", "")
|
|
or getattr(controller, "basename", "")
|
|
or ""
|
|
)
|
|
return not (candidate and _names_console_browser(candidate))
|
|
|
|
|
|
def _ssh_user_at_host() -> str:
|
|
"""Return best-effort 'user@hostname' for the SSH tunnel hint command.
|
|
|
|
Falls back to placeholder tokens when the values cannot be determined so the hint is always
|
|
syntactically valid even if not copy-pasteable.
|
|
"""
|
|
try:
|
|
import socket as _socket
|
|
hostname = _socket.gethostname() or "<this-host>"
|
|
except OSError:
|
|
hostname = "<this-host>"
|
|
user = os.getenv("USER") or os.getenv("LOGNAME") or "<user>"
|
|
return f"{user}@{hostname}"
|
|
|
|
|
|
def _print_loopback_ssh_hint(redirect_uri: str, *, docs_url: str | None = None) -> None:
|
|
"""Print an SSH tunnel hint when running a loopback-redirect OAuth flow on a remote host. The auth
|
|
server (Spotify, MCP servers, ...) will redirect the user's browser to
|
|
``127.0.0.1:<port>/callback``. If the browser is on a different machine than the loopback
|
|
listener (the usual SSH case), the redirect can't reach the listener without a local port
|
|
forward.
|
|
"""
|
|
from hermes_cli.auth import _is_remote_session
|
|
if not _is_remote_session():
|
|
return
|
|
try:
|
|
parsed = urlparse(redirect_uri)
|
|
except Exception:
|
|
return
|
|
host = parsed.hostname or ""
|
|
port = parsed.port
|
|
if host not in {"127.0.0.1", "::1", "localhost"} or not port:
|
|
return
|
|
divider = "-" * 60
|
|
print()
|
|
print(divider)
|
|
print("Remote session detected — SSH tunnel required")
|
|
print(divider)
|
|
print(f"Hermes is waiting for the OAuth callback on {redirect_uri}")
|
|
print("but your browser is on a different machine. Run this command")
|
|
print("in a NEW terminal on your local machine BEFORE opening the URL:")
|
|
print()
|
|
print(f" ssh -N -L {port}:127.0.0.1:{port} {_ssh_user_at_host()}")
|
|
print()
|
|
print("Then open the authorize URL above in your local browser.")
|
|
if docs_url:
|
|
print(f"Provider docs: {docs_url}")
|
|
print(f"SSH/jump-box guide: {OAUTH_OVER_SSH_DOCS_URL}")
|
|
print(divider)
|
|
print()
|
|
|
|
|
|
def _default_verify() -> bool | ssl.SSLContext:
|
|
"""Platform-aware default SSL verify for httpx clients.
|
|
|
|
On macOS with Homebrew Python, the system OpenSSL cannot locate the system trust store and valid
|
|
public certs fail verification. When certifi is importable we pin its bundle explicitly;
|
|
elsewhere we defer to httpx's built-in default (certifi via its own dependency). Mirrors the
|
|
weixin fix in 3a0ec1d93.
|
|
"""
|
|
if sys.platform == "darwin":
|
|
try:
|
|
import certifi
|
|
return ssl.create_default_context(cafile=certifi.where())
|
|
except ImportError:
|
|
pass
|
|
return True
|
|
|
|
|
|
def _resolve_verify(
|
|
*,
|
|
insecure: Optional[bool] = None,
|
|
ca_bundle: Optional[str] = None,
|
|
auth_state: Optional[Dict[str, Any]] = None,
|
|
) -> bool | ssl.SSLContext:
|
|
from hermes_cli.auth import _default_verify
|
|
tls_state = auth_state.get("tls") if isinstance(auth_state, dict) else {}
|
|
tls_state = tls_state if isinstance(tls_state, dict) else {}
|
|
|
|
effective_insecure = (
|
|
is_truthy_value(insecure, default=False) if insecure is not None
|
|
else is_truthy_value(tls_state.get("insecure", False), default=False)
|
|
)
|
|
effective_ca = (
|
|
ca_bundle
|
|
or tls_state.get("ca_bundle")
|
|
or os.getenv("HERMES_CA_BUNDLE")
|
|
or os.getenv("SSL_CERT_FILE")
|
|
or os.getenv("REQUESTS_CA_BUNDLE")
|
|
)
|
|
|
|
if effective_insecure:
|
|
return False
|
|
if effective_ca:
|
|
ca_path = str(effective_ca)
|
|
if not os.path.isfile(ca_path):
|
|
logger.warning(
|
|
"CA bundle path does not exist: %s — falling back to default certificates",
|
|
ca_path,
|
|
)
|
|
return _default_verify()
|
|
return ssl.create_default_context(cafile=ca_path)
|
|
return _default_verify()
|
|
|
|
|
|
def _request_device_code(
|
|
client: httpx.Client,
|
|
portal_base_url: str,
|
|
client_id: str,
|
|
scope: Optional[str],
|
|
) -> Dict[str, Any]:
|
|
"""POST to the device code endpoint. Returns device_code, user_code, etc."""
|
|
response = client.post(
|
|
f"{portal_base_url}/api/oauth/device/code",
|
|
data={
|
|
"client_id": client_id,
|
|
**({"scope": scope} if scope else {}),
|
|
},
|
|
)
|
|
response.raise_for_status()
|
|
data = response.json()
|
|
|
|
required_fields = [
|
|
"device_code", "user_code", "verification_uri",
|
|
"verification_uri_complete", "expires_in", "interval",
|
|
]
|
|
missing = [f for f in required_fields if f not in data]
|
|
if missing:
|
|
raise ValueError(f"Device code response missing fields: {', '.join(missing)}")
|
|
return data
|
|
|
|
|
|
def _nous_device_auth_timeout_message(portal_base_url: str) -> str:
|
|
"""Actionable timeout text for Nous device-code login failures.
|
|
|
|
A bare "timed out" gives the user nothing to act on; the usual cause is Portal sign-in failing
|
|
in the opened browser tab, so point at the Portal login page and the retry command.
|
|
"""
|
|
portal = (portal_base_url or DEFAULT_NOUS_PORTAL_URL).rstrip("/")
|
|
return (
|
|
"Timed out waiting for device authorization.\n"
|
|
" Portal sign-in is required before the device code can be approved.\n"
|
|
" If the browser showed a CAPTCHA / 'You did not pass CAPTCHA' error,\n"
|
|
" finish signing in at the Portal in a normal browser tab, then retry:\n"
|
|
" hermes portal\n"
|
|
f" Portal login: {portal}/login"
|
|
)
|
|
|
|
|
|
def _print_device_code_instructions(
|
|
verification_url: str,
|
|
user_code: str,
|
|
*,
|
|
open_browser: bool,
|
|
failure_dash: str = "--",
|
|
swallow_open_errors: bool = False,
|
|
) -> None:
|
|
"""Print the shared "To continue" device-code block and optionally open the browser.
|
|
|
|
Callers decide *whether* to open (remote-session / graphical-browser gating differs per
|
|
provider); the wording of the fallback hint is parameterized so each provider keeps its
|
|
historical dash style.
|
|
"""
|
|
print()
|
|
print("To continue:")
|
|
print(f" 1. Open: {verification_url}")
|
|
print(f" 2. If prompted, enter code: {user_code}")
|
|
if not open_browser:
|
|
return
|
|
if swallow_open_errors:
|
|
try:
|
|
opened = webbrowser.open(verification_url)
|
|
except Exception:
|
|
opened = False
|
|
else:
|
|
opened = webbrowser.open(verification_url)
|
|
if opened:
|
|
print(" (Opened browser for verification)")
|
|
else:
|
|
print(f" Could not open browser automatically {failure_dash} use the URL above.")
|
|
|
|
|
|
def _poll_device_token_generic(
|
|
post: Callable[[], "httpx.Response"],
|
|
*,
|
|
expires_in: int,
|
|
poll_interval: int,
|
|
validate_success: Callable[[Dict[str, Any]], None],
|
|
on_non_json_error: Callable[["httpx.Response"], Exception],
|
|
on_error: Callable[["httpx.Response", Dict[str, Any]], Exception],
|
|
on_timeout: Callable[[], Exception],
|
|
) -> Dict[str, Any]:
|
|
"""RFC 8628 device-code polling loop shared by the Nous and xAI flows.
|
|
|
|
``authorization_pending`` sleeps and retries; ``slow_down`` grows the interval by 1s (cap 30s).
|
|
Every other error, a non-JSON error body, and the deadline are turned into provider-specific
|
|
exceptions by the supplied factories so each caller keeps its exact error contract.
|
|
"""
|
|
deadline = time.monotonic() + max(1, expires_in)
|
|
current_interval = poll_interval
|
|
while time.monotonic() < deadline:
|
|
response = post()
|
|
if response.status_code == 200:
|
|
payload = response.json()
|
|
validate_success(payload)
|
|
return payload
|
|
try:
|
|
error_payload = response.json()
|
|
except Exception:
|
|
response.raise_for_status()
|
|
raise on_non_json_error(response)
|
|
error_code = str(error_payload.get("error") or "")
|
|
if error_code == "authorization_pending":
|
|
time.sleep(current_interval)
|
|
continue
|
|
if error_code == "slow_down":
|
|
current_interval = min(current_interval + 1, 30)
|
|
time.sleep(current_interval)
|
|
continue
|
|
raise on_error(response, error_payload)
|
|
raise on_timeout()
|
|
|
|
|
|
def _poll_for_token(
|
|
client: httpx.Client,
|
|
portal_base_url: str,
|
|
client_id: str,
|
|
device_code: str,
|
|
expires_in: int,
|
|
poll_interval: int,
|
|
) -> Dict[str, Any]:
|
|
"""Poll the Nous token endpoint until the user approves or the code expires."""
|
|
def _validate(payload: Dict[str, Any]) -> None:
|
|
if "access_token" not in payload:
|
|
raise ValueError("Token response did not include access_token")
|
|
|
|
def _error(_response, error_payload) -> Exception:
|
|
error_code = error_payload.get("error", "")
|
|
description = error_payload.get("error_description") or "Unknown authentication error"
|
|
return RuntimeError(f"{error_code}: {description}")
|
|
|
|
return _poll_device_token_generic(
|
|
lambda: client.post(
|
|
f"{portal_base_url}/api/oauth/token",
|
|
data={
|
|
"grant_type": DEVICE_CODE_GRANT_TYPE,
|
|
"client_id": client_id,
|
|
"device_code": device_code,
|
|
},
|
|
),
|
|
expires_in=expires_in,
|
|
poll_interval=max(1, min(poll_interval, DEVICE_AUTH_POLL_INTERVAL_CAP_SECONDS)),
|
|
validate_success=_validate,
|
|
on_non_json_error=lambda _r: RuntimeError("Token endpoint returned a non-JSON error response"),
|
|
on_error=_error,
|
|
# Enriched at the SOURCE so every caller inherits the guidance:
|
|
# the CLI login (_nous_device_code_login) and the dashboard/desktop
|
|
# poller (web_server._nous_poller, which surfaces str(e) to the UI).
|
|
on_timeout=lambda: TimeoutError(_nous_device_auth_timeout_message(portal_base_url)),
|
|
)
|
|
|
|
|
|
def _prompt_yes_no(prompt: str, *, default: str) -> bool:
|
|
"""``input()`` a [Y/n]-style question; EOF/Ctrl-C count as *default*."""
|
|
try:
|
|
answer = input(prompt).strip().lower()
|
|
except (EOFError, KeyboardInterrupt):
|
|
answer = default
|
|
return answer in {"", "y", "yes"} if default == "y" else answer in {"y", "yes"}
|
|
|
|
|
|
def _print_login_success(provider_id: str, config_path: Path, *, show_auth_state: bool = False) -> None:
|
|
print()
|
|
print("Login successful!")
|
|
if show_auth_state:
|
|
from hermes_constants import display_hermes_home as _dhh
|
|
print(f" Auth state: {_dhh()}/auth.json")
|
|
print(f" Config updated: {config_path} (model.provider={provider_id})")
|
|
|
|
|
|
def _offer_existing_oauth_credentials(
|
|
provider_id: str,
|
|
*,
|
|
resolve: Callable[[], Dict[str, Any]],
|
|
is_expiring: Callable[[str, int], bool],
|
|
display_name: str,
|
|
default_base_url: str,
|
|
expired_notice: Optional[str] = None,
|
|
) -> bool:
|
|
"""Offer to reuse still-valid stored OAuth credentials. Returns True when the user accepted.
|
|
|
|
*resolve* attempts a refresh, so a resolved token should be valid — but double-check the
|
|
expiry before telling the user "Login successful!".
|
|
"""
|
|
from hermes_cli.auth import _update_config_for_provider
|
|
try:
|
|
existing = resolve()
|
|
api_key = existing.get("api_key", "")
|
|
if isinstance(api_key, str) and api_key and not is_expiring(api_key, 60):
|
|
print(f"Existing {display_name} credentials found in Hermes auth store.")
|
|
if _prompt_yes_no("Use existing credentials? [Y/n]: ", default="y"):
|
|
config_path = _update_config_for_provider(
|
|
provider_id, existing.get("base_url", default_base_url),
|
|
)
|
|
_print_login_success(provider_id, config_path)
|
|
return True
|
|
elif expired_notice:
|
|
print(expired_notice)
|
|
except AuthError:
|
|
pass
|
|
return False
|