test(auth): OpenRouter PKCE invariants, fake-authority A/B harness, docs, contributor map

Two invariant tests (red on main): the PKCE key lands as an api_key pool row that
resolve_provider("auto") picks up while the bare --api-key path keeps its default, and a forged
callback path is a 404 while the genuine nonce path yields the code. evals/openrouter_pkce_ab
drives the real auth_add_command against a local fake /api/v1/auth/keys (verifier check,
single-use codes) for legit / wrong-state / replayed-code / malformed-response / api-key-path.
This commit is contained in:
Teknium
2026-09-06 04:13:45 -07:00
parent 0007a4c2f9
commit 0c2e66ea8b
7 changed files with 283 additions and 1 deletions

View File

@@ -0,0 +1,2 @@
nyx573
# PR #102639 salvage (OpenRouter OAuth PKCE)

View File

@@ -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=<venv 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()

View File

@@ -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"

View File

@@ -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.

View File

@@ -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) |

View File

@@ -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

View File

@@ -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