diff --git a/contributors/emails/aakash.j.abraham@gmail.com b/contributors/emails/aakash.j.abraham@gmail.com new file mode 100644 index 0000000000..8be36cb6cc --- /dev/null +++ b/contributors/emails/aakash.j.abraham@gmail.com @@ -0,0 +1,2 @@ +nyx573 +# PR #102639 salvage (OpenRouter OAuth PKCE) diff --git a/evals/openrouter_pkce_ab/harness.py b/evals/openrouter_pkce_ab/harness.py new file mode 100644 index 0000000000..9247caf2a7 --- /dev/null +++ b/evals/openrouter_pkce_ab/harness.py @@ -0,0 +1,194 @@ +#!/usr/bin/env python3 +"""Local A/B harness for `hermes auth add openrouter --type oauth` against a FAKE OpenRouter. + +Runs the REAL entry point (``hermes_cli.auth_commands.auth_add_command``) with a temp HERMES_HOME. +Only the network authority is replaced: ``webbrowser.open`` is swapped for a scripted "browser" +that follows the auth URL's ``callback_url`` the way openrouter.ai would (redirecting the loopback +listener with ``?code=``), and ``OPENROUTER_AUTH_KEYS_URL`` points at a local fake code-exchange +server that enforces OpenRouter's documented contract (S256 verifier check, single-use code, 403). + +Scenarios (each prints PASS/FAIL, exit code = number of failures): + legit full flow → pool entry written, auth_type=api_key, source=manual:openrouter_pkce + wrong_state browser redirects to a different callback path (forged nonce) → 404, no exchange + replayed_code code already consumed at the fake server → 403 → AuthError, nothing persisted + malformed exchange returns JSON without "key" → AuthError, nothing persisted + api_key_path `hermes auth add openrouter --api-key` still works with no --type (regression) + +Usage: HERMES_PYTHON= python3 evals/openrouter_pkce_ab/harness.py [--json OUT] +Run against origin/main to see the BEFORE state (every oauth scenario fails with SystemExit +"not implemented"), then against the salvage branch for AFTER. +""" +from __future__ import annotations + +import argparse +import hashlib +import base64 +import json +import os +import sys +import tempfile +import threading +import urllib.error +import urllib.request +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from types import SimpleNamespace +from urllib.parse import parse_qs, urlparse + +REPO = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +sys.path.insert(0, REPO) + + +class FakeOpenRouter(ThreadingHTTPServer): + """Fake ``POST /api/v1/auth/keys`` implementing the published contract.""" + + def __init__(self): + super().__init__(("127.0.0.1", 0), _Handler) + self.issued: dict[str, str] = {} # code -> code_challenge + self.consumed: set[str] = set() + self.mode = "ok" # ok | malformed + self.exchanges: list[dict] = [] + + @property + def url(self): + return f"http://127.0.0.1:{self.server_address[1]}/api/v1/auth/keys" + + +class _Handler(BaseHTTPRequestHandler): + def log_message(self, *a): # noqa: A003 + return + + def do_POST(self): # noqa: N802 + srv: FakeOpenRouter = self.server # type: ignore[assignment] + body = json.loads(self.rfile.read(int(self.headers.get("Content-Length", "0")) or 0) or b"{}") + srv.exchanges.append(body) + code, verifier, method = body.get("code"), body.get("code_verifier", ""), body.get("code_challenge_method") + if method not in ("S256", "plain", None): + return self._json(400, {"error": {"code": 400, "message": "Invalid code_challenge_method"}}) + if code not in srv.issued or code in srv.consumed: + return self._json(403, {"error": {"code": 403, "message": "Invalid code or code_verifier"}}) + expected = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).decode().rstrip("=") + if expected != srv.issued[code]: + return self._json(403, {"error": {"code": 403, "message": "Invalid code or code_verifier"}}) + srv.consumed.add(code) + if srv.mode == "malformed": + return self._json(200, {"user_id": "user_x"}) + return self._json(200, {"key": "sk-or-v1-" + hashlib.sha256(code.encode()).hexdigest(), "user_id": "user_x"}) + + def _json(self, status, payload): + raw = json.dumps(payload).encode() + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(raw))) + self.end_headers() + self.wfile.write(raw) + + +def scripted_browser(fake: FakeOpenRouter, *, tamper_path=False, pre_consume=False): + """Return a ``webbrowser.open`` stand-in that behaves like openrouter.ai/auth after user consent.""" + def _open(url): + q = parse_qs(urlparse(url).query) + callback = q["callback_url"][0] + code = "auth_code_" + hashlib.sha1(callback.encode()).hexdigest()[:12] + fake.issued[code] = q["code_challenge"][0] + if pre_consume: + fake.consumed.add(code) + if tamper_path: # attacker guesses the port but not the nonce path + p = urlparse(callback) + callback = f"{p.scheme}://{p.netloc}/callback/forged-nonce" + def _redirect(): + try: + with urllib.request.urlopen(f"{callback}?code={code}", timeout=5) as r: + _open.last_status = r.status + except urllib.error.HTTPError as e: + _open.last_status = e.code + except Exception as e: # listener already closed + _open.last_status = repr(e) + threading.Thread(target=_redirect, daemon=True).start() + return True + _open.last_status = None + return _open + + +def run(scenario: str, fake: FakeOpenRouter, home: str) -> dict: + os.environ["HERMES_HOME"] = home + for k in ("OPENROUTER_API_KEY", "OPENAI_API_KEY", "SSH_CLIENT", "SSH_TTY"): + os.environ.pop(k, None) + for m in [m for m in sys.modules if m.startswith(("hermes_cli", "agent", "hermes_constants"))]: + del sys.modules[m] + fake.mode = "malformed" if scenario == "malformed" else "ok" + browser = scripted_browser(fake, tamper_path=(scenario == "wrong_state"), pre_consume=(scenario == "replayed_code")) + outcome = {"scenario": scenario, "exchanges_before": len(fake.exchanges)} + try: + from hermes_cli.auth_commands import auth_add_command + except Exception as e: # e.g. the original PR branch's auth.py fails at import time + outcome.update(result=f"IMPORT FAILURE {type(e).__name__}: {e}", browser_redirect_status=None, + exchange_calls=0, pool_entries=[]) + outcome.pop("exchanges_before") + return outcome + try: # BEFORE (origin/main) has no auth_openrouter sibling; the flow itself must then fail. + import hermes_cli.auth_openrouter as orm + import hermes_cli.auth_device_flow as dfl + orm.OPENROUTER_AUTH_KEYS_URL = fake.url + dfl._can_open_graphical_browser = lambda: True + orm.webbrowser.open = browser + except ImportError: + pass + args = SimpleNamespace(provider="openrouter", auth_type="oauth", label="pkce-test", api_key=None, + no_browser=False, timeout=6) + if scenario == "api_key_path": + args = SimpleNamespace(provider="openrouter", auth_type=None, label="plain", api_key="sk-or-v1-manual") + try: + auth_add_command(args) + outcome["result"] = "ok" + except SystemExit as e: + outcome["result"] = f"SystemExit: {e}" + except Exception as e: # AuthError etc. + outcome["result"] = f"{type(e).__name__}({getattr(e, 'code', '')}): {e}" + outcome["browser_redirect_status"] = browser.last_status + outcome["exchange_calls"] = len(fake.exchanges) - outcome.pop("exchanges_before") + auth_json = os.path.join(home, "auth.json") + entries = [] + if os.path.exists(auth_json): + entries = json.load(open(auth_json, encoding="utf-8")).get("credential_pool", {}).get("openrouter", []) + outcome["pool_entries"] = [{k: e.get(k) for k in ("auth_type", "source", "label", "base_url")} + | {"key_prefix": str(e.get("access_token", ""))[:9]} for e in entries] + return outcome + + +EXPECT = { + "legit": lambda o: o["result"] == "ok" and o["exchange_calls"] == 1 and any( + e["auth_type"] == "api_key" and e["source"] == "manual:openrouter_pkce" and e["key_prefix"] == "sk-or-v1-" + for e in o["pool_entries"]), + "wrong_state": lambda o: o["browser_redirect_status"] == 404 and o["exchange_calls"] == 0 + and "openrouter_callback_timeout" in o["result"] and not o["pool_entries"], + "replayed_code": lambda o: "openrouter_token_exchange_denied" in o["result"] and not o["pool_entries"], + "malformed": lambda o: "openrouter_token_exchange_invalid" in o["result"] and not o["pool_entries"], + "api_key_path": lambda o: o["result"] == "ok" and o["exchange_calls"] == 0 and any( + e["auth_type"] == "api_key" and e["source"] == "manual" for e in o["pool_entries"]), +} + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--json") + ap.add_argument("--only", nargs="*") + a = ap.parse_args() + fake = FakeOpenRouter() + threading.Thread(target=fake.serve_forever, daemon=True).start() + results, fails = [], 0 + for scenario in a.only or EXPECT: + home = tempfile.mkdtemp(prefix=f"or-pkce-{scenario}-") + o = run(scenario, fake, home) + o["pass"] = bool(EXPECT[scenario](o)) + fails += not o["pass"] + print(("PASS" if o["pass"] else "FAIL"), json.dumps(o)) + results.append(o) + fake.shutdown() + if a.json: + with open(a.json, "w", encoding="utf-8") as f: + json.dump(results, f, indent=1) + sys.exit(fails) + + +if __name__ == "__main__": + main() diff --git a/tests/hermes_cli/test_auth_commands.py b/tests/hermes_cli/test_auth_commands.py index af8e539771..aecd6a5635 100644 --- a/tests/hermes_cli/test_auth_commands.py +++ b/tests/hermes_cli/test_auth_commands.py @@ -1166,3 +1166,84 @@ def test_qwen_oauth_login_marks_active_through_moved_owner(monkeypatch): assert auth_commands._qwen_oauth_login(None) is creds assert marked == [creds] + + +def test_auth_add_openrouter_oauth_persists_pkce_key_without_touching_api_key_default(tmp_path, monkeypatch): + """`hermes auth add openrouter --type oauth` stores the PKCE-minted key as an ``api_key`` pool row + (OpenRouter returns a plain key, no refresh pair) that ``resolve_provider("auto")`` picks up with no + env var — same as a pasted key; the bare `--api-key` path keeps its API-key default.""" + monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes")) + monkeypatch.delenv("OPENROUTER_API_KEY", raising=False) + monkeypatch.delenv("OPENAI_API_KEY", raising=False) + _write_auth_store(tmp_path, {"version": 1, "providers": {}}) + monkeypatch.setattr("hermes_cli.auth._openrouter_pkce_login", lambda **kw: {"api_key": "sk-or-v1-from-pkce"}) + + from hermes_cli.auth import resolve_provider + from hermes_cli.auth_commands import auth_add_command + + class _Oauth: + provider = "openrouter" + auth_type = "oauth" + api_key = None + label = "browser-login" + timeout = None + no_browser = True + + class _Plain: + provider = "openrouter" + auth_type = None # no --type: must NOT fall into the OAuth flow + api_key = "sk-or-v1-pasted" + label = "pasted" + + auth_add_command(_Oauth()) + # No env var, no config.yaml provider: the pooled PKCE key alone must make openrouter resolvable. + assert resolve_provider("auto") == "openrouter" + auth_add_command(_Plain()) + + payload = json.loads((tmp_path / "hermes" / "auth.json").read_text()) + by_source = {e["source"]: e for e in payload["credential_pool"]["openrouter"]} + assert by_source["manual:openrouter_pkce"]["auth_type"] == "api_key" + assert by_source["manual:openrouter_pkce"]["access_token"] == "sk-or-v1-from-pkce" + assert by_source["manual:openrouter_pkce"]["base_url"] == "https://openrouter.ai/api/v1" + assert by_source["manual"]["access_token"] == "sk-or-v1-pasted" + + +def test_openrouter_loopback_callback_binds_nonce_path_and_rejects_forged_redirect(monkeypatch): + """The CSRF nonce lives in the callback PATH (OpenRouter echoes no ``state``): a redirect that + knows the port but not the nonce is a 404 and never yields a code; the genuine path does.""" + import threading + import urllib.error + import urllib.parse + import urllib.request + + import hermes_cli.auth_openrouter as orm + + seen: dict = {} + + def _browser(url): + callback = urllib.parse.parse_qs(urllib.parse.urlparse(url).query)["callback_url"][0] + seen["callback"] = callback + forged = callback.rsplit("/", 1)[0] + "/forged-nonce?code=evil" + + def _redirects(): + try: + urllib.request.urlopen(forged, timeout=5) + except urllib.error.HTTPError as exc: + seen["forged_status"] = exc.code + with urllib.request.urlopen(f"{callback}?code=good-code", timeout=5) as resp: + seen["genuine_status"] = resp.status + + threading.Thread(target=_redirects, daemon=True).start() + return True + + monkeypatch.setattr(orm, "_can_open_graphical_browser", lambda: True) + monkeypatch.setattr(orm.webbrowser, "open", _browser) + + code = orm._openrouter_loopback_code( + {"code_challenge": "c", "code_challenge_method": "S256"}, open_browser=True, timeout_seconds=10) + + parsed = urllib.parse.urlparse(seen["callback"]) + assert parsed.hostname == "127.0.0.1" and parsed.path.startswith("/callback/") and len(parsed.path) > 20 + assert seen["forged_status"] == 404 + assert seen["genuine_status"] == 200 + assert code == "good-code" diff --git a/website/docs/guides/oauth-over-ssh.md b/website/docs/guides/oauth-over-ssh.md index 258f1d1324..83ce2048ee 100644 --- a/website/docs/guides/oauth-over-ssh.md +++ b/website/docs/guides/oauth-over-ssh.md @@ -39,6 +39,7 @@ Hermes prints the exact port it bound to on the `Waiting for callback on ...` li | `anthropic` (Claude Pro/Max) | n/a | No — paste-the-code flow | | `openai-codex` (ChatGPT Plus/Pro) | n/a | No — device code flow | | `minimax`, `nous-portal` | n/a | No — device code flow | +| `openrouter` (`hermes auth add openrouter --type oauth`) | OS-assigned, local only | No — over SSH Hermes switches to OpenRouter's headless flow and asks you to paste the code shown in the browser | If your provider isn't in the table, you don't need a tunnel. diff --git a/website/docs/integrations/providers.md b/website/docs/integrations/providers.md index 286abcee2a..759aed3dac 100644 --- a/website/docs/integrations/providers.md +++ b/website/docs/integrations/providers.md @@ -19,7 +19,7 @@ You need at least one way to connect to an LLM. Use `hermes model` to switch pro | **GitHub Copilot** | `hermes model` (OAuth device code flow, `COPILOT_GITHUB_TOKEN`, `GH_TOKEN`, or `gh auth token`) | | **GitHub Copilot ACP** | `hermes model` (spawns local `copilot --acp --stdio`) | | **Anthropic** | `hermes model` (Claude Max + extra usage credits via OAuth; also supports Anthropic API key or manual setup-token — see note below) | -| **OpenRouter** | `OPENROUTER_API_KEY` in `~/.hermes/.env` | +| **OpenRouter** | `OPENROUTER_API_KEY` in `~/.hermes/.env`, or `hermes auth add openrouter --type oauth` (browser login via OpenRouter's PKCE flow; stores a key in the credential pool) | | **Ramp Router** | `RAMP_ROUTER_API_KEY` in `~/.hermes/.env` (provider: `router`; aliases: `ramp-router`, `ramp`, `router.com`; Responses-native gateway, live account-scoped catalog) | | **Fireworks AI** | `FIREWORKS_API_KEY` in `~/.hermes/.env` (provider: `fireworks`; aliases: `fireworks-ai`, `fw`) | | **NovitaAI** | `NOVITA_API_KEY` in `~/.hermes/.env` (provider: `novita`, 200+ models, Model API, Agent Sandbox, GPU Cloud) | diff --git a/website/docs/reference/cli-commands.md b/website/docs/reference/cli-commands.md index a76ba93fe3..dbef435e63 100644 --- a/website/docs/reference/cli-commands.md +++ b/website/docs/reference/cli-commands.md @@ -602,6 +602,7 @@ hermes auth # Interactive wizard hermes auth list # Show all pools hermes auth list openrouter # Show specific provider hermes auth add openrouter --api-key sk-or-v1-xxx # Add API key +hermes auth add openrouter --type oauth # Browser login (OpenRouter PKCE) mints a key for you hermes auth add anthropic --type oauth # Add OAuth credential hermes auth add openai-codex --type oauth --priority 0 # Add an account and try it first hermes auth remove openrouter 2 # Remove by index diff --git a/website/docs/user-guide/features/credential-pools.md b/website/docs/user-guide/features/credential-pools.md index fd3ce5e663..477fb3aef3 100644 --- a/website/docs/user-guide/features/credential-pools.md +++ b/website/docs/user-guide/features/credential-pools.md @@ -48,6 +48,9 @@ If you already have an API key set in `.env`, Hermes auto-discovers it as a 1-ke # Add a second OpenRouter key hermes auth add openrouter --api-key sk-or-v1-your-second-key +# ...or let a browser login mint one (OpenRouter OAuth PKCE; stored as a plain API key) +hermes auth add openrouter --type oauth + # Add a second Anthropic key hermes auth add anthropic --type api-key --api-key sk-ant-api03-your-second-key