"""Browser sign-in flow for the Honcho memory provider — no CLI step. ``begin_authorization`` / ``complete_authorization`` are the transport-agnostic core: the code can arrive via the loopback listener here or a future ``hermes://`` handler. Endpoints are env-overridable with local-dev defaults because ``/authorize`` (dashboard) and ``/oauth/token`` (API) live on different origins. """ from __future__ import annotations import base64 import hashlib import logging import os import secrets import threading import time from dataclasses import dataclass from http.server import BaseHTTPRequestHandler, HTTPServer from pathlib import Path from typing import Callable from urllib.parse import parse_qs, urlencode, urlparse from plugins.memory.honcho import oauth from plugins.memory.honcho.client import resolve_active_host, resolve_config_path logger = logging.getLogger(__name__) # The loopback redirect registered for the Hermes OAuth client. IP-literal so # the browser can't resolve the advertised host to ::1 and miss the IPv4 bind. LOOPBACK_HOST = "127.0.0.1" LOOPBACK_PORT = 8765 LOOPBACK_REDIRECT_URI = f"http://{LOOPBACK_HOST}:{LOOPBACK_PORT}/callback" # Pending authorizations live only until their callback returns; keyed by the # CSRF ``state`` so a stray/forged callback can't complete a grant. _PENDING_TTL_SECONDS = 600 def _display_config_path(path: object) -> str: """Home-relative display string for the consent screen. The absolute path (username + home layout) never leaves the machine — it's only shown to the user. Collapse ``$HOME`` to ``~``; for a path outside home, send the bare filename rather than leak an arbitrary absolute path. """ from pathlib import Path as _Path p = _Path(str(path)) try: return "~/" + str(p.relative_to(_Path.home())) except ValueError: return p.name @dataclass(frozen=True) class OAuthEndpoints: """Resolved authorization-server URLs and client identity.""" authorize_url: str # dashboard /authorize token_url: str # API /oauth/token client_id: str scope: str # Cloud (production) hosts; dashboard serves /authorize, API serves /oauth/token. _CLOUD_DASHBOARD = "https://app.honcho.dev" _CLOUD_TOKEN_URL = "https://api.honcho.dev/oauth/token" _LOCAL_DASHBOARD = "http://localhost:3000" _LOCAL_TOKEN_URL = "http://localhost:8000/oauth/token" # One OAuth client for every surface. Consent branding/UI adapt via the # ``source`` query param (not a separate client_id), so there's a single grant # identity to refresh — no clientId-vs-refresh-token desync to revoke the grant. _DEFAULT_CLIENT_ID = "hermes-agent" def _is_loopback_url(url: str | None) -> bool: return bool(url) and any(h in url for h in ("localhost", "127.0.0.1", "::1")) def resolve_endpoints( environment: str | None = None, base_url: str | None = None ) -> OAuthEndpoints: """Resolve OAuth endpoints, zero-config by default. Keys off the host's honcho ``environment`` (production → cloud, local → localhost); a self-hosted ``base_url`` derives the token endpoint from the API host. Env vars override every field for unusual deployments. """ if environment is None or base_url is None: try: from plugins.memory.honcho.client import HonchoClientConfig cfg = HonchoClientConfig.from_global_config() environment = environment or cfg.environment base_url = base_url if base_url is not None else cfg.base_url except Exception: environment = environment or "production" is_local = (environment or "").lower() == "local" or _is_loopback_url(base_url) default_dashboard = _LOCAL_DASHBOARD if is_local else _CLOUD_DASHBOARD default_token = _LOCAL_TOKEN_URL if is_local else _CLOUD_TOKEN_URL # Self-hosted API (non-loopback base_url): token rides the same host. if base_url and not is_local: default_token = f"{base_url.rstrip('/')}/oauth/token" dashboard = os.environ.get("HONCHO_OAUTH_DASHBOARD", default_dashboard).rstrip("/") return OAuthEndpoints( authorize_url=os.environ.get("HONCHO_OAUTH_AUTHORIZE_URL", f"{dashboard}/authorize"), token_url=os.environ.get("HONCHO_OAUTH_TOKEN_URL", default_token), client_id=os.environ.get("HONCHO_OAUTH_CLIENT_ID", _DEFAULT_CLIENT_ID), scope=os.environ.get("HONCHO_OAUTH_SCOPE", "write"), ) @dataclass class _Pending: verifier: str redirect_uri: str created_at: float _pending: dict[str, _Pending] = {} _pending_lock = threading.Lock() def _pkce() -> tuple[str, str]: """Return (verifier, S256 challenge) for an authorization-code request.""" verifier = secrets.token_urlsafe(64) challenge = ( base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()) .rstrip(b"=") .decode() ) return verifier, challenge def _prune_pending(now: float) -> None: expired = [s for s, p in _pending.items() if now - p.created_at > _PENDING_TTL_SECONDS] for state in expired: _pending.pop(state, None) def begin_authorization( endpoints: OAuthEndpoints, redirect_uri: str = LOOPBACK_REDIRECT_URI, *, source: str | None = None, config_path: str | None = None, now: float | None = None, ) -> tuple[str, str]: """Start an authorization: return ``(authorize_url, state)`` and stash PKCE. ``source`` tags the authorize link with the initiating surface (``hermes-desktop`` / ``hermes-cli``) so the consent side can attribute connects and vary behavior per surface. ``config_path`` is a home-relative *display* string for the consent screen (never the absolute path); callers pass the actual write path separately to ``complete_authorization``. """ now = time.time() if now is None else now verifier, challenge = _pkce() state = secrets.token_urlsafe(32) with _pending_lock: _prune_pending(now) _pending[state] = _Pending(verifier=verifier, redirect_uri=redirect_uri, created_at=now) params = { "client_id": endpoints.client_id, "redirect_uri": redirect_uri, "scope": endpoints.scope, "code_challenge": challenge, "code_challenge_method": "S256", "response_type": "code", "state": state, } if source: params["source"] = source if config_path: params["config_path"] = config_path return f"{endpoints.authorize_url}?{urlencode(params)}", state def complete_authorization( endpoints: OAuthEndpoints, code: str, state: str, *, config_path: Path | None = None, host: str | None = None, apply_config: bool = True, now: float | None = None, ) -> oauth.OAuthCredential: """Exchange ``code`` for a grant and persist it. Raises on bad state/exchange. ``apply_config=False`` stores the tokens only, skipping the grant's config block — the CLI path, where settings stay wizard-owned. """ with _pending_lock: pending = _pending.pop(state, None) if pending is None: raise ValueError("unknown or expired authorization state") grant = oauth._http_post_form( endpoints.token_url, { "grant_type": "authorization_code", "client_id": endpoints.client_id, "code": code, "redirect_uri": pending.redirect_uri, "code_verifier": pending.verifier, }, oauth._REFRESH_TIMEOUT_SECONDS, ) path = config_path or resolve_config_path() target_host = host or resolve_active_host() cred = oauth.install_grant( path, target_host, grant, client_id=endpoints.client_id, token_endpoint=endpoints.token_url, apply_config=apply_config, now=now, ) # Drop the singleton so the next acquisition builds with the new token. from plugins.memory.honcho.client import reset_honcho_client reset_honcho_client() logger.info("Honcho OAuth grant installed for host %s", target_host) return cred _CALLBACK_HTML = ( b"" b"