File size: 18,339 Bytes
4df9a41 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 | """Shared device-code / loopback-PKCE / browser / TLS helpers for interactive OAuth logins.
Split out of ``hermes_cli/auth.py`` and re-exported there; origin helpers are imported lazily
inside each function so ``hermes_cli.auth.<name>`` patches still intercept (and no import cycle).
"""
from __future__ import annotations
import base64
import hashlib
import logging
import os
import ssl
import sys
import threading
import time
import webbrowser
from http.server import BaseHTTPRequestHandler, HTTPServer
from pathlib import Path
from typing import Any, Callable, Dict, FrozenSet, Optional
from urllib.parse import parse_qs, 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 launch INSIDE the terminal, hijacking the
# user's TTY with an unusable text 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
})
# Browser-only remote IDEs / cloud shells (they don't set SSH_CLIENT / SSH_TTY). Keep this list
# narrow β well-known env vars set by the host platform β so a local shell never trips it.
_REMOTE_IDE_ENV_VARS = (
"CLOUD_SHELL", # GCP Cloud Shell
"CODESPACES", "CODESPACE_NAME", # GitHub Codespaces
"GITPOD_WORKSPACE_ID", # Gitpod
"REPL_ID", # Replit
"STACKBLITZ", # StackBlitz
)
def _is_remote_session() -> bool:
"""Detect environments where loopback OAuth can't reach the local browser.
Historically only SSH was checked, but #26923 surfaced that **browser-only remote consoles** (GCP Cloud
Shell, GitHub Codespaces, AWS EC2 Instance Connect, Gitpod, Replit, etc.) hit the exact same problem β
the user has a browser on their laptop but the loopback listener is bound on the remote VM that the
laptop's browser can't reach. These environments typically don't set ``SSH_CLIENT`` / ``SSH_TTY``, so
the SSH-only check left them with no guidance and no fallback.
"""
return bool(
os.getenv("SSH_CLIENT") or os.getenv("SSH_TTY")
or any(os.getenv(var) for var in _REMOTE_IDE_ENV_VARS))
def _names_console_browser(value: str) -> bool:
token = value.strip().split()[0] if value.strip() else ""
return os.path.basename(token).lower() in _CONSOLE_BROWSER_NAMES
def _can_open_graphical_browser() -> bool:
"""Return True only when a *graphical* browser is likely to open.
On a headless Linux box ``webbrowser.open()`` often resolves to a text-mode browser that takes
over the terminal. Heuristics: a ``$BROWSER`` naming a console browser refuses; on Linux a
display server (``$DISPLAY`` / ``$WAYLAND_DISPLAY``) is required unless ``$BROWSER`` is set
(a console one already returned False, so a set ``$BROWSER`` here is graphical).
"""
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"))
if not has_display and not browser_env:
return False
try:
controller = webbrowser.get()
except Exception:
return False # No browser resolvable at all β definitely don't auto-open.
candidate = getattr(controller, "name", "") or getattr(controller, "basename", "") or ""
return not (candidate and _names_console_browser(candidate))
def _ssh_user_at_host() -> str:
"""Best-effort 'user@hostname' for the SSH tunnel hint; placeholders keep it valid syntax."""
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 _pkce_code_verifier(length: int = 64) -> str:
return base64.urlsafe_b64encode(os.urandom(length)).decode("ascii").rstrip("=")[:128]
def _pkce_code_challenge(code_verifier: str) -> str:
digest = hashlib.sha256(code_verifier.encode("utf-8")).digest()
return base64.urlsafe_b64encode(digest).decode("ascii").rstrip("=")
def _make_loopback_callback_handler(
expected_path: str, *, display_name: str,
) -> tuple[type[BaseHTTPRequestHandler], dict[str, Any]]:
"""Handler class for an RFC 8252 loopback redirect plus the dict it fills in.
Only a GET on *expected_path* is accepted (anything else is a 404 and leaves the result
untouched), so a nonce embedded in the path acts as the CSRF ``state`` for authorization
servers that do not echo an explicit ``state`` parameter.
"""
result: dict[str, Any] = {"code": None, "state": None, "error": None, "error_description": None}
class _LoopbackCallbackHandler(BaseHTTPRequestHandler):
def do_GET(self) -> None: # noqa: N802
parsed = urlparse(self.path)
if parsed.path != expected_path:
self.send_response(404)
self.end_headers()
self.wfile.write(b"Not found.")
return
params = parse_qs(parsed.query)
for key in result:
result[key] = params.get(key, [None])[0]
self.send_response(200)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.end_headers()
outcome = "failed" if result["error"] else "received"
self.wfile.write(
f"<html><body><h1>{display_name} authorization {outcome}.</h1>"
"You can close this tab.</body></html>".encode("utf-8"))
def log_message(self, format: str, *args: Any) -> None: # noqa: A003
return
return _LoopbackCallbackHandler, result
def _bind_loopback_callback_server(
host: str, port: int, handler_cls: type[BaseHTTPRequestHandler], *, err: Callable[..., AuthError],
bind_failed_code: str,
) -> HTTPServer:
"""Bind the loopback listener up front (``port=0`` = OS-assigned) so the redirect URI sent to
the authorization server names a port we already own β no probe-close-rebind race."""
class _ReuseHTTPServer(HTTPServer):
allow_reuse_address = True
try:
return _ReuseHTTPServer((host, port), handler_cls)
except OSError as exc:
raise err(f"Could not bind callback server on {host}:{port}: {exc}", bind_failed_code) from exc
def _serve_loopback_callback(
server: HTTPServer, result: dict[str, Any], *, timeout_seconds: float, err: Callable[..., AuthError],
timeout_code: str,
) -> dict[str, Any]:
"""Serve *server* until the redirect lands in *result* or the deadline passes; always closes."""
thread = threading.Thread(target=server.serve_forever, kwargs={"poll_interval": 0.1}, daemon=True)
thread.start()
deadline = time.monotonic() + max(5.0, timeout_seconds)
try:
while time.monotonic() < deadline:
if result["code"] or result["error"]:
return result
time.sleep(0.1)
finally:
server.shutdown()
server.server_close()
thread.join(timeout=1.0)
raise err("Authorization timed out waiting for the local callback.", timeout_code)
def _print_loopback_ssh_hint(redirect_uri: str, *, docs_url: str | None = None) -> None:
"""Print an SSH tunnel hint when a loopback-redirect OAuth flow runs on a remote host.
The auth server redirects the browser to ``127.0.0.1:<port>/callback``; when the browser is
on another machine (the SSH case) the redirect needs a local port forward to reach us.
"""
from hermes_cli.auth import _is_remote_session
if not _is_remote_session():
return
try:
parsed = urlparse(redirect_uri)
except Exception:
return
host, port = parsed.hostname or "", parsed.port
if host not in {"127.0.0.1", "::1", "localhost"} or not port:
return
divider = "-" * 60
print(
f"\n{divider}\nRemote session detected β SSH tunnel required\n{divider}\n"
f"Hermes is waiting for the OAuth callback on {redirect_uri}\n"
"but your browser is on a different machine. Run this command\n"
"in a NEW terminal on your local machine BEFORE opening the URL:\n\n"
f" ssh -N -L {port}:127.0.0.1:{port} {_ssh_user_at_host()}\n\n"
"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}\n{divider}\n")
def _default_verify() -> bool | ssl.SSLContext:
"""Platform-aware default SSL verify for httpx clients.
On macOS with Homebrew Python the system OpenSSL cannot find the system trust store, so pin
certifi's bundle when importable; elsewhere defer to httpx's built-in default.
"""
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: the usual cause is Portal sign-in failing in the browser tab.
A bare "Timed out waiting for device authorization" gives the user nothing to act on. The most common
cause is Portal sign-in failing in the opened browser tab (including the server-side CAPTCHA loop from
20605), so point at the Portal login page and the retry command. See #20605.
"""
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); *failure_dash* keeps each provider's historical hint wording.
"""
print()
print("To continue:")
print(f" 1. Open: {verification_url}")
print(f" 2. If prompted, enter code: {user_code}")
if not open_browser:
return
try:
opened = webbrowser.open(verification_url)
except Exception:
if not swallow_open_errors:
raise
opened = False
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 become provider-specific exceptions
via 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_error=_error,
on_non_json_error=lambda _r: RuntimeError(
"Token endpoint returned a non-JSON error response"),
# Enriched at the SOURCE so the CLI login and the dashboard/desktop poller
# (web_server_oauth._nous_promotion_poller surfaces it to the UI) both inherit the guidance.
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
|