From 0007a4c2f98eea76b11decf16063016a0e7ffb83 Mon Sep 17 00:00:00 2001 From: nyx573 Date: Thu, 3 Sep 2026 22:14:40 -0500 Subject: [PATCH] feat(auth): OpenRouter OAuth PKCE login via `hermes auth add openrouter --type oauth` Browser login against openrouter.ai/auth (S256 PKCE, bind-first OS-assigned loopback port, POST /api/v1/auth/keys code exchange) that stores the minted key as a plain api_key pool entry with source manual:openrouter_pkce, so it rotates and resolves exactly like a pasted key. Salvaged from #102639 (nyx573) onto the facade+siblings layout: the flow lives in the new auth_openrouter sibling and rides the shared loopback helpers instead of appending to the auth.py facade; auth_commands gains a table row rather than a provider branch. OpenRouter echoes no `state`, so the CSRF nonce rides in the callback path (a redirect that guesses the port but not the nonce is a 404 and never reaches the exchange); remote/SSH sessions use OpenRouter's documented headless paste-the-code mode instead of an unreachable loopback listener. OpenRouter keeps its API-key default when --type is omitted so the documented `--api-key` form is unchanged. --- hermes_cli/auth.py | 3 +- hermes_cli/auth_commands.py | 20 ++++++- hermes_cli/auth_constants.py | 6 ++ hermes_cli/auth_openrouter.py | 110 ++++++++++++++++++++++++++++++++++ 4 files changed, 135 insertions(+), 4 deletions(-) create mode 100644 hermes_cli/auth_openrouter.py diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py index c6fb6a9c7f..54683f4974 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -6,7 +6,7 @@ only I/O primitives (cross-process flock, atomic 0o600 writes). - ``resolve_provider()`` picks the active provider via the documented priority chain. - ``OAUTH_PROVIDER_FLOWS`` maps each OAuth provider to its resolver/status builder; the flows live in - ``auth_nous``/``auth_codex``/``auth_xai``/``auth_qwen``/``auth_minimax``/``auth_spotify`` and are + ``auth_nous``/``auth_codex``/``auth_xai``/``auth_qwen``/``auth_minimax``/``auth_spotify``/``auth_openrouter`` and are re-imported here so ``hermes_cli.auth.`` stays the public/patchable surface.""" from __future__ import annotations @@ -85,6 +85,7 @@ from hermes_cli.auth_codex import ( # noqa: F401 re-exported from hermes_cli.auth_spotify import ( # noqa: F401 re-exported _refresh_spotify_oauth_state, get_spotify_auth_status, login_spotify_command, resolve_spotify_runtime_credentials) +from hermes_cli.auth_openrouter import _openrouter_pkce_login # noqa: F401 re-exported from hermes_cli.auth_qwen import ( # noqa: F401 re-exported _qwen_access_token_is_expiring, _qwen_cli_auth_path, _read_qwen_cli_tokens, _refresh_qwen_cli_tokens, _save_qwen_cli_tokens, get_qwen_auth_status, diff --git a/hermes_cli/auth_commands.py b/hermes_cli/auth_commands.py index 78c3303707..93833f4841 100644 --- a/hermes_cli/auth_commands.py +++ b/hermes_cli/auth_commands.py @@ -24,7 +24,10 @@ from hermes_cli.secret_prompt import masked_secret_prompt # Providers that support OAuth login in addition to API keys. -_OAUTH_CAPABLE_PROVIDERS = {"anthropic", "nous", "openai-codex", "xai-oauth", "qwen-oauth", "minimax-oauth"} +_OAUTH_CAPABLE_PROVIDERS = {"anthropic", "nous", "openai-codex", "xai-oauth", "qwen-oauth", "minimax-oauth", "openrouter"} +# ...and default to it when ``--type`` is omitted. OpenRouter stays API-key-first: the documented +# ``hermes auth add openrouter --api-key sk-or-...`` must keep working with no ``--type``. +_OAUTH_DEFAULT_PROVIDERS = _OAUTH_CAPABLE_PROVIDERS - {"openrouter"} def _get_custom_provider_entries() -> list[dict]: @@ -203,6 +206,9 @@ class _OAuthAddSpec: source: str fields: Callable[[dict, str], dict] activate_first: bool = False + # OpenRouter's PKCE exchange mints a plain API key (no refresh pair), so its pool entry is an + # ``api_key`` row that happens to come from a browser login. + auth_type: str = AUTH_TYPE_OAUTH _OAUTH_ADD_SPECS: dict[str, _OAuthAddSpec] = { @@ -247,6 +253,14 @@ _OAUTH_ADD_SPECS: dict[str, _OAuthAddSpec] = { source=f"{SOURCE_MANUAL}:minimax_oauth", fields=lambda creds, provider: { "refresh_token": creds.get("refresh_token"), "base_url": creds.get("inference_base_url")}), + "openrouter": _OAuthAddSpec( + login=lambda args: auth_mod._openrouter_pkce_login( + open_browser=not getattr(args, "no_browser", False), + timeout_seconds=float(getattr(args, "timeout", None) or 300.0)), + token=lambda creds: creds["api_key"], + source=f"{SOURCE_MANUAL}:openrouter_pkce", + fields=lambda creds, provider: {"base_url": _provider_base_url(provider)}, + auth_type=AUTH_TYPE_API_KEY), } @@ -343,7 +357,7 @@ def auth_add_command(args) -> None: if requested_type == "api-key": requested_type = AUTH_TYPE_API_KEY elif not requested_type: - oauth_default = provider in _OAUTH_CAPABLE_PROVIDERS and not is_custom + oauth_default = provider in _OAUTH_DEFAULT_PROVIDERS and not is_custom requested_type = AUTH_TYPE_OAUTH if oauth_default else AUTH_TYPE_API_KEY pool = load_pool(provider) @@ -376,7 +390,7 @@ def _add_credential(args, provider: str, pool, requested_type: str) -> PooledCre # singleton save path (which collapsed every added account into the latest login). # ``manual:*`` entries refresh from their own token pair, so they need no singleton shadow. entry = PooledCredential( - provider=provider, id=uuid.uuid4().hex[:6], label=label, auth_type=AUTH_TYPE_OAUTH, priority=0, + provider=provider, id=uuid.uuid4().hex[:6], label=label, auth_type=spec.auth_type, priority=0, source=spec.source, access_token=token, **spec.fields(creds, provider)) first_credential = not pool.entries() entry = pool.add_entry(entry) diff --git a/hermes_cli/auth_constants.py b/hermes_cli/auth_constants.py index 73ce99b961..740940d2ae 100644 --- a/hermes_cli/auth_constants.py +++ b/hermes_cli/auth_constants.py @@ -111,6 +111,11 @@ DEFAULT_SPOTIFY_REDIRECT_URI = "http://127.0.0.1:43827/spotify/callback" SPOTIFY_DOCS_URL = "https://hermes-agent.nousresearch.com/docs/user-guide/features/spotify" SPOTIFY_DASHBOARD_URL = "https://developer.spotify.com/dashboard" SPOTIFY_ACCESS_TOKEN_REFRESH_SKEW_SECONDS = 120 +# OpenRouter PKCE (https://openrouter.ai/docs/guides/overview/auth/oauth): the "token" endpoint +# mints a plain user-controlled API key; there is no refresh token. +OPENROUTER_AUTH_URL = "https://openrouter.ai/auth" +OPENROUTER_AUTH_KEYS_URL = "https://openrouter.ai/api/v1/auth/keys" +OPENROUTER_OAUTH_DOCS_URL = "https://openrouter.ai/docs/guides/overview/auth/oauth" OAUTH_OVER_SSH_DOCS_URL = "https://hermes-agent.nousresearch.com/docs/guides/oauth-over-ssh" DEFAULT_SPOTIFY_SCOPE = " ".join(( @@ -156,6 +161,7 @@ _codex_err = _provider_error_factory("openai-codex") _spotify_err = _provider_error_factory("spotify") _qwen_err = _provider_error_factory("qwen-oauth") _minimax_err = _provider_error_factory("minimax-oauth") +_openrouter_err = _provider_error_factory("openrouter") def _decode_jwt_claims(token: Any) -> Dict[str, Any]: diff --git a/hermes_cli/auth_openrouter.py b/hermes_cli/auth_openrouter.py new file mode 100644 index 0000000000..942ae61017 --- /dev/null +++ b/hermes_cli/auth_openrouter.py @@ -0,0 +1,110 @@ +"""OpenRouter OAuth PKCE login (``hermes auth add openrouter --type oauth``). + +Contract: https://openrouter.ai/docs/guides/overview/auth/oauth. The browser is sent to +``/auth?callback_url=...&code_challenge=...&code_challenge_method=S256``; the redirect carries +``?code=``; ``POST /api/v1/auth/keys`` swaps ``{code, code_verifier, code_challenge_method}`` for +``{"key": "sk-or-v1-..."}`` — a plain user-controlled API key, no refresh token. OpenRouter echoes no +``state`` parameter, so the CSRF nonce rides in the loopback callback PATH: a redirect to any other +path is a 404 and never reaches the exchange. +""" + +from __future__ import annotations + +import secrets +import webbrowser +from typing import Any, Dict +from urllib.parse import urlencode + +from hermes_cli.auth_constants import ( + OPENROUTER_AUTH_KEYS_URL, OPENROUTER_AUTH_URL, OPENROUTER_OAUTH_DOCS_URL, _openrouter_err, httpx) +from hermes_cli.auth_device_flow import ( + _bind_loopback_callback_server, _can_open_graphical_browser, _is_remote_session, + _make_loopback_callback_handler, _pkce_code_challenge, _pkce_code_verifier, _serve_loopback_callback) + +_ERROR_BODY_LIMIT = 2048 + + +def _openrouter_exchange_code(code: str, code_verifier: str, *, timeout_seconds: float = 20.0) -> str: + """Exchange the authorization code for an API key; the key never enters a log or error message.""" + try: + response = httpx.post( + OPENROUTER_AUTH_KEYS_URL, json={ + "code": code, "code_verifier": code_verifier, "code_challenge_method": "S256"}, + headers={"Content-Type": "application/json"}, timeout=timeout_seconds) + except Exception as exc: + raise _openrouter_err(f"OpenRouter code exchange failed: {exc}", "openrouter_token_exchange_failed") from exc + + if response.status_code == 403: + raise _openrouter_err( + "OpenRouter rejected the authorization code (invalid, already used, or older than 10 minutes). " + "Run the login again.", "openrouter_token_exchange_denied", relogin=True) + if response.status_code >= 400: + detail = response.text.strip()[:_ERROR_BODY_LIMIT] + raise _openrouter_err( + f"OpenRouter code exchange failed (HTTP {response.status_code})." + (f" Response: {detail}" if detail else ""), + "openrouter_token_exchange_failed") + try: + payload = response.json() + except ValueError as exc: + raise _openrouter_err( + "OpenRouter code exchange returned a non-JSON body.", "openrouter_token_exchange_invalid") from exc + key = str(payload.get("key") or "").strip() if isinstance(payload, dict) else "" + if not key: + raise _openrouter_err( + "OpenRouter code exchange response did not include a 'key'.", "openrouter_token_exchange_invalid") + return key + + +def _openrouter_headless_code(auth_url: str) -> str: + """Remote/SSH: OpenRouter shows the code on screen when ``callback_url`` is omitted; the user pastes it.""" + from hermes_cli.secret_prompt import masked_secret_prompt + print( + "Remote session detected — using OpenRouter's headless flow.\n" + f"Open this URL in a browser on any machine, authorize, then paste the code shown:\n {auth_url}\n") + code = masked_secret_prompt("Authorization code: ").strip() + if not code: + raise _openrouter_err("No authorization code entered.", "openrouter_auth_no_code") + return code + + +def _openrouter_loopback_code(auth_url_params: Dict[str, str], *, open_browser: bool, timeout_seconds: float) -> str: + nonce = secrets.token_urlsafe(16) + path = f"/callback/{nonce}" + handler_cls, result = _make_loopback_callback_handler(path, display_name="OpenRouter") + server = _bind_loopback_callback_server( + "127.0.0.1", 0, handler_cls, err=_openrouter_err, bind_failed_code="openrouter_callback_bind_failed") + redirect_uri = f"http://127.0.0.1:{server.server_address[1]}{path}" + auth_url = f"{OPENROUTER_AUTH_URL}?{urlencode({'callback_url': redirect_uri, **auth_url_params})}" + + print(f"Open this URL to authorize Hermes with OpenRouter:\n {auth_url}\n\nDocs: {OPENROUTER_OAUTH_DOCS_URL}") + if open_browser and _can_open_graphical_browser(): + try: + opened = webbrowser.open(auth_url) + except Exception: + opened = False + print("Browser opened for OpenRouter authorization." if opened + else "Could not open the browser automatically; use the URL above.") + print("Waiting for the OpenRouter callback...") + callback = _serve_loopback_callback( + server, result, timeout_seconds=timeout_seconds, err=_openrouter_err, + timeout_code="openrouter_callback_timeout") + if callback.get("error"): + raise _openrouter_err( + f"OpenRouter authorization failed: {callback.get('error_description') or callback['error']}", + "openrouter_auth_denied") + code = str(callback.get("code") or "").strip() + if not code: + raise _openrouter_err("OpenRouter callback did not carry an authorization code.", "openrouter_auth_no_code") + return code + + +def _openrouter_pkce_login(*, open_browser: bool = True, timeout_seconds: float = 300.0) -> Dict[str, Any]: + """Run the PKCE flow and return ``{"api_key": ...}`` for the credential-pool add path.""" + code_verifier = _pkce_code_verifier() + params = {"code_challenge": _pkce_code_challenge(code_verifier), "code_challenge_method": "S256"} + if _is_remote_session(): + code = _openrouter_headless_code(f"{OPENROUTER_AUTH_URL}?{urlencode({**params, 'key_label': 'hermes-agent'})}") + else: + code = _openrouter_loopback_code(params, open_browser=open_browser, timeout_seconds=timeout_seconds) + print("Exchanging the authorization code for an OpenRouter API key...") + return {"api_key": _openrouter_exchange_code(code, code_verifier)}