"""Regression tests for the Anthropic OAuth PKCE flow. Guards against re-introducing the bug where the PKCE ``code_verifier`` was reused as the OAuth ``state`` parameter, leaking the verifier via the authorization URL (browser history, Referer headers, auth-server logs) and removing CSRF protection on the callback path. History: - PR #1775 first fixed this on ``run_hermes_oauth_login()``. - PR #2647 (b17e5c10) added ``run_hermes_oauth_login_pure()`` and silently copy-pasted the pre-#1775 vulnerable pattern. - PR #3107 removed the old function, leaving only the regressed copy. - PR #10699 (issue #10693) fixed the regression on the surviving function. """ from __future__ import annotations import json from typing import Any, Dict from urllib.parse import parse_qs, urlparse def _patch_oauth_flow( monkeypatch, *, callback_code: str, token_response: Dict[str, Any] | None = None, capture_token_request: Dict[str, Any] | None = None, capture_auth_url: Dict[str, str] | None = None, ) -> None: """Wire up monkeypatches that let ``run_hermes_oauth_login_pure()`` run end-to-end without touching a real browser, stdin, or HTTP endpoint. ``callback_code`` is the literal string the user would paste back into the terminal (``"#"`` format). ``capture_token_request`` and ``capture_auth_url`` are out-dict captures so the test can introspect what was sent to the auth URL and the token endpoint, respectively. """ import urllib.request if token_response is None: token_response = { "access_token": "sk-ant-test-access", "refresh_token": "sk-ant-test-refresh", "expires_in": 3600, } def fake_open(url): if capture_auth_url is not None: capture_auth_url["url"] = url return True monkeypatch.setattr("webbrowser.open", fake_open) # The flow now gates webbrowser.open() behind a graphical-browser check so # it never launches a console browser (w3m/lynx) inside the terminal. Tests # run headless, so force the GUI path to True — the URL capture relies on # webbrowser.open() being invoked. monkeypatch.setattr( "hermes_cli.auth._can_open_graphical_browser", lambda: True ) monkeypatch.setattr("builtins.input", lambda *_a, **_kw: callback_code) class _FakeResponse: def __init__(self, body: bytes) -> None: self._body = body def __enter__(self): return self def __exit__(self, *_exc): return False def read(self): return self._body def fake_urlopen(req, *_a, **_kw): if capture_token_request is not None: capture_token_request["url"] = req.full_url capture_token_request["data"] = json.loads(req.data.decode()) capture_token_request["headers"] = dict(req.headers) return _FakeResponse(json.dumps(token_response).encode()) monkeypatch.setattr(urllib.request, "urlopen", fake_urlopen) def test_authorization_url_state_is_not_pkce_verifier(monkeypatch, tmp_path): """The ``state`` parameter in the authorization URL must NOT equal the PKCE ``code_verifier``. Reusing the verifier as state leaks the verifier into browser history, Referer headers, and auth-server access logs — defeating RFC 7636. """ monkeypatch.setenv("HERMES_HOME", str(tmp_path)) captured_url: Dict[str, str] = {} captured_token: Dict[str, Any] = {} _patch_oauth_flow( monkeypatch, # state echoed back unchanged so the CSRF guard passes callback_code="auth-code-from-anthropic#PLACEHOLDER", capture_auth_url=captured_url, capture_token_request=captured_token, ) # Stub the callback parse: we need the state echoed back to match. To do # that without hardcoding the state value, override input() AFTER seeing # the auth URL. import builtins real_input_calls = {"count": 0} def fake_input(*_a, **_kw): real_input_calls["count"] += 1 # First (and only) call is the "Authorization code:" prompt. url = captured_url.get("url", "") qs = parse_qs(urlparse(url).query) state = qs.get("state", [""])[0] return f"auth-code-from-anthropic#{state}" monkeypatch.setattr(builtins, "input", fake_input) from agent.anthropic_adapter import run_hermes_oauth_login_pure result = run_hermes_oauth_login_pure() assert result is not None, "OAuth flow should succeed with matching state" url = captured_url["url"] qs = parse_qs(urlparse(url).query) assert "state" in qs and qs["state"][0], "authorization URL must include state" assert "code_challenge" in qs, "authorization URL must include code_challenge" state_in_url = qs["state"][0] verifier_sent = captured_token["data"]["code_verifier"] # The whole point: state and verifier must be independent values. assert state_in_url != verifier_sent, ( "PKCE code_verifier was reused as OAuth state — regression of #10693 / " "#1775. The verifier is supposed to be a secret known only to the " "client; placing it in the authorization URL leaks it via browser " "history, Referer headers, and auth-server logs." ) # And the verifier MUST NOT appear anywhere in the URL. assert verifier_sent not in url, ( "PKCE verifier leaked into authorization URL — regression of #10693" ) def test_login_token_exchange_uses_platform_claude_host(monkeypatch, tmp_path): """The login token exchange must hit ``platform.claude.com`` first. Anthropic migrated the OAuth token endpoint to ``platform.claude.com``; ``console.anthropic.com`` now 404s, so a hardcoded console host makes a fresh login impossible (issue #45250 / #49821). The refresh path already iterates the new host first — the login path must do the same. """ monkeypatch.setenv("HERMES_HOME", str(tmp_path)) captured_token: Dict[str, Any] = {} captured_url: Dict[str, str] = {} _patch_oauth_flow( monkeypatch, callback_code="placeholder", capture_token_request=captured_token, capture_auth_url=captured_url, ) import builtins def fake_input(*_a, **_kw): qs = parse_qs(urlparse(captured_url.get("url", "")).query) state = qs.get("state", [""])[0] return f"auth-code#{state}" monkeypatch.setattr(builtins, "input", fake_input) from agent.anthropic_adapter import run_hermes_oauth_login_pure result = run_hermes_oauth_login_pure() assert result is not None, "login should succeed against the live host" assert captured_token["url"] == "https://platform.claude.com/v1/oauth/token", ( "login token exchange must target platform.claude.com first, not the " "dead console.anthropic.com host (regression of #45250 / #49821)" ) def test_login_token_exchange_falls_back_to_console_host(monkeypatch, tmp_path): """If ``platform.claude.com`` is unreachable, the login path must fall back to the legacy ``console.anthropic.com`` host — mirroring the refresh path's fallback list — rather than failing outright. """ import urllib.request monkeypatch.setenv("HERMES_HOME", str(tmp_path)) captured_url: Dict[str, str] = {} _patch_oauth_flow( monkeypatch, callback_code="placeholder", capture_auth_url=captured_url, ) attempts: list[str] = [] class _FakeResponse: def __init__(self, body: bytes) -> None: self._body = body def __enter__(self): return self def __exit__(self, *_exc): return False def read(self): return self._body def fake_urlopen(req, *_a, **_kw): attempts.append(req.full_url) if req.full_url.startswith("https://platform.claude.com"): raise RuntimeError("HTTP Error 404: Not Found") body = json.dumps( { "access_token": "sk-ant-test-access", "refresh_token": "sk-ant-test-refresh", "expires_in": 3600, } ).encode() return _FakeResponse(body) monkeypatch.setattr(urllib.request, "urlopen", fake_urlopen) import builtins def fake_input(*_a, **_kw): qs = parse_qs(urlparse(captured_url.get("url", "")).query) state = qs.get("state", [""])[0] return f"auth-code#{state}" monkeypatch.setattr(builtins, "input", fake_input) from agent.anthropic_adapter import run_hermes_oauth_login_pure result = run_hermes_oauth_login_pure() assert result is not None, "login should succeed via the console fallback" assert attempts == [ "https://platform.claude.com/v1/oauth/token", "https://console.anthropic.com/v1/oauth/token", ], "login must try platform.claude.com first, then fall back to console" def test_callback_state_mismatch_aborts(monkeypatch, tmp_path, caplog): """If the state returned in the callback does not match the one we sent in the authorization URL, the flow must abort before exchanging the code. Without this check, an attacker who tricks the user into pasting a crafted ``#`` string can complete the token exchange — the CSRF protection that ``state`` is supposed to provide (RFC 6749 §10.12) would be absent. """ monkeypatch.setenv("HERMES_HOME", str(tmp_path)) captured_token: Dict[str, Any] = {} _patch_oauth_flow( monkeypatch, callback_code="attacker-code#attacker-state-does-not-match", capture_token_request=captured_token, ) from agent.anthropic_adapter import run_hermes_oauth_login_pure result = run_hermes_oauth_login_pure() assert result is None, "mismatched state must abort the flow" assert "url" not in captured_token, ( "token exchange must NOT happen when state mismatches" )